@novedu/cli 0.1.2 → 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.
Files changed (2) hide show
  1. package/dist/main.js +206 -26
  2. package/package.json +2 -2
package/dist/main.js CHANGED
@@ -317,6 +317,97 @@ const TutorSchema = z.strictObject({
317
317
  })
318
318
  });
319
319
  //#endregion
320
+ //#region ../lib/tutors/fragment.ts
321
+ /**
322
+ * A placeholder value for a declared input, shaped to its type so the template
323
+ * actually exercises it: a string renders, a boolean drives `{{#if}}`, an array
324
+ * makes `{{#each}}` run its body (so references inside the loop are checked too). We
325
+ * deliberately ignore the fragment's real `default`s — we only need *some* value of
326
+ * the right shape, so that strict rendering throws solely on variables the fragment
327
+ * never declares.
328
+ */
329
+ function placeholder(prop) {
330
+ switch (prop.type) {
331
+ case "string": return "x";
332
+ case "boolean": return true;
333
+ case "array": return ["x"];
334
+ }
335
+ }
336
+ /** Synthetic render context = every declared property name → a typed placeholder. */
337
+ function placeholderContext(fragment) {
338
+ const ctx = {};
339
+ for (const [name, prop] of Object.entries(fragment.input_schema?.properties ?? {})) ctx[name] = placeholder(prop);
340
+ return ctx;
341
+ }
342
+ /**
343
+ * Strict-render each fragment's `content` against a context built from its own
344
+ * `input_schema`. A throw — a Handlebars syntax error, or `strict` mode hitting a
345
+ * variable not in the context (one the fragment never declares) — becomes a
346
+ * `FRAGMENT_TEMPLATE_ERROR`. Uses the SAME compile options as real prompt assembly
347
+ * (`assemble.ts`), so passing here ⟺ rendering inside a tutor. `fileAlias`/`url` are
348
+ * stamped for attribution when this runs as part of a tutor's whole-library check.
349
+ */
350
+ function checkFragmentTemplates(file, opts = {}) {
351
+ const errors = [];
352
+ for (const fragment of file.fragments) try {
353
+ Handlebars.compile(fragment.content, COMPILE_OPTIONS)(placeholderContext(fragment));
354
+ } catch (e) {
355
+ const message = e instanceof Error ? e.message : String(e);
356
+ errors.push(error("FRAGMENT_TEMPLATE_ERROR", `Fragment "${fragment.id}" failed to render: ${message}`, {
357
+ fragmentId: fragment.id,
358
+ fileAlias: opts.fileAlias,
359
+ url: opts.url
360
+ }));
361
+ }
362
+ return {
363
+ errors,
364
+ warnings: []
365
+ };
366
+ }
367
+ /**
368
+ * Fragment ids declared more than once within a single file. The standalone
369
+ * validator runs this directly; the tutor path gets the same check from
370
+ * `checkConsistency` (so the whole-library pass must NOT repeat it).
371
+ */
372
+ function findDuplicateFragmentIds(file) {
373
+ const errors = [];
374
+ const seen = /* @__PURE__ */ new Set();
375
+ for (const fragment of file.fragments) {
376
+ if (seen.has(fragment.id)) {
377
+ errors.push(error("DUPLICATE_FRAGMENT_ID_IN_FILE", `Fragment "${fragment.id}" is declared more than once`, { fragmentId: fragment.id }));
378
+ continue;
379
+ }
380
+ seen.add(fragment.id);
381
+ }
382
+ return errors;
383
+ }
384
+ /**
385
+ * Validate a fragment FILE on its own: schema → unique ids → every template renders.
386
+ * Pure (the parsed value is passed in); `loadAndCheckFragmentFile` in `load.ts` wraps
387
+ * it with fetch + YAML parse. On success, reports the file id and its fragment ids.
388
+ */
389
+ function checkFragmentFileValue(parsed, url) {
390
+ const valid = validate(parsed, FragmentFileSchema, "FRAGMENT_FILE_SCHEMA_ERROR", url);
391
+ if (!valid.ok) return {
392
+ ok: false,
393
+ errors: [valid.error],
394
+ warnings: []
395
+ };
396
+ const file = valid.data;
397
+ const errors = [...findDuplicateFragmentIds(file), ...checkFragmentTemplates(file, { url }).errors];
398
+ if (errors.length > 0) return {
399
+ ok: false,
400
+ errors,
401
+ warnings: []
402
+ };
403
+ return {
404
+ ok: true,
405
+ fragmentFileId: file.id,
406
+ fragmentIds: file.fragments.map((f) => f.id),
407
+ warnings: []
408
+ };
409
+ }
410
+ //#endregion
320
411
  //#region ../lib/tutors/load.ts
321
412
  /**
322
413
  * Resolve a fragment-file reference to an absolute URL. An absolute http(s) ref is used
@@ -352,8 +443,13 @@ async function fetchText(url, fetchImpl) {
352
443
  }
353
444
  }
354
445
  const DEFAULT_ALLOWED_SCHEMES = ["http:", "https:"];
355
- async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
356
- const warnings = [];
446
+ /**
447
+ * The shared front of every load: enforce the URL scheme allow-list (SSRF guard),
448
+ * fetch the document, and parse it as YAML — returning the parsed-but-not-yet-schema-
449
+ * validated value or the first structured error. Reused by the tutor builder and the
450
+ * standalone fragment checker so both gate schemes identically.
451
+ */
452
+ async function loadYaml(url, fetchImpl, opts = {}) {
357
453
  const allowedSchemes = opts.allowedSchemes ?? DEFAULT_ALLOWED_SCHEMES;
358
454
  let scheme;
359
455
  try {
@@ -363,16 +459,26 @@ async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
363
459
  }
364
460
  if (!allowedSchemes.includes(scheme)) return {
365
461
  ok: false,
366
- errors: [error("INVALID_URL", `Provide a valid ${allowedSchemes.map((s) => s.replace(/:$/, "")).join("/")} URL`, { url })],
367
- warnings
462
+ error: error("INVALID_URL", `Provide a valid ${allowedSchemes.map((s) => s.replace(/:$/, "")).join("/")} URL`, { url })
368
463
  };
369
- const tutorFetch = await fetchText(url, fetchImpl);
370
- if (!tutorFetch.ok) return {
464
+ const fetched = await fetchText(url, fetchImpl);
465
+ if (!fetched.ok) return {
371
466
  ok: false,
372
- errors: [tutorFetch.error],
373
- warnings
467
+ error: fetched.error
468
+ };
469
+ const parsed = parseYaml(fetched.text, url);
470
+ if (!parsed.ok) return {
471
+ ok: false,
472
+ error: parsed.error
473
+ };
474
+ return {
475
+ ok: true,
476
+ value: parsed.value
374
477
  };
375
- const tutorYaml = parseYaml(tutorFetch.text, url);
478
+ }
479
+ async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
480
+ const warnings = [];
481
+ const tutorYaml = await loadYaml(url, fetchImpl, opts);
376
482
  if (!tutorYaml.ok) return {
377
483
  ok: false,
378
484
  errors: [tutorYaml.error],
@@ -418,23 +524,38 @@ async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
418
524
  };
419
525
  return {
420
526
  alias: ref.id,
421
- file: valid.data
527
+ file: valid.data,
528
+ url: fragmentUrl
422
529
  };
423
530
  }));
424
531
  const fragmentFilesByAlias = /* @__PURE__ */ new Map();
532
+ const fragmentUrlByAlias = /* @__PURE__ */ new Map();
425
533
  const fileErrors = [];
426
534
  for (const result of settled) if ("error" in result) fileErrors.push(result.error);
427
- else fragmentFilesByAlias.set(result.alias, result.file);
535
+ else {
536
+ fragmentFilesByAlias.set(result.alias, result.file);
537
+ fragmentUrlByAlias.set(result.alias, result.url);
538
+ }
428
539
  if (fileErrors.length > 0) return {
429
540
  ok: false,
430
541
  errors: fileErrors,
431
542
  warnings
432
543
  };
544
+ const libraryErrors = [];
545
+ if (opts.validateLibraries) for (const [alias, file] of fragmentFilesByAlias) {
546
+ const checked = checkFragmentTemplates(file, {
547
+ fileAlias: alias,
548
+ url: fragmentUrlByAlias.get(alias)
549
+ });
550
+ libraryErrors.push(...checked.errors);
551
+ warnings.push(...checked.warnings);
552
+ }
433
553
  const consistency = checkConsistency(tutor, fragmentFilesByAlias);
434
554
  warnings.push(...consistency.warnings);
435
- if (consistency.errors.length > 0) return {
555
+ const preAssemblyErrors = [...libraryErrors, ...consistency.errors];
556
+ if (preAssemblyErrors.length > 0) return {
436
557
  ok: false,
437
- errors: consistency.errors,
558
+ errors: preAssemblyErrors,
438
559
  warnings
439
560
  };
440
561
  try {
@@ -457,6 +578,22 @@ async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
457
578
  };
458
579
  }
459
580
  }
581
+ /**
582
+ * Validate a fragment FILE on its own (the `--kind fragment` / "Fragment library"
583
+ * path): scheme-gate + fetch + parse, then the pure `checkFragmentFileValue`. A
584
+ * fragment library is self-contained, so — unlike a tutor — there are no further
585
+ * files to fetch. The caller already knows it asked for a fragment, so this returns
586
+ * a `FragmentCheckResult` directly (no tutor `BuildResult`, no kind discriminator).
587
+ */
588
+ async function loadAndCheckFragmentFile(url, fetchImpl, opts = {}) {
589
+ const yaml = await loadYaml(url, fetchImpl, opts);
590
+ if (!yaml.ok) return {
591
+ ok: false,
592
+ errors: [yaml.error],
593
+ warnings: []
594
+ };
595
+ return checkFragmentFileValue(yaml.value, url);
596
+ }
460
597
  //#endregion
461
598
  //#region src/file-fetcher.ts
462
599
  const cliFetcher = async (url) => {
@@ -523,6 +660,31 @@ function formatResult(result, source) {
523
660
  }
524
661
  return lines.join("\n");
525
662
  }
663
+ /** Same renderer, for a standalone fragment-FILE check (`--kind fragment`). */
664
+ function formatFragmentResult(result, source) {
665
+ const lines = [];
666
+ if (result.ok) {
667
+ lines.push(green(`✔ Valid fragment file`) + dim(` — ${source}`));
668
+ lines.push(` id: ${result.fragmentFileId}`);
669
+ lines.push(` fragments: ${result.fragmentIds.length}` + (result.fragmentIds.length ? ` (${result.fragmentIds.join(", ")})` : ""));
670
+ if (result.warnings.length) {
671
+ lines.push("");
672
+ lines.push(yellow(`${result.warnings.length} warning(s):`));
673
+ lines.push(...renderWarnings(result.warnings));
674
+ }
675
+ return lines.join("\n");
676
+ }
677
+ lines.push(red(`✘ Invalid fragment file`) + dim(` — ${source}`));
678
+ lines.push("");
679
+ lines.push(red(`${result.errors.length} error(s):`));
680
+ for (const e of result.errors) lines.push(` ${red("✗")} ${red(e.code)} ${e.message}${context(e)}`);
681
+ if (result.warnings.length) {
682
+ lines.push("");
683
+ lines.push(yellow(`${result.warnings.length} warning(s):`));
684
+ lines.push(...renderWarnings(result.warnings));
685
+ }
686
+ return lines.join("\n");
687
+ }
526
688
  //#endregion
527
689
  //#region src/commands/validate.ts
528
690
  /**
@@ -535,30 +697,48 @@ function toUrl(pathOrUrl) {
535
697
  return pathToFileURL(resolve(pathOrUrl)).href;
536
698
  }
537
699
  /**
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).
700
+ * The validate command's pure core: run the requested pipeline over a local file or
701
+ * public URL. `file:` is allowed in addition to http(s) so local YAML can be
702
+ * validated (the web app deliberately stays http(s)-only). As an authoring tool, the
703
+ * tutor path runs the THOROUGH check (`validateLibraries`), so every fragment in every
704
+ * referenced library is rendered — not just the ones the tutor uses.
542
705
  */
543
- function runValidate(pathOrUrl) {
544
- return loadAndBuildTutorPrompt(toUrl(pathOrUrl), cliFetcher, { allowedSchemes: [
706
+ function runValidate(pathOrUrl, kind) {
707
+ const url = toUrl(pathOrUrl);
708
+ const allowedSchemes = [
545
709
  "http:",
546
710
  "https:",
547
711
  "file:"
548
- ] });
712
+ ];
713
+ if (kind === "fragment") return loadAndCheckFragmentFile(url, cliFetcher, { allowedSchemes }).then((result) => ({
714
+ kind,
715
+ result
716
+ }));
717
+ return loadAndBuildTutorPrompt(url, cliFetcher, {
718
+ allowedSchemes,
719
+ validateLibraries: true
720
+ }).then((result) => ({
721
+ kind,
722
+ result
723
+ }));
549
724
  }
550
725
  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;
726
+ 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").action(async (pathOrUrl, options) => {
727
+ if (options.kind !== void 0 && options.kind !== "tutor" && options.kind !== "fragment") {
728
+ console.error(`Invalid --kind "${options.kind}": expected "tutor" or "fragment".`);
729
+ process.exitCode = 1;
730
+ return;
731
+ }
732
+ const outcome = await runValidate(pathOrUrl, options.kind === "fragment" ? "fragment" : "tutor");
733
+ if (options.json) console.log(JSON.stringify(outcome.result, null, 2));
734
+ else console.log(outcome.kind === "fragment" ? formatFragmentResult(outcome.result, pathOrUrl) : formatResult(outcome.result, pathOrUrl));
735
+ process.exitCode = outcome.result.ok ? 0 : 1;
556
736
  });
557
737
  }
558
738
  //#endregion
559
739
  //#region src/main.ts
560
740
  const program = new Command();
561
- program.name("novedu-cli").description("Command-line companion for the Novedu chat app").version("0.1.0");
741
+ program.name("novedu-cli").description("Command-line companion for the Novedu chat app").version("0.2.0");
562
742
  registerValidate(program);
563
743
  program.parseAsync().catch((err) => {
564
744
  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.2.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",