@lessonkit/lxpack 0.9.3 → 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
  }
@@ -383,7 +425,19 @@ async function resolveSpaDirs(options) {
383
425
  }
384
426
  const resolved = projectRoot ? resolve3(projectRoot, src) : resolve3(src);
385
427
  if (projectRoot) {
386
- assertResolvedPathUnderRoot(resolve3(projectRoot), resolved);
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
+ );
387
441
  }
388
442
  dirs[lesson.id] = resolved;
389
443
  }
@@ -419,36 +473,114 @@ async function writeLxpackProject(options) {
419
473
  const courseDir = materialized.courseDir;
420
474
  return {
421
475
  outDir: courseDir,
422
- courseYamlPath: join(courseDir, "course.yaml"),
423
- lessonkitJsonPath: join(courseDir, "lessonkit.json")
476
+ courseYamlPath: join2(courseDir, "course.yaml"),
477
+ lessonkitJsonPath: join2(courseDir, "lessonkit.json")
424
478
  };
425
479
  }
426
480
 
427
481
  // src/packageCourse.ts
428
- import * as fsp from "fs/promises";
429
- import { dirname, join as join2, resolve as resolve5 } from "path";
430
- import { tmpdir } from "os";
482
+ import { resolve as resolve6 } from "path";
483
+ import * as fsp3 from "fs/promises";
431
484
  import {
432
485
  buildCourse,
433
- packageLessonkit,
434
486
  validateCourse
435
487
  } from "@lxpack/api";
436
- async function validateLessonkitProject(options) {
437
- return validateCourse({
438
- courseDir: resolve5(options.courseDir),
439
- target: options.target
440
- });
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 };
441
555
  }
442
- async function buildLessonkitProject(options) {
443
- return buildCourse({
444
- courseDir: resolve5(options.courseDir),
445
- target: options.target,
446
- output: options.output,
447
- dir: options.dir,
448
- outputBaseDir: options.outputBaseDir,
449
- assessments: options.assessments
450
- });
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;
451
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);
580
+ }
581
+
582
+ // src/packaging/promote.ts
583
+ import * as fsp from "fs/promises";
452
584
  async function pathExists(path) {
453
585
  try {
454
586
  await fsp.access(path);
@@ -457,82 +589,76 @@ async function pathExists(path) {
457
589
  return false;
458
590
  }
459
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
+ }
460
602
  async function promoteStagingToOutDir(stagingDir, outDir) {
461
603
  const tmpPromote = `${outDir}.tmp-promote`;
462
604
  const backup = `${outDir}.bak`;
463
- await fsp.rename(stagingDir, tmpPromote);
605
+ await renameOrCopy(stagingDir, tmpPromote);
464
606
  const hadOutDir = await pathExists(outDir);
465
607
  if (hadOutDir) {
466
- await fsp.rename(outDir, backup);
608
+ await renameOrCopy(outDir, backup);
467
609
  }
468
610
  try {
469
- await fsp.rename(tmpPromote, outDir);
611
+ await renameOrCopy(tmpPromote, outDir);
470
612
  } catch (promoteError) {
471
613
  if (hadOutDir) {
472
614
  try {
473
- 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);
474
632
  } catch (restoreError) {
475
633
  console.warn(
476
- `[lessonkit/lxpack] failed to restore ${outDir} after promote error:`,
634
+ `[lessonkit/lxpack] failed to restore ${stagingDir} after promote error:`,
477
635
  restoreError instanceof Error ? restoreError.message : restoreError
478
636
  );
637
+ await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(() => void 0);
479
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);
480
646
  }
481
- await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(() => void 0);
482
647
  throw promoteError;
483
648
  }
484
649
  if (hadOutDir) {
485
650
  await fsp.rm(backup, { recursive: true, force: true }).catch(() => void 0);
486
651
  }
487
652
  }
488
- async function packageLessonkitCourse(options) {
489
- const { target, output, dir, outputBaseDir, ...writeOpts } = options;
490
- const outDir = resolve5(writeOpts.outDir);
491
- const projectRoot = writeOpts.projectRoot ? resolve5(writeOpts.projectRoot) : void 0;
492
- if (projectRoot) {
493
- assertResolvedPathUnderRoot(projectRoot, outDir);
494
- }
495
- if (outputBaseDir && !isSafeRelativeSpaPath(outputBaseDir)) {
496
- return {
497
- ok: false,
498
- courseDir: outDir,
499
- target,
500
- issues: [{ path: "outputBaseDir", message: `unsafe outputBaseDir: ${outputBaseDir}` }]
501
- };
502
- }
503
- if (projectRoot && output) {
504
- const resolvedOutput = resolve5(projectRoot, output);
505
- try {
506
- assertResolvedPathUnderRoot(projectRoot, resolvedOutput);
507
- } catch (err) {
508
- return {
509
- ok: false,
510
- courseDir: outDir,
511
- target,
512
- issues: [
513
- {
514
- path: "output",
515
- message: err instanceof Error ? err.message : String(err)
516
- }
517
- ]
518
- };
519
- }
520
- }
521
- const descriptorValidation = validateDescriptor(writeOpts.descriptor);
522
- if (!descriptorValidation.ok) {
523
- return {
524
- ok: false,
525
- courseDir: outDir,
526
- target,
527
- issues: descriptorValidation.issues.map((i) => ({
528
- path: i.path,
529
- message: i.message
530
- }))
531
- };
532
- }
533
- const descriptor = descriptorValidation.descriptor;
534
- const stagingDir = await fsp.mkdtemp(join2(tmpdir(), "lessonkit-lxpack-"));
535
- 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-"));
536
662
  try {
537
663
  let spaDirs;
538
664
  try {
@@ -540,8 +666,7 @@ async function packageLessonkitCourse(options) {
540
666
  } catch (err) {
541
667
  return {
542
668
  ok: false,
543
- courseDir: outDir,
544
- target,
669
+ stagingDir,
545
670
  issues: [
546
671
  {
547
672
  path: "spaDirs",
@@ -552,8 +677,8 @@ async function packageLessonkitCourse(options) {
552
677
  }
553
678
  const interchange = descriptorToInterchange(descriptor);
554
679
  const outputBase = outputBaseDir ?? ".lxpack/out";
555
- await fsp.mkdir(join2(stagingDir, outputBase), { recursive: true });
556
- 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`));
557
682
  const build = await packageLessonkit({
558
683
  interchange,
559
684
  spaDirs,
@@ -566,15 +691,9 @@ async function packageLessonkitCourse(options) {
566
691
  writeAuthoringFiles: true
567
692
  });
568
693
  if (!build.ok) {
569
- const validation2 = {
570
- ok: false,
571
- issues: build.issues
572
- };
573
694
  return {
574
695
  ok: false,
575
- courseDir: outDir,
576
- target,
577
- validation: validation2,
696
+ stagingDir,
578
697
  build,
579
698
  issues: build.issues.map((i) => ({
580
699
  path: i.path,
@@ -583,48 +702,266 @@ async function packageLessonkitCourse(options) {
583
702
  }))
584
703
  };
585
704
  }
586
- const validation = {
705
+ return {
587
706
  ok: true,
588
- manifest: build.manifest,
589
- 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
590
711
  };
591
- const stagingRoot = await fsp.realpath(stagingDir);
592
- const remapArtifactPath = (artifactPath) => {
593
- if (!artifactPath) return void 0;
594
- const resolved = resolve5(artifactPath);
595
- if (resolved === stagingRoot || resolved.startsWith(`${stagingRoot}/`)) {
596
- return join2(outDir, resolved.slice(stagingRoot.length + 1));
597
- }
598
- 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
599
753
  };
600
- const remappedOutputPath = remapArtifactPath(
601
- "outputPath" in build ? build.outputPath : void 0
602
- );
603
- const remappedOutputDir = remapArtifactPath("outputDir" in build ? build.outputDir : void 0);
604
- await fsp.mkdir(dirname(outDir), { recursive: true });
605
- await promoteStagingToOutDir(stagingDir, outDir);
606
- promoted = true;
607
- const remappedBuild = { ...build };
608
- if ("outputPath" in remappedBuild && remappedOutputPath !== void 0) {
609
- remappedBuild.outputPath = remappedOutputPath;
610
- }
611
- if ("outputDir" in remappedBuild && remappedOutputDir !== void 0) {
612
- 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
+ };
613
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;
614
796
  return {
615
- 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,
616
835
  courseDir: outDir,
617
836
  target,
618
- outputPath: remappedOutputPath,
619
- outputDir: remappedOutputDir,
620
- fileCount: build.fileCount,
621
837
  validation,
622
- build: remappedBuild
838
+ build,
839
+ issues: [
840
+ {
841
+ path: "promote",
842
+ message: err instanceof Error ? err.message : String(err)
843
+ }
844
+ ]
623
845
  };
624
- } finally {
625
- if (!promoted) {
626
- 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
+ });
627
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" }] };
628
965
  }
629
966
  }
630
967
 
@@ -643,21 +980,28 @@ export {
643
980
  LESSONKIT_TELEMETRY_EVENTS,
644
981
  assessmentDescriptorToLxpack,
645
982
  buildLessonkitProject,
983
+ buildStagingPackage,
646
984
  descriptorToInterchange,
985
+ ensureOutDirParent,
647
986
  extractAssessments,
648
987
  lessonkitInterchangeSchema,
988
+ loadLessonkitManifestFromFile,
649
989
  mapLessonkitIds,
650
990
  mapLessonkitTelemetryToBridgeAction,
651
991
  mapLessonkitTelemetryToLxpack,
652
992
  materializeLessonkitProject2 as materializeLessonkitProject,
653
993
  packageLessonkitCourse,
654
994
  parseLessonkitInterchange,
995
+ parseLessonkitManifest,
996
+ promoteStagingToOutDir,
997
+ remapArtifactPaths,
655
998
  resolveSafePackageOutputOverride,
656
999
  resolveSpaLessons,
657
1000
  telemetryEventToLessonkit,
658
1001
  themeToLxpackRuntime,
659
1002
  validateDescriptor,
660
1003
  validateLessonkitProject,
1004
+ validatePackageInputs,
661
1005
  validateProjectPaths,
662
1006
  writeLxpackProject
663
1007
  };