@novedu/cli 0.17.0 → 0.18.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 +84 -0
  2. package/dist/main.js +593 -19
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -87,6 +87,7 @@ codes create --module <tutor|quiz|writing|coding> --file <url>
87
87
  [--start <iso>] [--end <iso>] [--note <text>]
88
88
  [--llm-provider <p> --llm-model <m>]
89
89
  codes list [--search <q>] [--module <m>] [--all]
90
+ codes sync <registry-file> [--lock <path>] [--dry-run] [--json]
90
91
  files upload <name> [--kind <tutor|fragment|quiz|writing|coding>]
91
92
  (--file <path> | reads stdin)
92
93
  files list [--search <q>] [--all]
@@ -103,6 +104,8 @@ images list [--search <q>] [--all]
103
104
  shareable `url`. `--start`/`--end` must be ISO 8601 **with an explicit
104
105
  offset or `Z`** (e.g. `2026-07-07T08:00:00Z`); the
105
106
  `--llm-provider`/`--llm-model` override pair is both-or-nothing.
107
+ - `codes sync <registry-file>` mints codes for a whole **course** at once — see
108
+ [Many activities at once](#many-activities-at-once-codes-sync) below.
106
109
  - `files upload <name>` is an **upsert**: creating a new file requires
107
110
  `--kind`; an existing file's kind is frozen at create time (a contradicting
108
111
  `--kind` fails with 409). The YAML comes from `--file <path>` or stdin.
@@ -150,6 +153,87 @@ image:
150
153
  alt: Merge sort splitting an array
151
154
  ```
152
155
 
156
+ ## Many activities at once: `codes sync`
157
+
158
+ A course with twenty quizzes should not be twenty `codes create` calls whose
159
+ codes you paste into twenty files by hand. Instead, keep an **activity registry**
160
+ next to the material: one hand-written YAML file listing every activity under a
161
+ stable key, plus a **lock file** the CLI generates and you commit.
162
+
163
+ ```yaml
164
+ # yaml-language-server: $schema=https://raw.githubusercontent.com/Teaching-HTL-Leonding/novedu-chat-mvp/refs/heads/main/activities/registry/registry-yaml.schema.json
165
+ # ddp-activities.yaml — the registry (you write this)
166
+ base-url: "https://raw.githubusercontent.com/acme/course/refs/heads/main/"
167
+
168
+ activities:
169
+ quizzes:
170
+ welcome:
171
+ file: 0010-introduction/0010-welcome-quiz.yaml
172
+ note: "Creative Coding book: Welcome (0010)"
173
+ number-systems:
174
+ file: 0030-conditions/0050-number-systems-quiz.yaml
175
+ start: 2026-09-01T00:00:00+02:00
176
+ end: 2027-01-31T23:59:59+01:00
177
+ tutors:
178
+ sorting:
179
+ url: https://novedu.at/api/files/sorting-tutor
180
+ ```
181
+
182
+ ```bash
183
+ npx @novedu/cli codes sync ddp-activities.yaml
184
+ # ddp-activities.yaml: 3 entries
185
+ # reused welcome cu4afwoa23 https://novedu.at/cu4afwoa23
186
+ # minted number-systems hb34gpvahn https://novedu.at/hb34gpvahn
187
+ # reused sorting nlc90ezf5z https://novedu.at/nlc90ezf5z
188
+ #
189
+ # 2 reused, 1 minted, 0 failed
190
+ # Lock file: ddp-activities.lock.yaml
191
+ ```
192
+
193
+ ```yaml
194
+ # ddp-activities.lock.yaml — generated; commit it, do not edit it
195
+ activity-codes:
196
+ number-systems: hb34gpvahn
197
+ sorting: nlc90ezf5z
198
+ welcome: cu4afwoa23
199
+ ```
200
+
201
+ - **Groups decide the module:** `quizzes`, `tutors`, `writing`, `coding`. Each
202
+ entry gives either `file` (relative to `base-url`, which must end in `/`) or
203
+ an absolute `url`, plus any of `start`/`end` (ISO 8601 **with an offset or
204
+ `Z`**, whole seconds), `note`, and an `llm: {provider, model}` override.
205
+ - **Keys are yours and must be unique across all groups** — lowercase letters,
206
+ digits and hyphens. Your material references the key; the lock file maps it to
207
+ the code.
208
+ - **Re-runs are safe.** An entry whose activity, window and model override match
209
+ an existing code of yours **reuses** that code; only entries without a match
210
+ are minted. So `codes sync` after every edit is the normal workflow, and the
211
+ first run against already-minted codes should report all-reused.
212
+ - **Changing a window or override mints a NEW code.** The old one is not touched
213
+ (it keeps working) and is reported as superseded — delete it in the web app
214
+ when the class has moved on. Changing only the `note` never forks a code.
215
+ - `--dry-run` shows what would happen without minting or writing anything;
216
+ `--json` prints the machine-readable report; `--lock <path>` puts the lock file
217
+ somewhere else.
218
+ - One broken activity does not stop the run: it is reported as `failed`, the
219
+ other entries still sync, the lock keeps that entry's previous code, and the
220
+ command exits 1.
221
+ - **A key keeps its code.** Two keys may point at the same activity on purpose
222
+ (one quiz linked from two chapters, each with its own statistics); they get one
223
+ code each, and neither moves on a later run.
224
+ - Unknown extra keys are ignored, so you can annotate entries freely — but an
225
+ entry with nothing under it is an error, not an annotation, because that is
226
+ what a mis-indented entry looks like.
227
+ - The `# yaml-language-server:` line on top is optional: it gives editors with
228
+ YAML support field completion, hover help and a warning on a misspelled group
229
+ name. `codes sync` is still the authority — it checks things a schema cannot,
230
+ such as key uniqueness and whether `end` is after `start`.
231
+
232
+ Publications read the lock file offline. In a Quarto book, for example, add
233
+ `metadata-files: [ddp-activities.lock.yaml]` to `_quarto.yml` and let the
234
+ shortcode look the key up in `activity-codes` — the book then renders without
235
+ ever calling the app.
236
+
153
237
  ## Triaging student reports (teacher account required)
154
238
 
155
239
  Students can flag an AI interaction — a chat or a graded quiz answer — with a
package/dist/main.js CHANGED
@@ -1,16 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { Command } from "commander";
4
+ import { readFile, writeFile } from "node:fs/promises";
5
+ import { basename, dirname, join, resolve } from "node:path";
4
6
  import { spawn } from "node:child_process";
5
7
  import { createServer } from "node:http";
6
8
  import { homedir } from "node:os";
7
- import { dirname, join, resolve } from "node:path";
8
9
  import { CryptoProvider, PublicClientApplication } from "@azure/msal-node";
9
- import { readFile } from "node:fs/promises";
10
+ import { parse, stringify } from "yaml";
11
+ import { z } from "zod";
10
12
  import { fileURLToPath, pathToFileURL } from "node:url";
11
13
  import Handlebars from "handlebars";
12
- import { parse } from "yaml";
13
- import { z } from "zod";
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";
@@ -174,8 +174,16 @@ async function acquireByDeviceCode(pca, onMessage) {
174
174
  * The one call every API command makes: a silently-acquired access token for
175
175
  * the Authorization header. Throws NotSignedInError when interactive login is
176
176
  * required first.
177
+ *
178
+ * `NOVEDU_TOKEN` short-circuits the MSAL cache with a caller-supplied bearer
179
+ * token. It exists for TESTS and CI (the CLI integration suite runs the real
180
+ * binary against a fake API, with no browser to sign in) — the token is still
181
+ * validated by the server on every request, so this weakens nothing; it only
182
+ * removes the interactive step. Not a substitute for `login` in normal use.
177
183
  */
178
184
  async function getAccessToken() {
185
+ const override = process.env.NOVEDU_TOKEN?.trim();
186
+ if (override) return override;
179
187
  const result = await acquireSilent(buildPca());
180
188
  if (!result) throw new NotSignedInError();
181
189
  return result.accessToken;
@@ -206,20 +214,30 @@ function failJson(value) {
206
214
  * network, non-2xx) it prints the JSON error to stderr per the contract —
207
215
  * server error bodies (`{ message }` — incl. the generic 401/403 — or
208
216
  * `{ errors }`) passed through VERBATIM — marks the process failed, and
209
- * returns `{ ok: false }`. On success it returns the parsed payload WITHOUT
210
- * printing, so multi-step commands (`images upload`) can consume intermediate
211
- * responses silently. No client-side pre-validation: the server runs the
212
- * identical pipeline; offline checking is the `validate` command's job.
217
+ * returns `{ ok: false, error }` with that same payload. On success it returns
218
+ * the parsed payload WITHOUT printing, so multi-step commands (`images upload`)
219
+ * can consume intermediate responses silently. No client-side pre-validation:
220
+ * the server runs the identical pipeline; offline checking is the `validate`
221
+ * command's job.
222
+ *
223
+ * `quiet` suppresses both the stderr print and the exit-code marking and hands
224
+ * the failure payload back instead — for commands that make MANY requests and
225
+ * report the outcome themselves (`codes sync`, where one entry's rejection must
226
+ * not abort the run).
213
227
  */
214
228
  async function performApiRequest(options) {
229
+ const fail = (value) => {
230
+ if (!options.quiet) failJson(value);
231
+ return {
232
+ ok: false,
233
+ error: value
234
+ };
235
+ };
215
236
  let token;
216
237
  try {
217
238
  token = await getAccessToken();
218
239
  } catch (error) {
219
- if (error instanceof NotSignedInError) {
220
- failJson({ message: error.message });
221
- return { ok: false };
222
- }
240
+ if (error instanceof NotSignedInError) return fail({ message: error.message });
223
241
  throw error;
224
242
  }
225
243
  const server = resolveServerUrl(options.server);
@@ -234,8 +252,7 @@ async function performApiRequest(options) {
234
252
  body: options.body === void 0 ? void 0 : JSON.stringify(options.body)
235
253
  });
236
254
  } catch (error) {
237
- failJson({ message: `Could not reach ${server}: ${error instanceof Error ? error.message : error}` });
238
- return { ok: false };
255
+ return fail({ message: `Could not reach ${server}: ${error instanceof Error ? error.message : error}` });
239
256
  }
240
257
  let payload;
241
258
  try {
@@ -243,10 +260,7 @@ async function performApiRequest(options) {
243
260
  } catch {
244
261
  payload = void 0;
245
262
  }
246
- if (!response.ok) {
247
- failJson(payload ?? { message: `${server} rejected the request: HTTP ${response.status}` });
248
- return { ok: false };
249
- }
263
+ if (!response.ok) return fail(payload ?? { message: `${server} rejected the request: HTTP ${response.status}` });
250
264
  return {
251
265
  ok: true,
252
266
  payload
@@ -262,8 +276,566 @@ async function runApiRequest(options) {
262
276
  if (result.ok) printJson(result.payload ?? null);
263
277
  }
264
278
  //#endregion
279
+ //#region ../lib/llm/provider.ts
280
+ const LLM_PROVIDERS = ["SCCH", "Azure Foundry"];
281
+ const providerSchema = z.enum(LLM_PROVIDERS).default("SCCH").meta({ description: "The LLM provider serving the model. For Azure Foundry, model is the deployment name." });
282
+ //#endregion
283
+ //#region ../lib/registry-schema.ts
284
+ /** The fixed group names and the code module each one mints for. */
285
+ const GROUP_MODULES = {
286
+ quizzes: "quiz",
287
+ tutors: "tutor",
288
+ writing: "writing",
289
+ coding: "coding"
290
+ };
291
+ const GROUP_NAMES = Object.keys(GROUP_MODULES);
292
+ /** Registry keys share the lock file's flat namespace, so they stay URL/YAML-plain. */
293
+ const KEY_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
294
+ const EXPLICIT_OFFSET = /(?:Z|[+-]\d{2}:?\d{2})$/;
295
+ function timestampField(field) {
296
+ return z.string().refine((value) => EXPLICIT_OFFSET.test(value) && !Number.isNaN(Date.parse(value)), `${field} must be an ISO 8601 datetime with an explicit offset or Z, e.g. 2026-09-01T08:00:00+02:00`).refine((value) => {
297
+ const parsed = Date.parse(value);
298
+ return Number.isNaN(parsed) || parsed % 1e3 === 0;
299
+ }, `${field} must not carry sub-second precision — the server stores whole seconds`);
300
+ }
301
+ const providerField = z.enum(LLM_PROVIDERS, { error: "must be \"SCCH\" or \"Azure Foundry\"" });
302
+ /**
303
+ * One registry entry. Unknown extra properties are ACCEPTED and ignored so authors can
304
+ * annotate freely and a newer registry keeps working with an older CLI — which is why
305
+ * this is a `looseObject` and the generated JSON Schema does NOT flag a misspelled field.
306
+ */
307
+ const RegistryEntrySchema = z.looseObject({
308
+ file: z.string().trim().min(1).optional().meta({ description: "Path to the activity YAML, resolved against `base-url`. Give exactly one of `file` or `url`." }),
309
+ url: z.string().trim().min(1).optional().meta({ description: "Absolute http(s) URL of the activity YAML. Give exactly one of `file` or `url`." }),
310
+ start: timestampField("start").optional().meta({ description: "Start of the code's validity window, ISO 8601 with an explicit offset or Z (e.g. 2026-09-01T08:00:00+02:00), whole seconds." }),
311
+ end: timestampField("end").optional().meta({ description: "End of the code's validity window, same format as `start`, and must be after it." }),
312
+ note: z.string().trim().max(200, `note must be at most 200 characters`).optional().meta({ description: `Note shown in the codes list, at most 200 characters. No effect on behaviour.` }),
313
+ llm: z.looseObject({
314
+ provider: providerField.meta({ description: "LLM provider override for this code. Required when `llm` is present." }),
315
+ model: z.string().trim().min(1).max(256).meta({ description: "Model id (for Azure Foundry, the deployment name). Required when `llm` is present." })
316
+ }).optional().meta({ description: "Per-code LLM override replacing the activity YAML's own `llm:`. Provider and model must be given together." })
317
+ }).refine((entry) => entry.file === void 0 !== (entry.url === void 0), "give exactly one of `file` (relative to base-url) or `url` (absolute)").meta({
318
+ id: "registryEntry",
319
+ description: "One activity: where its YAML lives, plus the parameters its code is minted with."
320
+ });
321
+ /** A group holds `key: entry` pairs; an empty group (`quizzes:` with nothing under it) is fine. */
322
+ function groupOf(group) {
323
+ return z.record(z.string().regex(KEY_PATTERN).max(64), RegistryEntrySchema).nullable().optional().meta({ description: `Activities minted as \`${GROUP_MODULES[group]}\` codes, keyed by the name your material references.` });
324
+ }
325
+ z.looseObject({
326
+ "base-url": z.string().optional().meta({ description: "Base URL each entry's `file` is resolved against. Must end with a slash. Only needed when an entry uses `file`." }),
327
+ activities: z.strictObject(Object.fromEntries(GROUP_NAMES.map((group) => [group, groupOf(group)]))).meta({ description: "The activities, grouped by the kind of code each one is minted as." })
328
+ });
329
+ //#endregion
330
+ //#region src/registry.ts
331
+ const rootSchema = z.looseObject({
332
+ "base-url": z.string().optional(),
333
+ activities: z.record(z.string(), z.unknown(), { error: "activities must be a mapping of activity groups" })
334
+ });
335
+ function issue(code, path, message) {
336
+ return {
337
+ code,
338
+ path,
339
+ message
340
+ };
341
+ }
342
+ /** Turns zod's issue list into registry issues rooted at `basePath`. */
343
+ function schemaIssues(error, basePath) {
344
+ return error.issues.map((item) => issue("REGISTRY_SCHEMA_ERROR", [basePath, ...item.path.map(String)].filter(Boolean).join("."), item.message));
345
+ }
346
+ /** A plain YAML mapping — the shape both a group and an entry must have. */
347
+ function isMapping(value) {
348
+ return typeof value === "object" && value !== null && !Array.isArray(value);
349
+ }
350
+ /**
351
+ * Parses and validates a registry document, resolving every entry's file URL.
352
+ * Returns ALL issues found rather than the first — a registry is edited by hand,
353
+ * so one run should surface every problem.
354
+ */
355
+ function parseRegistry(text) {
356
+ let document;
357
+ try {
358
+ document = parse(text);
359
+ } catch (error) {
360
+ return {
361
+ ok: false,
362
+ errors: [issue("REGISTRY_PARSE_ERROR", "", `Invalid YAML: ${error instanceof Error ? error.message : String(error)}`)]
363
+ };
364
+ }
365
+ const root = rootSchema.safeParse(document ?? {});
366
+ if (!root.success) return {
367
+ ok: false,
368
+ errors: schemaIssues(root.error, "")
369
+ };
370
+ const errors = [];
371
+ const entries = [];
372
+ const seenKeys = /* @__PURE__ */ new Map();
373
+ const baseUrlText = root.data["base-url"];
374
+ let baseUrl;
375
+ if (baseUrlText !== void 0) {
376
+ const parsed = safeHttpUrl(baseUrlText);
377
+ if (!parsed) errors.push(issue("REGISTRY_SCHEMA_ERROR", "base-url", "must be an http(s) URL"));
378
+ else if (!parsed.endsWith("/")) errors.push(issue("REGISTRY_SCHEMA_ERROR", "base-url", "must end with a slash — it is resolved against, not concatenated with, each entry's `file`"));
379
+ else baseUrl = parsed;
380
+ }
381
+ for (const [groupName, groupValue] of Object.entries(root.data.activities)) {
382
+ const groupPath = `activities.${groupName}`;
383
+ if (!(groupName in GROUP_MODULES)) {
384
+ errors.push(issue("REGISTRY_SCHEMA_ERROR", groupPath, `unknown activity group — use one of ${GROUP_NAMES.join(", ")}`));
385
+ continue;
386
+ }
387
+ if (groupValue === null || groupValue === void 0) continue;
388
+ if (!isMapping(groupValue)) {
389
+ errors.push(issue("REGISTRY_SCHEMA_ERROR", groupPath, "must be a mapping of key → entry"));
390
+ continue;
391
+ }
392
+ const module = GROUP_MODULES[groupName];
393
+ for (const [key, value] of Object.entries(groupValue)) {
394
+ if (value !== null && !isMapping(value)) continue;
395
+ const entryPath = `${groupPath}.${key}`;
396
+ if (value === null) {
397
+ errors.push(issue("REGISTRY_SCHEMA_ERROR", entryPath, "entry has no fields — check the indentation of the lines below it"));
398
+ continue;
399
+ }
400
+ if (!KEY_PATTERN.test(key) || key.length > 64) {
401
+ errors.push(issue("REGISTRY_SCHEMA_ERROR", entryPath, `invalid key — use lowercase letters, digits and hyphens (max 64 characters)`));
402
+ continue;
403
+ }
404
+ const previousGroup = seenKeys.get(key);
405
+ if (previousGroup) {
406
+ errors.push(issue("REGISTRY_SCHEMA_ERROR", entryPath, `duplicate key — already defined under activities.${previousGroup}; keys are unique across all groups`));
407
+ continue;
408
+ }
409
+ seenKeys.set(key, groupName);
410
+ const parsed = RegistryEntrySchema.safeParse(value);
411
+ if (!parsed.success) {
412
+ errors.push(...schemaIssues(parsed.error, entryPath));
413
+ continue;
414
+ }
415
+ const entry = parsed.data;
416
+ if (entry.start && entry.end && Date.parse(entry.end) <= Date.parse(entry.start)) {
417
+ errors.push(issue("REGISTRY_SCHEMA_ERROR", `${entryPath}.end`, "must be after `start`"));
418
+ continue;
419
+ }
420
+ let fileUrl;
421
+ if (entry.url !== void 0) {
422
+ fileUrl = safeHttpUrl(entry.url);
423
+ if (!fileUrl) {
424
+ errors.push(issue("REGISTRY_SCHEMA_ERROR", `${entryPath}.url`, "must be an absolute http(s) URL"));
425
+ continue;
426
+ }
427
+ } else if (baseUrl === void 0) {
428
+ errors.push(issue("REGISTRY_SCHEMA_ERROR", `${entryPath}.file`, baseUrlText === void 0 ? "`file` is relative, but the registry has no `base-url`" : "`file` cannot be resolved — fix `base-url`"));
429
+ continue;
430
+ } else {
431
+ fileUrl = safeHttpUrl(entry.file ?? "", baseUrl);
432
+ if (!fileUrl) {
433
+ errors.push(issue("REGISTRY_SCHEMA_ERROR", `${entryPath}.file`, "does not resolve to an http(s) URL against `base-url`"));
434
+ continue;
435
+ }
436
+ }
437
+ entries.push({
438
+ key,
439
+ module,
440
+ fileUrl,
441
+ validFrom: entry.start ?? null,
442
+ validUntil: entry.end ?? null,
443
+ note: entry.note ?? null,
444
+ llm: entry.llm ? {
445
+ provider: entry.llm.provider,
446
+ model: entry.llm.model
447
+ } : null
448
+ });
449
+ }
450
+ }
451
+ return errors.length > 0 ? {
452
+ ok: false,
453
+ errors
454
+ } : {
455
+ ok: true,
456
+ entries
457
+ };
458
+ }
459
+ /**
460
+ * Normalizes a (possibly relative) URL the way `validateCodeRequest` does —
461
+ * `URL.href` — so a resolved entry compares byte-identical to the `file_url` the
462
+ * server stored. Returns undefined for anything that is not http(s).
463
+ */
464
+ function safeHttpUrl(value, base) {
465
+ let url;
466
+ try {
467
+ url = new URL(value.trim(), base);
468
+ } catch {
469
+ return;
470
+ }
471
+ return url.protocol === "http:" || url.protocol === "https:" ? url.href : void 0;
472
+ }
473
+ /** Reads and validates a registry file; a read failure is reported like a schema issue. */
474
+ async function loadRegistry(path) {
475
+ let text;
476
+ try {
477
+ text = await readFile(path, "utf8");
478
+ } catch (error) {
479
+ return {
480
+ ok: false,
481
+ errors: [issue("REGISTRY_READ_ERROR", "", `Could not read ${path}: ${error instanceof Error ? error.message : String(error)}`)]
482
+ };
483
+ }
484
+ return parseRegistry(text);
485
+ }
486
+ /** The lock file that belongs to a registry: `<name>.yaml` → `<name>.lock.yaml`. */
487
+ function defaultLockPath(registryPath) {
488
+ return `${registryPath.replace(/\.ya?ml$/i, "")}.lock.yaml`;
489
+ }
490
+ //#endregion
491
+ //#region src/sync.ts
492
+ /**
493
+ * Narrows the `GET /api/codes` payload to the fields matching needs, dropping
494
+ * anything unrecognizable. The CLI never fails on an unexpected extra field —
495
+ * the server may grow the shape at any time.
496
+ */
497
+ function parseServerCodes(payload) {
498
+ if (!Array.isArray(payload)) return [];
499
+ const codes = [];
500
+ for (const row of payload) {
501
+ if (typeof row !== "object" || row === null) continue;
502
+ const value = row;
503
+ if (typeof value.code !== "string" || typeof value.fileUrl !== "string") continue;
504
+ if (typeof value.module !== "string") continue;
505
+ const llm = value.llm;
506
+ codes.push({
507
+ code: value.code,
508
+ url: typeof value.url === "string" ? value.url : null,
509
+ module: value.module,
510
+ fileUrl: value.fileUrl,
511
+ note: typeof value.note === "string" ? value.note : null,
512
+ validFrom: typeof value.validFrom === "string" ? value.validFrom : null,
513
+ validUntil: typeof value.validUntil === "string" ? value.validUntil : null,
514
+ llm: typeof llm === "object" && llm !== null ? {
515
+ provider: String(llm.provider ?? ""),
516
+ model: String(llm.model ?? "")
517
+ } : null,
518
+ createdAt: typeof value.createdAt === "string" ? value.createdAt : null
519
+ });
520
+ }
521
+ return codes;
522
+ }
523
+ /**
524
+ * Window bounds compare as INSTANTS, not as strings: the registry may spell a
525
+ * moment `+02:00` while the server always answers in `Z`. An absent bound (null)
526
+ * only matches an absent one.
527
+ */
528
+ function sameInstant(a, b) {
529
+ if (a === null || b === null) return a === b;
530
+ const left = Date.parse(a);
531
+ const right = Date.parse(b);
532
+ return !Number.isNaN(left) && left === right;
533
+ }
534
+ function sameLlm(a, b) {
535
+ if (a === null || b === null) return a === b;
536
+ return a.provider === b.provider && a.model === b.model;
537
+ }
538
+ /**
539
+ * The codes that ARE this entry: same activity URL, module and availability
540
+ * window, same LLM override. `note` is deliberately excluded — it is a label for
541
+ * the teacher, not part of the code's behavior, so editing it must not fork a
542
+ * new code. Newest first, so the caller reuses the most recent one.
543
+ */
544
+ function matchEntry(entry, codes) {
545
+ return codes.filter((code) => code.fileUrl === entry.fileUrl && code.module === entry.module && sameInstant(code.validFrom, entry.validFrom) && sameInstant(code.validUntil, entry.validUntil) && sameLlm(entry.llm, code.llm)).sort((a, b) => Date.parse(b.createdAt ?? "") - Date.parse(a.createdAt ?? "") || 0);
546
+ }
547
+ /**
548
+ * Picks ONE code per registry key out of the pool, so a key's code never moves
549
+ * while a matching code exists.
550
+ *
551
+ * Two entries may legitimately describe the same activity with the same window —
552
+ * one quiz linked from two chapters, each wanting its own statistics — and both
553
+ * then match both of the codes that minted for them. Taking "the newest match"
554
+ * per entry independently would hand BOTH keys the same code, strand the other,
555
+ * and flip the assignment whenever a newer code appeared: a key's published code
556
+ * would move under the students already using it, and two consecutive runs of an
557
+ * unchanged registry would write different lock files. Selection therefore
558
+ * claims: every key that still matches the code it already has keeps it (all of
559
+ * them, before any key takes a free one), then each remaining key takes the
560
+ * newest code nobody has claimed.
561
+ */
562
+ function selectMatches(entries, codes, previousLock) {
563
+ const candidates = /* @__PURE__ */ new Map();
564
+ for (const entry of entries) candidates.set(entry.key, matchEntry(entry, codes));
565
+ const selected = /* @__PURE__ */ new Map();
566
+ const claimed = /* @__PURE__ */ new Set();
567
+ for (const entry of entries) {
568
+ const previous = previousLock[entry.key];
569
+ if (!previous || claimed.has(previous)) continue;
570
+ const kept = candidates.get(entry.key)?.find((code) => code.code === previous);
571
+ if (!kept) continue;
572
+ selected.set(entry.key, kept);
573
+ claimed.add(kept.code);
574
+ }
575
+ for (const entry of entries) {
576
+ if (selected.has(entry.key)) continue;
577
+ const free = candidates.get(entry.key)?.find((code) => !claimed.has(code.code));
578
+ if (!free) continue;
579
+ selected.set(entry.key, free);
580
+ claimed.add(free.code);
581
+ }
582
+ return selected;
583
+ }
584
+ /** The mint body for an entry — exactly what `POST /api/codes` accepts. */
585
+ function mintBody(entry) {
586
+ return {
587
+ module: entry.module,
588
+ fileUrl: entry.fileUrl,
589
+ ...entry.validFrom === null ? {} : { validFrom: entry.validFrom },
590
+ ...entry.validUntil === null ? {} : { validUntil: entry.validUntil },
591
+ ...entry.note === null ? {} : { note: entry.note },
592
+ ...entry.llm === null ? {} : { llm: entry.llm }
593
+ };
594
+ }
595
+ /**
596
+ * Advisory findings for one run: codes the registry no longer describes but that
597
+ * still exist for one of its activities (a parameter change mints a NEW code —
598
+ * the old one is never touched), several codes matching one entry, and lock keys
599
+ * that have left the registry. Nothing here is an error; superseded codes stay
600
+ * live until a teacher deletes them in the web app.
601
+ */
602
+ function collectWarnings(results, serverCodes, previousLock) {
603
+ const warnings = [];
604
+ const claimed = /* @__PURE__ */ new Set();
605
+ const inUse = new Set(results.filter((result) => result.action === "reused").map((result) => result.code));
606
+ for (const result of results) {
607
+ if (result.action !== "reused") continue;
608
+ const matches = matchEntry(result.entry, serverCodes);
609
+ for (const match of matches) claimed.add(match.code);
610
+ const spare = matches.filter((match) => match.code !== result.code && !inUse.has(match.code));
611
+ if (spare.length > 0) warnings.push({
612
+ type: "duplicate",
613
+ key: result.entry.key,
614
+ codes: matches.map((match) => match.code),
615
+ message: `${result.entry.key}: ${matches.length} codes match this entry — using ${result.code}; unused: ${spare.map((match) => match.code).join(", ")}`
616
+ });
617
+ const matched = matches.find((match) => match.code === result.code);
618
+ if (matched && (matched.note ?? "") !== (result.entry.note ?? "")) warnings.push({
619
+ type: "note",
620
+ key: result.entry.key,
621
+ codes: [matched.code],
622
+ message: `${result.entry.key}: the existing code's note differs from the registry's — a note is never re-applied to a minted code`
623
+ });
624
+ }
625
+ const activities = new Set(results.map((result) => `${result.entry.module} ${result.entry.fileUrl}`));
626
+ const superseded = serverCodes.filter((code) => !claimed.has(code.code) && activities.has(`${code.module} ${code.fileUrl}`));
627
+ for (const code of superseded) warnings.push({
628
+ type: "superseded",
629
+ codes: [code.code],
630
+ message: `${code.code}: an older code for ${code.fileUrl} no longer matches any registry entry — it still works; delete it in the web app when the class has moved on`
631
+ });
632
+ const keys = new Set(results.map((result) => result.entry.key));
633
+ for (const key of Object.keys(previousLock)) {
634
+ if (keys.has(key)) continue;
635
+ warnings.push({
636
+ type: "orphaned",
637
+ key,
638
+ codes: [previousLock[key] ?? ""],
639
+ message: `${key}: in the lock file but no longer in the registry — dropped from the lock; the code ${previousLock[key]} still exists`
640
+ });
641
+ }
642
+ return warnings;
643
+ }
644
+ /**
645
+ * The lock content for a run. An entry that FAILED this run keeps the code the
646
+ * previous lock had for it: a transient server error must never break the
647
+ * consumer's build. A failed entry with no previous code is simply absent.
648
+ */
649
+ function buildLockCodes(results, previousLock) {
650
+ const codes = {};
651
+ for (const result of results) {
652
+ const code = result.code ?? previousLock[result.entry.key];
653
+ if (code) codes[result.entry.key] = code;
654
+ }
655
+ return codes;
656
+ }
657
+ /** The single top-level key of a lock file — namespaced so it can be merged into other metadata. */
658
+ const LOCK_ROOT_KEY = "activity-codes";
659
+ /** Serializes the lock file: keys sorted, so a re-run produces a byte-identical file. */
660
+ function serializeLock(codes, registryFileName) {
661
+ const sorted = {};
662
+ for (const key of Object.keys(codes).sort()) sorted[key] = codes[key];
663
+ return [
664
+ "# Generated by @novedu/cli — do not edit.",
665
+ `# Regenerate with: novedu-cli codes sync ${registryFileName}`,
666
+ stringify({ [LOCK_ROOT_KEY]: sorted })
667
+ ].join("\n");
668
+ }
669
+ /** Reads a lock file's `activity-codes` map; anything unusable yields an empty map. */
670
+ function parseLock(text) {
671
+ let document;
672
+ try {
673
+ document = parse(text);
674
+ } catch {
675
+ return {};
676
+ }
677
+ if (typeof document !== "object" || document === null) return {};
678
+ const map = document[LOCK_ROOT_KEY];
679
+ if (typeof map !== "object" || map === null) return {};
680
+ const codes = {};
681
+ for (const [key, value] of Object.entries(map)) if (typeof value === "string" && value) codes[key] = value;
682
+ return codes;
683
+ }
684
+ /**
685
+ * The human-readable report: one line per entry (action, key, code, share URL or
686
+ * the server's complaint), then the advisory findings, then a summary. Returned
687
+ * as lines so the command decides where they go.
688
+ */
689
+ function formatSyncReport(results, warnings, options) {
690
+ const width = Math.max(0, ...results.map((result) => result.entry.key.length));
691
+ const label = (action) => (options.dryRun && action === "minted" ? "would mint" : action).padEnd(9);
692
+ const lines = [`${options.registryFileName}: ${results.length} ${results.length === 1 ? "entry" : "entries"}`];
693
+ for (const result of results) {
694
+ const detail = result.action === "failed" ? describeError(result.error) : result.url ?? result.code ?? "(not minted — dry run)";
695
+ lines.push(` ${label(result.action)} ${result.entry.key.padEnd(width)} ${result.code ? `${result.code} ` : ""}${detail}`);
696
+ }
697
+ if (warnings.length > 0) {
698
+ lines.push("", "Notes:");
699
+ for (const warning of warnings) lines.push(` - ${warning.message}`);
700
+ }
701
+ const counts = {
702
+ reused: 0,
703
+ minted: 0,
704
+ failed: 0
705
+ };
706
+ for (const result of results) counts[result.action] += 1;
707
+ lines.push("", `${counts.reused} reused, ${counts.minted} ${options.dryRun ? "to mint" : "minted"}, ${counts.failed} failed`);
708
+ return lines;
709
+ }
710
+ /** A one-line rendering of the server's failure payload for the report. */
711
+ function describeError(error) {
712
+ if (typeof error !== "object" || error === null) return String(error ?? "unknown error");
713
+ const value = error;
714
+ if (typeof value.message === "string") return value.message;
715
+ if (Array.isArray(value.errors)) return value.errors.map((item) => {
716
+ if (typeof item !== "object" || item === null) return String(item);
717
+ const detail = item;
718
+ return [detail.code, detail.message].filter(Boolean).join(": ");
719
+ }).join("; ");
720
+ return JSON.stringify(error);
721
+ }
722
+ //#endregion
265
723
  //#region src/commands/codes.ts
266
724
  const SERVER_OPTION$3 = ["--server <url>", "Novedu server base URL (defaults to the NOVEDU_SERVER env var, then production)"];
725
+ /**
726
+ * Reconciles a registry file with the server and rewrites its lock file: match
727
+ * every entry against the caller's existing codes (URL + module + window + LLM
728
+ * override), mint what has no match, and report the rest. Existing codes are
729
+ * never modified or deleted — changed parameters produce a NEW code and the old
730
+ * one is reported as superseded (docs/registry.md).
731
+ */
732
+ async function runSync(registryFile, options) {
733
+ const registry = await loadRegistry(registryFile);
734
+ if (!registry.ok) {
735
+ failJson({
736
+ message: `${registryFile} is not a usable activity registry.`,
737
+ errors: registry.errors
738
+ });
739
+ return;
740
+ }
741
+ const lockPath = options.lock ?? defaultLockPath(registryFile);
742
+ const previousLock = await readLock(lockPath);
743
+ const listed = await performApiRequest({
744
+ server: options.server,
745
+ path: "/api/codes"
746
+ });
747
+ if (!listed.ok) return;
748
+ const serverCodes = parseServerCodes(listed.payload);
749
+ const results = [];
750
+ const selected = selectMatches(registry.entries, serverCodes, previousLock);
751
+ for (const entry of registry.entries) {
752
+ const match = selected.get(entry.key);
753
+ if (match) {
754
+ results.push({
755
+ entry,
756
+ action: "reused",
757
+ code: match.code,
758
+ url: match.url ?? void 0
759
+ });
760
+ continue;
761
+ }
762
+ if (options.dryRun) {
763
+ results.push({
764
+ entry,
765
+ action: "minted"
766
+ });
767
+ continue;
768
+ }
769
+ const created = await performApiRequest({
770
+ server: options.server,
771
+ path: "/api/codes",
772
+ method: "POST",
773
+ body: mintBody(entry),
774
+ quiet: true
775
+ });
776
+ if (!created.ok) {
777
+ results.push({
778
+ entry,
779
+ action: "failed",
780
+ error: created.error
781
+ });
782
+ continue;
783
+ }
784
+ const minted = created.payload;
785
+ if (typeof minted?.code !== "string" || minted.code === "") {
786
+ results.push({
787
+ entry,
788
+ action: "failed",
789
+ error: { message: "the server accepted the request but returned no code" }
790
+ });
791
+ continue;
792
+ }
793
+ results.push({
794
+ entry,
795
+ action: "minted",
796
+ code: minted.code,
797
+ url: typeof minted.url === "string" ? minted.url : void 0
798
+ });
799
+ }
800
+ const warnings = collectWarnings(results, serverCodes, previousLock);
801
+ const failed = results.filter((result) => result.action === "failed").length;
802
+ if (options.json) printJson({
803
+ ...options.dryRun ? { dryRun: true } : {},
804
+ entries: results.map((result) => ({
805
+ key: result.entry.key,
806
+ module: result.entry.module,
807
+ fileUrl: result.entry.fileUrl,
808
+ action: result.action,
809
+ ...result.code ? { code: result.code } : {},
810
+ ...result.url ? { url: result.url } : {},
811
+ ...result.action === "failed" ? { error: result.error } : {}
812
+ })),
813
+ warnings
814
+ });
815
+ else for (const line of formatSyncReport(results, warnings, {
816
+ registryFileName: basename(registryFile),
817
+ dryRun: Boolean(options.dryRun)
818
+ })) console.log(line);
819
+ if (!options.dryRun) {
820
+ const lock = serializeLock(buildLockCodes(results, previousLock), basename(registryFile));
821
+ try {
822
+ await writeFile(lockPath, lock, "utf8");
823
+ } catch (error) {
824
+ failJson({ message: `Could not write the lock file ${lockPath}: ${error instanceof Error ? error.message : error}` });
825
+ return;
826
+ }
827
+ if (!options.json) console.log(`Lock file: ${lockPath}`);
828
+ }
829
+ if (failed > 0) process.exitCode = 1;
830
+ }
831
+ /** The lock file's previous content; a missing or unreadable lock is simply empty. */
832
+ async function readLock(lockPath) {
833
+ try {
834
+ return parseLock(await readFile(lockPath, "utf8"));
835
+ } catch {
836
+ return {};
837
+ }
838
+ }
267
839
  function registerCodes(program) {
268
840
  const codes = program.command("codes").description("Manage activity codes on the Novedu server");
269
841
  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$3).action(async (options) => {
@@ -295,6 +867,9 @@ function registerCodes(program) {
295
867
  path: `/api/codes${query ? `?${query}` : ""}`
296
868
  });
297
869
  });
870
+ codes.command("sync").description("Reconcile an activity registry file with the server and write its lock file").argument("<registry-file>", "path to the hand-written activity registry YAML").option("--lock <path>", "lock file path (default: the registry path with .lock.yaml)").option("--dry-run", "report what would happen; mint nothing and write no lock file").option("--json", "machine-readable report on stdout instead of the per-entry lines").option(...SERVER_OPTION$3).action(async (registryFile, options) => {
871
+ await runSync(registryFile, options);
872
+ });
298
873
  }
299
874
  //#endregion
300
875
  //#region src/commands/files.ts
@@ -1576,7 +2151,6 @@ async function loadAndCheckFragmentFile(url, fetchImpl, opts = {}) {
1576
2151
  };
1577
2152
  return checkFragmentFileValue(yaml.value, url);
1578
2153
  }
1579
- const providerSchema = z.enum(["SCCH", "Azure Foundry"]).default("SCCH").meta({ description: "The LLM provider serving the model. For Azure Foundry, model is the deployment name." });
1580
2154
  //#endregion
1581
2155
  //#region ../lib/coding-schema.ts
1582
2156
  const CodingYamlSchema = z.strictObject({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@novedu/cli",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
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, app-hosted files and images over the app's API.",
5
5
  "type": "module",
6
6
  "repository": {