@lessonkit/lxpack 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +421 -164
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +443 -186
- package/lessonkit-manifest.v1.json +99 -7
- package/package.json +3 -3
package/dist/index.cjs
CHANGED
|
@@ -193,6 +193,14 @@ function parseAssessmentDescriptor(raw) {
|
|
|
193
193
|
correctTargetIds: Array.isArray(raw.correctTargetIds) ? raw.correctTargetIds.filter((id) => typeof id === "string") : []
|
|
194
194
|
};
|
|
195
195
|
}
|
|
196
|
+
if (typeof kind === "string" && kind !== "mcq" && kind !== "trueFalse" && kind !== "fillInBlanks" && kind !== "findHotspot" && kind !== "findMultipleHotspots") {
|
|
197
|
+
return {
|
|
198
|
+
kind,
|
|
199
|
+
...base,
|
|
200
|
+
choices: [],
|
|
201
|
+
answer: ""
|
|
202
|
+
};
|
|
203
|
+
}
|
|
196
204
|
return {
|
|
197
205
|
kind: kind === "mcq" ? "mcq" : void 0,
|
|
198
206
|
...base,
|
|
@@ -242,6 +250,7 @@ function parseCourseDescriptorInput(input) {
|
|
|
242
250
|
|
|
243
251
|
// src/descriptor/validateCourse.ts
|
|
244
252
|
var import_core3 = require("@lessonkit/core");
|
|
253
|
+
var import_themes2 = require("@lessonkit/themes");
|
|
245
254
|
|
|
246
255
|
// src/spaPath.ts
|
|
247
256
|
var import_node_fs = require("fs");
|
|
@@ -271,6 +280,28 @@ function assertResolvedPathUnderRoot(root, target) {
|
|
|
271
280
|
throw new Error(`unsafe path escapes project root: ${target}`);
|
|
272
281
|
}
|
|
273
282
|
}
|
|
283
|
+
function resolveExistingPathUnderRoot(rootReal, rootResolved, targetResolved) {
|
|
284
|
+
const rel = (0, import_node_path.relative)(rootResolved, targetResolved);
|
|
285
|
+
if (rel.startsWith("..") || rel.includes(`..${import_node_path.sep}`)) {
|
|
286
|
+
throw new Error(`unsafe path escapes project root: ${targetResolved}`);
|
|
287
|
+
}
|
|
288
|
+
const segments = rel.split(/[/\\]/).filter((s) => s.length > 0 && s !== ".");
|
|
289
|
+
let current = rootReal;
|
|
290
|
+
for (const segment of segments) {
|
|
291
|
+
const next = (0, import_node_path.join)(current, segment);
|
|
292
|
+
if ((0, import_node_fs.existsSync)(next)) {
|
|
293
|
+
try {
|
|
294
|
+
current = (0, import_node_fs.realpathSync)(next);
|
|
295
|
+
} catch {
|
|
296
|
+
current = next;
|
|
297
|
+
}
|
|
298
|
+
} else {
|
|
299
|
+
current = next;
|
|
300
|
+
}
|
|
301
|
+
assertResolvedPathUnderRoot(rootReal, current);
|
|
302
|
+
}
|
|
303
|
+
return current;
|
|
304
|
+
}
|
|
274
305
|
function assertRealPathUnderRoot(root, target) {
|
|
275
306
|
const rootResolved = resolveComparablePath(root);
|
|
276
307
|
const targetResolved = resolveComparablePath(target);
|
|
@@ -280,17 +311,12 @@ function assertRealPathUnderRoot(root, target) {
|
|
|
280
311
|
} catch {
|
|
281
312
|
rootReal = rootResolved;
|
|
282
313
|
}
|
|
283
|
-
let targetCheck;
|
|
284
314
|
try {
|
|
285
|
-
targetCheck = (0, import_node_fs.realpathSync)(targetResolved);
|
|
315
|
+
const targetCheck = (0, import_node_fs.realpathSync)(targetResolved);
|
|
316
|
+
assertResolvedPathUnderRoot(rootReal, targetCheck);
|
|
286
317
|
} catch {
|
|
287
|
-
|
|
288
|
-
if (rel.startsWith("..") || rel.includes(`..${import_node_path.sep}`)) {
|
|
289
|
-
throw new Error(`unsafe path escapes project root: ${target}`);
|
|
290
|
-
}
|
|
291
|
-
targetCheck = (0, import_node_path.resolve)(rootReal, rel);
|
|
318
|
+
resolveExistingPathUnderRoot(rootReal, rootResolved, targetResolved);
|
|
292
319
|
}
|
|
293
|
-
assertResolvedPathUnderRoot(rootReal, targetCheck);
|
|
294
320
|
}
|
|
295
321
|
function normalizePathForComparison(p) {
|
|
296
322
|
const resolved = resolveComparablePath(p);
|
|
@@ -331,7 +357,12 @@ function themeToLxpackRuntime(input) {
|
|
|
331
357
|
// src/descriptor/validateAssessments.ts
|
|
332
358
|
var import_core2 = require("@lessonkit/core");
|
|
333
359
|
var validateMcqLike = (assessment, path, issues) => {
|
|
334
|
-
if (!("choices" in assessment) || !(
|
|
360
|
+
if (!("choices" in assessment) || !Array.isArray(assessment.choices)) {
|
|
361
|
+
issues.push({ path: `${path}.choices`, message: "choices is required for mcq" });
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (!("answer" in assessment) || typeof assessment.answer !== "string") {
|
|
365
|
+
issues.push({ path: `${path}.answer`, message: "answer is required for mcq" });
|
|
335
366
|
return;
|
|
336
367
|
}
|
|
337
368
|
const trimmedChoices = assessment.choices.map((c) => c.trim()).filter((c) => c.length > 0);
|
|
@@ -344,6 +375,22 @@ var validateMcqLike = (assessment, path, issues) => {
|
|
|
344
375
|
issues.push({ path: `${path}.answer`, message: "answer must match a choice" });
|
|
345
376
|
}
|
|
346
377
|
};
|
|
378
|
+
function countStarDelimitedBlanks(template) {
|
|
379
|
+
const matches = template.match(/\*[^*]+\*/g);
|
|
380
|
+
return matches?.length ?? 0;
|
|
381
|
+
}
|
|
382
|
+
function maxAchievableAssessmentScore(assessment) {
|
|
383
|
+
const kind = assessment.kind ?? "mcq";
|
|
384
|
+
if (kind === "fillInBlanks" && assessment.kind === "fillInBlanks") {
|
|
385
|
+
const explicit = assessment.blanks?.filter((b) => b?.id?.trim() && b?.answer?.trim()).length ?? 0;
|
|
386
|
+
if (explicit > 0) return explicit;
|
|
387
|
+
return countStarDelimitedBlanks(assessment.template ?? "");
|
|
388
|
+
}
|
|
389
|
+
if (kind === "findMultipleHotspots" && assessment.kind === "findMultipleHotspots") {
|
|
390
|
+
return assessment.correctTargetIds?.map((id) => id.trim()).filter((id) => id.length > 0).length ?? 0;
|
|
391
|
+
}
|
|
392
|
+
return 1;
|
|
393
|
+
}
|
|
347
394
|
var ASSESSMENT_VALIDATORS = {
|
|
348
395
|
mcq: validateMcqLike,
|
|
349
396
|
trueFalse: (assessment, path, issues) => {
|
|
@@ -356,9 +403,33 @@ var ASSESSMENT_VALIDATORS = {
|
|
|
356
403
|
issues.push({ path: `${path}.template`, message: "template is required for fillInBlanks" });
|
|
357
404
|
}
|
|
358
405
|
},
|
|
359
|
-
findHotspot: () => {
|
|
406
|
+
findHotspot: (assessment, path, issues) => {
|
|
407
|
+
if (assessment.kind !== "findHotspot") return;
|
|
408
|
+
if (!assessment.src?.trim()) {
|
|
409
|
+
issues.push({ path: `${path}.src`, message: "src is required for findHotspot" });
|
|
410
|
+
}
|
|
411
|
+
if (!assessment.alt?.trim()) {
|
|
412
|
+
issues.push({ path: `${path}.alt`, message: "alt is required for findHotspot" });
|
|
413
|
+
}
|
|
414
|
+
if (!assessment.correctTargetId?.trim()) {
|
|
415
|
+
issues.push({ path: `${path}.correctTargetId`, message: "correctTargetId is required for findHotspot" });
|
|
416
|
+
}
|
|
360
417
|
},
|
|
361
|
-
findMultipleHotspots: () => {
|
|
418
|
+
findMultipleHotspots: (assessment, path, issues) => {
|
|
419
|
+
if (assessment.kind !== "findMultipleHotspots") return;
|
|
420
|
+
if (!assessment.src?.trim()) {
|
|
421
|
+
issues.push({ path: `${path}.src`, message: "src is required for findMultipleHotspots" });
|
|
422
|
+
}
|
|
423
|
+
if (!assessment.alt?.trim()) {
|
|
424
|
+
issues.push({ path: `${path}.alt`, message: "alt is required for findMultipleHotspots" });
|
|
425
|
+
}
|
|
426
|
+
const ids = assessment.correctTargetIds?.map((id) => id.trim()).filter((id) => id.length > 0) ?? [];
|
|
427
|
+
if (!ids.length) {
|
|
428
|
+
issues.push({
|
|
429
|
+
path: `${path}.correctTargetIds`,
|
|
430
|
+
message: "at least one non-empty correctTargetId is required for findMultipleHotspots"
|
|
431
|
+
});
|
|
432
|
+
}
|
|
362
433
|
}
|
|
363
434
|
};
|
|
364
435
|
function validateAssessmentEntry(assessment, index, issues, checkIds) {
|
|
@@ -374,14 +445,38 @@ function validateAssessmentEntry(assessment, index, issues, checkIds) {
|
|
|
374
445
|
if (!assessment.question?.trim()) {
|
|
375
446
|
issues.push({ path: `${path}.question`, message: "question is required" });
|
|
376
447
|
}
|
|
448
|
+
const knownKinds = Object.keys(ASSESSMENT_VALIDATORS);
|
|
449
|
+
if (assessment.kind !== void 0 && assessment.kind !== "mcq" && !knownKinds.includes(assessment.kind)) {
|
|
450
|
+
issues.push({
|
|
451
|
+
path: `${path}.kind`,
|
|
452
|
+
message: `unknown kind; use one of: ${knownKinds.join(", ")}`
|
|
453
|
+
});
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
377
456
|
const kind = assessment.kind ?? "mcq";
|
|
378
|
-
ASSESSMENT_VALIDATORS[kind]
|
|
457
|
+
const validator = ASSESSMENT_VALIDATORS[kind];
|
|
458
|
+
if (!validator) {
|
|
459
|
+
issues.push({
|
|
460
|
+
path: `${path}.kind`,
|
|
461
|
+
message: `unknown kind; use one of: ${knownKinds.join(", ")}`
|
|
462
|
+
});
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
validator(assessment, path, issues);
|
|
379
466
|
const passingScore = assessment.passingScore;
|
|
380
467
|
if (passingScore !== void 0 && !(Number.isFinite(passingScore) && passingScore > 0)) {
|
|
381
468
|
issues.push({
|
|
382
469
|
path: `${path}.passingScore`,
|
|
383
470
|
message: "passingScore must be greater than 0 (absolute point threshold)"
|
|
384
471
|
});
|
|
472
|
+
} else if (passingScore !== void 0) {
|
|
473
|
+
const maxAchievable = maxAchievableAssessmentScore(assessment);
|
|
474
|
+
if (maxAchievable > 0 && passingScore > maxAchievable) {
|
|
475
|
+
issues.push({
|
|
476
|
+
path: `${path}.passingScore`,
|
|
477
|
+
message: `passingScore cannot exceed achievable score (${maxAchievable}) for this assessment kind`
|
|
478
|
+
});
|
|
479
|
+
}
|
|
385
480
|
}
|
|
386
481
|
}
|
|
387
482
|
|
|
@@ -415,13 +510,23 @@ function validateCourseDescriptor(input) {
|
|
|
415
510
|
});
|
|
416
511
|
}
|
|
417
512
|
if (input.theme?.theme) {
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
513
|
+
const themeResult = (0, import_themes2.validateTheme)(input.theme.theme);
|
|
514
|
+
if (!themeResult.ok) {
|
|
515
|
+
for (const issue of themeResult.issues) {
|
|
516
|
+
issues.push({
|
|
517
|
+
path: issue.path ? `theme.theme.${issue.path}` : "theme.theme",
|
|
518
|
+
message: issue.message
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
} else {
|
|
522
|
+
try {
|
|
523
|
+
themeToLxpackRuntime({ preset: themePreset, theme: themeResult.theme });
|
|
524
|
+
} catch (err) {
|
|
525
|
+
issues.push({
|
|
526
|
+
path: "theme.theme",
|
|
527
|
+
message: err instanceof Error ? err.message : "invalid custom theme"
|
|
528
|
+
});
|
|
529
|
+
}
|
|
425
530
|
}
|
|
426
531
|
}
|
|
427
532
|
const completionThreshold = input.tracking?.completion?.threshold;
|
|
@@ -492,19 +597,102 @@ function validateCourseDescriptor(input) {
|
|
|
492
597
|
return issues;
|
|
493
598
|
}
|
|
494
599
|
|
|
600
|
+
// src/assessments.ts
|
|
601
|
+
function slugChoiceId(text, index) {
|
|
602
|
+
const base = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 32);
|
|
603
|
+
const stem = base.length ? base : "choice";
|
|
604
|
+
return `${stem}-${index + 1}`;
|
|
605
|
+
}
|
|
606
|
+
function mcqToLxpack(assessment) {
|
|
607
|
+
const choices = assessment.choices.map((text, index) => {
|
|
608
|
+
const id = slugChoiceId(text, index);
|
|
609
|
+
return {
|
|
610
|
+
id,
|
|
611
|
+
text,
|
|
612
|
+
correct: text === assessment.answer
|
|
613
|
+
};
|
|
614
|
+
});
|
|
615
|
+
return {
|
|
616
|
+
id: assessment.checkId,
|
|
617
|
+
passingScore: assessment.passingScore ?? 1,
|
|
618
|
+
questions: [
|
|
619
|
+
{
|
|
620
|
+
id: "q1",
|
|
621
|
+
prompt: assessment.question,
|
|
622
|
+
choices
|
|
623
|
+
}
|
|
624
|
+
]
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
function assessmentDescriptorToLxpack(assessment) {
|
|
628
|
+
const kind = assessment.kind ?? "mcq";
|
|
629
|
+
if (kind === "trueFalse" && assessment.kind === "trueFalse") {
|
|
630
|
+
const choices = ["True", "False"];
|
|
631
|
+
const answerText = assessment.answer ? "True" : "False";
|
|
632
|
+
return mcqToLxpack({
|
|
633
|
+
kind: "mcq",
|
|
634
|
+
checkId: assessment.checkId,
|
|
635
|
+
question: assessment.question,
|
|
636
|
+
choices,
|
|
637
|
+
answer: answerText,
|
|
638
|
+
passingScore: assessment.passingScore
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
if (kind === "fillInBlanks") {
|
|
642
|
+
return null;
|
|
643
|
+
}
|
|
644
|
+
if (kind === "findHotspot" && assessment.kind === "findHotspot") {
|
|
645
|
+
return mcqToLxpack({
|
|
646
|
+
kind: "mcq",
|
|
647
|
+
checkId: assessment.checkId,
|
|
648
|
+
question: assessment.question,
|
|
649
|
+
choices: [assessment.correctTargetId, "other"],
|
|
650
|
+
answer: assessment.correctTargetId,
|
|
651
|
+
passingScore: assessment.passingScore
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
if (kind === "findMultipleHotspots") {
|
|
655
|
+
return null;
|
|
656
|
+
}
|
|
657
|
+
if ("choices" in assessment && "answer" in assessment && typeof assessment.answer === "string") {
|
|
658
|
+
return mcqToLxpack(assessment);
|
|
659
|
+
}
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
function extractAssessments(descriptor) {
|
|
663
|
+
return (descriptor.assessments ?? []).map(assessmentDescriptorToLxpack).filter((a) => a !== null);
|
|
664
|
+
}
|
|
665
|
+
|
|
495
666
|
// src/descriptor/validateForTarget.ts
|
|
667
|
+
var LMS_SHELL_TARGETS = /* @__PURE__ */ new Set([
|
|
668
|
+
"scorm12",
|
|
669
|
+
"scorm2004",
|
|
670
|
+
"standalone",
|
|
671
|
+
"xapi",
|
|
672
|
+
"cmi5"
|
|
673
|
+
]);
|
|
496
674
|
function validateDescriptorForExportTarget(descriptor, target) {
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
{
|
|
675
|
+
const issues = [];
|
|
676
|
+
if (target === "xapi" || target === "cmi5") {
|
|
677
|
+
const activityIri = descriptor.tracking?.xapi?.activityIri?.trim();
|
|
678
|
+
if (!activityIri) {
|
|
679
|
+
issues.push({
|
|
502
680
|
path: "course.tracking.xapi.activityIri",
|
|
503
681
|
message: "tracking.xapi.activityIri is required for xapi and cmi5 export targets"
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
if (LMS_SHELL_TARGETS.has(target)) {
|
|
686
|
+
(descriptor.assessments ?? []).forEach((assessment, index) => {
|
|
687
|
+
if (assessmentDescriptorToLxpack(assessment) === null) {
|
|
688
|
+
issues.push({
|
|
689
|
+
path: `assessments[${index}]`,
|
|
690
|
+
message: `assessment kind "${assessment.kind ?? "mcq"}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes for target "${target}"`
|
|
691
|
+
});
|
|
504
692
|
}
|
|
505
|
-
|
|
693
|
+
});
|
|
506
694
|
}
|
|
507
|
-
return
|
|
695
|
+
return issues;
|
|
508
696
|
}
|
|
509
697
|
|
|
510
698
|
// src/validateDescriptor.ts
|
|
@@ -593,72 +781,6 @@ function mapLessonkitIds(descriptor) {
|
|
|
593
781
|
return { courseId, lessonIds, checkIds };
|
|
594
782
|
}
|
|
595
783
|
|
|
596
|
-
// src/assessments.ts
|
|
597
|
-
function slugChoiceId(text, index) {
|
|
598
|
-
const base = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 32);
|
|
599
|
-
const stem = base.length ? base : "choice";
|
|
600
|
-
return `${stem}-${index + 1}`;
|
|
601
|
-
}
|
|
602
|
-
function mcqToLxpack(assessment) {
|
|
603
|
-
const choices = assessment.choices.map((text, index) => {
|
|
604
|
-
const id = slugChoiceId(text, index);
|
|
605
|
-
return {
|
|
606
|
-
id,
|
|
607
|
-
text,
|
|
608
|
-
correct: text === assessment.answer
|
|
609
|
-
};
|
|
610
|
-
});
|
|
611
|
-
return {
|
|
612
|
-
id: assessment.checkId,
|
|
613
|
-
passingScore: assessment.passingScore ?? 1,
|
|
614
|
-
questions: [
|
|
615
|
-
{
|
|
616
|
-
id: "q1",
|
|
617
|
-
prompt: assessment.question,
|
|
618
|
-
choices
|
|
619
|
-
}
|
|
620
|
-
]
|
|
621
|
-
};
|
|
622
|
-
}
|
|
623
|
-
function assessmentDescriptorToLxpack(assessment) {
|
|
624
|
-
const kind = assessment.kind ?? "mcq";
|
|
625
|
-
if (kind === "trueFalse" && assessment.kind === "trueFalse") {
|
|
626
|
-
const choices = ["True", "False"];
|
|
627
|
-
const answerText = assessment.answer ? "True" : "False";
|
|
628
|
-
return mcqToLxpack({
|
|
629
|
-
kind: "mcq",
|
|
630
|
-
checkId: assessment.checkId,
|
|
631
|
-
question: assessment.question,
|
|
632
|
-
choices,
|
|
633
|
-
answer: answerText,
|
|
634
|
-
passingScore: assessment.passingScore
|
|
635
|
-
});
|
|
636
|
-
}
|
|
637
|
-
if (kind === "fillInBlanks") {
|
|
638
|
-
return null;
|
|
639
|
-
}
|
|
640
|
-
if (kind === "findHotspot" && assessment.kind === "findHotspot") {
|
|
641
|
-
return mcqToLxpack({
|
|
642
|
-
kind: "mcq",
|
|
643
|
-
checkId: assessment.checkId,
|
|
644
|
-
question: assessment.question,
|
|
645
|
-
choices: [assessment.correctTargetId, "other"],
|
|
646
|
-
answer: assessment.correctTargetId,
|
|
647
|
-
passingScore: assessment.passingScore
|
|
648
|
-
});
|
|
649
|
-
}
|
|
650
|
-
if (kind === "findMultipleHotspots") {
|
|
651
|
-
return null;
|
|
652
|
-
}
|
|
653
|
-
if ("choices" in assessment && "answer" in assessment && typeof assessment.answer === "string") {
|
|
654
|
-
return mcqToLxpack(assessment);
|
|
655
|
-
}
|
|
656
|
-
return null;
|
|
657
|
-
}
|
|
658
|
-
function extractAssessments(descriptor) {
|
|
659
|
-
return (descriptor.assessments ?? []).map(assessmentDescriptorToLxpack).filter((a) => a !== null);
|
|
660
|
-
}
|
|
661
|
-
|
|
662
784
|
// src/interchange.ts
|
|
663
785
|
function mapDescriptorTracking(tracking) {
|
|
664
786
|
if (!tracking) return void 0;
|
|
@@ -821,44 +943,71 @@ var import_node_path5 = require("path");
|
|
|
821
943
|
function validatePackageInputs(options) {
|
|
822
944
|
const { target, output, outputBaseDir } = options;
|
|
823
945
|
const outDir = (0, import_node_path5.resolve)(options.outDir);
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
courseDir: outDir,
|
|
832
|
-
target,
|
|
833
|
-
issues: [
|
|
834
|
-
{
|
|
835
|
-
path: "outDir",
|
|
836
|
-
message: (
|
|
837
|
-
/* v8 ignore next */
|
|
838
|
-
err instanceof Error ? err.message : String(err)
|
|
839
|
-
)
|
|
840
|
-
}
|
|
841
|
-
]
|
|
842
|
-
};
|
|
843
|
-
}
|
|
946
|
+
if (!options.projectRoot) {
|
|
947
|
+
return {
|
|
948
|
+
ok: false,
|
|
949
|
+
courseDir: outDir,
|
|
950
|
+
target,
|
|
951
|
+
issues: [{ path: "projectRoot", message: "projectRoot is required for packageLessonkitCourse" }]
|
|
952
|
+
};
|
|
844
953
|
}
|
|
845
|
-
|
|
954
|
+
const projectRoot = (0, import_node_path5.resolve)(options.projectRoot);
|
|
955
|
+
try {
|
|
956
|
+
assertRealPathUnderRoot(projectRoot, outDir);
|
|
957
|
+
} catch (err) {
|
|
846
958
|
return {
|
|
847
959
|
ok: false,
|
|
848
960
|
courseDir: outDir,
|
|
849
961
|
target,
|
|
850
|
-
issues: [
|
|
962
|
+
issues: [
|
|
963
|
+
{
|
|
964
|
+
path: "outDir",
|
|
965
|
+
message: (
|
|
966
|
+
/* v8 ignore next */
|
|
967
|
+
err instanceof Error ? err.message : String(err)
|
|
968
|
+
)
|
|
969
|
+
}
|
|
970
|
+
]
|
|
851
971
|
};
|
|
852
972
|
}
|
|
853
|
-
if (
|
|
973
|
+
if (outputBaseDir && !isSafeRelativeSpaPath(outputBaseDir)) {
|
|
854
974
|
return {
|
|
855
975
|
ok: false,
|
|
856
976
|
courseDir: outDir,
|
|
857
977
|
target,
|
|
858
|
-
issues: [{ path: "
|
|
978
|
+
issues: [{ path: "outputBaseDir", message: `unsafe outputBaseDir: ${outputBaseDir}` }]
|
|
859
979
|
};
|
|
860
980
|
}
|
|
861
|
-
if (
|
|
981
|
+
if (output && !isSafeRelativeSpaPath(output)) {
|
|
982
|
+
if ((0, import_node_path5.isAbsolute)(output)) {
|
|
983
|
+
try {
|
|
984
|
+
assertRealPathUnderRoot(projectRoot, (0, import_node_path5.resolve)(output));
|
|
985
|
+
} catch (err) {
|
|
986
|
+
return {
|
|
987
|
+
ok: false,
|
|
988
|
+
courseDir: outDir,
|
|
989
|
+
target,
|
|
990
|
+
issues: [
|
|
991
|
+
{
|
|
992
|
+
path: "output",
|
|
993
|
+
message: (
|
|
994
|
+
/* v8 ignore next */
|
|
995
|
+
err instanceof Error ? err.message : `unsafe output: ${output}`
|
|
996
|
+
)
|
|
997
|
+
}
|
|
998
|
+
]
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
} else {
|
|
1002
|
+
return {
|
|
1003
|
+
ok: false,
|
|
1004
|
+
courseDir: outDir,
|
|
1005
|
+
target,
|
|
1006
|
+
issues: [{ path: "output", message: `unsafe output: ${output}` }]
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
if (outputBaseDir) {
|
|
862
1011
|
const resolvedOutputBase = (0, import_node_path5.resolve)(projectRoot, outputBaseDir);
|
|
863
1012
|
try {
|
|
864
1013
|
assertRealPathUnderRoot(projectRoot, resolvedOutputBase);
|
|
@@ -879,8 +1028,8 @@ function validatePackageInputs(options) {
|
|
|
879
1028
|
};
|
|
880
1029
|
}
|
|
881
1030
|
}
|
|
882
|
-
if (
|
|
883
|
-
const resolvedOutput = (0, import_node_path5.resolve)(projectRoot, output);
|
|
1031
|
+
if (output) {
|
|
1032
|
+
const resolvedOutput = (0, import_node_path5.isAbsolute)(output) ? (0, import_node_path5.resolve)(output) : (0, import_node_path5.resolve)(projectRoot, output);
|
|
884
1033
|
try {
|
|
885
1034
|
assertRealPathUnderRoot(projectRoot, resolvedOutput);
|
|
886
1035
|
} catch (err) {
|
|
@@ -917,11 +1066,11 @@ function remapArtifactPaths(stagingRoot, outDir, artifactPath) {
|
|
|
917
1066
|
if (!artifactPath) return void 0;
|
|
918
1067
|
const resolved = resolveComparablePath(artifactPath);
|
|
919
1068
|
if (!isResolvedPathUnderRoot(stagingRoot, resolved)) {
|
|
920
|
-
|
|
1069
|
+
throw new Error(`${artifactPath} is outside the staging directory`);
|
|
921
1070
|
}
|
|
922
1071
|
const rel = relativePathUnderRoot(stagingRoot, resolved);
|
|
923
1072
|
if (rel.startsWith("..") || (0, import_node_path5.isAbsolute)(rel)) {
|
|
924
|
-
|
|
1073
|
+
throw new Error(`${artifactPath} is outside the staging directory`);
|
|
925
1074
|
}
|
|
926
1075
|
if (!rel) return outDir;
|
|
927
1076
|
if (/^[a-zA-Z]:[/\\]/.test(outDir)) {
|
|
@@ -952,6 +1101,68 @@ async function renameOrCopy(from, to) {
|
|
|
952
1101
|
await fsp.rm(from, { recursive: true, force: true });
|
|
953
1102
|
}
|
|
954
1103
|
}
|
|
1104
|
+
function promoteLockPath(outDir) {
|
|
1105
|
+
const parent = (0, import_node_path6.dirname)(outDir);
|
|
1106
|
+
const hash = (0, import_node_crypto.createHash)("sha256").update((0, import_node_path6.resolve)(outDir)).digest("hex").slice(0, 16);
|
|
1107
|
+
return (0, import_node_path6.join)(parent, `.lk-promote-lock-${hash}`);
|
|
1108
|
+
}
|
|
1109
|
+
var STALE_LOCK_TTL_MS = 5 * 60 * 1e3;
|
|
1110
|
+
async function isStalePromoteLock(lockPath) {
|
|
1111
|
+
try {
|
|
1112
|
+
const stat2 = await fsp.stat(lockPath);
|
|
1113
|
+
if (Date.now() - stat2.mtimeMs > STALE_LOCK_TTL_MS) return true;
|
|
1114
|
+
const content = await fsp.readFile(lockPath, "utf8");
|
|
1115
|
+
const pid = Number.parseInt(content.trim(), 10);
|
|
1116
|
+
if (!Number.isFinite(pid) || pid <= 0) return true;
|
|
1117
|
+
try {
|
|
1118
|
+
process.kill(pid, 0);
|
|
1119
|
+
return false;
|
|
1120
|
+
} catch {
|
|
1121
|
+
return true;
|
|
1122
|
+
}
|
|
1123
|
+
} catch {
|
|
1124
|
+
return true;
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
async function withPromoteLock(outDir, fn) {
|
|
1128
|
+
const lockPath = promoteLockPath(outDir);
|
|
1129
|
+
await fsp.mkdir((0, import_node_path6.dirname)(outDir), { recursive: true });
|
|
1130
|
+
let lockHandle;
|
|
1131
|
+
for (let attempt = 0; attempt < 200; attempt++) {
|
|
1132
|
+
try {
|
|
1133
|
+
lockHandle = await fsp.open(lockPath, "wx");
|
|
1134
|
+
await lockHandle.writeFile(`${process.pid}
|
|
1135
|
+
`, "utf8");
|
|
1136
|
+
break;
|
|
1137
|
+
} catch (err) {
|
|
1138
|
+
const code = err && typeof err === "object" && "code" in err ? String(err.code) : "";
|
|
1139
|
+
if (code !== "EEXIST") throw err;
|
|
1140
|
+
if (await isStalePromoteLock(lockPath)) {
|
|
1141
|
+
await fsp.rm(lockPath, { force: true }).catch(
|
|
1142
|
+
/* v8 ignore next */
|
|
1143
|
+
() => void 0
|
|
1144
|
+
);
|
|
1145
|
+
continue;
|
|
1146
|
+
}
|
|
1147
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, 25));
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
if (!lockHandle) {
|
|
1151
|
+
throw new Error(`[lessonkit/lxpack] timed out acquiring promote lock for ${outDir}`);
|
|
1152
|
+
}
|
|
1153
|
+
try {
|
|
1154
|
+
return await fn();
|
|
1155
|
+
} finally {
|
|
1156
|
+
await lockHandle.close().catch(
|
|
1157
|
+
/* v8 ignore next */
|
|
1158
|
+
() => void 0
|
|
1159
|
+
);
|
|
1160
|
+
await fsp.rm(lockPath, { force: true }).catch(
|
|
1161
|
+
/* v8 ignore next */
|
|
1162
|
+
() => void 0
|
|
1163
|
+
);
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
955
1166
|
async function assertNoLegacyPromoteArtifacts(outDir) {
|
|
956
1167
|
const legacyTmp = `${outDir}.tmp-promote`;
|
|
957
1168
|
const legacyBak = `${outDir}.bak`;
|
|
@@ -965,45 +1176,57 @@ async function assertNoLegacyPromoteArtifacts(outDir) {
|
|
|
965
1176
|
}
|
|
966
1177
|
}
|
|
967
1178
|
async function promoteStagingToOutDir(stagingDir, outDir) {
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
await renameOrCopy(outDir, backup);
|
|
976
|
-
}
|
|
977
|
-
try {
|
|
978
|
-
await renameOrCopy(tmpPromote, outDir);
|
|
979
|
-
} catch (promoteError) {
|
|
1179
|
+
return withPromoteLock(outDir, async () => {
|
|
1180
|
+
await assertNoLegacyPromoteArtifacts(outDir);
|
|
1181
|
+
const parent = (0, import_node_path6.dirname)(outDir);
|
|
1182
|
+
const tmpPromote = await fsp.mkdtemp((0, import_node_path6.join)(parent, ".lk-promote-"));
|
|
1183
|
+
await renameOrCopy(stagingDir, tmpPromote);
|
|
1184
|
+
const hadOutDir = await pathExists(outDir);
|
|
1185
|
+
const backup = hadOutDir ? await fsp.mkdtemp((0, import_node_path6.join)(parent, ".lk-backup-")) : void 0;
|
|
980
1186
|
if (hadOutDir && backup) {
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
1187
|
+
await renameOrCopy(outDir, backup);
|
|
1188
|
+
}
|
|
1189
|
+
try {
|
|
1190
|
+
await renameOrCopy(tmpPromote, outDir);
|
|
1191
|
+
} catch (promoteError) {
|
|
1192
|
+
if (hadOutDir && backup) {
|
|
985
1193
|
try {
|
|
986
|
-
await renameOrCopy(
|
|
987
|
-
} catch {
|
|
1194
|
+
await renameOrCopy(backup, outDir);
|
|
1195
|
+
} catch (restoreError) {
|
|
1196
|
+
const failedPromote2 = (0, import_node_path6.join)(parent, `.lk-failed-promote-${(0, import_node_crypto.randomUUID)()}`);
|
|
1197
|
+
try {
|
|
1198
|
+
await renameOrCopy(tmpPromote, failedPromote2);
|
|
1199
|
+
} catch {
|
|
1200
|
+
await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(
|
|
1201
|
+
/* v8 ignore next */
|
|
1202
|
+
() => void 0
|
|
1203
|
+
);
|
|
1204
|
+
}
|
|
1205
|
+
const promoteMsg = promoteError instanceof Error ? promoteError.message : String(promoteError);
|
|
1206
|
+
const restoreMsg = restoreError instanceof Error ? restoreError.message : String(restoreError);
|
|
1207
|
+
throw new Error(
|
|
1208
|
+
`[lessonkit/lxpack] promote failed (${promoteMsg}) and could not restore ${outDir} (${restoreMsg}). Recovery: previous output may be in ${backup}; staged package may be in ${failedPromote2}.`
|
|
1209
|
+
);
|
|
1210
|
+
}
|
|
1211
|
+
} else {
|
|
1212
|
+
try {
|
|
1213
|
+
await renameOrCopy(tmpPromote, stagingDir);
|
|
1214
|
+
} catch (restoreError) {
|
|
1215
|
+
console.warn(
|
|
1216
|
+
`[lessonkit/lxpack] failed to restore ${stagingDir} after promote error:`,
|
|
1217
|
+
restoreError instanceof Error ? restoreError.message : restoreError
|
|
1218
|
+
);
|
|
988
1219
|
await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(
|
|
989
1220
|
/* v8 ignore next */
|
|
990
1221
|
() => void 0
|
|
991
1222
|
);
|
|
992
1223
|
}
|
|
993
|
-
|
|
994
|
-
const restoreMsg = restoreError instanceof Error ? restoreError.message : String(restoreError);
|
|
995
|
-
throw new Error(
|
|
996
|
-
`[lessonkit/lxpack] promote failed (${promoteMsg}) and could not restore ${outDir} (${restoreMsg}). Recovery: previous output may be in ${backup}; staged package may be in ${failedPromote2}.`
|
|
997
|
-
);
|
|
1224
|
+
throw promoteError;
|
|
998
1225
|
}
|
|
999
|
-
|
|
1226
|
+
const failedPromote = (0, import_node_path6.join)(parent, `.lk-failed-promote-${(0, import_node_crypto.randomUUID)()}`);
|
|
1000
1227
|
try {
|
|
1001
|
-
await renameOrCopy(tmpPromote,
|
|
1002
|
-
} catch
|
|
1003
|
-
console.warn(
|
|
1004
|
-
`[lessonkit/lxpack] failed to restore ${stagingDir} after promote error:`,
|
|
1005
|
-
restoreError instanceof Error ? restoreError.message : restoreError
|
|
1006
|
-
);
|
|
1228
|
+
await renameOrCopy(tmpPromote, failedPromote);
|
|
1229
|
+
} catch {
|
|
1007
1230
|
await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(
|
|
1008
1231
|
/* v8 ignore next */
|
|
1009
1232
|
() => void 0
|
|
@@ -1011,23 +1234,13 @@ async function promoteStagingToOutDir(stagingDir, outDir) {
|
|
|
1011
1234
|
}
|
|
1012
1235
|
throw promoteError;
|
|
1013
1236
|
}
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
await renameOrCopy(tmpPromote, failedPromote);
|
|
1017
|
-
} catch {
|
|
1018
|
-
await fsp.rm(tmpPromote, { recursive: true, force: true }).catch(
|
|
1237
|
+
if (backup) {
|
|
1238
|
+
await fsp.rm(backup, { recursive: true, force: true }).catch(
|
|
1019
1239
|
/* v8 ignore next */
|
|
1020
1240
|
() => void 0
|
|
1021
1241
|
);
|
|
1022
1242
|
}
|
|
1023
|
-
|
|
1024
|
-
}
|
|
1025
|
-
if (backup) {
|
|
1026
|
-
await fsp.rm(backup, { recursive: true, force: true }).catch(
|
|
1027
|
-
/* v8 ignore next */
|
|
1028
|
-
() => void 0
|
|
1029
|
-
);
|
|
1030
|
-
}
|
|
1243
|
+
});
|
|
1031
1244
|
}
|
|
1032
1245
|
|
|
1033
1246
|
// src/packaging/staging.ts
|
|
@@ -1100,6 +1313,15 @@ async function ensureOutDirParent(outDir) {
|
|
|
1100
1313
|
await fsp2.mkdir((0, import_node_path7.dirname)(outDir), { recursive: true });
|
|
1101
1314
|
}
|
|
1102
1315
|
|
|
1316
|
+
// src/packaging/issueSeverity.ts
|
|
1317
|
+
function isPackagingErrorIssue(issue) {
|
|
1318
|
+
const severity = issue.severity?.toLowerCase();
|
|
1319
|
+
return severity === "error" || severity === "fatal";
|
|
1320
|
+
}
|
|
1321
|
+
function findPackagingErrorIssues(issues) {
|
|
1322
|
+
return (issues ?? []).filter(isPackagingErrorIssue);
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1103
1325
|
// src/packageCourse.ts
|
|
1104
1326
|
async function validateLessonkitProject(options) {
|
|
1105
1327
|
return (0, import_api2.validateCourse)({
|
|
@@ -1149,6 +1371,18 @@ async function packageLessonkitCourse(options) {
|
|
|
1149
1371
|
};
|
|
1150
1372
|
}
|
|
1151
1373
|
const descriptor = descriptorValidation.descriptor;
|
|
1374
|
+
const nonInjectableAssessments = (descriptor.assessments ?? []).map((assessment, index) => ({ assessment, index })).filter(({ assessment }) => assessmentDescriptorToLxpack(assessment) === null);
|
|
1375
|
+
if (nonInjectableAssessments.length > 0) {
|
|
1376
|
+
return {
|
|
1377
|
+
ok: false,
|
|
1378
|
+
courseDir: outDir,
|
|
1379
|
+
target,
|
|
1380
|
+
issues: nonInjectableAssessments.map(({ assessment, index }) => ({
|
|
1381
|
+
path: `assessments[${index}]`,
|
|
1382
|
+
message: `assessment kind "${assessment.kind ?? "mcq"}" (checkId "${assessment.checkId}") is not injected into LMS shell quizzes for target "${target}"`
|
|
1383
|
+
}))
|
|
1384
|
+
};
|
|
1385
|
+
}
|
|
1152
1386
|
const staged = await buildStagingPackage({
|
|
1153
1387
|
...writeOpts,
|
|
1154
1388
|
descriptor,
|
|
@@ -1173,6 +1407,25 @@ async function packageLessonkitCourse(options) {
|
|
|
1173
1407
|
};
|
|
1174
1408
|
}
|
|
1175
1409
|
const { stagingDir, build } = staged;
|
|
1410
|
+
const buildErrorIssues = findPackagingErrorIssues(build.issues);
|
|
1411
|
+
if (buildErrorIssues.length > 0) {
|
|
1412
|
+
await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
|
|
1413
|
+
/* v8 ignore next */
|
|
1414
|
+
() => void 0
|
|
1415
|
+
);
|
|
1416
|
+
return {
|
|
1417
|
+
ok: false,
|
|
1418
|
+
courseDir: outDir,
|
|
1419
|
+
target,
|
|
1420
|
+
validation: { ok: false, manifest: build.manifest, issues: build.issues },
|
|
1421
|
+
build,
|
|
1422
|
+
issues: build.issues.filter((i) => findPackagingErrorIssues([i]).length > 0).map((i) => ({
|
|
1423
|
+
path: i.path ?? "build",
|
|
1424
|
+
message: i.message,
|
|
1425
|
+
severity: i.severity
|
|
1426
|
+
}))
|
|
1427
|
+
};
|
|
1428
|
+
}
|
|
1176
1429
|
const stagingRoot = await fsp3.realpath(stagingDir);
|
|
1177
1430
|
const artifactIssues = [
|
|
1178
1431
|
validateArtifactInStaging(stagingRoot, staged.outputPath, "outputPath"),
|
|
@@ -1203,6 +1456,10 @@ async function packageLessonkitCourse(options) {
|
|
|
1203
1456
|
await ensureOutDirParent(outDir);
|
|
1204
1457
|
await promoteStagingToOutDir(stagingDir, outDir);
|
|
1205
1458
|
} catch (err) {
|
|
1459
|
+
await fsp3.rm(stagingDir, { recursive: true, force: true }).catch(
|
|
1460
|
+
/* v8 ignore next */
|
|
1461
|
+
() => void 0
|
|
1462
|
+
);
|
|
1206
1463
|
return {
|
|
1207
1464
|
ok: false,
|
|
1208
1465
|
courseDir: outDir,
|