@novedu/cli 0.1.2 → 0.3.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.
Files changed (2) hide show
  1. package/dist/main.js +250 -27
  2. package/package.json +2 -2
package/dist/main.js CHANGED
@@ -40,6 +40,26 @@ function warning(code, message, extra = {}) {
40
40
  ...extra
41
41
  };
42
42
  }
43
+ /**
44
+ * Flattens a treeified Zod error into `path: message` lines so a generic
45
+ * "Document does not match the expected structure" becomes actionable — e.g.
46
+ * `Unrecognized key: "nae"` and `name: Invalid input: expected string`.
47
+ * Framework-agnostic: shared by the web UI (`ErrorList`), the share-tutor
48
+ * action, and the CLI formatter, so a schema error reads the same everywhere.
49
+ */
50
+ function formatZodIssues(zodIssues) {
51
+ const out = [];
52
+ const walk = (node, path) => {
53
+ if (!node || typeof node !== "object") return;
54
+ if (Array.isArray(node.errors)) for (const message of node.errors) out.push(path.length ? `${path.join(".")}: ${message}` : message);
55
+ if (node.properties) for (const [key, child] of Object.entries(node.properties)) walk(child, [...path, key]);
56
+ if (Array.isArray(node.items)) node.items.forEach((child, index) => {
57
+ walk(child, [...path, String(index)]);
58
+ });
59
+ };
60
+ walk(zodIssues, []);
61
+ return out;
62
+ }
43
63
  //#endregion
44
64
  //#region ../lib/tutors/consistency.ts
45
65
  /** Compare a supplied value against its declared property type. Returns null when it matches. */
@@ -317,6 +337,97 @@ const TutorSchema = z.strictObject({
317
337
  })
318
338
  });
319
339
  //#endregion
340
+ //#region ../lib/tutors/fragment.ts
341
+ /**
342
+ * A placeholder value for a declared input, shaped to its type so the template
343
+ * actually exercises it: a string renders, a boolean drives `{{#if}}`, an array
344
+ * makes `{{#each}}` run its body (so references inside the loop are checked too). We
345
+ * deliberately ignore the fragment's real `default`s — we only need *some* value of
346
+ * the right shape, so that strict rendering throws solely on variables the fragment
347
+ * never declares.
348
+ */
349
+ function placeholder(prop) {
350
+ switch (prop.type) {
351
+ case "string": return "x";
352
+ case "boolean": return true;
353
+ case "array": return ["x"];
354
+ }
355
+ }
356
+ /** Synthetic render context = every declared property name → a typed placeholder. */
357
+ function placeholderContext(fragment) {
358
+ const ctx = {};
359
+ for (const [name, prop] of Object.entries(fragment.input_schema?.properties ?? {})) ctx[name] = placeholder(prop);
360
+ return ctx;
361
+ }
362
+ /**
363
+ * Strict-render each fragment's `content` against a context built from its own
364
+ * `input_schema`. A throw — a Handlebars syntax error, or `strict` mode hitting a
365
+ * variable not in the context (one the fragment never declares) — becomes a
366
+ * `FRAGMENT_TEMPLATE_ERROR`. Uses the SAME compile options as real prompt assembly
367
+ * (`assemble.ts`), so passing here ⟺ rendering inside a tutor. `fileAlias`/`url` are
368
+ * stamped for attribution when this runs as part of a tutor's whole-library check.
369
+ */
370
+ function checkFragmentTemplates(file, opts = {}) {
371
+ const errors = [];
372
+ for (const fragment of file.fragments) try {
373
+ Handlebars.compile(fragment.content, COMPILE_OPTIONS)(placeholderContext(fragment));
374
+ } catch (e) {
375
+ const message = e instanceof Error ? e.message : String(e);
376
+ errors.push(error("FRAGMENT_TEMPLATE_ERROR", `Fragment "${fragment.id}" failed to render: ${message}`, {
377
+ fragmentId: fragment.id,
378
+ fileAlias: opts.fileAlias,
379
+ url: opts.url
380
+ }));
381
+ }
382
+ return {
383
+ errors,
384
+ warnings: []
385
+ };
386
+ }
387
+ /**
388
+ * Fragment ids declared more than once within a single file. The standalone
389
+ * validator runs this directly; the tutor path gets the same check from
390
+ * `checkConsistency` (so the whole-library pass must NOT repeat it).
391
+ */
392
+ function findDuplicateFragmentIds(file) {
393
+ const errors = [];
394
+ const seen = /* @__PURE__ */ new Set();
395
+ for (const fragment of file.fragments) {
396
+ if (seen.has(fragment.id)) {
397
+ errors.push(error("DUPLICATE_FRAGMENT_ID_IN_FILE", `Fragment "${fragment.id}" is declared more than once`, { fragmentId: fragment.id }));
398
+ continue;
399
+ }
400
+ seen.add(fragment.id);
401
+ }
402
+ return errors;
403
+ }
404
+ /**
405
+ * Validate a fragment FILE on its own: schema → unique ids → every template renders.
406
+ * Pure (the parsed value is passed in); `loadAndCheckFragmentFile` in `load.ts` wraps
407
+ * it with fetch + YAML parse. On success, reports the file id and its fragment ids.
408
+ */
409
+ function checkFragmentFileValue(parsed, url) {
410
+ const valid = validate(parsed, FragmentFileSchema, "FRAGMENT_FILE_SCHEMA_ERROR", url);
411
+ if (!valid.ok) return {
412
+ ok: false,
413
+ errors: [valid.error],
414
+ warnings: []
415
+ };
416
+ const file = valid.data;
417
+ const errors = [...findDuplicateFragmentIds(file), ...checkFragmentTemplates(file, { url }).errors];
418
+ if (errors.length > 0) return {
419
+ ok: false,
420
+ errors,
421
+ warnings: []
422
+ };
423
+ return {
424
+ ok: true,
425
+ fragmentFileId: file.id,
426
+ fragmentIds: file.fragments.map((f) => f.id),
427
+ warnings: []
428
+ };
429
+ }
430
+ //#endregion
320
431
  //#region ../lib/tutors/load.ts
321
432
  /**
322
433
  * Resolve a fragment-file reference to an absolute URL. An absolute http(s) ref is used
@@ -352,8 +463,13 @@ async function fetchText(url, fetchImpl) {
352
463
  }
353
464
  }
354
465
  const DEFAULT_ALLOWED_SCHEMES = ["http:", "https:"];
355
- async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
356
- const warnings = [];
466
+ /**
467
+ * The shared front of every load: enforce the URL scheme allow-list (SSRF guard),
468
+ * fetch the document, and parse it as YAML — returning the parsed-but-not-yet-schema-
469
+ * validated value or the first structured error. Reused by the tutor builder and the
470
+ * standalone fragment checker so both gate schemes identically.
471
+ */
472
+ async function loadYaml(url, fetchImpl, opts = {}) {
357
473
  const allowedSchemes = opts.allowedSchemes ?? DEFAULT_ALLOWED_SCHEMES;
358
474
  let scheme;
359
475
  try {
@@ -363,16 +479,26 @@ async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
363
479
  }
364
480
  if (!allowedSchemes.includes(scheme)) return {
365
481
  ok: false,
366
- errors: [error("INVALID_URL", `Provide a valid ${allowedSchemes.map((s) => s.replace(/:$/, "")).join("/")} URL`, { url })],
367
- warnings
482
+ error: error("INVALID_URL", `Provide a valid ${allowedSchemes.map((s) => s.replace(/:$/, "")).join("/")} URL`, { url })
368
483
  };
369
- const tutorFetch = await fetchText(url, fetchImpl);
370
- if (!tutorFetch.ok) return {
484
+ const fetched = await fetchText(url, fetchImpl);
485
+ if (!fetched.ok) return {
371
486
  ok: false,
372
- errors: [tutorFetch.error],
373
- warnings
487
+ error: fetched.error
488
+ };
489
+ const parsed = parseYaml(fetched.text, url);
490
+ if (!parsed.ok) return {
491
+ ok: false,
492
+ error: parsed.error
374
493
  };
375
- const tutorYaml = parseYaml(tutorFetch.text, url);
494
+ return {
495
+ ok: true,
496
+ value: parsed.value
497
+ };
498
+ }
499
+ async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
500
+ const warnings = [];
501
+ const tutorYaml = await loadYaml(url, fetchImpl, opts);
376
502
  if (!tutorYaml.ok) return {
377
503
  ok: false,
378
504
  errors: [tutorYaml.error],
@@ -418,23 +544,38 @@ async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
418
544
  };
419
545
  return {
420
546
  alias: ref.id,
421
- file: valid.data
547
+ file: valid.data,
548
+ url: fragmentUrl
422
549
  };
423
550
  }));
424
551
  const fragmentFilesByAlias = /* @__PURE__ */ new Map();
552
+ const fragmentUrlByAlias = /* @__PURE__ */ new Map();
425
553
  const fileErrors = [];
426
554
  for (const result of settled) if ("error" in result) fileErrors.push(result.error);
427
- else fragmentFilesByAlias.set(result.alias, result.file);
555
+ else {
556
+ fragmentFilesByAlias.set(result.alias, result.file);
557
+ fragmentUrlByAlias.set(result.alias, result.url);
558
+ }
428
559
  if (fileErrors.length > 0) return {
429
560
  ok: false,
430
561
  errors: fileErrors,
431
562
  warnings
432
563
  };
564
+ const libraryErrors = [];
565
+ if (opts.validateLibraries) for (const [alias, file] of fragmentFilesByAlias) {
566
+ const checked = checkFragmentTemplates(file, {
567
+ fileAlias: alias,
568
+ url: fragmentUrlByAlias.get(alias)
569
+ });
570
+ libraryErrors.push(...checked.errors);
571
+ warnings.push(...checked.warnings);
572
+ }
433
573
  const consistency = checkConsistency(tutor, fragmentFilesByAlias);
434
574
  warnings.push(...consistency.warnings);
435
- if (consistency.errors.length > 0) return {
575
+ const preAssemblyErrors = [...libraryErrors, ...consistency.errors];
576
+ if (preAssemblyErrors.length > 0) return {
436
577
  ok: false,
437
- errors: consistency.errors,
578
+ errors: preAssemblyErrors,
438
579
  warnings
439
580
  };
440
581
  try {
@@ -457,6 +598,22 @@ async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
457
598
  };
458
599
  }
459
600
  }
601
+ /**
602
+ * Validate a fragment FILE on its own (the `--kind fragment` / "Fragment library"
603
+ * path): scheme-gate + fetch + parse, then the pure `checkFragmentFileValue`. A
604
+ * fragment library is self-contained, so — unlike a tutor — there are no further
605
+ * files to fetch. The caller already knows it asked for a fragment, so this returns
606
+ * a `FragmentCheckResult` directly (no tutor `BuildResult`, no kind discriminator).
607
+ */
608
+ async function loadAndCheckFragmentFile(url, fetchImpl, opts = {}) {
609
+ const yaml = await loadYaml(url, fetchImpl, opts);
610
+ if (!yaml.ok) return {
611
+ ok: false,
612
+ errors: [yaml.error],
613
+ warnings: []
614
+ };
615
+ return checkFragmentFileValue(yaml.value, url);
616
+ }
460
617
  //#endregion
461
618
  //#region src/file-fetcher.ts
462
619
  const cliFetcher = async (url) => {
@@ -498,6 +655,20 @@ function context(item) {
498
655
  function renderWarnings(warnings) {
499
656
  return warnings.map((w) => ` ${yellow("⚠")} ${yellow(w.code)} ${w.message}${context(w)}`);
500
657
  }
658
+ /**
659
+ * Render each error as a line, with any flattened Zod schema-issue detail
660
+ * indented beneath it — so a generic "Document does not match the expected
661
+ * structure" is followed by the actual field paths (e.g. `Unrecognized key:
662
+ * "nae"`), matching what the web UI shows.
663
+ */
664
+ function renderErrors(errors) {
665
+ const lines = [];
666
+ for (const e of errors) {
667
+ lines.push(` ${red("✗")} ${red(e.code)} ${e.message}${context(e)}`);
668
+ if (e.zodIssues) for (const issue of formatZodIssues(e.zodIssues)) lines.push(` ${dim(issue)}`);
669
+ }
670
+ return lines;
671
+ }
501
672
  function formatResult(result, source) {
502
673
  const lines = [];
503
674
  if (result.ok) {
@@ -515,7 +686,32 @@ function formatResult(result, source) {
515
686
  lines.push(red(`✘ Invalid tutor`) + dim(` — ${source}`));
516
687
  lines.push("");
517
688
  lines.push(red(`${result.errors.length} error(s):`));
518
- for (const e of result.errors) lines.push(` ${red("✗")} ${red(e.code)} ${e.message}${context(e)}`);
689
+ lines.push(...renderErrors(result.errors));
690
+ if (result.warnings.length) {
691
+ lines.push("");
692
+ lines.push(yellow(`${result.warnings.length} warning(s):`));
693
+ lines.push(...renderWarnings(result.warnings));
694
+ }
695
+ return lines.join("\n");
696
+ }
697
+ /** Same renderer, for a standalone fragment-FILE check (`--kind fragment`). */
698
+ function formatFragmentResult(result, source) {
699
+ const lines = [];
700
+ if (result.ok) {
701
+ lines.push(green(`✔ Valid fragment file`) + dim(` — ${source}`));
702
+ lines.push(` id: ${result.fragmentFileId}`);
703
+ lines.push(` fragments: ${result.fragmentIds.length}` + (result.fragmentIds.length ? ` (${result.fragmentIds.join(", ")})` : ""));
704
+ if (result.warnings.length) {
705
+ lines.push("");
706
+ lines.push(yellow(`${result.warnings.length} warning(s):`));
707
+ lines.push(...renderWarnings(result.warnings));
708
+ }
709
+ return lines.join("\n");
710
+ }
711
+ lines.push(red(`✘ Invalid fragment file`) + dim(` — ${source}`));
712
+ lines.push("");
713
+ lines.push(red(`${result.errors.length} error(s):`));
714
+ lines.push(...renderErrors(result.errors));
519
715
  if (result.warnings.length) {
520
716
  lines.push("");
521
717
  lines.push(yellow(`${result.warnings.length} warning(s):`));
@@ -535,30 +731,57 @@ function toUrl(pathOrUrl) {
535
731
  return pathToFileURL(resolve(pathOrUrl)).href;
536
732
  }
537
733
  /**
538
- * The validate command's pure core: run the existing tutor pipeline over a local
539
- * file or public URL. Kept separate from the commander wiring so it can be unit
540
- * tested in-process. `file:` is allowed in addition to http(s) so local YAML can
541
- * be validated (the web app deliberately stays http(s)-only).
734
+ * The validate command's pure core: run the requested pipeline over a local file or
735
+ * public URL. `file:` is allowed in addition to http(s) so local YAML can be
736
+ * validated (the web app deliberately stays http(s)-only). As an authoring tool, the
737
+ * tutor path runs the THOROUGH check (`validateLibraries`), so every fragment in every
738
+ * referenced library is rendered — not just the ones the tutor uses.
542
739
  */
543
- function runValidate(pathOrUrl) {
544
- return loadAndBuildTutorPrompt(toUrl(pathOrUrl), cliFetcher, { allowedSchemes: [
740
+ function runValidate(pathOrUrl, kind) {
741
+ const url = toUrl(pathOrUrl);
742
+ const allowedSchemes = [
545
743
  "http:",
546
744
  "https:",
547
745
  "file:"
548
- ] });
746
+ ];
747
+ if (kind === "fragment") return loadAndCheckFragmentFile(url, cliFetcher, { allowedSchemes }).then((result) => ({
748
+ kind,
749
+ result
750
+ }));
751
+ return loadAndBuildTutorPrompt(url, cliFetcher, {
752
+ allowedSchemes,
753
+ validateLibraries: true
754
+ }).then((result) => ({
755
+ kind,
756
+ result
757
+ }));
549
758
  }
550
759
  function registerValidate(program) {
551
- program.command("validate").description("Validate a tutor YAML by local path or public http(s) URL").argument("<pathOrUrl>", "path to a tutor YAML file, or a public http(s) URL").option("--json", "print the raw validation result as JSON").action(async (pathOrUrl, options) => {
552
- const result = await runValidate(pathOrUrl);
553
- if (options.json) console.log(JSON.stringify(result, null, 2));
554
- else console.log(formatResult(result, pathOrUrl));
555
- process.exitCode = result.ok ? 0 : 1;
760
+ program.command("validate").description("Validate a tutor YAML (default) or a fragment library by local path or public http(s) URL").argument("<pathOrUrl>", "path to a tutor or fragment YAML file, or a public http(s) URL").option("--kind <kind>", "what the file is: 'tutor' (default) or 'fragment'", "tutor").option("--json", "print the raw validation result as JSON").addHelpText("after", `
761
+ Examples:
762
+ # Validate a tutor (also strict-renders every fragment in every referenced library)
763
+ $ novedu-cli validate ./tutors/my-tutor.yaml
764
+
765
+ # Validate a fragment library on its own
766
+ $ novedu-cli validate ./tutors/my-fragments.yaml --kind fragment
767
+
768
+ # Machine-readable output for CI
769
+ $ novedu-cli validate https://example.com/tutor.yaml --json`).action(async (pathOrUrl, options) => {
770
+ if (options.kind !== void 0 && options.kind !== "tutor" && options.kind !== "fragment") {
771
+ console.error(`Invalid --kind "${options.kind}": expected "tutor" or "fragment".`);
772
+ process.exitCode = 1;
773
+ return;
774
+ }
775
+ const outcome = await runValidate(pathOrUrl, options.kind === "fragment" ? "fragment" : "tutor");
776
+ if (options.json) console.log(JSON.stringify(outcome.result, null, 2));
777
+ else console.log(outcome.kind === "fragment" ? formatFragmentResult(outcome.result, pathOrUrl) : formatResult(outcome.result, pathOrUrl));
778
+ process.exitCode = outcome.result.ok ? 0 : 1;
556
779
  });
557
780
  }
558
781
  //#endregion
559
782
  //#region src/main.ts
560
783
  const program = new Command();
561
- program.name("novedu-cli").description("Command-line companion for the Novedu chat app").version("0.1.0");
784
+ program.name("novedu-cli").description("Command-line companion for the Novedu chat app").version("0.2.0");
562
785
  registerValidate(program);
563
786
  program.parseAsync().catch((err) => {
564
787
  console.error(err instanceof Error ? err.message : err);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@novedu/cli",
3
- "version": "0.1.2",
4
- "description": "Command-line companion for the Novedu chat app. Validates tutor YAML definitions (more commands to follow).",
3
+ "version": "0.3.0",
4
+ "description": "Command-line companion for the Novedu chat app. Validates tutor YAML definitions and fragment libraries (more commands to follow).",
5
5
  "type": "module",
6
6
  "repository": {
7
7
  "type": "git",