@lessonkit/cli 1.3.1 → 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,14 +173,37 @@ async function applyTemplateSubstitutions(projectDir, projectName, slug) {
177
173
  const course = lessonkit.course;
178
174
  course.courseId = slug;
179
175
  course.title = projectName;
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;
180
181
  await writeFile(lessonkitPath, `${JSON.stringify(lessonkit, null, 2)}
181
182
  `, "utf8");
183
+ const courseConfigPath = join(projectDir, "src", "courseConfig.ts");
184
+ let courseConfigSource = await readFile(courseConfigPath, "utf8");
185
+ courseConfigSource = courseConfigSource.replace(/courseId: "my-course"/g, `courseId: "${slug}"`);
186
+ await writeFile(courseConfigPath, courseConfigSource, "utf8");
182
187
  const appPath = join(projectDir, "src", "App.tsx");
183
188
  let appSource = await readFile(appPath, "utf8");
184
189
  appSource = appSource.replace(/courseId="my-course"/g, `courseId="${slug}"`);
185
190
  appSource = appSource.replace(/\{\{courseTitle\}\}/g, escapeJsxString(projectName));
186
191
  await writeFile(appPath, appSource, "utf8");
187
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
+ }
188
207
  async function runInit(opts, logger) {
189
208
  const cwd = process.cwd();
190
209
  const rawName = opts.name ?? (opts.here ? slugifyId(basename(process.cwd()) || "my-course") : void 0);
@@ -212,7 +231,7 @@ async function runInit(opts, logger) {
212
231
  }
213
232
  );
214
233
  }
215
- if (opts.here && !await isDirEmpty(projectDir) && !opts.force) {
234
+ if (opts.here && !await isDirEmptyOrDotfilesOnly(projectDir) && !opts.force) {
216
235
  throw new CliError(`Directory is not empty: ${projectDir}. Use --force to initialize anyway.`, {
217
236
  code: "INVALID_PROJECT",
218
237
  exitCode: EXIT_INVALID_PROJECT
@@ -234,11 +253,26 @@ async function runInit(opts, logger) {
234
253
  exitCode: EXIT_INVALID_PROJECT
235
254
  });
236
255
  }
237
- await copyTemplate(templateDir, projectDir);
238
- await applyTemplateSubstitutions(projectDir, projectName, slug);
239
- if (!opts.skipInstall) {
240
- if (!opts.json) logger.log(`Installing dependencies in ${projectDir}\u2026`);
241
- 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;
242
276
  }
243
277
  if (!opts.json) {
244
278
  logger.log(`Created LessonKit project at ${projectDir}`);
@@ -247,6 +281,10 @@ async function runInit(opts, logger) {
247
281
  return { ok: true, command: "init", projectRoot: projectDir };
248
282
  }
249
283
 
284
+ // src/commands/dev.ts
285
+ import { existsSync as existsSync3 } from "fs";
286
+ import { join as join3 } from "path";
287
+
250
288
  // src/lib/project.ts
251
289
  import { readFileSync, existsSync as existsSync2 } from "fs";
252
290
  import { readFile as readFile2 } from "fs/promises";
@@ -442,13 +480,24 @@ function resolvePackageOutput(project, target, override) {
442
480
  }
443
481
  return { output: `${outputBaseDir}/course-${target}.zip`, dir: false, outputBaseDir };
444
482
  }
445
- var DEFAULT_SPA_DIST_DIR = "dist";
446
- function resolveViteBuildArgs(project) {
447
- const args = ["build"];
448
- if (project.paths.spaDistDir !== DEFAULT_SPA_DIST_DIR) {
449
- 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);
450
495
  }
451
- return args;
496
+ return stripped;
497
+ }
498
+ function resolveViteBuildArgv(project, viteArgs = []) {
499
+ const passthrough = stripOutDirFromViteArgs(viteArgs);
500
+ return ["build", ...passthrough, "--outDir", project.paths.spaDistDir];
452
501
  }
453
502
  function parsePackageTarget(value) {
454
503
  if (!value) {
@@ -477,15 +526,24 @@ async function runBuild(opts) {
477
526
  const pkg = await readPackageJson(project.root);
478
527
  assertViteProject(pkg, project.root);
479
528
  const viteJs = resolveViteJs(project.root);
480
- const buildArgs = resolveViteBuildArgs(project);
481
- await runCommand(process.execPath, [viteJs, ...buildArgs, ...opts.viteArgs ?? []], {
529
+ const buildArgs = resolveViteBuildArgv(project, opts.viteArgs);
530
+ await runCommand(process.execPath, [viteJs, ...buildArgs], {
482
531
  cwd: project.root
483
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
+ }
484
541
  return { ok: true, command: "build", projectRoot: project.root };
485
542
  }
486
543
 
487
544
  // src/commands/package.ts
488
- import { existsSync as existsSync3 } from "fs";
545
+ import { existsSync as existsSync4 } from "fs";
546
+ import { isAbsolute } from "path";
489
547
  import { packageLessonkitCourse } from "@lessonkit/lxpack";
490
548
  async function runPackage(opts) {
491
549
  let target;
@@ -503,7 +561,7 @@ async function runPackage(opts) {
503
561
  }
504
562
  const project = await loadProject(opts.cwd ?? process.cwd());
505
563
  const distDir = resolveDistDir(project);
506
- if (opts.noBuild && !existsSync3(distDir)) {
564
+ if (opts.noBuild && !existsSync4(distDir)) {
507
565
  throw new CliError(
508
566
  `dist directory not found at ${distDir}. Run lessonkit build before packaging with --no-build.`,
509
567
  {
@@ -516,7 +574,7 @@ async function runPackage(opts) {
516
574
  if (!opts.noBuild) {
517
575
  await runBuild({ cwd: project.root, json: opts.json });
518
576
  }
519
- if (!existsSync3(distDir)) {
577
+ if (!existsSync4(distDir)) {
520
578
  throw new CliError(`Build completed but dist directory not found at ${distDir}.`, {
521
579
  code: "INVALID_PROJECT",
522
580
  exitCode: EXIT_INVALID_PROJECT
@@ -528,14 +586,20 @@ async function runPackage(opts) {
528
586
  if (!opts.noBuild) {
529
587
  await runBuild({ cwd: project.root, json: opts.json });
530
588
  }
531
- if (!existsSync3(distDir)) {
589
+ if (!existsSync4(distDir)) {
532
590
  throw new CliError(`dist directory not found at ${distDir}. Run lessonkit build first.`, {
533
591
  code: "INVALID_PROJECT",
534
592
  exitCode: EXIT_INVALID_PROJECT
535
593
  });
536
594
  }
537
595
  const outDir = resolveLxpackOutDir(project);
538
- 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;
539
603
  const result = await packageLessonkitCourse({
540
604
  descriptor: project.course,
541
605
  outDir,
@@ -544,7 +608,8 @@ async function runPackage(opts) {
544
608
  target,
545
609
  output,
546
610
  dir,
547
- outputBaseDir
611
+ outputBaseDir,
612
+ strictParity: opts.strictParity
548
613
  });
549
614
  if (!result.ok) {
550
615
  throw new CliError("Packaging failed.", {
@@ -647,7 +712,7 @@ function createProgram(baseLogger = console) {
647
712
  );
648
713
  }
649
714
  );
650
- 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) => {
651
716
  const logger = createLogger({ json: opts.json });
652
717
  await handleCommand(
653
718
  async () => {
@@ -656,7 +721,8 @@ function createProgram(baseLogger = console) {
656
721
  cwd: opts.cwd,
657
722
  noBuild: opts.build === false,
658
723
  out: opts.out,
659
- json: opts.json
724
+ json: opts.json,
725
+ strictParity: opts.strictParity
660
726
  });
661
727
  if (!opts.json && result.ok && result.command === "package") {
662
728
  if (result.target === "react-vite") {
@@ -674,8 +740,10 @@ function createProgram(baseLogger = console) {
674
740
  Boolean(opts.json)
675
741
  );
676
742
  });
677
- program.command("publish").description("Publish package artifacts (stub)").action(() => {
678
- 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
+ );
679
747
  });
680
748
  return program;
681
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,14 +171,37 @@ async function applyTemplateSubstitutions(projectDir, projectName, slug) {
175
171
  const course = lessonkit.course;
176
172
  course.courseId = slug;
177
173
  course.title = projectName;
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;
178
179
  await writeFile(lessonkitPath, `${JSON.stringify(lessonkit, null, 2)}
179
180
  `, "utf8");
181
+ const courseConfigPath = join(projectDir, "src", "courseConfig.ts");
182
+ let courseConfigSource = await readFile(courseConfigPath, "utf8");
183
+ courseConfigSource = courseConfigSource.replace(/courseId: "my-course"/g, `courseId: "${slug}"`);
184
+ await writeFile(courseConfigPath, courseConfigSource, "utf8");
180
185
  const appPath = join(projectDir, "src", "App.tsx");
181
186
  let appSource = await readFile(appPath, "utf8");
182
187
  appSource = appSource.replace(/courseId="my-course"/g, `courseId="${slug}"`);
183
188
  appSource = appSource.replace(/\{\{courseTitle\}\}/g, escapeJsxString(projectName));
184
189
  await writeFile(appPath, appSource, "utf8");
185
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
+ }
186
205
  async function runInit(opts, logger) {
187
206
  const cwd = process.cwd();
188
207
  const rawName = opts.name ?? (opts.here ? slugifyId(basename(process.cwd()) || "my-course") : void 0);
@@ -210,7 +229,7 @@ async function runInit(opts, logger) {
210
229
  }
211
230
  );
212
231
  }
213
- if (opts.here && !await isDirEmpty(projectDir) && !opts.force) {
232
+ if (opts.here && !await isDirEmptyOrDotfilesOnly(projectDir) && !opts.force) {
214
233
  throw new CliError(`Directory is not empty: ${projectDir}. Use --force to initialize anyway.`, {
215
234
  code: "INVALID_PROJECT",
216
235
  exitCode: EXIT_INVALID_PROJECT
@@ -232,11 +251,26 @@ async function runInit(opts, logger) {
232
251
  exitCode: EXIT_INVALID_PROJECT
233
252
  });
234
253
  }
235
- await copyTemplate(templateDir, projectDir);
236
- await applyTemplateSubstitutions(projectDir, projectName, slug);
237
- if (!opts.skipInstall) {
238
- if (!opts.json) logger.log(`Installing dependencies in ${projectDir}\u2026`);
239
- 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;
240
274
  }
241
275
  if (!opts.json) {
242
276
  logger.log(`Created LessonKit project at ${projectDir}`);
@@ -245,6 +279,10 @@ async function runInit(opts, logger) {
245
279
  return { ok: true, command: "init", projectRoot: projectDir };
246
280
  }
247
281
 
282
+ // src/commands/dev.ts
283
+ import { existsSync as existsSync3 } from "fs";
284
+ import { join as join3 } from "path";
285
+
248
286
  // src/lib/project.ts
249
287
  import { readFileSync, existsSync as existsSync2 } from "fs";
250
288
  import { readFile as readFile2 } from "fs/promises";
@@ -440,13 +478,24 @@ function resolvePackageOutput(project, target, override) {
440
478
  }
441
479
  return { output: `${outputBaseDir}/course-${target}.zip`, dir: false, outputBaseDir };
442
480
  }
443
- var DEFAULT_SPA_DIST_DIR = "dist";
444
- function resolveViteBuildArgs(project) {
445
- const args = ["build"];
446
- if (project.paths.spaDistDir !== DEFAULT_SPA_DIST_DIR) {
447
- 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);
448
493
  }
449
- return args;
494
+ return stripped;
495
+ }
496
+ function resolveViteBuildArgv(project, viteArgs = []) {
497
+ const passthrough = stripOutDirFromViteArgs(viteArgs);
498
+ return ["build", ...passthrough, "--outDir", project.paths.spaDistDir];
450
499
  }
451
500
  function parsePackageTarget(value) {
452
501
  if (!value) {
@@ -475,15 +524,24 @@ async function runBuild(opts) {
475
524
  const pkg = await readPackageJson(project.root);
476
525
  assertViteProject(pkg, project.root);
477
526
  const viteJs = resolveViteJs(project.root);
478
- const buildArgs = resolveViteBuildArgs(project);
479
- await runCommand(process.execPath, [viteJs, ...buildArgs, ...opts.viteArgs ?? []], {
527
+ const buildArgs = resolveViteBuildArgv(project, opts.viteArgs);
528
+ await runCommand(process.execPath, [viteJs, ...buildArgs], {
480
529
  cwd: project.root
481
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
+ }
482
539
  return { ok: true, command: "build", projectRoot: project.root };
483
540
  }
484
541
 
485
542
  // src/commands/package.ts
486
- import { existsSync as existsSync3 } from "fs";
543
+ import { existsSync as existsSync4 } from "fs";
544
+ import { isAbsolute } from "path";
487
545
  import { packageLessonkitCourse } from "@lessonkit/lxpack";
488
546
  async function runPackage(opts) {
489
547
  let target;
@@ -501,7 +559,7 @@ async function runPackage(opts) {
501
559
  }
502
560
  const project = await loadProject(opts.cwd ?? process.cwd());
503
561
  const distDir = resolveDistDir(project);
504
- if (opts.noBuild && !existsSync3(distDir)) {
562
+ if (opts.noBuild && !existsSync4(distDir)) {
505
563
  throw new CliError(
506
564
  `dist directory not found at ${distDir}. Run lessonkit build before packaging with --no-build.`,
507
565
  {
@@ -514,7 +572,7 @@ async function runPackage(opts) {
514
572
  if (!opts.noBuild) {
515
573
  await runBuild({ cwd: project.root, json: opts.json });
516
574
  }
517
- if (!existsSync3(distDir)) {
575
+ if (!existsSync4(distDir)) {
518
576
  throw new CliError(`Build completed but dist directory not found at ${distDir}.`, {
519
577
  code: "INVALID_PROJECT",
520
578
  exitCode: EXIT_INVALID_PROJECT
@@ -526,14 +584,20 @@ async function runPackage(opts) {
526
584
  if (!opts.noBuild) {
527
585
  await runBuild({ cwd: project.root, json: opts.json });
528
586
  }
529
- if (!existsSync3(distDir)) {
587
+ if (!existsSync4(distDir)) {
530
588
  throw new CliError(`dist directory not found at ${distDir}. Run lessonkit build first.`, {
531
589
  code: "INVALID_PROJECT",
532
590
  exitCode: EXIT_INVALID_PROJECT
533
591
  });
534
592
  }
535
593
  const outDir = resolveLxpackOutDir(project);
536
- 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;
537
601
  const result = await packageLessonkitCourse({
538
602
  descriptor: project.course,
539
603
  outDir,
@@ -542,7 +606,8 @@ async function runPackage(opts) {
542
606
  target,
543
607
  output,
544
608
  dir,
545
- outputBaseDir
609
+ outputBaseDir,
610
+ strictParity: opts.strictParity
546
611
  });
547
612
  if (!result.ok) {
548
613
  throw new CliError("Packaging failed.", {
@@ -645,7 +710,7 @@ function createProgram(baseLogger = console) {
645
710
  );
646
711
  }
647
712
  );
648
- 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) => {
649
714
  const logger = createLogger({ json: opts.json });
650
715
  await handleCommand(
651
716
  async () => {
@@ -654,7 +719,8 @@ function createProgram(baseLogger = console) {
654
719
  cwd: opts.cwd,
655
720
  noBuild: opts.build === false,
656
721
  out: opts.out,
657
- json: opts.json
722
+ json: opts.json,
723
+ strictParity: opts.strictParity
658
724
  });
659
725
  if (!opts.json && result.ok && result.command === "package") {
660
726
  if (result.target === "react-vite") {
@@ -672,8 +738,10 @@ function createProgram(baseLogger = console) {
672
738
  Boolean(opts.json)
673
739
  );
674
740
  });
675
- program.command("publish").description("Publish package artifacts (stub)").action(() => {
676
- 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
+ );
677
745
  });
678
746
  return program;
679
747
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lessonkit/cli",
3
- "version": "1.3.1",
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",
@@ -25,7 +25,10 @@
25
25
  "lessonkit": "./dist/bin.js"
26
26
  },
27
27
  "exports": {
28
- ".": "./dist/index.js"
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "import": "./dist/index.js"
31
+ }
29
32
  },
30
33
  "files": [
31
34
  "dist",
@@ -39,20 +42,20 @@
39
42
  "typecheck": "tsc -p tsconfig.json",
40
43
  "test": "vitest run --passWithNoTests",
41
44
  "test:coverage": "vitest run --coverage --passWithNoTests=false",
42
- "lint": "echo \"(no lint configured yet)\""
45
+ "lint": "eslint --max-warnings 0 \"src/**/*.{ts,tsx}\" \"test/**/*.{ts,tsx}\""
43
46
  },
44
47
  "dependencies": {
45
- "@lessonkit/core": "1.3.1",
46
- "@lessonkit/lxpack": "1.3.1",
47
- "commander": "^14.0.1"
48
+ "@lessonkit/core": "1.5.0",
49
+ "@lessonkit/lxpack": "1.5.0",
50
+ "commander": "^15.0.0"
48
51
  },
49
52
  "engines": {
50
53
  "node": ">=18"
51
54
  },
52
55
  "devDependencies": {
53
- "@types/node": "^24.0.0",
56
+ "@types/node": "^25.9.2",
54
57
  "tsup": "^8.5.0",
55
- "typescript": "^5.8.3",
58
+ "typescript": "^6.0.3",
56
59
  "vitest": "^4.1.8"
57
60
  }
58
61
  }
@@ -0,0 +1,8 @@
1
+ # Copy to `.env` for production LMS export (see courseConfig.ts).
2
+ # Never commit real secrets — use short-lived tokens from your backend.
3
+
4
+ # Analytics batch ingest proxy (createFetchBatchSink)
5
+ VITE_ANALYTICS_URL=
6
+
7
+ # xAPI statement proxy (createFetchTransport)
8
+ VITE_XAPI_PROXY_URL=
@@ -14,8 +14,39 @@ npm run package:scorm12
14
14
  ## Files
15
15
 
16
16
  - `src/App.tsx` — course UI (IDs match `lessonkit.json`)
17
+ - `src/courseConfig.ts` — production transports, observability hooks, and LMS bridge config
18
+ - `.env.example` — `VITE_XAPI_PROXY_URL` and `VITE_ANALYTICS_URL` for production builds
17
19
  - `lessonkit.json` — manifest for CLI and LXPack packaging
18
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
+
46
+ ## Production
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).
49
+
19
50
  ## Docs
20
51
 
21
- [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)
@@ -7,29 +7,33 @@
7
7
  "build": "lessonkit build",
8
8
  "preview": "vite preview",
9
9
  "package:scorm12": "lessonkit package --target scorm12",
10
+ "package:scorm2004": "lessonkit package --target scorm2004",
10
11
  "package:standalone": "lessonkit package --target standalone",
12
+ "package:xapi": "lessonkit package --target xapi",
13
+ "package:cmi5": "lessonkit package --target cmi5",
11
14
  "typecheck": "tsc -p tsconfig.json",
12
15
  "test": "vitest run --passWithNoTests",
13
16
  "test:coverage": "vitest run --coverage --passWithNoTests=false"
14
17
  },
15
18
  "dependencies": {
16
- "@lessonkit/core": "^1.3.1",
17
- "@lessonkit/react": "^1.3.1",
18
- "@lessonkit/themes": "^1.3.1",
19
- "@lessonkit/xapi": "^1.3.1",
20
- "react": "^18.3.1",
21
- "react-dom": "^18.3.1"
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
+ "react": "^19.2.7",
24
+ "react-dom": "^19.2.7"
22
25
  },
23
26
  "devDependencies": {
24
- "@lessonkit/cli": "^1.3.1",
25
- "@lessonkit/lxpack": "^1.3.1",
27
+ "@lessonkit/cli": "^1.5.0",
28
+ "@lessonkit/lxpack": "^1.5.0",
26
29
  "@testing-library/react": "^16.3.0",
27
- "@types/react": "^18.3.23",
28
- "@types/react-dom": "^18.3.7",
29
- "@vitejs/plugin-react": "^4.6.0",
30
- "jsdom": "^26.1.0",
31
- "typescript": "^5.8.3",
32
- "vite": "^7.1.3",
30
+ "@testing-library/dom": "^10.4.1",
31
+ "@types/react": "^19.2.17",
32
+ "@types/react-dom": "^19.2.3",
33
+ "@vitejs/plugin-react": "^6.0.2",
34
+ "jsdom": "^29.1.1",
35
+ "typescript": "^6.0.3",
36
+ "vite": "^8.0.11",
33
37
  "vitest": "^4.1.8"
34
38
  }
35
39
  }
@@ -1,14 +1,14 @@
1
1
  import React from "react";
2
2
  import { describe, expect, it, vi } from "vitest";
3
- import { render } from "@testing-library/react";
3
+ import { render, screen } from "@testing-library/react";
4
4
  import App from "./App";
5
5
 
6
6
  describe("template App", () => {
7
- it("renders without crashing", () => {
7
+ it("renders the starter quiz question", () => {
8
8
  const spy = vi.spyOn(console, "log").mockImplementation(() => {});
9
9
  render(<App />);
10
+ expect(screen.getByText("Ready to build?")).toBeDefined();
10
11
  spy.mockRestore();
11
- expect(true).toBe(true);
12
12
  });
13
13
  });
14
14
 
@@ -1,24 +1,12 @@
1
1
  import React from "react";
2
2
  import { Course, Lesson, Quiz, Scenario, ThemeProvider } from "@lessonkit/react";
3
- import type { TelemetryEvent } from "@lessonkit/core";
4
- import type { XAPIStatement } from "@lessonkit/xapi";
3
+ import { createCourseConfig, COURSE_THEME_PRESET } from "./courseConfig";
5
4
 
6
- const courseConfig = {
7
- tracking: {
8
- sink: (event: TelemetryEvent) => {
9
- console.log("[telemetry]", event);
10
- },
11
- },
12
- xapi: {
13
- transport: (statement: XAPIStatement) => {
14
- console.log("[xapi]", statement);
15
- },
16
- },
17
- } as const;
5
+ const courseConfig = createCourseConfig();
18
6
 
19
7
  export default function App() {
20
8
  return (
21
- <ThemeProvider preset="default" mode="light">
9
+ <ThemeProvider preset={COURSE_THEME_PRESET} mode="light">
22
10
  <div className="app-shell">
23
11
  <Course title="{{courseTitle}}" courseId="my-course" config={courseConfig}>
24
12
  <Lesson title="My first lesson" lessonId="lesson-1">
@@ -0,0 +1,93 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import type { TelemetryEvent } from "@lessonkit/core";
3
+ import { createCourseConfig } from "./courseConfig";
4
+
5
+ describe("createCourseConfig", () => {
6
+ const originalFetch = globalThis.fetch;
7
+
8
+ afterEach(() => {
9
+ vi.unstubAllEnvs();
10
+ globalThis.fetch = originalFetch;
11
+ });
12
+
13
+ it("returns dev console sinks when proxy URLs are unset", () => {
14
+ const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
15
+ const config = createCourseConfig();
16
+
17
+ expect(config.courseId).toBe("my-course");
18
+ expect(config.lxpack?.bridge).toBe("off");
19
+ expect(config.observability?.onTelemetrySinkError).toBeTypeOf("function");
20
+ expect(config.observability?.onLxpackBridgeMiss).toBeTypeOf("function");
21
+
22
+ config.tracking?.sink?.({
23
+ name: "interaction",
24
+ timestamp: "2026-01-01T00:00:00Z",
25
+ courseId: "my-course",
26
+ } as TelemetryEvent);
27
+ expect(log).toHaveBeenCalledWith("[telemetry]", expect.any(Object));
28
+
29
+ log.mockRestore();
30
+ });
31
+
32
+ it("invokes observability hooks without throwing", () => {
33
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
34
+ const config = createCourseConfig();
35
+
36
+ config.observability?.onTelemetrySinkError?.(new Error("sink"), { sinkId: "tracking" });
37
+ config.observability?.onTelemetryBufferDrop?.();
38
+ config.observability?.onXapiQueueDepth?.(10);
39
+ config.observability?.onXapiQueueDepth?.(60);
40
+ config.observability?.onXapiQueueCap?.();
41
+ config.observability?.onLxpackBridgeMiss?.({
42
+ name: "course_completed",
43
+ timestamp: "2026-01-01T00:00:00Z",
44
+ courseId: "my-course",
45
+ } as TelemetryEvent);
46
+ config.observability?.onXapiTransportError?.(new Error("transport"));
47
+ config.observability?.onXapiMappingError?.(new Error("mapping"));
48
+ config.observability?.onLxpackBridgeError?.(new Error("bridge"));
49
+
50
+ expect(warn).toHaveBeenCalled();
51
+ warn.mockRestore();
52
+ });
53
+
54
+ it("uses fetch transports when proxy URLs are set", async () => {
55
+ vi.stubEnv("VITE_XAPI_PROXY_URL", "https://lrs.example/statements");
56
+ vi.stubEnv("VITE_ANALYTICS_URL", "https://analytics.example/events");
57
+ const fetchMock = vi.fn(() => Promise.resolve(new Response(null, { status: 204 })));
58
+ globalThis.fetch = fetchMock as typeof fetch;
59
+
60
+ const config = createCourseConfig();
61
+
62
+ expect(config.tracking?.batchSink).toBeTypeOf("function");
63
+ expect(config.xapi?.transport).toBeTypeOf("function");
64
+
65
+ await config.tracking?.batchSink?.([
66
+ { name: "course_started", timestamp: "t", courseId: "my-course" } as TelemetryEvent,
67
+ ]);
68
+ await config.xapi?.transport?.({
69
+ id: "s1",
70
+ timestamp: "2026-01-01T00:00:00Z",
71
+ verb: "http://adlnet.gov/expapi/verbs/completed",
72
+ object: { id: "https://example.com/a" },
73
+ });
74
+
75
+ expect(fetchMock).toHaveBeenCalled();
76
+ });
77
+
78
+ it("skips production guard when MODE is test even with proxy URLs set", () => {
79
+ vi.stubEnv("NODE_ENV", "production");
80
+ vi.stubEnv("MODE", "test");
81
+ vi.stubEnv("VITE_XAPI_PROXY_URL", "https://lrs.example/statements");
82
+ vi.stubEnv("VITE_ANALYTICS_URL", "https://analytics.example/events");
83
+
84
+ expect(() => createCourseConfig()).not.toThrow();
85
+ });
86
+
87
+ it("throws when production guard is enforced with console sinks", () => {
88
+ vi.stubEnv("NODE_ENV", "production");
89
+ vi.stubEnv("MODE", "production");
90
+
91
+ expect(() => createCourseConfig()).toThrow(/console telemetry sinks|observability hooks/);
92
+ });
93
+ });
@@ -0,0 +1,127 @@
1
+ import type { TelemetryEvent } from "@lessonkit/core";
2
+ import type { LessonkitConfig } from "@lessonkit/react";
3
+ import { assertProductionCourseConfig, shouldEnforceProductionGuard } from "@lessonkit/react";
4
+ import { createFetchBatchSink, createFetchTransport } from "@lessonkit/xapi";
5
+ import type { XAPIStatement } from "@lessonkit/xapi";
6
+
7
+ /**
8
+ * Replace with your backend token proxy. Never ship static LRS passwords in the bundle.
9
+ * Example: fetch("/api/lrs-token") and return { Authorization: `Bearer ${token}` }.
10
+ */
11
+ function lrsAuthHeaders(): Record<string, string> {
12
+ return {};
13
+ }
14
+
15
+ function createObservability(): NonNullable<LessonkitConfig["observability"]> {
16
+ const report = (channel: string, detail: unknown) => {
17
+ if (import.meta.env.DEV) {
18
+ console.warn(`[lessonkit:${channel}]`, detail);
19
+ return;
20
+ }
21
+ // Wire to your monitoring stack (Sentry, Datadog, etc.).
22
+ /* v8 ignore next -- production-only; swap for your APM in go-live builds */
23
+ console.error(`[lessonkit:${channel}]`, detail);
24
+ };
25
+
26
+ return {
27
+ onTelemetrySinkError: (err, ctx) => report("telemetry-sink", { err, ...ctx }),
28
+ onTelemetryBufferDrop: () => report("telemetry-buffer-cap", {}),
29
+ onXapiQueueDepth: (depth) => {
30
+ if (depth > 50) report("xapi-queue-depth", { depth });
31
+ },
32
+ onXapiQueueCap: () => report("xapi-queue-cap", {}),
33
+ onLxpackBridgeMiss: (event) => report("lxpack-bridge-miss", { event: event.name }),
34
+ onLxpackBridgeError: (err) => report("lxpack-bridge-error", { err }),
35
+ onXapiTransportError: (err) => report("xapi-transport", { err }),
36
+ onXapiMappingError: (err) => report("xapi-mapping", { err }),
37
+ };
38
+ }
39
+
40
+ function readProxyUrls(): { xapiProxyUrl?: string; analyticsUrl?: string } {
41
+ return {
42
+ xapiProxyUrl: import.meta.env.VITE_XAPI_PROXY_URL as string | undefined,
43
+ analyticsUrl: import.meta.env.VITE_ANALYTICS_URL as string | undefined,
44
+ };
45
+ }
46
+
47
+ function devConsoleTracking(): LessonkitConfig["tracking"] {
48
+ return {
49
+ sink: (event: TelemetryEvent) => {
50
+ console.log("[telemetry]", event);
51
+ },
52
+ };
53
+ }
54
+
55
+ function devConsoleXapi(): LessonkitConfig["xapi"] {
56
+ return {
57
+ enabled: true,
58
+ transport: (statement: XAPIStatement) => {
59
+ console.log("[xapi]", statement);
60
+ return Promise.resolve();
61
+ },
62
+ };
63
+ }
64
+
65
+ function productionTracking(analyticsUrl: string | undefined): LessonkitConfig["tracking"] {
66
+ if (!analyticsUrl) {
67
+ /* v8 ignore next */
68
+ throw new Error(
69
+ "VITE_ANALYTICS_URL is required in production. Point it at your analytics ingest proxy.",
70
+ );
71
+ }
72
+ const { batchSink, exitBatchSink } = createFetchBatchSink({
73
+ url: analyticsUrl,
74
+ headers: lrsAuthHeaders,
75
+ });
76
+ return {
77
+ enabled: true,
78
+ batchSink,
79
+ exitBatchSink,
80
+ batch: { enabled: true },
81
+ };
82
+ }
83
+
84
+ function productionXapi(xapiProxyUrl: string | undefined): LessonkitConfig["xapi"] {
85
+ if (!xapiProxyUrl) {
86
+ /* v8 ignore next */
87
+ throw new Error(
88
+ "VITE_XAPI_PROXY_URL is required in production. Point it at your LRS proxy (never the raw LRS with embedded secrets).",
89
+ );
90
+ }
91
+ const { transport, exitTransport, abortInFlight } = createFetchTransport({
92
+ url: xapiProxyUrl,
93
+ headers: lrsAuthHeaders,
94
+ });
95
+ return {
96
+ enabled: true,
97
+ transport,
98
+ exitTransport,
99
+ abortInFlight,
100
+ };
101
+ }
102
+
103
+ /** Theme preset — keep in sync with lessonkit.json `course.theme.preset`. */
104
+ export const COURSE_THEME_PRESET = "default" as const;
105
+
106
+ export function createCourseConfig(): LessonkitConfig {
107
+ const { xapiProxyUrl, analyticsUrl } = readProxyUrls();
108
+ const useProductionTransports = import.meta.env.PROD || (xapiProxyUrl && analyticsUrl);
109
+
110
+ const config: LessonkitConfig = {
111
+ courseId: "my-course",
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
+ },
118
+ observability: createObservability(),
119
+ tracking: useProductionTransports ? productionTracking(analyticsUrl) : devConsoleTracking(),
120
+ xapi: useProductionTransports ? productionXapi(xapiProxyUrl) : devConsoleXapi(),
121
+ };
122
+
123
+ if (shouldEnforceProductionGuard()) {
124
+ assertProductionCourseConfig(config);
125
+ }
126
+ return config;
127
+ }
@@ -0,0 +1,3 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ declare module "*.css";
@@ -8,7 +8,7 @@ export default defineConfig({
8
8
  provider: "v8",
9
9
  include: ["src/**/*.{ts,tsx}"],
10
10
  exclude: ["dist/**", "node_modules/**", "**/*.d.ts", "**/*.d.cts"],
11
- thresholds: { statements: 85, branches: 85, functions: 85, lines: 85 },
11
+ thresholds: { statements: 85, branches: 80, functions: 85, lines: 85 },
12
12
  },
13
13
  },
14
14
  });