@lxpack/cli 0.1.0 → 0.2.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 ADDED
@@ -0,0 +1,127 @@
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 (**v0.2.0**).
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
+ | Lesson widgets | [`@lxpack/components`](../components/README.md) |
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ npm install -g @lxpack/cli
23
+ # or: pnpm add -g @lxpack/cli
24
+ ```
25
+
26
+ Requires Node.js 20+.
27
+
28
+ ## Quick start
29
+
30
+ ```bash
31
+ lxpack init my-course
32
+ cd my-course
33
+ lxpack preview # http://127.0.0.1:3847 by default
34
+ lxpack validate
35
+ lxpack build --target scorm12
36
+ lxpack build --target scorm2004
37
+ ```
38
+
39
+ Output lands in `.lxpack/` unless overridden by `-o` or `lxpack.config.json`.
40
+
41
+ ## Commands
42
+
43
+ | Command | Description |
44
+ |---------|-------------|
45
+ | `init <name>` | Scaffold a new course (`-d, --dir <path>`, `-f, --force`) |
46
+ | `preview` | Start local preview server (`-p, --port`, `-H, --host`) |
47
+ | `validate` | Validate `course.yaml` and referenced files |
48
+ | `build` | Package for LMS or standalone export |
49
+
50
+ ### `build` options
51
+
52
+ | Option | Description |
53
+ |--------|-------------|
54
+ | `-t, --target <target>` | `scorm12` (default), `scorm2004`, or `standalone` |
55
+ | `-o, --output <path>` | Output ZIP file or directory |
56
+ | `--dir` | Write an unpacked directory instead of a ZIP |
57
+
58
+ `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.
59
+
60
+ **SCORM 2004** builds produce a multi-SCO ZIP: one launch page per activity under `sco/<activityId>/index.html`, plus shared `lxpack-runtime.js` and `lxpack-components.js`.
61
+
62
+ **Preview** serves the runtime client, optional components bundle at `/runtime/components.js`, and installs SCORM API simulators (1.2 and 2004) for local testing.
63
+
64
+ ### Course discovery
65
+
66
+ Commands walk up from the current working directory until they find `course.yaml`. Run them from inside your course project (or a subdirectory).
67
+
68
+ ### Path safety
69
+
70
+ - `init --dir` must be a relative path that stays inside the current working directory.
71
+ - `lxpack.config.json` `output.dir` is resolved relative to the course root with the same containment rules.
72
+
73
+ ## Course layout
74
+
75
+ ```text
76
+ my-course/
77
+ course.yaml
78
+ lxpack.config.json # optional: defaultTarget, output
79
+ lessons/
80
+ interactions/
81
+ assessments/ # authoring only — omitted from export ZIPs
82
+ components/ # optional widget overrides
83
+ assets/
84
+ .lxpack/ # build output (generated)
85
+ ```
86
+
87
+ `init` scaffolds commented examples for `variables`, `flow`, and `type: component` lessons. See [branching-demo](https://github.com/eddiethedean/lxpack/tree/main/examples/branching-demo) for a full v0.2 course.
88
+
89
+ ### `lxpack.config.json`
90
+
91
+ ```json
92
+ {
93
+ "defaultTarget": "scorm12",
94
+ "output": {
95
+ "dir": ".lxpack"
96
+ }
97
+ }
98
+ ```
99
+
100
+ Use `"defaultTarget": "scorm2004"` when your LMS expects SCORM 2004 4th Edition packages.
101
+
102
+ See the [root README](https://github.com/eddiethedean/lxpack#course-structure) for a full `course.yaml` example.
103
+
104
+ ## Programmatic use
105
+
106
+ 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`, `@lxpack/runtime`, and `@lxpack/components` directly.
107
+
108
+ ## Development
109
+
110
+ From the monorepo root:
111
+
112
+ ```bash
113
+ pnpm --filter @lxpack/cli build
114
+ pnpm --filter @lxpack/cli test
115
+ pnpm --filter @lxpack/cli typecheck
116
+ ```
117
+
118
+ ## Links
119
+
120
+ - [LXPack repository](https://github.com/eddiethedean/lxpack)
121
+ - [Documentation index](https://github.com/eddiethedean/lxpack/blob/main/docs/README.md)
122
+ - [Roadmap & phases](https://github.com/eddiethedean/lxpack/blob/main/docs/ROADMAP.md)
123
+ - [Changelog](https://github.com/eddiethedean/lxpack/blob/main/CHANGELOG.md)
124
+
125
+ ## License
126
+
127
+ Apache-2.0
package/dist/cli.d.ts CHANGED
@@ -3,11 +3,11 @@ import { z } from 'zod';
3
3
 
4
4
  declare const lxpackConfigSchema: z.ZodObject<{
5
5
  exports: z.ZodOptional<z.ZodObject<{
6
- defaultTarget: z.ZodOptional<z.ZodEnum<["scorm12", "standalone"]>>;
6
+ defaultTarget: z.ZodOptional<z.ZodEnum<["scorm12", "scorm2004", "standalone"]>>;
7
7
  }, "strip", z.ZodTypeAny, {
8
- defaultTarget?: "scorm12" | "standalone" | undefined;
8
+ defaultTarget?: "scorm12" | "scorm2004" | "standalone" | undefined;
9
9
  }, {
10
- defaultTarget?: "scorm12" | "standalone" | undefined;
10
+ defaultTarget?: "scorm12" | "scorm2004" | "standalone" | undefined;
11
11
  }>>;
12
12
  output: z.ZodOptional<z.ZodObject<{
13
13
  dir: z.ZodOptional<z.ZodString>;
@@ -18,14 +18,14 @@ declare const lxpackConfigSchema: z.ZodObject<{
18
18
  }>>;
19
19
  }, "strict", z.ZodTypeAny, {
20
20
  exports?: {
21
- defaultTarget?: "scorm12" | "standalone" | undefined;
21
+ defaultTarget?: "scorm12" | "scorm2004" | "standalone" | undefined;
22
22
  } | undefined;
23
23
  output?: {
24
24
  dir?: string | undefined;
25
25
  } | undefined;
26
26
  }, {
27
27
  exports?: {
28
- defaultTarget?: "scorm12" | "standalone" | undefined;
28
+ defaultTarget?: "scorm12" | "scorm2004" | "standalone" | undefined;
29
29
  } | undefined;
30
30
  output?: {
31
31
  dir?: string | undefined;
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
  }
@@ -69,9 +66,17 @@ async function readRuntimeBundle(assetsDir = getRuntimeAssetsDir()) {
69
66
  }
70
67
  return { clientJs, css };
71
68
  }
69
+ async function readComponentsBundle() {
70
+ try {
71
+ const bundlePath = require2.resolve("@lxpack/components/bundle");
72
+ return await readFile(bundlePath, "utf-8");
73
+ } catch {
74
+ return void 0;
75
+ }
76
+ }
72
77
  var lxpackConfigSchema = z.object({
73
78
  exports: z.object({
74
- defaultTarget: z.enum(["scorm12", "standalone"]).optional()
79
+ defaultTarget: z.enum(["scorm12", "scorm2004", "standalone"]).optional()
75
80
  }).optional(),
76
81
  output: z.object({
77
82
  dir: z.string().optional()
@@ -112,6 +117,27 @@ function getCliVersion() {
112
117
  const pkg = require2("../package.json");
113
118
  return pkg.version;
114
119
  }
120
+ function resolvePathInCwd(relativePath) {
121
+ const cwd = resolve(process.cwd());
122
+ if (relativePath.startsWith("/") || /^[a-zA-Z]:\\/.test(relativePath)) {
123
+ throw new Error(
124
+ "Use a relative path for the output directory (must stay inside the current working directory)"
125
+ );
126
+ }
127
+ const target = resolve(cwd, relativePath);
128
+ if (!isPathContained(cwd, target)) {
129
+ throw new Error("Path must be inside the current working directory");
130
+ }
131
+ return target;
132
+ }
133
+ function resolveOutputDir(courseDir, outputDir) {
134
+ const root = resolve(courseDir);
135
+ const target = resolve(root, outputDir);
136
+ if (!isPathContained(root, target)) {
137
+ throw new Error("output.dir in lxpack.config.json must stay inside the course directory");
138
+ }
139
+ return target;
140
+ }
115
141
 
116
142
  // src/commands/init.ts
117
143
  var COURSE_YAML_TEMPLATE = `title: {{title}}
@@ -139,6 +165,17 @@ lessons:
139
165
  assessments:
140
166
  - id: final_quiz
141
167
  file: assessments/final.yaml
168
+
169
+ # Optional Phase 2 features (see examples/branching-demo):
170
+ # variables:
171
+ # path:
172
+ # default: intro
173
+ # type: string
174
+ # flow:
175
+ # - when:
176
+ # variable:
177
+ # eq: [path, advanced]
178
+ # goto: component_lesson
142
179
  `;
143
180
  var WELCOME_MD = `# Welcome
144
181
 
@@ -234,7 +271,7 @@ function formatTitle(projectName) {
234
271
  return projectName.split(/[-_\s]+/).filter(Boolean).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
235
272
  }
236
273
  async function initCommand(projectName, options = {}) {
237
- const targetDir = options.dir ?? projectName;
274
+ const targetDir = resolvePathInCwd(options.dir ?? projectName);
238
275
  const title = formatTitle(projectName);
239
276
  const yamlTitle = formatCourseTitleForYaml(title);
240
277
  if (existsSync2(join2(targetDir, "course.yaml")) && !options.force) {
@@ -251,7 +288,8 @@ async function initCommand(projectName, options = {}) {
251
288
  join2(targetDir, "interactions", "phishing-lab"),
252
289
  join2(targetDir, "assets"),
253
290
  join2(targetDir, "assessments"),
254
- join2(targetDir, "theme")
291
+ join2(targetDir, "theme"),
292
+ join2(targetDir, "components")
255
293
  ];
256
294
  for (const dir of dirs) {
257
295
  await mkdir(dir, { recursive: true });
@@ -268,6 +306,7 @@ async function initCommand(projectName, options = {}) {
268
306
  await writeFile(join2(targetDir, "assessments", "final.yaml"), FINAL_ASSESSMENT);
269
307
  await writeFile(join2(targetDir, "lxpack.config.json"), LXPACK_CONFIG);
270
308
  await writeFile(join2(targetDir, "theme", ".gitkeep"), "");
309
+ await writeFile(join2(targetDir, "components", ".gitkeep"), "");
271
310
  console.log(pc.green(`\u2713 Created LXPack course: ${targetDir}`));
272
311
  console.log();
273
312
  console.log("Next steps:");
@@ -281,11 +320,25 @@ async function initCommand(projectName, options = {}) {
281
320
  import Fastify from "fastify";
282
321
  import fastifyStatic from "@fastify/static";
283
322
  import pc2 from "picocolors";
284
- import { validateCourse } from "@lxpack/validators";
323
+ import { validateCourse, buildRuntimeAssessmentBundle } from "@lxpack/validators";
324
+ import { safeJsonForHtml } from "@lxpack/scorm";
285
325
  async function loadPreviewStyles(assetsDir = getRuntimeAssetsDir()) {
286
326
  return loadRuntimeStyles(assetsDir);
287
327
  }
288
- async function createPreviewServer(courseDir, manifest) {
328
+ function buildPreviewConfig(manifest, assessmentBundle) {
329
+ return safeJsonForHtml({
330
+ manifest,
331
+ baseUrl: "/course",
332
+ mode: "preview",
333
+ ...assessmentBundle ? {
334
+ assessments: assessmentBundle.assessments,
335
+ answerKeys: assessmentBundle.answerKeys,
336
+ assessmentConfigs: assessmentBundle.configs,
337
+ assessmentFeedback: assessmentBundle.feedback
338
+ } : {}
339
+ });
340
+ }
341
+ async function createPreviewServer(courseDir, manifest, assessmentBundle) {
289
342
  const runtimeDir = getRuntimeAssetsDir();
290
343
  const app = Fastify({ logger: false });
291
344
  await app.register(fastifyStatic, {
@@ -299,12 +352,14 @@ async function createPreviewServer(courseDir, manifest) {
299
352
  decorateReply: false
300
353
  });
301
354
  const stylesCss = await loadPreviewStyles(runtimeDir);
302
- app.get("/", async (_req, reply) => {
303
- const config = JSON.stringify({
304
- manifest,
305
- baseUrl: "/course",
306
- mode: "preview"
355
+ const config = buildPreviewConfig(manifest, assessmentBundle);
356
+ const componentsJs = await readComponentsBundle();
357
+ if (componentsJs) {
358
+ app.get("/runtime/components.js", async (_req, reply) => {
359
+ return reply.type("application/javascript").send(componentsJs);
307
360
  });
361
+ }
362
+ app.get("/", async (_req, reply) => {
308
363
  const html = `<!DOCTYPE html>
309
364
  <html lang="en">
310
365
  <head>
@@ -315,9 +370,11 @@ async function createPreviewServer(courseDir, manifest) {
315
370
  </head>
316
371
  <body>
317
372
  <div id="lxpack-app"></div>
373
+ <script type="application/json" id="lxpack-config">${config}</script>
318
374
  <script>
319
- window.__LXPACK_CONFIG__ = ${config};
375
+ window.__LXPACK_CONFIG__ = JSON.parse(document.getElementById('lxpack-config').textContent);
320
376
  </script>
377
+ ${componentsJs ? '<script type="module" src="/runtime/components.js"></script>' : ""}
321
378
  <script type="module" src="/runtime/client.js"></script>
322
379
  </body>
323
380
  </html>`;
@@ -336,13 +393,21 @@ async function startPreview(courseDir, _options = {}) {
336
393
  process.exit(1);
337
394
  }
338
395
  if (!validation.valid) {
339
- console.log(pc2.yellow("Warning: course has validation issues:"));
396
+ console.error(pc2.red("Cannot preview: course validation failed"));
340
397
  for (const issue of validation.issues) {
341
- console.log(` ${issue.path}: ${issue.message}`);
398
+ console.error(` ${issue.path}: ${issue.message}`);
342
399
  }
343
- console.log();
400
+ process.exit(1);
344
401
  }
345
- const app = await createPreviewServer(courseDir, validation.manifest);
402
+ const assessmentBundle = await buildRuntimeAssessmentBundle(
403
+ courseDir,
404
+ validation.manifest
405
+ );
406
+ const app = await createPreviewServer(
407
+ courseDir,
408
+ validation.manifest,
409
+ assessmentBundle
410
+ );
346
411
  return { app, validation };
347
412
  }
348
413
  function resolvePreviewDeps(deps) {
@@ -415,10 +480,13 @@ async function validateCommand() {
415
480
  // src/commands/build.ts
416
481
  import { mkdir as mkdir2 } from "fs/promises";
417
482
  import { join as join3 } from "path";
418
- import { packageCourse, packageStandaloneDir } from "@lxpack/scorm";
419
- import { validateCourse as validateCourse3 } from "@lxpack/validators";
483
+ import { packageCourse, packageStandaloneDir, courseSlug } from "@lxpack/scorm";
484
+ import {
485
+ validateCourse as validateCourse3,
486
+ buildRuntimeAssessmentBundle as buildRuntimeAssessmentBundle2
487
+ } from "@lxpack/validators";
420
488
  import pc4 from "picocolors";
421
- var VALID_TARGETS = ["scorm12", "standalone"];
489
+ var VALID_TARGETS = ["scorm12", "scorm2004", "standalone"];
422
490
  async function buildCommand(options) {
423
491
  const courseDir = findCourseDir();
424
492
  const config = await loadLxpackConfig(courseDir);
@@ -432,41 +500,50 @@ async function buildCommand(options) {
432
500
  process.exit(1);
433
501
  }
434
502
  const validation = await validateCourse3(courseDir);
435
- if (!validation.valid) {
503
+ if (!validation.valid || !validation.manifest) {
436
504
  console.error(pc4.red("Cannot build: course validation failed"));
437
505
  for (const issue of validation.issues) {
438
506
  console.error(` ${issue.path}: ${issue.message}`);
439
507
  }
440
508
  process.exit(1);
441
509
  }
442
- const manifest = await loadCourseManifest(courseDir);
443
- const { clientJs, css } = await readRuntimeBundle();
444
- const slug = manifest.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "course";
510
+ const manifest = validation.manifest;
511
+ const assessmentBundle = await buildRuntimeAssessmentBundle2(
512
+ courseDir,
513
+ manifest
514
+ );
515
+ const [{ clientJs, css }, componentsBundleJs] = await Promise.all([
516
+ readRuntimeBundle(),
517
+ readComponentsBundle()
518
+ ]);
519
+ const slug = courseSlug(manifest);
445
520
  const outputBase = config?.output?.dir ?? ".lxpack";
446
- await mkdir2(join3(courseDir, outputBase), { recursive: true });
521
+ const outputRoot = resolveOutputDir(courseDir, outputBase);
522
+ await mkdir2(outputRoot, { recursive: true });
523
+ const packageOptions = {
524
+ courseDir,
525
+ manifest,
526
+ target,
527
+ runtimeClientJs: clientJs,
528
+ runtimeCss: css,
529
+ componentsBundleJs,
530
+ assessmentBundle
531
+ };
447
532
  if (options.dir) {
448
- const outputDir = options.output ?? join3(courseDir, outputBase, target);
533
+ const outputDir = options.output ?? join3(outputRoot, target);
449
534
  const result = await packageStandaloneDir({
450
- courseDir,
451
- manifest,
452
- outputDir,
453
- target,
454
- runtimeClientJs: clientJs,
455
- runtimeCss: css
535
+ ...packageOptions,
536
+ outputDir
456
537
  });
457
538
  console.log(pc4.green(`\u2713 Built ${target} package`));
458
539
  console.log(` Output: ${result.outputDir}`);
459
540
  console.log(` Files: ${result.fileCount}`);
460
541
  } else {
461
- const defaultName = target === "standalone" ? `${slug}-standalone.zip` : `${slug}-scorm12.zip`;
462
- const outputPath = options.output ?? join3(courseDir, outputBase, defaultName);
542
+ const defaultName = target === "standalone" ? `${slug}-standalone.zip` : target === "scorm2004" ? `${slug}-scorm2004.zip` : `${slug}-scorm12.zip`;
543
+ const outputPath = options.output ?? join3(outputRoot, defaultName);
463
544
  const result = await packageCourse({
464
- courseDir,
465
- manifest,
466
- outputPath,
467
- target,
468
- runtimeClientJs: clientJs,
469
- runtimeCss: css
545
+ ...packageOptions,
546
+ outputPath
470
547
  });
471
548
  console.log(pc4.green(`\u2713 Built ${target} package`));
472
549
  console.log(` Output: ${result.outputPath}`);
@@ -494,7 +571,7 @@ function createCliProgram() {
494
571
  });
495
572
  program.command("build").description("Build LMS-compatible package").option(
496
573
  "-t, --target <target>",
497
- "Export target: scorm12, standalone",
574
+ "Export target: scorm12, scorm2004, standalone",
498
575
  void 0
499
576
  ).option("-o, --output <path>", "Output file or directory path").option("--dir", "Output as directory instead of ZIP").action(
500
577
  async (options) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lxpack/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "CLI for building, validating, and packaging LXPack courses",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -39,9 +39,10 @@
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.2.0",
43
+ "@lxpack/components": "0.2.0",
44
+ "@lxpack/scorm": "0.2.0",
45
+ "@lxpack/validators": "0.2.0"
45
46
  },
46
47
  "devDependencies": {
47
48
  "@types/node": "^22.13.10",