@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.js CHANGED
@@ -1,28 +1,78 @@
1
1
  import {
2
2
  telemetryEventToLessonkit
3
- } from "./chunk-PSUSESH3.js";
3
+ } from "./chunk-DYQI222N.js";
4
4
 
5
5
  // src/validateDescriptor.ts
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 { isAbsolute, 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;
13
- if (/^[a-zA-Z]:[/\\]/.test(spaPath)) return false;
14
- const segments = spaPath.split(/[/\\]/).filter((s) => s.length > 0);
20
+ if (/^[a-zA-Z]:/.test(spaPath)) return false;
21
+ if (spaPath === "." || spaPath === "./") return false;
22
+ const segments = spaPath.split(/[/\\]/).filter((s) => s.length > 0 && s !== ".");
15
23
  if (segments.some((s) => s === "..")) return false;
16
- return true;
24
+ return segments.length > 0;
17
25
  }
18
26
  function assertResolvedPathUnderRoot(root, target) {
19
- const rootResolved = resolve(root);
20
- const targetResolved = resolve(target);
27
+ const rootResolved = resolveComparablePath(root);
28
+ const targetResolved = resolveComparablePath(target);
21
29
  const prefix = rootResolved.endsWith(sep) ? rootResolved : rootResolved + sep;
22
- if (targetResolved !== rootResolved && !targetResolved.startsWith(prefix)) {
30
+ const win32Prefix = rootResolved.endsWith(win32.sep) ? rootResolved : rootResolved + win32.sep;
31
+ if (targetResolved !== rootResolved && !targetResolved.startsWith(prefix) && !targetResolved.startsWith(win32Prefix)) {
23
32
  throw new Error(`unsafe path escapes project root: ${target}`);
24
33
  }
25
34
  }
35
+ function assertRealPathUnderRoot(root, target) {
36
+ const rootResolved = resolveComparablePath(root);
37
+ const targetResolved = resolveComparablePath(target);
38
+ let rootReal;
39
+ try {
40
+ rootReal = realpathSync(rootResolved);
41
+ } catch {
42
+ rootReal = rootResolved;
43
+ }
44
+ let targetCheck;
45
+ try {
46
+ targetCheck = realpathSync(targetResolved);
47
+ } catch {
48
+ const rel = relative(rootResolved, targetResolved);
49
+ if (rel.startsWith("..") || rel.includes(`..${sep}`)) {
50
+ throw new Error(`unsafe path escapes project root: ${target}`);
51
+ }
52
+ targetCheck = resolve(rootReal, rel);
53
+ }
54
+ assertResolvedPathUnderRoot(rootReal, targetCheck);
55
+ }
56
+ function normalizePathForComparison(p) {
57
+ const resolved = resolveComparablePath(p);
58
+ return /^[a-zA-Z]:[/\\]/.test(resolved) ? resolved.toLowerCase() : resolved;
59
+ }
60
+ function relativePathUnderRoot(root, target) {
61
+ const rootResolved = normalizePathForComparison(root);
62
+ const targetResolved = normalizePathForComparison(target);
63
+ if (/^[a-zA-Z]:[/\\]/.test(rootResolved)) {
64
+ return win32.relative(rootResolved, targetResolved);
65
+ }
66
+ return relative(rootResolved, targetResolved);
67
+ }
68
+ function isResolvedPathUnderRoot(root, target) {
69
+ const rootResolved = normalizePathForComparison(root);
70
+ const targetResolved = normalizePathForComparison(target);
71
+ if (targetResolved === rootResolved) return true;
72
+ const rel = relativePathUnderRoot(root, target);
73
+ if (!rel) return true;
74
+ return !rel.startsWith("..") && !isAbsolute(rel);
75
+ }
26
76
 
27
77
  // src/theme.ts
28
78
  import { getPresetTheme, themeToCssVariables } from "@lessonkit/themes";
@@ -42,6 +92,70 @@ function themeToLxpackRuntime(input) {
42
92
  // src/validateDescriptor.ts
43
93
  var VALID_LAYOUTS = ["single-spa", "per-lesson-spa"];
44
94
  var VALID_THEME_PRESETS = ["default", "light", "dark", "brand"];
95
+ function isRecord(value) {
96
+ return typeof value === "object" && value !== null && !Array.isArray(value);
97
+ }
98
+ function parseLessonDescriptor(raw) {
99
+ if (!isRecord(raw)) {
100
+ return { id: "", title: "" };
101
+ }
102
+ return {
103
+ id: typeof raw.id === "string" ? raw.id : "",
104
+ title: typeof raw.title === "string" ? raw.title : "",
105
+ spaPath: typeof raw.spaPath === "string" ? raw.spaPath : void 0
106
+ };
107
+ }
108
+ function parseAssessmentDescriptor(raw) {
109
+ if (!isRecord(raw)) {
110
+ return { checkId: "", question: "", choices: [], answer: "" };
111
+ }
112
+ return {
113
+ checkId: typeof raw.checkId === "string" ? raw.checkId : "",
114
+ question: typeof raw.question === "string" ? raw.question : "",
115
+ choices: Array.isArray(raw.choices) ? raw.choices.filter((c) => typeof c === "string") : [],
116
+ answer: typeof raw.answer === "string" ? raw.answer : "",
117
+ passingScore: typeof raw.passingScore === "number" ? raw.passingScore : void 0
118
+ };
119
+ }
120
+ function parseCourseDescriptorInput(input) {
121
+ if (!isRecord(input)) return null;
122
+ const trackingRaw = input.tracking;
123
+ let tracking;
124
+ if (isRecord(trackingRaw)) {
125
+ const completionRaw = trackingRaw.completion;
126
+ const xapiRaw = trackingRaw.xapi;
127
+ tracking = {
128
+ completion: isRecord(completionRaw) ? {
129
+ threshold: typeof completionRaw.threshold === "number" ? completionRaw.threshold : void 0
130
+ } : void 0,
131
+ xapi: isRecord(xapiRaw) ? {
132
+ activityIri: typeof xapiRaw.activityIri === "string" ? xapiRaw.activityIri : void 0
133
+ } : void 0
134
+ };
135
+ }
136
+ const themeRaw = input.theme;
137
+ let theme;
138
+ if (isRecord(themeRaw)) {
139
+ theme = {
140
+ preset: typeof themeRaw.preset === "string" ? themeRaw.preset : void 0
141
+ };
142
+ if (isRecord(themeRaw.theme)) {
143
+ theme.theme = themeRaw.theme;
144
+ }
145
+ }
146
+ return {
147
+ courseId: typeof input.courseId === "string" ? input.courseId : "",
148
+ title: typeof input.title === "string" ? input.title : "",
149
+ version: typeof input.version === "string" ? input.version : void 0,
150
+ layout: typeof input.layout === "string" ? input.layout : void 0,
151
+ lessons: Array.isArray(input.lessons) ? input.lessons.map(parseLessonDescriptor) : [],
152
+ assessments: Array.isArray(input.assessments) ? input.assessments.map(parseAssessmentDescriptor) : void 0,
153
+ theme,
154
+ tracking,
155
+ spaDistDir: typeof input.spaDistDir === "string" ? input.spaDistDir : void 0,
156
+ spaLessonId: typeof input.spaLessonId === "string" ? input.spaLessonId : void 0
157
+ };
158
+ }
45
159
  function normalizeDescriptor(input) {
46
160
  const course = validateId(input.courseId, "courseId");
47
161
  if (!course.ok) throw new Error("normalizeDescriptor called with invalid courseId");
@@ -75,6 +189,31 @@ function normalizeDescriptor(input) {
75
189
  };
76
190
  }
77
191
  function validateDescriptor(input) {
192
+ const parsed = parseCourseDescriptorInput(input);
193
+ if (parsed === null) {
194
+ return { ok: false, issues: [{ path: "course", message: "must be an object" }] };
195
+ }
196
+ return validateDescriptorParsed(parsed);
197
+ }
198
+ function validateDescriptorForTarget(input, target) {
199
+ const result = validateDescriptor(input);
200
+ if (!result.ok || !target) return result;
201
+ if (target !== "xapi" && target !== "cmi5") return result;
202
+ const activityIri = result.descriptor.tracking?.xapi?.activityIri?.trim();
203
+ if (!activityIri) {
204
+ return {
205
+ ok: false,
206
+ issues: [
207
+ {
208
+ path: "course.tracking.xapi.activityIri",
209
+ message: "tracking.xapi.activityIri is required for xapi and cmi5 export targets"
210
+ }
211
+ ]
212
+ };
213
+ }
214
+ return result;
215
+ }
216
+ function validateDescriptorParsed(input) {
78
217
  const issues = [];
79
218
  const course = validateId(input.courseId, "courseId");
80
219
  if (!course.ok) issues.push(...course.issues.map((i) => ({ path: i.path, message: i.message })));
@@ -198,7 +337,7 @@ function validateDescriptor(input) {
198
337
  issues.push({ path: `${path}.answer`, message: "answer must match a choice" });
199
338
  }
200
339
  const passingScore = assessment.passingScore;
201
- if (passingScore !== void 0 && !(passingScore > 0)) {
340
+ if (passingScore !== void 0 && !(Number.isFinite(passingScore) && passingScore > 0)) {
202
341
  issues.push({
203
342
  path: `${path}.passingScore`,
204
343
  message: "passingScore must be greater than 0 (absolute point threshold)"
@@ -210,7 +349,7 @@ function validateDescriptor(input) {
210
349
  }
211
350
 
212
351
  // src/validateProjectPaths.ts
213
- import { isAbsolute, resolve as resolve2 } from "path";
352
+ import { isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
214
353
  function validatePathField(value, fieldPath, projectRoot, issues) {
215
354
  if (!isSafeRelativeSpaPath(value)) {
216
355
  issues.push({
@@ -220,7 +359,7 @@ function validatePathField(value, fieldPath, projectRoot, issues) {
220
359
  return;
221
360
  }
222
361
  try {
223
- assertResolvedPathUnderRoot(projectRoot, resolve2(projectRoot, value));
362
+ assertRealPathUnderRoot(projectRoot, resolve2(projectRoot, value));
224
363
  } catch {
225
364
  issues.push({
226
365
  path: fieldPath,
@@ -248,16 +387,16 @@ function resolveSafePackageOutputOverride(projectRoot, override) {
248
387
  if (!trimmed) {
249
388
  throw new Error("output override must be a non-empty path");
250
389
  }
251
- if (isAbsolute(trimmed)) {
390
+ if (isAbsolute2(trimmed)) {
252
391
  const resolved2 = resolve2(trimmed);
253
- assertResolvedPathUnderRoot(root, resolved2);
392
+ assertRealPathUnderRoot(root, resolved2);
254
393
  return resolved2;
255
394
  }
256
395
  if (!isSafeRelativeSpaPath(trimmed)) {
257
396
  throw new Error(`unsafe output path: ${override}`);
258
397
  }
259
398
  const resolved = resolve2(root, trimmed);
260
- assertResolvedPathUnderRoot(root, resolved);
399
+ assertRealPathUnderRoot(root, resolved);
261
400
  return resolved;
262
401
  }
263
402
 
@@ -304,6 +443,18 @@ function extractAssessments(descriptor) {
304
443
  }
305
444
 
306
445
  // src/interchange.ts
446
+ function mapDescriptorTracking(tracking) {
447
+ if (!tracking) return void 0;
448
+ const mapped = {};
449
+ if (tracking.completion?.threshold !== void 0) {
450
+ mapped.completion = { threshold: tracking.completion.threshold };
451
+ }
452
+ const activityIri = tracking.xapi?.activityIri?.trim();
453
+ if (activityIri) {
454
+ mapped.xapi = { activityIri };
455
+ }
456
+ return Object.keys(mapped).length > 0 ? mapped : void 0;
457
+ }
307
458
  function resolveSpaLessons(descriptor) {
308
459
  const mapped = mapLessonkitIds(descriptor);
309
460
  if (descriptor.layout === "single-spa") {
@@ -341,7 +492,7 @@ function descriptorToInterchange(descriptor) {
341
492
  type: "spa",
342
493
  path: l.path
343
494
  })),
344
- tracking: descriptor.tracking,
495
+ tracking: mapDescriptorTracking(descriptor.tracking),
345
496
  runtime: runtime ? {
346
497
  theme: runtime.theme,
347
498
  cssVariables: runtime.cssVariables
@@ -351,12 +502,12 @@ function descriptorToInterchange(descriptor) {
351
502
  }
352
503
 
353
504
  // src/writeProject.ts
354
- import { join, resolve as resolve4 } from "path";
505
+ import { join as join2, resolve as resolve4 } from "path";
355
506
  import { materializeLessonkitProject } from "@lxpack/validators";
356
507
 
357
508
  // src/spaDirs.ts
358
509
  import { access } from "fs/promises";
359
- import { resolve as resolve3 } from "path";
510
+ import { join, resolve as resolve3 } from "path";
360
511
  async function resolveSpaDirs(options) {
361
512
  const { descriptor, spaDistDir, lessonSpaDirs, projectRoot } = options;
362
513
  const spaLessons = resolveSpaLessons(descriptor);
@@ -364,13 +515,18 @@ async function resolveSpaDirs(options) {
364
515
  const spaDistRelative = spaDistDir ?? descriptor.spaDistDir ?? "dist";
365
516
  const srcDist = projectRoot ? resolve3(projectRoot, spaDistRelative) : resolve3(spaDistRelative);
366
517
  if (projectRoot) {
367
- assertResolvedPathUnderRoot(resolve3(projectRoot), srcDist);
518
+ assertRealPathUnderRoot(resolve3(projectRoot), srcDist);
368
519
  }
369
520
  try {
370
521
  await access(srcDist);
371
522
  } catch {
372
523
  throw new Error(`spaDistDir not found: ${srcDist}`);
373
524
  }
525
+ try {
526
+ await access(join(srcDist, "index.html"));
527
+ } catch {
528
+ throw new Error(`spaDistDir must contain index.html: ${join(srcDist, "index.html")}`);
529
+ }
374
530
  const lessonId = spaLessons[0]?.id ?? "main";
375
531
  return { [lessonId]: srcDist };
376
532
  }
@@ -383,7 +539,19 @@ async function resolveSpaDirs(options) {
383
539
  }
384
540
  const resolved = projectRoot ? resolve3(projectRoot, src) : resolve3(src);
385
541
  if (projectRoot) {
386
- assertResolvedPathUnderRoot(resolve3(projectRoot), resolved);
542
+ assertRealPathUnderRoot(resolve3(projectRoot), resolved);
543
+ }
544
+ try {
545
+ await access(resolved);
546
+ } catch {
547
+ throw new Error(`lessonSpaDirs path not found for lesson "${lesson.id}": ${resolved}`);
548
+ }
549
+ try {
550
+ await access(join(resolved, "index.html"));
551
+ } catch {
552
+ throw new Error(
553
+ `lessonSpaDirs must contain index.html for lesson "${lesson.id}": ${join(resolved, "index.html")}`
554
+ );
387
555
  }
388
556
  dirs[lesson.id] = resolved;
389
557
  }
@@ -401,7 +569,7 @@ async function writeLxpackProject(options) {
401
569
  const descriptor = validation.descriptor;
402
570
  const outDir = resolve4(options.outDir);
403
571
  if (options.projectRoot) {
404
- assertResolvedPathUnderRoot(resolve4(options.projectRoot), outDir);
572
+ assertRealPathUnderRoot(resolve4(options.projectRoot), outDir);
405
573
  }
406
574
  const spaDirs = await resolveSpaDirs({ ...options, descriptor });
407
575
  const interchange = descriptorToInterchange(descriptor);
@@ -419,78 +587,36 @@ async function writeLxpackProject(options) {
419
587
  const courseDir = materialized.courseDir;
420
588
  return {
421
589
  outDir: courseDir,
422
- courseYamlPath: join(courseDir, "course.yaml"),
423
- lessonkitJsonPath: join(courseDir, "lessonkit.json")
590
+ courseYamlPath: join2(courseDir, "course.yaml"),
591
+ lessonkitJsonPath: join2(courseDir, "lessonkit.json")
424
592
  };
425
593
  }
426
594
 
427
595
  // 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";
596
+ import { resolve as resolve6 } from "path";
597
+ import * as fsp3 from "fs/promises";
431
598
  import {
432
599
  buildCourse,
433
- packageLessonkit,
434
600
  validateCourse
435
601
  } from "@lxpack/api";
436
- async function validateLessonkitProject(options) {
437
- return validateCourse({
438
- courseDir: resolve5(options.courseDir),
439
- target: options.target
440
- });
441
- }
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
- });
451
- }
452
- async function pathExists(path) {
453
- try {
454
- await fsp.access(path);
455
- return true;
456
- } catch {
457
- return false;
458
- }
459
- }
460
- async function promoteStagingToOutDir(stagingDir, outDir) {
461
- const tmpPromote = `${outDir}.tmp-promote`;
462
- const backup = `${outDir}.bak`;
463
- await fsp.rename(stagingDir, tmpPromote);
464
- const hadOutDir = await pathExists(outDir);
465
- if (hadOutDir) {
466
- await fsp.rename(outDir, backup);
467
- }
468
- try {
469
- await fsp.rename(tmpPromote, outDir);
470
- } catch (promoteError) {
471
- if (hadOutDir) {
472
- try {
473
- await fsp.rename(backup, outDir);
474
- } catch (restoreError) {
475
- console.warn(
476
- `[lessonkit/lxpack] failed to restore ${outDir} after promote error:`,
477
- restoreError instanceof Error ? restoreError.message : restoreError
478
- );
479
- }
480
- }
481
- await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(() => void 0);
482
- throw promoteError;
483
- }
484
- if (hadOutDir) {
485
- await fsp.rm(backup, { recursive: true, force: true }).catch(() => void 0);
486
- }
487
- }
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;
602
+
603
+ // src/packaging/validateInputs.ts
604
+ import { isAbsolute as isAbsolute3, join as join3, resolve as resolve5, win32 as win322 } from "path";
605
+ function validatePackageInputs(options) {
606
+ const { target, output, outputBaseDir } = options;
607
+ const outDir = resolve5(options.outDir);
608
+ const projectRoot = options.projectRoot ? resolve5(options.projectRoot) : void 0;
492
609
  if (projectRoot) {
493
- assertResolvedPathUnderRoot(projectRoot, outDir);
610
+ try {
611
+ assertRealPathUnderRoot(projectRoot, outDir);
612
+ } catch (err) {
613
+ return {
614
+ ok: false,
615
+ courseDir: outDir,
616
+ target,
617
+ issues: [{ path: "outDir", message: err instanceof Error ? err.message : String(err) }]
618
+ };
619
+ }
494
620
  }
495
621
  if (outputBaseDir && !isSafeRelativeSpaPath(outputBaseDir)) {
496
622
  return {
@@ -500,10 +626,18 @@ async function packageLessonkitCourse(options) {
500
626
  issues: [{ path: "outputBaseDir", message: `unsafe outputBaseDir: ${outputBaseDir}` }]
501
627
  };
502
628
  }
503
- if (projectRoot && output) {
504
- const resolvedOutput = resolve5(projectRoot, output);
629
+ if (output && !projectRoot && !isSafeRelativeSpaPath(output)) {
630
+ return {
631
+ ok: false,
632
+ courseDir: outDir,
633
+ target,
634
+ issues: [{ path: "output", message: `unsafe output: ${output}` }]
635
+ };
636
+ }
637
+ if (projectRoot && outputBaseDir) {
638
+ const resolvedOutputBase = resolve5(projectRoot, outputBaseDir);
505
639
  try {
506
- assertResolvedPathUnderRoot(projectRoot, resolvedOutput);
640
+ assertRealPathUnderRoot(projectRoot, resolvedOutputBase);
507
641
  } catch (err) {
508
642
  return {
509
643
  ok: false,
@@ -511,28 +645,152 @@ async function packageLessonkitCourse(options) {
511
645
  target,
512
646
  issues: [
513
647
  {
514
- path: "output",
648
+ path: "outputBaseDir",
515
649
  message: err instanceof Error ? err.message : String(err)
516
650
  }
517
651
  ]
518
652
  };
519
653
  }
520
654
  }
521
- const descriptorValidation = validateDescriptor(writeOpts.descriptor);
522
- if (!descriptorValidation.ok) {
655
+ if (projectRoot && output) {
656
+ const resolvedOutput = resolve5(projectRoot, output);
657
+ try {
658
+ assertRealPathUnderRoot(projectRoot, resolvedOutput);
659
+ } catch (err) {
660
+ return {
661
+ ok: false,
662
+ courseDir: outDir,
663
+ target,
664
+ issues: [{ path: "output", message: err instanceof Error ? err.message : String(err) }]
665
+ };
666
+ }
667
+ }
668
+ return { ok: true, outDir, projectRoot };
669
+ }
670
+ function validateArtifactInStaging(stagingRoot, artifactPath, field) {
671
+ if (!artifactPath) return null;
672
+ const resolved = resolveComparablePath(artifactPath);
673
+ if (!isResolvedPathUnderRoot(stagingRoot, resolved)) {
523
674
  return {
524
- ok: false,
525
- courseDir: outDir,
526
- target,
527
- issues: descriptorValidation.issues.map((i) => ({
528
- path: i.path,
529
- message: i.message
530
- }))
675
+ path: field,
676
+ message: `${field} is outside the staging directory: ${artifactPath}`
531
677
  };
532
678
  }
533
- const descriptor = descriptorValidation.descriptor;
534
- const stagingDir = await fsp.mkdtemp(join2(tmpdir(), "lessonkit-lxpack-"));
535
- let promoted = false;
679
+ return null;
680
+ }
681
+ function remapArtifactPaths(stagingRoot, outDir, artifactPath) {
682
+ if (!artifactPath) return void 0;
683
+ const resolved = resolveComparablePath(artifactPath);
684
+ if (!isResolvedPathUnderRoot(stagingRoot, resolved)) {
685
+ return artifactPath;
686
+ }
687
+ const rel = relativePathUnderRoot(stagingRoot, resolved);
688
+ if (rel.startsWith("..") || isAbsolute3(rel)) {
689
+ return artifactPath;
690
+ }
691
+ if (!rel) return outDir;
692
+ if (/^[a-zA-Z]:[/\\]/.test(outDir)) {
693
+ return win322.join(outDir, rel.replace(/\//g, win322.sep));
694
+ }
695
+ return join3(outDir, rel);
696
+ }
697
+
698
+ // src/packaging/promote.ts
699
+ import * as fsp from "fs/promises";
700
+ import { randomUUID } from "crypto";
701
+ import { dirname, join as join4 } from "path";
702
+ async function pathExists(path) {
703
+ try {
704
+ await fsp.access(path);
705
+ return true;
706
+ } catch {
707
+ return false;
708
+ }
709
+ }
710
+ async function renameOrCopy(from, to) {
711
+ try {
712
+ await fsp.rename(from, to);
713
+ } catch (err) {
714
+ const code = err && typeof err === "object" && "code" in err ? String(err.code) : "";
715
+ if (code !== "EXDEV") throw err;
716
+ await fsp.cp(from, to, { recursive: true });
717
+ await fsp.rm(from, { recursive: true, force: true });
718
+ }
719
+ }
720
+ async function assertNoLegacyPromoteArtifacts(outDir) {
721
+ const legacyTmp = `${outDir}.tmp-promote`;
722
+ const legacyBak = `${outDir}.bak`;
723
+ const stale = [];
724
+ if (await pathExists(legacyTmp)) stale.push(legacyTmp);
725
+ if (await pathExists(legacyBak)) stale.push(legacyBak);
726
+ if (stale.length) {
727
+ throw new Error(
728
+ `[lessonkit/lxpack] cannot promote: remove stale packaging artifacts from a previous failed run: ${stale.join(", ")}`
729
+ );
730
+ }
731
+ }
732
+ async function promoteStagingToOutDir(stagingDir, outDir) {
733
+ await assertNoLegacyPromoteArtifacts(outDir);
734
+ const parent = dirname(outDir);
735
+ const tmpPromote = await fsp.mkdtemp(join4(parent, ".lk-promote-"));
736
+ await renameOrCopy(stagingDir, tmpPromote);
737
+ const hadOutDir = await pathExists(outDir);
738
+ const backup = hadOutDir ? await fsp.mkdtemp(join4(parent, ".lk-backup-")) : void 0;
739
+ if (hadOutDir && backup) {
740
+ await renameOrCopy(outDir, backup);
741
+ }
742
+ try {
743
+ await renameOrCopy(tmpPromote, outDir);
744
+ } catch (promoteError) {
745
+ if (hadOutDir && backup) {
746
+ try {
747
+ await renameOrCopy(backup, outDir);
748
+ } catch (restoreError) {
749
+ const failedPromote2 = join4(parent, `.lk-failed-promote-${randomUUID()}`);
750
+ try {
751
+ await renameOrCopy(tmpPromote, failedPromote2);
752
+ } catch {
753
+ await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(() => void 0);
754
+ }
755
+ const promoteMsg = promoteError instanceof Error ? promoteError.message : String(promoteError);
756
+ const restoreMsg = restoreError instanceof Error ? restoreError.message : String(restoreError);
757
+ throw new Error(
758
+ `[lessonkit/lxpack] promote failed (${promoteMsg}) and could not restore ${outDir} (${restoreMsg}). Recovery: previous output may be in ${backup}; staged package may be in ${failedPromote2}.`
759
+ );
760
+ }
761
+ } else {
762
+ try {
763
+ await renameOrCopy(tmpPromote, stagingDir);
764
+ } catch (restoreError) {
765
+ console.warn(
766
+ `[lessonkit/lxpack] failed to restore ${stagingDir} after promote error:`,
767
+ restoreError instanceof Error ? restoreError.message : restoreError
768
+ );
769
+ await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(() => void 0);
770
+ }
771
+ throw promoteError;
772
+ }
773
+ const failedPromote = join4(parent, `.lk-failed-promote-${randomUUID()}`);
774
+ try {
775
+ await renameOrCopy(tmpPromote, failedPromote);
776
+ } catch {
777
+ await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(() => void 0);
778
+ }
779
+ throw promoteError;
780
+ }
781
+ if (backup) {
782
+ await fsp.rm(backup, { recursive: true, force: true }).catch(() => void 0);
783
+ }
784
+ }
785
+
786
+ // src/packaging/staging.ts
787
+ import * as fsp2 from "fs/promises";
788
+ import { dirname as dirname2, join as join5 } from "path";
789
+ import { tmpdir } from "os";
790
+ import { packageLessonkit } from "@lxpack/api";
791
+ async function buildStagingPackage(options) {
792
+ const { target, output, dir, outputBaseDir, descriptor, ...writeOpts } = options;
793
+ const stagingDir = await fsp2.mkdtemp(join5(tmpdir(), "lessonkit-lxpack-"));
536
794
  try {
537
795
  let spaDirs;
538
796
  try {
@@ -540,8 +798,7 @@ async function packageLessonkitCourse(options) {
540
798
  } catch (err) {
541
799
  return {
542
800
  ok: false,
543
- courseDir: outDir,
544
- target,
801
+ stagingDir,
545
802
  issues: [
546
803
  {
547
804
  path: "spaDirs",
@@ -552,8 +809,8 @@ async function packageLessonkitCourse(options) {
552
809
  }
553
810
  const interchange = descriptorToInterchange(descriptor);
554
811
  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`));
812
+ await fsp2.mkdir(join5(stagingDir, outputBase), { recursive: true });
813
+ const defaultOutput = output ?? (dir ? join5(outputBase, target) : join5(outputBase, `course-${target}.zip`));
557
814
  const build = await packageLessonkit({
558
815
  interchange,
559
816
  spaDirs,
@@ -566,15 +823,9 @@ async function packageLessonkitCourse(options) {
566
823
  writeAuthoringFiles: true
567
824
  });
568
825
  if (!build.ok) {
569
- const validation2 = {
570
- ok: false,
571
- issues: build.issues
572
- };
573
826
  return {
574
827
  ok: false,
575
- courseDir: outDir,
576
- target,
577
- validation: validation2,
828
+ stagingDir,
578
829
  build,
579
830
  issues: build.issues.map((i) => ({
580
831
  path: i.path,
@@ -583,48 +834,256 @@ async function packageLessonkitCourse(options) {
583
834
  }))
584
835
  };
585
836
  }
586
- const validation = {
837
+ return {
587
838
  ok: true,
588
- manifest: build.manifest,
589
- issues: build.issues
839
+ stagingDir,
840
+ build,
841
+ outputPath: "outputPath" in build ? build.outputPath : void 0,
842
+ outputDir: "outputDir" in build ? build.outputDir : void 0
590
843
  };
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;
844
+ } catch (err) {
845
+ await fsp2.rm(stagingDir, { recursive: true, force: true }).catch(() => void 0);
846
+ throw err;
847
+ }
848
+ }
849
+ async function ensureOutDirParent(outDir) {
850
+ await fsp2.mkdir(dirname2(outDir), { recursive: true });
851
+ }
852
+
853
+ // src/packageCourse.ts
854
+ async function validateLessonkitProject(options) {
855
+ return validateCourse({
856
+ courseDir: resolve6(options.courseDir),
857
+ target: options.target
858
+ });
859
+ }
860
+ async function buildLessonkitProject(options) {
861
+ const buildOptions = {
862
+ courseDir: resolve6(options.courseDir),
863
+ target: options.target,
864
+ output: options.output,
865
+ dir: options.dir,
866
+ outputBaseDir: options.outputBaseDir,
867
+ assessments: options.assessments
868
+ };
869
+ return buildCourse(buildOptions);
870
+ }
871
+ async function packageLessonkitCourse(options) {
872
+ const { target, output, dir, outputBaseDir, ...writeOpts } = options;
873
+ const inputValidation = validatePackageInputs({
874
+ target,
875
+ output,
876
+ outputBaseDir,
877
+ outDir: writeOpts.outDir,
878
+ projectRoot: writeOpts.projectRoot
879
+ });
880
+ if (!inputValidation.ok) {
881
+ return {
882
+ ok: false,
883
+ courseDir: inputValidation.courseDir,
884
+ target: inputValidation.target,
885
+ issues: inputValidation.issues
599
886
  };
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 });
887
+ }
888
+ const outDir = inputValidation.outDir;
889
+ const descriptorValidation = validateDescriptorForTarget(writeOpts.descriptor, target);
890
+ if (!descriptorValidation.ok) {
891
+ return {
892
+ ok: false,
893
+ courseDir: outDir,
894
+ target,
895
+ issues: descriptorValidation.issues.map((i) => ({
896
+ path: i.path,
897
+ message: i.message
898
+ }))
899
+ };
900
+ }
901
+ const descriptor = descriptorValidation.descriptor;
902
+ const staged = await buildStagingPackage({
903
+ ...writeOpts,
904
+ descriptor,
905
+ target,
906
+ output,
907
+ dir,
908
+ outputBaseDir
909
+ });
910
+ if (!staged.ok) {
911
+ await fsp3.rm(staged.stagingDir, { recursive: true, force: true }).catch(() => void 0);
912
+ const validation2 = staged.build ? { ok: false, issues: staged.build.issues } : void 0;
913
+ return {
914
+ ok: false,
915
+ courseDir: outDir,
916
+ target,
917
+ validation: validation2,
918
+ build: staged.build,
919
+ issues: staged.issues
920
+ };
921
+ }
922
+ const { stagingDir, build } = staged;
923
+ const stagingRoot = await fsp3.realpath(stagingDir);
924
+ const artifactIssues = [
925
+ validateArtifactInStaging(stagingRoot, staged.outputPath, "outputPath"),
926
+ validateArtifactInStaging(stagingRoot, staged.outputDir, "outputDir")
927
+ ].filter((issue) => issue != null);
928
+ if (artifactIssues.length > 0) {
929
+ await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(() => void 0);
930
+ return {
931
+ ok: false,
932
+ courseDir: outDir,
933
+ target,
934
+ validation: { ok: true, manifest: build.manifest, issues: build.issues },
935
+ build,
936
+ issues: artifactIssues
937
+ };
938
+ }
939
+ const remappedOutputPath = remapArtifactPaths(stagingRoot, outDir, staged.outputPath);
940
+ const remappedOutputDir = remapArtifactPaths(stagingRoot, outDir, staged.outputDir);
941
+ const validation = {
942
+ ok: true,
943
+ manifest: build.manifest,
944
+ issues: build.issues
945
+ };
946
+ try {
947
+ await ensureOutDirParent(outDir);
605
948
  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;
613
- }
949
+ } catch (err) {
614
950
  return {
615
- ok: true,
951
+ ok: false,
616
952
  courseDir: outDir,
617
953
  target,
618
- outputPath: remappedOutputPath,
619
- outputDir: remappedOutputDir,
620
- fileCount: build.fileCount,
621
954
  validation,
622
- build: remappedBuild
955
+ build,
956
+ issues: [
957
+ {
958
+ path: "promote",
959
+ message: err instanceof Error ? err.message : String(err)
960
+ }
961
+ ]
623
962
  };
624
- } finally {
625
- if (!promoted) {
626
- await fsp.rm(stagingDir, { recursive: true, force: true }).catch(() => void 0);
963
+ }
964
+ const remappedBuild = { ...build };
965
+ if ("outputPath" in remappedBuild && remappedOutputPath !== void 0) {
966
+ remappedBuild.outputPath = remappedOutputPath;
967
+ }
968
+ if ("outputDir" in remappedBuild && remappedOutputDir !== void 0) {
969
+ remappedBuild.outputDir = remappedOutputDir;
970
+ }
971
+ return {
972
+ ok: true,
973
+ courseDir: outDir,
974
+ target,
975
+ outputPath: remappedOutputPath,
976
+ outputDir: remappedOutputDir,
977
+ fileCount: build.fileCount,
978
+ validation,
979
+ build: remappedBuild
980
+ };
981
+ }
982
+
983
+ // src/manifest.ts
984
+ var DEFAULT_PATHS = {
985
+ spaDistDir: "dist",
986
+ lxpackOutDir: ".lxpack/course",
987
+ outputBaseDir: ".lxpack/out"
988
+ };
989
+ function parseLessonkitManifest(raw, label = "lessonkit.json", projectRoot) {
990
+ if (!raw || typeof raw !== "object") {
991
+ return { ok: false, issues: [{ path: label, message: "must be a JSON object" }] };
992
+ }
993
+ const config = raw;
994
+ const issues = [];
995
+ let schemaVersion = config.schemaVersion;
996
+ if (schemaVersion === "1") {
997
+ schemaVersion = 1;
998
+ }
999
+ if (schemaVersion !== 1) {
1000
+ issues.push({
1001
+ path: "schemaVersion",
1002
+ message: `must be 1 (got ${String(config.schemaVersion)})`
1003
+ });
1004
+ }
1005
+ const nameRaw = config.name;
1006
+ const name = typeof nameRaw === "string" ? nameRaw.trim() : "";
1007
+ if (!name) {
1008
+ issues.push({ path: "name", message: "must be a non-empty string" });
1009
+ }
1010
+ const courseRaw = config.course;
1011
+ if (Array.isArray(courseRaw)) {
1012
+ issues.push({ path: "course", message: "must be an object, not an array" });
1013
+ return { ok: false, issues };
1014
+ }
1015
+ if (!courseRaw || typeof courseRaw !== "object") {
1016
+ issues.push({ path: "course", message: "must be an object" });
1017
+ return { ok: false, issues };
1018
+ }
1019
+ const courseObj = courseRaw;
1020
+ if (courseObj.lessons !== void 0 && !Array.isArray(courseObj.lessons)) {
1021
+ issues.push({ path: "course.lessons", message: "must be an array" });
1022
+ }
1023
+ if (courseObj.assessments !== void 0 && !Array.isArray(courseObj.assessments)) {
1024
+ issues.push({ path: "course.assessments", message: "must be an array" });
1025
+ }
1026
+ if (issues.length) return { ok: false, issues };
1027
+ const validation = validateDescriptor(courseRaw);
1028
+ if (!validation.ok) {
1029
+ for (const i of validation.issues) {
1030
+ issues.push({
1031
+ path: i.path.startsWith("course.") ? i.path : `course.${i.path}`,
1032
+ message: i.message
1033
+ });
627
1034
  }
1035
+ } else if (validation.descriptor.layout === "per-lesson-spa") {
1036
+ issues.push({
1037
+ path: "course.layout",
1038
+ message: "per-lesson-spa is not supported by lessonkit package yet. Use single-spa or package via @lessonkit/lxpack directly."
1039
+ });
1040
+ }
1041
+ const paths = { ...DEFAULT_PATHS };
1042
+ const pathsRaw = config.paths;
1043
+ if (pathsRaw !== void 0 && (typeof pathsRaw !== "object" || pathsRaw === null)) {
1044
+ issues.push({ path: "paths", message: "must be an object" });
1045
+ } else if (pathsRaw && typeof pathsRaw === "object") {
1046
+ const p = pathsRaw;
1047
+ for (const key of ["spaDistDir", "lxpackOutDir", "outputBaseDir"]) {
1048
+ if (p[key] !== void 0) {
1049
+ if (typeof p[key] !== "string" || !p[key].trim()) {
1050
+ issues.push({ path: `paths.${key}`, message: "must be a non-empty string" });
1051
+ } else {
1052
+ paths[key] = p[key].trim();
1053
+ }
1054
+ }
1055
+ }
1056
+ }
1057
+ const courseSpaDistDir = validation.ok ? validation.descriptor.spaDistDir?.trim() : void 0;
1058
+ if (courseSpaDistDir && courseSpaDistDir !== paths.spaDistDir) {
1059
+ issues.push({
1060
+ path: "course.spaDistDir",
1061
+ message: `"course.spaDistDir" (${courseSpaDistDir}) differs from "paths.spaDistDir" (${paths.spaDistDir}). Use paths.spaDistDir for CLI build and package.`
1062
+ });
1063
+ }
1064
+ if (projectRoot) {
1065
+ const pathIssues = validateProjectPaths(projectRoot, paths);
1066
+ for (const pi of pathIssues) {
1067
+ issues.push({ path: pi.path, message: pi.message });
1068
+ }
1069
+ }
1070
+ if (issues.length) return { ok: false, issues };
1071
+ if (!validation.ok) return { ok: false, issues };
1072
+ return {
1073
+ ok: true,
1074
+ manifest: {
1075
+ schemaVersion: 1,
1076
+ name,
1077
+ course: validation.descriptor,
1078
+ paths
1079
+ }
1080
+ };
1081
+ }
1082
+ async function loadLessonkitManifestFromFile(readJson, label = "lessonkit.json", projectRoot) {
1083
+ try {
1084
+ return parseLessonkitManifest(await readJson(), label, projectRoot);
1085
+ } catch {
1086
+ return { ok: false, issues: [{ path: label, message: "failed to read or parse JSON" }] };
628
1087
  }
629
1088
  }
630
1089
 
@@ -643,21 +1102,29 @@ export {
643
1102
  LESSONKIT_TELEMETRY_EVENTS,
644
1103
  assessmentDescriptorToLxpack,
645
1104
  buildLessonkitProject,
1105
+ buildStagingPackage,
646
1106
  descriptorToInterchange,
1107
+ ensureOutDirParent,
647
1108
  extractAssessments,
648
1109
  lessonkitInterchangeSchema,
1110
+ loadLessonkitManifestFromFile,
649
1111
  mapLessonkitIds,
650
1112
  mapLessonkitTelemetryToBridgeAction,
651
1113
  mapLessonkitTelemetryToLxpack,
652
1114
  materializeLessonkitProject2 as materializeLessonkitProject,
653
1115
  packageLessonkitCourse,
654
1116
  parseLessonkitInterchange,
1117
+ parseLessonkitManifest,
1118
+ promoteStagingToOutDir,
1119
+ remapArtifactPaths,
655
1120
  resolveSafePackageOutputOverride,
656
1121
  resolveSpaLessons,
657
1122
  telemetryEventToLessonkit,
658
1123
  themeToLxpackRuntime,
659
1124
  validateDescriptor,
1125
+ validateDescriptorForTarget,
660
1126
  validateLessonkitProject,
1127
+ validatePackageInputs,
661
1128
  validateProjectPaths,
662
1129
  writeLxpackProject
663
1130
  };