@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.js CHANGED
@@ -62,13 +62,40 @@ function normalizeDescriptor(input) {
62
62
  correctTargetIds: assessment.correctTargetIds.map((id) => id.trim()).filter((id) => id.length > 0)
63
63
  };
64
64
  }
65
+ if (assessment.kind === "sortParagraphs") {
66
+ return {
67
+ ...assessment,
68
+ checkId: check.id,
69
+ question,
70
+ paragraphs: assessment.paragraphs.map((p) => p.trim()).filter((p) => p.length > 0),
71
+ correctOrder: [...assessment.correctOrder]
72
+ };
73
+ }
74
+ if (assessment.kind === "guessTheAnswer") {
75
+ return {
76
+ ...assessment,
77
+ checkId: check.id,
78
+ question,
79
+ answer: assessment.answer.trim()
80
+ };
81
+ }
82
+ if (assessment.kind === "multimediaChoice") {
83
+ return {
84
+ ...assessment,
85
+ checkId: check.id,
86
+ question,
87
+ choices: assessment.choices.map((c) => c.trim()).filter((c) => c.length > 0),
88
+ answer: assessment.answer.trim()
89
+ };
90
+ }
65
91
  const mcq = assessment;
66
92
  return {
67
93
  ...mcq,
68
94
  checkId: check.id,
69
95
  question,
70
96
  choices: mcq.choices.map((c) => c.trim()).filter((c) => c.length > 0),
71
- answer: mcq.answer.trim()
97
+ answer: mcq.answer.trim(),
98
+ answers: mcq.answers?.map((a) => a.trim()).filter((a) => a.length > 0)
72
99
  };
73
100
  })
74
101
  };
@@ -142,7 +169,30 @@ function parseAssessmentDescriptor(raw) {
142
169
  correctTargetIds: Array.isArray(raw.correctTargetIds) ? raw.correctTargetIds.filter((id) => typeof id === "string") : []
143
170
  };
144
171
  }
145
- if (typeof kind === "string" && kind !== "mcq" && kind !== "trueFalse" && kind !== "fillInBlanks" && kind !== "findHotspot" && kind !== "findMultipleHotspots") {
172
+ if (kind === "sortParagraphs") {
173
+ return {
174
+ kind: "sortParagraphs",
175
+ ...base,
176
+ paragraphs: Array.isArray(raw.paragraphs) ? raw.paragraphs.filter((p) => typeof p === "string") : [],
177
+ correctOrder: Array.isArray(raw.correctOrder) ? raw.correctOrder.filter((n) => typeof n === "number" && Number.isFinite(n)) : []
178
+ };
179
+ }
180
+ if (kind === "guessTheAnswer") {
181
+ return {
182
+ kind: "guessTheAnswer",
183
+ ...base,
184
+ answer: typeof raw.answer === "string" ? raw.answer : ""
185
+ };
186
+ }
187
+ if (kind === "multimediaChoice") {
188
+ return {
189
+ kind: "multimediaChoice",
190
+ ...base,
191
+ choices: Array.isArray(raw.choices) ? raw.choices.filter((c) => typeof c === "string") : [],
192
+ answer: typeof raw.answer === "string" ? raw.answer : ""
193
+ };
194
+ }
195
+ if (typeof kind === "string" && kind !== "mcq" && kind !== "trueFalse" && kind !== "fillInBlanks" && kind !== "findHotspot" && kind !== "findMultipleHotspots" && kind !== "sortParagraphs" && kind !== "guessTheAnswer" && kind !== "multimediaChoice") {
146
196
  return {
147
197
  kind,
148
198
  ...base,
@@ -154,7 +204,15 @@ function parseAssessmentDescriptor(raw) {
154
204
  kind: kind === "mcq" ? "mcq" : void 0,
155
205
  ...base,
156
206
  choices: Array.isArray(raw.choices) ? raw.choices.filter((c) => typeof c === "string") : [],
157
- answer: typeof raw.answer === "string" ? raw.answer : ""
207
+ answer: typeof raw.answer === "string" ? raw.answer : "",
208
+ answers: Array.isArray(raw.answers) ? raw.answers.filter((a) => typeof a === "string") : void 0,
209
+ shuffleChoices: typeof raw.shuffleChoices === "boolean" ? raw.shuffleChoices : void 0,
210
+ shuffleSeed: typeof raw.shuffleSeed === "string" || typeof raw.shuffleSeed === "number" ? raw.shuffleSeed : void 0,
211
+ choiceFeedback: raw.choiceFeedback && typeof raw.choiceFeedback === "object" && !Array.isArray(raw.choiceFeedback) ? Object.fromEntries(
212
+ Object.entries(raw.choiceFeedback).filter(
213
+ (entry) => typeof entry[1] === "string"
214
+ )
215
+ ) : void 0
158
216
  };
159
217
  }
160
218
  function parseCourseDescriptorInput(input) {
@@ -202,8 +260,8 @@ import { validateId as validateId3 } from "@lessonkit/core";
202
260
  import { validateTheme } from "@lessonkit/themes";
203
261
 
204
262
  // src/spaPath.ts
205
- import { existsSync, realpathSync } from "fs";
206
- import { isAbsolute, join, relative, resolve, sep, win32 } from "path";
263
+ import { realpathSync } from "fs";
264
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep, win32 } from "path";
207
265
  function resolveComparablePath(p) {
208
266
  if (/^[a-zA-Z]:[/\\]/.test(p)) {
209
267
  return win32.resolve(p);
@@ -229,43 +287,32 @@ function assertResolvedPathUnderRoot(root, target) {
229
287
  throw new Error(`unsafe path escapes project root: ${target}`);
230
288
  }
231
289
  }
232
- function resolveExistingPathUnderRoot(rootReal, rootResolved, targetResolved) {
233
- const rel = relative(rootResolved, targetResolved);
234
- if (rel.startsWith("..") || rel.includes(`..${sep}`)) {
235
- throw new Error(`unsafe path escapes project root: ${targetResolved}`);
236
- }
237
- const segments = rel.split(/[/\\]/).filter((s) => s.length > 0 && s !== ".");
238
- let current = rootReal;
239
- for (const segment of segments) {
240
- const next = join(current, segment);
241
- if (existsSync(next)) {
290
+ function resolvePhysicalPathForCheck(p) {
291
+ const resolved = resolveComparablePath(p);
292
+ try {
293
+ return realpathSync.native(resolved);
294
+ } catch {
295
+ let probe = resolved;
296
+ let suffix = "";
297
+ while (true) {
242
298
  try {
243
- current = realpathSync(next);
299
+ const physical = realpathSync.native(probe);
300
+ return suffix ? join(physical, suffix) : physical;
244
301
  } catch {
245
- current = next;
302
+ if (probe === dirname(probe)) {
303
+ return resolved;
304
+ }
305
+ const segment = basename(probe);
306
+ suffix = suffix ? join(segment, suffix) : segment;
307
+ probe = dirname(probe);
246
308
  }
247
- } else {
248
- current = next;
249
309
  }
250
- assertResolvedPathUnderRoot(rootReal, current);
251
310
  }
252
- return current;
253
311
  }
254
312
  function assertRealPathUnderRoot(root, target) {
255
- const rootResolved = resolveComparablePath(root);
256
- const targetResolved = resolveComparablePath(target);
257
- let rootReal;
258
- try {
259
- rootReal = realpathSync(rootResolved);
260
- } catch {
261
- rootReal = rootResolved;
262
- }
263
- try {
264
- const targetCheck = realpathSync(targetResolved);
265
- assertResolvedPathUnderRoot(rootReal, targetCheck);
266
- } catch {
267
- resolveExistingPathUnderRoot(rootReal, rootResolved, targetResolved);
268
- }
313
+ const rootPhysical = resolvePhysicalPathForCheck(root);
314
+ const targetPhysical = resolvePhysicalPathForCheck(target);
315
+ assertResolvedPathUnderRoot(rootPhysical, targetPhysical);
269
316
  }
270
317
  function normalizePathForComparison(p) {
271
318
  const resolved = resolveComparablePath(p);
@@ -289,7 +336,7 @@ function isResolvedPathUnderRoot(root, target) {
289
336
  }
290
337
 
291
338
  // src/validateProjectPaths.ts
292
- import { existsSync as existsSync2, realpathSync as realpathSync2 } from "fs";
339
+ import { existsSync, realpathSync as realpathSync2 } from "fs";
293
340
  import { isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
294
341
  var RESERVED_OUTPUT_SEGMENTS = /* @__PURE__ */ new Set([".git", "node_modules", ".github"]);
295
342
  function isReservedOutputPath(value) {
@@ -299,12 +346,27 @@ function isReservedOutputPath(value) {
299
346
  const segments = normalized.split("/").filter(Boolean);
300
347
  return segments.some((segment) => RESERVED_OUTPUT_SEGMENTS.has(segment));
301
348
  }
349
+ function validateManifestName(name) {
350
+ if (!name.length) {
351
+ return "must be a non-empty string";
352
+ }
353
+ if (name.includes("/") || name.includes("\\")) {
354
+ return "must not contain path separators";
355
+ }
356
+ if (!isSafeRelativeSpaPath(name)) {
357
+ return "must be a safe relative name without '..' segments or absolute prefixes";
358
+ }
359
+ if (isReservedOutputPath(name) || isReservedOutputPath(`${name}.lkcourse`)) {
360
+ return "must not target reserved directories (.git, node_modules, .github)";
361
+ }
362
+ return null;
363
+ }
302
364
  function isReservedResolvedOutputPath(projectRoot, resolved) {
303
365
  const rootResolved = resolveComparablePath(projectRoot);
304
366
  const targetResolved = resolveComparablePath(resolved);
305
367
  try {
306
- const rootReal = existsSync2(rootResolved) ? realpathSync2(rootResolved) : rootResolved;
307
- const targetReal = existsSync2(targetResolved) ? realpathSync2(targetResolved) : targetResolved;
368
+ const rootReal = existsSync(rootResolved) ? realpathSync2(rootResolved) : rootResolved;
369
+ const targetReal = existsSync(targetResolved) ? realpathSync2(targetResolved) : targetResolved;
308
370
  const rel = relativePathUnderRoot(rootReal, targetReal);
309
371
  return isReservedOutputPath(rel);
310
372
  } catch {
@@ -397,6 +459,7 @@ function themeToLxpackRuntime(input) {
397
459
 
398
460
  // src/descriptor/validateAssessments.ts
399
461
  import { validateId as validateId2 } from "@lessonkit/core";
462
+ import { isMultiSelectMcq } from "@lessonkit/core";
400
463
  var validateMcqLike = (assessment, path, issues) => {
401
464
  if (!("choices" in assessment) || !Array.isArray(assessment.choices)) {
402
465
  issues.push({ path: `${path}.choices`, message: "choices is required for mcq" });
@@ -412,9 +475,44 @@ var validateMcqLike = (assessment, path, issues) => {
412
475
  }
413
476
  if (!assessment.answer.trim()) {
414
477
  issues.push({ path: `${path}.answer`, message: "answer is required" });
415
- } else if (trimmedChoices.length && !trimmedChoices.includes(assessment.answer.trim())) {
478
+ } else if (!("answers" in assessment && isMultiSelectMcq({ answers: assessment.answers })) && trimmedChoices.length && !trimmedChoices.includes(assessment.answer.trim())) {
416
479
  issues.push({ path: `${path}.answer`, message: "answer must match a choice" });
417
480
  }
481
+ if ("answers" in assessment && assessment.answers !== void 0) {
482
+ if (!Array.isArray(assessment.answers)) {
483
+ issues.push({ path: `${path}.answers`, message: "answers must be an array when provided" });
484
+ } else {
485
+ const trimmedAnswers = assessment.answers.map((a) => a.trim()).filter((a) => a.length > 0);
486
+ if (assessment.answers.length > 0 && trimmedAnswers.length === 0) {
487
+ issues.push({ path: `${path}.answers`, message: "answers must include non-empty strings" });
488
+ }
489
+ const uniqueAnswers = new Set(trimmedAnswers);
490
+ if (trimmedAnswers.length !== uniqueAnswers.size) {
491
+ issues.push({ path: `${path}.answers`, message: "answers must be unique" });
492
+ }
493
+ for (const ans of trimmedAnswers) {
494
+ if (trimmedChoices.length && !trimmedChoices.includes(ans)) {
495
+ issues.push({ path: `${path}.answers`, message: "each answer must match a choice" });
496
+ break;
497
+ }
498
+ }
499
+ }
500
+ }
501
+ if ("choiceFeedback" in assessment && assessment.choiceFeedback !== void 0) {
502
+ if (typeof assessment.choiceFeedback !== "object" || assessment.choiceFeedback === null) {
503
+ issues.push({ path: `${path}.choiceFeedback`, message: "choiceFeedback must be an object" });
504
+ } else {
505
+ for (const key of Object.keys(assessment.choiceFeedback)) {
506
+ if (!trimmedChoices.includes(key.trim())) {
507
+ issues.push({
508
+ path: `${path}.choiceFeedback`,
509
+ message: "choiceFeedback keys must match choice labels"
510
+ });
511
+ break;
512
+ }
513
+ }
514
+ }
515
+ }
418
516
  const uniqueChoices = new Set(trimmedChoices);
419
517
  if (trimmedChoices.length !== uniqueChoices.size) {
420
518
  issues.push({ path: `${path}.choices`, message: "choices must be unique" });
@@ -434,6 +532,12 @@ function maxAchievableAssessmentScore(assessment) {
434
532
  if (kind === "findMultipleHotspots" && assessment.kind === "findMultipleHotspots") {
435
533
  return assessment.correctTargetIds?.map((id) => id.trim()).filter((id) => id.length > 0).length ?? 0;
436
534
  }
535
+ if (kind === "sortParagraphs" && assessment.kind === "sortParagraphs") {
536
+ return assessment.paragraphs?.length ?? assessment.correctOrder?.length ?? 0;
537
+ }
538
+ if ("answers" in assessment && Array.isArray(assessment.answers) && assessment.answers.length > 1) {
539
+ return assessment.answers.filter((a) => a.trim().length > 0).length;
540
+ }
437
541
  return 1;
438
542
  }
439
543
  var ASSESSMENT_VALIDATORS = {
@@ -519,7 +623,31 @@ var ASSESSMENT_VALIDATORS = {
519
623
  message: "at least one non-empty correctTargetId is required for findMultipleHotspots"
520
624
  });
521
625
  }
522
- }
626
+ },
627
+ sortParagraphs: (assessment, path, issues) => {
628
+ if (assessment.kind !== "sortParagraphs") return;
629
+ if (!Array.isArray(assessment.paragraphs) || assessment.paragraphs.length === 0) {
630
+ issues.push({ path: `${path}.paragraphs`, message: "paragraphs is required for sortParagraphs" });
631
+ return;
632
+ }
633
+ if (!Array.isArray(assessment.correctOrder) || assessment.correctOrder.length === 0) {
634
+ issues.push({ path: `${path}.correctOrder`, message: "correctOrder is required for sortParagraphs" });
635
+ return;
636
+ }
637
+ if (assessment.correctOrder.length !== assessment.paragraphs.length) {
638
+ issues.push({
639
+ path: `${path}.correctOrder`,
640
+ message: "correctOrder length must match paragraphs length for sortParagraphs"
641
+ });
642
+ }
643
+ },
644
+ guessTheAnswer: (assessment, path, issues) => {
645
+ if (assessment.kind !== "guessTheAnswer") return;
646
+ if (!assessment.answer?.trim()) {
647
+ issues.push({ path: `${path}.answer`, message: "answer is required for guessTheAnswer" });
648
+ }
649
+ },
650
+ multimediaChoice: validateMcqLike
523
651
  };
524
652
  function validateAssessmentEntry(assessment, index, issues, checkIds) {
525
653
  const path = `assessments[${index}]`;
@@ -701,6 +829,7 @@ function validateCourseDescriptor(input) {
701
829
  }
702
830
 
703
831
  // src/assessments.ts
832
+ import { isMultiSelectMcq as isMultiSelectMcq2 } from "@lessonkit/core";
704
833
  var DEFAULT_SHELL_PASSING_SCORE = 1;
705
834
  function escapeShellText(text) {
706
835
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
@@ -726,6 +855,7 @@ function mcqToLxpack(assessment) {
726
855
  const prompt = sanitizeShellField(assessment.question);
727
856
  if (!checkId || !prompt) return null;
728
857
  const normalizedAnswer = assessment.answer.trim();
858
+ const multiCorrect = assessment.answers && assessment.answers.length > 1 ? new Set(assessment.answers.map((a) => a.trim())) : /* @__PURE__ */ new Set([normalizedAnswer]);
729
859
  const choices = assessment.choices.map((text, index) => {
730
860
  const sanitizedText = sanitizeShellField(text);
731
861
  if (!sanitizedText) return null;
@@ -733,17 +863,21 @@ function mcqToLxpack(assessment) {
733
863
  return {
734
864
  id,
735
865
  text: sanitizedText,
736
- correct: text.trim() === normalizedAnswer
866
+ correct: multiCorrect.has(text.trim())
737
867
  };
738
868
  });
739
869
  if (choices.some((choice) => choice === null)) return null;
870
+ const multiSelect = isMultiSelectMcq2(assessment);
740
871
  return {
741
872
  id: checkId,
742
873
  passingScore: assessment.passingScore ?? DEFAULT_SHELL_PASSING_SCORE,
874
+ shuffleChoices: assessment.shuffleChoices === true ? true : void 0,
875
+ showFeedback: assessment.choiceFeedback && Object.keys(assessment.choiceFeedback).length > 0 ? "immediate" : void 0,
743
876
  questions: [
744
877
  {
745
878
  id: "q1",
746
879
  prompt,
880
+ ...multiSelect ? { selectionMode: "multiple" } : {},
747
881
  choices
748
882
  }
749
883
  ]
@@ -772,7 +906,20 @@ function assessmentDescriptorToLxpack(assessment) {
772
906
  if (kind === "findMultipleHotspots") {
773
907
  return null;
774
908
  }
775
- if ("choices" in assessment && "answer" in assessment && typeof assessment.answer === "string") {
909
+ if (kind === "sortParagraphs" || kind === "guessTheAnswer") {
910
+ return null;
911
+ }
912
+ if (kind === "multimediaChoice" && assessment.kind === "multimediaChoice") {
913
+ return mcqToLxpack({
914
+ kind: "mcq",
915
+ checkId: assessment.checkId,
916
+ question: assessment.question,
917
+ choices: assessment.choices,
918
+ answer: assessment.answer,
919
+ passingScore: assessment.passingScore
920
+ });
921
+ }
922
+ if ((kind === "mcq" || assessment.kind === void 0) && "choices" in assessment && "answer" in assessment && typeof assessment.answer === "string") {
776
923
  return mcqToLxpack(assessment);
777
924
  }
778
925
  return null;
@@ -784,7 +931,13 @@ function extractAssessments(descriptor) {
784
931
  // src/descriptor/validateInjectableAssessments.ts
785
932
  function validateInjectableAssessments(descriptor) {
786
933
  const issues = [];
787
- const spaOnlyKinds = /* @__PURE__ */ new Set(["fillInBlanks", "findHotspot", "findMultipleHotspots"]);
934
+ const spaOnlyKinds = /* @__PURE__ */ new Set([
935
+ "fillInBlanks",
936
+ "findHotspot",
937
+ "findMultipleHotspots",
938
+ "sortParagraphs",
939
+ "guessTheAnswer"
940
+ ]);
788
941
  (descriptor.assessments ?? []).forEach((assessment, index) => {
789
942
  if (assessmentDescriptorToLxpack(assessment) === null) {
790
943
  const kind = assessment.kind ?? "mcq";
@@ -862,12 +1015,12 @@ function validateDescriptorForTarget(input, target) {
862
1015
  }
863
1016
 
864
1017
  // src/validateReactParity.ts
865
- import { readFileSync, existsSync as existsSync3, readdirSync, lstatSync } from "fs";
1018
+ import { readFileSync, existsSync as existsSync2, readdirSync, lstatSync } from "fs";
866
1019
  import { join as join2, relative as relative2 } from "path";
867
1020
  var SCANNABLE_EXTENSIONS = [".tsx", ".ts", ".jsx", ".js"];
868
1021
  function collectSourceUnderSrc(projectRoot, issues) {
869
1022
  const srcDir = join2(projectRoot, "src");
870
- if (!existsSync3(srcDir)) return [];
1023
+ if (!existsSync2(srcDir)) return [];
871
1024
  const results = [];
872
1025
  const walk = (dir) => {
873
1026
  for (const entry of readdirSync(dir)) {
@@ -931,7 +1084,7 @@ function readAppSources(projectRoot, appSources, issues, customSourcesProvided)
931
1084
  const abs = join2(projectRoot, rel);
932
1085
  try {
933
1086
  assertRealPathUnderRoot(projectRoot, abs);
934
- if (existsSync3(abs) && lstatSync(abs).isSymbolicLink()) {
1087
+ if (existsSync2(abs) && lstatSync(abs).isSymbolicLink()) {
935
1088
  issues.push({
936
1089
  path: rel,
937
1090
  message: `appSources path is a symlink: ${rel}`,
@@ -947,7 +1100,7 @@ function readAppSources(projectRoot, appSources, issues, customSourcesProvided)
947
1100
  });
948
1101
  return null;
949
1102
  }
950
- if (!existsSync3(abs)) return null;
1103
+ if (!existsSync2(abs)) return null;
951
1104
  return readFileSync(abs, "utf8");
952
1105
  }).filter((content) => content != null).join("\n");
953
1106
  }
@@ -1330,8 +1483,8 @@ async function writeLxpackProject(options) {
1330
1483
  }
1331
1484
 
1332
1485
  // src/packageCourse.ts
1333
- import { resolve as resolve7 } from "path";
1334
- import * as fsp3 from "fs/promises";
1486
+ import { resolve as resolve9 } from "path";
1487
+ import * as fsp4 from "fs/promises";
1335
1488
  import {
1336
1489
  buildCourse,
1337
1490
  validateCourse
@@ -1487,21 +1640,6 @@ function validatePackageInputs(options) {
1487
1640
  ]
1488
1641
  };
1489
1642
  }
1490
- try {
1491
- relativePathUnderRoot(outDir, resolvedOutput);
1492
- } catch {
1493
- return {
1494
- ok: false,
1495
- courseDir: outDir,
1496
- target,
1497
- issues: [
1498
- {
1499
- path: "output",
1500
- message: "output must resolve inside outDir"
1501
- }
1502
- ]
1503
- };
1504
- }
1505
1643
  }
1506
1644
  return { ok: true, outDir, projectRoot };
1507
1645
  }
@@ -1536,7 +1674,7 @@ function remapArtifactPaths(stagingRoot, outDir, artifactPath) {
1536
1674
  // src/packaging/promote.ts
1537
1675
  import * as fsp from "fs/promises";
1538
1676
  import { createHash, randomUUID } from "crypto";
1539
- import { dirname, join as join7, resolve as resolve6 } from "path";
1677
+ import { dirname as dirname2, join as join7, resolve as resolve6 } from "path";
1540
1678
  async function pathExists(path) {
1541
1679
  try {
1542
1680
  await fsp.access(path);
@@ -1556,7 +1694,7 @@ async function renameOrCopy(from, to) {
1556
1694
  }
1557
1695
  }
1558
1696
  function promoteLockPath(outDir) {
1559
- const parent = dirname(outDir);
1697
+ const parent = dirname2(outDir);
1560
1698
  const hash = createHash("sha256").update(resolve6(outDir)).digest("hex").slice(0, 16);
1561
1699
  return join7(parent, `.lk-promote-lock-${hash}`);
1562
1700
  }
@@ -1597,7 +1735,7 @@ async function isStalePromoteLock(lockPath) {
1597
1735
  var PROMOTE_LOCK_TIMEOUT_MS = 15e3;
1598
1736
  async function withPromoteLock(outDir, fn) {
1599
1737
  const lockPath = promoteLockPath(outDir);
1600
- await fsp.mkdir(dirname(outDir), { recursive: true });
1738
+ await fsp.mkdir(dirname2(outDir), { recursive: true });
1601
1739
  let lockHandle;
1602
1740
  const maxAttempts = 400;
1603
1741
  const started = Date.now();
@@ -1686,7 +1824,7 @@ async function mergePreservedOutArtifacts(priorArtifactsDir, destArtifactsDir, n
1686
1824
  if (newArtifactPaths.has(rel)) continue;
1687
1825
  const src = join7(priorArtifactsDir, rel);
1688
1826
  const dest = join7(destArtifactsDir, rel);
1689
- await fsp.mkdir(dirname(dest), { recursive: true });
1827
+ await fsp.mkdir(dirname2(dest), { recursive: true });
1690
1828
  await fsp.cp(src, dest, { force: true });
1691
1829
  }
1692
1830
  }
@@ -1704,7 +1842,7 @@ async function promoteStagingToOutDir(stagingDir, outDir, options) {
1704
1842
  newArtifactPaths.add(rel);
1705
1843
  }
1706
1844
  }
1707
- const parent = dirname(outDir);
1845
+ const parent = dirname2(outDir);
1708
1846
  let priorArtifactsBackup;
1709
1847
  const existingArtifactsDir = join7(outDir, outputBaseDir);
1710
1848
  if (await pathExists(outDir) && await pathExists(existingArtifactsDir)) {
@@ -1790,14 +1928,29 @@ async function promoteStagingToOutDir(stagingDir, outDir, options) {
1790
1928
  });
1791
1929
  }
1792
1930
 
1793
- // src/packaging/staging.ts
1931
+ // src/packaging/relocateOutput.ts
1794
1932
  import * as fsp2 from "fs/promises";
1795
- import { dirname as dirname2, join as join8 } from "path";
1933
+ import { dirname as dirname3, resolve as resolve7 } from "path";
1934
+ async function relocatePackageOutput(builtOutputPath, requestedOutputPath, projectRoot) {
1935
+ if (!builtOutputPath || !requestedOutputPath) return builtOutputPath;
1936
+ const resolvedBuilt = resolveComparablePath(builtOutputPath);
1937
+ const resolvedRequested = resolveComparablePath(requestedOutputPath);
1938
+ if (resolvedBuilt === resolvedRequested) return builtOutputPath;
1939
+ const root = resolve7(projectRoot);
1940
+ assertRealPathUnderRoot(root, resolvedRequested);
1941
+ await fsp2.mkdir(dirname3(resolvedRequested), { recursive: true });
1942
+ await renameOrCopy(resolvedBuilt, resolvedRequested);
1943
+ return resolvedRequested;
1944
+ }
1945
+
1946
+ // src/packaging/staging.ts
1947
+ import * as fsp3 from "fs/promises";
1948
+ import { dirname as dirname4, isAbsolute as isAbsolute4, join as join8, resolve as resolve8 } from "path";
1796
1949
  import { tmpdir } from "os";
1797
1950
  import { packageLessonkit } from "@lxpack/api";
1798
1951
  async function buildStagingPackage(options) {
1799
1952
  const { target, output, dir, outputBaseDir, descriptor, ...writeOpts } = options;
1800
- const stagingDir = await fsp2.mkdtemp(join8(tmpdir(), "lessonkit-lxpack-"));
1953
+ const stagingDir = await fsp3.mkdtemp(join8(tmpdir(), "lessonkit-lxpack-"));
1801
1954
  let succeeded = false;
1802
1955
  try {
1803
1956
  let spaDirs;
@@ -1829,14 +1982,28 @@ async function buildStagingPackage(options) {
1829
1982
  }
1830
1983
  const interchange = descriptorToInterchange(descriptor);
1831
1984
  const outputBase = outputBaseDir ?? ".lxpack/out";
1832
- await fsp2.mkdir(join8(stagingDir, outputBase), { recursive: true });
1833
- const defaultOutput = output ?? (dir ? join8(outputBase, target) : join8(outputBase, `course-${target}.zip`));
1985
+ await fsp3.mkdir(join8(stagingDir, outputBase), { recursive: true });
1986
+ const defaultOutput = dir ? join8(outputBase, target) : join8(outputBase, `course-${target}.zip`);
1987
+ let packageOutput = output ?? defaultOutput;
1988
+ let requestedOutputPath;
1989
+ let requestedOutputDir;
1990
+ if (output) {
1991
+ const projectRoot = resolve8(writeOpts.projectRoot);
1992
+ const requested = isAbsolute4(output) ? resolve8(output) : resolve8(projectRoot, output);
1993
+ if (dir) {
1994
+ requestedOutputDir = requested;
1995
+ packageOutput = defaultOutput;
1996
+ } else {
1997
+ requestedOutputPath = requested;
1998
+ packageOutput = defaultOutput;
1999
+ }
2000
+ }
1834
2001
  const build = await packageLessonkit({
1835
2002
  interchange,
1836
2003
  spaDirs,
1837
2004
  target,
1838
2005
  courseDir: stagingDir,
1839
- output: defaultOutput,
2006
+ output: packageOutput,
1840
2007
  dir,
1841
2008
  outputBaseDir,
1842
2009
  outputAnchorDir: stagingDir,
@@ -1860,17 +2027,19 @@ async function buildStagingPackage(options) {
1860
2027
  stagingDir,
1861
2028
  build,
1862
2029
  outputPath: "outputPath" in build ? build.outputPath : void 0,
1863
- outputDir: "outputDir" in build ? build.outputDir : void 0
2030
+ outputDir: "outputDir" in build ? build.outputDir : void 0,
2031
+ requestedOutputPath,
2032
+ requestedOutputDir
1864
2033
  };
1865
2034
  } catch (err) {
1866
- await fsp2.rm(stagingDir, { recursive: true, force: true }).catch(
2035
+ await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
1867
2036
  /* v8 ignore next */
1868
2037
  () => void 0
1869
2038
  );
1870
2039
  throw err;
1871
2040
  } finally {
1872
2041
  if (!succeeded) {
1873
- await fsp2.rm(stagingDir, { recursive: true, force: true }).catch(
2042
+ await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
1874
2043
  /* v8 ignore next */
1875
2044
  () => void 0
1876
2045
  );
@@ -1878,7 +2047,7 @@ async function buildStagingPackage(options) {
1878
2047
  }
1879
2048
  }
1880
2049
  async function ensureOutDirParent(outDir) {
1881
- await fsp2.mkdir(dirname2(outDir), { recursive: true });
2050
+ await fsp3.mkdir(dirname4(outDir), { recursive: true });
1882
2051
  }
1883
2052
 
1884
2053
  // src/packaging/issueSeverity.ts
@@ -1899,13 +2068,13 @@ function findPackagingWarningIssues(issues) {
1899
2068
  // src/packageCourse.ts
1900
2069
  async function validateLessonkitProject(options) {
1901
2070
  return validateCourse({
1902
- courseDir: resolve7(options.courseDir),
2071
+ courseDir: resolve9(options.courseDir),
1903
2072
  target: options.target
1904
2073
  });
1905
2074
  }
1906
2075
  async function buildLessonkitProject(options) {
1907
2076
  const buildOptions = {
1908
- courseDir: resolve7(options.courseDir),
2077
+ courseDir: resolve9(options.courseDir),
1909
2078
  target: options.target,
1910
2079
  output: options.output,
1911
2080
  dir: options.dir,
@@ -1936,7 +2105,7 @@ async function packageLessonkitCourse(options) {
1936
2105
  if (!descriptorValidation.ok) {
1937
2106
  return {
1938
2107
  ok: false,
1939
- courseDir: resolve7(writeOpts.outDir),
2108
+ courseDir: resolve9(writeOpts.outDir),
1940
2109
  target,
1941
2110
  issues: descriptorValidation.issues.map((i) => ({
1942
2111
  path: i.path,
@@ -1986,7 +2155,7 @@ async function packageLessonkitCourse(options) {
1986
2155
  };
1987
2156
  }
1988
2157
  if (!staged.ok) {
1989
- await fsp3.rm(staged.stagingDir, { recursive: true, force: true }).catch(
2158
+ await fsp4.rm(staged.stagingDir, { recursive: true, force: true }).catch(
1990
2159
  /* v8 ignore next */
1991
2160
  () => void 0
1992
2161
  );
@@ -2003,7 +2172,7 @@ async function packageLessonkitCourse(options) {
2003
2172
  const { stagingDir, build } = staged;
2004
2173
  const buildErrorIssues = findPackagingErrorIssues(build.issues);
2005
2174
  if (buildErrorIssues.length > 0) {
2006
- await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
2175
+ await fsp4.rm(stagingDir, { recursive: true, force: true }).catch(
2007
2176
  /* v8 ignore next */
2008
2177
  () => void 0
2009
2178
  );
@@ -2020,13 +2189,13 @@ async function packageLessonkitCourse(options) {
2020
2189
  }))
2021
2190
  };
2022
2191
  }
2023
- const stagingRoot = await fsp3.realpath(stagingDir);
2192
+ const stagingRoot = await fsp4.realpath(stagingDir);
2024
2193
  const artifactIssues = [
2025
2194
  validateArtifactInStaging(stagingRoot, staged.outputPath, "outputPath"),
2026
2195
  validateArtifactInStaging(stagingRoot, staged.outputDir, "outputDir")
2027
2196
  ].filter((issue) => issue != null);
2028
2197
  if (artifactIssues.length > 0) {
2029
- await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
2198
+ await fsp4.rm(stagingDir, { recursive: true, force: true }).catch(
2030
2199
  /* v8 ignore next */
2031
2200
  () => void 0
2032
2201
  );
@@ -2041,7 +2210,7 @@ async function packageLessonkitCourse(options) {
2041
2210
  }
2042
2211
  const buildWarningIssues = findPackagingWarningIssues(build.issues);
2043
2212
  if (options.strictBuild && buildWarningIssues.length > 0) {
2044
- await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
2213
+ await fsp4.rm(stagingDir, { recursive: true, force: true }).catch(
2045
2214
  /* v8 ignore next */
2046
2215
  () => void 0
2047
2216
  );
@@ -2058,8 +2227,8 @@ async function packageLessonkitCourse(options) {
2058
2227
  }))
2059
2228
  };
2060
2229
  }
2061
- const remappedOutputPath = remapArtifactPaths(stagingRoot, outDir, staged.outputPath);
2062
- const remappedOutputDir = remapArtifactPaths(stagingRoot, outDir, staged.outputDir);
2230
+ let remappedOutputPath = remapArtifactPaths(stagingRoot, outDir, staged.outputPath);
2231
+ let remappedOutputDir = remapArtifactPaths(stagingRoot, outDir, staged.outputDir);
2063
2232
  const validation = {
2064
2233
  ok: true,
2065
2234
  manifest: build.manifest,
@@ -2072,7 +2241,7 @@ async function packageLessonkitCourse(options) {
2072
2241
  projectRoot: writeOpts.projectRoot
2073
2242
  });
2074
2243
  } catch (err) {
2075
- await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
2244
+ await fsp4.rm(stagingDir, { recursive: true, force: true }).catch(
2076
2245
  /* v8 ignore next */
2077
2246
  () => void 0
2078
2247
  );
@@ -2090,6 +2259,32 @@ async function packageLessonkitCourse(options) {
2090
2259
  ]
2091
2260
  };
2092
2261
  }
2262
+ try {
2263
+ remappedOutputPath = await relocatePackageOutput(
2264
+ remappedOutputPath,
2265
+ staged.requestedOutputPath,
2266
+ writeOpts.projectRoot
2267
+ );
2268
+ remappedOutputDir = await relocatePackageOutput(
2269
+ remappedOutputDir,
2270
+ staged.requestedOutputDir,
2271
+ writeOpts.projectRoot
2272
+ );
2273
+ } catch (err) {
2274
+ return {
2275
+ ok: false,
2276
+ courseDir: outDir,
2277
+ target,
2278
+ validation,
2279
+ build,
2280
+ issues: [
2281
+ {
2282
+ path: "output",
2283
+ message: err instanceof Error ? err.message : String(err)
2284
+ }
2285
+ ]
2286
+ };
2287
+ }
2093
2288
  const remappedBuild = { ...build };
2094
2289
  if ("outputPath" in remappedBuild && remappedOutputPath !== void 0) {
2095
2290
  remappedBuild.outputPath = remappedOutputPath;
@@ -2133,8 +2328,9 @@ function parseLessonkitManifest(raw, label = "lessonkit.json", projectRoot) {
2133
2328
  }
2134
2329
  const nameRaw = config.name;
2135
2330
  const name = typeof nameRaw === "string" ? nameRaw.trim() : "";
2136
- if (!name) {
2137
- issues.push({ path: "name", message: "must be a non-empty string" });
2331
+ const nameIssue = validateManifestName(name);
2332
+ if (nameIssue) {
2333
+ issues.push({ path: "name", message: nameIssue });
2138
2334
  }
2139
2335
  const courseRaw = config.course;
2140
2336
  if (Array.isArray(courseRaw)) {
@@ -2247,7 +2443,7 @@ import {
2247
2443
 
2248
2444
  // src/lkcourse/zip.ts
2249
2445
  import { readFileSync as readFileSync2, statSync } from "fs";
2250
- import { dirname as dirname3, join as join9, normalize } from "path";
2446
+ import { dirname as dirname5, join as join9, normalize } from "path";
2251
2447
  import { strFromU8, strToU8, unzipSync, zipSync } from "fflate";
2252
2448
  var MAX_LKCOURSE_UNCOMPRESSED_BYTES = 256 * 1024 * 1024;
2253
2449
  function canonicalZipEntryPath(entryPath) {
@@ -2427,7 +2623,7 @@ function parseLkcourseEnvelope(raw, label = "manifest.json") {
2427
2623
  }
2428
2624
 
2429
2625
  // src/lkcourse/blockTree.ts
2430
- import { existsSync as existsSync4, lstatSync as lstatSync2, readdirSync as readdirSync2, readFileSync as readFileSync3 } from "fs";
2626
+ import { existsSync as existsSync3, lstatSync as lstatSync2, readdirSync as readdirSync2, readFileSync as readFileSync3 } from "fs";
2431
2627
  import { createRequire } from "module";
2432
2628
  import { join as join10, relative as relative3 } from "path";
2433
2629
  import { validateId as validateId4 } from "@lessonkit/core";
@@ -2438,7 +2634,7 @@ function stripComments2(source) {
2438
2634
  }
2439
2635
  function collectSourceUnderSrc2(projectRoot) {
2440
2636
  const srcDir = join10(projectRoot, "src");
2441
- if (!existsSync4(srcDir)) return [];
2637
+ if (!existsSync3(srcDir)) return [];
2442
2638
  const results = [];
2443
2639
  const walk = (dir) => {
2444
2640
  for (const entry of readdirSync2(dir)) {
@@ -2592,7 +2788,7 @@ function extractBlockTree(options) {
2592
2788
  const blocks = [];
2593
2789
  for (const rel of sources) {
2594
2790
  const abs = join10(options.projectRoot, rel);
2595
- if (!existsSync4(abs)) continue;
2791
+ if (!existsSync3(abs)) continue;
2596
2792
  const source = readFileSync3(abs, "utf8");
2597
2793
  const parsed = parseJsxBlocks(source, blockTypes);
2598
2794
  blocks.push(...parsed);
@@ -2605,9 +2801,9 @@ function extractBlockTree(options) {
2605
2801
  }
2606
2802
 
2607
2803
  // src/lkcourse/export.ts
2608
- import { mkdir as mkdir3, writeFile } from "fs/promises";
2804
+ import { mkdir as mkdir4, writeFile } from "fs/promises";
2609
2805
  import { createRequire as createRequire2 } from "module";
2610
- import { dirname as dirname4, join as join11, resolve as resolve8 } from "path";
2806
+ import { dirname as dirname6, join as join11, resolve as resolve10 } from "path";
2611
2807
  import { parseLessonkitInterchange } from "@lxpack/validators";
2612
2808
  function resolveLessonkitVersion(explicit) {
2613
2809
  if (explicit?.trim()) return explicit.trim();
@@ -2620,7 +2816,7 @@ function resolveLessonkitVersion(explicit) {
2620
2816
  }
2621
2817
  }
2622
2818
  async function exportLkcourse(options) {
2623
- const projectRoot = resolve8(options.projectRoot);
2819
+ const projectRoot = resolve10(options.projectRoot);
2624
2820
  const manifest = options.manifest;
2625
2821
  const spaDistDir = join11(projectRoot, manifest.paths.spaDistDir);
2626
2822
  try {
@@ -2637,6 +2833,16 @@ async function exportLkcourse(options) {
2637
2833
  ]
2638
2834
  };
2639
2835
  }
2836
+ const injectableIssues = validateInjectableAssessments(manifest.course);
2837
+ if (injectableIssues.length > 0) {
2838
+ return {
2839
+ ok: false,
2840
+ issues: injectableIssues.map((issue) => ({
2841
+ path: issue.path,
2842
+ message: issue.message
2843
+ }))
2844
+ };
2845
+ }
2640
2846
  const interchange = descriptorToInterchange(manifest.course);
2641
2847
  const interchangeParsed = parseLessonkitInterchange(interchange);
2642
2848
  if (!interchangeParsed.ok) {
@@ -2728,10 +2934,11 @@ async function exportLkcourse(options) {
2728
2934
  return { ok: false, issues: envelopeCheck.issues };
2729
2935
  }
2730
2936
  zipEntries.set("manifest.json", utf8ToEntry(JSON.stringify(envelope, null, 2)));
2731
- const archivePath = resolve8(
2937
+ const archivePath = resolve10(
2732
2938
  projectRoot,
2733
2939
  options.outPath ?? `${manifest.name}.lkcourse`
2734
2940
  );
2941
+ const archiveRel = options.outPath ?? `${manifest.name}.lkcourse`;
2735
2942
  try {
2736
2943
  assertRealPathUnderRoot(projectRoot, archivePath);
2737
2944
  } catch (err) {
@@ -2739,20 +2946,31 @@ async function exportLkcourse(options) {
2739
2946
  ok: false,
2740
2947
  issues: [
2741
2948
  {
2742
- path: options.outPath ?? `${manifest.name}.lkcourse`,
2949
+ path: archiveRel,
2743
2950
  message: err instanceof Error ? err.message : String(err)
2744
2951
  }
2745
2952
  ]
2746
2953
  };
2747
2954
  }
2748
- if (!isSafeZipEntryPath(options.outPath ?? `${manifest.name}.lkcourse`)) {
2955
+ if (isReservedResolvedOutputPath(projectRoot, archivePath)) {
2956
+ return {
2957
+ ok: false,
2958
+ issues: [
2959
+ {
2960
+ path: archiveRel,
2961
+ message: "output path must not target reserved directories (.git, node_modules, .github)"
2962
+ }
2963
+ ]
2964
+ };
2965
+ }
2966
+ if (!isSafeZipEntryPath(archiveRel)) {
2749
2967
  return {
2750
2968
  ok: false,
2751
2969
  issues: [{ path: "outPath", message: "output path must be a safe relative path" }]
2752
2970
  };
2753
2971
  }
2754
2972
  try {
2755
- await mkdir3(dirname4(archivePath), { recursive: true });
2973
+ await mkdir4(dirname6(archivePath), { recursive: true });
2756
2974
  const zipped = createZip(zipEntries);
2757
2975
  await writeFile(archivePath, zipped);
2758
2976
  } catch (err) {
@@ -2776,6 +2994,29 @@ async function exportLkcourse(options) {
2776
2994
 
2777
2995
  // src/lkcourse/validate.ts
2778
2996
  import { parseLessonkitInterchange as parseLessonkitInterchange2 } from "@lxpack/validators";
2997
+
2998
+ // src/lkcourse/assessmentParity.ts
2999
+ function validateLkcourseAssessmentConsistency(descriptor, interchange) {
3000
+ const issues = [];
3001
+ for (const issue of validateInjectableAssessments(descriptor)) {
3002
+ issues.push({
3003
+ path: `sourceManifest.course.${issue.path}`,
3004
+ message: issue.message
3005
+ });
3006
+ }
3007
+ const expectedIds = extractAssessments(descriptor).map((a) => a.id).sort();
3008
+ const interchangeIds = (interchange.assessments ?? []).map((a) => a.id).sort();
3009
+ const matches = expectedIds.length === interchangeIds.length && expectedIds.every((id, index) => id === interchangeIds[index]);
3010
+ if (!matches) {
3011
+ issues.push({
3012
+ path: "interchange.assessments",
3013
+ message: `injectable assessment ids [${expectedIds.join(", ")}] do not match interchange [${interchangeIds.join(", ")}]`
3014
+ });
3015
+ }
3016
+ return issues;
3017
+ }
3018
+
3019
+ // src/lkcourse/validate.ts
2779
3020
  function validateLkcourseArchiveEntries(entries, _archiveLabel) {
2780
3021
  const issues = [];
2781
3022
  const manifestData = entries.get("manifest.json");
@@ -2808,6 +3049,8 @@ function validateLkcourseArchiveEntries(entries, _archiveLabel) {
2808
3049
  if (!entries.has(spaIndexPath)) {
2809
3050
  issues.push({ path: spaIndexPath, message: "required file missing from archive" });
2810
3051
  }
3052
+ const allowlisted = new Set(envelope.entries.map((entryPath) => entryPath.replace(/\\/g, "/")));
3053
+ const spaDistPrefix = `${spaDistDir}/`;
2811
3054
  for (const entryPath of envelope.entries) {
2812
3055
  if (!entries.has(entryPath)) {
2813
3056
  issues.push({
@@ -2816,6 +3059,16 @@ function validateLkcourseArchiveEntries(entries, _archiveLabel) {
2816
3059
  });
2817
3060
  }
2818
3061
  }
3062
+ for (const zipPath of entries.keys()) {
3063
+ const normalized = zipPath.replace(/\\/g, "/");
3064
+ if (!normalized.startsWith(spaDistPrefix)) continue;
3065
+ if (!allowlisted.has(normalized)) {
3066
+ issues.push({
3067
+ path: zipPath,
3068
+ message: "unlisted file under spaDistDir; not in manifest.entries"
3069
+ });
3070
+ }
3071
+ }
2819
3072
  if (issues.length) return { ok: false, issues };
2820
3073
  let interchangeRaw;
2821
3074
  try {
@@ -2849,6 +3102,12 @@ function validateLkcourseArchiveEntries(entries, _archiveLabel) {
2849
3102
  message: `does not match interchange.course.id (${interchangeCourseId})`
2850
3103
  });
2851
3104
  }
3105
+ issues.push(
3106
+ ...validateLkcourseAssessmentConsistency(
3107
+ envelope.sourceManifest.course,
3108
+ interchange
3109
+ )
3110
+ );
2852
3111
  if (issues.length) return { ok: false, issues };
2853
3112
  const blockTreeData = entries.get("block-tree.json");
2854
3113
  if (blockTreeData) {
@@ -2888,8 +3147,8 @@ function validateLkcourse(archivePath) {
2888
3147
  }
2889
3148
 
2890
3149
  // src/lkcourse/import.ts
2891
- import { access as access3, cp as cp2, mkdir as mkdir4, mkdtemp as mkdtemp3, readdir as readdir3, rename as rename2, rm as rm4, writeFile as writeFile2 } from "fs/promises";
2892
- import { dirname as dirname5, join as join12, resolve as resolve9 } from "path";
3150
+ import { access as access3, cp as cp2, mkdir as mkdir5, mkdtemp as mkdtemp3, readdir as readdir3, rename as rename2, rm as rm4, writeFile as writeFile2 } from "fs/promises";
3151
+ import { dirname as dirname7, join as join12, resolve as resolve11 } from "path";
2893
3152
  var IMPORT_ARTIFACTS = ["lessonkit.json", "dist"];
2894
3153
  async function pathExists2(path) {
2895
3154
  try {
@@ -2910,7 +3169,7 @@ async function renameOrCopy2(from, to, opts) {
2910
3169
  await rm4(from, { recursive: true, force: true });
2911
3170
  }
2912
3171
  }
2913
- async function writeImportTree(stagingDir, manifest, entries, spaDistDir) {
3172
+ async function writeImportTree(stagingDir, manifest, entries, spaDistDir, allowlistedSpaPaths) {
2914
3173
  let fileCount = 0;
2915
3174
  await writeFile2(
2916
3175
  join12(stagingDir, "lessonkit.json"),
@@ -2922,14 +3181,17 @@ async function writeImportTree(stagingDir, manifest, entries, spaDistDir) {
2922
3181
  for (const [entryPath, data] of entries) {
2923
3182
  const normalized = entryPath.replace(/\\/g, "/");
2924
3183
  if (!normalized.startsWith(`${spaDistDir}/`)) continue;
3184
+ if (!allowlistedSpaPaths.has(normalized)) {
3185
+ throw new Error(`unlisted spaDist entry rejected: ${entryPath}`);
3186
+ }
2925
3187
  const relativeUnderSpa = normalized.slice(spaDistDir.length + 1);
2926
3188
  const outPath = join12(stagingDir, spaDistDir, relativeUnderSpa);
2927
- const resolvedOut = resolve9(outPath);
3189
+ const resolvedOut = resolve11(outPath);
2928
3190
  assertRealPathUnderRoot(stagingDir, resolvedOut);
2929
3191
  if (!isSafeZipEntryPath(join12(spaDistDir, relativeUnderSpa))) {
2930
3192
  throw new Error(`unsafe extraction path: ${entryPath}`);
2931
3193
  }
2932
- await mkdir4(dirname5(resolvedOut), { recursive: true });
3194
+ await mkdir5(dirname7(resolvedOut), { recursive: true });
2933
3195
  await writeFile2(resolvedOut, data);
2934
3196
  fileCount += 1;
2935
3197
  }
@@ -2960,30 +3222,49 @@ async function restoreImportBackup(targetDir, backupDir) {
2960
3222
  await renameOrCopy2(backupPath, destPath);
2961
3223
  }
2962
3224
  }
3225
+ async function snapshotPreExistingImportArtifacts(targetDir) {
3226
+ const existing = /* @__PURE__ */ new Set();
3227
+ for (const name of IMPORT_ARTIFACTS) {
3228
+ if (await pathExists2(join12(targetDir, name))) {
3229
+ existing.add(name);
3230
+ }
3231
+ }
3232
+ return existing;
3233
+ }
3234
+ async function rollbackFailedImport(targetDir, backupDir, preExisting) {
3235
+ if (backupDir) {
3236
+ await restoreImportBackup(targetDir, backupDir);
3237
+ }
3238
+ for (const name of IMPORT_ARTIFACTS) {
3239
+ if (preExisting.has(name)) continue;
3240
+ const destPath = join12(targetDir, name);
3241
+ if (await pathExists2(destPath)) {
3242
+ await rm4(destPath, { recursive: true, force: true });
3243
+ }
3244
+ }
3245
+ }
2963
3246
  async function promoteImportStaging(stagingDir, targetDir) {
3247
+ await mkdir5(targetDir, { recursive: true });
2964
3248
  const entries = await readdir3(stagingDir, { withFileTypes: true });
2965
3249
  for (const entry of entries) {
2966
3250
  const srcPath = join12(stagingDir, entry.name);
2967
3251
  const destPath = join12(targetDir, entry.name);
2968
- if (entry.isDirectory()) {
2969
- await cp2(srcPath, destPath, { recursive: true, force: true });
2970
- } else if (entry.isFile()) {
2971
- await mkdir4(dirname5(destPath), { recursive: true });
2972
- await cp2(srcPath, destPath);
3252
+ if (entry.isDirectory() || entry.isFile()) {
3253
+ await renameOrCopy2(srcPath, destPath);
2973
3254
  }
2974
3255
  }
2975
3256
  }
2976
3257
  var promoteImportStagingImpl = promoteImportStaging;
2977
3258
  async function importLkcourse(options) {
2978
- const archivePath = resolve9(options.archivePath);
2979
- const targetDir = resolve9(options.targetDir);
3259
+ const archivePath = resolve11(options.archivePath);
3260
+ const targetDir = resolve11(options.targetDir);
2980
3261
  const validated = validateLkcourse(archivePath);
2981
3262
  if (!validated.ok) return validated;
2982
3263
  const { envelope, interchange } = validated;
2983
3264
  const manifest = envelope.sourceManifest;
2984
3265
  const spaDistDir = manifest.paths.spaDistDir.replace(/\\/g, "/");
2985
3266
  try {
2986
- await mkdir4(targetDir, { recursive: true });
3267
+ await mkdir5(targetDir, { recursive: true });
2987
3268
  assertRealPathUnderRoot(targetDir, targetDir);
2988
3269
  } catch (err) {
2989
3270
  return {
@@ -3000,16 +3281,26 @@ async function importLkcourse(options) {
3000
3281
  if (!read.ok) return read;
3001
3282
  let stagingDir;
3002
3283
  let backupDir;
3284
+ let preExisting;
3003
3285
  try {
3004
3286
  stagingDir = await mkdtemp3(join12(targetDir, ".lkcourse-import-"));
3005
- const fileCount = await writeImportTree(stagingDir, manifest, read.entries, spaDistDir);
3287
+ const allowlistedSpaPaths = new Set(
3288
+ envelope.entries.map((entryPath) => entryPath.replace(/\\/g, "/")).filter((entryPath) => entryPath.startsWith(`${spaDistDir}/`))
3289
+ );
3290
+ const fileCount = await writeImportTree(
3291
+ stagingDir,
3292
+ manifest,
3293
+ read.entries,
3294
+ spaDistDir,
3295
+ allowlistedSpaPaths
3296
+ );
3297
+ preExisting = await snapshotPreExistingImportArtifacts(targetDir);
3006
3298
  backupDir = await backupImportArtifacts(targetDir);
3007
3299
  try {
3008
3300
  await promoteImportStagingImpl(stagingDir, targetDir);
3009
3301
  } catch (promoteError) {
3010
- if (backupDir) {
3011
- await restoreImportBackup(targetDir, backupDir);
3012
- }
3302
+ await rollbackFailedImport(targetDir, backupDir, preExisting);
3303
+ backupDir = void 0;
3013
3304
  throw promoteError;
3014
3305
  }
3015
3306
  if (backupDir) {
@@ -3026,8 +3317,12 @@ async function importLkcourse(options) {
3026
3317
  fileCount
3027
3318
  };
3028
3319
  } catch (err) {
3029
- if (backupDir) {
3320
+ if (preExisting) {
3321
+ await rollbackFailedImport(targetDir, backupDir, preExisting).catch(() => void 0);
3322
+ } else if (backupDir) {
3030
3323
  await restoreImportBackup(targetDir, backupDir).catch(() => void 0);
3324
+ }
3325
+ if (backupDir) {
3031
3326
  await rm4(backupDir, { recursive: true, force: true }).catch(() => void 0);
3032
3327
  }
3033
3328
  if (stagingDir) {
@@ -3078,6 +3373,7 @@ export {
3078
3373
  validateLessonkitProject,
3079
3374
  validateLkcourse,
3080
3375
  validateLkcourseArchiveEntries,
3376
+ validateManifestName,
3081
3377
  validatePackageInputs,
3082
3378
  validateProjectPaths,
3083
3379
  validateReactManifestParity,