@lessonkit/cli 1.4.0 → 1.6.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
@@ -4,25 +4,37 @@
4
4
  [![Documentation](https://readthedocs.org/projects/lessonkit/badge/?version=latest)](https://lessonkit.readthedocs.io/en/latest/reference/cli.html)
5
5
  [![License](https://img.shields.io/github/license/eddiethedean/lessonkit)](https://github.com/eddiethedean/lessonkit/blob/main/LICENSE)
6
6
 
7
- Scaffold, develop, build, and package LessonKit courses. Node.js **18+**.
7
+ Scaffold, develop, build, and package LessonKit courses.
8
8
 
9
9
  ## Install
10
10
 
11
11
  ```bash
12
12
  npm install -g @lessonkit/cli
13
- # or one-shot:
13
+ # or one-shot (recommended for new courses):
14
14
  npx @lessonkit/cli init my-course
15
15
  ```
16
16
 
17
+ **Node.js:** **20.19+** recommended for `init` (Vite 8 scaffold). **18+** minimum for `dev`, `build`, and `package`.
18
+
17
19
  ## Commands
18
20
 
19
21
  ```bash
20
- lessonkit init my-course # scaffold Vite + React project
22
+ lessonkit init my-course # scaffold Vite + React project (runs npm install)
21
23
  lessonkit dev # Vite dev server
22
24
  lessonkit build # production build → dist/
23
25
  lessonkit package --target scorm12 # LMS artifact
24
26
  ```
25
27
 
28
+ ### Init flags
29
+
30
+ | Flag | Purpose |
31
+ | --- | --- |
32
+ | `--here` | Scaffold in the current directory |
33
+ | `--force` | Overwrite existing files in the target directory |
34
+ | `--skip-install` | Skip `npm install` after copying the template |
35
+
36
+ ### Package targets
37
+
26
38
  | Target | Output |
27
39
  | --- | --- |
28
40
  | `react-vite` | Vite build only |
@@ -32,11 +44,20 @@ lessonkit package --target scorm12 # LMS artifact
32
44
 
33
45
  Every project includes a root `lessonkit.json` manifest (`schemaVersion: 1`).
34
46
 
35
- Subprocess timeout defaults to **30 minutes** (`LESSONKIT_CMD_TIMEOUT_MS`). To disable the timeout, pass `timeoutMs: 0` in the exec API (the env var falls back to 30 minutes when set to `0` or omitted).
47
+ Subprocess timeout defaults to **30 minutes** (`LESSONKIT_CMD_TIMEOUT_MS`).
48
+
49
+ ## Common issues
50
+
51
+ | Symptom | Fix |
52
+ | --- | --- |
53
+ | `lessonkit: command not found` | Use `npx @lessonkit/cli` or `npm run dev` in a scaffolded project |
54
+ | `init` fails on Node version | Use Node **20.19+** for Vite 8 scaffold |
55
+ | SCORM zip not at project root | Default path: `.lxpack/course/.lxpack/out/course-scorm12.zip` — trust CLI stdout |
56
+ | ID parity errors on `package` | Align `courseId`, `lessonId`, `checkId` between React and `lessonkit.json` |
36
57
 
37
58
  ## Docs
38
59
 
39
- [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) · [Template source](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) · [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))
40
61
 
41
62
  ## License
42
63
 
package/dist/bin.js CHANGED
@@ -1,13 +1,14 @@
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, writeFile } from "fs/promises";
9
+ import { cp, mkdir, readdir, readFile, rename, rm, stat, writeFile } from "fs/promises";
10
10
  import { existsSync } from "fs";
11
+ import { randomUUID } from "crypto";
11
12
  import { basename, dirname, join, resolve } from "path";
12
13
  import { fileURLToPath } from "url";
13
14
 
@@ -124,7 +125,7 @@ async function runNpmInstall(cwd) {
124
125
  }
125
126
 
126
127
  // src/commands/init.ts
127
- var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", ".lxpack", ".git"]);
128
+ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", ".lxpack", ".git", "coverage", ".nyc_output"]);
128
129
  var SKIP_FILES = /* @__PURE__ */ new Set([".DS_Store"]);
129
130
  function getTemplateDir() {
130
131
  const thisDir = dirname(fileURLToPath(import.meta.url));
@@ -148,7 +149,7 @@ async function isDirEmptyOrDotfilesOnly(dir) {
148
149
  return entries.every((name) => name.startsWith("."));
149
150
  }
150
151
  function escapeJsxString(value) {
151
- return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\{/g, "\\{").replace(/\}/g, "\\}").replace(/</g, "\\u003c").replace(/\r\n|\n|\r/g, "\\n");
152
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\{/g, "\\{").replace(/\}/g, "\\}").replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029").replace(/\r\n|\n|\r/g, "\\n");
152
153
  }
153
154
  async function copyTemplate(src, dest) {
154
155
  await mkdir(dest, { recursive: true });
@@ -169,7 +170,7 @@ async function applyTemplateSubstitutions(projectDir, projectName, slug) {
169
170
  const pkgPath = join(projectDir, "package.json");
170
171
  const lessonkitPath = join(projectDir, "lessonkit.json");
171
172
  const pkg = JSON.parse(await readFile(pkgPath, "utf8"));
172
- pkg.name = projectName;
173
+ pkg.name = slug;
173
174
  await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}
174
175
  `, "utf8");
175
176
  const lessonkit = JSON.parse(await readFile(lessonkitPath, "utf8"));
@@ -177,11 +178,11 @@ async function applyTemplateSubstitutions(projectDir, projectName, slug) {
177
178
  const course = lessonkit.course;
178
179
  course.courseId = slug;
179
180
  course.title = projectName;
180
- const tracking = lessonkit.tracking ?? {};
181
- const xapi = tracking.xapi ?? {};
182
- xapi.activityIri = `https://example.com/courses/${slug}`;
183
- tracking.xapi = xapi;
184
- lessonkit.tracking = tracking;
181
+ const courseTracking = course.tracking ?? {};
182
+ const courseXapi = courseTracking.xapi ?? {};
183
+ courseXapi.activityIri = `https://example.com/courses/${slug}`;
184
+ courseTracking.xapi = courseXapi;
185
+ course.tracking = courseTracking;
185
186
  await writeFile(lessonkitPath, `${JSON.stringify(lessonkit, null, 2)}
186
187
  `, "utf8");
187
188
  const courseConfigPath = join(projectDir, "src", "courseConfig.ts");
@@ -194,6 +195,75 @@ async function applyTemplateSubstitutions(projectDir, projectName, slug) {
194
195
  appSource = appSource.replace(/\{\{courseTitle\}\}/g, escapeJsxString(projectName));
195
196
  await writeFile(appPath, appSource, "utf8");
196
197
  }
198
+ async function backupConflictingFiles(stagingDir, projectDir) {
199
+ const backups = /* @__PURE__ */ new Map();
200
+ const stagingEntries = await readdir(stagingDir, { withFileTypes: true });
201
+ for (const entry of stagingEntries) {
202
+ const destPath = join(projectDir, entry.name);
203
+ if (!existsSync(destPath)) continue;
204
+ const destStat = await stat(destPath);
205
+ if (destStat.isFile()) {
206
+ backups.set(entry.name, await readFile(destPath));
207
+ }
208
+ }
209
+ return backups;
210
+ }
211
+ async function rollbackPromotedFiles(projectDir, stagingDir, preExisting, backups) {
212
+ const failures = [];
213
+ let stagingEntries;
214
+ try {
215
+ stagingEntries = await readdir(stagingDir, { withFileTypes: true });
216
+ } catch {
217
+ return;
218
+ }
219
+ for (const entry of stagingEntries) {
220
+ if (preExisting.has(entry.name)) continue;
221
+ try {
222
+ await rm(join(projectDir, entry.name), { recursive: true, force: true });
223
+ } catch (err) {
224
+ failures.push(
225
+ `remove ${entry.name}: ${err instanceof Error ? err.message : String(err)}`
226
+ );
227
+ }
228
+ }
229
+ for (const [name, content] of backups) {
230
+ try {
231
+ await writeFile(join(projectDir, name), content);
232
+ } catch (err) {
233
+ failures.push(`restore ${name}: ${err instanceof Error ? err.message : String(err)}`);
234
+ }
235
+ }
236
+ if (failures.length > 0) {
237
+ throw new CliError(`Init rollback failed: ${failures.join("; ")}`, {
238
+ code: "RUNTIME",
239
+ exitCode: EXIT_INVALID_PROJECT
240
+ });
241
+ }
242
+ }
243
+ async function promoteStagingToProjectDir(stagingDir, projectDir) {
244
+ await mkdir(projectDir, { recursive: true });
245
+ const entries = await readdir(stagingDir, { withFileTypes: true });
246
+ for (const entry of entries) {
247
+ const srcPath = join(stagingDir, entry.name);
248
+ const destPath = join(projectDir, entry.name);
249
+ if (entry.isDirectory()) {
250
+ await cp(srcPath, destPath, { recursive: true });
251
+ } else if (entry.isFile()) {
252
+ await cp(srcPath, destPath);
253
+ } else {
254
+ }
255
+ }
256
+ }
257
+ var __testInitHelpers = {
258
+ getTemplateDir,
259
+ isDirEmpty,
260
+ isDirEmptyOrDotfilesOnly,
261
+ escapeJsxString,
262
+ copyTemplate,
263
+ promoteStagingToProjectDir,
264
+ rollbackPromotedFiles,
265
+ backupConflictingFiles
266
+ };
197
267
  async function runInit(opts, logger) {
198
268
  const cwd = process.cwd();
199
269
  const rawName = opts.name ?? (opts.here ? slugifyId(basename(process.cwd()) || "my-course") : void 0);
@@ -221,11 +291,14 @@ async function runInit(opts, logger) {
221
291
  }
222
292
  );
223
293
  }
224
- if (opts.here && !await isDirEmpty(projectDir) && !opts.force) {
225
- throw new CliError(`Directory is not empty: ${projectDir}. Use --force to initialize anyway.`, {
226
- code: "INVALID_PROJECT",
227
- exitCode: EXIT_INVALID_PROJECT
228
- });
294
+ if (opts.here && !await isDirEmptyOrDotfilesOnly(projectDir) && !opts.force) {
295
+ throw new CliError(
296
+ `Directory is not empty: ${projectDir}. Use --here --force only when the directory is empty or contains dotfiles only (e.g. .git).`,
297
+ {
298
+ code: "INVALID_PROJECT",
299
+ exitCode: EXIT_INVALID_PROJECT
300
+ }
301
+ );
229
302
  }
230
303
  if (opts.here && opts.force && !await isDirEmptyOrDotfilesOnly(projectDir)) {
231
304
  throw new CliError(
@@ -243,11 +316,42 @@ async function runInit(opts, logger) {
243
316
  exitCode: EXIT_INVALID_PROJECT
244
317
  });
245
318
  }
246
- await copyTemplate(templateDir, projectDir);
247
- await applyTemplateSubstitutions(projectDir, projectName, slug);
248
- if (!opts.skipInstall) {
249
- if (!opts.json) logger.log(`Installing dependencies in ${projectDir}\u2026`);
250
- await runNpmInstall(projectDir);
319
+ const stagingDir = opts.here ? join(cwd, `.lessonkit-init-${randomUUID()}`) : join(cwd, `.${slug}-init-${randomUUID()}`);
320
+ try {
321
+ await copyTemplate(templateDir, stagingDir);
322
+ await applyTemplateSubstitutions(stagingDir, projectName, slug);
323
+ if (!opts.skipInstall) {
324
+ if (!opts.json) logger.log(`Installing dependencies in ${stagingDir}\u2026`);
325
+ await runNpmInstall(stagingDir);
326
+ }
327
+ if (opts.here) {
328
+ const preExisting = new Set(await readdir(projectDir));
329
+ const backups = await backupConflictingFiles(stagingDir, projectDir);
330
+ try {
331
+ await __testInitHelpers.promoteStagingToProjectDir(stagingDir, projectDir);
332
+ } catch (promoteErr) {
333
+ try {
334
+ await rollbackPromotedFiles(projectDir, stagingDir, preExisting, backups);
335
+ } catch (rollbackErr) {
336
+ const promoteMessage = promoteErr instanceof Error ? promoteErr.message : String(promoteErr);
337
+ const rollbackMessage = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
338
+ throw new CliError(`${promoteMessage}; ${rollbackMessage}`, {
339
+ code: "RUNTIME",
340
+ exitCode: EXIT_INVALID_PROJECT
341
+ });
342
+ }
343
+ throw promoteErr;
344
+ }
345
+ await rm(stagingDir, { recursive: true, force: true });
346
+ } else {
347
+ await rename(stagingDir, projectDir);
348
+ }
349
+ } catch (err) {
350
+ await rm(stagingDir, { recursive: true, force: true }).catch(
351
+ /* v8 ignore next */
352
+ () => void 0
353
+ );
354
+ throw err;
251
355
  }
252
356
  if (!opts.json) {
253
357
  logger.log(`Created LessonKit project at ${projectDir}`);
@@ -256,6 +360,12 @@ async function runInit(opts, logger) {
256
360
  return { ok: true, command: "init", projectRoot: projectDir };
257
361
  }
258
362
 
363
+ // src/commands/dev.ts
364
+ import { existsSync as existsSync3 } from "fs";
365
+ import { mkdir as mkdir2 } from "fs/promises";
366
+ import { dirname as dirname3, join as join3 } from "path";
367
+ import { assertSpaDistContentsSafe } from "@lessonkit/lxpack";
368
+
259
369
  // src/lib/project.ts
260
370
  import { readFileSync, existsSync as existsSync2 } from "fs";
261
371
  import { readFile as readFile2 } from "fs/promises";
@@ -264,10 +374,13 @@ import { dirname as dirname2, join as join2, parse, resolve as resolve2 } from "
264
374
  import { parseLessonkitManifest } from "@lessonkit/lxpack";
265
375
  var LESSONKIT_JSON = "lessonkit.json";
266
376
  var PACKAGE_JSON = "package.json";
377
+ function isSchemaVersionOne(value) {
378
+ return value === 1 || value === "1";
379
+ }
267
380
  function isProjectManifest(configPath) {
268
381
  try {
269
382
  const raw = JSON.parse(readFileSync(configPath, "utf8"));
270
- return raw.schemaVersion === 1 && typeof raw.name === "string" && raw.course !== null && typeof raw.course === "object" && !Array.isArray(raw.course);
383
+ return isSchemaVersionOne(raw.schemaVersion) && typeof raw.name === "string" && raw.course !== null && typeof raw.course === "object" && !Array.isArray(raw.course);
271
384
  } catch {
272
385
  return false;
273
386
  }
@@ -440,7 +553,12 @@ function resolvePackageOutput(project, target, override) {
440
553
  if (override) {
441
554
  try {
442
555
  const resolved = resolveSafePackageOutputOverride(project.root, override);
443
- return { output: resolved, dir: target === "standalone", outputBaseDir };
556
+ const isZipOutput = override.trim().toLowerCase().endsWith(".zip");
557
+ return {
558
+ output: resolved,
559
+ dir: target === "standalone" && !isZipOutput,
560
+ outputBaseDir
561
+ };
444
562
  } catch (err) {
445
563
  const message = err instanceof Error ? err.message : String(err);
446
564
  throw new CliError(message, { code: "INVALID_PROJECT", exitCode: EXIT_INVALID_PROJECT });
@@ -451,13 +569,24 @@ function resolvePackageOutput(project, target, override) {
451
569
  }
452
570
  return { output: `${outputBaseDir}/course-${target}.zip`, dir: false, outputBaseDir };
453
571
  }
454
- var DEFAULT_SPA_DIST_DIR = "dist";
455
- function resolveViteBuildArgs(project) {
456
- const args = ["build"];
457
- if (project.paths.spaDistDir !== DEFAULT_SPA_DIST_DIR) {
458
- args.push("--outDir", project.paths.spaDistDir);
572
+ function stripOutDirFromViteArgs(viteArgs) {
573
+ const stripped = [];
574
+ for (let i = 0; i < viteArgs.length; i++) {
575
+ const arg = viteArgs[i];
576
+ if (arg === "--outDir" || arg === "-o") {
577
+ i++;
578
+ continue;
579
+ }
580
+ if (arg.startsWith("--outDir=")) {
581
+ continue;
582
+ }
583
+ stripped.push(arg);
459
584
  }
460
- return args;
585
+ return stripped;
586
+ }
587
+ function resolveViteBuildArgv(project, viteArgs = []) {
588
+ const passthrough = stripOutDirFromViteArgs(viteArgs);
589
+ return ["build", ...passthrough, "--outDir", project.paths.spaDistDir];
461
590
  }
462
591
  function parsePackageTarget(value) {
463
592
  if (!value) {
@@ -475,7 +604,8 @@ async function runDev(opts) {
475
604
  const pkg = await readPackageJson(project.root);
476
605
  assertViteProject(pkg, project.root);
477
606
  const viteJs = resolveViteJs(project.root);
478
- await runCommand(process.execPath, [viteJs, ...opts.viteArgs ?? []], {
607
+ const devArgs = stripOutDirFromViteArgs(opts.viteArgs ?? []);
608
+ await runCommand(process.execPath, [viteJs, ...devArgs], {
479
609
  cwd: project.root,
480
610
  timeoutMs: 0
481
611
  });
@@ -486,15 +616,28 @@ async function runBuild(opts) {
486
616
  const pkg = await readPackageJson(project.root);
487
617
  assertViteProject(pkg, project.root);
488
618
  const viteJs = resolveViteJs(project.root);
489
- const buildArgs = resolveViteBuildArgs(project);
490
- await runCommand(process.execPath, [viteJs, ...buildArgs, ...opts.viteArgs ?? []], {
619
+ const distDir = resolveDistDir(project);
620
+ await mkdir2(dirname3(distDir), { recursive: true });
621
+ if (existsSync3(distDir)) {
622
+ await assertSpaDistContentsSafe({ main: distDir }, project.root);
623
+ }
624
+ const buildArgs = resolveViteBuildArgv(project, opts.viteArgs);
625
+ await runCommand(process.execPath, [viteJs, ...buildArgs], {
491
626
  cwd: project.root
492
627
  });
628
+ const indexHtml = join3(distDir, "index.html");
629
+ if (!existsSync3(indexHtml)) {
630
+ throw new CliError(
631
+ `Build did not produce index.html at ${indexHtml}. Check paths.spaDistDir in lessonkit.json.`,
632
+ { code: "INVALID_PROJECT", exitCode: EXIT_INVALID_PROJECT }
633
+ );
634
+ }
493
635
  return { ok: true, command: "build", projectRoot: project.root };
494
636
  }
495
637
 
496
638
  // src/commands/package.ts
497
- import { existsSync as existsSync3 } from "fs";
639
+ import { existsSync as existsSync4 } from "fs";
640
+ import { isAbsolute } from "path";
498
641
  import { packageLessonkitCourse } from "@lessonkit/lxpack";
499
642
  async function runPackage(opts) {
500
643
  let target;
@@ -512,7 +655,7 @@ async function runPackage(opts) {
512
655
  }
513
656
  const project = await loadProject(opts.cwd ?? process.cwd());
514
657
  const distDir = resolveDistDir(project);
515
- if (opts.noBuild && !existsSync3(distDir)) {
658
+ if (opts.noBuild && !existsSync4(distDir)) {
516
659
  throw new CliError(
517
660
  `dist directory not found at ${distDir}. Run lessonkit build before packaging with --no-build.`,
518
661
  {
@@ -525,7 +668,7 @@ async function runPackage(opts) {
525
668
  if (!opts.noBuild) {
526
669
  await runBuild({ cwd: project.root, json: opts.json });
527
670
  }
528
- if (!existsSync3(distDir)) {
671
+ if (!existsSync4(distDir)) {
529
672
  throw new CliError(`Build completed but dist directory not found at ${distDir}.`, {
530
673
  code: "INVALID_PROJECT",
531
674
  exitCode: EXIT_INVALID_PROJECT
@@ -537,14 +680,20 @@ async function runPackage(opts) {
537
680
  if (!opts.noBuild) {
538
681
  await runBuild({ cwd: project.root, json: opts.json });
539
682
  }
540
- if (!existsSync3(distDir)) {
683
+ if (!existsSync4(distDir)) {
541
684
  throw new CliError(`dist directory not found at ${distDir}. Run lessonkit build first.`, {
542
685
  code: "INVALID_PROJECT",
543
686
  exitCode: EXIT_INVALID_PROJECT
544
687
  });
545
688
  }
546
689
  const outDir = resolveLxpackOutDir(project);
547
- const { output, dir, outputBaseDir } = resolvePackageOutput(project, target, opts.out);
690
+ const { output: resolvedOutput, dir, outputBaseDir } = resolvePackageOutput(
691
+ project,
692
+ target,
693
+ opts.out
694
+ );
695
+ const trimmedOut = opts.out?.trim();
696
+ const output = trimmedOut && !isAbsolute(trimmedOut) ? trimmedOut : resolvedOutput;
548
697
  const result = await packageLessonkitCourse({
549
698
  descriptor: project.course,
550
699
  outDir,
@@ -553,7 +702,9 @@ async function runPackage(opts) {
553
702
  target,
554
703
  output,
555
704
  dir,
556
- outputBaseDir
705
+ outputBaseDir,
706
+ strictParity: opts.strictParity,
707
+ strictBuild: opts.strict
557
708
  });
558
709
  if (!result.ok) {
559
710
  throw new CliError("Packaging failed.", {
@@ -585,6 +736,112 @@ async function runPackage(opts) {
585
736
  };
586
737
  }
587
738
 
739
+ // src/commands/export.ts
740
+ import { existsSync as existsSync5 } from "fs";
741
+ import { relative, resolve as resolve4 } from "path";
742
+ import { exportLkcourse, resolveSafePackageOutputOverride as resolveSafePackageOutputOverride2 } from "@lessonkit/lxpack";
743
+ function resolveExportOutput(projectRoot, override, defaultName) {
744
+ if (override) {
745
+ try {
746
+ return resolveSafePackageOutputOverride2(projectRoot, override);
747
+ } catch (err) {
748
+ const message = err instanceof Error ? err.message : String(err);
749
+ throw new CliError(message, { code: "INVALID_PROJECT", exitCode: EXIT_INVALID_PROJECT });
750
+ }
751
+ }
752
+ return resolve4(projectRoot, `${defaultName ?? "course"}.lkcourse`);
753
+ }
754
+ async function runExport(opts) {
755
+ const project = await loadProject(opts.cwd ?? process.cwd());
756
+ const distDir = resolve4(project.root, project.paths.spaDistDir);
757
+ if (opts.noBuild && !existsSync5(distDir)) {
758
+ throw new CliError(
759
+ `dist directory not found at ${distDir}. Run lessonkit build before export with --no-build.`,
760
+ {
761
+ code: "INVALID_PROJECT",
762
+ exitCode: EXIT_INVALID_PROJECT
763
+ }
764
+ );
765
+ }
766
+ if (!opts.noBuild) {
767
+ await runBuild({ cwd: project.root, json: opts.json });
768
+ }
769
+ if (!existsSync5(distDir)) {
770
+ throw new CliError(`dist directory not found at ${distDir}. Run lessonkit build first.`, {
771
+ code: "INVALID_PROJECT",
772
+ exitCode: EXIT_INVALID_PROJECT
773
+ });
774
+ }
775
+ const resolvedOut = resolveExportOutput(project.root, opts.out, project.name);
776
+ const outRelative = relative(project.root, resolvedOut).replace(/\\/g, "/");
777
+ const result = await exportLkcourse({
778
+ projectRoot: project.root,
779
+ manifest: project,
780
+ outPath: outRelative,
781
+ includeBlockTree: Boolean(opts.withBlockTree)
782
+ });
783
+ if (!result.ok) {
784
+ throw new CliError(
785
+ result.issues.map((i) => `${i.path}: ${i.message}`).join("; "),
786
+ { code: "EXPORT_FAILED", exitCode: EXIT_PACKAGING }
787
+ );
788
+ }
789
+ return {
790
+ ok: true,
791
+ command: "export",
792
+ projectRoot: project.root,
793
+ archivePath: result.archivePath,
794
+ fileCount: result.fileCount,
795
+ includeBlockTree: result.includeBlockTree
796
+ };
797
+ }
798
+
799
+ // src/commands/blocks.ts
800
+ import { readFileSync as readFileSync2 } from "fs";
801
+ import { createRequire as createRequire2 } from "module";
802
+ function loadBlockCatalog() {
803
+ const require3 = createRequire2(import.meta.url);
804
+ const catalogPath = require3.resolve("@lessonkit/react/block-catalog.v3.json");
805
+ return JSON.parse(readFileSync2(catalogPath, "utf8"));
806
+ }
807
+ function filterEntries(entries, opts) {
808
+ return entries.filter((entry) => {
809
+ if (opts.category && entry.category !== opts.category) return false;
810
+ if (opts.tier && entry.tier !== opts.tier) return false;
811
+ return true;
812
+ });
813
+ }
814
+ async function runBlocksList(opts) {
815
+ const catalog = loadBlockCatalog();
816
+ const entries = filterEntries(catalog.entries, opts);
817
+ if (!opts.json) {
818
+ const lines = [
819
+ "type category h5pMachineName",
820
+ ...entries.map(
821
+ (entry) => [
822
+ entry.type,
823
+ entry.category ?? "\u2014",
824
+ entry.h5pMachineName ?? "\u2014"
825
+ ].join(" ")
826
+ )
827
+ ];
828
+ return {
829
+ ok: true,
830
+ command: "blocks list",
831
+ schemaVersion: catalog.schemaVersion,
832
+ count: entries.length,
833
+ text: lines.join("\n")
834
+ };
835
+ }
836
+ return {
837
+ ok: true,
838
+ command: "blocks list",
839
+ schemaVersion: catalog.schemaVersion,
840
+ count: entries.length,
841
+ entries
842
+ };
843
+ }
844
+
588
845
  // src/lib/logger.ts
589
846
  function createLogger(opts) {
590
847
  if (opts?.json) {
@@ -599,7 +856,7 @@ function createLogger(opts) {
599
856
  }
600
857
 
601
858
  // src/index.ts
602
- var require2 = createRequire2(import.meta.url);
859
+ var require2 = createRequire3(import.meta.url);
603
860
  var { version } = require2("../package.json");
604
861
  async function handleCommand(fn, logger, json) {
605
862
  try {
@@ -656,7 +913,7 @@ function createProgram(baseLogger = console) {
656
913
  );
657
914
  }
658
915
  );
659
- 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("--json", "Emit structured JSON result").action(async (opts) => {
916
+ 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) => {
660
917
  const logger = createLogger({ json: opts.json });
661
918
  await handleCommand(
662
919
  async () => {
@@ -665,7 +922,9 @@ function createProgram(baseLogger = console) {
665
922
  cwd: opts.cwd,
666
923
  noBuild: opts.build === false,
667
924
  out: opts.out,
668
- json: opts.json
925
+ json: opts.json,
926
+ strictParity: opts.strictParity,
927
+ strict: opts.strict
669
928
  });
670
929
  if (!opts.json && result.ok && result.command === "package") {
671
930
  if (result.target === "react-vite") {
@@ -683,8 +942,50 @@ function createProgram(baseLogger = console) {
683
942
  Boolean(opts.json)
684
943
  );
685
944
  });
686
- program.command("publish").description("Publish package artifacts (stub)").action(() => {
687
- baseLogger.log("lessonkit publish is not implemented. See RELEASING.md for npm publish workflow.");
945
+ addCwdAndJson(
946
+ 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")
947
+ ).action(async (opts) => {
948
+ const logger = createLogger({ json: opts.json });
949
+ await handleCommand(
950
+ async () => {
951
+ const result = await runExport({
952
+ cwd: opts.cwd,
953
+ out: opts.out,
954
+ noBuild: opts.build === false,
955
+ withBlockTree: opts.withBlockTree,
956
+ json: opts.json
957
+ });
958
+ if (!opts.json && result.ok && result.command === "export") {
959
+ logger.log(`Exported .lkcourse \u2192 ${result.archivePath} (${result.fileCount} files)`);
960
+ }
961
+ return result;
962
+ },
963
+ logger,
964
+ Boolean(opts.json)
965
+ );
966
+ });
967
+ 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) => {
968
+ const logger = createLogger({ json: opts.json });
969
+ await handleCommand(
970
+ async () => {
971
+ const result = await runBlocksList({
972
+ json: opts.json,
973
+ category: opts.category,
974
+ tier: opts.tier
975
+ });
976
+ if (!opts.json && result.ok && "text" in result && typeof result.text === "string") {
977
+ logger.log(result.text);
978
+ }
979
+ return result;
980
+ },
981
+ logger,
982
+ Boolean(opts.json)
983
+ );
984
+ });
985
+ program.command("publish").description("[maintainers] Not implemented \u2014 use Changesets (see RELEASING.md)").action(() => {
986
+ baseLogger.log(
987
+ "lessonkit publish is not implemented. Monorepo releases use Changesets: npm run changeset && npm run version-packages && npm run release. See RELEASING.md."
988
+ );
688
989
  });
689
990
  return program;
690
991
  }
package/dist/index.d.ts CHANGED
@@ -5,7 +5,14 @@ type CliLogger = {
5
5
  error: (...args: unknown[]) => void;
6
6
  };
7
7
 
8
+ /**
9
+ * Build the Commander program used by the `lessonkit` CLI binary.
10
+ * Useful for embedding init/build/package in Node scripts and tests.
11
+ */
8
12
  declare function createProgram(baseLogger?: CliLogger): Command;
13
+ /**
14
+ * Parse argv and run the LessonKit CLI (same as the `lessonkit` binary entrypoint).
15
+ */
9
16
  declare function run(argv?: string[], logger?: CliLogger): Promise<void>;
10
17
 
11
18
  export { type CliLogger, createProgram, run };