@lessonkit/lxpack 0.9.2 → 1.0.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
@@ -6,7 +6,14 @@ import {
6
6
  import { validateId } from "@lessonkit/core";
7
7
 
8
8
  // src/spaPath.ts
9
- import { resolve, sep } from "path";
9
+ import { realpathSync } from "fs";
10
+ import { relative, resolve, sep, win32 } from "path";
11
+ function resolveComparablePath(p) {
12
+ if (/^[a-zA-Z]:[/\\]/.test(p)) {
13
+ return win32.resolve(p);
14
+ }
15
+ return resolve(p);
16
+ }
10
17
  function isSafeRelativeSpaPath(spaPath) {
11
18
  if (!spaPath.length || spaPath.includes("\0")) return false;
12
19
  if (spaPath.startsWith("/") || spaPath.startsWith("\\")) return false;
@@ -16,13 +23,43 @@ function isSafeRelativeSpaPath(spaPath) {
16
23
  return true;
17
24
  }
18
25
  function assertResolvedPathUnderRoot(root, target) {
19
- const rootResolved = resolve(root);
20
- const targetResolved = resolve(target);
26
+ const rootResolved = resolveComparablePath(root);
27
+ const targetResolved = resolveComparablePath(target);
21
28
  const prefix = rootResolved.endsWith(sep) ? rootResolved : rootResolved + sep;
22
- if (targetResolved !== rootResolved && !targetResolved.startsWith(prefix)) {
29
+ const win32Prefix = rootResolved.endsWith(win32.sep) ? rootResolved : rootResolved + win32.sep;
30
+ if (targetResolved !== rootResolved && !targetResolved.startsWith(prefix) && !targetResolved.startsWith(win32Prefix)) {
23
31
  throw new Error(`unsafe path escapes project root: ${target}`);
24
32
  }
25
33
  }
34
+ function assertRealPathUnderRoot(root, target) {
35
+ const rootResolved = resolveComparablePath(root);
36
+ const targetResolved = resolveComparablePath(target);
37
+ let rootReal;
38
+ try {
39
+ rootReal = realpathSync(rootResolved);
40
+ } catch {
41
+ rootReal = rootResolved;
42
+ }
43
+ let targetCheck;
44
+ try {
45
+ targetCheck = realpathSync(targetResolved);
46
+ } catch {
47
+ const rel = relative(rootResolved, targetResolved);
48
+ if (rel.startsWith("..") || rel.includes(`..${sep}`)) {
49
+ throw new Error(`unsafe path escapes project root: ${target}`);
50
+ }
51
+ targetCheck = resolve(rootReal, rel);
52
+ }
53
+ assertResolvedPathUnderRoot(rootReal, targetCheck);
54
+ }
55
+ function isResolvedPathUnderRoot(root, target) {
56
+ const rootResolved = resolveComparablePath(root);
57
+ const targetResolved = resolveComparablePath(target);
58
+ if (targetResolved === rootResolved) return true;
59
+ const prefix = rootResolved.endsWith(sep) ? rootResolved : rootResolved + sep;
60
+ const win32Prefix = rootResolved.endsWith(win32.sep) ? rootResolved : rootResolved + win32.sep;
61
+ return targetResolved.startsWith(prefix) || targetResolved.startsWith(win32Prefix);
62
+ }
26
63
 
27
64
  // src/theme.ts
28
65
  import { getPresetTheme, themeToCssVariables } from "@lessonkit/themes";
@@ -198,7 +235,7 @@ function validateDescriptor(input) {
198
235
  issues.push({ path: `${path}.answer`, message: "answer must match a choice" });
199
236
  }
200
237
  const passingScore = assessment.passingScore;
201
- if (passingScore !== void 0 && !(passingScore > 0)) {
238
+ if (passingScore !== void 0 && !(Number.isFinite(passingScore) && passingScore > 0)) {
202
239
  issues.push({
203
240
  path: `${path}.passingScore`,
204
241
  message: "passingScore must be greater than 0 (absolute point threshold)"
@@ -351,12 +388,12 @@ function descriptorToInterchange(descriptor) {
351
388
  }
352
389
 
353
390
  // src/writeProject.ts
354
- import { join, resolve as resolve4 } from "path";
391
+ import { join as join2, resolve as resolve4 } from "path";
355
392
  import { materializeLessonkitProject } from "@lxpack/validators";
356
393
 
357
394
  // src/spaDirs.ts
358
395
  import { access } from "fs/promises";
359
- import { resolve as resolve3 } from "path";
396
+ import { join, resolve as resolve3 } from "path";
360
397
  async function resolveSpaDirs(options) {
361
398
  const { descriptor, spaDistDir, lessonSpaDirs, projectRoot } = options;
362
399
  const spaLessons = resolveSpaLessons(descriptor);
@@ -364,13 +401,18 @@ async function resolveSpaDirs(options) {
364
401
  const spaDistRelative = spaDistDir ?? descriptor.spaDistDir ?? "dist";
365
402
  const srcDist = projectRoot ? resolve3(projectRoot, spaDistRelative) : resolve3(spaDistRelative);
366
403
  if (projectRoot) {
367
- assertResolvedPathUnderRoot(resolve3(projectRoot), srcDist);
404
+ assertRealPathUnderRoot(resolve3(projectRoot), srcDist);
368
405
  }
369
406
  try {
370
407
  await access(srcDist);
371
408
  } catch {
372
409
  throw new Error(`spaDistDir not found: ${srcDist}`);
373
410
  }
411
+ try {
412
+ await access(join(srcDist, "index.html"));
413
+ } catch {
414
+ throw new Error(`spaDistDir must contain index.html: ${join(srcDist, "index.html")}`);
415
+ }
374
416
  const lessonId = spaLessons[0]?.id ?? "main";
375
417
  return { [lessonId]: srcDist };
376
418
  }
@@ -381,7 +423,23 @@ async function resolveSpaDirs(options) {
381
423
  if (!src) {
382
424
  throw new Error(`lessonSpaDirs missing build output for lesson "${lesson.id}"`);
383
425
  }
384
- dirs[lesson.id] = resolve3(src);
426
+ const resolved = projectRoot ? resolve3(projectRoot, src) : resolve3(src);
427
+ if (projectRoot) {
428
+ assertRealPathUnderRoot(resolve3(projectRoot), resolved);
429
+ }
430
+ try {
431
+ await access(resolved);
432
+ } catch {
433
+ throw new Error(`lessonSpaDirs path not found for lesson "${lesson.id}": ${resolved}`);
434
+ }
435
+ try {
436
+ await access(join(resolved, "index.html"));
437
+ } catch {
438
+ throw new Error(
439
+ `lessonSpaDirs must contain index.html for lesson "${lesson.id}": ${join(resolved, "index.html")}`
440
+ );
441
+ }
442
+ dirs[lesson.id] = resolved;
385
443
  }
386
444
  return dirs;
387
445
  }
@@ -415,36 +473,114 @@ async function writeLxpackProject(options) {
415
473
  const courseDir = materialized.courseDir;
416
474
  return {
417
475
  outDir: courseDir,
418
- courseYamlPath: join(courseDir, "course.yaml"),
419
- lessonkitJsonPath: join(courseDir, "lessonkit.json")
476
+ courseYamlPath: join2(courseDir, "course.yaml"),
477
+ lessonkitJsonPath: join2(courseDir, "lessonkit.json")
420
478
  };
421
479
  }
422
480
 
423
481
  // src/packageCourse.ts
424
- import * as fsp from "fs/promises";
425
- import { dirname, join as join2, resolve as resolve5 } from "path";
426
- import { tmpdir } from "os";
482
+ import { resolve as resolve6 } from "path";
483
+ import * as fsp3 from "fs/promises";
427
484
  import {
428
485
  buildCourse,
429
- packageLessonkit,
430
486
  validateCourse
431
487
  } from "@lxpack/api";
432
- async function validateLessonkitProject(options) {
433
- return validateCourse({
434
- courseDir: resolve5(options.courseDir),
435
- target: options.target
436
- });
488
+
489
+ // src/packaging/validateInputs.ts
490
+ import { join as join3, resolve as resolve5, win32 as win322 } from "path";
491
+ function validatePackageInputs(options) {
492
+ const { target, output, outputBaseDir } = options;
493
+ const outDir = resolve5(options.outDir);
494
+ const projectRoot = options.projectRoot ? resolve5(options.projectRoot) : void 0;
495
+ if (projectRoot) {
496
+ try {
497
+ assertResolvedPathUnderRoot(projectRoot, outDir);
498
+ } catch (err) {
499
+ return {
500
+ ok: false,
501
+ courseDir: outDir,
502
+ target,
503
+ issues: [{ path: "outDir", message: err instanceof Error ? err.message : String(err) }]
504
+ };
505
+ }
506
+ }
507
+ if (outputBaseDir && !isSafeRelativeSpaPath(outputBaseDir)) {
508
+ return {
509
+ ok: false,
510
+ courseDir: outDir,
511
+ target,
512
+ issues: [{ path: "outputBaseDir", message: `unsafe outputBaseDir: ${outputBaseDir}` }]
513
+ };
514
+ }
515
+ if (output && !projectRoot && !isSafeRelativeSpaPath(output)) {
516
+ return {
517
+ ok: false,
518
+ courseDir: outDir,
519
+ target,
520
+ issues: [{ path: "output", message: `unsafe output: ${output}` }]
521
+ };
522
+ }
523
+ if (projectRoot && outputBaseDir) {
524
+ const resolvedOutputBase = resolve5(projectRoot, outputBaseDir);
525
+ try {
526
+ assertRealPathUnderRoot(projectRoot, resolvedOutputBase);
527
+ } catch (err) {
528
+ return {
529
+ ok: false,
530
+ courseDir: outDir,
531
+ target,
532
+ issues: [
533
+ {
534
+ path: "outputBaseDir",
535
+ message: err instanceof Error ? err.message : String(err)
536
+ }
537
+ ]
538
+ };
539
+ }
540
+ }
541
+ if (projectRoot && output) {
542
+ const resolvedOutput = resolve5(projectRoot, output);
543
+ try {
544
+ assertResolvedPathUnderRoot(projectRoot, resolvedOutput);
545
+ } catch (err) {
546
+ return {
547
+ ok: false,
548
+ courseDir: outDir,
549
+ target,
550
+ issues: [{ path: "output", message: err instanceof Error ? err.message : String(err) }]
551
+ };
552
+ }
553
+ }
554
+ return { ok: true, outDir, projectRoot };
437
555
  }
438
- async function buildLessonkitProject(options) {
439
- return buildCourse({
440
- courseDir: resolve5(options.courseDir),
441
- target: options.target,
442
- output: options.output,
443
- dir: options.dir,
444
- outputBaseDir: options.outputBaseDir,
445
- assessments: options.assessments
446
- });
556
+ function validateArtifactInStaging(stagingRoot, artifactPath, field) {
557
+ if (!artifactPath) return null;
558
+ const resolved = resolveComparablePath(artifactPath);
559
+ if (!isResolvedPathUnderRoot(stagingRoot, resolved)) {
560
+ return {
561
+ path: field,
562
+ message: `${field} is outside the staging directory: ${artifactPath}`
563
+ };
564
+ }
565
+ return null;
566
+ }
567
+ function remapArtifactPaths(stagingRoot, outDir, artifactPath) {
568
+ if (!artifactPath) return void 0;
569
+ const resolved = resolveComparablePath(artifactPath);
570
+ if (!isResolvedPathUnderRoot(stagingRoot, resolved)) {
571
+ return artifactPath;
572
+ }
573
+ const stagingResolved = resolveComparablePath(stagingRoot);
574
+ const relative2 = resolved === stagingResolved ? "" : resolved.slice(stagingResolved.length).replace(/^[/\\]/, "");
575
+ if (!relative2) return outDir;
576
+ if (/^[a-zA-Z]:[/\\]/.test(outDir)) {
577
+ return win322.join(outDir, relative2.replace(/\//g, win322.sep));
578
+ }
579
+ return join3(outDir, relative2);
447
580
  }
581
+
582
+ // src/packaging/promote.ts
583
+ import * as fsp from "fs/promises";
448
584
  async function pathExists(path) {
449
585
  try {
450
586
  await fsp.access(path);
@@ -453,82 +589,76 @@ async function pathExists(path) {
453
589
  return false;
454
590
  }
455
591
  }
592
+ async function renameOrCopy(from, to) {
593
+ try {
594
+ await fsp.rename(from, to);
595
+ } catch (err) {
596
+ const code = err && typeof err === "object" && "code" in err ? String(err.code) : "";
597
+ if (code !== "EXDEV") throw err;
598
+ await fsp.cp(from, to, { recursive: true });
599
+ await fsp.rm(from, { recursive: true, force: true });
600
+ }
601
+ }
456
602
  async function promoteStagingToOutDir(stagingDir, outDir) {
457
603
  const tmpPromote = `${outDir}.tmp-promote`;
458
604
  const backup = `${outDir}.bak`;
459
- await fsp.rename(stagingDir, tmpPromote);
605
+ await renameOrCopy(stagingDir, tmpPromote);
460
606
  const hadOutDir = await pathExists(outDir);
461
607
  if (hadOutDir) {
462
- await fsp.rename(outDir, backup);
608
+ await renameOrCopy(outDir, backup);
463
609
  }
464
610
  try {
465
- await fsp.rename(tmpPromote, outDir);
611
+ await renameOrCopy(tmpPromote, outDir);
466
612
  } catch (promoteError) {
467
613
  if (hadOutDir) {
468
614
  try {
469
- await fsp.rename(backup, outDir);
615
+ await renameOrCopy(backup, outDir);
616
+ } catch (restoreError) {
617
+ const failedPromote2 = `${outDir}.failed-promote-${Date.now()}`;
618
+ try {
619
+ await renameOrCopy(tmpPromote, failedPromote2);
620
+ } catch {
621
+ await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(() => void 0);
622
+ }
623
+ const promoteMsg = promoteError instanceof Error ? promoteError.message : String(promoteError);
624
+ const restoreMsg = restoreError instanceof Error ? restoreError.message : String(restoreError);
625
+ throw new Error(
626
+ `[lessonkit/lxpack] promote failed (${promoteMsg}) and could not restore ${outDir} (${restoreMsg}). Recovery: previous output may be in ${backup}; staged package may be in ${failedPromote2}.`
627
+ );
628
+ }
629
+ } else {
630
+ try {
631
+ await renameOrCopy(tmpPromote, stagingDir);
470
632
  } catch (restoreError) {
471
633
  console.warn(
472
- `[lessonkit/lxpack] failed to restore ${outDir} after promote error:`,
634
+ `[lessonkit/lxpack] failed to restore ${stagingDir} after promote error:`,
473
635
  restoreError instanceof Error ? restoreError.message : restoreError
474
636
  );
637
+ await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(() => void 0);
475
638
  }
639
+ throw promoteError;
640
+ }
641
+ const failedPromote = `${outDir}.failed-promote-${Date.now()}`;
642
+ try {
643
+ await renameOrCopy(tmpPromote, failedPromote);
644
+ } catch {
645
+ await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(() => void 0);
476
646
  }
477
- await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(() => void 0);
478
647
  throw promoteError;
479
648
  }
480
649
  if (hadOutDir) {
481
650
  await fsp.rm(backup, { recursive: true, force: true }).catch(() => void 0);
482
651
  }
483
652
  }
484
- async function packageLessonkitCourse(options) {
485
- const { target, output, dir, outputBaseDir, ...writeOpts } = options;
486
- const outDir = resolve5(writeOpts.outDir);
487
- const projectRoot = writeOpts.projectRoot ? resolve5(writeOpts.projectRoot) : void 0;
488
- if (projectRoot) {
489
- assertResolvedPathUnderRoot(projectRoot, outDir);
490
- }
491
- if (outputBaseDir && !isSafeRelativeSpaPath(outputBaseDir)) {
492
- return {
493
- ok: false,
494
- courseDir: outDir,
495
- target,
496
- issues: [{ path: "outputBaseDir", message: `unsafe outputBaseDir: ${outputBaseDir}` }]
497
- };
498
- }
499
- if (projectRoot && output) {
500
- const resolvedOutput = resolve5(projectRoot, output);
501
- try {
502
- assertResolvedPathUnderRoot(projectRoot, resolvedOutput);
503
- } catch (err) {
504
- return {
505
- ok: false,
506
- courseDir: outDir,
507
- target,
508
- issues: [
509
- {
510
- path: "output",
511
- message: err instanceof Error ? err.message : String(err)
512
- }
513
- ]
514
- };
515
- }
516
- }
517
- const descriptorValidation = validateDescriptor(writeOpts.descriptor);
518
- if (!descriptorValidation.ok) {
519
- return {
520
- ok: false,
521
- courseDir: outDir,
522
- target,
523
- issues: descriptorValidation.issues.map((i) => ({
524
- path: i.path,
525
- message: i.message
526
- }))
527
- };
528
- }
529
- const descriptor = descriptorValidation.descriptor;
530
- const stagingDir = await fsp.mkdtemp(join2(tmpdir(), "lessonkit-lxpack-"));
531
- let promoted = false;
653
+
654
+ // src/packaging/staging.ts
655
+ import * as fsp2 from "fs/promises";
656
+ import { dirname, join as join4 } from "path";
657
+ import { tmpdir } from "os";
658
+ import { packageLessonkit } from "@lxpack/api";
659
+ async function buildStagingPackage(options) {
660
+ const { target, output, dir, outputBaseDir, descriptor, ...writeOpts } = options;
661
+ const stagingDir = await fsp2.mkdtemp(join4(tmpdir(), "lessonkit-lxpack-"));
532
662
  try {
533
663
  let spaDirs;
534
664
  try {
@@ -536,8 +666,7 @@ async function packageLessonkitCourse(options) {
536
666
  } catch (err) {
537
667
  return {
538
668
  ok: false,
539
- courseDir: outDir,
540
- target,
669
+ stagingDir,
541
670
  issues: [
542
671
  {
543
672
  path: "spaDirs",
@@ -548,8 +677,8 @@ async function packageLessonkitCourse(options) {
548
677
  }
549
678
  const interchange = descriptorToInterchange(descriptor);
550
679
  const outputBase = outputBaseDir ?? ".lxpack/out";
551
- await fsp.mkdir(join2(stagingDir, outputBase), { recursive: true });
552
- const defaultOutput = output ?? (dir ? join2(outputBase, target) : join2(outputBase, `course-${target}.zip`));
680
+ await fsp2.mkdir(join4(stagingDir, outputBase), { recursive: true });
681
+ const defaultOutput = output ?? (dir ? join4(outputBase, target) : join4(outputBase, `course-${target}.zip`));
553
682
  const build = await packageLessonkit({
554
683
  interchange,
555
684
  spaDirs,
@@ -562,15 +691,9 @@ async function packageLessonkitCourse(options) {
562
691
  writeAuthoringFiles: true
563
692
  });
564
693
  if (!build.ok) {
565
- const validation2 = {
566
- ok: false,
567
- issues: build.issues
568
- };
569
694
  return {
570
695
  ok: false,
571
- courseDir: outDir,
572
- target,
573
- validation: validation2,
696
+ stagingDir,
574
697
  build,
575
698
  issues: build.issues.map((i) => ({
576
699
  path: i.path,
@@ -579,48 +702,266 @@ async function packageLessonkitCourse(options) {
579
702
  }))
580
703
  };
581
704
  }
582
- const validation = {
705
+ return {
583
706
  ok: true,
584
- manifest: build.manifest,
585
- issues: build.issues
707
+ stagingDir,
708
+ build,
709
+ outputPath: "outputPath" in build ? build.outputPath : void 0,
710
+ outputDir: "outputDir" in build ? build.outputDir : void 0
586
711
  };
587
- const stagingRoot = await fsp.realpath(stagingDir);
588
- const remapArtifactPath = (artifactPath) => {
589
- if (!artifactPath) return void 0;
590
- const resolved = resolve5(artifactPath);
591
- if (resolved === stagingRoot || resolved.startsWith(`${stagingRoot}/`)) {
592
- return join2(outDir, resolved.slice(stagingRoot.length + 1));
593
- }
594
- return artifactPath;
712
+ } catch (err) {
713
+ await fsp2.rm(stagingDir, { recursive: true, force: true }).catch(() => void 0);
714
+ throw err;
715
+ }
716
+ }
717
+ async function ensureOutDirParent(outDir) {
718
+ await fsp2.mkdir(dirname(outDir), { recursive: true });
719
+ }
720
+
721
+ // src/packageCourse.ts
722
+ async function validateLessonkitProject(options) {
723
+ return validateCourse({
724
+ courseDir: resolve6(options.courseDir),
725
+ target: options.target
726
+ });
727
+ }
728
+ async function buildLessonkitProject(options) {
729
+ return buildCourse({
730
+ courseDir: resolve6(options.courseDir),
731
+ target: options.target,
732
+ output: options.output,
733
+ dir: options.dir,
734
+ outputBaseDir: options.outputBaseDir,
735
+ assessments: options.assessments
736
+ });
737
+ }
738
+ async function packageLessonkitCourse(options) {
739
+ const { target, output, dir, outputBaseDir, ...writeOpts } = options;
740
+ const inputValidation = validatePackageInputs({
741
+ target,
742
+ output,
743
+ outputBaseDir,
744
+ outDir: writeOpts.outDir,
745
+ projectRoot: writeOpts.projectRoot
746
+ });
747
+ if (!inputValidation.ok) {
748
+ return {
749
+ ok: false,
750
+ courseDir: inputValidation.courseDir,
751
+ target: inputValidation.target,
752
+ issues: inputValidation.issues
595
753
  };
596
- const remappedOutputPath = remapArtifactPath(
597
- "outputPath" in build ? build.outputPath : void 0
598
- );
599
- const remappedOutputDir = remapArtifactPath("outputDir" in build ? build.outputDir : void 0);
600
- await fsp.mkdir(dirname(outDir), { recursive: true });
601
- await promoteStagingToOutDir(stagingDir, outDir);
602
- promoted = true;
603
- const remappedBuild = { ...build };
604
- if ("outputPath" in remappedBuild && remappedOutputPath !== void 0) {
605
- remappedBuild.outputPath = remappedOutputPath;
606
- }
607
- if ("outputDir" in remappedBuild && remappedOutputDir !== void 0) {
608
- remappedBuild.outputDir = remappedOutputDir;
754
+ }
755
+ const outDir = inputValidation.outDir;
756
+ const descriptorValidation = validateDescriptor(writeOpts.descriptor);
757
+ if (!descriptorValidation.ok) {
758
+ return {
759
+ ok: false,
760
+ courseDir: outDir,
761
+ target,
762
+ issues: descriptorValidation.issues.map((i) => ({
763
+ path: i.path,
764
+ message: i.message
765
+ }))
766
+ };
767
+ }
768
+ const descriptor = descriptorValidation.descriptor;
769
+ if (target === "xapi" || target === "cmi5") {
770
+ const activityIri = descriptor.tracking?.xapi?.activityIri?.trim();
771
+ if (!activityIri) {
772
+ return {
773
+ ok: false,
774
+ courseDir: outDir,
775
+ target,
776
+ issues: [
777
+ {
778
+ path: "course.tracking.xapi.activityIri",
779
+ message: "tracking.xapi.activityIri is required for xapi and cmi5 export targets"
780
+ }
781
+ ]
782
+ };
609
783
  }
784
+ }
785
+ const staged = await buildStagingPackage({
786
+ ...writeOpts,
787
+ descriptor,
788
+ target,
789
+ output,
790
+ dir,
791
+ outputBaseDir
792
+ });
793
+ if (!staged.ok) {
794
+ await fsp3.rm(staged.stagingDir, { recursive: true, force: true }).catch(() => void 0);
795
+ const validation2 = staged.build ? { ok: false, issues: staged.build.issues } : void 0;
610
796
  return {
611
- ok: true,
797
+ ok: false,
798
+ courseDir: outDir,
799
+ target,
800
+ validation: validation2,
801
+ build: staged.build,
802
+ issues: staged.issues
803
+ };
804
+ }
805
+ const { stagingDir, build } = staged;
806
+ const stagingRoot = await fsp3.realpath(stagingDir);
807
+ const artifactIssues = [
808
+ validateArtifactInStaging(stagingRoot, staged.outputPath, "outputPath"),
809
+ validateArtifactInStaging(stagingRoot, staged.outputDir, "outputDir")
810
+ ].filter((issue) => issue != null);
811
+ if (artifactIssues.length > 0) {
812
+ await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(() => void 0);
813
+ return {
814
+ ok: false,
815
+ courseDir: outDir,
816
+ target,
817
+ validation: { ok: true, manifest: build.manifest, issues: build.issues },
818
+ build,
819
+ issues: artifactIssues
820
+ };
821
+ }
822
+ const remappedOutputPath = remapArtifactPaths(stagingRoot, outDir, staged.outputPath);
823
+ const remappedOutputDir = remapArtifactPaths(stagingRoot, outDir, staged.outputDir);
824
+ const validation = {
825
+ ok: true,
826
+ manifest: build.manifest,
827
+ issues: build.issues
828
+ };
829
+ try {
830
+ await ensureOutDirParent(outDir);
831
+ await promoteStagingToOutDir(stagingDir, outDir);
832
+ } catch (err) {
833
+ return {
834
+ ok: false,
612
835
  courseDir: outDir,
613
836
  target,
614
- outputPath: remappedOutputPath,
615
- outputDir: remappedOutputDir,
616
- fileCount: build.fileCount,
617
837
  validation,
618
- build: remappedBuild
838
+ build,
839
+ issues: [
840
+ {
841
+ path: "promote",
842
+ message: err instanceof Error ? err.message : String(err)
843
+ }
844
+ ]
619
845
  };
620
- } finally {
621
- if (!promoted) {
622
- await fsp.rm(stagingDir, { recursive: true, force: true }).catch(() => void 0);
846
+ }
847
+ const remappedBuild = { ...build };
848
+ if ("outputPath" in remappedBuild && remappedOutputPath !== void 0) {
849
+ remappedBuild.outputPath = remappedOutputPath;
850
+ }
851
+ if ("outputDir" in remappedBuild && remappedOutputDir !== void 0) {
852
+ remappedBuild.outputDir = remappedOutputDir;
853
+ }
854
+ return {
855
+ ok: true,
856
+ courseDir: outDir,
857
+ target,
858
+ outputPath: remappedOutputPath,
859
+ outputDir: remappedOutputDir,
860
+ fileCount: build.fileCount,
861
+ validation,
862
+ build: remappedBuild
863
+ };
864
+ }
865
+
866
+ // src/manifest.ts
867
+ var DEFAULT_PATHS = {
868
+ spaDistDir: "dist",
869
+ lxpackOutDir: ".lxpack/course",
870
+ outputBaseDir: ".lxpack/out"
871
+ };
872
+ function parseLessonkitManifest(raw, label = "lessonkit.json", projectRoot) {
873
+ if (!raw || typeof raw !== "object") {
874
+ return { ok: false, issues: [{ path: label, message: "must be a JSON object" }] };
875
+ }
876
+ const config = raw;
877
+ const issues = [];
878
+ if (config.schemaVersion !== 1) {
879
+ issues.push({
880
+ path: "schemaVersion",
881
+ message: `must be 1 (got ${String(config.schemaVersion)})`
882
+ });
883
+ }
884
+ const name = config.name;
885
+ if (typeof name !== "string" || !name.trim()) {
886
+ issues.push({ path: "name", message: "must be a non-empty string" });
887
+ }
888
+ const courseRaw = config.course;
889
+ if (Array.isArray(courseRaw)) {
890
+ issues.push({ path: "course", message: "must be an object, not an array" });
891
+ return { ok: false, issues };
892
+ }
893
+ if (!courseRaw || typeof courseRaw !== "object") {
894
+ issues.push({ path: "course", message: "must be an object" });
895
+ return { ok: false, issues };
896
+ }
897
+ const courseObj = courseRaw;
898
+ if (courseObj.lessons !== void 0 && !Array.isArray(courseObj.lessons)) {
899
+ issues.push({ path: "course.lessons", message: "must be an array" });
900
+ }
901
+ if (courseObj.assessments !== void 0 && !Array.isArray(courseObj.assessments)) {
902
+ issues.push({ path: "course.assessments", message: "must be an array" });
903
+ }
904
+ if (issues.length) return { ok: false, issues };
905
+ const validation = validateDescriptor(courseRaw);
906
+ if (!validation.ok) {
907
+ for (const i of validation.issues) {
908
+ issues.push({
909
+ path: i.path.startsWith("course.") ? i.path : `course.${i.path}`,
910
+ message: i.message
911
+ });
623
912
  }
913
+ } else if (validation.descriptor.layout === "per-lesson-spa") {
914
+ issues.push({
915
+ path: "course.layout",
916
+ message: "per-lesson-spa is not supported by lessonkit package yet. Use single-spa or package via @lessonkit/lxpack directly."
917
+ });
918
+ }
919
+ const paths = { ...DEFAULT_PATHS };
920
+ const pathsRaw = config.paths;
921
+ if (pathsRaw !== void 0 && (typeof pathsRaw !== "object" || pathsRaw === null)) {
922
+ issues.push({ path: "paths", message: "must be an object" });
923
+ } else if (pathsRaw && typeof pathsRaw === "object") {
924
+ const p = pathsRaw;
925
+ for (const key of ["spaDistDir", "lxpackOutDir", "outputBaseDir"]) {
926
+ if (p[key] !== void 0) {
927
+ if (typeof p[key] !== "string" || !p[key].trim()) {
928
+ issues.push({ path: `paths.${key}`, message: "must be a non-empty string" });
929
+ } else {
930
+ paths[key] = p[key].trim();
931
+ }
932
+ }
933
+ }
934
+ }
935
+ const courseSpaDistDir = validation.ok ? validation.descriptor.spaDistDir?.trim() : void 0;
936
+ if (courseSpaDistDir && courseSpaDistDir !== paths.spaDistDir) {
937
+ issues.push({
938
+ path: "course.spaDistDir",
939
+ message: `"course.spaDistDir" (${courseSpaDistDir}) differs from "paths.spaDistDir" (${paths.spaDistDir}). Use paths.spaDistDir for CLI build and package.`
940
+ });
941
+ }
942
+ if (projectRoot) {
943
+ const pathIssues = validateProjectPaths(projectRoot, paths);
944
+ for (const pi of pathIssues) {
945
+ issues.push({ path: pi.path, message: pi.message });
946
+ }
947
+ }
948
+ if (issues.length) return { ok: false, issues };
949
+ if (!validation.ok) return { ok: false, issues };
950
+ return {
951
+ ok: true,
952
+ manifest: {
953
+ schemaVersion: 1,
954
+ name,
955
+ course: validation.descriptor,
956
+ paths
957
+ }
958
+ };
959
+ }
960
+ async function loadLessonkitManifestFromFile(readJson, label = "lessonkit.json", projectRoot) {
961
+ try {
962
+ return parseLessonkitManifest(await readJson(), label, projectRoot);
963
+ } catch {
964
+ return { ok: false, issues: [{ path: label, message: "failed to read or parse JSON" }] };
624
965
  }
625
966
  }
626
967
 
@@ -639,21 +980,28 @@ export {
639
980
  LESSONKIT_TELEMETRY_EVENTS,
640
981
  assessmentDescriptorToLxpack,
641
982
  buildLessonkitProject,
983
+ buildStagingPackage,
642
984
  descriptorToInterchange,
985
+ ensureOutDirParent,
643
986
  extractAssessments,
644
987
  lessonkitInterchangeSchema,
988
+ loadLessonkitManifestFromFile,
645
989
  mapLessonkitIds,
646
990
  mapLessonkitTelemetryToBridgeAction,
647
991
  mapLessonkitTelemetryToLxpack,
648
992
  materializeLessonkitProject2 as materializeLessonkitProject,
649
993
  packageLessonkitCourse,
650
994
  parseLessonkitInterchange,
995
+ parseLessonkitManifest,
996
+ promoteStagingToOutDir,
997
+ remapArtifactPaths,
651
998
  resolveSafePackageOutputOverride,
652
999
  resolveSpaLessons,
653
1000
  telemetryEventToLessonkit,
654
1001
  themeToLxpackRuntime,
655
1002
  validateDescriptor,
656
1003
  validateLessonkitProject,
1004
+ validatePackageInputs,
657
1005
  validateProjectPaths,
658
1006
  writeLxpackProject
659
1007
  };