@lessonkit/cli 1.5.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 CHANGED
@@ -30,7 +30,7 @@ lessonkit package --target scorm12 # LMS artifact
30
30
  | Flag | Purpose |
31
31
  | --- | --- |
32
32
  | `--here` | Scaffold in the current directory |
33
- | `--force` | Overwrite existing files in the target directory |
33
+ | `--force` | With `--here`: scaffold in a non-empty directory; conflicting template files (e.g. `.gitignore`, `README.md`) are backed up to `.lessonkit-init-backup/` before overwrite. Non-conflicting files are kept. |
34
34
  | `--skip-install` | Skip `npm install` after copying the template |
35
35
 
36
36
  ### Package targets
@@ -57,7 +57,7 @@ Subprocess timeout defaults to **30 minutes** (`LESSONKIT_CMD_TIMEOUT_MS`).
57
57
 
58
58
  ## Docs
59
59
 
60
- [5-minute guide](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/getting-started-in-5-minutes.html) · [CLI reference](https://lessonkit.readthedocs.io/en/latest/reference/cli.html) · [Ship to LMS checklist](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/ship-to-lms.html) · [Packaging guide](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/packaging-and-cli.html) · [Published template](https://github.com/eddiethedean/lessonkit/tree/main/packages/cli/template/vite-react) (monorepo source: [`templates/vite-react`](https://github.com/eddiethedean/lessonkit/tree/main/templates/vite-react))
60
+ [5-minute guide](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/getting-started-in-5-minutes.html) · [CLI reference](https://lessonkit.readthedocs.io/en/latest/reference/cli.html) · [LMS Go-Live](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/lms-go-live.html) · [Packaging guide](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/packaging-and-cli.html) · [Published template](https://github.com/eddiethedean/lessonkit/tree/main/packages/cli/template/vite-react) (monorepo source: [`templates/vite-react`](https://github.com/eddiethedean/lessonkit/tree/main/templates/vite-react))
61
61
 
62
62
  ## License
63
63
 
package/dist/bin.js CHANGED
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { createRequire as createRequire2 } from "module";
4
+ import { createRequire as createRequire3 } from "module";
5
5
  import { Command } from "commander";
6
6
 
7
7
  // src/commands/init.ts
8
8
  import { slugifyId } from "@lessonkit/core";
9
- import { cp, mkdir, readdir, readFile, rename, rm, writeFile } from "fs/promises";
9
+ import { cp, mkdir, readdir, readFile, rename, rm, stat, writeFile } from "fs/promises";
10
10
  import { existsSync } from "fs";
11
11
  import { randomUUID } from "crypto";
12
12
  import { basename, dirname, join, resolve } from "path";
@@ -127,6 +127,7 @@ async function runNpmInstall(cwd) {
127
127
  // src/commands/init.ts
128
128
  var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", ".lxpack", ".git", "coverage", ".nyc_output"]);
129
129
  var SKIP_FILES = /* @__PURE__ */ new Set([".DS_Store"]);
130
+ var INIT_BACKUP_DIR = ".lessonkit-init-backup";
130
131
  function getTemplateDir() {
131
132
  const thisDir = dirname(fileURLToPath(import.meta.url));
132
133
  const candidates = [
@@ -138,6 +139,11 @@ function getTemplateDir() {
138
139
  }
139
140
  return candidates[0];
140
141
  }
142
+ async function isDirEmpty(dir) {
143
+ if (!existsSync(dir)) return true;
144
+ const entries = await readdir(dir);
145
+ return entries.length === 0;
146
+ }
141
147
  async function isDirEmptyOrDotfilesOnly(dir) {
142
148
  if (!existsSync(dir)) return true;
143
149
  const entries = await readdir(dir);
@@ -190,12 +196,128 @@ async function applyTemplateSubstitutions(projectDir, projectName, slug) {
190
196
  appSource = appSource.replace(/\{\{courseTitle\}\}/g, escapeJsxString(projectName));
191
197
  await writeFile(appPath, appSource, "utf8");
192
198
  }
199
+ function toPosixRelativePath(relativePath) {
200
+ return relativePath.replace(/\\/g, "/");
201
+ }
202
+ async function listStagingFiles(stagingDir, relativeDir = "") {
203
+ const dir = relativeDir ? join(stagingDir, relativeDir) : stagingDir;
204
+ const entries = await readdir(dir, { withFileTypes: true });
205
+ const files = [];
206
+ for (const entry of entries) {
207
+ if (SKIP_DIRS.has(entry.name) || SKIP_FILES.has(entry.name)) continue;
208
+ const rel = relativeDir ? join(relativeDir, entry.name) : entry.name;
209
+ const relPosix = toPosixRelativePath(rel);
210
+ if (entry.isDirectory()) {
211
+ files.push(...await listStagingFiles(stagingDir, rel));
212
+ } else if (entry.isFile()) {
213
+ files.push(relPosix);
214
+ } else {
215
+ }
216
+ }
217
+ return files;
218
+ }
219
+ async function listProjectFiles(projectDir, relativeDir = "") {
220
+ const files = /* @__PURE__ */ new Set();
221
+ const dir = relativeDir ? join(projectDir, relativeDir) : projectDir;
222
+ if (!existsSync(dir)) return files;
223
+ const entries = await readdir(dir, { withFileTypes: true });
224
+ for (const entry of entries) {
225
+ if (relativeDir === "" && entry.name === INIT_BACKUP_DIR) continue;
226
+ if (relativeDir === "" && entry.name.startsWith(".lessonkit-init-")) continue;
227
+ if (SKIP_DIRS.has(entry.name) || SKIP_FILES.has(entry.name)) continue;
228
+ const rel = relativeDir ? join(relativeDir, entry.name) : entry.name;
229
+ const relPosix = toPosixRelativePath(rel);
230
+ if (entry.isDirectory()) {
231
+ for (const nested of await listProjectFiles(projectDir, rel)) {
232
+ files.add(nested);
233
+ }
234
+ } else if (entry.isFile()) {
235
+ files.add(relPosix);
236
+ }
237
+ }
238
+ return files;
239
+ }
240
+ async function backupConflictingFiles(stagingDir, projectDir) {
241
+ const backups = /* @__PURE__ */ new Map();
242
+ for (const relPath of await listStagingFiles(stagingDir)) {
243
+ const destPath = join(projectDir, relPath);
244
+ if (!existsSync(destPath)) continue;
245
+ const destStat = await stat(destPath);
246
+ if (destStat.isFile()) {
247
+ backups.set(relPath, await readFile(destPath));
248
+ }
249
+ }
250
+ return backups;
251
+ }
252
+ async function writeInitBackupDir(projectDir, backups) {
253
+ const backupDir = join(projectDir, INIT_BACKUP_DIR);
254
+ await mkdir(backupDir, { recursive: true });
255
+ for (const [relPath, content] of backups) {
256
+ const outPath = join(backupDir, relPath);
257
+ await mkdir(dirname(outPath), { recursive: true });
258
+ await writeFile(outPath, content);
259
+ }
260
+ return backupDir;
261
+ }
262
+ async function rollbackPromotedFiles(projectDir, stagingDir, preExistingRoots, preExistingFiles, backups) {
263
+ const failures = [];
264
+ const stagingFiles = await listStagingFiles(stagingDir);
265
+ for (const relPath of stagingFiles) {
266
+ if (backups.has(relPath) || preExistingFiles.has(relPath)) continue;
267
+ try {
268
+ await rm(join(projectDir, relPath), { force: true });
269
+ } catch (err) {
270
+ failures.push(`remove ${relPath}: ${err instanceof Error ? err.message : String(err)}`);
271
+ }
272
+ }
273
+ let stagingEntries;
274
+ try {
275
+ stagingEntries = await readdir(stagingDir, { withFileTypes: true });
276
+ } catch {
277
+ if (failures.length > 0) {
278
+ throw new CliError(`Init rollback failed: ${failures.join("; ")}`, {
279
+ code: "RUNTIME",
280
+ exitCode: EXIT_INVALID_PROJECT
281
+ });
282
+ }
283
+ return;
284
+ }
285
+ for (const entry of stagingEntries) {
286
+ if (preExistingRoots.has(entry.name)) continue;
287
+ try {
288
+ await rm(join(projectDir, entry.name), { recursive: true, force: true });
289
+ } catch (err) {
290
+ failures.push(
291
+ `remove ${entry.name}: ${err instanceof Error ? err.message : String(err)}`
292
+ );
293
+ }
294
+ }
295
+ for (const [relPath, content] of backups) {
296
+ try {
297
+ const destPath = join(projectDir, relPath);
298
+ await mkdir(dirname(destPath), { recursive: true });
299
+ await writeFile(destPath, content);
300
+ } catch (err) {
301
+ failures.push(`restore ${relPath}: ${err instanceof Error ? err.message : String(err)}`);
302
+ }
303
+ }
304
+ if (failures.length > 0) {
305
+ throw new CliError(`Init rollback failed: ${failures.join("; ")}`, {
306
+ code: "RUNTIME",
307
+ exitCode: EXIT_INVALID_PROJECT
308
+ });
309
+ }
310
+ }
311
+ var PROMOTE_REPLACE_ENTRIES = /* @__PURE__ */ new Set(["node_modules", "package-lock.json"]);
193
312
  async function promoteStagingToProjectDir(stagingDir, projectDir) {
194
313
  await mkdir(projectDir, { recursive: true });
195
314
  const entries = await readdir(stagingDir, { withFileTypes: true });
196
315
  for (const entry of entries) {
197
316
  const srcPath = join(stagingDir, entry.name);
198
317
  const destPath = join(projectDir, entry.name);
318
+ if (PROMOTE_REPLACE_ENTRIES.has(entry.name) && existsSync(destPath)) {
319
+ await rm(destPath, { recursive: true, force: true });
320
+ }
199
321
  if (entry.isDirectory()) {
200
322
  await cp(srcPath, destPath, { recursive: true });
201
323
  } else if (entry.isFile()) {
@@ -204,6 +326,20 @@ async function promoteStagingToProjectDir(stagingDir, projectDir) {
204
326
  }
205
327
  }
206
328
  }
329
+ var __testInitHelpers = {
330
+ getTemplateDir,
331
+ isDirEmpty,
332
+ isDirEmptyOrDotfilesOnly,
333
+ escapeJsxString,
334
+ copyTemplate,
335
+ promoteStagingToProjectDir,
336
+ rollbackPromotedFiles,
337
+ backupConflictingFiles,
338
+ writeInitBackupDir,
339
+ listStagingFiles,
340
+ listProjectFiles,
341
+ INIT_BACKUP_DIR
342
+ };
207
343
  async function runInit(opts, logger) {
208
344
  const cwd = process.cwd();
209
345
  const rawName = opts.name ?? (opts.here ? slugifyId(basename(process.cwd()) || "my-course") : void 0);
@@ -232,14 +368,8 @@ async function runInit(opts, logger) {
232
368
  );
233
369
  }
234
370
  if (opts.here && !await isDirEmptyOrDotfilesOnly(projectDir) && !opts.force) {
235
- throw new CliError(`Directory is not empty: ${projectDir}. Use --force to initialize anyway.`, {
236
- code: "INVALID_PROJECT",
237
- exitCode: EXIT_INVALID_PROJECT
238
- });
239
- }
240
- if (opts.here && opts.force && !await isDirEmptyOrDotfilesOnly(projectDir)) {
241
371
  throw new CliError(
242
- `Directory is not empty: ${projectDir}. --force only initializes when the directory is empty or contains dotfiles only (e.g. .git).`,
372
+ `Directory is not empty: ${projectDir}. Use --here --force to scaffold anyway (conflicting files are backed up under ${INIT_BACKUP_DIR}/).`,
243
373
  {
244
374
  code: "INVALID_PROJECT",
245
375
  exitCode: EXIT_INVALID_PROJECT
@@ -262,7 +392,48 @@ async function runInit(opts, logger) {
262
392
  await runNpmInstall(stagingDir);
263
393
  }
264
394
  if (opts.here) {
265
- await promoteStagingToProjectDir(stagingDir, projectDir);
395
+ const preExistingRoots = new Set(await readdir(projectDir));
396
+ const preExistingFiles = await listProjectFiles(projectDir);
397
+ const backups = await backupConflictingFiles(stagingDir, projectDir);
398
+ const conflicts = [...backups.keys()].sort();
399
+ if (conflicts.length > 0 && !opts.force) {
400
+ throw new CliError(
401
+ `Would overwrite existing file(s): ${conflicts.join(", ")}. Re-run with --force to back them up under ${INIT_BACKUP_DIR}/ and continue.`,
402
+ {
403
+ code: "INVALID_PROJECT",
404
+ exitCode: EXIT_INVALID_PROJECT
405
+ }
406
+ );
407
+ }
408
+ if (conflicts.length > 0 && opts.force && !opts.json) {
409
+ const backupDir = await writeInitBackupDir(projectDir, backups);
410
+ logger.log(
411
+ `Backed up ${conflicts.length} conflicting file(s) to ${backupDir}: ${conflicts.join(", ")}`
412
+ );
413
+ } else if (conflicts.length > 0 && opts.force) {
414
+ await writeInitBackupDir(projectDir, backups);
415
+ }
416
+ try {
417
+ await __testInitHelpers.promoteStagingToProjectDir(stagingDir, projectDir);
418
+ } catch (promoteErr) {
419
+ try {
420
+ await rollbackPromotedFiles(
421
+ projectDir,
422
+ stagingDir,
423
+ preExistingRoots,
424
+ preExistingFiles,
425
+ backups
426
+ );
427
+ } catch (rollbackErr) {
428
+ const promoteMessage = promoteErr instanceof Error ? promoteErr.message : String(promoteErr);
429
+ const rollbackMessage = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
430
+ throw new CliError(`${promoteMessage}; ${rollbackMessage}`, {
431
+ code: "RUNTIME",
432
+ exitCode: EXIT_INVALID_PROJECT
433
+ });
434
+ }
435
+ throw promoteErr;
436
+ }
266
437
  await rm(stagingDir, { recursive: true, force: true });
267
438
  } else {
268
439
  await rename(stagingDir, projectDir);
@@ -283,7 +454,9 @@ async function runInit(opts, logger) {
283
454
 
284
455
  // src/commands/dev.ts
285
456
  import { existsSync as existsSync3 } from "fs";
286
- import { join as join3 } from "path";
457
+ import { mkdir as mkdir2 } from "fs/promises";
458
+ import { dirname as dirname3, join as join3 } from "path";
459
+ import { assertSpaDistContentsSafe } from "@lessonkit/lxpack";
287
460
 
288
461
  // src/lib/project.ts
289
462
  import { readFileSync, existsSync as existsSync2 } from "fs";
@@ -293,10 +466,13 @@ import { dirname as dirname2, join as join2, parse, resolve as resolve2 } from "
293
466
  import { parseLessonkitManifest } from "@lessonkit/lxpack";
294
467
  var LESSONKIT_JSON = "lessonkit.json";
295
468
  var PACKAGE_JSON = "package.json";
469
+ function isSchemaVersionOne(value) {
470
+ return value === 1 || value === "1";
471
+ }
296
472
  function isProjectManifest(configPath) {
297
473
  try {
298
474
  const raw = JSON.parse(readFileSync(configPath, "utf8"));
299
- return raw.schemaVersion === 1 && typeof raw.name === "string" && raw.course !== null && typeof raw.course === "object" && !Array.isArray(raw.course);
475
+ return isSchemaVersionOne(raw.schemaVersion) && typeof raw.name === "string" && raw.course !== null && typeof raw.course === "object" && !Array.isArray(raw.course);
300
476
  } catch {
301
477
  return false;
302
478
  }
@@ -469,7 +645,12 @@ function resolvePackageOutput(project, target, override) {
469
645
  if (override) {
470
646
  try {
471
647
  const resolved = resolveSafePackageOutputOverride(project.root, override);
472
- return { output: resolved, dir: target === "standalone", outputBaseDir };
648
+ const isZipOutput = override.trim().toLowerCase().endsWith(".zip");
649
+ return {
650
+ output: resolved,
651
+ dir: target === "standalone" && !isZipOutput,
652
+ outputBaseDir
653
+ };
473
654
  } catch (err) {
474
655
  const message = err instanceof Error ? err.message : String(err);
475
656
  throw new CliError(message, { code: "INVALID_PROJECT", exitCode: EXIT_INVALID_PROJECT });
@@ -515,7 +696,8 @@ async function runDev(opts) {
515
696
  const pkg = await readPackageJson(project.root);
516
697
  assertViteProject(pkg, project.root);
517
698
  const viteJs = resolveViteJs(project.root);
518
- await runCommand(process.execPath, [viteJs, ...opts.viteArgs ?? []], {
699
+ const devArgs = stripOutDirFromViteArgs(opts.viteArgs ?? []);
700
+ await runCommand(process.execPath, [viteJs, ...devArgs], {
519
701
  cwd: project.root,
520
702
  timeoutMs: 0
521
703
  });
@@ -526,11 +708,15 @@ async function runBuild(opts) {
526
708
  const pkg = await readPackageJson(project.root);
527
709
  assertViteProject(pkg, project.root);
528
710
  const viteJs = resolveViteJs(project.root);
711
+ const distDir = resolveDistDir(project);
712
+ await mkdir2(dirname3(distDir), { recursive: true });
713
+ if (existsSync3(distDir)) {
714
+ await assertSpaDistContentsSafe({ main: distDir }, project.root);
715
+ }
529
716
  const buildArgs = resolveViteBuildArgv(project, opts.viteArgs);
530
717
  await runCommand(process.execPath, [viteJs, ...buildArgs], {
531
718
  cwd: project.root
532
719
  });
533
- const distDir = resolveDistDir(project);
534
720
  const indexHtml = join3(distDir, "index.html");
535
721
  if (!existsSync3(indexHtml)) {
536
722
  throw new CliError(
@@ -538,13 +724,13 @@ async function runBuild(opts) {
538
724
  { code: "INVALID_PROJECT", exitCode: EXIT_INVALID_PROJECT }
539
725
  );
540
726
  }
727
+ await assertSpaDistContentsSafe({ main: distDir }, project.root);
541
728
  return { ok: true, command: "build", projectRoot: project.root };
542
729
  }
543
730
 
544
731
  // src/commands/package.ts
545
732
  import { existsSync as existsSync4 } from "fs";
546
- import { isAbsolute } from "path";
547
- import { packageLessonkitCourse } from "@lessonkit/lxpack";
733
+ import { assertSpaDistContentsSafe as assertSpaDistContentsSafe2, packageLessonkitCourse } from "@lessonkit/lxpack";
548
734
  async function runPackage(opts) {
549
735
  let target;
550
736
  try {
@@ -580,6 +766,7 @@ async function runPackage(opts) {
580
766
  exitCode: EXIT_INVALID_PROJECT
581
767
  });
582
768
  }
769
+ await assertSpaDistContentsSafe2({ main: distDir }, project.root);
583
770
  return { ok: true, command: "package", target, projectRoot: project.root, distDir };
584
771
  }
585
772
  assertNode18ForLxpack();
@@ -593,23 +780,23 @@ async function runPackage(opts) {
593
780
  });
594
781
  }
595
782
  const outDir = resolveLxpackOutDir(project);
783
+ const trimmedOut = opts.out?.trim();
596
784
  const { output: resolvedOutput, dir, outputBaseDir } = resolvePackageOutput(
597
785
  project,
598
786
  target,
599
- opts.out
787
+ trimmedOut
600
788
  );
601
- const trimmedOut = opts.out?.trim();
602
- const output = trimmedOut && !isAbsolute(trimmedOut) ? trimmedOut : resolvedOutput;
603
789
  const result = await packageLessonkitCourse({
604
790
  descriptor: project.course,
605
791
  outDir,
606
792
  spaDistDir: distDir,
607
793
  projectRoot: project.root,
608
794
  target,
609
- output,
795
+ output: trimmedOut ? resolvedOutput : void 0,
610
796
  dir,
611
797
  outputBaseDir,
612
- strictParity: opts.strictParity
798
+ strictParity: opts.strictParity,
799
+ strictBuild: opts.strict
613
800
  });
614
801
  if (!result.ok) {
615
802
  throw new CliError("Packaging failed.", {
@@ -641,6 +828,112 @@ async function runPackage(opts) {
641
828
  };
642
829
  }
643
830
 
831
+ // src/commands/export.ts
832
+ import { existsSync as existsSync5 } from "fs";
833
+ import { relative, resolve as resolve4 } from "path";
834
+ import { exportLkcourse, resolveSafePackageOutputOverride as resolveSafePackageOutputOverride2 } from "@lessonkit/lxpack";
835
+ function resolveExportOutput(projectRoot, override, defaultName) {
836
+ if (override) {
837
+ try {
838
+ return resolveSafePackageOutputOverride2(projectRoot, override);
839
+ } catch (err) {
840
+ const message = err instanceof Error ? err.message : String(err);
841
+ throw new CliError(message, { code: "INVALID_PROJECT", exitCode: EXIT_INVALID_PROJECT });
842
+ }
843
+ }
844
+ return resolve4(projectRoot, `${defaultName ?? "course"}.lkcourse`);
845
+ }
846
+ async function runExport(opts) {
847
+ const project = await loadProject(opts.cwd ?? process.cwd());
848
+ const distDir = resolve4(project.root, project.paths.spaDistDir);
849
+ if (opts.noBuild && !existsSync5(distDir)) {
850
+ throw new CliError(
851
+ `dist directory not found at ${distDir}. Run lessonkit build before export with --no-build.`,
852
+ {
853
+ code: "INVALID_PROJECT",
854
+ exitCode: EXIT_INVALID_PROJECT
855
+ }
856
+ );
857
+ }
858
+ if (!opts.noBuild) {
859
+ await runBuild({ cwd: project.root, json: opts.json });
860
+ }
861
+ if (!existsSync5(distDir)) {
862
+ throw new CliError(`dist directory not found at ${distDir}. Run lessonkit build first.`, {
863
+ code: "INVALID_PROJECT",
864
+ exitCode: EXIT_INVALID_PROJECT
865
+ });
866
+ }
867
+ const resolvedOut = resolveExportOutput(project.root, opts.out, project.name);
868
+ const outRelative = relative(project.root, resolvedOut).replace(/\\/g, "/");
869
+ const result = await exportLkcourse({
870
+ projectRoot: project.root,
871
+ manifest: project,
872
+ outPath: outRelative,
873
+ includeBlockTree: Boolean(opts.withBlockTree)
874
+ });
875
+ if (!result.ok) {
876
+ throw new CliError(
877
+ result.issues.map((i) => `${i.path}: ${i.message}`).join("; "),
878
+ { code: "EXPORT_FAILED", exitCode: EXIT_PACKAGING }
879
+ );
880
+ }
881
+ return {
882
+ ok: true,
883
+ command: "export",
884
+ projectRoot: project.root,
885
+ archivePath: result.archivePath,
886
+ fileCount: result.fileCount,
887
+ includeBlockTree: result.includeBlockTree
888
+ };
889
+ }
890
+
891
+ // src/commands/blocks.ts
892
+ import { readFileSync as readFileSync2 } from "fs";
893
+ import { createRequire as createRequire2 } from "module";
894
+ function loadBlockCatalog() {
895
+ const require3 = createRequire2(import.meta.url);
896
+ const catalogPath = require3.resolve("@lessonkit/react/block-catalog.v3.json");
897
+ return JSON.parse(readFileSync2(catalogPath, "utf8"));
898
+ }
899
+ function filterEntries(entries, opts) {
900
+ return entries.filter((entry) => {
901
+ if (opts.category && entry.category !== opts.category) return false;
902
+ if (opts.tier && entry.tier !== opts.tier) return false;
903
+ return true;
904
+ });
905
+ }
906
+ async function runBlocksList(opts) {
907
+ const catalog = loadBlockCatalog();
908
+ const entries = filterEntries(catalog.entries, opts);
909
+ if (!opts.json) {
910
+ const lines = [
911
+ "type category h5pMachineName",
912
+ ...entries.map(
913
+ (entry) => [
914
+ entry.type,
915
+ entry.category ?? "\u2014",
916
+ entry.h5pMachineName ?? "\u2014"
917
+ ].join(" ")
918
+ )
919
+ ];
920
+ return {
921
+ ok: true,
922
+ command: "blocks list",
923
+ schemaVersion: catalog.schemaVersion,
924
+ count: entries.length,
925
+ text: lines.join("\n")
926
+ };
927
+ }
928
+ return {
929
+ ok: true,
930
+ command: "blocks list",
931
+ schemaVersion: catalog.schemaVersion,
932
+ count: entries.length,
933
+ entries
934
+ };
935
+ }
936
+
644
937
  // src/lib/logger.ts
645
938
  function createLogger(opts) {
646
939
  if (opts?.json) {
@@ -655,7 +948,7 @@ function createLogger(opts) {
655
948
  }
656
949
 
657
950
  // src/index.ts
658
- var require2 = createRequire2(import.meta.url);
951
+ var require2 = createRequire3(import.meta.url);
659
952
  var { version } = require2("../package.json");
660
953
  async function handleCommand(fn, logger, json) {
661
954
  try {
@@ -678,7 +971,7 @@ function createProgram(baseLogger = console) {
678
971
  program.name("lessonkit").description("LessonKit CLI").version(version);
679
972
  program.command("init").description("Initialize a LessonKit project from the Vite + React template").argument("[name]", "Project directory name").option("--here", "Initialize in the current directory").option("--skip-install", "Skip npm install").option(
680
973
  "--force",
681
- "Requires --here: allow init when the directory is empty or contains only dotfiles"
974
+ "With --here: scaffold in a non-empty directory; back up conflicting template paths under .lessonkit-init-backup/ before overwrite"
682
975
  ).option("--json", "Emit structured JSON result").action(async (name, opts) => {
683
976
  const logger = createLogger({ json: opts.json });
684
977
  await handleCommand(
@@ -712,7 +1005,7 @@ function createProgram(baseLogger = console) {
712
1005
  );
713
1006
  }
714
1007
  );
715
- program.command("package").description("Build or package for web / LMS delivery").requiredOption("--target <target>", `Export target (${PACKAGE_TARGETS.join(", ")})`).option("--cwd <dir>", "Project root directory").option("--no-build", "Skip implicit Vite build for LMS targets").option("--out <path>", "Override output artifact path").option("--strict-parity", "Treat React ID parity warnings as packaging errors").option("--json", "Emit structured JSON result").action(async (opts) => {
1008
+ program.command("package").description("Build or package for web / LMS delivery").requiredOption("--target <target>", `Export target (${PACKAGE_TARGETS.join(", ")})`).option("--cwd <dir>", "Project root directory").option("--no-build", "Skip implicit Vite build for LMS targets").option("--out <path>", "Override output artifact path").option("--strict-parity", "Treat React ID parity warnings as packaging errors").option("--strict", "Treat Vite build warnings as packaging failures").option("--json", "Emit structured JSON result").action(async (opts) => {
716
1009
  const logger = createLogger({ json: opts.json });
717
1010
  await handleCommand(
718
1011
  async () => {
@@ -722,7 +1015,8 @@ function createProgram(baseLogger = console) {
722
1015
  noBuild: opts.build === false,
723
1016
  out: opts.out,
724
1017
  json: opts.json,
725
- strictParity: opts.strictParity
1018
+ strictParity: opts.strictParity,
1019
+ strict: opts.strict
726
1020
  });
727
1021
  if (!opts.json && result.ok && result.command === "package") {
728
1022
  if (result.target === "react-vite") {
@@ -740,6 +1034,46 @@ function createProgram(baseLogger = console) {
740
1034
  Boolean(opts.json)
741
1035
  );
742
1036
  });
1037
+ addCwdAndJson(
1038
+ program.command("export").description("Export a portable .lkcourse archive (manifest + interchange + dist)").option("--out <path>", "Output .lkcourse path (relative to project root)").option("--no-build", "Skip implicit Vite build").option("--with-block-tree", "Include optional block-tree.json from src scan")
1039
+ ).action(async (opts) => {
1040
+ const logger = createLogger({ json: opts.json });
1041
+ await handleCommand(
1042
+ async () => {
1043
+ const result = await runExport({
1044
+ cwd: opts.cwd,
1045
+ out: opts.out,
1046
+ noBuild: opts.build === false,
1047
+ withBlockTree: opts.withBlockTree,
1048
+ json: opts.json
1049
+ });
1050
+ if (!opts.json && result.ok && result.command === "export") {
1051
+ logger.log(`Exported .lkcourse \u2192 ${result.archivePath} (${result.fileCount} files)`);
1052
+ }
1053
+ return result;
1054
+ },
1055
+ logger,
1056
+ Boolean(opts.json)
1057
+ );
1058
+ });
1059
+ program.command("blocks").description("Block registry commands").command("list").description("List runtime blocks from block-catalog.v3.json").option("--json", "Emit structured JSON result").option("--category <category>", "Filter by category (container, assessment, content, compound)").option("--tier <tier>", "Filter by tier (A, B, C, D, E)").action(async (opts) => {
1060
+ const logger = createLogger({ json: opts.json });
1061
+ await handleCommand(
1062
+ async () => {
1063
+ const result = await runBlocksList({
1064
+ json: opts.json,
1065
+ category: opts.category,
1066
+ tier: opts.tier
1067
+ });
1068
+ if (!opts.json && result.ok && "text" in result && typeof result.text === "string") {
1069
+ logger.log(result.text);
1070
+ }
1071
+ return result;
1072
+ },
1073
+ logger,
1074
+ Boolean(opts.json)
1075
+ );
1076
+ });
743
1077
  program.command("publish").description("[maintainers] Not implemented \u2014 use Changesets (see RELEASING.md)").action(() => {
744
1078
  baseLogger.log(
745
1079
  "lessonkit publish is not implemented. Monorepo releases use Changesets: npm run changeset && npm run version-packages && npm run release. See RELEASING.md."
package/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  // src/index.ts
2
- import { createRequire as createRequire2 } from "module";
2
+ import { createRequire as createRequire3 } from "module";
3
3
  import { Command } from "commander";
4
4
 
5
5
  // src/commands/init.ts
6
6
  import { slugifyId } from "@lessonkit/core";
7
- import { cp, mkdir, readdir, readFile, rename, rm, writeFile } from "fs/promises";
7
+ import { cp, mkdir, readdir, readFile, rename, rm, stat, writeFile } from "fs/promises";
8
8
  import { existsSync } from "fs";
9
9
  import { randomUUID } from "crypto";
10
10
  import { basename, dirname, join, resolve } from "path";
@@ -125,6 +125,7 @@ async function runNpmInstall(cwd) {
125
125
  // src/commands/init.ts
126
126
  var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", ".lxpack", ".git", "coverage", ".nyc_output"]);
127
127
  var SKIP_FILES = /* @__PURE__ */ new Set([".DS_Store"]);
128
+ var INIT_BACKUP_DIR = ".lessonkit-init-backup";
128
129
  function getTemplateDir() {
129
130
  const thisDir = dirname(fileURLToPath(import.meta.url));
130
131
  const candidates = [
@@ -136,6 +137,11 @@ function getTemplateDir() {
136
137
  }
137
138
  return candidates[0];
138
139
  }
140
+ async function isDirEmpty(dir) {
141
+ if (!existsSync(dir)) return true;
142
+ const entries = await readdir(dir);
143
+ return entries.length === 0;
144
+ }
139
145
  async function isDirEmptyOrDotfilesOnly(dir) {
140
146
  if (!existsSync(dir)) return true;
141
147
  const entries = await readdir(dir);
@@ -188,12 +194,128 @@ async function applyTemplateSubstitutions(projectDir, projectName, slug) {
188
194
  appSource = appSource.replace(/\{\{courseTitle\}\}/g, escapeJsxString(projectName));
189
195
  await writeFile(appPath, appSource, "utf8");
190
196
  }
197
+ function toPosixRelativePath(relativePath) {
198
+ return relativePath.replace(/\\/g, "/");
199
+ }
200
+ async function listStagingFiles(stagingDir, relativeDir = "") {
201
+ const dir = relativeDir ? join(stagingDir, relativeDir) : stagingDir;
202
+ const entries = await readdir(dir, { withFileTypes: true });
203
+ const files = [];
204
+ for (const entry of entries) {
205
+ if (SKIP_DIRS.has(entry.name) || SKIP_FILES.has(entry.name)) continue;
206
+ const rel = relativeDir ? join(relativeDir, entry.name) : entry.name;
207
+ const relPosix = toPosixRelativePath(rel);
208
+ if (entry.isDirectory()) {
209
+ files.push(...await listStagingFiles(stagingDir, rel));
210
+ } else if (entry.isFile()) {
211
+ files.push(relPosix);
212
+ } else {
213
+ }
214
+ }
215
+ return files;
216
+ }
217
+ async function listProjectFiles(projectDir, relativeDir = "") {
218
+ const files = /* @__PURE__ */ new Set();
219
+ const dir = relativeDir ? join(projectDir, relativeDir) : projectDir;
220
+ if (!existsSync(dir)) return files;
221
+ const entries = await readdir(dir, { withFileTypes: true });
222
+ for (const entry of entries) {
223
+ if (relativeDir === "" && entry.name === INIT_BACKUP_DIR) continue;
224
+ if (relativeDir === "" && entry.name.startsWith(".lessonkit-init-")) continue;
225
+ if (SKIP_DIRS.has(entry.name) || SKIP_FILES.has(entry.name)) continue;
226
+ const rel = relativeDir ? join(relativeDir, entry.name) : entry.name;
227
+ const relPosix = toPosixRelativePath(rel);
228
+ if (entry.isDirectory()) {
229
+ for (const nested of await listProjectFiles(projectDir, rel)) {
230
+ files.add(nested);
231
+ }
232
+ } else if (entry.isFile()) {
233
+ files.add(relPosix);
234
+ }
235
+ }
236
+ return files;
237
+ }
238
+ async function backupConflictingFiles(stagingDir, projectDir) {
239
+ const backups = /* @__PURE__ */ new Map();
240
+ for (const relPath of await listStagingFiles(stagingDir)) {
241
+ const destPath = join(projectDir, relPath);
242
+ if (!existsSync(destPath)) continue;
243
+ const destStat = await stat(destPath);
244
+ if (destStat.isFile()) {
245
+ backups.set(relPath, await readFile(destPath));
246
+ }
247
+ }
248
+ return backups;
249
+ }
250
+ async function writeInitBackupDir(projectDir, backups) {
251
+ const backupDir = join(projectDir, INIT_BACKUP_DIR);
252
+ await mkdir(backupDir, { recursive: true });
253
+ for (const [relPath, content] of backups) {
254
+ const outPath = join(backupDir, relPath);
255
+ await mkdir(dirname(outPath), { recursive: true });
256
+ await writeFile(outPath, content);
257
+ }
258
+ return backupDir;
259
+ }
260
+ async function rollbackPromotedFiles(projectDir, stagingDir, preExistingRoots, preExistingFiles, backups) {
261
+ const failures = [];
262
+ const stagingFiles = await listStagingFiles(stagingDir);
263
+ for (const relPath of stagingFiles) {
264
+ if (backups.has(relPath) || preExistingFiles.has(relPath)) continue;
265
+ try {
266
+ await rm(join(projectDir, relPath), { force: true });
267
+ } catch (err) {
268
+ failures.push(`remove ${relPath}: ${err instanceof Error ? err.message : String(err)}`);
269
+ }
270
+ }
271
+ let stagingEntries;
272
+ try {
273
+ stagingEntries = await readdir(stagingDir, { withFileTypes: true });
274
+ } catch {
275
+ if (failures.length > 0) {
276
+ throw new CliError(`Init rollback failed: ${failures.join("; ")}`, {
277
+ code: "RUNTIME",
278
+ exitCode: EXIT_INVALID_PROJECT
279
+ });
280
+ }
281
+ return;
282
+ }
283
+ for (const entry of stagingEntries) {
284
+ if (preExistingRoots.has(entry.name)) continue;
285
+ try {
286
+ await rm(join(projectDir, entry.name), { recursive: true, force: true });
287
+ } catch (err) {
288
+ failures.push(
289
+ `remove ${entry.name}: ${err instanceof Error ? err.message : String(err)}`
290
+ );
291
+ }
292
+ }
293
+ for (const [relPath, content] of backups) {
294
+ try {
295
+ const destPath = join(projectDir, relPath);
296
+ await mkdir(dirname(destPath), { recursive: true });
297
+ await writeFile(destPath, content);
298
+ } catch (err) {
299
+ failures.push(`restore ${relPath}: ${err instanceof Error ? err.message : String(err)}`);
300
+ }
301
+ }
302
+ if (failures.length > 0) {
303
+ throw new CliError(`Init rollback failed: ${failures.join("; ")}`, {
304
+ code: "RUNTIME",
305
+ exitCode: EXIT_INVALID_PROJECT
306
+ });
307
+ }
308
+ }
309
+ var PROMOTE_REPLACE_ENTRIES = /* @__PURE__ */ new Set(["node_modules", "package-lock.json"]);
191
310
  async function promoteStagingToProjectDir(stagingDir, projectDir) {
192
311
  await mkdir(projectDir, { recursive: true });
193
312
  const entries = await readdir(stagingDir, { withFileTypes: true });
194
313
  for (const entry of entries) {
195
314
  const srcPath = join(stagingDir, entry.name);
196
315
  const destPath = join(projectDir, entry.name);
316
+ if (PROMOTE_REPLACE_ENTRIES.has(entry.name) && existsSync(destPath)) {
317
+ await rm(destPath, { recursive: true, force: true });
318
+ }
197
319
  if (entry.isDirectory()) {
198
320
  await cp(srcPath, destPath, { recursive: true });
199
321
  } else if (entry.isFile()) {
@@ -202,6 +324,20 @@ async function promoteStagingToProjectDir(stagingDir, projectDir) {
202
324
  }
203
325
  }
204
326
  }
327
+ var __testInitHelpers = {
328
+ getTemplateDir,
329
+ isDirEmpty,
330
+ isDirEmptyOrDotfilesOnly,
331
+ escapeJsxString,
332
+ copyTemplate,
333
+ promoteStagingToProjectDir,
334
+ rollbackPromotedFiles,
335
+ backupConflictingFiles,
336
+ writeInitBackupDir,
337
+ listStagingFiles,
338
+ listProjectFiles,
339
+ INIT_BACKUP_DIR
340
+ };
205
341
  async function runInit(opts, logger) {
206
342
  const cwd = process.cwd();
207
343
  const rawName = opts.name ?? (opts.here ? slugifyId(basename(process.cwd()) || "my-course") : void 0);
@@ -230,14 +366,8 @@ async function runInit(opts, logger) {
230
366
  );
231
367
  }
232
368
  if (opts.here && !await isDirEmptyOrDotfilesOnly(projectDir) && !opts.force) {
233
- throw new CliError(`Directory is not empty: ${projectDir}. Use --force to initialize anyway.`, {
234
- code: "INVALID_PROJECT",
235
- exitCode: EXIT_INVALID_PROJECT
236
- });
237
- }
238
- if (opts.here && opts.force && !await isDirEmptyOrDotfilesOnly(projectDir)) {
239
369
  throw new CliError(
240
- `Directory is not empty: ${projectDir}. --force only initializes when the directory is empty or contains dotfiles only (e.g. .git).`,
370
+ `Directory is not empty: ${projectDir}. Use --here --force to scaffold anyway (conflicting files are backed up under ${INIT_BACKUP_DIR}/).`,
241
371
  {
242
372
  code: "INVALID_PROJECT",
243
373
  exitCode: EXIT_INVALID_PROJECT
@@ -260,7 +390,48 @@ async function runInit(opts, logger) {
260
390
  await runNpmInstall(stagingDir);
261
391
  }
262
392
  if (opts.here) {
263
- await promoteStagingToProjectDir(stagingDir, projectDir);
393
+ const preExistingRoots = new Set(await readdir(projectDir));
394
+ const preExistingFiles = await listProjectFiles(projectDir);
395
+ const backups = await backupConflictingFiles(stagingDir, projectDir);
396
+ const conflicts = [...backups.keys()].sort();
397
+ if (conflicts.length > 0 && !opts.force) {
398
+ throw new CliError(
399
+ `Would overwrite existing file(s): ${conflicts.join(", ")}. Re-run with --force to back them up under ${INIT_BACKUP_DIR}/ and continue.`,
400
+ {
401
+ code: "INVALID_PROJECT",
402
+ exitCode: EXIT_INVALID_PROJECT
403
+ }
404
+ );
405
+ }
406
+ if (conflicts.length > 0 && opts.force && !opts.json) {
407
+ const backupDir = await writeInitBackupDir(projectDir, backups);
408
+ logger.log(
409
+ `Backed up ${conflicts.length} conflicting file(s) to ${backupDir}: ${conflicts.join(", ")}`
410
+ );
411
+ } else if (conflicts.length > 0 && opts.force) {
412
+ await writeInitBackupDir(projectDir, backups);
413
+ }
414
+ try {
415
+ await __testInitHelpers.promoteStagingToProjectDir(stagingDir, projectDir);
416
+ } catch (promoteErr) {
417
+ try {
418
+ await rollbackPromotedFiles(
419
+ projectDir,
420
+ stagingDir,
421
+ preExistingRoots,
422
+ preExistingFiles,
423
+ backups
424
+ );
425
+ } catch (rollbackErr) {
426
+ const promoteMessage = promoteErr instanceof Error ? promoteErr.message : String(promoteErr);
427
+ const rollbackMessage = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
428
+ throw new CliError(`${promoteMessage}; ${rollbackMessage}`, {
429
+ code: "RUNTIME",
430
+ exitCode: EXIT_INVALID_PROJECT
431
+ });
432
+ }
433
+ throw promoteErr;
434
+ }
264
435
  await rm(stagingDir, { recursive: true, force: true });
265
436
  } else {
266
437
  await rename(stagingDir, projectDir);
@@ -281,7 +452,9 @@ async function runInit(opts, logger) {
281
452
 
282
453
  // src/commands/dev.ts
283
454
  import { existsSync as existsSync3 } from "fs";
284
- import { join as join3 } from "path";
455
+ import { mkdir as mkdir2 } from "fs/promises";
456
+ import { dirname as dirname3, join as join3 } from "path";
457
+ import { assertSpaDistContentsSafe } from "@lessonkit/lxpack";
285
458
 
286
459
  // src/lib/project.ts
287
460
  import { readFileSync, existsSync as existsSync2 } from "fs";
@@ -291,10 +464,13 @@ import { dirname as dirname2, join as join2, parse, resolve as resolve2 } from "
291
464
  import { parseLessonkitManifest } from "@lessonkit/lxpack";
292
465
  var LESSONKIT_JSON = "lessonkit.json";
293
466
  var PACKAGE_JSON = "package.json";
467
+ function isSchemaVersionOne(value) {
468
+ return value === 1 || value === "1";
469
+ }
294
470
  function isProjectManifest(configPath) {
295
471
  try {
296
472
  const raw = JSON.parse(readFileSync(configPath, "utf8"));
297
- return raw.schemaVersion === 1 && typeof raw.name === "string" && raw.course !== null && typeof raw.course === "object" && !Array.isArray(raw.course);
473
+ return isSchemaVersionOne(raw.schemaVersion) && typeof raw.name === "string" && raw.course !== null && typeof raw.course === "object" && !Array.isArray(raw.course);
298
474
  } catch {
299
475
  return false;
300
476
  }
@@ -467,7 +643,12 @@ function resolvePackageOutput(project, target, override) {
467
643
  if (override) {
468
644
  try {
469
645
  const resolved = resolveSafePackageOutputOverride(project.root, override);
470
- return { output: resolved, dir: target === "standalone", outputBaseDir };
646
+ const isZipOutput = override.trim().toLowerCase().endsWith(".zip");
647
+ return {
648
+ output: resolved,
649
+ dir: target === "standalone" && !isZipOutput,
650
+ outputBaseDir
651
+ };
471
652
  } catch (err) {
472
653
  const message = err instanceof Error ? err.message : String(err);
473
654
  throw new CliError(message, { code: "INVALID_PROJECT", exitCode: EXIT_INVALID_PROJECT });
@@ -513,7 +694,8 @@ async function runDev(opts) {
513
694
  const pkg = await readPackageJson(project.root);
514
695
  assertViteProject(pkg, project.root);
515
696
  const viteJs = resolveViteJs(project.root);
516
- await runCommand(process.execPath, [viteJs, ...opts.viteArgs ?? []], {
697
+ const devArgs = stripOutDirFromViteArgs(opts.viteArgs ?? []);
698
+ await runCommand(process.execPath, [viteJs, ...devArgs], {
517
699
  cwd: project.root,
518
700
  timeoutMs: 0
519
701
  });
@@ -524,11 +706,15 @@ async function runBuild(opts) {
524
706
  const pkg = await readPackageJson(project.root);
525
707
  assertViteProject(pkg, project.root);
526
708
  const viteJs = resolveViteJs(project.root);
709
+ const distDir = resolveDistDir(project);
710
+ await mkdir2(dirname3(distDir), { recursive: true });
711
+ if (existsSync3(distDir)) {
712
+ await assertSpaDistContentsSafe({ main: distDir }, project.root);
713
+ }
527
714
  const buildArgs = resolveViteBuildArgv(project, opts.viteArgs);
528
715
  await runCommand(process.execPath, [viteJs, ...buildArgs], {
529
716
  cwd: project.root
530
717
  });
531
- const distDir = resolveDistDir(project);
532
718
  const indexHtml = join3(distDir, "index.html");
533
719
  if (!existsSync3(indexHtml)) {
534
720
  throw new CliError(
@@ -536,13 +722,13 @@ async function runBuild(opts) {
536
722
  { code: "INVALID_PROJECT", exitCode: EXIT_INVALID_PROJECT }
537
723
  );
538
724
  }
725
+ await assertSpaDistContentsSafe({ main: distDir }, project.root);
539
726
  return { ok: true, command: "build", projectRoot: project.root };
540
727
  }
541
728
 
542
729
  // src/commands/package.ts
543
730
  import { existsSync as existsSync4 } from "fs";
544
- import { isAbsolute } from "path";
545
- import { packageLessonkitCourse } from "@lessonkit/lxpack";
731
+ import { assertSpaDistContentsSafe as assertSpaDistContentsSafe2, packageLessonkitCourse } from "@lessonkit/lxpack";
546
732
  async function runPackage(opts) {
547
733
  let target;
548
734
  try {
@@ -578,6 +764,7 @@ async function runPackage(opts) {
578
764
  exitCode: EXIT_INVALID_PROJECT
579
765
  });
580
766
  }
767
+ await assertSpaDistContentsSafe2({ main: distDir }, project.root);
581
768
  return { ok: true, command: "package", target, projectRoot: project.root, distDir };
582
769
  }
583
770
  assertNode18ForLxpack();
@@ -591,23 +778,23 @@ async function runPackage(opts) {
591
778
  });
592
779
  }
593
780
  const outDir = resolveLxpackOutDir(project);
781
+ const trimmedOut = opts.out?.trim();
594
782
  const { output: resolvedOutput, dir, outputBaseDir } = resolvePackageOutput(
595
783
  project,
596
784
  target,
597
- opts.out
785
+ trimmedOut
598
786
  );
599
- const trimmedOut = opts.out?.trim();
600
- const output = trimmedOut && !isAbsolute(trimmedOut) ? trimmedOut : resolvedOutput;
601
787
  const result = await packageLessonkitCourse({
602
788
  descriptor: project.course,
603
789
  outDir,
604
790
  spaDistDir: distDir,
605
791
  projectRoot: project.root,
606
792
  target,
607
- output,
793
+ output: trimmedOut ? resolvedOutput : void 0,
608
794
  dir,
609
795
  outputBaseDir,
610
- strictParity: opts.strictParity
796
+ strictParity: opts.strictParity,
797
+ strictBuild: opts.strict
611
798
  });
612
799
  if (!result.ok) {
613
800
  throw new CliError("Packaging failed.", {
@@ -639,6 +826,112 @@ async function runPackage(opts) {
639
826
  };
640
827
  }
641
828
 
829
+ // src/commands/export.ts
830
+ import { existsSync as existsSync5 } from "fs";
831
+ import { relative, resolve as resolve4 } from "path";
832
+ import { exportLkcourse, resolveSafePackageOutputOverride as resolveSafePackageOutputOverride2 } from "@lessonkit/lxpack";
833
+ function resolveExportOutput(projectRoot, override, defaultName) {
834
+ if (override) {
835
+ try {
836
+ return resolveSafePackageOutputOverride2(projectRoot, override);
837
+ } catch (err) {
838
+ const message = err instanceof Error ? err.message : String(err);
839
+ throw new CliError(message, { code: "INVALID_PROJECT", exitCode: EXIT_INVALID_PROJECT });
840
+ }
841
+ }
842
+ return resolve4(projectRoot, `${defaultName ?? "course"}.lkcourse`);
843
+ }
844
+ async function runExport(opts) {
845
+ const project = await loadProject(opts.cwd ?? process.cwd());
846
+ const distDir = resolve4(project.root, project.paths.spaDistDir);
847
+ if (opts.noBuild && !existsSync5(distDir)) {
848
+ throw new CliError(
849
+ `dist directory not found at ${distDir}. Run lessonkit build before export with --no-build.`,
850
+ {
851
+ code: "INVALID_PROJECT",
852
+ exitCode: EXIT_INVALID_PROJECT
853
+ }
854
+ );
855
+ }
856
+ if (!opts.noBuild) {
857
+ await runBuild({ cwd: project.root, json: opts.json });
858
+ }
859
+ if (!existsSync5(distDir)) {
860
+ throw new CliError(`dist directory not found at ${distDir}. Run lessonkit build first.`, {
861
+ code: "INVALID_PROJECT",
862
+ exitCode: EXIT_INVALID_PROJECT
863
+ });
864
+ }
865
+ const resolvedOut = resolveExportOutput(project.root, opts.out, project.name);
866
+ const outRelative = relative(project.root, resolvedOut).replace(/\\/g, "/");
867
+ const result = await exportLkcourse({
868
+ projectRoot: project.root,
869
+ manifest: project,
870
+ outPath: outRelative,
871
+ includeBlockTree: Boolean(opts.withBlockTree)
872
+ });
873
+ if (!result.ok) {
874
+ throw new CliError(
875
+ result.issues.map((i) => `${i.path}: ${i.message}`).join("; "),
876
+ { code: "EXPORT_FAILED", exitCode: EXIT_PACKAGING }
877
+ );
878
+ }
879
+ return {
880
+ ok: true,
881
+ command: "export",
882
+ projectRoot: project.root,
883
+ archivePath: result.archivePath,
884
+ fileCount: result.fileCount,
885
+ includeBlockTree: result.includeBlockTree
886
+ };
887
+ }
888
+
889
+ // src/commands/blocks.ts
890
+ import { readFileSync as readFileSync2 } from "fs";
891
+ import { createRequire as createRequire2 } from "module";
892
+ function loadBlockCatalog() {
893
+ const require3 = createRequire2(import.meta.url);
894
+ const catalogPath = require3.resolve("@lessonkit/react/block-catalog.v3.json");
895
+ return JSON.parse(readFileSync2(catalogPath, "utf8"));
896
+ }
897
+ function filterEntries(entries, opts) {
898
+ return entries.filter((entry) => {
899
+ if (opts.category && entry.category !== opts.category) return false;
900
+ if (opts.tier && entry.tier !== opts.tier) return false;
901
+ return true;
902
+ });
903
+ }
904
+ async function runBlocksList(opts) {
905
+ const catalog = loadBlockCatalog();
906
+ const entries = filterEntries(catalog.entries, opts);
907
+ if (!opts.json) {
908
+ const lines = [
909
+ "type category h5pMachineName",
910
+ ...entries.map(
911
+ (entry) => [
912
+ entry.type,
913
+ entry.category ?? "\u2014",
914
+ entry.h5pMachineName ?? "\u2014"
915
+ ].join(" ")
916
+ )
917
+ ];
918
+ return {
919
+ ok: true,
920
+ command: "blocks list",
921
+ schemaVersion: catalog.schemaVersion,
922
+ count: entries.length,
923
+ text: lines.join("\n")
924
+ };
925
+ }
926
+ return {
927
+ ok: true,
928
+ command: "blocks list",
929
+ schemaVersion: catalog.schemaVersion,
930
+ count: entries.length,
931
+ entries
932
+ };
933
+ }
934
+
642
935
  // src/lib/logger.ts
643
936
  function createLogger(opts) {
644
937
  if (opts?.json) {
@@ -653,7 +946,7 @@ function createLogger(opts) {
653
946
  }
654
947
 
655
948
  // src/index.ts
656
- var require2 = createRequire2(import.meta.url);
949
+ var require2 = createRequire3(import.meta.url);
657
950
  var { version } = require2("../package.json");
658
951
  async function handleCommand(fn, logger, json) {
659
952
  try {
@@ -676,7 +969,7 @@ function createProgram(baseLogger = console) {
676
969
  program.name("lessonkit").description("LessonKit CLI").version(version);
677
970
  program.command("init").description("Initialize a LessonKit project from the Vite + React template").argument("[name]", "Project directory name").option("--here", "Initialize in the current directory").option("--skip-install", "Skip npm install").option(
678
971
  "--force",
679
- "Requires --here: allow init when the directory is empty or contains only dotfiles"
972
+ "With --here: scaffold in a non-empty directory; back up conflicting template paths under .lessonkit-init-backup/ before overwrite"
680
973
  ).option("--json", "Emit structured JSON result").action(async (name, opts) => {
681
974
  const logger = createLogger({ json: opts.json });
682
975
  await handleCommand(
@@ -710,7 +1003,7 @@ function createProgram(baseLogger = console) {
710
1003
  );
711
1004
  }
712
1005
  );
713
- program.command("package").description("Build or package for web / LMS delivery").requiredOption("--target <target>", `Export target (${PACKAGE_TARGETS.join(", ")})`).option("--cwd <dir>", "Project root directory").option("--no-build", "Skip implicit Vite build for LMS targets").option("--out <path>", "Override output artifact path").option("--strict-parity", "Treat React ID parity warnings as packaging errors").option("--json", "Emit structured JSON result").action(async (opts) => {
1006
+ program.command("package").description("Build or package for web / LMS delivery").requiredOption("--target <target>", `Export target (${PACKAGE_TARGETS.join(", ")})`).option("--cwd <dir>", "Project root directory").option("--no-build", "Skip implicit Vite build for LMS targets").option("--out <path>", "Override output artifact path").option("--strict-parity", "Treat React ID parity warnings as packaging errors").option("--strict", "Treat Vite build warnings as packaging failures").option("--json", "Emit structured JSON result").action(async (opts) => {
714
1007
  const logger = createLogger({ json: opts.json });
715
1008
  await handleCommand(
716
1009
  async () => {
@@ -720,7 +1013,8 @@ function createProgram(baseLogger = console) {
720
1013
  noBuild: opts.build === false,
721
1014
  out: opts.out,
722
1015
  json: opts.json,
723
- strictParity: opts.strictParity
1016
+ strictParity: opts.strictParity,
1017
+ strict: opts.strict
724
1018
  });
725
1019
  if (!opts.json && result.ok && result.command === "package") {
726
1020
  if (result.target === "react-vite") {
@@ -738,6 +1032,46 @@ function createProgram(baseLogger = console) {
738
1032
  Boolean(opts.json)
739
1033
  );
740
1034
  });
1035
+ addCwdAndJson(
1036
+ program.command("export").description("Export a portable .lkcourse archive (manifest + interchange + dist)").option("--out <path>", "Output .lkcourse path (relative to project root)").option("--no-build", "Skip implicit Vite build").option("--with-block-tree", "Include optional block-tree.json from src scan")
1037
+ ).action(async (opts) => {
1038
+ const logger = createLogger({ json: opts.json });
1039
+ await handleCommand(
1040
+ async () => {
1041
+ const result = await runExport({
1042
+ cwd: opts.cwd,
1043
+ out: opts.out,
1044
+ noBuild: opts.build === false,
1045
+ withBlockTree: opts.withBlockTree,
1046
+ json: opts.json
1047
+ });
1048
+ if (!opts.json && result.ok && result.command === "export") {
1049
+ logger.log(`Exported .lkcourse \u2192 ${result.archivePath} (${result.fileCount} files)`);
1050
+ }
1051
+ return result;
1052
+ },
1053
+ logger,
1054
+ Boolean(opts.json)
1055
+ );
1056
+ });
1057
+ program.command("blocks").description("Block registry commands").command("list").description("List runtime blocks from block-catalog.v3.json").option("--json", "Emit structured JSON result").option("--category <category>", "Filter by category (container, assessment, content, compound)").option("--tier <tier>", "Filter by tier (A, B, C, D, E)").action(async (opts) => {
1058
+ const logger = createLogger({ json: opts.json });
1059
+ await handleCommand(
1060
+ async () => {
1061
+ const result = await runBlocksList({
1062
+ json: opts.json,
1063
+ category: opts.category,
1064
+ tier: opts.tier
1065
+ });
1066
+ if (!opts.json && result.ok && "text" in result && typeof result.text === "string") {
1067
+ logger.log(result.text);
1068
+ }
1069
+ return result;
1070
+ },
1071
+ logger,
1072
+ Boolean(opts.json)
1073
+ );
1074
+ });
741
1075
  program.command("publish").description("[maintainers] Not implemented \u2014 use Changesets (see RELEASING.md)").action(() => {
742
1076
  baseLogger.log(
743
1077
  "lessonkit publish is not implemented. Monorepo releases use Changesets: npm run changeset && npm run version-packages && npm run release. See RELEASING.md."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lessonkit/cli",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
4
  "private": false,
5
5
  "description": "LessonKit CLI — init, dev, build, and package learning experiences.",
6
6
  "license": "Apache-2.0",
@@ -36,8 +36,8 @@
36
36
  ],
37
37
  "scripts": {
38
38
  "copy-template": "node scripts/copy-template.mjs",
39
- "build": "npm run copy-template && tsup src/index.ts src/bin.ts --format esm --splitting=false --platform node --target node18 --dts",
40
- "dev": "npm run copy-template && tsup src/index.ts src/bin.ts --format esm --watch --splitting=false --platform node --target node18 --dts",
39
+ "build": "npm run copy-template && tsup src/index.ts src/bin.ts --format esm --splitting=false --platform node --target node18 --dts --tsconfig tsconfig.build.json",
40
+ "dev": "npm run copy-template && tsup src/index.ts src/bin.ts --format esm --watch --splitting=false --platform node --target node18 --dts --tsconfig tsconfig.build.json",
41
41
  "prepublishOnly": "npm run build",
42
42
  "typecheck": "tsc -p tsconfig.json",
43
43
  "test": "vitest run --passWithNoTests",
@@ -45,8 +45,9 @@
45
45
  "lint": "eslint --max-warnings 0 \"src/**/*.{ts,tsx}\" \"test/**/*.{ts,tsx}\""
46
46
  },
47
47
  "dependencies": {
48
- "@lessonkit/core": "1.5.0",
49
- "@lessonkit/lxpack": "1.5.0",
48
+ "@lessonkit/core": "1.7.0",
49
+ "@lessonkit/lxpack": "1.7.0",
50
+ "@lessonkit/react": "1.7.0",
50
51
  "commander": "^15.0.0"
51
52
  },
52
53
  "engines": {
@@ -45,8 +45,8 @@ After `npm run package:scorm12`, the CLI prints the resolved ZIP path. Default:
45
45
 
46
46
  ## Production
47
47
 
48
- Copy `.env.example` to `.env` and set your LRS/analytics proxy URLs before `npm run build`. See the [production checklist](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/production-checklist.html) and [Ship to LMS checklist](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/ship-to-lms.html).
48
+ Copy `.env.example` to `.env` and set your LRS/analytics proxy URLs before `npm run build`. See [LMS Go-Live](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/lms-go-live.html) and the [backend proxy cookbook](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/backend-proxy-cookbook.html).
49
49
 
50
50
  ## Docs
51
51
 
52
- [5-minute guide](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/getting-started-in-5-minutes.html) · [First LMS export](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/first-lms-export.html) · [CLI reference](https://lessonkit.readthedocs.io/en/latest/reference/cli.html) · [Packaging guide](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/packaging-and-cli.html)
52
+ [5-minute guide](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/getting-started-in-5-minutes.html) · [LMS Go-Live](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/lms-go-live.html) · [CLI reference](https://lessonkit.readthedocs.io/en/latest/reference/cli.html) · [Packaging guide](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/packaging-and-cli.html)
@@ -16,16 +16,16 @@
16
16
  "test:coverage": "vitest run --coverage --passWithNoTests=false"
17
17
  },
18
18
  "dependencies": {
19
- "@lessonkit/core": "^1.5.0",
20
- "@lessonkit/react": "^1.5.0",
21
- "@lessonkit/themes": "^1.5.0",
22
- "@lessonkit/xapi": "^1.5.0",
19
+ "@lessonkit/core": "^1.7.0",
20
+ "@lessonkit/react": "^1.7.0",
21
+ "@lessonkit/themes": "^1.7.0",
22
+ "@lessonkit/xapi": "^1.7.0",
23
23
  "react": "^19.2.7",
24
24
  "react-dom": "^19.2.7"
25
25
  },
26
26
  "devDependencies": {
27
- "@lessonkit/cli": "^1.5.0",
28
- "@lessonkit/lxpack": "^1.5.0",
27
+ "@lessonkit/cli": "^1.7.0",
28
+ "@lessonkit/lxpack": "^1.7.0",
29
29
  "@testing-library/react": "^16.3.0",
30
30
  "@testing-library/dom": "^10.4.1",
31
31
  "@types/react": "^19.2.17",
@@ -46,6 +46,11 @@ describe("createCourseConfig", () => {
46
46
  config.observability?.onXapiTransportError?.(new Error("transport"));
47
47
  config.observability?.onXapiMappingError?.(new Error("mapping"));
48
48
  config.observability?.onLxpackBridgeError?.(new Error("bridge"));
49
+ config.observability?.onInvalidSessionId?.({
50
+ invalidId: "bad:id",
51
+ fallbackId: "tab-1",
52
+ source: "provided",
53
+ });
49
54
 
50
55
  expect(warn).toHaveBeenCalled();
51
56
  warn.mockRestore();
@@ -34,6 +34,9 @@ function createObservability(): NonNullable<LessonkitConfig["observability"]> {
34
34
  onLxpackBridgeError: (err) => report("lxpack-bridge-error", { err }),
35
35
  onXapiTransportError: (err) => report("xapi-transport", { err }),
36
36
  onXapiMappingError: (err) => report("xapi-mapping", { err }),
37
+ onXapiDeadLetterPersistError: (err, ctx) =>
38
+ report("xapi-dead-letter-persist", { err, statementId: ctx.statement.id }),
39
+ onInvalidSessionId: (ctx) => report("invalid-session-id", ctx),
37
40
  };
38
41
  }
39
42