@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.
- package/dist/main.js +206 -26
- 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
|
-
|
|
356
|
-
|
|
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
|
-
|
|
367
|
-
warnings
|
|
462
|
+
error: error("INVALID_URL", `Provide a valid ${allowedSchemes.map((s) => s.replace(/:$/, "")).join("/")} URL`, { url })
|
|
368
463
|
};
|
|
369
|
-
const
|
|
370
|
-
if (!
|
|
464
|
+
const fetched = await fetchText(url, fetchImpl);
|
|
465
|
+
if (!fetched.ok) return {
|
|
371
466
|
ok: false,
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
555
|
+
const preAssemblyErrors = [...libraryErrors, ...consistency.errors];
|
|
556
|
+
if (preAssemblyErrors.length > 0) return {
|
|
436
557
|
ok: false,
|
|
437
|
-
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
|
|
539
|
-
*
|
|
540
|
-
*
|
|
541
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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.
|
|
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.
|
|
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",
|