@novedu/cli 0.16.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.
- package/README.md +84 -0
- package/dist/main.js +649 -49
- 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 {
|
|
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 }
|
|
210
|
-
* printing, so multi-step commands (`images upload`)
|
|
211
|
-
* responses silently. No client-side pre-validation:
|
|
212
|
-
* identical pipeline; offline checking is the `validate`
|
|
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
|
-
|
|
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
|
|
@@ -1380,11 +1955,31 @@ async function loadYaml(url, fetchImpl, opts = {}) {
|
|
|
1380
1955
|
* Handlebars, so a literal `{{` in course material can never execute.
|
|
1381
1956
|
*/
|
|
1382
1957
|
async function assembleFragmentPrompt(block, baseUrl, fetchImpl, opts = {}, hostText = "") {
|
|
1958
|
+
const result = await assembleFragmentPrompts(block, baseUrl, fetchImpl, opts, [hostText]);
|
|
1959
|
+
if (!result.ok) return result;
|
|
1960
|
+
return {
|
|
1961
|
+
ok: true,
|
|
1962
|
+
prompt: result.prompts[0] ?? "",
|
|
1963
|
+
warnings: result.warnings
|
|
1964
|
+
};
|
|
1965
|
+
}
|
|
1966
|
+
/**
|
|
1967
|
+
* The multi-host variant of {@link assembleFragmentPrompt}: renders SEVERAL host texts
|
|
1968
|
+
* of the SAME document (e.g. a quiz's `instructions` and `discussion.instructions`)
|
|
1969
|
+
* against the document's one fragment block in a single pass — every fragment library
|
|
1970
|
+
* and text file is fetched and checked ONCE, placements are parsed per host but
|
|
1971
|
+
* consistency-checked in ONE pass over their union (so a library placed in any host
|
|
1972
|
+
* counts as used), and each host renders on its own. Any error fails the whole call
|
|
1973
|
+
* (fail closed); `prompts` is index-aligned with `hostTexts`. The template-semantics
|
|
1974
|
+
* opt-in applies identically: with neither list declared, ALL host texts return
|
|
1975
|
+
* byte-verbatim.
|
|
1976
|
+
*/
|
|
1977
|
+
async function assembleFragmentPrompts(block, baseUrl, fetchImpl, opts = {}, hostTexts = []) {
|
|
1383
1978
|
const warnings = [];
|
|
1384
1979
|
const allowedSchemes = opts.allowedSchemes ?? DEFAULT_ALLOWED_SCHEMES;
|
|
1385
1980
|
if (block.fragment_files.length === 0 && block.text_files.length === 0) return {
|
|
1386
1981
|
ok: true,
|
|
1387
|
-
|
|
1982
|
+
prompts: [...hostTexts],
|
|
1388
1983
|
warnings
|
|
1389
1984
|
};
|
|
1390
1985
|
const fragmentSettledPromise = Promise.all(block.fragment_files.map(async (ref) => {
|
|
@@ -1497,13 +2092,14 @@ async function assembleFragmentPrompt(block, baseUrl, fetchImpl, opts = {}, host
|
|
|
1497
2092
|
libraryErrors.push(...checked.errors);
|
|
1498
2093
|
warnings.push(...checked.warnings);
|
|
1499
2094
|
}
|
|
1500
|
-
const
|
|
1501
|
-
|
|
2095
|
+
const parsedHosts = hostTexts.map((hostText) => parseHostPlacements(hostText));
|
|
2096
|
+
const parseErrors = parsedHosts.flatMap((parsed) => parsed.errors);
|
|
2097
|
+
if (parseErrors.length > 0) return {
|
|
1502
2098
|
ok: false,
|
|
1503
|
-
errors: [...libraryErrors, ...
|
|
2099
|
+
errors: [...libraryErrors, ...parseErrors],
|
|
1504
2100
|
warnings
|
|
1505
2101
|
};
|
|
1506
|
-
const placementCheck = checkPlacements(parsed.placements, fragmentFilesByAlias, block.fragment_files, textFilesByAlias, block.text_files, opts.validateLibraries ?? false);
|
|
2102
|
+
const placementCheck = checkPlacements(parsedHosts.flatMap((parsed) => parsed.placements), fragmentFilesByAlias, block.fragment_files, textFilesByAlias, block.text_files, opts.validateLibraries ?? false);
|
|
1507
2103
|
warnings.push(...placementCheck.warnings);
|
|
1508
2104
|
const preRenderErrors = [...libraryErrors, ...placementCheck.errors];
|
|
1509
2105
|
if (preRenderErrors.length > 0) return {
|
|
@@ -1511,28 +2107,33 @@ async function assembleFragmentPrompt(block, baseUrl, fetchImpl, opts = {}, host
|
|
|
1511
2107
|
errors: preRenderErrors,
|
|
1512
2108
|
warnings
|
|
1513
2109
|
};
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
warnings
|
|
1528
|
-
};
|
|
2110
|
+
const prompts = [];
|
|
2111
|
+
const renderErrors = [];
|
|
2112
|
+
for (const hostText of hostTexts) try {
|
|
2113
|
+
prompts.push(renderHostTemplate(hostText, (ref, args) => {
|
|
2114
|
+
const resolved = resolveAndMerge(ref, args, fragmentFilesByAlias);
|
|
2115
|
+
if (resolved.content === null || resolved.errors.length > 0) throw new Error(`Fragment "${ref}" could not be resolved`);
|
|
2116
|
+
return renderFragmentContent(resolved.content, resolved.variables);
|
|
2117
|
+
}, (alias, from, to) => {
|
|
2118
|
+
const body = textFilesByAlias.get(alias);
|
|
2119
|
+
if (body === void 0) throw new Error(`Text file "${alias}" was not prefetched`);
|
|
2120
|
+
if (from === void 0 && to === void 0) return body;
|
|
2121
|
+
return sliceLines(body, from, to);
|
|
2122
|
+
}));
|
|
1529
2123
|
} catch (e) {
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
errors: [error("ASSEMBLY_ERROR", `Failed to render system prompt: ${e instanceof Error ? e.message : String(e)}`)],
|
|
1533
|
-
warnings
|
|
1534
|
-
};
|
|
2124
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
2125
|
+
renderErrors.push(error("ASSEMBLY_ERROR", `Failed to render system prompt: ${message}`));
|
|
1535
2126
|
}
|
|
2127
|
+
if (renderErrors.length > 0) return {
|
|
2128
|
+
ok: false,
|
|
2129
|
+
errors: renderErrors,
|
|
2130
|
+
warnings
|
|
2131
|
+
};
|
|
2132
|
+
return {
|
|
2133
|
+
ok: true,
|
|
2134
|
+
prompts,
|
|
2135
|
+
warnings
|
|
2136
|
+
};
|
|
1536
2137
|
}
|
|
1537
2138
|
/**
|
|
1538
2139
|
* Validate a fragment FILE on its own (the `--kind fragment` / "Fragment library"
|
|
@@ -1550,7 +2151,6 @@ async function loadAndCheckFragmentFile(url, fetchImpl, opts = {}) {
|
|
|
1550
2151
|
};
|
|
1551
2152
|
return checkFragmentFileValue(yaml.value, url);
|
|
1552
2153
|
}
|
|
1553
|
-
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." });
|
|
1554
2154
|
//#endregion
|
|
1555
2155
|
//#region ../lib/coding-schema.ts
|
|
1556
2156
|
const CodingYamlSchema = z.strictObject({
|
|
@@ -1699,7 +2299,7 @@ const QuizYamlSchema = z.strictObject({
|
|
|
1699
2299
|
id: "llm",
|
|
1700
2300
|
description: "The single model + provider that grades and discusses answers."
|
|
1701
2301
|
}),
|
|
1702
|
-
discussion: z.strictObject({ instructions: z.string().min(1).meta({ description: "Optional guidance appended to the per-question follow-up discussion chat's system prompt." }) }).optional().meta({
|
|
2302
|
+
discussion: z.strictObject({ instructions: z.string().min(1).meta({ description: "Optional guidance appended to the per-question follow-up discussion chat's system prompt. When any fragment_files or text_files are declared, place fragments inline with {{fragment \"alias.id\" …}} and embed text files with {{file \"alias\"}} (escape a literal {{ as \\{{) — same rules as instructions." }) }).optional().meta({
|
|
1703
2303
|
id: "discussion",
|
|
1704
2304
|
description: "Optional guidance for the per-question follow-up discussion chat."
|
|
1705
2305
|
}),
|
|
@@ -1825,13 +2425,13 @@ async function checkInclude(ref, baseUrl, fetchImpl, opts) {
|
|
|
1825
2425
|
errors: [includeUnreadable(ref.id, includeUrl, checked.errors)],
|
|
1826
2426
|
warnings: checked.warnings
|
|
1827
2427
|
};
|
|
1828
|
-
const assembled = await
|
|
2428
|
+
const assembled = await assembleFragmentPrompts({
|
|
1829
2429
|
fragment_files: valid.data.fragment_files,
|
|
1830
2430
|
text_files: valid.data.text_files
|
|
1831
2431
|
}, includeUrl, fetchImpl, {
|
|
1832
2432
|
allowedSchemes: opts.allowedSchemes,
|
|
1833
2433
|
validateLibraries: opts.validateLibraries ?? true
|
|
1834
|
-
}, valid.data.instructions ?? "");
|
|
2434
|
+
}, [valid.data.instructions ?? "", valid.data.discussion?.instructions ?? ""]);
|
|
1835
2435
|
if (!assembled.ok) return {
|
|
1836
2436
|
ok: false,
|
|
1837
2437
|
errors: [includeUnreadable(ref.id, includeUrl, assembled.errors)],
|
|
@@ -1868,13 +2468,13 @@ async function loadAndCheckQuiz(url, fetchImpl, opts = {}) {
|
|
|
1868
2468
|
};
|
|
1869
2469
|
const checked = checkQuizParsed(valid.data);
|
|
1870
2470
|
if (!checked.ok) return checked;
|
|
1871
|
-
const assembled = await
|
|
2471
|
+
const assembled = await assembleFragmentPrompts({
|
|
1872
2472
|
fragment_files: valid.data.fragment_files,
|
|
1873
2473
|
text_files: valid.data.text_files
|
|
1874
2474
|
}, url, fetchImpl, {
|
|
1875
2475
|
allowedSchemes: opts.allowedSchemes,
|
|
1876
2476
|
validateLibraries: opts.validateLibraries ?? true
|
|
1877
|
-
}, valid.data.instructions ?? "");
|
|
2477
|
+
}, [valid.data.instructions ?? "", valid.data.discussion?.instructions ?? ""]);
|
|
1878
2478
|
const warnings = [...checked.warnings, ...assembled.warnings];
|
|
1879
2479
|
if (!assembled.ok) return {
|
|
1880
2480
|
ok: false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@novedu/cli",
|
|
3
|
-
"version": "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": {
|