@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.
- package/dist/main.js +250 -27
- 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
|
-
|
|
356
|
-
|
|
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
|
-
|
|
367
|
-
warnings
|
|
482
|
+
error: error("INVALID_URL", `Provide a valid ${allowedSchemes.map((s) => s.replace(/:$/, "")).join("/")} URL`, { url })
|
|
368
483
|
};
|
|
369
|
-
const
|
|
370
|
-
if (!
|
|
484
|
+
const fetched = await fetchText(url, fetchImpl);
|
|
485
|
+
if (!fetched.ok) return {
|
|
371
486
|
ok: false,
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
575
|
+
const preAssemblyErrors = [...libraryErrors, ...consistency.errors];
|
|
576
|
+
if (preAssemblyErrors.length > 0) return {
|
|
436
577
|
ok: false,
|
|
437
|
-
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
|
-
|
|
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
|
|
539
|
-
*
|
|
540
|
-
*
|
|
541
|
-
*
|
|
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
|
-
|
|
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").
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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.
|
|
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.
|
|
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",
|