@novedu/cli 0.7.0 → 0.9.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 +390 -132
  2. package/package.json +4 -4
package/dist/main.js CHANGED
@@ -6,11 +6,11 @@ import { createServer } from "node:http";
6
6
  import { homedir } from "node:os";
7
7
  import { dirname, join, resolve } from "node:path";
8
8
  import { CryptoProvider, PublicClientApplication } from "@azure/msal-node";
9
+ import { readFile } from "node:fs/promises";
9
10
  import { fileURLToPath, pathToFileURL } from "node:url";
10
11
  import Handlebars from "handlebars";
11
12
  import { parse } from "yaml";
12
13
  import { z } from "zod";
13
- import { readFile } from "node:fs/promises";
14
14
  //#region src/auth.ts
15
15
  const DEFAULT_TENANT_ID = "91fc072c-edef-4f97-bdc5-cfb67718ae3a";
16
16
  const DEFAULT_CLIENT_ID = "4d44fc4b-0434-4981-9765-62e2074ceecb";
@@ -185,6 +185,143 @@ function displayName(result) {
185
185
  return result.account?.name ?? result.account?.username ?? "(unknown account)";
186
186
  }
187
187
  //#endregion
188
+ //#region src/server-url.ts
189
+ const DEFAULT_SERVER = "https://novedu-chat-mvp-at.azurewebsites.net";
190
+ function resolveServerUrl(cliOption) {
191
+ return cliOption || process.env.NOVEDU_SERVER || DEFAULT_SERVER;
192
+ }
193
+ //#endregion
194
+ //#region src/api.ts
195
+ /** Pretty-prints a success payload to stdout. */
196
+ function printJson(value) {
197
+ console.log(JSON.stringify(value, null, 2));
198
+ }
199
+ /** Prints a failure payload as JSON to stderr and marks the process failed. */
200
+ function failJson(value) {
201
+ console.error(JSON.stringify(value, null, 2));
202
+ process.exitCode = 1;
203
+ }
204
+ /**
205
+ * Performs one authenticated API request and prints the outcome per the JSON
206
+ * contract. Server error bodies (`{ message }` — incl. the generic 401/403 —
207
+ * or `{ errors }`) are passed through VERBATIM to stderr — the server's
208
+ * structured validation detail is the CLI's error message. No client-side
209
+ * pre-validation: the server runs the identical pipeline; offline checking is
210
+ * the `validate` command's job.
211
+ */
212
+ async function runApiRequest(options) {
213
+ let token;
214
+ try {
215
+ token = await getAccessToken();
216
+ } catch (error) {
217
+ if (error instanceof NotSignedInError) {
218
+ failJson({ message: error.message });
219
+ return;
220
+ }
221
+ throw error;
222
+ }
223
+ const server = resolveServerUrl(options.server);
224
+ let response;
225
+ try {
226
+ response = await fetch(new URL(options.path, server), {
227
+ method: options.method ?? "GET",
228
+ headers: {
229
+ authorization: `Bearer ${token}`,
230
+ ...options.body === void 0 ? {} : { "content-type": "application/json" }
231
+ },
232
+ body: options.body === void 0 ? void 0 : JSON.stringify(options.body)
233
+ });
234
+ } catch (error) {
235
+ failJson({ message: `Could not reach ${server}: ${error instanceof Error ? error.message : error}` });
236
+ return;
237
+ }
238
+ let payload;
239
+ try {
240
+ payload = await response.json();
241
+ } catch {
242
+ payload = void 0;
243
+ }
244
+ if (!response.ok) {
245
+ failJson(payload ?? { message: `${server} rejected the request: HTTP ${response.status}` });
246
+ return;
247
+ }
248
+ printJson(payload ?? null);
249
+ }
250
+ //#endregion
251
+ //#region src/commands/codes.ts
252
+ const SERVER_OPTION$1 = ["--server <url>", "Novedu server base URL (defaults to the NOVEDU_SERVER env var, then production)"];
253
+ function registerCodes(program) {
254
+ const codes = program.command("codes").description("Manage activity codes on the Novedu server");
255
+ codes.command("create").description("Create a code for an activity YAML (validated server-side before storing)").requiredOption("--module <module>", "activity module: tutor, quiz, writing or coding").requiredOption("--file <url>", "public http(s) URL of the activity YAML").option("--start <iso>", "window start, ISO 8601 with explicit offset (e.g. 2026-07-07T08:00:00Z)").option("--end <iso>", "window end, ISO 8601 with explicit offset").option("--note <text>", "note shown in the codes list").option("--llm-provider <provider>", "LLM override provider (\"SCCH\" or \"Azure Foundry\"; needs --llm-model)").option("--llm-model <model>", "LLM override model id (needs --llm-provider)").option(...SERVER_OPTION$1).action(async (options) => {
256
+ await runApiRequest({
257
+ server: options.server,
258
+ path: "/api/codes",
259
+ method: "POST",
260
+ body: {
261
+ module: options.module,
262
+ fileUrl: options.file,
263
+ ...options.start === void 0 ? {} : { validFrom: options.start },
264
+ ...options.end === void 0 ? {} : { validUntil: options.end },
265
+ ...options.note === void 0 ? {} : { note: options.note },
266
+ ...options.llmProvider === void 0 && options.llmModel === void 0 ? {} : { llm: {
267
+ provider: options.llmProvider ?? "",
268
+ model: options.llmModel ?? ""
269
+ } }
270
+ }
271
+ });
272
+ });
273
+ codes.command("list").description("List codes (defaults to only your own, like the web list)").option("--search <q>", "contains-filter over note/code").option("--module <module>", "only codes for one activity module").option("--all", "include codes created by other teachers").option(...SERVER_OPTION$1).action(async (options) => {
274
+ const params = new URLSearchParams();
275
+ if (options.search) params.set("q", options.search);
276
+ if (options.module) params.set("module", options.module);
277
+ if (options.all) params.set("mine", "0");
278
+ const query = params.toString();
279
+ await runApiRequest({
280
+ server: options.server,
281
+ path: `/api/codes${query ? `?${query}` : ""}`
282
+ });
283
+ });
284
+ }
285
+ //#endregion
286
+ //#region src/commands/files.ts
287
+ const SERVER_OPTION = ["--server <url>", "Novedu server base URL (defaults to the NOVEDU_SERVER env var, then production)"];
288
+ async function readStdin() {
289
+ const chunks = [];
290
+ for await (const chunk of process.stdin) chunks.push(chunk);
291
+ return Buffer.concat(chunks).toString("utf8");
292
+ }
293
+ function registerFiles(program) {
294
+ const files = program.command("files").description("Manage app-hosted YAML files on the Novedu server");
295
+ files.command("upload <name>").description("Create or update an app-hosted YAML file from --file or stdin (validated server-side)").option("--kind <kind>", "file kind (tutor, fragment, quiz, writing, coding) — required when creating").option("--file <path>", "read the YAML from this path instead of stdin").option(...SERVER_OPTION).action(async (name, options) => {
296
+ let content;
297
+ try {
298
+ content = options.file === void 0 ? await readStdin() : await readFile(options.file, "utf8");
299
+ } catch (error) {
300
+ failJson({ message: `Could not read ${options.file}: ${error instanceof Error ? error.message : error}` });
301
+ return;
302
+ }
303
+ await runApiRequest({
304
+ server: options.server,
305
+ path: `/api/files/${encodeURIComponent(name)}`,
306
+ method: "PUT",
307
+ body: {
308
+ ...options.kind === void 0 ? {} : { kind: options.kind },
309
+ content
310
+ }
311
+ });
312
+ });
313
+ files.command("list").description("List app-hosted YAML files (defaults to only your own, like the web list)").option("--search <q>", "contains-filter over name/title/description").option("--all", "include files last written by other teachers").option(...SERVER_OPTION).action(async (options) => {
314
+ const params = new URLSearchParams();
315
+ if (options.search) params.set("q", options.search);
316
+ if (options.all) params.set("mine", "0");
317
+ const query = params.toString();
318
+ await runApiRequest({
319
+ server: options.server,
320
+ path: `/api/files${query ? `?${query}` : ""}`
321
+ });
322
+ });
323
+ }
324
+ //#endregion
188
325
  //#region src/commands/login.ts
189
326
  function registerLogin(program) {
190
327
  program.command("login").description("Sign in to Microsoft Entra ID (opens your browser)").option("--device-code", "sign in with the device code flow instead (for machines without a browser; the tenant must allow it)").addHelpText("after", `
@@ -221,25 +358,34 @@ Purely local — already-issued access tokens stay valid until they expire
221
358
  });
222
359
  }
223
360
  //#endregion
224
- //#region ../lib/tutors/assemble.ts
361
+ //#region ../lib/prompt-fragments/assemble.ts
225
362
  const COMPILE_OPTIONS = {
226
363
  strict: true,
227
364
  noEscape: true
228
365
  };
229
366
  /**
230
- * Render each fragment in priority order and append the tutor-specific
231
- * instructions last (they carry no priority, so "after everything" is the only
232
- * deterministic position). May throw if a template references a missing variable.
367
+ * Render each fragment in priority order and, when provided, append the caller's
368
+ * trailing instructions last (they carry no priority, so "after everything" is the
369
+ * only deterministic position the exact role `tutor_instructions` plays for a
370
+ * tutor, and the activity frame / `instructions` play for quiz / writing / coding).
371
+ * May throw if a template references a missing variable.
372
+ *
373
+ * `trailingInstructions` is optional so a consumer can assemble a fragment-only
374
+ * PREAMBLE (quiz / writing / coding) and concatenate its own frame afterwards. An
375
+ * empty plan with no trailing text renders to the empty string (so an activity that
376
+ * declares no fragments gets no stray whitespace); every non-empty result ends in a
377
+ * single trailing newline, byte-identical to the historic tutor output.
233
378
  */
234
- function assembleSystemPrompt(plan, tutor) {
379
+ function assembleSystemPrompt(plan, trailingInstructions) {
235
380
  const parts = plan.map((fragment) => {
236
381
  return Handlebars.compile(fragment.content, COMPILE_OPTIONS)(fragment.variables).trimEnd();
237
382
  });
238
- parts.push(tutor.prompt.tutor_instructions.trimEnd());
383
+ if (trailingInstructions !== void 0) parts.push(trailingInstructions.trimEnd());
384
+ if (parts.length === 0) return "";
239
385
  return `${parts.join("\n\n")}\n`;
240
386
  }
241
387
  //#endregion
242
- //#region ../lib/tutors/errors.ts
388
+ //#region ../lib/prompt-fragments/errors.ts
243
389
  /** Small helper to build an error object tersely at call sites. */
244
390
  function error(code, message, extra = {}) {
245
391
  return {
@@ -276,7 +422,7 @@ function formatZodIssues(zodIssues) {
276
422
  return out;
277
423
  }
278
424
  //#endregion
279
- //#region ../lib/tutors/consistency.ts
425
+ //#region ../lib/prompt-fragments/consistency.ts
280
426
  /** Compare a supplied value against its declared property type. Returns null when it matches. */
281
427
  function typeMismatch(prop, value) {
282
428
  const actual = Array.isArray(value) ? "array" : typeof value;
@@ -295,11 +441,11 @@ function typeMismatch(prop, value) {
295
441
  };
296
442
  }
297
443
  }
298
- function checkConsistency(tutor, fragmentFilesByAlias) {
444
+ function checkConsistency(block, fragmentFilesByAlias) {
299
445
  const errors = [];
300
446
  const warnings = [];
301
447
  const aliasCounts = /* @__PURE__ */ new Map();
302
- for (const ref of tutor.prompt.fragment_files) aliasCounts.set(ref.id, (aliasCounts.get(ref.id) ?? 0) + 1);
448
+ for (const ref of block.fragment_files) aliasCounts.set(ref.id, (aliasCounts.get(ref.id) ?? 0) + 1);
303
449
  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 }));
304
450
  const fragmentIndex = /* @__PURE__ */ new Map();
305
451
  for (const [alias, file] of fragmentFilesByAlias) {
@@ -318,7 +464,7 @@ function checkConsistency(tutor, fragmentFilesByAlias) {
318
464
  }
319
465
  const resolved = [];
320
466
  const seenRefs = /* @__PURE__ */ new Set();
321
- for (const ref of tutor.prompt.fragments) {
467
+ for (const ref of block.fragments) {
322
468
  const refKey = `${ref.file}::${ref.id}`;
323
469
  if (seenRefs.has(refKey)) warnings.push(warning("DUPLICATE_FRAGMENT_REFERENCE", `Fragment "${ref.id}" from "${ref.file}" is referenced more than once`, {
324
470
  fileAlias: ref.file,
@@ -410,7 +556,7 @@ function checkConsistency(tutor, fragmentFilesByAlias) {
410
556
  };
411
557
  }
412
558
  //#endregion
413
- //#region ../lib/tutors/fetcher.ts
559
+ //#region ../lib/prompt-fragments/fetcher.ts
414
560
  const DEFAULT_TIMEOUT_MS = 1e4;
415
561
  /** Production fetcher: global `fetch` with an abort-based timeout so a slow host can't hang the request. */
416
562
  const defaultFetcher = async (url) => {
@@ -426,7 +572,7 @@ const defaultFetcher = async (url) => {
426
572
  }
427
573
  };
428
574
  //#endregion
429
- //#region ../lib/tutors/parse.ts
575
+ //#region ../lib/prompt-fragments/parse.ts
430
576
  /** Parse a YAML document, mapping syntax errors to a structured `YAML_PARSE_ERROR`. */
431
577
  function parseYaml(text, url) {
432
578
  try {
@@ -456,12 +602,11 @@ function validate(value, schema, code, url) {
456
602
  })
457
603
  };
458
604
  }
459
- const providerSchema = z.enum(["SCCH", "Azure Foundry"]).default("SCCH");
460
605
  //#endregion
461
- //#region ../lib/tutors/schemas.ts
606
+ //#region ../lib/prompt-fragments/schemas.ts
462
607
  /**
463
608
  * A fragment-file reference: either an absolute http(s) URL or a relative path that
464
- * `load.ts` resolves against the tutor YAML's own URL. We reject any *other* absolute
609
+ * `load.ts` resolves against the activity YAML's own URL. We reject any *other* absolute
465
610
  * scheme (`ftp:`, `mailto:`, …) so a typo can't smuggle in a non-http(s) target — the
466
611
  * refine reads as "if it carries a URI scheme at all, that scheme must be http(s)".
467
612
  * Strings without a scheme (relative paths) pass through and are resolved at load time.
@@ -470,7 +615,7 @@ const FragmentUrlRef = z.string().min(1).refine((u) => !/^[a-z][a-z0-9+.-]*:/i.t
470
615
  /**
471
616
  * A declared property is a string, a boolean, or an array of strings. Each may carry an
472
617
  * optional `default`, typed to match its `type` (a string default on a boolean property
473
- * is a schema error). When the tutor omits the variable, the default is used; supplying
618
+ * is a schema error). When the activity omits the variable, the default is used; supplying
474
619
  * a value overrides it. See `consistency.ts` for where defaults are injected.
475
620
  */
476
621
  const PropertySchema = z.discriminatedUnion("type", [
@@ -526,35 +671,8 @@ const FragmentRefSchema = z.strictObject({
526
671
  bind: z.record(z.string(), z.string()).optional(),
527
672
  required: z.boolean().optional()
528
673
  });
529
- /**
530
- * An example question offered to students on the welcome screen: the `title` is
531
- * the clickable label, the `question` is the full text placed into the chat
532
- * input on click. Tutors may define any number; the UI samples at most 5.
533
- */
534
- const ExampleQuestionSchema = z.strictObject({
535
- title: z.string().min(1),
536
- question: z.string().min(1)
537
- });
538
- const TutorSchema = z.strictObject({
539
- id: z.string(),
540
- name: z.string(),
541
- title: z.string().optional(),
542
- description: z.string(),
543
- exampleQuestions: z.array(ExampleQuestionSchema).optional(),
544
- anonymous: z.boolean().optional(),
545
- llm: z.strictObject({
546
- model: z.string(),
547
- provider: providerSchema,
548
- imageInput: z.boolean().optional()
549
- }),
550
- prompt: z.strictObject({
551
- fragment_files: z.array(FragmentFileRefSchema).default([]),
552
- fragments: z.array(FragmentRefSchema).default([]),
553
- tutor_instructions: z.string()
554
- })
555
- });
556
674
  //#endregion
557
- //#region ../lib/tutors/fragment.ts
675
+ //#region ../lib/prompt-fragments/fragment.ts
558
676
  /**
559
677
  * A placeholder value for a declared input, shaped to its type so the template
560
678
  * actually exercises it: a string renders, a boolean drives `{{#if}}`, an array
@@ -657,17 +775,17 @@ function resolveRelativeUrl(ref, baseUrl) {
657
775
  return new URL(ref, baseUrl).href;
658
776
  }
659
777
  //#endregion
660
- //#region ../lib/tutors/load.ts
778
+ //#region ../lib/prompt-fragments/load.ts
661
779
  /**
662
780
  * Resolve a fragment-file reference to an absolute URL. An absolute http(s) ref is used
663
- * as-is; anything else is treated as relative to the tutor URL — standard URL resolution
664
- * drops the tutor's filename and appends the relative path (so `my-fragments.yaml`
781
+ * as-is; anything else is treated as relative to the activity URL — standard URL resolution
782
+ * drops the activity's filename and appends the relative path (so `my-fragments.yaml`
665
783
  * next to `.../tutors/my-tutor.yaml` becomes `.../tutors/my-fragments.yaml`,
666
784
  * and `./` / `../` segments work too). Throws if a relative ref is unparseable; the schema
667
785
  * already guarantees the only inputs here are http(s) URLs or relative paths.
668
786
  */
669
- function resolveFragmentUrl(ref, tutorUrl) {
670
- return resolveRelativeUrl(ref, tutorUrl);
787
+ function resolveFragmentUrl(ref, baseUrl) {
788
+ return resolveRelativeUrl(ref, baseUrl);
671
789
  }
672
790
  async function fetchText(url, fetchImpl) {
673
791
  try {
@@ -692,23 +810,33 @@ async function fetchText(url, fetchImpl) {
692
810
  }
693
811
  const DEFAULT_ALLOWED_SCHEMES = ["http:", "https:"];
694
812
  /**
695
- * The shared front of every load: enforce the URL scheme allow-list (SSRF guard),
696
- * fetch the document, and parse it as YAML returning the parsed-but-not-yet-schema-
697
- * validated value or the first structured error. Reused by the tutor builder, the
698
- * standalone fragment checker, and the quiz/writing validators so they all gate
699
- * schemes identically.
813
+ * The SSRF scheme gate shared by the top-level activity load (`loadYaml`) and every
814
+ * fragment-file fetch (`assembleFragmentPrompt`): a URL is allowed only if its scheme
815
+ * is in `allowedSchemes` (default http(s); the CLI adds `file:` for on-disk validation).
816
+ * Returns the structured error to surface, or `null` when the scheme is allowed.
700
817
  */
701
- async function loadYaml(url, fetchImpl, opts = {}) {
702
- const allowedSchemes = opts.allowedSchemes ?? DEFAULT_ALLOWED_SCHEMES;
818
+ function schemeGate(url, allowedSchemes) {
703
819
  let scheme;
704
820
  try {
705
821
  scheme = new URL(url).protocol;
706
822
  } catch {
707
823
  scheme = "";
708
824
  }
709
- if (!allowedSchemes.includes(scheme)) return {
825
+ if (allowedSchemes.includes(scheme)) return null;
826
+ return error("INVALID_URL", `Provide a valid ${allowedSchemes.map((s) => s.replace(/:$/, "")).join("/")} URL`, { url });
827
+ }
828
+ /**
829
+ * The shared front of every load: enforce the URL scheme allow-list (SSRF guard),
830
+ * fetch the document, and parse it as YAML — returning the parsed-but-not-yet-schema-
831
+ * validated value or the first structured error. Reused by the activity builders, the
832
+ * standalone fragment checker, and the quiz/writing/coding validators so they all gate
833
+ * schemes identically.
834
+ */
835
+ async function loadYaml(url, fetchImpl, opts = {}) {
836
+ const schemeError = schemeGate(url, opts.allowedSchemes ?? DEFAULT_ALLOWED_SCHEMES);
837
+ if (schemeError) return {
710
838
  ok: false,
711
- error: error("INVALID_URL", `Provide a valid ${allowedSchemes.map((s) => s.replace(/:$/, "")).join("/")} URL`, { url })
839
+ error: schemeError
712
840
  };
713
841
  const fetched = await fetchText(url, fetchImpl);
714
842
  if (!fetched.ok) return {
@@ -725,25 +853,30 @@ async function loadYaml(url, fetchImpl, opts = {}) {
725
853
  value: parsed.value
726
854
  };
727
855
  }
728
- async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
856
+ /**
857
+ * Resolve a document-level fragment block to a finished prompt string: fetch every
858
+ * declared fragment file in parallel (relative refs resolved against `baseUrl`),
859
+ * schema-validate each, (optionally) run the thorough whole-library check, check
860
+ * consistency, and assemble the priority-ordered plan followed by the optional
861
+ * `trailingInstructions`.
862
+ *
863
+ * The single seam every activity kind shares — the sole owner of the fetch → validate
864
+ * → consistency → assemble pipeline. Consumers concatenate their own frame only when
865
+ * they pass no `trailingInstructions` (a fragment-only preamble); tutors pass their
866
+ * `tutor_instructions` and get a complete prompt.
867
+ */
868
+ async function assembleFragmentPrompt(block, baseUrl, fetchImpl, opts = {}, trailingInstructions) {
729
869
  const warnings = [];
730
- const tutorYaml = await loadYaml(url, fetchImpl, opts);
731
- if (!tutorYaml.ok) return {
732
- ok: false,
733
- errors: [tutorYaml.error],
734
- warnings
735
- };
736
- const tutorValid = validate(tutorYaml.value, TutorSchema, "TUTOR_SCHEMA_ERROR", url);
737
- if (!tutorValid.ok) return {
738
- ok: false,
739
- errors: [tutorValid.error],
870
+ const allowedSchemes = opts.allowedSchemes ?? DEFAULT_ALLOWED_SCHEMES;
871
+ if (block.fragment_files.length === 0 && block.fragments.length === 0) return {
872
+ ok: true,
873
+ prompt: assembleSystemPrompt([], trailingInstructions),
740
874
  warnings
741
875
  };
742
- const tutor = tutorValid.data;
743
- const settled = await Promise.all(tutor.prompt.fragment_files.map(async (ref) => {
876
+ const settled = await Promise.all(block.fragment_files.map(async (ref) => {
744
877
  let fragmentUrl;
745
878
  try {
746
- fragmentUrl = resolveFragmentUrl(ref.url, url);
879
+ fragmentUrl = resolveFragmentUrl(ref.url, baseUrl);
747
880
  } catch {
748
881
  return {
749
882
  alias: ref.id,
@@ -753,6 +886,14 @@ async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
753
886
  })
754
887
  };
755
888
  }
889
+ const schemeError = schemeGate(fragmentUrl, allowedSchemes);
890
+ if (schemeError) return {
891
+ alias: ref.id,
892
+ error: {
893
+ ...schemeError,
894
+ fileAlias: ref.id
895
+ }
896
+ };
756
897
  const fetched = await fetchText(fragmentUrl, fetchImpl);
757
898
  if (!fetched.ok) return {
758
899
  alias: ref.id,
@@ -799,7 +940,7 @@ async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
799
940
  libraryErrors.push(...checked.errors);
800
941
  warnings.push(...checked.warnings);
801
942
  }
802
- const consistency = checkConsistency(tutor, fragmentFilesByAlias);
943
+ const consistency = checkConsistency(block, fragmentFilesByAlias);
803
944
  warnings.push(...consistency.warnings);
804
945
  const preAssemblyErrors = [...libraryErrors, ...consistency.errors];
805
946
  if (preAssemblyErrors.length > 0) return {
@@ -810,14 +951,7 @@ async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
810
951
  try {
811
952
  return {
812
953
  ok: true,
813
- prompt: assembleSystemPrompt(consistency.plan, tutor),
814
- model: tutor.llm.model,
815
- provider: tutor.llm.provider,
816
- imageInput: tutor.llm.imageInput ?? true,
817
- anonymous: tutor.anonymous ?? true,
818
- title: tutor.title,
819
- description: tutor.description,
820
- exampleQuestions: tutor.exampleQuestions ?? [],
954
+ prompt: assembleSystemPrompt(consistency.plan, trailingInstructions),
821
955
  warnings
822
956
  };
823
957
  } catch (e) {
@@ -831,9 +965,9 @@ async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
831
965
  /**
832
966
  * Validate a fragment FILE on its own (the `--kind fragment` / "Fragment library"
833
967
  * path): scheme-gate + fetch + parse, then the pure `checkFragmentFileValue`. A
834
- * fragment library is self-contained, so — unlike a tutor — there are no further
968
+ * fragment library is self-contained, so — unlike an activity — there are no further
835
969
  * files to fetch. The caller already knows it asked for a fragment, so this returns
836
- * a `FragmentCheckResult` directly (no tutor `BuildResult`, no kind discriminator).
970
+ * a `FragmentCheckResult` directly (no activity `BuildResult`, no kind discriminator).
837
971
  */
838
972
  async function loadAndCheckFragmentFile(url, fetchImpl, opts = {}) {
839
973
  const yaml = await loadYaml(url, fetchImpl, opts);
@@ -844,6 +978,7 @@ async function loadAndCheckFragmentFile(url, fetchImpl, opts = {}) {
844
978
  };
845
979
  return checkFragmentFileValue(yaml.value, url);
846
980
  }
981
+ const providerSchema = z.enum(["SCCH", "Azure Foundry"]).default("SCCH");
847
982
  //#endregion
848
983
  //#region ../lib/coding-schema.ts
849
984
  const CodingYamlSchema = z.strictObject({
@@ -854,23 +989,18 @@ const CodingYamlSchema = z.strictObject({
854
989
  model: z.string().min(1),
855
990
  provider: providerSchema
856
991
  }),
992
+ fragment_files: z.array(FragmentFileRefSchema).default([]),
993
+ fragments: z.array(FragmentRefSchema).default([]),
857
994
  instructions: z.string().min(1)
858
995
  });
859
996
  //#endregion
860
997
  //#region ../lib/coding-validate.ts
861
998
  /**
862
- * Validate an already-parsed coding value against its schema, then extract metadata.
863
- * Pure (the parsed value is passed in); `loadAndCheckCoding` wraps it with fetch +
864
- * YAML parse.
999
+ * Extract metadata from an already-schema-validated coding value. Split from
1000
+ * `checkCodingValue` so `loadAndCheckCoding` can reuse the single `validate` it already
1001
+ * ran (no second parse of the same document against the same schema).
865
1002
  */
866
- function checkCodingValue(parsed, url) {
867
- const valid = validate(parsed, CodingYamlSchema, "CODING_SCHEMA_ERROR", url);
868
- if (!valid.ok) return {
869
- ok: false,
870
- errors: [valid.error],
871
- warnings: []
872
- };
873
- const coding = valid.data;
1003
+ function checkCodingParsed(coding) {
874
1004
  return {
875
1005
  ok: true,
876
1006
  codingId: coding.id,
@@ -892,7 +1022,31 @@ async function loadAndCheckCoding(url, fetchImpl, opts = {}) {
892
1022
  errors: [yaml.error],
893
1023
  warnings: []
894
1024
  };
895
- return checkCodingValue(yaml.value, url);
1025
+ const valid = validate(yaml.value, CodingYamlSchema, "CODING_SCHEMA_ERROR", url);
1026
+ if (!valid.ok) return {
1027
+ ok: false,
1028
+ errors: [valid.error],
1029
+ warnings: []
1030
+ };
1031
+ const checked = checkCodingParsed(valid.data);
1032
+ if (!checked.ok) return checked;
1033
+ const assembled = await assembleFragmentPrompt({
1034
+ fragment_files: valid.data.fragment_files,
1035
+ fragments: valid.data.fragments
1036
+ }, url, fetchImpl, {
1037
+ allowedSchemes: opts.allowedSchemes,
1038
+ validateLibraries: opts.validateLibraries ?? true
1039
+ });
1040
+ const warnings = [...checked.warnings, ...assembled.warnings];
1041
+ if (!assembled.ok) return {
1042
+ ok: false,
1043
+ errors: assembled.errors,
1044
+ warnings
1045
+ };
1046
+ return {
1047
+ ...checked,
1048
+ warnings
1049
+ };
896
1050
  }
897
1051
  //#endregion
898
1052
  //#region ../lib/quiz-schema.ts
@@ -913,7 +1067,8 @@ const QuizQuestionSchema = z.strictObject({
913
1067
  title: z.string().optional(),
914
1068
  question: z.string().min(1),
915
1069
  evaluation: z.string().min(1),
916
- image: ImageRefSchema.optional()
1070
+ image: ImageRefSchema.optional(),
1071
+ imageInput: z.boolean().optional()
917
1072
  });
918
1073
  const QuizYamlSchema = z.strictObject({
919
1074
  id: z.string().min(1),
@@ -924,9 +1079,12 @@ const QuizYamlSchema = z.strictObject({
924
1079
  shuffle: z.boolean().optional(),
925
1080
  llm: z.strictObject({
926
1081
  model: z.string().min(1),
927
- provider: providerSchema
1082
+ provider: providerSchema,
1083
+ imageInput: z.boolean().optional()
928
1084
  }),
929
1085
  discussion: z.strictObject({ instructions: z.string().min(1) }).optional(),
1086
+ fragment_files: z.array(FragmentFileRefSchema).default([]),
1087
+ fragments: z.array(FragmentRefSchema).default([]),
930
1088
  questions: z.array(QuizQuestionSchema).min(1)
931
1089
  });
932
1090
  //#endregion
@@ -947,18 +1105,11 @@ function findDuplicateQuestionIds(quiz) {
947
1105
  return errors;
948
1106
  }
949
1107
  /**
950
- * Validate an already-parsed quiz value: schema → unique question ids → metadata.
951
- * Pure (the parsed value is passed in); `loadAndCheckQuiz` wraps it with fetch +
952
- * YAML parse.
1108
+ * Check an already-schema-validated quiz: unique question ids → metadata. Split from
1109
+ * `checkQuizValue` so `loadAndCheckQuiz` can reuse the single `validate` it already ran
1110
+ * (no second parse of the same document against the same schema).
953
1111
  */
954
- function checkQuizValue(parsed, url) {
955
- const valid = validate(parsed, QuizYamlSchema, "QUIZ_SCHEMA_ERROR", url);
956
- if (!valid.ok) return {
957
- ok: false,
958
- errors: [valid.error],
959
- warnings: []
960
- };
961
- const quiz = valid.data;
1112
+ function checkQuizParsed(quiz) {
962
1113
  const errors = findDuplicateQuestionIds(quiz);
963
1114
  if (errors.length > 0) return {
964
1115
  ok: false,
@@ -977,9 +1128,11 @@ function checkQuizValue(parsed, url) {
977
1128
  };
978
1129
  }
979
1130
  /**
980
- * Validate a quiz FILE: scheme-gate + fetch + parse (shared `loadYaml`), then the
981
- * pure `checkQuizValue`. The web app passes the default http(s)-only schemes; the
982
- * CLI adds `file:` so a local quiz YAML on disk validates too.
1131
+ * Validate a quiz FILE: scheme-gate + fetch + parse (shared `loadYaml`), the pure
1132
+ * `checkQuizValue`, then the document-level fragment block's authoring gate fetch
1133
+ * every referenced library, run the THOROUGH whole-library check, consistency, and an
1134
+ * assembly dry-run (the strict-Handlebars backstop). The web app passes the default
1135
+ * http(s)-only schemes; the CLI adds `file:` so a local quiz YAML on disk validates too.
983
1136
  */
984
1137
  async function loadAndCheckQuiz(url, fetchImpl, opts = {}) {
985
1138
  const yaml = await loadYaml(url, fetchImpl, opts);
@@ -988,7 +1141,97 @@ async function loadAndCheckQuiz(url, fetchImpl, opts = {}) {
988
1141
  errors: [yaml.error],
989
1142
  warnings: []
990
1143
  };
991
- return checkQuizValue(yaml.value, url);
1144
+ const valid = validate(yaml.value, QuizYamlSchema, "QUIZ_SCHEMA_ERROR", url);
1145
+ if (!valid.ok) return {
1146
+ ok: false,
1147
+ errors: [valid.error],
1148
+ warnings: []
1149
+ };
1150
+ const checked = checkQuizParsed(valid.data);
1151
+ if (!checked.ok) return checked;
1152
+ const assembled = await assembleFragmentPrompt({
1153
+ fragment_files: valid.data.fragment_files,
1154
+ fragments: valid.data.fragments
1155
+ }, url, fetchImpl, {
1156
+ allowedSchemes: opts.allowedSchemes,
1157
+ validateLibraries: opts.validateLibraries ?? true
1158
+ });
1159
+ const warnings = [...checked.warnings, ...assembled.warnings];
1160
+ if (!assembled.ok) return {
1161
+ ok: false,
1162
+ errors: assembled.errors,
1163
+ warnings
1164
+ };
1165
+ return {
1166
+ ...checked,
1167
+ warnings
1168
+ };
1169
+ }
1170
+ //#endregion
1171
+ //#region ../lib/tutors/schemas.ts
1172
+ /**
1173
+ * An example question offered to students on the welcome screen: the `title` is
1174
+ * the clickable label, the `question` is the full text placed into the chat
1175
+ * input on click. Tutors may define any number; the UI samples at most 5.
1176
+ */
1177
+ const ExampleQuestionSchema = z.strictObject({
1178
+ title: z.string().min(1),
1179
+ question: z.string().min(1)
1180
+ });
1181
+ const TutorSchema = z.strictObject({
1182
+ id: z.string(),
1183
+ name: z.string(),
1184
+ title: z.string().optional(),
1185
+ description: z.string(),
1186
+ exampleQuestions: z.array(ExampleQuestionSchema).optional(),
1187
+ anonymous: z.boolean().optional(),
1188
+ llm: z.strictObject({
1189
+ model: z.string(),
1190
+ provider: providerSchema,
1191
+ imageInput: z.boolean().optional()
1192
+ }),
1193
+ prompt: z.strictObject({
1194
+ fragment_files: z.array(FragmentFileRefSchema).default([]),
1195
+ fragments: z.array(FragmentRefSchema).default([]),
1196
+ tutor_instructions: z.string()
1197
+ })
1198
+ });
1199
+ //#endregion
1200
+ //#region ../lib/tutors/load.ts
1201
+ async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
1202
+ const warnings = [];
1203
+ const tutorYaml = await loadYaml(url, fetchImpl, opts);
1204
+ if (!tutorYaml.ok) return {
1205
+ ok: false,
1206
+ errors: [tutorYaml.error],
1207
+ warnings
1208
+ };
1209
+ const tutorValid = validate(tutorYaml.value, TutorSchema, "TUTOR_SCHEMA_ERROR", url);
1210
+ if (!tutorValid.ok) return {
1211
+ ok: false,
1212
+ errors: [tutorValid.error],
1213
+ warnings
1214
+ };
1215
+ const tutor = tutorValid.data;
1216
+ const assembled = await assembleFragmentPrompt(tutor.prompt, url, fetchImpl, opts, tutor.prompt.tutor_instructions);
1217
+ warnings.push(...assembled.warnings);
1218
+ if (!assembled.ok) return {
1219
+ ok: false,
1220
+ errors: assembled.errors,
1221
+ warnings
1222
+ };
1223
+ return {
1224
+ ok: true,
1225
+ prompt: assembled.prompt,
1226
+ model: tutor.llm.model,
1227
+ provider: tutor.llm.provider,
1228
+ imageInput: tutor.llm.imageInput ?? true,
1229
+ anonymous: tutor.anonymous ?? true,
1230
+ title: tutor.title,
1231
+ description: tutor.description,
1232
+ exampleQuestions: tutor.exampleQuestions ?? [],
1233
+ warnings
1234
+ };
992
1235
  }
993
1236
  //#endregion
994
1237
  //#region ../lib/writing-schema.ts
@@ -1002,6 +1245,8 @@ const WritingYamlSchema = z.strictObject({
1002
1245
  model: z.string().min(1),
1003
1246
  provider: providerSchema
1004
1247
  }),
1248
+ fragment_files: z.array(FragmentFileRefSchema).default([]),
1249
+ fragments: z.array(FragmentRefSchema).default([]),
1005
1250
  instructions: z.string().min(1),
1006
1251
  placeholder: z.string().optional()
1007
1252
  });
@@ -1010,18 +1255,11 @@ const WritingYamlSchema = z.strictObject({
1010
1255
  /** Writing DIVERGES from tutor/quiz: it defaults to attributed (`anonymous: false`). */
1011
1256
  const DEFAULT_ANONYMOUS = false;
1012
1257
  /**
1013
- * Validate an already-parsed writing value against its schema, then extract metadata.
1014
- * Pure (the parsed value is passed in); `loadAndCheckWriting` wraps it with fetch +
1015
- * YAML parse.
1258
+ * Extract metadata from an already-schema-validated writing value. Split from
1259
+ * `checkWritingValue` so `loadAndCheckWriting` can reuse the single `validate` it already
1260
+ * ran (no second parse of the same document against the same schema).
1016
1261
  */
1017
- function checkWritingValue(parsed, url) {
1018
- const valid = validate(parsed, WritingYamlSchema, "WRITING_SCHEMA_ERROR", url);
1019
- if (!valid.ok) return {
1020
- ok: false,
1021
- errors: [valid.error],
1022
- warnings: []
1023
- };
1024
- const writing = valid.data;
1262
+ function checkWritingParsed(writing) {
1025
1263
  return {
1026
1264
  ok: true,
1027
1265
  writingId: writing.id,
@@ -1044,7 +1282,31 @@ async function loadAndCheckWriting(url, fetchImpl, opts = {}) {
1044
1282
  errors: [yaml.error],
1045
1283
  warnings: []
1046
1284
  };
1047
- return checkWritingValue(yaml.value, url);
1285
+ const valid = validate(yaml.value, WritingYamlSchema, "WRITING_SCHEMA_ERROR", url);
1286
+ if (!valid.ok) return {
1287
+ ok: false,
1288
+ errors: [valid.error],
1289
+ warnings: []
1290
+ };
1291
+ const checked = checkWritingParsed(valid.data);
1292
+ if (!checked.ok) return checked;
1293
+ const assembled = await assembleFragmentPrompt({
1294
+ fragment_files: valid.data.fragment_files,
1295
+ fragments: valid.data.fragments
1296
+ }, url, fetchImpl, {
1297
+ allowedSchemes: opts.allowedSchemes,
1298
+ validateLibraries: opts.validateLibraries ?? true
1299
+ });
1300
+ const warnings = [...checked.warnings, ...assembled.warnings];
1301
+ if (!assembled.ok) return {
1302
+ ok: false,
1303
+ errors: assembled.errors,
1304
+ warnings
1305
+ };
1306
+ return {
1307
+ ...checked,
1308
+ warnings
1309
+ };
1048
1310
  }
1049
1311
  //#endregion
1050
1312
  //#region src/file-fetcher.ts
@@ -1310,12 +1572,6 @@ function formatOutcome(outcome, source) {
1310
1572
  }
1311
1573
  }
1312
1574
  //#endregion
1313
- //#region src/server-url.ts
1314
- const DEFAULT_SERVER = "https://novedu-chat-mvp-at.azurewebsites.net";
1315
- function resolveServerUrl(cliOption) {
1316
- return cliOption || process.env.NOVEDU_SERVER || DEFAULT_SERVER;
1317
- }
1318
- //#endregion
1319
1575
  //#region src/commands/whoami.ts
1320
1576
  function registerWhoami(program) {
1321
1577
  program.command("whoami").description("Show who is signed in by calling the Novedu server's /api/me").option("--server <url>", "Novedu server base URL (defaults to the NOVEDU_SERVER env var, then production)").action(async (options) => {
@@ -1359,6 +1615,8 @@ registerValidate(program);
1359
1615
  registerLogin(program);
1360
1616
  registerLogout(program);
1361
1617
  registerWhoami(program);
1618
+ registerCodes(program);
1619
+ registerFiles(program);
1362
1620
  program.parseAsync().catch((err) => {
1363
1621
  console.error(err instanceof Error ? err.message : err);
1364
1622
  process.exitCode = 1;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@novedu/cli",
3
- "version": "0.7.0",
4
- "description": "Command-line companion for the Novedu chat app. Validates tutor, fragment, quiz, writing and coding YAML definitions (more commands to follow).",
3
+ "version": "0.9.0",
4
+ "description": "Command-line companion for the Novedu chat app. Validates tutor, fragment, quiz, writing and coding YAML definitions; signs in with Entra ID and manages codes and app-hosted files over the app's API.",
5
5
  "type": "module",
6
6
  "repository": {
7
7
  "type": "git",
@@ -25,13 +25,13 @@
25
25
  "prepublishOnly": "npm run build"
26
26
  },
27
27
  "dependencies": {
28
- "@azure/msal-node": "^5.3.1",
28
+ "@azure/msal-node": "^5.4.0",
29
29
  "commander": "^15.0.0",
30
30
  "handlebars": "^4.7.9",
31
31
  "yaml": "^2.9.0",
32
32
  "zod": "^4.4.3"
33
33
  },
34
34
  "devDependencies": {
35
- "tsdown": "^0.22.3"
35
+ "tsdown": "^0.22.4"
36
36
  }
37
37
  }