@lessonkit/lxpack 1.6.0 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -63,6 +63,7 @@ __export(index_exports, {
63
63
  validateLessonkitProject: () => validateLessonkitProject,
64
64
  validateLkcourse: () => validateLkcourse,
65
65
  validateLkcourseArchiveEntries: () => validateLkcourseArchiveEntries,
66
+ validateManifestName: () => validateManifestName,
66
67
  validatePackageInputs: () => validatePackageInputs,
67
68
  validateProjectPaths: () => validateProjectPaths,
68
69
  validateReactManifestParity: () => validateReactManifestParity,
@@ -130,13 +131,40 @@ function normalizeDescriptor(input) {
130
131
  correctTargetIds: assessment.correctTargetIds.map((id) => id.trim()).filter((id) => id.length > 0)
131
132
  };
132
133
  }
134
+ if (assessment.kind === "sortParagraphs") {
135
+ return {
136
+ ...assessment,
137
+ checkId: check.id,
138
+ question,
139
+ paragraphs: assessment.paragraphs.map((p) => p.trim()).filter((p) => p.length > 0),
140
+ correctOrder: [...assessment.correctOrder]
141
+ };
142
+ }
143
+ if (assessment.kind === "guessTheAnswer") {
144
+ return {
145
+ ...assessment,
146
+ checkId: check.id,
147
+ question,
148
+ answer: assessment.answer.trim()
149
+ };
150
+ }
151
+ if (assessment.kind === "multimediaChoice") {
152
+ return {
153
+ ...assessment,
154
+ checkId: check.id,
155
+ question,
156
+ choices: assessment.choices.map((c) => c.trim()).filter((c) => c.length > 0),
157
+ answer: assessment.answer.trim()
158
+ };
159
+ }
133
160
  const mcq = assessment;
134
161
  return {
135
162
  ...mcq,
136
163
  checkId: check.id,
137
164
  question,
138
165
  choices: mcq.choices.map((c) => c.trim()).filter((c) => c.length > 0),
139
- answer: mcq.answer.trim()
166
+ answer: mcq.answer.trim(),
167
+ answers: mcq.answers?.map((a) => a.trim()).filter((a) => a.length > 0)
140
168
  };
141
169
  })
142
170
  };
@@ -210,7 +238,30 @@ function parseAssessmentDescriptor(raw) {
210
238
  correctTargetIds: Array.isArray(raw.correctTargetIds) ? raw.correctTargetIds.filter((id) => typeof id === "string") : []
211
239
  };
212
240
  }
213
- if (typeof kind === "string" && kind !== "mcq" && kind !== "trueFalse" && kind !== "fillInBlanks" && kind !== "findHotspot" && kind !== "findMultipleHotspots") {
241
+ if (kind === "sortParagraphs") {
242
+ return {
243
+ kind: "sortParagraphs",
244
+ ...base,
245
+ paragraphs: Array.isArray(raw.paragraphs) ? raw.paragraphs.filter((p) => typeof p === "string") : [],
246
+ correctOrder: Array.isArray(raw.correctOrder) ? raw.correctOrder.filter((n) => typeof n === "number" && Number.isFinite(n)) : []
247
+ };
248
+ }
249
+ if (kind === "guessTheAnswer") {
250
+ return {
251
+ kind: "guessTheAnswer",
252
+ ...base,
253
+ answer: typeof raw.answer === "string" ? raw.answer : ""
254
+ };
255
+ }
256
+ if (kind === "multimediaChoice") {
257
+ return {
258
+ kind: "multimediaChoice",
259
+ ...base,
260
+ choices: Array.isArray(raw.choices) ? raw.choices.filter((c) => typeof c === "string") : [],
261
+ answer: typeof raw.answer === "string" ? raw.answer : ""
262
+ };
263
+ }
264
+ if (typeof kind === "string" && kind !== "mcq" && kind !== "trueFalse" && kind !== "fillInBlanks" && kind !== "findHotspot" && kind !== "findMultipleHotspots" && kind !== "sortParagraphs" && kind !== "guessTheAnswer" && kind !== "multimediaChoice") {
214
265
  return {
215
266
  kind,
216
267
  ...base,
@@ -222,7 +273,15 @@ function parseAssessmentDescriptor(raw) {
222
273
  kind: kind === "mcq" ? "mcq" : void 0,
223
274
  ...base,
224
275
  choices: Array.isArray(raw.choices) ? raw.choices.filter((c) => typeof c === "string") : [],
225
- answer: typeof raw.answer === "string" ? raw.answer : ""
276
+ answer: typeof raw.answer === "string" ? raw.answer : "",
277
+ answers: Array.isArray(raw.answers) ? raw.answers.filter((a) => typeof a === "string") : void 0,
278
+ shuffleChoices: typeof raw.shuffleChoices === "boolean" ? raw.shuffleChoices : void 0,
279
+ shuffleSeed: typeof raw.shuffleSeed === "string" || typeof raw.shuffleSeed === "number" ? raw.shuffleSeed : void 0,
280
+ choiceFeedback: raw.choiceFeedback && typeof raw.choiceFeedback === "object" && !Array.isArray(raw.choiceFeedback) ? Object.fromEntries(
281
+ Object.entries(raw.choiceFeedback).filter(
282
+ (entry) => typeof entry[1] === "string"
283
+ )
284
+ ) : void 0
226
285
  };
227
286
  }
228
287
  function parseCourseDescriptorInput(input) {
@@ -266,7 +325,7 @@ function parseCourseDescriptorInput(input) {
266
325
  }
267
326
 
268
327
  // src/descriptor/validateCourse.ts
269
- var import_core3 = require("@lessonkit/core");
328
+ var import_core4 = require("@lessonkit/core");
270
329
  var import_themes2 = require("@lessonkit/themes");
271
330
 
272
331
  // src/spaPath.ts
@@ -297,43 +356,32 @@ function assertResolvedPathUnderRoot(root, target) {
297
356
  throw new Error(`unsafe path escapes project root: ${target}`);
298
357
  }
299
358
  }
300
- function resolveExistingPathUnderRoot(rootReal, rootResolved, targetResolved) {
301
- const rel = (0, import_node_path.relative)(rootResolved, targetResolved);
302
- if (rel.startsWith("..") || rel.includes(`..${import_node_path.sep}`)) {
303
- throw new Error(`unsafe path escapes project root: ${targetResolved}`);
304
- }
305
- const segments = rel.split(/[/\\]/).filter((s) => s.length > 0 && s !== ".");
306
- let current = rootReal;
307
- for (const segment of segments) {
308
- const next = (0, import_node_path.join)(current, segment);
309
- if ((0, import_node_fs.existsSync)(next)) {
359
+ function resolvePhysicalPathForCheck(p) {
360
+ const resolved = resolveComparablePath(p);
361
+ try {
362
+ return import_node_fs.realpathSync.native(resolved);
363
+ } catch {
364
+ let probe = resolved;
365
+ let suffix = "";
366
+ while (true) {
310
367
  try {
311
- current = (0, import_node_fs.realpathSync)(next);
368
+ const physical = import_node_fs.realpathSync.native(probe);
369
+ return suffix ? (0, import_node_path.join)(physical, suffix) : physical;
312
370
  } catch {
313
- current = next;
371
+ if (probe === (0, import_node_path.dirname)(probe)) {
372
+ return resolved;
373
+ }
374
+ const segment = (0, import_node_path.basename)(probe);
375
+ suffix = suffix ? (0, import_node_path.join)(segment, suffix) : segment;
376
+ probe = (0, import_node_path.dirname)(probe);
314
377
  }
315
- } else {
316
- current = next;
317
378
  }
318
- assertResolvedPathUnderRoot(rootReal, current);
319
379
  }
320
- return current;
321
380
  }
322
381
  function assertRealPathUnderRoot(root, target) {
323
- const rootResolved = resolveComparablePath(root);
324
- const targetResolved = resolveComparablePath(target);
325
- let rootReal;
326
- try {
327
- rootReal = (0, import_node_fs.realpathSync)(rootResolved);
328
- } catch {
329
- rootReal = rootResolved;
330
- }
331
- try {
332
- const targetCheck = (0, import_node_fs.realpathSync)(targetResolved);
333
- assertResolvedPathUnderRoot(rootReal, targetCheck);
334
- } catch {
335
- resolveExistingPathUnderRoot(rootReal, rootResolved, targetResolved);
336
- }
382
+ const rootPhysical = resolvePhysicalPathForCheck(root);
383
+ const targetPhysical = resolvePhysicalPathForCheck(target);
384
+ assertResolvedPathUnderRoot(rootPhysical, targetPhysical);
337
385
  }
338
386
  function normalizePathForComparison(p) {
339
387
  const resolved = resolveComparablePath(p);
@@ -367,6 +415,21 @@ function isReservedOutputPath(value) {
367
415
  const segments = normalized.split("/").filter(Boolean);
368
416
  return segments.some((segment) => RESERVED_OUTPUT_SEGMENTS.has(segment));
369
417
  }
418
+ function validateManifestName(name) {
419
+ if (!name.length) {
420
+ return "must be a non-empty string";
421
+ }
422
+ if (name.includes("/") || name.includes("\\")) {
423
+ return "must not contain path separators";
424
+ }
425
+ if (!isSafeRelativeSpaPath(name)) {
426
+ return "must be a safe relative name without '..' segments or absolute prefixes";
427
+ }
428
+ if (isReservedOutputPath(name) || isReservedOutputPath(`${name}.lkcourse`)) {
429
+ return "must not target reserved directories (.git, node_modules, .github)";
430
+ }
431
+ return null;
432
+ }
370
433
  function isReservedResolvedOutputPath(projectRoot, resolved) {
371
434
  const rootResolved = resolveComparablePath(projectRoot);
372
435
  const targetResolved = resolveComparablePath(resolved);
@@ -465,6 +528,7 @@ function themeToLxpackRuntime(input) {
465
528
 
466
529
  // src/descriptor/validateAssessments.ts
467
530
  var import_core2 = require("@lessonkit/core");
531
+ var import_core3 = require("@lessonkit/core");
468
532
  var validateMcqLike = (assessment, path, issues) => {
469
533
  if (!("choices" in assessment) || !Array.isArray(assessment.choices)) {
470
534
  issues.push({ path: `${path}.choices`, message: "choices is required for mcq" });
@@ -480,9 +544,44 @@ var validateMcqLike = (assessment, path, issues) => {
480
544
  }
481
545
  if (!assessment.answer.trim()) {
482
546
  issues.push({ path: `${path}.answer`, message: "answer is required" });
483
- } else if (trimmedChoices.length && !trimmedChoices.includes(assessment.answer.trim())) {
547
+ } else if (!("answers" in assessment && (0, import_core3.isMultiSelectMcq)({ answers: assessment.answers })) && trimmedChoices.length && !trimmedChoices.includes(assessment.answer.trim())) {
484
548
  issues.push({ path: `${path}.answer`, message: "answer must match a choice" });
485
549
  }
550
+ if ("answers" in assessment && assessment.answers !== void 0) {
551
+ if (!Array.isArray(assessment.answers)) {
552
+ issues.push({ path: `${path}.answers`, message: "answers must be an array when provided" });
553
+ } else {
554
+ const trimmedAnswers = assessment.answers.map((a) => a.trim()).filter((a) => a.length > 0);
555
+ if (assessment.answers.length > 0 && trimmedAnswers.length === 0) {
556
+ issues.push({ path: `${path}.answers`, message: "answers must include non-empty strings" });
557
+ }
558
+ const uniqueAnswers = new Set(trimmedAnswers);
559
+ if (trimmedAnswers.length !== uniqueAnswers.size) {
560
+ issues.push({ path: `${path}.answers`, message: "answers must be unique" });
561
+ }
562
+ for (const ans of trimmedAnswers) {
563
+ if (trimmedChoices.length && !trimmedChoices.includes(ans)) {
564
+ issues.push({ path: `${path}.answers`, message: "each answer must match a choice" });
565
+ break;
566
+ }
567
+ }
568
+ }
569
+ }
570
+ if ("choiceFeedback" in assessment && assessment.choiceFeedback !== void 0) {
571
+ if (typeof assessment.choiceFeedback !== "object" || assessment.choiceFeedback === null) {
572
+ issues.push({ path: `${path}.choiceFeedback`, message: "choiceFeedback must be an object" });
573
+ } else {
574
+ for (const key of Object.keys(assessment.choiceFeedback)) {
575
+ if (!trimmedChoices.includes(key.trim())) {
576
+ issues.push({
577
+ path: `${path}.choiceFeedback`,
578
+ message: "choiceFeedback keys must match choice labels"
579
+ });
580
+ break;
581
+ }
582
+ }
583
+ }
584
+ }
486
585
  const uniqueChoices = new Set(trimmedChoices);
487
586
  if (trimmedChoices.length !== uniqueChoices.size) {
488
587
  issues.push({ path: `${path}.choices`, message: "choices must be unique" });
@@ -502,6 +601,12 @@ function maxAchievableAssessmentScore(assessment) {
502
601
  if (kind === "findMultipleHotspots" && assessment.kind === "findMultipleHotspots") {
503
602
  return assessment.correctTargetIds?.map((id) => id.trim()).filter((id) => id.length > 0).length ?? 0;
504
603
  }
604
+ if (kind === "sortParagraphs" && assessment.kind === "sortParagraphs") {
605
+ return assessment.paragraphs?.length ?? assessment.correctOrder?.length ?? 0;
606
+ }
607
+ if ("answers" in assessment && Array.isArray(assessment.answers) && assessment.answers.length > 1) {
608
+ return assessment.answers.filter((a) => a.trim().length > 0).length;
609
+ }
505
610
  return 1;
506
611
  }
507
612
  var ASSESSMENT_VALIDATORS = {
@@ -587,7 +692,31 @@ var ASSESSMENT_VALIDATORS = {
587
692
  message: "at least one non-empty correctTargetId is required for findMultipleHotspots"
588
693
  });
589
694
  }
590
- }
695
+ },
696
+ sortParagraphs: (assessment, path, issues) => {
697
+ if (assessment.kind !== "sortParagraphs") return;
698
+ if (!Array.isArray(assessment.paragraphs) || assessment.paragraphs.length === 0) {
699
+ issues.push({ path: `${path}.paragraphs`, message: "paragraphs is required for sortParagraphs" });
700
+ return;
701
+ }
702
+ if (!Array.isArray(assessment.correctOrder) || assessment.correctOrder.length === 0) {
703
+ issues.push({ path: `${path}.correctOrder`, message: "correctOrder is required for sortParagraphs" });
704
+ return;
705
+ }
706
+ if (assessment.correctOrder.length !== assessment.paragraphs.length) {
707
+ issues.push({
708
+ path: `${path}.correctOrder`,
709
+ message: "correctOrder length must match paragraphs length for sortParagraphs"
710
+ });
711
+ }
712
+ },
713
+ guessTheAnswer: (assessment, path, issues) => {
714
+ if (assessment.kind !== "guessTheAnswer") return;
715
+ if (!assessment.answer?.trim()) {
716
+ issues.push({ path: `${path}.answer`, message: "answer is required for guessTheAnswer" });
717
+ }
718
+ },
719
+ multimediaChoice: validateMcqLike
591
720
  };
592
721
  function validateAssessmentEntry(assessment, index, issues, checkIds) {
593
722
  const path = `assessments[${index}]`;
@@ -642,7 +771,7 @@ var VALID_LAYOUTS = ["single-spa", "per-lesson-spa"];
642
771
  var VALID_THEME_PRESETS = ["default", "light", "dark", "brand"];
643
772
  function validateCourseDescriptor(input) {
644
773
  const issues = [];
645
- const course = (0, import_core3.validateId)(input.courseId, "courseId");
774
+ const course = (0, import_core4.validateId)(input.courseId, "courseId");
646
775
  if (!course.ok) issues.push(...course.issues.map((i) => ({ path: i.path, message: i.message })));
647
776
  if (!input.title?.trim()) {
648
777
  issues.push({ path: "title", message: "title is required" });
@@ -719,7 +848,7 @@ function validateCourseDescriptor(input) {
719
848
  const spaPaths = /* @__PURE__ */ new Set();
720
849
  for (const [index, lesson] of (input.lessons ?? []).entries()) {
721
850
  const path = `lessons[${index}]`;
722
- const lessonResult = (0, import_core3.validateId)(lesson.id, `${path}.id`);
851
+ const lessonResult = (0, import_core4.validateId)(lesson.id, `${path}.id`);
723
852
  if (!lessonResult.ok) {
724
853
  issues.push(...lessonResult.issues.map((i) => ({ path: i.path, message: i.message })));
725
854
  } else if (lessonIds.has(lessonResult.id)) {
@@ -751,7 +880,7 @@ function validateCourseDescriptor(input) {
751
880
  }
752
881
  if (layout === "single-spa" && input.spaLessonId?.trim()) {
753
882
  const spaId = input.spaLessonId.trim();
754
- const spaResult = (0, import_core3.validateId)(spaId, "spaLessonId");
883
+ const spaResult = (0, import_core4.validateId)(spaId, "spaLessonId");
755
884
  if (!spaResult.ok) {
756
885
  issues.push(...spaResult.issues.map((i) => ({ path: i.path, message: i.message })));
757
886
  } else if (!lessonIds.has(spaResult.id)) {
@@ -769,6 +898,7 @@ function validateCourseDescriptor(input) {
769
898
  }
770
899
 
771
900
  // src/assessments.ts
901
+ var import_core5 = require("@lessonkit/core");
772
902
  var DEFAULT_SHELL_PASSING_SCORE = 1;
773
903
  function escapeShellText(text) {
774
904
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
@@ -794,6 +924,7 @@ function mcqToLxpack(assessment) {
794
924
  const prompt = sanitizeShellField(assessment.question);
795
925
  if (!checkId || !prompt) return null;
796
926
  const normalizedAnswer = assessment.answer.trim();
927
+ const multiCorrect = assessment.answers && assessment.answers.length > 1 ? new Set(assessment.answers.map((a) => a.trim())) : /* @__PURE__ */ new Set([normalizedAnswer]);
797
928
  const choices = assessment.choices.map((text, index) => {
798
929
  const sanitizedText = sanitizeShellField(text);
799
930
  if (!sanitizedText) return null;
@@ -801,17 +932,21 @@ function mcqToLxpack(assessment) {
801
932
  return {
802
933
  id,
803
934
  text: sanitizedText,
804
- correct: text.trim() === normalizedAnswer
935
+ correct: multiCorrect.has(text.trim())
805
936
  };
806
937
  });
807
938
  if (choices.some((choice) => choice === null)) return null;
939
+ const multiSelect = (0, import_core5.isMultiSelectMcq)(assessment);
808
940
  return {
809
941
  id: checkId,
810
942
  passingScore: assessment.passingScore ?? DEFAULT_SHELL_PASSING_SCORE,
943
+ shuffleChoices: assessment.shuffleChoices === true ? true : void 0,
944
+ showFeedback: assessment.choiceFeedback && Object.keys(assessment.choiceFeedback).length > 0 ? "immediate" : void 0,
811
945
  questions: [
812
946
  {
813
947
  id: "q1",
814
948
  prompt,
949
+ ...multiSelect ? { selectionMode: "multiple" } : {},
815
950
  choices
816
951
  }
817
952
  ]
@@ -840,7 +975,20 @@ function assessmentDescriptorToLxpack(assessment) {
840
975
  if (kind === "findMultipleHotspots") {
841
976
  return null;
842
977
  }
843
- if ("choices" in assessment && "answer" in assessment && typeof assessment.answer === "string") {
978
+ if (kind === "sortParagraphs" || kind === "guessTheAnswer") {
979
+ return null;
980
+ }
981
+ if (kind === "multimediaChoice" && assessment.kind === "multimediaChoice") {
982
+ return mcqToLxpack({
983
+ kind: "mcq",
984
+ checkId: assessment.checkId,
985
+ question: assessment.question,
986
+ choices: assessment.choices,
987
+ answer: assessment.answer,
988
+ passingScore: assessment.passingScore
989
+ });
990
+ }
991
+ if ((kind === "mcq" || assessment.kind === void 0) && "choices" in assessment && "answer" in assessment && typeof assessment.answer === "string") {
844
992
  return mcqToLxpack(assessment);
845
993
  }
846
994
  return null;
@@ -852,7 +1000,13 @@ function extractAssessments(descriptor) {
852
1000
  // src/descriptor/validateInjectableAssessments.ts
853
1001
  function validateInjectableAssessments(descriptor) {
854
1002
  const issues = [];
855
- const spaOnlyKinds = /* @__PURE__ */ new Set(["fillInBlanks", "findHotspot", "findMultipleHotspots"]);
1003
+ const spaOnlyKinds = /* @__PURE__ */ new Set([
1004
+ "fillInBlanks",
1005
+ "findHotspot",
1006
+ "findMultipleHotspots",
1007
+ "sortParagraphs",
1008
+ "guessTheAnswer"
1009
+ ]);
856
1010
  (descriptor.assessments ?? []).forEach((assessment, index) => {
857
1011
  if (assessmentDescriptorToLxpack(assessment) === null) {
858
1012
  const kind = assessment.kind ?? "mcq";
@@ -1174,12 +1328,12 @@ function validateReactManifestParity(opts) {
1174
1328
  }
1175
1329
 
1176
1330
  // src/mapIds.ts
1177
- var import_core4 = require("@lessonkit/core");
1331
+ var import_core6 = require("@lessonkit/core");
1178
1332
  function mapLessonkitIds(descriptor) {
1179
- const courseId = (0, import_core4.assertValidId)(descriptor.courseId, "courseId");
1180
- const lessonIds = descriptor.lessons.map((l) => (0, import_core4.assertValidId)(l.id, "lessonId"));
1333
+ const courseId = (0, import_core6.assertValidId)(descriptor.courseId, "courseId");
1334
+ const lessonIds = descriptor.lessons.map((l) => (0, import_core6.assertValidId)(l.id, "lessonId"));
1181
1335
  const checkIds = (descriptor.assessments ?? []).map(
1182
- (a) => (0, import_core4.assertValidId)(a.checkId, "checkId")
1336
+ (a) => (0, import_core6.assertValidId)(a.checkId, "checkId")
1183
1337
  );
1184
1338
  return { courseId, lessonIds, checkIds };
1185
1339
  }
@@ -1398,8 +1552,8 @@ async function writeLxpackProject(options) {
1398
1552
  }
1399
1553
 
1400
1554
  // src/packageCourse.ts
1401
- var import_node_path10 = require("path");
1402
- var fsp3 = __toESM(require("fs/promises"), 1);
1555
+ var import_node_path11 = require("path");
1556
+ var fsp4 = __toESM(require("fs/promises"), 1);
1403
1557
  var import_api2 = require("@lxpack/api");
1404
1558
 
1405
1559
  // src/packaging/validateInputs.ts
@@ -1552,21 +1706,6 @@ function validatePackageInputs(options) {
1552
1706
  ]
1553
1707
  };
1554
1708
  }
1555
- try {
1556
- relativePathUnderRoot(outDir, resolvedOutput);
1557
- } catch {
1558
- return {
1559
- ok: false,
1560
- courseDir: outDir,
1561
- target,
1562
- issues: [
1563
- {
1564
- path: "output",
1565
- message: "output must resolve inside outDir"
1566
- }
1567
- ]
1568
- };
1569
- }
1570
1709
  }
1571
1710
  return { ok: true, outDir, projectRoot };
1572
1711
  }
@@ -1855,14 +1994,29 @@ async function promoteStagingToOutDir(stagingDir, outDir, options) {
1855
1994
  });
1856
1995
  }
1857
1996
 
1858
- // src/packaging/staging.ts
1997
+ // src/packaging/relocateOutput.ts
1859
1998
  var fsp2 = __toESM(require("fs/promises"), 1);
1860
1999
  var import_node_path9 = require("path");
2000
+ async function relocatePackageOutput(builtOutputPath, requestedOutputPath, projectRoot) {
2001
+ if (!builtOutputPath || !requestedOutputPath) return builtOutputPath;
2002
+ const resolvedBuilt = resolveComparablePath(builtOutputPath);
2003
+ const resolvedRequested = resolveComparablePath(requestedOutputPath);
2004
+ if (resolvedBuilt === resolvedRequested) return builtOutputPath;
2005
+ const root = (0, import_node_path9.resolve)(projectRoot);
2006
+ assertRealPathUnderRoot(root, resolvedRequested);
2007
+ await fsp2.mkdir((0, import_node_path9.dirname)(resolvedRequested), { recursive: true });
2008
+ await renameOrCopy(resolvedBuilt, resolvedRequested);
2009
+ return resolvedRequested;
2010
+ }
2011
+
2012
+ // src/packaging/staging.ts
2013
+ var fsp3 = __toESM(require("fs/promises"), 1);
2014
+ var import_node_path10 = require("path");
1861
2015
  var import_node_os = require("os");
1862
2016
  var import_api = require("@lxpack/api");
1863
2017
  async function buildStagingPackage(options) {
1864
2018
  const { target, output, dir, outputBaseDir, descriptor, ...writeOpts } = options;
1865
- const stagingDir = await fsp2.mkdtemp((0, import_node_path9.join)((0, import_node_os.tmpdir)(), "lessonkit-lxpack-"));
2019
+ const stagingDir = await fsp3.mkdtemp((0, import_node_path10.join)((0, import_node_os.tmpdir)(), "lessonkit-lxpack-"));
1866
2020
  let succeeded = false;
1867
2021
  try {
1868
2022
  let spaDirs;
@@ -1894,14 +2048,28 @@ async function buildStagingPackage(options) {
1894
2048
  }
1895
2049
  const interchange = descriptorToInterchange(descriptor);
1896
2050
  const outputBase = outputBaseDir ?? ".lxpack/out";
1897
- await fsp2.mkdir((0, import_node_path9.join)(stagingDir, outputBase), { recursive: true });
1898
- const defaultOutput = output ?? (dir ? (0, import_node_path9.join)(outputBase, target) : (0, import_node_path9.join)(outputBase, `course-${target}.zip`));
2051
+ await fsp3.mkdir((0, import_node_path10.join)(stagingDir, outputBase), { recursive: true });
2052
+ const defaultOutput = dir ? (0, import_node_path10.join)(outputBase, target) : (0, import_node_path10.join)(outputBase, `course-${target}.zip`);
2053
+ let packageOutput = output ?? defaultOutput;
2054
+ let requestedOutputPath;
2055
+ let requestedOutputDir;
2056
+ if (output) {
2057
+ const projectRoot = (0, import_node_path10.resolve)(writeOpts.projectRoot);
2058
+ const requested = (0, import_node_path10.isAbsolute)(output) ? (0, import_node_path10.resolve)(output) : (0, import_node_path10.resolve)(projectRoot, output);
2059
+ if (dir) {
2060
+ requestedOutputDir = requested;
2061
+ packageOutput = defaultOutput;
2062
+ } else {
2063
+ requestedOutputPath = requested;
2064
+ packageOutput = defaultOutput;
2065
+ }
2066
+ }
1899
2067
  const build = await (0, import_api.packageLessonkit)({
1900
2068
  interchange,
1901
2069
  spaDirs,
1902
2070
  target,
1903
2071
  courseDir: stagingDir,
1904
- output: defaultOutput,
2072
+ output: packageOutput,
1905
2073
  dir,
1906
2074
  outputBaseDir,
1907
2075
  outputAnchorDir: stagingDir,
@@ -1925,17 +2093,19 @@ async function buildStagingPackage(options) {
1925
2093
  stagingDir,
1926
2094
  build,
1927
2095
  outputPath: "outputPath" in build ? build.outputPath : void 0,
1928
- outputDir: "outputDir" in build ? build.outputDir : void 0
2096
+ outputDir: "outputDir" in build ? build.outputDir : void 0,
2097
+ requestedOutputPath,
2098
+ requestedOutputDir
1929
2099
  };
1930
2100
  } catch (err) {
1931
- await fsp2.rm(stagingDir, { recursive: true, force: true }).catch(
2101
+ await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
1932
2102
  /* v8 ignore next */
1933
2103
  () => void 0
1934
2104
  );
1935
2105
  throw err;
1936
2106
  } finally {
1937
2107
  if (!succeeded) {
1938
- await fsp2.rm(stagingDir, { recursive: true, force: true }).catch(
2108
+ await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
1939
2109
  /* v8 ignore next */
1940
2110
  () => void 0
1941
2111
  );
@@ -1943,7 +2113,7 @@ async function buildStagingPackage(options) {
1943
2113
  }
1944
2114
  }
1945
2115
  async function ensureOutDirParent(outDir) {
1946
- await fsp2.mkdir((0, import_node_path9.dirname)(outDir), { recursive: true });
2116
+ await fsp3.mkdir((0, import_node_path10.dirname)(outDir), { recursive: true });
1947
2117
  }
1948
2118
 
1949
2119
  // src/packaging/issueSeverity.ts
@@ -1964,13 +2134,13 @@ function findPackagingWarningIssues(issues) {
1964
2134
  // src/packageCourse.ts
1965
2135
  async function validateLessonkitProject(options) {
1966
2136
  return (0, import_api2.validateCourse)({
1967
- courseDir: (0, import_node_path10.resolve)(options.courseDir),
2137
+ courseDir: (0, import_node_path11.resolve)(options.courseDir),
1968
2138
  target: options.target
1969
2139
  });
1970
2140
  }
1971
2141
  async function buildLessonkitProject(options) {
1972
2142
  const buildOptions = {
1973
- courseDir: (0, import_node_path10.resolve)(options.courseDir),
2143
+ courseDir: (0, import_node_path11.resolve)(options.courseDir),
1974
2144
  target: options.target,
1975
2145
  output: options.output,
1976
2146
  dir: options.dir,
@@ -2001,7 +2171,7 @@ async function packageLessonkitCourse(options) {
2001
2171
  if (!descriptorValidation.ok) {
2002
2172
  return {
2003
2173
  ok: false,
2004
- courseDir: (0, import_node_path10.resolve)(writeOpts.outDir),
2174
+ courseDir: (0, import_node_path11.resolve)(writeOpts.outDir),
2005
2175
  target,
2006
2176
  issues: descriptorValidation.issues.map((i) => ({
2007
2177
  path: i.path,
@@ -2051,7 +2221,7 @@ async function packageLessonkitCourse(options) {
2051
2221
  };
2052
2222
  }
2053
2223
  if (!staged.ok) {
2054
- await fsp3.rm(staged.stagingDir, { recursive: true, force: true }).catch(
2224
+ await fsp4.rm(staged.stagingDir, { recursive: true, force: true }).catch(
2055
2225
  /* v8 ignore next */
2056
2226
  () => void 0
2057
2227
  );
@@ -2068,7 +2238,7 @@ async function packageLessonkitCourse(options) {
2068
2238
  const { stagingDir, build } = staged;
2069
2239
  const buildErrorIssues = findPackagingErrorIssues(build.issues);
2070
2240
  if (buildErrorIssues.length > 0) {
2071
- await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
2241
+ await fsp4.rm(stagingDir, { recursive: true, force: true }).catch(
2072
2242
  /* v8 ignore next */
2073
2243
  () => void 0
2074
2244
  );
@@ -2085,13 +2255,13 @@ async function packageLessonkitCourse(options) {
2085
2255
  }))
2086
2256
  };
2087
2257
  }
2088
- const stagingRoot = await fsp3.realpath(stagingDir);
2258
+ const stagingRoot = await fsp4.realpath(stagingDir);
2089
2259
  const artifactIssues = [
2090
2260
  validateArtifactInStaging(stagingRoot, staged.outputPath, "outputPath"),
2091
2261
  validateArtifactInStaging(stagingRoot, staged.outputDir, "outputDir")
2092
2262
  ].filter((issue) => issue != null);
2093
2263
  if (artifactIssues.length > 0) {
2094
- await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
2264
+ await fsp4.rm(stagingDir, { recursive: true, force: true }).catch(
2095
2265
  /* v8 ignore next */
2096
2266
  () => void 0
2097
2267
  );
@@ -2106,7 +2276,7 @@ async function packageLessonkitCourse(options) {
2106
2276
  }
2107
2277
  const buildWarningIssues = findPackagingWarningIssues(build.issues);
2108
2278
  if (options.strictBuild && buildWarningIssues.length > 0) {
2109
- await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
2279
+ await fsp4.rm(stagingDir, { recursive: true, force: true }).catch(
2110
2280
  /* v8 ignore next */
2111
2281
  () => void 0
2112
2282
  );
@@ -2123,8 +2293,8 @@ async function packageLessonkitCourse(options) {
2123
2293
  }))
2124
2294
  };
2125
2295
  }
2126
- const remappedOutputPath = remapArtifactPaths(stagingRoot, outDir, staged.outputPath);
2127
- const remappedOutputDir = remapArtifactPaths(stagingRoot, outDir, staged.outputDir);
2296
+ let remappedOutputPath = remapArtifactPaths(stagingRoot, outDir, staged.outputPath);
2297
+ let remappedOutputDir = remapArtifactPaths(stagingRoot, outDir, staged.outputDir);
2128
2298
  const validation = {
2129
2299
  ok: true,
2130
2300
  manifest: build.manifest,
@@ -2137,7 +2307,7 @@ async function packageLessonkitCourse(options) {
2137
2307
  projectRoot: writeOpts.projectRoot
2138
2308
  });
2139
2309
  } catch (err) {
2140
- await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
2310
+ await fsp4.rm(stagingDir, { recursive: true, force: true }).catch(
2141
2311
  /* v8 ignore next */
2142
2312
  () => void 0
2143
2313
  );
@@ -2155,6 +2325,32 @@ async function packageLessonkitCourse(options) {
2155
2325
  ]
2156
2326
  };
2157
2327
  }
2328
+ try {
2329
+ remappedOutputPath = await relocatePackageOutput(
2330
+ remappedOutputPath,
2331
+ staged.requestedOutputPath,
2332
+ writeOpts.projectRoot
2333
+ );
2334
+ remappedOutputDir = await relocatePackageOutput(
2335
+ remappedOutputDir,
2336
+ staged.requestedOutputDir,
2337
+ writeOpts.projectRoot
2338
+ );
2339
+ } catch (err) {
2340
+ return {
2341
+ ok: false,
2342
+ courseDir: outDir,
2343
+ target,
2344
+ validation,
2345
+ build,
2346
+ issues: [
2347
+ {
2348
+ path: "output",
2349
+ message: err instanceof Error ? err.message : String(err)
2350
+ }
2351
+ ]
2352
+ };
2353
+ }
2158
2354
  const remappedBuild = { ...build };
2159
2355
  if ("outputPath" in remappedBuild && remappedOutputPath !== void 0) {
2160
2356
  remappedBuild.outputPath = remappedOutputPath;
@@ -2198,8 +2394,9 @@ function parseLessonkitManifest(raw, label = "lessonkit.json", projectRoot) {
2198
2394
  }
2199
2395
  const nameRaw = config.name;
2200
2396
  const name = typeof nameRaw === "string" ? nameRaw.trim() : "";
2201
- if (!name) {
2202
- issues.push({ path: "name", message: "must be a non-empty string" });
2397
+ const nameIssue = validateManifestName(name);
2398
+ if (nameIssue) {
2399
+ issues.push({ path: "name", message: nameIssue });
2203
2400
  }
2204
2401
  const courseRaw = config.course;
2205
2402
  if (Array.isArray(courseRaw)) {
@@ -2363,12 +2560,12 @@ var import_validators4 = require("@lxpack/validators");
2363
2560
 
2364
2561
  // src/lkcourse/zip.ts
2365
2562
  var import_node_fs5 = require("fs");
2366
- var import_node_path11 = require("path");
2563
+ var import_node_path12 = require("path");
2367
2564
  var import_fflate = require("fflate");
2368
2565
  var MAX_LKCOURSE_UNCOMPRESSED_BYTES = 256 * 1024 * 1024;
2369
2566
  function canonicalZipEntryPath(entryPath) {
2370
2567
  const slashNormalized = entryPath.replace(/\\/g, "/");
2371
- const canonical = (0, import_node_path11.normalize)(slashNormalized).replace(/\\/g, "/");
2568
+ const canonical = (0, import_node_path12.normalize)(slashNormalized).replace(/\\/g, "/");
2372
2569
  if (canonical !== slashNormalized) return null;
2373
2570
  return canonical;
2374
2571
  }
@@ -2443,7 +2640,7 @@ async function collectDistEntries(distDir, spaDistRelative) {
2443
2640
  const walk = async (absDir, relPrefix) => {
2444
2641
  const dirEntries = await readdir4(absDir, { withFileTypes: true });
2445
2642
  for (const entry of dirEntries) {
2446
- const abs = (0, import_node_path11.join)(absDir, entry.name);
2643
+ const abs = (0, import_node_path12.join)(absDir, entry.name);
2447
2644
  const rel = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
2448
2645
  const zipPath = `${spaDistRelative}/${rel}`.replace(/\\/g, "/");
2449
2646
  if (!isSafeRelativeSpaPath(zipPath)) {
@@ -2545,8 +2742,8 @@ function parseLkcourseEnvelope(raw, label = "manifest.json") {
2545
2742
  // src/lkcourse/blockTree.ts
2546
2743
  var import_node_fs6 = require("fs");
2547
2744
  var import_node_module = require("module");
2548
- var import_node_path12 = require("path");
2549
- var import_core5 = require("@lessonkit/core");
2745
+ var import_node_path13 = require("path");
2746
+ var import_core7 = require("@lessonkit/core");
2550
2747
  var import_meta = {};
2551
2748
  var SCANNABLE_EXTENSIONS2 = [".tsx", ".ts", ".jsx", ".js"];
2552
2749
  var ID_PROPS = ["courseId", "lessonId", "checkId", "blockId", "nodeId"];
@@ -2554,12 +2751,12 @@ function stripComments2(source) {
2554
2751
  return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/[^\n]*/g, " ");
2555
2752
  }
2556
2753
  function collectSourceUnderSrc2(projectRoot) {
2557
- const srcDir = (0, import_node_path12.join)(projectRoot, "src");
2754
+ const srcDir = (0, import_node_path13.join)(projectRoot, "src");
2558
2755
  if (!(0, import_node_fs6.existsSync)(srcDir)) return [];
2559
2756
  const results = [];
2560
2757
  const walk = (dir) => {
2561
2758
  for (const entry of (0, import_node_fs6.readdirSync)(dir)) {
2562
- const abs = (0, import_node_path12.join)(dir, entry);
2759
+ const abs = (0, import_node_path13.join)(dir, entry);
2563
2760
  try {
2564
2761
  assertRealPathUnderRoot(projectRoot, abs);
2565
2762
  } catch {
@@ -2570,7 +2767,7 @@ function collectSourceUnderSrc2(projectRoot) {
2570
2767
  if (stat2.isDirectory()) {
2571
2768
  walk(abs);
2572
2769
  } else if (SCANNABLE_EXTENSIONS2.some((ext) => entry.endsWith(ext))) {
2573
- results.push((0, import_node_path12.relative)(projectRoot, abs));
2770
+ results.push((0, import_node_path13.relative)(projectRoot, abs));
2574
2771
  }
2575
2772
  }
2576
2773
  };
@@ -2684,7 +2881,7 @@ function validateNodeIds(node, pathPrefix, issues) {
2684
2881
  for (const prop of ID_PROPS) {
2685
2882
  const value = node[prop];
2686
2883
  if (value === void 0) continue;
2687
- const validated = (0, import_core5.validateId)(value, prop);
2884
+ const validated = (0, import_core7.validateId)(value, prop);
2688
2885
  if (!validated.ok) {
2689
2886
  issues.push({
2690
2887
  path: `${pathPrefix}.${prop}`,
@@ -2708,7 +2905,7 @@ function extractBlockTree(options) {
2708
2905
  const sources = options.appSources ?? collectSourceUnderSrc2(options.projectRoot);
2709
2906
  const blocks = [];
2710
2907
  for (const rel of sources) {
2711
- const abs = (0, import_node_path12.join)(options.projectRoot, rel);
2908
+ const abs = (0, import_node_path13.join)(options.projectRoot, rel);
2712
2909
  if (!(0, import_node_fs6.existsSync)(abs)) continue;
2713
2910
  const source = (0, import_node_fs6.readFileSync)(abs, "utf8");
2714
2911
  const parsed = parseJsxBlocks(source, blockTypes);
@@ -2724,7 +2921,7 @@ function extractBlockTree(options) {
2724
2921
  // src/lkcourse/export.ts
2725
2922
  var import_promises3 = require("fs/promises");
2726
2923
  var import_node_module2 = require("module");
2727
- var import_node_path13 = require("path");
2924
+ var import_node_path14 = require("path");
2728
2925
  var import_validators2 = require("@lxpack/validators");
2729
2926
  var import_meta2 = {};
2730
2927
  function resolveLessonkitVersion(explicit) {
@@ -2738,9 +2935,9 @@ function resolveLessonkitVersion(explicit) {
2738
2935
  }
2739
2936
  }
2740
2937
  async function exportLkcourse(options) {
2741
- const projectRoot = (0, import_node_path13.resolve)(options.projectRoot);
2938
+ const projectRoot = (0, import_node_path14.resolve)(options.projectRoot);
2742
2939
  const manifest = options.manifest;
2743
- const spaDistDir = (0, import_node_path13.join)(projectRoot, manifest.paths.spaDistDir);
2940
+ const spaDistDir = (0, import_node_path14.join)(projectRoot, manifest.paths.spaDistDir);
2744
2941
  try {
2745
2942
  assertRealPathUnderRoot(projectRoot, spaDistDir);
2746
2943
  await assertSpaDistContentsSafe({ main: spaDistDir }, projectRoot);
@@ -2755,6 +2952,16 @@ async function exportLkcourse(options) {
2755
2952
  ]
2756
2953
  };
2757
2954
  }
2955
+ const injectableIssues = validateInjectableAssessments(manifest.course);
2956
+ if (injectableIssues.length > 0) {
2957
+ return {
2958
+ ok: false,
2959
+ issues: injectableIssues.map((issue) => ({
2960
+ path: issue.path,
2961
+ message: issue.message
2962
+ }))
2963
+ };
2964
+ }
2758
2965
  const interchange = descriptorToInterchange(manifest.course);
2759
2966
  const interchangeParsed = (0, import_validators2.parseLessonkitInterchange)(interchange);
2760
2967
  if (!interchangeParsed.ok) {
@@ -2846,10 +3053,11 @@ async function exportLkcourse(options) {
2846
3053
  return { ok: false, issues: envelopeCheck.issues };
2847
3054
  }
2848
3055
  zipEntries.set("manifest.json", utf8ToEntry(JSON.stringify(envelope, null, 2)));
2849
- const archivePath = (0, import_node_path13.resolve)(
3056
+ const archivePath = (0, import_node_path14.resolve)(
2850
3057
  projectRoot,
2851
3058
  options.outPath ?? `${manifest.name}.lkcourse`
2852
3059
  );
3060
+ const archiveRel = options.outPath ?? `${manifest.name}.lkcourse`;
2853
3061
  try {
2854
3062
  assertRealPathUnderRoot(projectRoot, archivePath);
2855
3063
  } catch (err) {
@@ -2857,20 +3065,31 @@ async function exportLkcourse(options) {
2857
3065
  ok: false,
2858
3066
  issues: [
2859
3067
  {
2860
- path: options.outPath ?? `${manifest.name}.lkcourse`,
3068
+ path: archiveRel,
2861
3069
  message: err instanceof Error ? err.message : String(err)
2862
3070
  }
2863
3071
  ]
2864
3072
  };
2865
3073
  }
2866
- if (!isSafeZipEntryPath(options.outPath ?? `${manifest.name}.lkcourse`)) {
3074
+ if (isReservedResolvedOutputPath(projectRoot, archivePath)) {
3075
+ return {
3076
+ ok: false,
3077
+ issues: [
3078
+ {
3079
+ path: archiveRel,
3080
+ message: "output path must not target reserved directories (.git, node_modules, .github)"
3081
+ }
3082
+ ]
3083
+ };
3084
+ }
3085
+ if (!isSafeZipEntryPath(archiveRel)) {
2867
3086
  return {
2868
3087
  ok: false,
2869
3088
  issues: [{ path: "outPath", message: "output path must be a safe relative path" }]
2870
3089
  };
2871
3090
  }
2872
3091
  try {
2873
- await (0, import_promises3.mkdir)((0, import_node_path13.dirname)(archivePath), { recursive: true });
3092
+ await (0, import_promises3.mkdir)((0, import_node_path14.dirname)(archivePath), { recursive: true });
2874
3093
  const zipped = createZip(zipEntries);
2875
3094
  await (0, import_promises3.writeFile)(archivePath, zipped);
2876
3095
  } catch (err) {
@@ -2894,6 +3113,29 @@ async function exportLkcourse(options) {
2894
3113
 
2895
3114
  // src/lkcourse/validate.ts
2896
3115
  var import_validators3 = require("@lxpack/validators");
3116
+
3117
+ // src/lkcourse/assessmentParity.ts
3118
+ function validateLkcourseAssessmentConsistency(descriptor, interchange) {
3119
+ const issues = [];
3120
+ for (const issue of validateInjectableAssessments(descriptor)) {
3121
+ issues.push({
3122
+ path: `sourceManifest.course.${issue.path}`,
3123
+ message: issue.message
3124
+ });
3125
+ }
3126
+ const expectedIds = extractAssessments(descriptor).map((a) => a.id).sort();
3127
+ const interchangeIds = (interchange.assessments ?? []).map((a) => a.id).sort();
3128
+ const matches = expectedIds.length === interchangeIds.length && expectedIds.every((id, index) => id === interchangeIds[index]);
3129
+ if (!matches) {
3130
+ issues.push({
3131
+ path: "interchange.assessments",
3132
+ message: `injectable assessment ids [${expectedIds.join(", ")}] do not match interchange [${interchangeIds.join(", ")}]`
3133
+ });
3134
+ }
3135
+ return issues;
3136
+ }
3137
+
3138
+ // src/lkcourse/validate.ts
2897
3139
  function validateLkcourseArchiveEntries(entries, _archiveLabel) {
2898
3140
  const issues = [];
2899
3141
  const manifestData = entries.get("manifest.json");
@@ -2926,6 +3168,8 @@ function validateLkcourseArchiveEntries(entries, _archiveLabel) {
2926
3168
  if (!entries.has(spaIndexPath)) {
2927
3169
  issues.push({ path: spaIndexPath, message: "required file missing from archive" });
2928
3170
  }
3171
+ const allowlisted = new Set(envelope.entries.map((entryPath) => entryPath.replace(/\\/g, "/")));
3172
+ const spaDistPrefix = `${spaDistDir}/`;
2929
3173
  for (const entryPath of envelope.entries) {
2930
3174
  if (!entries.has(entryPath)) {
2931
3175
  issues.push({
@@ -2934,6 +3178,16 @@ function validateLkcourseArchiveEntries(entries, _archiveLabel) {
2934
3178
  });
2935
3179
  }
2936
3180
  }
3181
+ for (const zipPath of entries.keys()) {
3182
+ const normalized = zipPath.replace(/\\/g, "/");
3183
+ if (!normalized.startsWith(spaDistPrefix)) continue;
3184
+ if (!allowlisted.has(normalized)) {
3185
+ issues.push({
3186
+ path: zipPath,
3187
+ message: "unlisted file under spaDistDir; not in manifest.entries"
3188
+ });
3189
+ }
3190
+ }
2937
3191
  if (issues.length) return { ok: false, issues };
2938
3192
  let interchangeRaw;
2939
3193
  try {
@@ -2967,6 +3221,12 @@ function validateLkcourseArchiveEntries(entries, _archiveLabel) {
2967
3221
  message: `does not match interchange.course.id (${interchangeCourseId})`
2968
3222
  });
2969
3223
  }
3224
+ issues.push(
3225
+ ...validateLkcourseAssessmentConsistency(
3226
+ envelope.sourceManifest.course,
3227
+ interchange
3228
+ )
3229
+ );
2970
3230
  if (issues.length) return { ok: false, issues };
2971
3231
  const blockTreeData = entries.get("block-tree.json");
2972
3232
  if (blockTreeData) {
@@ -3007,7 +3267,7 @@ function validateLkcourse(archivePath) {
3007
3267
 
3008
3268
  // src/lkcourse/import.ts
3009
3269
  var import_promises4 = require("fs/promises");
3010
- var import_node_path14 = require("path");
3270
+ var import_node_path15 = require("path");
3011
3271
  var IMPORT_ARTIFACTS = ["lessonkit.json", "dist"];
3012
3272
  async function pathExists2(path) {
3013
3273
  try {
@@ -3028,10 +3288,10 @@ async function renameOrCopy2(from, to, opts) {
3028
3288
  await (0, import_promises4.rm)(from, { recursive: true, force: true });
3029
3289
  }
3030
3290
  }
3031
- async function writeImportTree(stagingDir, manifest, entries, spaDistDir) {
3291
+ async function writeImportTree(stagingDir, manifest, entries, spaDistDir, allowlistedSpaPaths) {
3032
3292
  let fileCount = 0;
3033
3293
  await (0, import_promises4.writeFile)(
3034
- (0, import_node_path14.join)(stagingDir, "lessonkit.json"),
3294
+ (0, import_node_path15.join)(stagingDir, "lessonkit.json"),
3035
3295
  `${JSON.stringify(manifest, null, 2)}
3036
3296
  `,
3037
3297
  "utf8"
@@ -3040,14 +3300,17 @@ async function writeImportTree(stagingDir, manifest, entries, spaDistDir) {
3040
3300
  for (const [entryPath, data] of entries) {
3041
3301
  const normalized = entryPath.replace(/\\/g, "/");
3042
3302
  if (!normalized.startsWith(`${spaDistDir}/`)) continue;
3303
+ if (!allowlistedSpaPaths.has(normalized)) {
3304
+ throw new Error(`unlisted spaDist entry rejected: ${entryPath}`);
3305
+ }
3043
3306
  const relativeUnderSpa = normalized.slice(spaDistDir.length + 1);
3044
- const outPath = (0, import_node_path14.join)(stagingDir, spaDistDir, relativeUnderSpa);
3045
- const resolvedOut = (0, import_node_path14.resolve)(outPath);
3307
+ const outPath = (0, import_node_path15.join)(stagingDir, spaDistDir, relativeUnderSpa);
3308
+ const resolvedOut = (0, import_node_path15.resolve)(outPath);
3046
3309
  assertRealPathUnderRoot(stagingDir, resolvedOut);
3047
- if (!isSafeZipEntryPath((0, import_node_path14.join)(spaDistDir, relativeUnderSpa))) {
3310
+ if (!isSafeZipEntryPath((0, import_node_path15.join)(spaDistDir, relativeUnderSpa))) {
3048
3311
  throw new Error(`unsafe extraction path: ${entryPath}`);
3049
3312
  }
3050
- await (0, import_promises4.mkdir)((0, import_node_path14.dirname)(resolvedOut), { recursive: true });
3313
+ await (0, import_promises4.mkdir)((0, import_node_path15.dirname)(resolvedOut), { recursive: true });
3051
3314
  await (0, import_promises4.writeFile)(resolvedOut, data);
3052
3315
  fileCount += 1;
3053
3316
  }
@@ -3056,45 +3319,64 @@ async function writeImportTree(stagingDir, manifest, entries, spaDistDir) {
3056
3319
  async function backupImportArtifacts(targetDir) {
3057
3320
  const existing = [];
3058
3321
  for (const name of IMPORT_ARTIFACTS) {
3059
- if (await pathExists2((0, import_node_path14.join)(targetDir, name))) {
3322
+ if (await pathExists2((0, import_node_path15.join)(targetDir, name))) {
3060
3323
  existing.push(name);
3061
3324
  }
3062
3325
  }
3063
3326
  if (!existing.length) return void 0;
3064
- const backupDir = await (0, import_promises4.mkdtemp)((0, import_node_path14.join)(targetDir, ".lkcourse-backup-"));
3327
+ const backupDir = await (0, import_promises4.mkdtemp)((0, import_node_path15.join)(targetDir, ".lkcourse-backup-"));
3065
3328
  for (const name of existing) {
3066
- await renameOrCopy2((0, import_node_path14.join)(targetDir, name), (0, import_node_path14.join)(backupDir, name));
3329
+ await renameOrCopy2((0, import_node_path15.join)(targetDir, name), (0, import_node_path15.join)(backupDir, name));
3067
3330
  }
3068
3331
  return backupDir;
3069
3332
  }
3070
3333
  async function restoreImportBackup(targetDir, backupDir) {
3071
3334
  for (const name of IMPORT_ARTIFACTS) {
3072
- const backupPath = (0, import_node_path14.join)(backupDir, name);
3335
+ const backupPath = (0, import_node_path15.join)(backupDir, name);
3073
3336
  if (!await pathExists2(backupPath)) continue;
3074
- const destPath = (0, import_node_path14.join)(targetDir, name);
3337
+ const destPath = (0, import_node_path15.join)(targetDir, name);
3075
3338
  if (await pathExists2(destPath)) {
3076
3339
  await (0, import_promises4.rm)(destPath, { recursive: true, force: true });
3077
3340
  }
3078
3341
  await renameOrCopy2(backupPath, destPath);
3079
3342
  }
3080
3343
  }
3344
+ async function snapshotPreExistingImportArtifacts(targetDir) {
3345
+ const existing = /* @__PURE__ */ new Set();
3346
+ for (const name of IMPORT_ARTIFACTS) {
3347
+ if (await pathExists2((0, import_node_path15.join)(targetDir, name))) {
3348
+ existing.add(name);
3349
+ }
3350
+ }
3351
+ return existing;
3352
+ }
3353
+ async function rollbackFailedImport(targetDir, backupDir, preExisting) {
3354
+ if (backupDir) {
3355
+ await restoreImportBackup(targetDir, backupDir);
3356
+ }
3357
+ for (const name of IMPORT_ARTIFACTS) {
3358
+ if (preExisting.has(name)) continue;
3359
+ const destPath = (0, import_node_path15.join)(targetDir, name);
3360
+ if (await pathExists2(destPath)) {
3361
+ await (0, import_promises4.rm)(destPath, { recursive: true, force: true });
3362
+ }
3363
+ }
3364
+ }
3081
3365
  async function promoteImportStaging(stagingDir, targetDir) {
3366
+ await (0, import_promises4.mkdir)(targetDir, { recursive: true });
3082
3367
  const entries = await (0, import_promises4.readdir)(stagingDir, { withFileTypes: true });
3083
3368
  for (const entry of entries) {
3084
- const srcPath = (0, import_node_path14.join)(stagingDir, entry.name);
3085
- const destPath = (0, import_node_path14.join)(targetDir, entry.name);
3086
- if (entry.isDirectory()) {
3087
- await (0, import_promises4.cp)(srcPath, destPath, { recursive: true, force: true });
3088
- } else if (entry.isFile()) {
3089
- await (0, import_promises4.mkdir)((0, import_node_path14.dirname)(destPath), { recursive: true });
3090
- await (0, import_promises4.cp)(srcPath, destPath);
3369
+ const srcPath = (0, import_node_path15.join)(stagingDir, entry.name);
3370
+ const destPath = (0, import_node_path15.join)(targetDir, entry.name);
3371
+ if (entry.isDirectory() || entry.isFile()) {
3372
+ await renameOrCopy2(srcPath, destPath);
3091
3373
  }
3092
3374
  }
3093
3375
  }
3094
3376
  var promoteImportStagingImpl = promoteImportStaging;
3095
3377
  async function importLkcourse(options) {
3096
- const archivePath = (0, import_node_path14.resolve)(options.archivePath);
3097
- const targetDir = (0, import_node_path14.resolve)(options.targetDir);
3378
+ const archivePath = (0, import_node_path15.resolve)(options.archivePath);
3379
+ const targetDir = (0, import_node_path15.resolve)(options.targetDir);
3098
3380
  const validated = validateLkcourse(archivePath);
3099
3381
  if (!validated.ok) return validated;
3100
3382
  const { envelope, interchange } = validated;
@@ -3118,16 +3400,26 @@ async function importLkcourse(options) {
3118
3400
  if (!read.ok) return read;
3119
3401
  let stagingDir;
3120
3402
  let backupDir;
3403
+ let preExisting;
3121
3404
  try {
3122
- stagingDir = await (0, import_promises4.mkdtemp)((0, import_node_path14.join)(targetDir, ".lkcourse-import-"));
3123
- const fileCount = await writeImportTree(stagingDir, manifest, read.entries, spaDistDir);
3405
+ stagingDir = await (0, import_promises4.mkdtemp)((0, import_node_path15.join)(targetDir, ".lkcourse-import-"));
3406
+ const allowlistedSpaPaths = new Set(
3407
+ envelope.entries.map((entryPath) => entryPath.replace(/\\/g, "/")).filter((entryPath) => entryPath.startsWith(`${spaDistDir}/`))
3408
+ );
3409
+ const fileCount = await writeImportTree(
3410
+ stagingDir,
3411
+ manifest,
3412
+ read.entries,
3413
+ spaDistDir,
3414
+ allowlistedSpaPaths
3415
+ );
3416
+ preExisting = await snapshotPreExistingImportArtifacts(targetDir);
3124
3417
  backupDir = await backupImportArtifacts(targetDir);
3125
3418
  try {
3126
3419
  await promoteImportStagingImpl(stagingDir, targetDir);
3127
3420
  } catch (promoteError) {
3128
- if (backupDir) {
3129
- await restoreImportBackup(targetDir, backupDir);
3130
- }
3421
+ await rollbackFailedImport(targetDir, backupDir, preExisting);
3422
+ backupDir = void 0;
3131
3423
  throw promoteError;
3132
3424
  }
3133
3425
  if (backupDir) {
@@ -3144,8 +3436,12 @@ async function importLkcourse(options) {
3144
3436
  fileCount
3145
3437
  };
3146
3438
  } catch (err) {
3147
- if (backupDir) {
3439
+ if (preExisting) {
3440
+ await rollbackFailedImport(targetDir, backupDir, preExisting).catch(() => void 0);
3441
+ } else if (backupDir) {
3148
3442
  await restoreImportBackup(targetDir, backupDir).catch(() => void 0);
3443
+ }
3444
+ if (backupDir) {
3149
3445
  await (0, import_promises4.rm)(backupDir, { recursive: true, force: true }).catch(() => void 0);
3150
3446
  }
3151
3447
  if (stagingDir) {
@@ -3197,6 +3493,7 @@ async function importLkcourse(options) {
3197
3493
  validateLessonkitProject,
3198
3494
  validateLkcourse,
3199
3495
  validateLkcourseArchiveEntries,
3496
+ validateManifestName,
3200
3497
  validatePackageInputs,
3201
3498
  validateProjectPaths,
3202
3499
  validateReactManifestParity,