@lxpack/cli 0.2.0 → 0.2.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 +2 -2
  2. package/dist/cli.js +158 -109
  3. package/package.json +5 -5
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  Command-line tool for scaffolding, previewing, validating, and packaging LXPack courses.
9
9
 
10
- Part of [LXPack](https://github.com/eddiethedean/lxpack) — an AI-native learning experience compiler and runtime (**v0.2.0**).
10
+ Part of [LXPack](https://github.com/eddiethedean/lxpack) — an AI-native learning experience compiler and runtime (**v0.2.1**).
11
11
 
12
12
  | Related | Package |
13
13
  |---------|---------|
@@ -59,7 +59,7 @@ Output lands in `.lxpack/` unless overridden by `-o` or `lxpack.config.json`.
59
59
 
60
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
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.
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. Direct HTTP access to `assessments/*.yaml` under `/course/` returns 404; quiz content is embedded in the preview page config only.
63
63
 
64
64
  ### Course discovery
65
65
 
package/dist/cli.js CHANGED
@@ -5,24 +5,19 @@ import { Command } from "commander";
5
5
  import pc5 from "picocolors";
6
6
 
7
7
  // src/commands/init.ts
8
- import { existsSync as existsSync2 } from "fs";
8
+ import { existsSync as existsSync4 } from "fs";
9
9
  import { mkdir, writeFile } from "fs/promises";
10
- import { join as join2 } from "path";
10
+ import { join as join4 } from "path";
11
11
  import pc from "picocolors";
12
12
 
13
13
  // src/utils.ts
14
+ import { stringify as stringifyYaml } from "yaml";
15
+ import { createRequire as createRequire2 } from "module";
16
+
17
+ // src/lib/course-discovery.ts
14
18
  import { existsSync } from "fs";
15
- import { readFile } from "fs/promises";
16
- import { createRequire } from "module";
17
19
  import { dirname, join, resolve } from "path";
18
- import { stringify as stringifyYaml } from "yaml";
19
- import { z } from "zod";
20
- import {
21
- loadManifest,
22
- formatErrorMessage,
23
- isPathContained
24
- } from "@lxpack/validators";
25
- var require2 = createRequire(import.meta.url);
20
+ import { loadManifest } from "@lxpack/validators";
26
21
  function findCourseDir(startDir = process.cwd()) {
27
22
  let dir = resolve(startDir);
28
23
  while (dir !== dirname(dir)) {
@@ -35,22 +30,29 @@ function findCourseDir(startDir = process.cwd()) {
35
30
  "No course.yaml found. Run from a course directory or use lxpack init."
36
31
  );
37
32
  }
33
+
34
+ // src/lib/bundle-io.ts
35
+ import { existsSync as existsSync2 } from "fs";
36
+ import { readFile } from "fs/promises";
37
+ import { createRequire } from "module";
38
+ import { dirname as dirname2, join as join2 } from "path";
39
+ var require2 = createRequire(import.meta.url);
38
40
  function getRuntimeAssetsDir() {
39
- return dirname(require2.resolve("@lxpack/runtime/client"));
41
+ return dirname2(require2.resolve("@lxpack/runtime/client"));
40
42
  }
41
43
  function getEmbeddedStyles() {
42
44
  return `:root { --lxpack-bg: #0f1419; } body { margin: 0; }`;
43
45
  }
44
46
  async function loadRuntimeStyles(assetsDir) {
45
47
  try {
46
- return await readFile(join(assetsDir, "styles.css"), "utf-8");
48
+ return await readFile(join2(assetsDir, "styles.css"), "utf-8");
47
49
  } catch {
48
50
  return getEmbeddedStyles();
49
51
  }
50
52
  }
51
53
  async function readRuntimeBundle(assetsDir = getRuntimeAssetsDir()) {
52
- const clientPath = join(assetsDir, "client.js");
53
- if (!existsSync(clientPath)) {
54
+ const clientPath = join2(assetsDir, "client.js");
55
+ if (!existsSync2(clientPath)) {
54
56
  throw new Error(
55
57
  "Runtime bundle not found. Run `pnpm build` from the LXPack repo root, or reinstall @lxpack/runtime."
56
58
  );
@@ -74,6 +76,13 @@ async function readComponentsBundle() {
74
76
  return void 0;
75
77
  }
76
78
  }
79
+
80
+ // src/lib/lxpack-config.ts
81
+ import { existsSync as existsSync3 } from "fs";
82
+ import { readFile as readFile2 } from "fs/promises";
83
+ import { join as join3, resolve as resolve2 } from "path";
84
+ import { z } from "zod";
85
+ import { formatErrorMessage, isPathContained } from "@lxpack/validators";
77
86
  var lxpackConfigSchema = z.object({
78
87
  exports: z.object({
79
88
  defaultTarget: z.enum(["scorm12", "scorm2004", "standalone"]).optional()
@@ -83,10 +92,10 @@ var lxpackConfigSchema = z.object({
83
92
  }).optional()
84
93
  }).strict();
85
94
  async function loadLxpackConfig(courseDir) {
86
- const configPath = join(courseDir, "lxpack.config.json");
87
- if (!existsSync(configPath)) return null;
95
+ const configPath = join3(courseDir, "lxpack.config.json");
96
+ if (!existsSync3(configPath)) return null;
88
97
  try {
89
- const content = await readFile(configPath, "utf-8");
98
+ const content = await readFile2(configPath, "utf-8");
90
99
  const raw = JSON.parse(content);
91
100
  const parsed = lxpackConfigSchema.safeParse(raw);
92
101
  if (!parsed.success) {
@@ -101,44 +110,47 @@ async function loadLxpackConfig(courseDir) {
101
110
  );
102
111
  }
103
112
  }
104
- function escapeHtml(text) {
105
- return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
106
- }
107
- function formatCourseTitleForYaml(title) {
108
- const doc = { title, version: "1.0.0" };
109
- const yaml = stringifyYaml(doc);
110
- const titleLine = yaml.split("\n").find((l) => l.startsWith("title:"));
111
- if (!titleLine) {
112
- return JSON.stringify(title);
113
- }
114
- return titleLine.replace(/^title:\s*/, "");
115
- }
116
- function getCliVersion() {
117
- const pkg = require2("../package.json");
118
- return pkg.version;
119
- }
120
113
  function resolvePathInCwd(relativePath) {
121
- const cwd = resolve(process.cwd());
114
+ const cwd = resolve2(process.cwd());
122
115
  if (relativePath.startsWith("/") || /^[a-zA-Z]:\\/.test(relativePath)) {
123
116
  throw new Error(
124
117
  "Use a relative path for the output directory (must stay inside the current working directory)"
125
118
  );
126
119
  }
127
- const target = resolve(cwd, relativePath);
120
+ const target = resolve2(cwd, relativePath);
128
121
  if (!isPathContained(cwd, target)) {
129
122
  throw new Error("Path must be inside the current working directory");
130
123
  }
131
124
  return target;
132
125
  }
133
126
  function resolveOutputDir(courseDir, outputDir) {
134
- const root = resolve(courseDir);
135
- const target = resolve(root, outputDir);
127
+ const root = resolve2(courseDir);
128
+ const target = resolve2(root, outputDir);
136
129
  if (!isPathContained(root, target)) {
137
130
  throw new Error("output.dir in lxpack.config.json must stay inside the course directory");
138
131
  }
139
132
  return target;
140
133
  }
141
134
 
135
+ // src/lib/html.ts
136
+ import { escapeHtml } from "@lxpack/validators";
137
+
138
+ // src/utils.ts
139
+ var require3 = createRequire2(import.meta.url);
140
+ function formatCourseTitleForYaml(title) {
141
+ const doc = { title, version: "1.0.0" };
142
+ const yaml = stringifyYaml(doc);
143
+ const titleLine = yaml.split("\n").find((l) => l.startsWith("title:"));
144
+ if (!titleLine) {
145
+ return JSON.stringify(title);
146
+ }
147
+ return titleLine.replace(/^title:\s*/, "");
148
+ }
149
+ function getCliVersion() {
150
+ const pkg = require3("../package.json");
151
+ return pkg.version;
152
+ }
153
+
142
154
  // src/commands/init.ts
143
155
  var COURSE_YAML_TEMPLATE = `title: {{title}}
144
156
  version: 1.0.0
@@ -274,7 +286,7 @@ async function initCommand(projectName, options = {}) {
274
286
  const targetDir = resolvePathInCwd(options.dir ?? projectName);
275
287
  const title = formatTitle(projectName);
276
288
  const yamlTitle = formatCourseTitleForYaml(title);
277
- if (existsSync2(join2(targetDir, "course.yaml")) && !options.force) {
289
+ if (existsSync4(join4(targetDir, "course.yaml")) && !options.force) {
278
290
  console.error(
279
291
  pc.red(
280
292
  `Directory already contains a course. Use --force to overwrite: ${targetDir}`
@@ -284,29 +296,29 @@ async function initCommand(projectName, options = {}) {
284
296
  }
285
297
  const dirs = [
286
298
  targetDir,
287
- join2(targetDir, "lessons"),
288
- join2(targetDir, "interactions", "phishing-lab"),
289
- join2(targetDir, "assets"),
290
- join2(targetDir, "assessments"),
291
- join2(targetDir, "theme"),
292
- join2(targetDir, "components")
299
+ join4(targetDir, "lessons"),
300
+ join4(targetDir, "interactions", "phishing-lab"),
301
+ join4(targetDir, "assets"),
302
+ join4(targetDir, "assessments"),
303
+ join4(targetDir, "theme"),
304
+ join4(targetDir, "components")
293
305
  ];
294
306
  for (const dir of dirs) {
295
307
  await mkdir(dir, { recursive: true });
296
308
  }
297
309
  await writeFile(
298
- join2(targetDir, "course.yaml"),
310
+ join4(targetDir, "course.yaml"),
299
311
  COURSE_YAML_TEMPLATE.replace("{{title}}", yamlTitle)
300
312
  );
301
- await writeFile(join2(targetDir, "lessons", "welcome.md"), WELCOME_MD);
313
+ await writeFile(join4(targetDir, "lessons", "welcome.md"), WELCOME_MD);
302
314
  await writeFile(
303
- join2(targetDir, "interactions", "phishing-lab", "index.html"),
315
+ join4(targetDir, "interactions", "phishing-lab", "index.html"),
304
316
  PHISHING_HTML
305
317
  );
306
- await writeFile(join2(targetDir, "assessments", "final.yaml"), FINAL_ASSESSMENT);
307
- await writeFile(join2(targetDir, "lxpack.config.json"), LXPACK_CONFIG);
308
- await writeFile(join2(targetDir, "theme", ".gitkeep"), "");
309
- await writeFile(join2(targetDir, "components", ".gitkeep"), "");
318
+ await writeFile(join4(targetDir, "assessments", "final.yaml"), FINAL_ASSESSMENT);
319
+ await writeFile(join4(targetDir, "lxpack.config.json"), LXPACK_CONFIG);
320
+ await writeFile(join4(targetDir, "theme", ".gitkeep"), "");
321
+ await writeFile(join4(targetDir, "components", ".gitkeep"), "");
310
322
  console.log(pc.green(`\u2713 Created LXPack course: ${targetDir}`));
311
323
  console.log();
312
324
  console.log("Next steps:");
@@ -320,8 +332,35 @@ async function initCommand(projectName, options = {}) {
320
332
  import Fastify from "fastify";
321
333
  import fastifyStatic from "@fastify/static";
322
334
  import pc2 from "picocolors";
323
- import { validateCourse, buildRuntimeAssessmentBundle } from "@lxpack/validators";
324
- import { safeJsonForHtml } from "@lxpack/scorm";
335
+ import { validateCourse as validateCourse2 } from "@lxpack/validators";
336
+
337
+ // src/lib/validated-course.ts
338
+ import {
339
+ validateCourse,
340
+ buildRuntimeAssessmentBundleFromParsed
341
+ } from "@lxpack/validators";
342
+ async function loadValidatedCourseContext(courseDir) {
343
+ const validation = await validateCourse(courseDir);
344
+ if (!validation.valid || !validation.manifest) {
345
+ return null;
346
+ }
347
+ const parsed = validation.parsedAssessments ?? /* @__PURE__ */ new Map();
348
+ const assessmentBundle = buildRuntimeAssessmentBundleFromParsed(parsed);
349
+ return {
350
+ courseDir,
351
+ validation,
352
+ manifest: validation.manifest,
353
+ assessmentBundle
354
+ };
355
+ }
356
+ function printValidationIssues(validation) {
357
+ for (const issue of validation.issues) {
358
+ console.error(` ${issue.path}: ${issue.message}`);
359
+ }
360
+ }
361
+
362
+ // src/commands/preview.ts
363
+ import { buildLearnerPageHtml, safeJsonForHtml } from "@lxpack/scorm";
325
364
  async function loadPreviewStyles(assetsDir = getRuntimeAssetsDir()) {
326
365
  return loadRuntimeStyles(assetsDir);
327
366
  }
@@ -341,6 +380,12 @@ function buildPreviewConfig(manifest, assessmentBundle) {
341
380
  async function createPreviewServer(courseDir, manifest, assessmentBundle) {
342
381
  const runtimeDir = getRuntimeAssetsDir();
343
382
  const app = Fastify({ logger: false });
383
+ app.addHook("onRequest", async (request, reply) => {
384
+ const path = request.url.split("?")[0] ?? "";
385
+ if (path.startsWith("/course/assessments/")) {
386
+ return reply.code(404).send("Not found");
387
+ }
388
+ });
344
389
  await app.register(fastifyStatic, {
345
390
  root: courseDir,
346
391
  prefix: "/course/",
@@ -360,52 +405,39 @@ async function createPreviewServer(courseDir, manifest, assessmentBundle) {
360
405
  });
361
406
  }
362
407
  app.get("/", async (_req, reply) => {
363
- const html = `<!DOCTYPE html>
364
- <html lang="en">
365
- <head>
366
- <meta charset="UTF-8">
367
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
368
- <title>${escapeHtml(manifest.title)} \u2014 Preview</title>
369
- <style>${stylesCss}</style>
370
- </head>
371
- <body>
372
- <div id="lxpack-app"></div>
373
- <script type="application/json" id="lxpack-config">${config}</script>
374
- <script>
375
- window.__LXPACK_CONFIG__ = JSON.parse(document.getElementById('lxpack-config').textContent);
376
- </script>
377
- ${componentsJs ? '<script type="module" src="/runtime/components.js"></script>' : ""}
378
- <script type="module" src="/runtime/client.js"></script>
379
- </body>
380
- </html>`;
408
+ const html = buildLearnerPageHtml({
409
+ title: `${manifest.title} \u2014 Preview`,
410
+ runtimeCss: stylesCss,
411
+ configJson: config,
412
+ runtimeScript: "/runtime/client.js",
413
+ componentsScript: componentsJs ? "/runtime/components.js" : void 0
414
+ });
381
415
  return reply.type("text/html").send(html);
382
416
  });
383
417
  app.get("/health", async () => ({ status: "ok" }));
384
418
  return app;
385
419
  }
386
420
  async function startPreview(courseDir, _options = {}) {
387
- const validation = await validateCourse(courseDir);
388
- if (!validation.manifest) {
389
- console.error(pc2.red("Cannot preview: course manifest is invalid"));
390
- for (const issue of validation.issues) {
391
- console.error(` ${issue.path}: ${issue.message}`);
421
+ const ctx = await loadValidatedCourseContext(courseDir);
422
+ if (!ctx) {
423
+ const validation2 = await validateCourse2(courseDir);
424
+ if (!validation2.manifest) {
425
+ console.error(pc2.red("Cannot preview: course manifest is invalid"));
426
+ for (const issue of validation2.issues) {
427
+ console.error(` ${issue.path}: ${issue.message}`);
428
+ }
429
+ process.exit(1);
392
430
  }
393
- process.exit(1);
394
- }
395
- if (!validation.valid) {
396
431
  console.error(pc2.red("Cannot preview: course validation failed"));
397
- for (const issue of validation.issues) {
432
+ for (const issue of validation2.issues) {
398
433
  console.error(` ${issue.path}: ${issue.message}`);
399
434
  }
400
435
  process.exit(1);
401
436
  }
402
- const assessmentBundle = await buildRuntimeAssessmentBundle(
403
- courseDir,
404
- validation.manifest
405
- );
437
+ const { validation, manifest, assessmentBundle } = ctx;
406
438
  const app = await createPreviewServer(
407
439
  courseDir,
408
- validation.manifest,
440
+ manifest,
409
441
  assessmentBundle
410
442
  );
411
443
  return { app, validation };
@@ -452,11 +484,11 @@ function logPreviewStarted(host, port) {
452
484
  }
453
485
 
454
486
  // src/commands/validate.ts
455
- import { validateCourse as validateCourse2 } from "@lxpack/validators";
487
+ import { validateCourse as validateCourse3 } from "@lxpack/validators";
456
488
  import pc3 from "picocolors";
457
489
  async function validateCommand() {
458
490
  const courseDir = findCourseDir();
459
- const result = await validateCourse2(courseDir);
491
+ const result = await validateCourse3(courseDir);
460
492
  if (result.manifest) {
461
493
  console.log(
462
494
  pc3.dim(`Course: ${result.manifest.title} v${result.manifest.version}`)
@@ -479,13 +511,35 @@ async function validateCommand() {
479
511
 
480
512
  // src/commands/build.ts
481
513
  import { mkdir as mkdir2 } from "fs/promises";
482
- import { join as join3 } from "path";
483
- import { packageCourse, packageStandaloneDir, courseSlug } from "@lxpack/scorm";
484
- import {
485
- validateCourse as validateCourse3,
486
- buildRuntimeAssessmentBundle as buildRuntimeAssessmentBundle2
487
- } from "@lxpack/validators";
514
+ import { join as join5 } from "path";
515
+ import { courseSlug } from "@lxpack/scorm";
488
516
  import pc4 from "picocolors";
517
+
518
+ // src/packagers/index.ts
519
+ import {
520
+ packageCourse,
521
+ packageScorm2004Dir,
522
+ packageStandaloneDir
523
+ } from "@lxpack/scorm";
524
+ var zipPackagers = {
525
+ scorm12: { package: packageCourse },
526
+ scorm2004: { package: packageCourse },
527
+ standalone: { package: packageCourse }
528
+ };
529
+ var dirPackagers = {
530
+ scorm12: { package: packageStandaloneDir },
531
+ scorm2004: { package: packageScorm2004Dir },
532
+ standalone: { package: packageStandaloneDir }
533
+ };
534
+ function getZipPackager(target) {
535
+ return zipPackagers[target];
536
+ }
537
+ function getDirPackager(target) {
538
+ return dirPackagers[target];
539
+ }
540
+
541
+ // src/commands/build.ts
542
+ import { validateCourse as validateCourse4 } from "@lxpack/validators";
489
543
  var VALID_TARGETS = ["scorm12", "scorm2004", "standalone"];
490
544
  async function buildCommand(options) {
491
545
  const courseDir = findCourseDir();
@@ -499,19 +553,14 @@ async function buildCommand(options) {
499
553
  );
500
554
  process.exit(1);
501
555
  }
502
- const validation = await validateCourse3(courseDir);
503
- if (!validation.valid || !validation.manifest) {
556
+ const ctx = await loadValidatedCourseContext(courseDir);
557
+ if (!ctx) {
504
558
  console.error(pc4.red("Cannot build: course validation failed"));
505
- for (const issue of validation.issues) {
506
- console.error(` ${issue.path}: ${issue.message}`);
507
- }
559
+ const validation = await validateCourse4(courseDir);
560
+ printValidationIssues(validation);
508
561
  process.exit(1);
509
562
  }
510
- const manifest = validation.manifest;
511
- const assessmentBundle = await buildRuntimeAssessmentBundle2(
512
- courseDir,
513
- manifest
514
- );
563
+ const { manifest, assessmentBundle } = ctx;
515
564
  const [{ clientJs, css }, componentsBundleJs] = await Promise.all([
516
565
  readRuntimeBundle(),
517
566
  readComponentsBundle()
@@ -530,8 +579,8 @@ async function buildCommand(options) {
530
579
  assessmentBundle
531
580
  };
532
581
  if (options.dir) {
533
- const outputDir = options.output ?? join3(outputRoot, target);
534
- const result = await packageStandaloneDir({
582
+ const outputDir = options.output ?? join5(outputRoot, target);
583
+ const result = await getDirPackager(target).package({
535
584
  ...packageOptions,
536
585
  outputDir
537
586
  });
@@ -540,8 +589,8 @@ async function buildCommand(options) {
540
589
  console.log(` Files: ${result.fileCount}`);
541
590
  } else {
542
591
  const defaultName = target === "standalone" ? `${slug}-standalone.zip` : target === "scorm2004" ? `${slug}-scorm2004.zip` : `${slug}-scorm12.zip`;
543
- const outputPath = options.output ?? join3(outputRoot, defaultName);
544
- const result = await packageCourse({
592
+ const outputPath = options.output ?? join5(outputRoot, defaultName);
593
+ const result = await getZipPackager(target).package({
545
594
  ...packageOptions,
546
595
  outputPath
547
596
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lxpack/cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "CLI for building, validating, and packaging LXPack courses",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -39,10 +39,10 @@
39
39
  "picocolors": "^1.1.1",
40
40
  "yaml": "^2.7.0",
41
41
  "zod": "^3.24.2",
42
- "@lxpack/runtime": "0.2.0",
43
- "@lxpack/components": "0.2.0",
44
- "@lxpack/scorm": "0.2.0",
45
- "@lxpack/validators": "0.2.0"
42
+ "@lxpack/components": "0.2.1",
43
+ "@lxpack/validators": "0.2.1",
44
+ "@lxpack/runtime": "0.2.1",
45
+ "@lxpack/scorm": "0.2.1"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/node": "^22.13.10",