@novedu/cli 0.1.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 (3) hide show
  1. package/README.md +36 -0
  2. package/dist/main.js +568 -0
  3. package/package.json +30 -0
package/README.md ADDED
@@ -0,0 +1,36 @@
1
+ # @novedu/cli
2
+
3
+ Command-line companion for the Novedu chat app (installed command: `novedu-cli`).
4
+ Today it validates **tutor YAML** definitions; more commands will follow.
5
+
6
+ It reuses the app's exact validation pipeline (`lib/tutors`), so a tutor that
7
+ passes here is the same tutor the app would accept — no separate, drifting rules.
8
+
9
+ ## Usage
10
+
11
+ ```bash
12
+ # Validate a local file (relative fragment_files resolve from the same folder)
13
+ npx @novedu/cli validate ./tutors/simple-tutor.yaml
14
+
15
+ # Validate a published tutor by URL
16
+ npx @novedu/cli validate https://raw.githubusercontent.com/Teaching-HTL-Leonding/novedu-chat-mvp/refs/heads/main/tutors/simple-tutor.yaml
17
+
18
+ # Machine-readable output (the raw validation result)
19
+ npx @novedu/cli validate ./tutors/simple-tutor.yaml --json
20
+ ```
21
+
22
+ Exit code is `0` when the tutor is valid and `1` when it has errors, so it works
23
+ as a pre-commit / CI gate.
24
+
25
+ ## Development
26
+
27
+ The CLI lives in the app repo as an npm workspace.
28
+
29
+ ```bash
30
+ npm run cli -- validate ./tutors/simple-tutor.yaml # run from source via tsx
31
+ npm run cli:build # bundle to cli/dist via tsdown
32
+ npm run test:cli # build + integration tests (local & live URLs)
33
+ ```
34
+
35
+ The fast in-process unit test (`cli/src/commands/validate.unit.test.ts`) runs in
36
+ CI; the integration tests hit the network and are local-only.
package/dist/main.js ADDED
@@ -0,0 +1,568 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { resolve } from "node:path";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
+ import Handlebars from "handlebars";
6
+ import { parse } from "yaml";
7
+ import { z } from "zod";
8
+ import { readFile } from "node:fs/promises";
9
+ //#region ../lib/tutors/assemble.ts
10
+ const COMPILE_OPTIONS = {
11
+ strict: true,
12
+ noEscape: true
13
+ };
14
+ /**
15
+ * Render each fragment in priority order and append the tutor-specific
16
+ * instructions last (they carry no priority, so "after everything" is the only
17
+ * deterministic position). May throw if a template references a missing variable.
18
+ */
19
+ function assembleSystemPrompt(plan, tutor) {
20
+ const parts = plan.map((fragment) => {
21
+ return Handlebars.compile(fragment.content, COMPILE_OPTIONS)(fragment.variables).trimEnd();
22
+ });
23
+ parts.push(tutor.prompt.tutor_instructions.trimEnd());
24
+ return `${parts.join("\n\n")}\n`;
25
+ }
26
+ //#endregion
27
+ //#region ../lib/tutors/errors.ts
28
+ /** Small helper to build an error object tersely at call sites. */
29
+ function error(code, message, extra = {}) {
30
+ return {
31
+ code,
32
+ message,
33
+ ...extra
34
+ };
35
+ }
36
+ function warning(code, message, extra = {}) {
37
+ return {
38
+ code,
39
+ message,
40
+ ...extra
41
+ };
42
+ }
43
+ //#endregion
44
+ //#region ../lib/tutors/consistency.ts
45
+ /** Compare a supplied value against its declared property type. Returns null when it matches. */
46
+ function typeMismatch(prop, value) {
47
+ const actual = Array.isArray(value) ? "array" : typeof value;
48
+ switch (prop.type) {
49
+ case "string": return typeof value === "string" ? null : {
50
+ expected: "string",
51
+ actual
52
+ };
53
+ case "boolean": return typeof value === "boolean" ? null : {
54
+ expected: "boolean",
55
+ actual
56
+ };
57
+ case "array": return Array.isArray(value) && value.every((v) => typeof v === "string") ? null : {
58
+ expected: "array<string>",
59
+ actual
60
+ };
61
+ }
62
+ }
63
+ function checkConsistency(tutor, fragmentFilesByAlias) {
64
+ const errors = [];
65
+ const warnings = [];
66
+ const aliasCounts = /* @__PURE__ */ new Map();
67
+ for (const ref of tutor.prompt.fragment_files) aliasCounts.set(ref.id, (aliasCounts.get(ref.id) ?? 0) + 1);
68
+ for (const [alias, count] of aliasCounts) if (count > 1) errors.push(error("DUPLICATE_FRAGMENT_FILE_ALIAS", `Fragment-file alias "${alias}" is declared ${count} times`, { fileAlias: alias }));
69
+ const fragmentIndex = /* @__PURE__ */ new Map();
70
+ for (const [alias, file] of fragmentFilesByAlias) {
71
+ const byId = /* @__PURE__ */ new Map();
72
+ for (const frag of file.fragments) {
73
+ if (byId.has(frag.id)) {
74
+ errors.push(error("DUPLICATE_FRAGMENT_ID_IN_FILE", `Fragment "${frag.id}" is declared more than once in file "${alias}"`, {
75
+ fileAlias: alias,
76
+ fragmentId: frag.id
77
+ }));
78
+ continue;
79
+ }
80
+ byId.set(frag.id, frag);
81
+ }
82
+ fragmentIndex.set(alias, byId);
83
+ }
84
+ const resolved = [];
85
+ const seenRefs = /* @__PURE__ */ new Set();
86
+ for (const ref of tutor.prompt.fragments) {
87
+ const refKey = `${ref.file}::${ref.id}`;
88
+ if (seenRefs.has(refKey)) warnings.push(warning("DUPLICATE_FRAGMENT_REFERENCE", `Fragment "${ref.id}" from "${ref.file}" is referenced more than once`, {
89
+ fileAlias: ref.file,
90
+ fragmentId: ref.id
91
+ }));
92
+ seenRefs.add(refKey);
93
+ const byId = fragmentIndex.get(ref.file);
94
+ if (!byId) {
95
+ errors.push(error("UNKNOWN_FRAGMENT_FILE_ALIAS", `Fragment reference uses unknown file alias "${ref.file}"`, {
96
+ fileAlias: ref.file,
97
+ fragmentId: ref.id
98
+ }));
99
+ continue;
100
+ }
101
+ const fragment = byId.get(ref.id);
102
+ if (!fragment) {
103
+ errors.push(error("FRAGMENT_NOT_FOUND", `Fragment "${ref.id}" not found in file "${ref.file}"`, {
104
+ fileAlias: ref.file,
105
+ fragmentId: ref.id
106
+ }));
107
+ continue;
108
+ }
109
+ const variables = ref.variables ?? {};
110
+ const schema = fragment.input_schema;
111
+ const merged = { ...variables };
112
+ if (schema) {
113
+ for (const name of schema.required) if (!(name in variables)) errors.push(error("MISSING_REQUIRED_VARIABLE", `Fragment "${ref.id}" requires variable "${name}", which is not supplied`, {
114
+ fileAlias: ref.file,
115
+ fragmentId: ref.id,
116
+ variable: name
117
+ }));
118
+ for (const [name, value] of Object.entries(variables)) {
119
+ const prop = schema.properties[name];
120
+ if (!prop) {
121
+ warnings.push(warning("UNDECLARED_VARIABLE", `Variable "${name}" supplied to "${ref.id}" is not declared in its input schema`, {
122
+ fileAlias: ref.file,
123
+ fragmentId: ref.id,
124
+ variable: name
125
+ }));
126
+ continue;
127
+ }
128
+ const mismatch = typeMismatch(prop, value);
129
+ if (mismatch) errors.push(error("VARIABLE_TYPE_MISMATCH", `Variable "${name}" of "${ref.id}" should be ${mismatch.expected} but got ${mismatch.actual}`, {
130
+ fileAlias: ref.file,
131
+ fragmentId: ref.id,
132
+ variable: name,
133
+ expectedType: mismatch.expected,
134
+ actualType: mismatch.actual
135
+ }));
136
+ }
137
+ const requiredSet = new Set(schema.required);
138
+ for (const [name, prop] of Object.entries(schema.properties)) {
139
+ if (prop.default === void 0) continue;
140
+ if (requiredSet.has(name)) {
141
+ warnings.push(warning("REQUIRED_PROPERTY_HAS_DEFAULT", `Variable "${name}" of "${ref.id}" is required, so its default is never used`, {
142
+ fileAlias: ref.file,
143
+ fragmentId: ref.id,
144
+ variable: name
145
+ }));
146
+ continue;
147
+ }
148
+ if (!(name in merged)) merged[name] = prop.default;
149
+ }
150
+ } else for (const name of Object.keys(variables)) warnings.push(warning("UNDECLARED_VARIABLE", `Variable "${name}" supplied to "${ref.id}", which declares no input schema`, {
151
+ fileAlias: ref.file,
152
+ fragmentId: ref.id,
153
+ variable: name
154
+ }));
155
+ resolved.push({
156
+ fileAlias: ref.file,
157
+ fragmentId: ref.id,
158
+ priority: fragment.priority,
159
+ content: fragment.content,
160
+ variables: merged
161
+ });
162
+ }
163
+ const plan = [...resolved].sort((a, b) => a.priority - b.priority);
164
+ const priorityOwners = /* @__PURE__ */ new Map();
165
+ for (const r of resolved) {
166
+ const owners = priorityOwners.get(r.priority) ?? [];
167
+ owners.push(r.fragmentId);
168
+ priorityOwners.set(r.priority, owners);
169
+ }
170
+ for (const [priority, owners] of priorityOwners) if (owners.length > 1) errors.push(error("DUPLICATE_PRIORITY", `Priority ${priority} is shared by fragments: ${owners.join(", ")} — ordering is ambiguous`));
171
+ return {
172
+ errors,
173
+ warnings,
174
+ plan
175
+ };
176
+ }
177
+ //#endregion
178
+ //#region ../lib/tutors/fetcher.ts
179
+ const DEFAULT_TIMEOUT_MS = 1e4;
180
+ /** Production fetcher: global `fetch` with an abort-based timeout so a slow host can't hang the request. */
181
+ const defaultFetcher = async (url) => {
182
+ const controller = new AbortController();
183
+ const timeout = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
184
+ try {
185
+ return await fetch(url, {
186
+ signal: controller.signal,
187
+ redirect: "follow"
188
+ });
189
+ } finally {
190
+ clearTimeout(timeout);
191
+ }
192
+ };
193
+ //#endregion
194
+ //#region ../lib/tutors/parse.ts
195
+ /** Parse a YAML document, mapping syntax errors to a structured `YAML_PARSE_ERROR`. */
196
+ function parseYaml(text, url) {
197
+ try {
198
+ return {
199
+ ok: true,
200
+ value: parse(text)
201
+ };
202
+ } catch (e) {
203
+ return {
204
+ ok: false,
205
+ error: error("YAML_PARSE_ERROR", `Invalid YAML: ${e instanceof Error ? e.message : String(e)}`, { url })
206
+ };
207
+ }
208
+ }
209
+ /** Validate an already-parsed value against a Zod schema, attaching treeified issues on failure. */
210
+ function validate(value, schema, code, url) {
211
+ const result = schema.safeParse(value);
212
+ if (result.success) return {
213
+ ok: true,
214
+ data: result.data
215
+ };
216
+ return {
217
+ ok: false,
218
+ error: error(code, "Document does not match the expected structure", {
219
+ url,
220
+ zodIssues: z.treeifyError(result.error)
221
+ })
222
+ };
223
+ }
224
+ //#endregion
225
+ //#region ../lib/tutors/schemas.ts
226
+ /**
227
+ * A fragment-file reference: either an absolute http(s) URL or a relative path that
228
+ * `load.ts` resolves against the tutor YAML's own URL. We reject any *other* absolute
229
+ * scheme (`ftp:`, `mailto:`, …) so a typo can't smuggle in a non-http(s) target — the
230
+ * refine reads as "if it carries a URI scheme at all, that scheme must be http(s)".
231
+ * Strings without a scheme (relative paths) pass through and are resolved at load time.
232
+ */
233
+ const FragmentUrlRef = z.string().min(1).refine((u) => !/^[a-z][a-z0-9+.-]*:/i.test(u) || /^https?:\/\//i.test(u), { message: "Must be an http(s) URL or a relative path" });
234
+ /**
235
+ * A declared property is a string, a boolean, or an array of strings. Each may carry an
236
+ * optional `default`, typed to match its `type` (a string default on a boolean property
237
+ * is a schema error). When the tutor omits the variable, the default is used; supplying
238
+ * a value overrides it. See `consistency.ts` for where defaults are injected.
239
+ */
240
+ const PropertySchema = z.discriminatedUnion("type", [
241
+ z.strictObject({
242
+ type: z.literal("string"),
243
+ default: z.string().optional()
244
+ }),
245
+ z.strictObject({
246
+ type: z.literal("boolean"),
247
+ default: z.boolean().optional()
248
+ }),
249
+ z.strictObject({
250
+ type: z.literal("array"),
251
+ items: z.strictObject({ type: z.literal("string") }),
252
+ default: z.array(z.string()).optional()
253
+ })
254
+ ]);
255
+ const InputSchema = z.strictObject({
256
+ type: z.literal("object"),
257
+ required: z.array(z.string()).default([]),
258
+ properties: z.record(z.string(), PropertySchema).default({})
259
+ });
260
+ const ClassificationSchema = z.strictObject({
261
+ type: z.string(),
262
+ override_allowed: z.boolean().optional()
263
+ });
264
+ const FragmentSchema = z.strictObject({
265
+ id: z.string(),
266
+ version: z.number(),
267
+ priority: z.number(),
268
+ input_schema: InputSchema.optional(),
269
+ classification: ClassificationSchema.optional(),
270
+ content: z.string()
271
+ });
272
+ const FragmentFileSchema = z.strictObject({
273
+ id: z.string(),
274
+ fragments: z.array(FragmentSchema).min(1)
275
+ });
276
+ /** A supplied variable value mirrors what `input_schema` can declare. */
277
+ const VariableValueSchema = z.union([
278
+ z.string(),
279
+ z.boolean(),
280
+ z.array(z.string())
281
+ ]);
282
+ const FragmentFileRefSchema = z.strictObject({
283
+ id: z.string(),
284
+ url: FragmentUrlRef
285
+ });
286
+ const FragmentRefSchema = z.strictObject({
287
+ file: z.string(),
288
+ id: z.string(),
289
+ variables: z.record(z.string(), VariableValueSchema).optional(),
290
+ bind: z.record(z.string(), z.string()).optional(),
291
+ required: z.boolean().optional()
292
+ });
293
+ /**
294
+ * An example question offered to students on the welcome screen: the `title` is
295
+ * the clickable label, the `question` is the full text placed into the chat
296
+ * input on click. Tutors may define any number; the UI samples at most 5.
297
+ */
298
+ const ExampleQuestionSchema = z.strictObject({
299
+ title: z.string().min(1),
300
+ question: z.string().min(1)
301
+ });
302
+ const TutorSchema = z.strictObject({
303
+ id: z.string(),
304
+ name: z.string(),
305
+ title: z.string().optional(),
306
+ description: z.string(),
307
+ exampleQuestions: z.array(ExampleQuestionSchema).optional(),
308
+ anonymous: z.boolean().optional(),
309
+ llm: z.strictObject({
310
+ model: z.string(),
311
+ imageInput: z.boolean().optional()
312
+ }),
313
+ prompt: z.strictObject({
314
+ fragment_files: z.array(FragmentFileRefSchema).default([]),
315
+ fragments: z.array(FragmentRefSchema).default([]),
316
+ tutor_instructions: z.string()
317
+ })
318
+ });
319
+ //#endregion
320
+ //#region ../lib/tutors/load.ts
321
+ /**
322
+ * Resolve a fragment-file reference to an absolute URL. An absolute http(s) ref is used
323
+ * as-is; anything else is treated as relative to the tutor URL — standard URL resolution
324
+ * drops the tutor's filename and appends the relative path (so `general-fragments.yaml`
325
+ * next to `.../tutors/linked-list-tutor.yaml` becomes `.../tutors/general-fragments.yaml`,
326
+ * and `./` / `../` segments work too). Throws if a relative ref is unparseable; the schema
327
+ * already guarantees the only inputs here are http(s) URLs or relative paths.
328
+ */
329
+ function resolveFragmentUrl(ref, tutorUrl) {
330
+ if (/^https?:\/\//i.test(ref)) return ref;
331
+ return new URL(ref, tutorUrl).href;
332
+ }
333
+ async function fetchText(url, fetchImpl) {
334
+ try {
335
+ const res = await fetchImpl(url);
336
+ if (!res.ok) return {
337
+ ok: false,
338
+ error: error("FETCH_FAILED", `Failed to fetch ${url} (HTTP ${res.status})`, {
339
+ url,
340
+ status: res.status
341
+ })
342
+ };
343
+ return {
344
+ ok: true,
345
+ text: await res.text()
346
+ };
347
+ } catch (e) {
348
+ return {
349
+ ok: false,
350
+ error: error("FETCH_FAILED", `Failed to fetch ${url}: ${e instanceof Error ? e.message : String(e)}`, { url })
351
+ };
352
+ }
353
+ }
354
+ const DEFAULT_ALLOWED_SCHEMES = ["http:", "https:"];
355
+ async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
356
+ const warnings = [];
357
+ const allowedSchemes = opts.allowedSchemes ?? DEFAULT_ALLOWED_SCHEMES;
358
+ let scheme;
359
+ try {
360
+ scheme = new URL(url).protocol;
361
+ } catch {
362
+ scheme = "";
363
+ }
364
+ if (!allowedSchemes.includes(scheme)) return {
365
+ ok: false,
366
+ errors: [error("INVALID_URL", `Provide a valid ${allowedSchemes.map((s) => s.replace(/:$/, "")).join("/")} URL`, { url })],
367
+ warnings
368
+ };
369
+ const tutorFetch = await fetchText(url, fetchImpl);
370
+ if (!tutorFetch.ok) return {
371
+ ok: false,
372
+ errors: [tutorFetch.error],
373
+ warnings
374
+ };
375
+ const tutorYaml = parseYaml(tutorFetch.text, url);
376
+ if (!tutorYaml.ok) return {
377
+ ok: false,
378
+ errors: [tutorYaml.error],
379
+ warnings
380
+ };
381
+ const tutorValid = validate(tutorYaml.value, TutorSchema, "TUTOR_SCHEMA_ERROR", url);
382
+ if (!tutorValid.ok) return {
383
+ ok: false,
384
+ errors: [tutorValid.error],
385
+ warnings
386
+ };
387
+ const tutor = tutorValid.data;
388
+ const settled = await Promise.all(tutor.prompt.fragment_files.map(async (ref) => {
389
+ let fragmentUrl;
390
+ try {
391
+ fragmentUrl = resolveFragmentUrl(ref.url, url);
392
+ } catch {
393
+ return {
394
+ alias: ref.id,
395
+ error: error("INVALID_URL", `Invalid fragment URL: ${ref.url}`, {
396
+ url: ref.url,
397
+ fileAlias: ref.id
398
+ })
399
+ };
400
+ }
401
+ const fetched = await fetchText(fragmentUrl, fetchImpl);
402
+ if (!fetched.ok) return {
403
+ alias: ref.id,
404
+ error: fetched.error
405
+ };
406
+ const parsed = parseYaml(fetched.text, fragmentUrl);
407
+ if (!parsed.ok) return {
408
+ alias: ref.id,
409
+ error: parsed.error
410
+ };
411
+ const valid = validate(parsed.value, FragmentFileSchema, "FRAGMENT_FILE_SCHEMA_ERROR", fragmentUrl);
412
+ if (!valid.ok) return {
413
+ alias: ref.id,
414
+ error: {
415
+ ...valid.error,
416
+ fileAlias: ref.id
417
+ }
418
+ };
419
+ return {
420
+ alias: ref.id,
421
+ file: valid.data
422
+ };
423
+ }));
424
+ const fragmentFilesByAlias = /* @__PURE__ */ new Map();
425
+ const fileErrors = [];
426
+ for (const result of settled) if ("error" in result) fileErrors.push(result.error);
427
+ else fragmentFilesByAlias.set(result.alias, result.file);
428
+ if (fileErrors.length > 0) return {
429
+ ok: false,
430
+ errors: fileErrors,
431
+ warnings
432
+ };
433
+ const consistency = checkConsistency(tutor, fragmentFilesByAlias);
434
+ warnings.push(...consistency.warnings);
435
+ if (consistency.errors.length > 0) return {
436
+ ok: false,
437
+ errors: consistency.errors,
438
+ warnings
439
+ };
440
+ try {
441
+ return {
442
+ ok: true,
443
+ prompt: assembleSystemPrompt(consistency.plan, tutor),
444
+ model: tutor.llm.model,
445
+ imageInput: tutor.llm.imageInput ?? true,
446
+ anonymous: tutor.anonymous ?? true,
447
+ title: tutor.title,
448
+ description: tutor.description,
449
+ exampleQuestions: tutor.exampleQuestions ?? [],
450
+ warnings
451
+ };
452
+ } catch (e) {
453
+ return {
454
+ ok: false,
455
+ errors: [error("ASSEMBLY_ERROR", `Failed to render system prompt: ${e instanceof Error ? e.message : String(e)}`)],
456
+ warnings
457
+ };
458
+ }
459
+ }
460
+ //#endregion
461
+ //#region src/file-fetcher.ts
462
+ const cliFetcher = async (url) => {
463
+ if (url.startsWith("file:")) try {
464
+ const text = await readFile(fileURLToPath(url), "utf8");
465
+ return {
466
+ ok: true,
467
+ status: 200,
468
+ text: async () => text
469
+ };
470
+ } catch {
471
+ return {
472
+ ok: false,
473
+ status: 404,
474
+ text: async () => ""
475
+ };
476
+ }
477
+ return defaultFetcher(url);
478
+ };
479
+ //#endregion
480
+ //#region src/format.ts
481
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
482
+ const paint = (code, s) => useColor ? `\x1b[${code}m${s}\x1b[0m` : s;
483
+ const green = (s) => paint("32", s);
484
+ const red = (s) => paint("31", s);
485
+ const yellow = (s) => paint("33", s);
486
+ const dim = (s) => paint("2", s);
487
+ /** Append the context fields an error/warning carries, when present. */
488
+ function context(item) {
489
+ const parts = [];
490
+ if (item.fileAlias) parts.push(`file=${item.fileAlias}`);
491
+ if (item.fragmentId) parts.push(`fragment=${item.fragmentId}`);
492
+ if (item.variable) parts.push(`variable=${item.variable}`);
493
+ if ("url" in item && item.url) parts.push(`url=${item.url}`);
494
+ if ("expectedType" in item && item.expectedType) parts.push(`expected=${item.expectedType}`);
495
+ if ("actualType" in item && item.actualType) parts.push(`actual=${item.actualType}`);
496
+ return parts.length ? dim(` (${parts.join(", ")})`) : "";
497
+ }
498
+ function renderWarnings(warnings) {
499
+ return warnings.map((w) => ` ${yellow("⚠")} ${yellow(w.code)} ${w.message}${context(w)}`);
500
+ }
501
+ function formatResult(result, source) {
502
+ const lines = [];
503
+ if (result.ok) {
504
+ lines.push(green(`✔ Valid tutor`) + dim(` — ${source}`));
505
+ lines.push(` model: ${result.model}`);
506
+ lines.push(` system prompt: ${result.prompt.length} chars`);
507
+ lines.push(` imageInput: ${result.imageInput} anonymous: ${result.anonymous}` + (result.exampleQuestions.length ? ` exampleQuestions: ${result.exampleQuestions.length}` : ""));
508
+ if (result.warnings.length) {
509
+ lines.push("");
510
+ lines.push(yellow(`${result.warnings.length} warning(s):`));
511
+ lines.push(...renderWarnings(result.warnings));
512
+ }
513
+ return lines.join("\n");
514
+ }
515
+ lines.push(red(`✘ Invalid tutor`) + dim(` — ${source}`));
516
+ lines.push("");
517
+ lines.push(red(`${result.errors.length} error(s):`));
518
+ for (const e of result.errors) lines.push(` ${red("✗")} ${red(e.code)} ${e.message}${context(e)}`);
519
+ if (result.warnings.length) {
520
+ lines.push("");
521
+ lines.push(yellow(`${result.warnings.length} warning(s):`));
522
+ lines.push(...renderWarnings(result.warnings));
523
+ }
524
+ return lines.join("\n");
525
+ }
526
+ //#endregion
527
+ //#region src/commands/validate.ts
528
+ /**
529
+ * Turn the CLI argument into a URL the tutor core understands: an http(s) URL is
530
+ * used as-is; anything else is treated as a filesystem path and converted to an
531
+ * absolute `file://` URL.
532
+ */
533
+ function toUrl(pathOrUrl) {
534
+ if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl;
535
+ return pathToFileURL(resolve(pathOrUrl)).href;
536
+ }
537
+ /**
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).
542
+ */
543
+ function runValidate(pathOrUrl) {
544
+ return loadAndBuildTutorPrompt(toUrl(pathOrUrl), cliFetcher, { allowedSchemes: [
545
+ "http:",
546
+ "https:",
547
+ "file:"
548
+ ] });
549
+ }
550
+ 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;
556
+ });
557
+ }
558
+ //#endregion
559
+ //#region src/main.ts
560
+ const program = new Command();
561
+ program.name("novedu-cli").description("Command-line companion for the Novedu chat app").version("0.1.0");
562
+ registerValidate(program);
563
+ program.parseAsync().catch((err) => {
564
+ console.error(err instanceof Error ? err.message : err);
565
+ process.exitCode = 1;
566
+ });
567
+ //#endregion
568
+ export {};
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@novedu/cli",
3
+ "version": "0.1.0",
4
+ "description": "Command-line companion for the Novedu chat app. Validates tutor YAML definitions (more commands to follow).",
5
+ "type": "module",
6
+ "bin": {
7
+ "novedu-cli": "dist/main.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "engines": {
13
+ "node": ">=18"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "scripts": {
19
+ "build": "tsdown"
20
+ },
21
+ "dependencies": {
22
+ "commander": "^15.0.0",
23
+ "handlebars": "^4.7.9",
24
+ "yaml": "^2.9.0",
25
+ "zod": "^4.4.3"
26
+ },
27
+ "devDependencies": {
28
+ "tsdown": "^0.22.2"
29
+ }
30
+ }