@lessonkit/lxpack 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/bridge.cjs +4 -0
- package/dist/bridge.js +4 -0
- package/dist/index.cjs +426 -129
- package/dist/index.d.cts +51 -2
- package/dist/index.d.ts +51 -2
- package/dist/index.js +417 -121
- package/lessonkit-manifest.v1.json +69 -2
- package/package.json +10 -10
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 (
|
|
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 {
|
|
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
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
299
|
+
const physical = realpathSync.native(probe);
|
|
300
|
+
return suffix ? join(physical, suffix) : physical;
|
|
244
301
|
} catch {
|
|
245
|
-
|
|
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
|
|
256
|
-
const
|
|
257
|
-
|
|
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
|
|
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 =
|
|
307
|
-
const targetReal =
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
@@ -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()
|
|
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 (
|
|
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([
|
|
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
|
|
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 (!
|
|
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 (
|
|
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 (!
|
|
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
|
|
1334
|
-
import * as
|
|
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 =
|
|
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(
|
|
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(
|
|
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 =
|
|
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/
|
|
1931
|
+
// src/packaging/relocateOutput.ts
|
|
1794
1932
|
import * as fsp2 from "fs/promises";
|
|
1795
|
-
import { dirname as
|
|
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
|
|
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
|
|
1833
|
-
const defaultOutput =
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
2062
|
-
|
|
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
|
|
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
|
-
|
|
2137
|
-
|
|
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
|
|
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
|
|
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 (!
|
|
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 (!
|
|
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
|
|
2804
|
+
import { mkdir as mkdir4, writeFile } from "fs/promises";
|
|
2609
2805
|
import { createRequire as createRequire2 } from "module";
|
|
2610
|
-
import { dirname as
|
|
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 =
|
|
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 =
|
|
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:
|
|
2949
|
+
path: archiveRel,
|
|
2743
2950
|
message: err instanceof Error ? err.message : String(err)
|
|
2744
2951
|
}
|
|
2745
2952
|
]
|
|
2746
2953
|
};
|
|
2747
2954
|
}
|
|
2748
|
-
if (
|
|
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
|
|
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
|
|
2892
|
-
import { dirname as
|
|
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 =
|
|
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
|
|
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
|
|
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 =
|
|
2979
|
-
const 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
|
|
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
|
|
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
|
-
|
|
3011
|
-
|
|
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 (
|
|
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,
|