@novedu/cli 0.3.1 → 0.5.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 +15 -8
- package/dist/main.js +322 -21
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
# @novedu/cli
|
|
2
2
|
|
|
3
3
|
Command-line companion for the Novedu chat app (installed command: `novedu-cli`).
|
|
4
|
-
Today it validates
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
its own.
|
|
4
|
+
Today it validates every activity YAML the app accepts — **tutors**, **fragment
|
|
5
|
+
libraries**, **quizzes**, **writing activities**, and **coding activities**; more
|
|
6
|
+
commands will follow. Validating a tutor also fully validates every fragment library
|
|
7
|
+
it references; pass `--kind` to validate any other kind on its own.
|
|
8
8
|
|
|
9
|
-
It reuses the app's exact validation pipeline (`lib/tutors`
|
|
10
|
-
|
|
9
|
+
It reuses the app's exact validation pipeline (`lib/tutors`, `lib/quiz-validate`,
|
|
10
|
+
`lib/writing-validate`, `lib/coding-validate`), so an activity that passes here is
|
|
11
|
+
the same one the app would accept — no separate, drifting rules.
|
|
11
12
|
|
|
12
13
|
## Usage
|
|
13
14
|
|
|
@@ -21,13 +22,19 @@ npx @novedu/cli validate https://raw.githubusercontent.com/Teaching-HTL-Leonding
|
|
|
21
22
|
# Validate a fragment library on its own
|
|
22
23
|
npx @novedu/cli validate ./tutors/simple-fragments.yaml --kind fragment
|
|
23
24
|
|
|
25
|
+
# Validate a quiz, a writing activity, or a coding activity
|
|
26
|
+
npx @novedu/cli validate ./quizzes/sample-quiz.yaml --kind quiz
|
|
27
|
+
npx @novedu/cli validate ./writings/human-animal-short-story.yaml --kind writing
|
|
28
|
+
npx @novedu/cli validate ./coding/beginner-typescript.yaml --kind coding
|
|
29
|
+
|
|
24
30
|
# Machine-readable output (the raw validation result)
|
|
25
31
|
npx @novedu/cli validate ./tutors/simple-tutor.yaml --json
|
|
26
32
|
```
|
|
27
33
|
|
|
28
|
-
`--kind`
|
|
34
|
+
`--kind` accepts `tutor` (default), `fragment`, `quiz`, `writing`, or `coding`; it
|
|
35
|
+
is caller-declared, not auto-detected.
|
|
29
36
|
|
|
30
|
-
Exit code is `0` when the
|
|
37
|
+
Exit code is `0` when the activity is valid and `1` when it has errors, so it works
|
|
31
38
|
as a pre-commit / CI gate.
|
|
32
39
|
|
|
33
40
|
## Development
|
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
|
|
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
|
-
|
|
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
|
|
471
|
-
* standalone fragment checker so
|
|
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,196 @@ async function loadAndCheckFragmentFile(url, fetchImpl, opts = {}) {
|
|
|
616
628
|
return checkFragmentFileValue(yaml.value, url);
|
|
617
629
|
}
|
|
618
630
|
//#endregion
|
|
631
|
+
//#region ../lib/coding-schema.ts
|
|
632
|
+
const CodingYamlSchema = z.strictObject({
|
|
633
|
+
id: z.string().min(1),
|
|
634
|
+
name: z.string().optional(),
|
|
635
|
+
title: z.string().optional(),
|
|
636
|
+
llm: z.strictObject({ model: z.string().min(1) }),
|
|
637
|
+
instructions: z.string().min(1)
|
|
638
|
+
});
|
|
639
|
+
//#endregion
|
|
640
|
+
//#region ../lib/coding-validate.ts
|
|
641
|
+
/**
|
|
642
|
+
* Validate an already-parsed coding value against its schema, then extract metadata.
|
|
643
|
+
* Pure (the parsed value is passed in); `loadAndCheckCoding` wraps it with fetch +
|
|
644
|
+
* YAML parse.
|
|
645
|
+
*/
|
|
646
|
+
function checkCodingValue(parsed, url) {
|
|
647
|
+
const valid = validate(parsed, CodingYamlSchema, "CODING_SCHEMA_ERROR", url);
|
|
648
|
+
if (!valid.ok) return {
|
|
649
|
+
ok: false,
|
|
650
|
+
errors: [valid.error],
|
|
651
|
+
warnings: []
|
|
652
|
+
};
|
|
653
|
+
const coding = valid.data;
|
|
654
|
+
return {
|
|
655
|
+
ok: true,
|
|
656
|
+
codingId: coding.id,
|
|
657
|
+
model: coding.llm.model,
|
|
658
|
+
title: coding.title ?? null,
|
|
659
|
+
warnings: []
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Validate a coding FILE: scheme-gate + fetch + parse (shared `loadYaml`), then the
|
|
664
|
+
* pure `checkCodingValue`. The web app passes the default http(s)-only schemes; the
|
|
665
|
+
* CLI adds `file:` so a local coding YAML on disk validates too.
|
|
666
|
+
*/
|
|
667
|
+
async function loadAndCheckCoding(url, fetchImpl, opts = {}) {
|
|
668
|
+
const yaml = await loadYaml(url, fetchImpl, opts);
|
|
669
|
+
if (!yaml.ok) return {
|
|
670
|
+
ok: false,
|
|
671
|
+
errors: [yaml.error],
|
|
672
|
+
warnings: []
|
|
673
|
+
};
|
|
674
|
+
return checkCodingValue(yaml.value, url);
|
|
675
|
+
}
|
|
676
|
+
//#endregion
|
|
677
|
+
//#region ../lib/quiz-schema.ts
|
|
678
|
+
/** An optional content image attached to a question (carries no secret). */
|
|
679
|
+
const ImageRefSchema = z.strictObject({
|
|
680
|
+
hosted: z.boolean().optional(),
|
|
681
|
+
src: z.string().min(1),
|
|
682
|
+
alt: z.string().optional(),
|
|
683
|
+
credit: z.string().optional()
|
|
684
|
+
});
|
|
685
|
+
/**
|
|
686
|
+
* One question. `id` keys the per-question stats (must be unique — see
|
|
687
|
+
* `lib/quiz-validate.ts`); `question` is the Markdown shown to the student;
|
|
688
|
+
* `evaluation` is the server-only grading prompt.
|
|
689
|
+
*/
|
|
690
|
+
const QuizQuestionSchema = z.strictObject({
|
|
691
|
+
id: z.string().min(1),
|
|
692
|
+
title: z.string().optional(),
|
|
693
|
+
question: z.string().min(1),
|
|
694
|
+
evaluation: z.string().min(1),
|
|
695
|
+
image: ImageRefSchema.optional()
|
|
696
|
+
});
|
|
697
|
+
const QuizYamlSchema = z.strictObject({
|
|
698
|
+
id: z.string().min(1),
|
|
699
|
+
name: z.string().optional(),
|
|
700
|
+
title: z.string().optional(),
|
|
701
|
+
description: z.string().optional(),
|
|
702
|
+
anonymous: z.boolean().optional(),
|
|
703
|
+
shuffle: z.boolean().optional(),
|
|
704
|
+
llm: z.strictObject({ model: z.string().min(1) }),
|
|
705
|
+
discussion: z.strictObject({ instructions: z.string().min(1) }).optional(),
|
|
706
|
+
questions: z.array(QuizQuestionSchema).min(1)
|
|
707
|
+
});
|
|
708
|
+
//#endregion
|
|
709
|
+
//#region ../lib/quiz-validate.ts
|
|
710
|
+
/** Quizzes default to anonymous — answers are recorded for stats but not attributed. */
|
|
711
|
+
const DEFAULT_ANONYMOUS$1 = true;
|
|
712
|
+
/** Question ids declared on more than one question (the per-question stats key). */
|
|
713
|
+
function findDuplicateQuestionIds(quiz) {
|
|
714
|
+
const errors = [];
|
|
715
|
+
const seen = /* @__PURE__ */ new Set();
|
|
716
|
+
for (const question of quiz.questions) {
|
|
717
|
+
if (seen.has(question.id)) {
|
|
718
|
+
errors.push(error("DUPLICATE_QUIZ_QUESTION_ID", `Question id "${question.id}" is declared more than once`, { questionId: question.id }));
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
seen.add(question.id);
|
|
722
|
+
}
|
|
723
|
+
return errors;
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Validate an already-parsed quiz value: schema → unique question ids → metadata.
|
|
727
|
+
* Pure (the parsed value is passed in); `loadAndCheckQuiz` wraps it with fetch +
|
|
728
|
+
* YAML parse.
|
|
729
|
+
*/
|
|
730
|
+
function checkQuizValue(parsed, url) {
|
|
731
|
+
const valid = validate(parsed, QuizYamlSchema, "QUIZ_SCHEMA_ERROR", url);
|
|
732
|
+
if (!valid.ok) return {
|
|
733
|
+
ok: false,
|
|
734
|
+
errors: [valid.error],
|
|
735
|
+
warnings: []
|
|
736
|
+
};
|
|
737
|
+
const quiz = valid.data;
|
|
738
|
+
const errors = findDuplicateQuestionIds(quiz);
|
|
739
|
+
if (errors.length > 0) return {
|
|
740
|
+
ok: false,
|
|
741
|
+
errors,
|
|
742
|
+
warnings: []
|
|
743
|
+
};
|
|
744
|
+
return {
|
|
745
|
+
ok: true,
|
|
746
|
+
quizId: quiz.id,
|
|
747
|
+
model: quiz.llm.model,
|
|
748
|
+
questionCount: quiz.questions.length,
|
|
749
|
+
anonymous: quiz.anonymous ?? DEFAULT_ANONYMOUS$1,
|
|
750
|
+
title: quiz.title ?? null,
|
|
751
|
+
warnings: []
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
/**
|
|
755
|
+
* Validate a quiz FILE: scheme-gate + fetch + parse (shared `loadYaml`), then the
|
|
756
|
+
* pure `checkQuizValue`. The web app passes the default http(s)-only schemes; the
|
|
757
|
+
* CLI adds `file:` so a local quiz YAML on disk validates too.
|
|
758
|
+
*/
|
|
759
|
+
async function loadAndCheckQuiz(url, fetchImpl, opts = {}) {
|
|
760
|
+
const yaml = await loadYaml(url, fetchImpl, opts);
|
|
761
|
+
if (!yaml.ok) return {
|
|
762
|
+
ok: false,
|
|
763
|
+
errors: [yaml.error],
|
|
764
|
+
warnings: []
|
|
765
|
+
};
|
|
766
|
+
return checkQuizValue(yaml.value, url);
|
|
767
|
+
}
|
|
768
|
+
//#endregion
|
|
769
|
+
//#region ../lib/writing-schema.ts
|
|
770
|
+
const WritingYamlSchema = z.strictObject({
|
|
771
|
+
id: z.string().min(1),
|
|
772
|
+
name: z.string().optional(),
|
|
773
|
+
title: z.string().optional(),
|
|
774
|
+
description: z.string().optional(),
|
|
775
|
+
anonymous: z.boolean().optional(),
|
|
776
|
+
llm: z.strictObject({ model: z.string().min(1) }),
|
|
777
|
+
instructions: z.string().min(1),
|
|
778
|
+
placeholder: z.string().optional()
|
|
779
|
+
});
|
|
780
|
+
//#endregion
|
|
781
|
+
//#region ../lib/writing-validate.ts
|
|
782
|
+
/** Writing DIVERGES from tutor/quiz: it defaults to attributed (`anonymous: false`). */
|
|
783
|
+
const DEFAULT_ANONYMOUS = false;
|
|
784
|
+
/**
|
|
785
|
+
* Validate an already-parsed writing value against its schema, then extract metadata.
|
|
786
|
+
* Pure (the parsed value is passed in); `loadAndCheckWriting` wraps it with fetch +
|
|
787
|
+
* YAML parse.
|
|
788
|
+
*/
|
|
789
|
+
function checkWritingValue(parsed, url) {
|
|
790
|
+
const valid = validate(parsed, WritingYamlSchema, "WRITING_SCHEMA_ERROR", url);
|
|
791
|
+
if (!valid.ok) return {
|
|
792
|
+
ok: false,
|
|
793
|
+
errors: [valid.error],
|
|
794
|
+
warnings: []
|
|
795
|
+
};
|
|
796
|
+
const writing = valid.data;
|
|
797
|
+
return {
|
|
798
|
+
ok: true,
|
|
799
|
+
writingId: writing.id,
|
|
800
|
+
model: writing.llm.model,
|
|
801
|
+
anonymous: writing.anonymous ?? DEFAULT_ANONYMOUS,
|
|
802
|
+
title: writing.title ?? null,
|
|
803
|
+
warnings: []
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* Validate a writing FILE: scheme-gate + fetch + parse (shared `loadYaml`), then the
|
|
808
|
+
* pure `checkWritingValue`. The web app passes the default http(s)-only schemes; the
|
|
809
|
+
* CLI adds `file:` so a local writing YAML on disk validates too.
|
|
810
|
+
*/
|
|
811
|
+
async function loadAndCheckWriting(url, fetchImpl, opts = {}) {
|
|
812
|
+
const yaml = await loadYaml(url, fetchImpl, opts);
|
|
813
|
+
if (!yaml.ok) return {
|
|
814
|
+
ok: false,
|
|
815
|
+
errors: [yaml.error],
|
|
816
|
+
warnings: []
|
|
817
|
+
};
|
|
818
|
+
return checkWritingValue(yaml.value, url);
|
|
819
|
+
}
|
|
820
|
+
//#endregion
|
|
619
821
|
//#region src/file-fetcher.ts
|
|
620
822
|
const cliFetcher = async (url) => {
|
|
621
823
|
if (url.startsWith("file:")) try {
|
|
@@ -647,6 +849,7 @@ function context(item) {
|
|
|
647
849
|
const parts = [];
|
|
648
850
|
if (item.fileAlias) parts.push(`file=${item.fileAlias}`);
|
|
649
851
|
if (item.fragmentId) parts.push(`fragment=${item.fragmentId}`);
|
|
852
|
+
if ("questionId" in item && item.questionId) parts.push(`question=${item.questionId}`);
|
|
650
853
|
if (item.variable) parts.push(`variable=${item.variable}`);
|
|
651
854
|
if ("url" in item && item.url) parts.push(`url=${item.url}`);
|
|
652
855
|
if ("expectedType" in item && item.expectedType) parts.push(`expected=${item.expectedType}`);
|
|
@@ -720,8 +923,77 @@ function formatFragmentResult(result, source) {
|
|
|
720
923
|
}
|
|
721
924
|
return lines.join("\n");
|
|
722
925
|
}
|
|
926
|
+
/**
|
|
927
|
+
* Shared tail for the quiz/writing renderers: on failure, the error list (with any
|
|
928
|
+
* flattened Zod issues); plus any warnings on either branch.
|
|
929
|
+
*/
|
|
930
|
+
function renderFailureAndWarnings(result, label, source) {
|
|
931
|
+
const lines = [red(`✘ Invalid ${label}`) + dim(` — ${source}`), ""];
|
|
932
|
+
lines.push(red(`${result.errors.length} error(s):`));
|
|
933
|
+
lines.push(...renderErrors(result.errors));
|
|
934
|
+
if (result.warnings.length) {
|
|
935
|
+
lines.push("");
|
|
936
|
+
lines.push(yellow(`${result.warnings.length} warning(s):`));
|
|
937
|
+
lines.push(...renderWarnings(result.warnings));
|
|
938
|
+
}
|
|
939
|
+
return lines.join("\n");
|
|
940
|
+
}
|
|
941
|
+
/** Renderer for a quiz check (`--kind quiz`). */
|
|
942
|
+
function formatQuizResult(result, source) {
|
|
943
|
+
if (!result.ok) return renderFailureAndWarnings(result, "quiz", source);
|
|
944
|
+
const lines = [green(`✔ Valid quiz`) + dim(` — ${source}`)];
|
|
945
|
+
lines.push(` id: ${result.quizId}`);
|
|
946
|
+
lines.push(` model: ${result.model}`);
|
|
947
|
+
lines.push(` questions: ${result.questionCount} anonymous: ${result.anonymous}`);
|
|
948
|
+
if (result.warnings.length) {
|
|
949
|
+
lines.push("");
|
|
950
|
+
lines.push(yellow(`${result.warnings.length} warning(s):`));
|
|
951
|
+
lines.push(...renderWarnings(result.warnings));
|
|
952
|
+
}
|
|
953
|
+
return lines.join("\n");
|
|
954
|
+
}
|
|
955
|
+
/** Renderer for a writing-activity check (`--kind writing`). */
|
|
956
|
+
function formatWritingResult(result, source) {
|
|
957
|
+
if (!result.ok) return renderFailureAndWarnings(result, "writing activity", source);
|
|
958
|
+
const lines = [green(`✔ Valid writing activity`) + dim(` — ${source}`)];
|
|
959
|
+
lines.push(` id: ${result.writingId}`);
|
|
960
|
+
lines.push(` model: ${result.model}`);
|
|
961
|
+
lines.push(` anonymous: ${result.anonymous}`);
|
|
962
|
+
if (result.warnings.length) {
|
|
963
|
+
lines.push("");
|
|
964
|
+
lines.push(yellow(`${result.warnings.length} warning(s):`));
|
|
965
|
+
lines.push(...renderWarnings(result.warnings));
|
|
966
|
+
}
|
|
967
|
+
return lines.join("\n");
|
|
968
|
+
}
|
|
969
|
+
/**
|
|
970
|
+
* Renderer for a coding-activity check (`--kind coding`). Coding is ALWAYS anonymous
|
|
971
|
+
* (the API path carries no per-student identity), so — unlike quiz/writing — that is
|
|
972
|
+
* shown as a fixed note, not a per-file value.
|
|
973
|
+
*/
|
|
974
|
+
function formatCodingResult(result, source) {
|
|
975
|
+
if (!result.ok) return renderFailureAndWarnings(result, "coding activity", source);
|
|
976
|
+
const lines = [green(`✔ Valid coding activity`) + dim(` — ${source}`)];
|
|
977
|
+
lines.push(` id: ${result.codingId}`);
|
|
978
|
+
lines.push(` model: ${result.model}`);
|
|
979
|
+
lines.push(` anonymous: true ${dim("(always — the API path carries no identity)")}`);
|
|
980
|
+
if (result.warnings.length) {
|
|
981
|
+
lines.push("");
|
|
982
|
+
lines.push(yellow(`${result.warnings.length} warning(s):`));
|
|
983
|
+
lines.push(...renderWarnings(result.warnings));
|
|
984
|
+
}
|
|
985
|
+
return lines.join("\n");
|
|
986
|
+
}
|
|
723
987
|
//#endregion
|
|
724
988
|
//#region src/commands/validate.ts
|
|
989
|
+
/** Every kind the `--kind` flag accepts (used for the option help + guard). */
|
|
990
|
+
const VALIDATE_KINDS = [
|
|
991
|
+
"tutor",
|
|
992
|
+
"fragment",
|
|
993
|
+
"quiz",
|
|
994
|
+
"writing",
|
|
995
|
+
"coding"
|
|
996
|
+
];
|
|
725
997
|
/**
|
|
726
998
|
* Turn the CLI argument into a URL the tutor core understands: an http(s) URL is
|
|
727
999
|
* used as-is; anything else is treated as a filesystem path and converted to an
|
|
@@ -745,20 +1017,34 @@ function runValidate(pathOrUrl, kind) {
|
|
|
745
1017
|
"https:",
|
|
746
1018
|
"file:"
|
|
747
1019
|
];
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
allowedSchemes
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
result
|
|
758
|
-
|
|
1020
|
+
switch (kind) {
|
|
1021
|
+
case "fragment": return loadAndCheckFragmentFile(url, cliFetcher, { allowedSchemes }).then((result) => ({
|
|
1022
|
+
kind,
|
|
1023
|
+
result
|
|
1024
|
+
}));
|
|
1025
|
+
case "quiz": return loadAndCheckQuiz(url, cliFetcher, { allowedSchemes }).then((result) => ({
|
|
1026
|
+
kind,
|
|
1027
|
+
result
|
|
1028
|
+
}));
|
|
1029
|
+
case "writing": return loadAndCheckWriting(url, cliFetcher, { allowedSchemes }).then((result) => ({
|
|
1030
|
+
kind,
|
|
1031
|
+
result
|
|
1032
|
+
}));
|
|
1033
|
+
case "coding": return loadAndCheckCoding(url, cliFetcher, { allowedSchemes }).then((result) => ({
|
|
1034
|
+
kind,
|
|
1035
|
+
result
|
|
1036
|
+
}));
|
|
1037
|
+
default: return loadAndBuildTutorPrompt(url, cliFetcher, {
|
|
1038
|
+
allowedSchemes,
|
|
1039
|
+
validateLibraries: true
|
|
1040
|
+
}).then((result) => ({
|
|
1041
|
+
kind,
|
|
1042
|
+
result
|
|
1043
|
+
}));
|
|
1044
|
+
}
|
|
759
1045
|
}
|
|
760
1046
|
function registerValidate(program) {
|
|
761
|
-
program.command("validate").description("Validate a tutor
|
|
1047
|
+
program.command("validate").description("Validate a tutor (default), fragment library, quiz, writing or coding YAML by local path or public http(s) URL").argument("<pathOrUrl>", "path to a tutor, fragment, quiz, writing or coding 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
1048
|
Examples:
|
|
763
1049
|
# Validate a tutor (also strict-renders every fragment in every referenced library)
|
|
764
1050
|
$ novedu-cli validate ./tutors/my-tutor.yaml
|
|
@@ -766,19 +1052,34 @@ Examples:
|
|
|
766
1052
|
# Validate a fragment library on its own
|
|
767
1053
|
$ novedu-cli validate ./tutors/my-fragments.yaml --kind fragment
|
|
768
1054
|
|
|
1055
|
+
# Validate a quiz, a writing activity, or a coding activity
|
|
1056
|
+
$ novedu-cli validate ./quizzes/my-quiz.yaml --kind quiz
|
|
1057
|
+
$ novedu-cli validate ./writings/my-writing.yaml --kind writing
|
|
1058
|
+
$ novedu-cli validate ./coding/my-coding.yaml --kind coding
|
|
1059
|
+
|
|
769
1060
|
# Machine-readable output for CI
|
|
770
1061
|
$ novedu-cli validate https://example.com/tutor.yaml --json`).action(async (pathOrUrl, options) => {
|
|
771
|
-
if (options.kind !== void 0 &&
|
|
772
|
-
console.error(`Invalid --kind "${options.kind}": expected "
|
|
1062
|
+
if (options.kind !== void 0 && !VALIDATE_KINDS.includes(options.kind)) {
|
|
1063
|
+
console.error(`Invalid --kind "${options.kind}": expected ${VALIDATE_KINDS.map((k) => `"${k}"`).join(", ")}.`);
|
|
773
1064
|
process.exitCode = 1;
|
|
774
1065
|
return;
|
|
775
1066
|
}
|
|
776
|
-
const outcome = await runValidate(pathOrUrl, options.kind
|
|
1067
|
+
const outcome = await runValidate(pathOrUrl, options.kind ?? "tutor");
|
|
777
1068
|
if (options.json) console.log(JSON.stringify(outcome.result, null, 2));
|
|
778
|
-
else console.log(
|
|
1069
|
+
else console.log(formatOutcome(outcome, pathOrUrl));
|
|
779
1070
|
process.exitCode = outcome.result.ok ? 0 : 1;
|
|
780
1071
|
});
|
|
781
1072
|
}
|
|
1073
|
+
/** Pick the formatter for the outcome's kind (each result type has its own renderer). */
|
|
1074
|
+
function formatOutcome(outcome, source) {
|
|
1075
|
+
switch (outcome.kind) {
|
|
1076
|
+
case "fragment": return formatFragmentResult(outcome.result, source);
|
|
1077
|
+
case "quiz": return formatQuizResult(outcome.result, source);
|
|
1078
|
+
case "writing": return formatWritingResult(outcome.result, source);
|
|
1079
|
+
case "coding": return formatCodingResult(outcome.result, source);
|
|
1080
|
+
default: return formatResult(outcome.result, source);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
782
1083
|
//#endregion
|
|
783
1084
|
//#region src/main.ts
|
|
784
1085
|
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.
|
|
4
|
-
"description": "Command-line companion for the Novedu chat app. Validates tutor
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Command-line companion for the Novedu chat app. Validates tutor, fragment, quiz, writing and coding YAML definitions (more commands to follow).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -31,6 +31,6 @@
|
|
|
31
31
|
"zod": "^4.4.3"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
|
-
"tsdown": "^0.22.
|
|
34
|
+
"tsdown": "^0.22.3"
|
|
35
35
|
}
|
|
36
36
|
}
|