@lessonkit/cli 1.4.0 → 1.5.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
@@ -6,8 +6,9 @@ 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, 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));
@@ -137,18 +138,13 @@ function getTemplateDir() {
137
138
  }
138
139
  return candidates[0];
139
140
  }
140
- async function isDirEmpty(dir) {
141
- if (!existsSync(dir)) return true;
142
- const entries = await readdir(dir);
143
- return entries.length === 0;
144
- }
145
141
  async function isDirEmptyOrDotfilesOnly(dir) {
146
142
  if (!existsSync(dir)) return true;
147
143
  const entries = await readdir(dir);
148
144
  return entries.every((name) => name.startsWith("."));
149
145
  }
150
146
  function escapeJsxString(value) {
151
- return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\{/g, "\\{").replace(/\}/g, "\\}").replace(/</g, "\\u003c").replace(/\r\n|\n|\r/g, "\\n");
147
+ 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
148
  }
153
149
  async function copyTemplate(src, dest) {
154
150
  await mkdir(dest, { recursive: true });
@@ -169,7 +165,7 @@ async function applyTemplateSubstitutions(projectDir, projectName, slug) {
169
165
  const pkgPath = join(projectDir, "package.json");
170
166
  const lessonkitPath = join(projectDir, "lessonkit.json");
171
167
  const pkg = JSON.parse(await readFile(pkgPath, "utf8"));
172
- pkg.name = projectName;
168
+ pkg.name = slug;
173
169
  await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}
174
170
  `, "utf8");
175
171
  const lessonkit = JSON.parse(await readFile(lessonkitPath, "utf8"));
@@ -177,11 +173,11 @@ async function applyTemplateSubstitutions(projectDir, projectName, slug) {
177
173
  const course = lessonkit.course;
178
174
  course.courseId = slug;
179
175
  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;
176
+ const courseTracking = course.tracking ?? {};
177
+ const courseXapi = courseTracking.xapi ?? {};
178
+ courseXapi.activityIri = `https://example.com/courses/${slug}`;
179
+ courseTracking.xapi = courseXapi;
180
+ course.tracking = courseTracking;
185
181
  await writeFile(lessonkitPath, `${JSON.stringify(lessonkit, null, 2)}
186
182
  `, "utf8");
187
183
  const courseConfigPath = join(projectDir, "src", "courseConfig.ts");
@@ -194,6 +190,20 @@ async function applyTemplateSubstitutions(projectDir, projectName, slug) {
194
190
  appSource = appSource.replace(/\{\{courseTitle\}\}/g, escapeJsxString(projectName));
195
191
  await writeFile(appPath, appSource, "utf8");
196
192
  }
193
+ async function promoteStagingToProjectDir(stagingDir, projectDir) {
194
+ await mkdir(projectDir, { recursive: true });
195
+ const entries = await readdir(stagingDir, { withFileTypes: true });
196
+ for (const entry of entries) {
197
+ const srcPath = join(stagingDir, entry.name);
198
+ const destPath = join(projectDir, entry.name);
199
+ if (entry.isDirectory()) {
200
+ await cp(srcPath, destPath, { recursive: true });
201
+ } else if (entry.isFile()) {
202
+ await cp(srcPath, destPath);
203
+ } else {
204
+ }
205
+ }
206
+ }
197
207
  async function runInit(opts, logger) {
198
208
  const cwd = process.cwd();
199
209
  const rawName = opts.name ?? (opts.here ? slugifyId(basename(process.cwd()) || "my-course") : void 0);
@@ -221,7 +231,7 @@ async function runInit(opts, logger) {
221
231
  }
222
232
  );
223
233
  }
224
- if (opts.here && !await isDirEmpty(projectDir) && !opts.force) {
234
+ if (opts.here && !await isDirEmptyOrDotfilesOnly(projectDir) && !opts.force) {
225
235
  throw new CliError(`Directory is not empty: ${projectDir}. Use --force to initialize anyway.`, {
226
236
  code: "INVALID_PROJECT",
227
237
  exitCode: EXIT_INVALID_PROJECT
@@ -243,11 +253,26 @@ async function runInit(opts, logger) {
243
253
  exitCode: EXIT_INVALID_PROJECT
244
254
  });
245
255
  }
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);
256
+ const stagingDir = opts.here ? join(cwd, `.lessonkit-init-${randomUUID()}`) : join(cwd, `.${slug}-init-${randomUUID()}`);
257
+ try {
258
+ await copyTemplate(templateDir, stagingDir);
259
+ await applyTemplateSubstitutions(stagingDir, projectName, slug);
260
+ if (!opts.skipInstall) {
261
+ if (!opts.json) logger.log(`Installing dependencies in ${stagingDir}\u2026`);
262
+ await runNpmInstall(stagingDir);
263
+ }
264
+ if (opts.here) {
265
+ await promoteStagingToProjectDir(stagingDir, projectDir);
266
+ await rm(stagingDir, { recursive: true, force: true });
267
+ } else {
268
+ await rename(stagingDir, projectDir);
269
+ }
270
+ } catch (err) {
271
+ await rm(stagingDir, { recursive: true, force: true }).catch(
272
+ /* v8 ignore next */
273
+ () => void 0
274
+ );
275
+ throw err;
251
276
  }
252
277
  if (!opts.json) {
253
278
  logger.log(`Created LessonKit project at ${projectDir}`);
@@ -256,6 +281,10 @@ async function runInit(opts, logger) {
256
281
  return { ok: true, command: "init", projectRoot: projectDir };
257
282
  }
258
283
 
284
+ // src/commands/dev.ts
285
+ import { existsSync as existsSync3 } from "fs";
286
+ import { join as join3 } from "path";
287
+
259
288
  // src/lib/project.ts
260
289
  import { readFileSync, existsSync as existsSync2 } from "fs";
261
290
  import { readFile as readFile2 } from "fs/promises";
@@ -451,13 +480,24 @@ function resolvePackageOutput(project, target, override) {
451
480
  }
452
481
  return { output: `${outputBaseDir}/course-${target}.zip`, dir: false, outputBaseDir };
453
482
  }
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);
483
+ function stripOutDirFromViteArgs(viteArgs) {
484
+ const stripped = [];
485
+ for (let i = 0; i < viteArgs.length; i++) {
486
+ const arg = viteArgs[i];
487
+ if (arg === "--outDir" || arg === "-o") {
488
+ i++;
489
+ continue;
490
+ }
491
+ if (arg.startsWith("--outDir=")) {
492
+ continue;
493
+ }
494
+ stripped.push(arg);
459
495
  }
460
- return args;
496
+ return stripped;
497
+ }
498
+ function resolveViteBuildArgv(project, viteArgs = []) {
499
+ const passthrough = stripOutDirFromViteArgs(viteArgs);
500
+ return ["build", ...passthrough, "--outDir", project.paths.spaDistDir];
461
501
  }
462
502
  function parsePackageTarget(value) {
463
503
  if (!value) {
@@ -486,15 +526,24 @@ async function runBuild(opts) {
486
526
  const pkg = await readPackageJson(project.root);
487
527
  assertViteProject(pkg, project.root);
488
528
  const viteJs = resolveViteJs(project.root);
489
- const buildArgs = resolveViteBuildArgs(project);
490
- await runCommand(process.execPath, [viteJs, ...buildArgs, ...opts.viteArgs ?? []], {
529
+ const buildArgs = resolveViteBuildArgv(project, opts.viteArgs);
530
+ await runCommand(process.execPath, [viteJs, ...buildArgs], {
491
531
  cwd: project.root
492
532
  });
533
+ const distDir = resolveDistDir(project);
534
+ const indexHtml = join3(distDir, "index.html");
535
+ if (!existsSync3(indexHtml)) {
536
+ throw new CliError(
537
+ `Build did not produce index.html at ${indexHtml}. Check paths.spaDistDir in lessonkit.json.`,
538
+ { code: "INVALID_PROJECT", exitCode: EXIT_INVALID_PROJECT }
539
+ );
540
+ }
493
541
  return { ok: true, command: "build", projectRoot: project.root };
494
542
  }
495
543
 
496
544
  // src/commands/package.ts
497
- import { existsSync as existsSync3 } from "fs";
545
+ import { existsSync as existsSync4 } from "fs";
546
+ import { isAbsolute } from "path";
498
547
  import { packageLessonkitCourse } from "@lessonkit/lxpack";
499
548
  async function runPackage(opts) {
500
549
  let target;
@@ -512,7 +561,7 @@ async function runPackage(opts) {
512
561
  }
513
562
  const project = await loadProject(opts.cwd ?? process.cwd());
514
563
  const distDir = resolveDistDir(project);
515
- if (opts.noBuild && !existsSync3(distDir)) {
564
+ if (opts.noBuild && !existsSync4(distDir)) {
516
565
  throw new CliError(
517
566
  `dist directory not found at ${distDir}. Run lessonkit build before packaging with --no-build.`,
518
567
  {
@@ -525,7 +574,7 @@ async function runPackage(opts) {
525
574
  if (!opts.noBuild) {
526
575
  await runBuild({ cwd: project.root, json: opts.json });
527
576
  }
528
- if (!existsSync3(distDir)) {
577
+ if (!existsSync4(distDir)) {
529
578
  throw new CliError(`Build completed but dist directory not found at ${distDir}.`, {
530
579
  code: "INVALID_PROJECT",
531
580
  exitCode: EXIT_INVALID_PROJECT
@@ -537,14 +586,20 @@ async function runPackage(opts) {
537
586
  if (!opts.noBuild) {
538
587
  await runBuild({ cwd: project.root, json: opts.json });
539
588
  }
540
- if (!existsSync3(distDir)) {
589
+ if (!existsSync4(distDir)) {
541
590
  throw new CliError(`dist directory not found at ${distDir}. Run lessonkit build first.`, {
542
591
  code: "INVALID_PROJECT",
543
592
  exitCode: EXIT_INVALID_PROJECT
544
593
  });
545
594
  }
546
595
  const outDir = resolveLxpackOutDir(project);
547
- const { output, dir, outputBaseDir } = resolvePackageOutput(project, target, opts.out);
596
+ const { output: resolvedOutput, dir, outputBaseDir } = resolvePackageOutput(
597
+ project,
598
+ target,
599
+ opts.out
600
+ );
601
+ const trimmedOut = opts.out?.trim();
602
+ const output = trimmedOut && !isAbsolute(trimmedOut) ? trimmedOut : resolvedOutput;
548
603
  const result = await packageLessonkitCourse({
549
604
  descriptor: project.course,
550
605
  outDir,
@@ -553,7 +608,8 @@ async function runPackage(opts) {
553
608
  target,
554
609
  output,
555
610
  dir,
556
- outputBaseDir
611
+ outputBaseDir,
612
+ strictParity: opts.strictParity
557
613
  });
558
614
  if (!result.ok) {
559
615
  throw new CliError("Packaging failed.", {
@@ -656,7 +712,7 @@ function createProgram(baseLogger = console) {
656
712
  );
657
713
  }
658
714
  );
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) => {
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) => {
660
716
  const logger = createLogger({ json: opts.json });
661
717
  await handleCommand(
662
718
  async () => {
@@ -665,7 +721,8 @@ function createProgram(baseLogger = console) {
665
721
  cwd: opts.cwd,
666
722
  noBuild: opts.build === false,
667
723
  out: opts.out,
668
- json: opts.json
724
+ json: opts.json,
725
+ strictParity: opts.strictParity
669
726
  });
670
727
  if (!opts.json && result.ok && result.command === "package") {
671
728
  if (result.target === "react-vite") {
@@ -683,8 +740,10 @@ function createProgram(baseLogger = console) {
683
740
  Boolean(opts.json)
684
741
  );
685
742
  });
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.");
743
+ program.command("publish").description("[maintainers] Not implemented \u2014 use Changesets (see RELEASING.md)").action(() => {
744
+ baseLogger.log(
745
+ "lessonkit publish is not implemented. Monorepo releases use Changesets: npm run changeset && npm run version-packages && npm run release. See RELEASING.md."
746
+ );
688
747
  });
689
748
  return program;
690
749
  }
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 };
package/dist/index.js CHANGED
@@ -4,8 +4,9 @@ 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, writeFile } from "fs/promises";
7
+ import { cp, mkdir, readdir, readFile, rename, rm, writeFile } from "fs/promises";
8
8
  import { existsSync } from "fs";
9
+ import { randomUUID } from "crypto";
9
10
  import { basename, dirname, join, resolve } from "path";
10
11
  import { fileURLToPath } from "url";
11
12
 
@@ -122,7 +123,7 @@ async function runNpmInstall(cwd) {
122
123
  }
123
124
 
124
125
  // src/commands/init.ts
125
- var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", ".lxpack", ".git"]);
126
+ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", ".lxpack", ".git", "coverage", ".nyc_output"]);
126
127
  var SKIP_FILES = /* @__PURE__ */ new Set([".DS_Store"]);
127
128
  function getTemplateDir() {
128
129
  const thisDir = dirname(fileURLToPath(import.meta.url));
@@ -135,18 +136,13 @@ function getTemplateDir() {
135
136
  }
136
137
  return candidates[0];
137
138
  }
138
- async function isDirEmpty(dir) {
139
- if (!existsSync(dir)) return true;
140
- const entries = await readdir(dir);
141
- return entries.length === 0;
142
- }
143
139
  async function isDirEmptyOrDotfilesOnly(dir) {
144
140
  if (!existsSync(dir)) return true;
145
141
  const entries = await readdir(dir);
146
142
  return entries.every((name) => name.startsWith("."));
147
143
  }
148
144
  function escapeJsxString(value) {
149
- return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\{/g, "\\{").replace(/\}/g, "\\}").replace(/</g, "\\u003c").replace(/\r\n|\n|\r/g, "\\n");
145
+ 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");
150
146
  }
151
147
  async function copyTemplate(src, dest) {
152
148
  await mkdir(dest, { recursive: true });
@@ -167,7 +163,7 @@ async function applyTemplateSubstitutions(projectDir, projectName, slug) {
167
163
  const pkgPath = join(projectDir, "package.json");
168
164
  const lessonkitPath = join(projectDir, "lessonkit.json");
169
165
  const pkg = JSON.parse(await readFile(pkgPath, "utf8"));
170
- pkg.name = projectName;
166
+ pkg.name = slug;
171
167
  await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}
172
168
  `, "utf8");
173
169
  const lessonkit = JSON.parse(await readFile(lessonkitPath, "utf8"));
@@ -175,11 +171,11 @@ async function applyTemplateSubstitutions(projectDir, projectName, slug) {
175
171
  const course = lessonkit.course;
176
172
  course.courseId = slug;
177
173
  course.title = projectName;
178
- const tracking = lessonkit.tracking ?? {};
179
- const xapi = tracking.xapi ?? {};
180
- xapi.activityIri = `https://example.com/courses/${slug}`;
181
- tracking.xapi = xapi;
182
- lessonkit.tracking = tracking;
174
+ const courseTracking = course.tracking ?? {};
175
+ const courseXapi = courseTracking.xapi ?? {};
176
+ courseXapi.activityIri = `https://example.com/courses/${slug}`;
177
+ courseTracking.xapi = courseXapi;
178
+ course.tracking = courseTracking;
183
179
  await writeFile(lessonkitPath, `${JSON.stringify(lessonkit, null, 2)}
184
180
  `, "utf8");
185
181
  const courseConfigPath = join(projectDir, "src", "courseConfig.ts");
@@ -192,6 +188,20 @@ async function applyTemplateSubstitutions(projectDir, projectName, slug) {
192
188
  appSource = appSource.replace(/\{\{courseTitle\}\}/g, escapeJsxString(projectName));
193
189
  await writeFile(appPath, appSource, "utf8");
194
190
  }
191
+ async function promoteStagingToProjectDir(stagingDir, projectDir) {
192
+ await mkdir(projectDir, { recursive: true });
193
+ const entries = await readdir(stagingDir, { withFileTypes: true });
194
+ for (const entry of entries) {
195
+ const srcPath = join(stagingDir, entry.name);
196
+ const destPath = join(projectDir, entry.name);
197
+ if (entry.isDirectory()) {
198
+ await cp(srcPath, destPath, { recursive: true });
199
+ } else if (entry.isFile()) {
200
+ await cp(srcPath, destPath);
201
+ } else {
202
+ }
203
+ }
204
+ }
195
205
  async function runInit(opts, logger) {
196
206
  const cwd = process.cwd();
197
207
  const rawName = opts.name ?? (opts.here ? slugifyId(basename(process.cwd()) || "my-course") : void 0);
@@ -219,7 +229,7 @@ async function runInit(opts, logger) {
219
229
  }
220
230
  );
221
231
  }
222
- if (opts.here && !await isDirEmpty(projectDir) && !opts.force) {
232
+ if (opts.here && !await isDirEmptyOrDotfilesOnly(projectDir) && !opts.force) {
223
233
  throw new CliError(`Directory is not empty: ${projectDir}. Use --force to initialize anyway.`, {
224
234
  code: "INVALID_PROJECT",
225
235
  exitCode: EXIT_INVALID_PROJECT
@@ -241,11 +251,26 @@ async function runInit(opts, logger) {
241
251
  exitCode: EXIT_INVALID_PROJECT
242
252
  });
243
253
  }
244
- await copyTemplate(templateDir, projectDir);
245
- await applyTemplateSubstitutions(projectDir, projectName, slug);
246
- if (!opts.skipInstall) {
247
- if (!opts.json) logger.log(`Installing dependencies in ${projectDir}\u2026`);
248
- await runNpmInstall(projectDir);
254
+ const stagingDir = opts.here ? join(cwd, `.lessonkit-init-${randomUUID()}`) : join(cwd, `.${slug}-init-${randomUUID()}`);
255
+ try {
256
+ await copyTemplate(templateDir, stagingDir);
257
+ await applyTemplateSubstitutions(stagingDir, projectName, slug);
258
+ if (!opts.skipInstall) {
259
+ if (!opts.json) logger.log(`Installing dependencies in ${stagingDir}\u2026`);
260
+ await runNpmInstall(stagingDir);
261
+ }
262
+ if (opts.here) {
263
+ await promoteStagingToProjectDir(stagingDir, projectDir);
264
+ await rm(stagingDir, { recursive: true, force: true });
265
+ } else {
266
+ await rename(stagingDir, projectDir);
267
+ }
268
+ } catch (err) {
269
+ await rm(stagingDir, { recursive: true, force: true }).catch(
270
+ /* v8 ignore next */
271
+ () => void 0
272
+ );
273
+ throw err;
249
274
  }
250
275
  if (!opts.json) {
251
276
  logger.log(`Created LessonKit project at ${projectDir}`);
@@ -254,6 +279,10 @@ async function runInit(opts, logger) {
254
279
  return { ok: true, command: "init", projectRoot: projectDir };
255
280
  }
256
281
 
282
+ // src/commands/dev.ts
283
+ import { existsSync as existsSync3 } from "fs";
284
+ import { join as join3 } from "path";
285
+
257
286
  // src/lib/project.ts
258
287
  import { readFileSync, existsSync as existsSync2 } from "fs";
259
288
  import { readFile as readFile2 } from "fs/promises";
@@ -449,13 +478,24 @@ function resolvePackageOutput(project, target, override) {
449
478
  }
450
479
  return { output: `${outputBaseDir}/course-${target}.zip`, dir: false, outputBaseDir };
451
480
  }
452
- var DEFAULT_SPA_DIST_DIR = "dist";
453
- function resolveViteBuildArgs(project) {
454
- const args = ["build"];
455
- if (project.paths.spaDistDir !== DEFAULT_SPA_DIST_DIR) {
456
- args.push("--outDir", project.paths.spaDistDir);
481
+ function stripOutDirFromViteArgs(viteArgs) {
482
+ const stripped = [];
483
+ for (let i = 0; i < viteArgs.length; i++) {
484
+ const arg = viteArgs[i];
485
+ if (arg === "--outDir" || arg === "-o") {
486
+ i++;
487
+ continue;
488
+ }
489
+ if (arg.startsWith("--outDir=")) {
490
+ continue;
491
+ }
492
+ stripped.push(arg);
457
493
  }
458
- return args;
494
+ return stripped;
495
+ }
496
+ function resolveViteBuildArgv(project, viteArgs = []) {
497
+ const passthrough = stripOutDirFromViteArgs(viteArgs);
498
+ return ["build", ...passthrough, "--outDir", project.paths.spaDistDir];
459
499
  }
460
500
  function parsePackageTarget(value) {
461
501
  if (!value) {
@@ -484,15 +524,24 @@ async function runBuild(opts) {
484
524
  const pkg = await readPackageJson(project.root);
485
525
  assertViteProject(pkg, project.root);
486
526
  const viteJs = resolveViteJs(project.root);
487
- const buildArgs = resolveViteBuildArgs(project);
488
- await runCommand(process.execPath, [viteJs, ...buildArgs, ...opts.viteArgs ?? []], {
527
+ const buildArgs = resolveViteBuildArgv(project, opts.viteArgs);
528
+ await runCommand(process.execPath, [viteJs, ...buildArgs], {
489
529
  cwd: project.root
490
530
  });
531
+ const distDir = resolveDistDir(project);
532
+ const indexHtml = join3(distDir, "index.html");
533
+ if (!existsSync3(indexHtml)) {
534
+ throw new CliError(
535
+ `Build did not produce index.html at ${indexHtml}. Check paths.spaDistDir in lessonkit.json.`,
536
+ { code: "INVALID_PROJECT", exitCode: EXIT_INVALID_PROJECT }
537
+ );
538
+ }
491
539
  return { ok: true, command: "build", projectRoot: project.root };
492
540
  }
493
541
 
494
542
  // src/commands/package.ts
495
- import { existsSync as existsSync3 } from "fs";
543
+ import { existsSync as existsSync4 } from "fs";
544
+ import { isAbsolute } from "path";
496
545
  import { packageLessonkitCourse } from "@lessonkit/lxpack";
497
546
  async function runPackage(opts) {
498
547
  let target;
@@ -510,7 +559,7 @@ async function runPackage(opts) {
510
559
  }
511
560
  const project = await loadProject(opts.cwd ?? process.cwd());
512
561
  const distDir = resolveDistDir(project);
513
- if (opts.noBuild && !existsSync3(distDir)) {
562
+ if (opts.noBuild && !existsSync4(distDir)) {
514
563
  throw new CliError(
515
564
  `dist directory not found at ${distDir}. Run lessonkit build before packaging with --no-build.`,
516
565
  {
@@ -523,7 +572,7 @@ async function runPackage(opts) {
523
572
  if (!opts.noBuild) {
524
573
  await runBuild({ cwd: project.root, json: opts.json });
525
574
  }
526
- if (!existsSync3(distDir)) {
575
+ if (!existsSync4(distDir)) {
527
576
  throw new CliError(`Build completed but dist directory not found at ${distDir}.`, {
528
577
  code: "INVALID_PROJECT",
529
578
  exitCode: EXIT_INVALID_PROJECT
@@ -535,14 +584,20 @@ async function runPackage(opts) {
535
584
  if (!opts.noBuild) {
536
585
  await runBuild({ cwd: project.root, json: opts.json });
537
586
  }
538
- if (!existsSync3(distDir)) {
587
+ if (!existsSync4(distDir)) {
539
588
  throw new CliError(`dist directory not found at ${distDir}. Run lessonkit build first.`, {
540
589
  code: "INVALID_PROJECT",
541
590
  exitCode: EXIT_INVALID_PROJECT
542
591
  });
543
592
  }
544
593
  const outDir = resolveLxpackOutDir(project);
545
- const { output, dir, outputBaseDir } = resolvePackageOutput(project, target, opts.out);
594
+ const { output: resolvedOutput, dir, outputBaseDir } = resolvePackageOutput(
595
+ project,
596
+ target,
597
+ opts.out
598
+ );
599
+ const trimmedOut = opts.out?.trim();
600
+ const output = trimmedOut && !isAbsolute(trimmedOut) ? trimmedOut : resolvedOutput;
546
601
  const result = await packageLessonkitCourse({
547
602
  descriptor: project.course,
548
603
  outDir,
@@ -551,7 +606,8 @@ async function runPackage(opts) {
551
606
  target,
552
607
  output,
553
608
  dir,
554
- outputBaseDir
609
+ outputBaseDir,
610
+ strictParity: opts.strictParity
555
611
  });
556
612
  if (!result.ok) {
557
613
  throw new CliError("Packaging failed.", {
@@ -654,7 +710,7 @@ function createProgram(baseLogger = console) {
654
710
  );
655
711
  }
656
712
  );
657
- 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) => {
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) => {
658
714
  const logger = createLogger({ json: opts.json });
659
715
  await handleCommand(
660
716
  async () => {
@@ -663,7 +719,8 @@ function createProgram(baseLogger = console) {
663
719
  cwd: opts.cwd,
664
720
  noBuild: opts.build === false,
665
721
  out: opts.out,
666
- json: opts.json
722
+ json: opts.json,
723
+ strictParity: opts.strictParity
667
724
  });
668
725
  if (!opts.json && result.ok && result.command === "package") {
669
726
  if (result.target === "react-vite") {
@@ -681,8 +738,10 @@ function createProgram(baseLogger = console) {
681
738
  Boolean(opts.json)
682
739
  );
683
740
  });
684
- program.command("publish").description("Publish package artifacts (stub)").action(() => {
685
- baseLogger.log("lessonkit publish is not implemented. See RELEASING.md for npm publish workflow.");
741
+ program.command("publish").description("[maintainers] Not implemented \u2014 use Changesets (see RELEASING.md)").action(() => {
742
+ baseLogger.log(
743
+ "lessonkit publish is not implemented. Monorepo releases use Changesets: npm run changeset && npm run version-packages && npm run release. See RELEASING.md."
744
+ );
686
745
  });
687
746
  return program;
688
747
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lessonkit/cli",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "private": false,
5
5
  "description": "LessonKit CLI — init, dev, build, and package learning experiences.",
6
6
  "license": "Apache-2.0",
@@ -42,11 +42,11 @@
42
42
  "typecheck": "tsc -p tsconfig.json",
43
43
  "test": "vitest run --passWithNoTests",
44
44
  "test:coverage": "vitest run --coverage --passWithNoTests=false",
45
- "lint": "echo \"(no lint configured yet)\""
45
+ "lint": "eslint --max-warnings 0 \"src/**/*.{ts,tsx}\" \"test/**/*.{ts,tsx}\""
46
46
  },
47
47
  "dependencies": {
48
- "@lessonkit/core": "1.4.0",
49
- "@lessonkit/lxpack": "1.4.0",
48
+ "@lessonkit/core": "1.5.0",
49
+ "@lessonkit/lxpack": "1.5.0",
50
50
  "commander": "^15.0.0"
51
51
  },
52
52
  "engines": {
@@ -55,7 +55,7 @@
55
55
  "devDependencies": {
56
56
  "@types/node": "^25.9.2",
57
57
  "tsup": "^8.5.0",
58
- "typescript": "^5.8.3",
58
+ "typescript": "^6.0.3",
59
59
  "vitest": "^4.1.8"
60
60
  }
61
61
  }
@@ -1,5 +1,8 @@
1
- # Copy to .env.production before `npm run build`.
2
- # Point both URLs at your backend proxies never embed raw LRS credentials in the bundle.
1
+ # Copy to `.env` for production LMS export (see courseConfig.ts).
2
+ # Never commit real secretsuse short-lived tokens from your backend.
3
3
 
4
- VITE_XAPI_PROXY_URL=
4
+ # Analytics batch ingest proxy (createFetchBatchSink)
5
5
  VITE_ANALYTICS_URL=
6
+
7
+ # xAPI statement proxy (createFetchTransport)
8
+ VITE_XAPI_PROXY_URL=
@@ -18,10 +18,35 @@ npm run package:scorm12
18
18
  - `.env.example` — `VITE_XAPI_PROXY_URL` and `VITE_ANALYTICS_URL` for production builds
19
19
  - `lessonkit.json` — manifest for CLI and LXPack packaging
20
20
 
21
+ ## Before LMS packaging
22
+
23
+ 1. **LMS bridge** — In `src/courseConfig.ts`, enable the bridge for SCORM/xAPI/cmi5 export (template defaults to `"off"` for local preview):
24
+
25
+ ```ts
26
+ lxpack: {
27
+ bridge: "auto",
28
+ allowedParentOrigins: ["https://your-lms.example"], // required in production builds
29
+ },
30
+ ```
31
+
32
+ Development builds allow `bridge: "auto"` without an allowlist; **production builds do not**. Discover your LMS origin from the SCORM preview URL or browser devtools (`document.referrer`).
33
+
34
+ 2. **Production runtime** — Copy `.env.example` to `.env`, set `VITE_ANALYTICS_URL` and `VITE_XAPI_PROXY_URL`, and wire the observability hooks in `courseConfig.ts` (see comments there). Rebuild with `npm run build` before packaging. Alternatively, disable `tracking` and `xapi` for a first test export only.
35
+
36
+ 3. **Activity IRI** — Replace the `example.com` placeholder in `lessonkit.json` → `course.tracking.xapi.activityIri` before xAPI/cmi5 export (must be HTTPS).
37
+
38
+ ## SCORM output path
39
+
40
+ After `npm run package:scorm12`, the CLI prints the resolved ZIP path. Default:
41
+
42
+ **`.lxpack/course/.lxpack/out/course-scorm12.zip`**
43
+
44
+ (`paths.outputBaseDir` is resolved inside `paths.lxpackOutDir`, not at the project root.)
45
+
21
46
  ## Production
22
47
 
23
- Copy `.env.example` to `.env.production` 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).
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).
24
49
 
25
50
  ## Docs
26
51
 
27
- [CLI reference](https://lessonkit.readthedocs.io/en/latest/reference/cli.html) · [React quickstart](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/quickstart.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) · [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)
@@ -16,23 +16,23 @@
16
16
  "test:coverage": "vitest run --coverage --passWithNoTests=false"
17
17
  },
18
18
  "dependencies": {
19
- "@lessonkit/core": "^1.4.0",
20
- "@lessonkit/react": "^1.4.0",
21
- "@lessonkit/themes": "^1.4.0",
22
- "@lessonkit/xapi": "^1.4.0",
19
+ "@lessonkit/core": "^1.5.0",
20
+ "@lessonkit/react": "^1.5.0",
21
+ "@lessonkit/themes": "^1.5.0",
22
+ "@lessonkit/xapi": "^1.5.0",
23
23
  "react": "^19.2.7",
24
24
  "react-dom": "^19.2.7"
25
25
  },
26
26
  "devDependencies": {
27
- "@lessonkit/cli": "^1.4.0",
28
- "@lessonkit/lxpack": "^1.4.0",
27
+ "@lessonkit/cli": "^1.5.0",
28
+ "@lessonkit/lxpack": "^1.5.0",
29
29
  "@testing-library/react": "^16.3.0",
30
30
  "@testing-library/dom": "^10.4.1",
31
31
  "@types/react": "^19.2.17",
32
32
  "@types/react-dom": "^19.2.3",
33
33
  "@vitejs/plugin-react": "^6.0.2",
34
34
  "jsdom": "^29.1.1",
35
- "typescript": "^5.8.3",
35
+ "typescript": "^6.0.3",
36
36
  "vite": "^8.0.11",
37
37
  "vitest": "^4.1.8"
38
38
  }
@@ -1,12 +1,12 @@
1
1
  import React from "react";
2
2
  import { Course, Lesson, Quiz, Scenario, ThemeProvider } from "@lessonkit/react";
3
- import { createCourseConfig } from "./courseConfig";
3
+ import { createCourseConfig, COURSE_THEME_PRESET } from "./courseConfig";
4
4
 
5
5
  const courseConfig = createCourseConfig();
6
6
 
7
7
  export default function App() {
8
8
  return (
9
- <ThemeProvider preset="default" mode="light">
9
+ <ThemeProvider preset={COURSE_THEME_PRESET} mode="light">
10
10
  <div className="app-shell">
11
11
  <Course title="{{courseTitle}}" courseId="my-course" config={courseConfig}>
12
12
  <Lesson title="My first lesson" lessonId="lesson-1">
@@ -44,6 +44,8 @@ describe("createCourseConfig", () => {
44
44
  courseId: "my-course",
45
45
  } as TelemetryEvent);
46
46
  config.observability?.onXapiTransportError?.(new Error("transport"));
47
+ config.observability?.onXapiMappingError?.(new Error("mapping"));
48
+ config.observability?.onLxpackBridgeError?.(new Error("bridge"));
47
49
 
48
50
  expect(warn).toHaveBeenCalled();
49
51
  warn.mockRestore();
@@ -31,7 +31,9 @@ function createObservability(): NonNullable<LessonkitConfig["observability"]> {
31
31
  },
32
32
  onXapiQueueCap: () => report("xapi-queue-cap", {}),
33
33
  onLxpackBridgeMiss: (event) => report("lxpack-bridge-miss", { event: event.name }),
34
+ onLxpackBridgeError: (err) => report("lxpack-bridge-error", { err }),
34
35
  onXapiTransportError: (err) => report("xapi-transport", { err }),
36
+ onXapiMappingError: (err) => report("xapi-mapping", { err }),
35
37
  };
36
38
  }
37
39
 
@@ -98,13 +100,21 @@ function productionXapi(xapiProxyUrl: string | undefined): LessonkitConfig["xapi
98
100
  };
99
101
  }
100
102
 
103
+ /** Theme preset — keep in sync with lessonkit.json `course.theme.preset`. */
104
+ export const COURSE_THEME_PRESET = "default" as const;
105
+
101
106
  export function createCourseConfig(): LessonkitConfig {
102
107
  const { xapiProxyUrl, analyticsUrl } = readProxyUrls();
103
108
  const useProductionTransports = import.meta.env.PROD || (xapiProxyUrl && analyticsUrl);
104
109
 
105
110
  const config: LessonkitConfig = {
106
111
  courseId: "my-course",
107
- lxpack: { bridge: "off" },
112
+ lxpack: {
113
+ // Set bridge: "auto" when packaging for LMS (SCORM/xAPI/cmi5). In production, allowedParentOrigins
114
+ // is required — see https://lessonkit.readthedocs.io/en/latest/guides/react-developers/production-checklist.html
115
+ bridge: "off",
116
+ // allowedParentOrigins: ["https://your-lms.example"],
117
+ },
108
118
  observability: createObservability(),
109
119
  tracking: useProductionTransports ? productionTracking(analyticsUrl) : devConsoleTracking(),
110
120
  xapi: useProductionTransports ? productionXapi(xapiProxyUrl) : devConsoleXapi(),
@@ -0,0 +1,3 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ declare module "*.css";