@lxpack/cli 0.1.0 → 0.1.1

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.
Files changed (3) hide show
  1. package/README.md +114 -0
  2. package/dist/cli.js +84 -41
  3. package/package.json +4 -4
package/README.md ADDED
@@ -0,0 +1,114 @@
1
+ # @lxpack/cli
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@lxpack/cli)](https://www.npmjs.com/package/@lxpack/cli)
4
+ [![CI](https://github.com/eddiethedean/lxpack/actions/workflows/ci.yml/badge.svg)](https://github.com/eddiethedean/lxpack/actions/workflows/ci.yml)
5
+ [![License](https://img.shields.io/github/license/eddiethedean/lxpack)](https://github.com/eddiethedean/lxpack/blob/main/LICENSE)
6
+ [![Node.js](https://img.shields.io/badge/node-%3E%3D20-brightgreen)](https://nodejs.org/)
7
+
8
+ Command-line tool for scaffolding, previewing, validating, and packaging LXPack courses.
9
+
10
+ Part of [LXPack](https://github.com/eddiethedean/lxpack) — an AI-native learning experience compiler and runtime.
11
+
12
+ | Related | Package |
13
+ |---------|---------|
14
+ | Validation | [`@lxpack/validators`](../validators/README.md) |
15
+ | Browser runtime | [`@lxpack/runtime`](../runtime/README.md) |
16
+ | Export / ZIP | [`@lxpack/scorm`](../scorm/README.md) |
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ npm install -g @lxpack/cli
22
+ # or: pnpm add -g @lxpack/cli
23
+ ```
24
+
25
+ Requires Node.js 20+.
26
+
27
+ ## Quick start
28
+
29
+ ```bash
30
+ lxpack init my-course
31
+ cd my-course
32
+ lxpack preview # http://127.0.0.1:3847 by default
33
+ lxpack validate
34
+ lxpack build --target scorm12
35
+ ```
36
+
37
+ Output lands in `.lxpack/` unless overridden by `-o` or `lxpack.config.json`.
38
+
39
+ ## Commands
40
+
41
+ | Command | Description |
42
+ |---------|-------------|
43
+ | `init <name>` | Scaffold a new course (`-d, --dir <path>`, `-f, --force`) |
44
+ | `preview` | Start local preview server (`-p, --port`, `-H, --host`) |
45
+ | `validate` | Validate `course.yaml` and referenced files |
46
+ | `build` | Package for LMS or standalone export |
47
+
48
+ ### `build` options
49
+
50
+ | Option | Description |
51
+ |--------|-------------|
52
+ | `-t, --target <target>` | `scorm12` (default) or `standalone` |
53
+ | `-o, --output <path>` | Output ZIP file or directory |
54
+ | `--dir` | Write an unpacked directory instead of a ZIP |
55
+
56
+ `build` and `preview` use the same validation rules: errors fail the command (exit code 1). `build` reuses the validated manifest and bakes a sanitized [assessment bundle](../validators/README.md#assessment-packaging) into the exported HTML config.
57
+
58
+ ### Course discovery
59
+
60
+ Commands walk up from the current working directory until they find `course.yaml`. Run them from inside your course project (or a subdirectory).
61
+
62
+ ### Path safety
63
+
64
+ - `init --dir` must be a relative path that stays inside the current working directory.
65
+ - `lxpack.config.json` `output.dir` is resolved relative to the course root with the same containment rules.
66
+
67
+ ## Course layout
68
+
69
+ ```text
70
+ my-course/
71
+ course.yaml
72
+ lxpack.config.json # optional: defaultTarget, output
73
+ lessons/
74
+ interactions/
75
+ assessments/ # authoring only — omitted from export ZIPs
76
+ assets/
77
+ .lxpack/ # build output (generated)
78
+ ```
79
+
80
+ ### `lxpack.config.json`
81
+
82
+ ```json
83
+ {
84
+ "defaultTarget": "scorm12",
85
+ "output": {
86
+ "dir": ".lxpack"
87
+ }
88
+ }
89
+ ```
90
+
91
+ See the [root README](https://github.com/eddiethedean/lxpack#course-structure) for a full `course.yaml` example.
92
+
93
+ ## Programmatic use
94
+
95
+ The CLI is built with [Commander](https://github.com/tj/commander.js). For library integration, import from the built package or depend on `@lxpack/validators`, `@lxpack/scorm`, and `@lxpack/runtime` directly.
96
+
97
+ ## Development
98
+
99
+ From the monorepo root:
100
+
101
+ ```bash
102
+ pnpm --filter @lxpack/cli build
103
+ pnpm --filter @lxpack/cli test
104
+ pnpm --filter @lxpack/cli typecheck
105
+ ```
106
+
107
+ ## Links
108
+
109
+ - [LXPack repository](https://github.com/eddiethedean/lxpack)
110
+ - [Changelog](https://github.com/eddiethedean/lxpack/blob/main/CHANGELOG.md)
111
+
112
+ ## License
113
+
114
+ Apache-2.0
package/dist/cli.js CHANGED
@@ -17,7 +17,11 @@ import { createRequire } from "module";
17
17
  import { dirname, join, resolve } from "path";
18
18
  import { stringify as stringifyYaml } from "yaml";
19
19
  import { z } from "zod";
20
- import { loadManifest, formatErrorMessage } from "@lxpack/validators";
20
+ import {
21
+ loadManifest,
22
+ formatErrorMessage,
23
+ isPathContained
24
+ } from "@lxpack/validators";
21
25
  var require2 = createRequire(import.meta.url);
22
26
  function findCourseDir(startDir = process.cwd()) {
23
27
  let dir = resolve(startDir);
@@ -31,13 +35,6 @@ function findCourseDir(startDir = process.cwd()) {
31
35
  "No course.yaml found. Run from a course directory or use lxpack init."
32
36
  );
33
37
  }
34
- async function loadCourseManifest(courseDir) {
35
- const loaded = await loadManifest(courseDir);
36
- if (Array.isArray(loaded)) {
37
- throw new Error(loaded.map((i) => i.message).join("; "));
38
- }
39
- return loaded.manifest;
40
- }
41
38
  function getRuntimeAssetsDir() {
42
39
  return dirname(require2.resolve("@lxpack/runtime/client"));
43
40
  }
@@ -112,6 +109,27 @@ function getCliVersion() {
112
109
  const pkg = require2("../package.json");
113
110
  return pkg.version;
114
111
  }
112
+ function resolvePathInCwd(relativePath) {
113
+ const cwd = resolve(process.cwd());
114
+ if (relativePath.startsWith("/") || /^[a-zA-Z]:\\/.test(relativePath)) {
115
+ throw new Error(
116
+ "Use a relative path for the output directory (must stay inside the current working directory)"
117
+ );
118
+ }
119
+ const target = resolve(cwd, relativePath);
120
+ if (!isPathContained(cwd, target)) {
121
+ throw new Error("Path must be inside the current working directory");
122
+ }
123
+ return target;
124
+ }
125
+ function resolveOutputDir(courseDir, outputDir) {
126
+ const root = resolve(courseDir);
127
+ const target = resolve(root, outputDir);
128
+ if (!isPathContained(root, target)) {
129
+ throw new Error("output.dir in lxpack.config.json must stay inside the course directory");
130
+ }
131
+ return target;
132
+ }
115
133
 
116
134
  // src/commands/init.ts
117
135
  var COURSE_YAML_TEMPLATE = `title: {{title}}
@@ -234,7 +252,7 @@ function formatTitle(projectName) {
234
252
  return projectName.split(/[-_\s]+/).filter(Boolean).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
235
253
  }
236
254
  async function initCommand(projectName, options = {}) {
237
- const targetDir = options.dir ?? projectName;
255
+ const targetDir = resolvePathInCwd(options.dir ?? projectName);
238
256
  const title = formatTitle(projectName);
239
257
  const yamlTitle = formatCourseTitleForYaml(title);
240
258
  if (existsSync2(join2(targetDir, "course.yaml")) && !options.force) {
@@ -281,11 +299,23 @@ async function initCommand(projectName, options = {}) {
281
299
  import Fastify from "fastify";
282
300
  import fastifyStatic from "@fastify/static";
283
301
  import pc2 from "picocolors";
284
- import { validateCourse } from "@lxpack/validators";
302
+ import { validateCourse, buildRuntimeAssessmentBundle } from "@lxpack/validators";
303
+ import { safeJsonForHtml } from "@lxpack/scorm";
285
304
  async function loadPreviewStyles(assetsDir = getRuntimeAssetsDir()) {
286
305
  return loadRuntimeStyles(assetsDir);
287
306
  }
288
- async function createPreviewServer(courseDir, manifest) {
307
+ function buildPreviewConfig(manifest, assessmentBundle) {
308
+ return safeJsonForHtml({
309
+ manifest,
310
+ baseUrl: "/course",
311
+ mode: "preview",
312
+ ...assessmentBundle ? {
313
+ assessments: assessmentBundle.assessments,
314
+ answerKeys: assessmentBundle.answerKeys
315
+ } : {}
316
+ });
317
+ }
318
+ async function createPreviewServer(courseDir, manifest, assessmentBundle) {
289
319
  const runtimeDir = getRuntimeAssetsDir();
290
320
  const app = Fastify({ logger: false });
291
321
  await app.register(fastifyStatic, {
@@ -299,12 +329,8 @@ async function createPreviewServer(courseDir, manifest) {
299
329
  decorateReply: false
300
330
  });
301
331
  const stylesCss = await loadPreviewStyles(runtimeDir);
332
+ const config = buildPreviewConfig(manifest, assessmentBundle);
302
333
  app.get("/", async (_req, reply) => {
303
- const config = JSON.stringify({
304
- manifest,
305
- baseUrl: "/course",
306
- mode: "preview"
307
- });
308
334
  const html = `<!DOCTYPE html>
309
335
  <html lang="en">
310
336
  <head>
@@ -315,8 +341,9 @@ async function createPreviewServer(courseDir, manifest) {
315
341
  </head>
316
342
  <body>
317
343
  <div id="lxpack-app"></div>
344
+ <script type="application/json" id="lxpack-config">${config}</script>
318
345
  <script>
319
- window.__LXPACK_CONFIG__ = ${config};
346
+ window.__LXPACK_CONFIG__ = JSON.parse(document.getElementById('lxpack-config').textContent);
320
347
  </script>
321
348
  <script type="module" src="/runtime/client.js"></script>
322
349
  </body>
@@ -336,13 +363,21 @@ async function startPreview(courseDir, _options = {}) {
336
363
  process.exit(1);
337
364
  }
338
365
  if (!validation.valid) {
339
- console.log(pc2.yellow("Warning: course has validation issues:"));
366
+ console.error(pc2.red("Cannot preview: course validation failed"));
340
367
  for (const issue of validation.issues) {
341
- console.log(` ${issue.path}: ${issue.message}`);
368
+ console.error(` ${issue.path}: ${issue.message}`);
342
369
  }
343
- console.log();
370
+ process.exit(1);
344
371
  }
345
- const app = await createPreviewServer(courseDir, validation.manifest);
372
+ const assessmentBundle = await buildRuntimeAssessmentBundle(
373
+ courseDir,
374
+ validation.manifest
375
+ );
376
+ const app = await createPreviewServer(
377
+ courseDir,
378
+ validation.manifest,
379
+ assessmentBundle
380
+ );
346
381
  return { app, validation };
347
382
  }
348
383
  function resolvePreviewDeps(deps) {
@@ -415,8 +450,11 @@ async function validateCommand() {
415
450
  // src/commands/build.ts
416
451
  import { mkdir as mkdir2 } from "fs/promises";
417
452
  import { join as join3 } from "path";
418
- import { packageCourse, packageStandaloneDir } from "@lxpack/scorm";
419
- import { validateCourse as validateCourse3 } from "@lxpack/validators";
453
+ import { packageCourse, packageStandaloneDir, courseSlug } from "@lxpack/scorm";
454
+ import {
455
+ validateCourse as validateCourse3,
456
+ buildRuntimeAssessmentBundle as buildRuntimeAssessmentBundle2
457
+ } from "@lxpack/validators";
420
458
  import pc4 from "picocolors";
421
459
  var VALID_TARGETS = ["scorm12", "standalone"];
422
460
  async function buildCommand(options) {
@@ -432,41 +470,46 @@ async function buildCommand(options) {
432
470
  process.exit(1);
433
471
  }
434
472
  const validation = await validateCourse3(courseDir);
435
- if (!validation.valid) {
473
+ if (!validation.valid || !validation.manifest) {
436
474
  console.error(pc4.red("Cannot build: course validation failed"));
437
475
  for (const issue of validation.issues) {
438
476
  console.error(` ${issue.path}: ${issue.message}`);
439
477
  }
440
478
  process.exit(1);
441
479
  }
442
- const manifest = await loadCourseManifest(courseDir);
480
+ const manifest = validation.manifest;
481
+ const assessmentBundle = await buildRuntimeAssessmentBundle2(
482
+ courseDir,
483
+ manifest
484
+ );
443
485
  const { clientJs, css } = await readRuntimeBundle();
444
- const slug = manifest.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "course";
486
+ const slug = courseSlug(manifest);
445
487
  const outputBase = config?.output?.dir ?? ".lxpack";
446
- await mkdir2(join3(courseDir, outputBase), { recursive: true });
488
+ const outputRoot = resolveOutputDir(courseDir, outputBase);
489
+ await mkdir2(outputRoot, { recursive: true });
490
+ const packageOptions = {
491
+ courseDir,
492
+ manifest,
493
+ target,
494
+ runtimeClientJs: clientJs,
495
+ runtimeCss: css,
496
+ assessmentBundle
497
+ };
447
498
  if (options.dir) {
448
- const outputDir = options.output ?? join3(courseDir, outputBase, target);
499
+ const outputDir = options.output ?? join3(outputRoot, target);
449
500
  const result = await packageStandaloneDir({
450
- courseDir,
451
- manifest,
452
- outputDir,
453
- target,
454
- runtimeClientJs: clientJs,
455
- runtimeCss: css
501
+ ...packageOptions,
502
+ outputDir
456
503
  });
457
504
  console.log(pc4.green(`\u2713 Built ${target} package`));
458
505
  console.log(` Output: ${result.outputDir}`);
459
506
  console.log(` Files: ${result.fileCount}`);
460
507
  } else {
461
508
  const defaultName = target === "standalone" ? `${slug}-standalone.zip` : `${slug}-scorm12.zip`;
462
- const outputPath = options.output ?? join3(courseDir, outputBase, defaultName);
509
+ const outputPath = options.output ?? join3(outputRoot, defaultName);
463
510
  const result = await packageCourse({
464
- courseDir,
465
- manifest,
466
- outputPath,
467
- target,
468
- runtimeClientJs: clientJs,
469
- runtimeCss: css
511
+ ...packageOptions,
512
+ outputPath
470
513
  });
471
514
  console.log(pc4.green(`\u2713 Built ${target} package`));
472
515
  console.log(` Output: ${result.outputPath}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lxpack/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "CLI for building, validating, and packaging LXPack courses",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -39,9 +39,9 @@
39
39
  "picocolors": "^1.1.1",
40
40
  "yaml": "^2.7.0",
41
41
  "zod": "^3.24.2",
42
- "@lxpack/runtime": "0.1.0",
43
- "@lxpack/scorm": "0.1.0",
44
- "@lxpack/validators": "0.1.0"
42
+ "@lxpack/runtime": "0.1.1",
43
+ "@lxpack/scorm": "0.1.1",
44
+ "@lxpack/validators": "0.1.1"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@types/node": "^22.13.10",