@novedu/cli 0.3.1 → 0.4.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 +251 -21
  2. package/package.json +2 -2
package/dist/main.js CHANGED
@@ -45,7 +45,7 @@ function warning(code, message, extra = {}) {
45
45
  * Flattens a treeified Zod error into `path: message` lines so a generic
46
46
  * "Document does not match the expected structure" becomes actionable — e.g.
47
47
  * `Unrecognized key: "nae"` and `name: Invalid input: expected string`.
48
- * Framework-agnostic: shared by the web UI (`ErrorList`), the share-tutor
48
+ * Framework-agnostic: shared by the web UI (`ErrorList`), the tutor-code
49
49
  * action, and the CLI formatter, so a schema error reads the same everywhere.
50
50
  */
51
51
  function formatZodIssues(zodIssues) {
@@ -429,6 +429,18 @@ function checkFragmentFileValue(parsed, url) {
429
429
  };
430
430
  }
431
431
  //#endregion
432
+ //#region ../lib/relative-url.ts
433
+ /**
434
+ * Resolve a reference to an absolute URL. An absolute http(s) ref is used as-is;
435
+ * anything else is treated as relative to `baseUrl` — standard URL resolution drops
436
+ * the base's filename and appends the relative path (so `./` / `../` segments work too).
437
+ * Throws if a relative ref is unparseable.
438
+ */
439
+ function resolveRelativeUrl(ref, baseUrl) {
440
+ if (/^https?:\/\//i.test(ref)) return ref;
441
+ return new URL(ref, baseUrl).href;
442
+ }
443
+ //#endregion
432
444
  //#region ../lib/tutors/load.ts
433
445
  /**
434
446
  * Resolve a fragment-file reference to an absolute URL. An absolute http(s) ref is used
@@ -439,8 +451,7 @@ function checkFragmentFileValue(parsed, url) {
439
451
  * already guarantees the only inputs here are http(s) URLs or relative paths.
440
452
  */
441
453
  function resolveFragmentUrl(ref, tutorUrl) {
442
- if (/^https?:\/\//i.test(ref)) return ref;
443
- return new URL(ref, tutorUrl).href;
454
+ return resolveRelativeUrl(ref, tutorUrl);
444
455
  }
445
456
  async function fetchText(url, fetchImpl) {
446
457
  try {
@@ -467,8 +478,9 @@ const DEFAULT_ALLOWED_SCHEMES = ["http:", "https:"];
467
478
  /**
468
479
  * The shared front of every load: enforce the URL scheme allow-list (SSRF guard),
469
480
  * fetch the document, and parse it as YAML — returning the parsed-but-not-yet-schema-
470
- * validated value or the first structured error. Reused by the tutor builder and the
471
- * standalone fragment checker so both gate schemes identically.
481
+ * validated value or the first structured error. Reused by the tutor builder, the
482
+ * standalone fragment checker, and the quiz/writing validators so they all gate
483
+ * schemes identically.
472
484
  */
473
485
  async function loadYaml(url, fetchImpl, opts = {}) {
474
486
  const allowedSchemes = opts.allowedSchemes ?? DEFAULT_ALLOWED_SCHEMES;
@@ -616,6 +628,150 @@ async function loadAndCheckFragmentFile(url, fetchImpl, opts = {}) {
616
628
  return checkFragmentFileValue(yaml.value, url);
617
629
  }
618
630
  //#endregion
631
+ //#region ../lib/quiz-schema.ts
632
+ /** An optional content image attached to a question (carries no secret). */
633
+ const ImageRefSchema = z.strictObject({
634
+ hosted: z.boolean().optional(),
635
+ src: z.string().min(1),
636
+ alt: z.string().optional(),
637
+ credit: z.string().optional()
638
+ });
639
+ /**
640
+ * One question. `id` keys the per-question stats (must be unique — see
641
+ * `lib/quiz-validate.ts`); `question` is the Markdown shown to the student;
642
+ * `evaluation` is the server-only grading prompt.
643
+ */
644
+ const QuizQuestionSchema = z.strictObject({
645
+ id: z.string().min(1),
646
+ title: z.string().optional(),
647
+ question: z.string().min(1),
648
+ evaluation: z.string().min(1),
649
+ image: ImageRefSchema.optional()
650
+ });
651
+ const QuizYamlSchema = z.strictObject({
652
+ id: z.string().min(1),
653
+ name: z.string().optional(),
654
+ title: z.string().optional(),
655
+ description: z.string().optional(),
656
+ anonymous: z.boolean().optional(),
657
+ shuffle: z.boolean().optional(),
658
+ llm: z.strictObject({ model: z.string().min(1) }),
659
+ discussion: z.strictObject({ instructions: z.string().min(1) }).optional(),
660
+ questions: z.array(QuizQuestionSchema).min(1)
661
+ });
662
+ //#endregion
663
+ //#region ../lib/quiz-validate.ts
664
+ /** Quizzes default to anonymous — answers are recorded for stats but not attributed. */
665
+ const DEFAULT_ANONYMOUS$1 = true;
666
+ /** Question ids declared on more than one question (the per-question stats key). */
667
+ function findDuplicateQuestionIds(quiz) {
668
+ const errors = [];
669
+ const seen = /* @__PURE__ */ new Set();
670
+ for (const question of quiz.questions) {
671
+ if (seen.has(question.id)) {
672
+ errors.push(error("DUPLICATE_QUIZ_QUESTION_ID", `Question id "${question.id}" is declared more than once`, { questionId: question.id }));
673
+ continue;
674
+ }
675
+ seen.add(question.id);
676
+ }
677
+ return errors;
678
+ }
679
+ /**
680
+ * Validate an already-parsed quiz value: schema → unique question ids → metadata.
681
+ * Pure (the parsed value is passed in); `loadAndCheckQuiz` wraps it with fetch +
682
+ * YAML parse.
683
+ */
684
+ function checkQuizValue(parsed, url) {
685
+ const valid = validate(parsed, QuizYamlSchema, "QUIZ_SCHEMA_ERROR", url);
686
+ if (!valid.ok) return {
687
+ ok: false,
688
+ errors: [valid.error],
689
+ warnings: []
690
+ };
691
+ const quiz = valid.data;
692
+ const errors = findDuplicateQuestionIds(quiz);
693
+ if (errors.length > 0) return {
694
+ ok: false,
695
+ errors,
696
+ warnings: []
697
+ };
698
+ return {
699
+ ok: true,
700
+ quizId: quiz.id,
701
+ model: quiz.llm.model,
702
+ questionCount: quiz.questions.length,
703
+ anonymous: quiz.anonymous ?? DEFAULT_ANONYMOUS$1,
704
+ title: quiz.title ?? null,
705
+ warnings: []
706
+ };
707
+ }
708
+ /**
709
+ * Validate a quiz FILE: scheme-gate + fetch + parse (shared `loadYaml`), then the
710
+ * pure `checkQuizValue`. The web app passes the default http(s)-only schemes; the
711
+ * CLI adds `file:` so a local quiz YAML on disk validates too.
712
+ */
713
+ async function loadAndCheckQuiz(url, fetchImpl, opts = {}) {
714
+ const yaml = await loadYaml(url, fetchImpl, opts);
715
+ if (!yaml.ok) return {
716
+ ok: false,
717
+ errors: [yaml.error],
718
+ warnings: []
719
+ };
720
+ return checkQuizValue(yaml.value, url);
721
+ }
722
+ //#endregion
723
+ //#region ../lib/writing-schema.ts
724
+ const WritingYamlSchema = z.strictObject({
725
+ id: z.string().min(1),
726
+ name: z.string().optional(),
727
+ title: z.string().optional(),
728
+ description: z.string().optional(),
729
+ anonymous: z.boolean().optional(),
730
+ llm: z.strictObject({ model: z.string().min(1) }),
731
+ instructions: z.string().min(1),
732
+ placeholder: z.string().optional()
733
+ });
734
+ //#endregion
735
+ //#region ../lib/writing-validate.ts
736
+ /** Writing DIVERGES from tutor/quiz: it defaults to attributed (`anonymous: false`). */
737
+ const DEFAULT_ANONYMOUS = false;
738
+ /**
739
+ * Validate an already-parsed writing value against its schema, then extract metadata.
740
+ * Pure (the parsed value is passed in); `loadAndCheckWriting` wraps it with fetch +
741
+ * YAML parse.
742
+ */
743
+ function checkWritingValue(parsed, url) {
744
+ const valid = validate(parsed, WritingYamlSchema, "WRITING_SCHEMA_ERROR", url);
745
+ if (!valid.ok) return {
746
+ ok: false,
747
+ errors: [valid.error],
748
+ warnings: []
749
+ };
750
+ const writing = valid.data;
751
+ return {
752
+ ok: true,
753
+ writingId: writing.id,
754
+ model: writing.llm.model,
755
+ anonymous: writing.anonymous ?? DEFAULT_ANONYMOUS,
756
+ title: writing.title ?? null,
757
+ warnings: []
758
+ };
759
+ }
760
+ /**
761
+ * Validate a writing FILE: scheme-gate + fetch + parse (shared `loadYaml`), then the
762
+ * pure `checkWritingValue`. The web app passes the default http(s)-only schemes; the
763
+ * CLI adds `file:` so a local writing YAML on disk validates too.
764
+ */
765
+ async function loadAndCheckWriting(url, fetchImpl, opts = {}) {
766
+ const yaml = await loadYaml(url, fetchImpl, opts);
767
+ if (!yaml.ok) return {
768
+ ok: false,
769
+ errors: [yaml.error],
770
+ warnings: []
771
+ };
772
+ return checkWritingValue(yaml.value, url);
773
+ }
774
+ //#endregion
619
775
  //#region src/file-fetcher.ts
620
776
  const cliFetcher = async (url) => {
621
777
  if (url.startsWith("file:")) try {
@@ -647,6 +803,7 @@ function context(item) {
647
803
  const parts = [];
648
804
  if (item.fileAlias) parts.push(`file=${item.fileAlias}`);
649
805
  if (item.fragmentId) parts.push(`fragment=${item.fragmentId}`);
806
+ if ("questionId" in item && item.questionId) parts.push(`question=${item.questionId}`);
650
807
  if (item.variable) parts.push(`variable=${item.variable}`);
651
808
  if ("url" in item && item.url) parts.push(`url=${item.url}`);
652
809
  if ("expectedType" in item && item.expectedType) parts.push(`expected=${item.expectedType}`);
@@ -720,8 +877,58 @@ function formatFragmentResult(result, source) {
720
877
  }
721
878
  return lines.join("\n");
722
879
  }
880
+ /**
881
+ * Shared tail for the quiz/writing renderers: on failure, the error list (with any
882
+ * flattened Zod issues); plus any warnings on either branch.
883
+ */
884
+ function renderFailureAndWarnings(result, label, source) {
885
+ const lines = [red(`✘ Invalid ${label}`) + dim(` — ${source}`), ""];
886
+ lines.push(red(`${result.errors.length} error(s):`));
887
+ lines.push(...renderErrors(result.errors));
888
+ if (result.warnings.length) {
889
+ lines.push("");
890
+ lines.push(yellow(`${result.warnings.length} warning(s):`));
891
+ lines.push(...renderWarnings(result.warnings));
892
+ }
893
+ return lines.join("\n");
894
+ }
895
+ /** Renderer for a quiz check (`--kind quiz`). */
896
+ function formatQuizResult(result, source) {
897
+ if (!result.ok) return renderFailureAndWarnings(result, "quiz", source);
898
+ const lines = [green(`✔ Valid quiz`) + dim(` — ${source}`)];
899
+ lines.push(` id: ${result.quizId}`);
900
+ lines.push(` model: ${result.model}`);
901
+ lines.push(` questions: ${result.questionCount} anonymous: ${result.anonymous}`);
902
+ if (result.warnings.length) {
903
+ lines.push("");
904
+ lines.push(yellow(`${result.warnings.length} warning(s):`));
905
+ lines.push(...renderWarnings(result.warnings));
906
+ }
907
+ return lines.join("\n");
908
+ }
909
+ /** Renderer for a writing-activity check (`--kind writing`). */
910
+ function formatWritingResult(result, source) {
911
+ if (!result.ok) return renderFailureAndWarnings(result, "writing activity", source);
912
+ const lines = [green(`✔ Valid writing activity`) + dim(` — ${source}`)];
913
+ lines.push(` id: ${result.writingId}`);
914
+ lines.push(` model: ${result.model}`);
915
+ lines.push(` anonymous: ${result.anonymous}`);
916
+ if (result.warnings.length) {
917
+ lines.push("");
918
+ lines.push(yellow(`${result.warnings.length} warning(s):`));
919
+ lines.push(...renderWarnings(result.warnings));
920
+ }
921
+ return lines.join("\n");
922
+ }
723
923
  //#endregion
724
924
  //#region src/commands/validate.ts
925
+ /** Every kind the `--kind` flag accepts (used for the option help + guard). */
926
+ const VALIDATE_KINDS = [
927
+ "tutor",
928
+ "fragment",
929
+ "quiz",
930
+ "writing"
931
+ ];
725
932
  /**
726
933
  * Turn the CLI argument into a URL the tutor core understands: an http(s) URL is
727
934
  * used as-is; anything else is treated as a filesystem path and converted to an
@@ -745,20 +952,30 @@ function runValidate(pathOrUrl, kind) {
745
952
  "https:",
746
953
  "file:"
747
954
  ];
748
- if (kind === "fragment") return loadAndCheckFragmentFile(url, cliFetcher, { allowedSchemes }).then((result) => ({
749
- kind,
750
- result
751
- }));
752
- return loadAndBuildTutorPrompt(url, cliFetcher, {
753
- allowedSchemes,
754
- validateLibraries: true
755
- }).then((result) => ({
756
- kind,
757
- result
758
- }));
955
+ switch (kind) {
956
+ case "fragment": return loadAndCheckFragmentFile(url, cliFetcher, { allowedSchemes }).then((result) => ({
957
+ kind,
958
+ result
959
+ }));
960
+ case "quiz": return loadAndCheckQuiz(url, cliFetcher, { allowedSchemes }).then((result) => ({
961
+ kind,
962
+ result
963
+ }));
964
+ case "writing": return loadAndCheckWriting(url, cliFetcher, { allowedSchemes }).then((result) => ({
965
+ kind,
966
+ result
967
+ }));
968
+ default: return loadAndBuildTutorPrompt(url, cliFetcher, {
969
+ allowedSchemes,
970
+ validateLibraries: true
971
+ }).then((result) => ({
972
+ kind,
973
+ result
974
+ }));
975
+ }
759
976
  }
760
977
  function registerValidate(program) {
761
- 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", `
978
+ program.command("validate").description("Validate a tutor (default), fragment library, quiz or writing YAML by local path or public http(s) URL").argument("<pathOrUrl>", "path to a tutor, fragment, quiz or writing YAML file, or a public http(s) URL").option("--kind <kind>", `what the file is: ${VALIDATE_KINDS.map((k) => `'${k}'`).join(", ")} ('tutor' is the default)`, "tutor").option("--json", "print the raw validation result as JSON").addHelpText("after", `
762
979
  Examples:
763
980
  # Validate a tutor (also strict-renders every fragment in every referenced library)
764
981
  $ novedu-cli validate ./tutors/my-tutor.yaml
@@ -766,19 +983,32 @@ Examples:
766
983
  # Validate a fragment library on its own
767
984
  $ novedu-cli validate ./tutors/my-fragments.yaml --kind fragment
768
985
 
986
+ # Validate a quiz or a writing activity
987
+ $ novedu-cli validate ./quizzes/my-quiz.yaml --kind quiz
988
+ $ novedu-cli validate ./writings/my-writing.yaml --kind writing
989
+
769
990
  # Machine-readable output for CI
770
991
  $ novedu-cli validate https://example.com/tutor.yaml --json`).action(async (pathOrUrl, options) => {
771
- if (options.kind !== void 0 && options.kind !== "tutor" && options.kind !== "fragment") {
772
- console.error(`Invalid --kind "${options.kind}": expected "tutor" or "fragment".`);
992
+ if (options.kind !== void 0 && !VALIDATE_KINDS.includes(options.kind)) {
993
+ console.error(`Invalid --kind "${options.kind}": expected ${VALIDATE_KINDS.map((k) => `"${k}"`).join(", ")}.`);
773
994
  process.exitCode = 1;
774
995
  return;
775
996
  }
776
- const outcome = await runValidate(pathOrUrl, options.kind === "fragment" ? "fragment" : "tutor");
997
+ const outcome = await runValidate(pathOrUrl, options.kind ?? "tutor");
777
998
  if (options.json) console.log(JSON.stringify(outcome.result, null, 2));
778
- else console.log(outcome.kind === "fragment" ? formatFragmentResult(outcome.result, pathOrUrl) : formatResult(outcome.result, pathOrUrl));
999
+ else console.log(formatOutcome(outcome, pathOrUrl));
779
1000
  process.exitCode = outcome.result.ok ? 0 : 1;
780
1001
  });
781
1002
  }
1003
+ /** Pick the formatter for the outcome's kind (each result type has its own renderer). */
1004
+ function formatOutcome(outcome, source) {
1005
+ switch (outcome.kind) {
1006
+ case "fragment": return formatFragmentResult(outcome.result, source);
1007
+ case "quiz": return formatQuizResult(outcome.result, source);
1008
+ case "writing": return formatWritingResult(outcome.result, source);
1009
+ default: return formatResult(outcome.result, source);
1010
+ }
1011
+ }
782
1012
  //#endregion
783
1013
  //#region src/main.ts
784
1014
  const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@novedu/cli",
3
- "version": "0.3.1",
4
- "description": "Command-line companion for the Novedu chat app. Validates tutor YAML definitions and fragment libraries (more commands to follow).",
3
+ "version": "0.4.0",
4
+ "description": "Command-line companion for the Novedu chat app. Validates tutor, fragment, quiz and writing YAML definitions (more commands to follow).",
5
5
  "type": "module",
6
6
  "repository": {
7
7
  "type": "git",