@novedu/cli 0.5.1 → 0.7.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 +46 -14
- package/dist/main.js +280 -7
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
# @novedu/cli
|
|
2
2
|
|
|
3
|
-
Command-line companion for the Novedu chat app (installed command: `novedu-cli
|
|
4
|
-
|
|
5
|
-
libraries**, **quizzes**, **writing activities**, and **coding
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
Command-line companion for the Novedu chat app (installed command: `novedu-cli`;
|
|
4
|
+
requires Node >= 20). It validates every activity YAML the app accepts — **tutors**,
|
|
5
|
+
**fragment libraries**, **quizzes**, **writing activities**, and **coding
|
|
6
|
+
activities** — and signs in to Microsoft Entra ID (`login` / `logout` / `whoami`)
|
|
7
|
+
to call the app's protected APIs; more commands will follow. Validating a tutor
|
|
8
|
+
also fully validates every fragment library it references; pass `--kind` to
|
|
9
|
+
validate any other kind on its own.
|
|
8
10
|
|
|
9
11
|
It reuses the app's exact validation pipeline (`lib/tutors`, `lib/quiz-validate`,
|
|
10
12
|
`lib/writing-validate`, `lib/coding-validate`), so an activity that passes here is
|
|
@@ -13,22 +15,22 @@ the same one the app would accept — no separate, drifting rules.
|
|
|
13
15
|
## Usage
|
|
14
16
|
|
|
15
17
|
```bash
|
|
16
|
-
# Validate a local file (relative fragment_files resolve
|
|
17
|
-
npx @novedu/cli validate ./activities/
|
|
18
|
+
# Validate a local file (relative fragment_files resolve against the file's location)
|
|
19
|
+
npx @novedu/cli validate ./activities/examples/sorting-algorithms/sorting-tutor.yaml
|
|
18
20
|
|
|
19
21
|
# Validate a published tutor by URL
|
|
20
|
-
npx @novedu/cli validate https://raw.githubusercontent.com/Teaching-HTL-Leonding/novedu-chat-mvp/refs/heads/main/activities/
|
|
22
|
+
npx @novedu/cli validate https://raw.githubusercontent.com/Teaching-HTL-Leonding/novedu-chat-mvp/refs/heads/main/activities/examples/sorting-algorithms/sorting-tutor.yaml
|
|
21
23
|
|
|
22
24
|
# Validate a fragment library on its own
|
|
23
|
-
npx @novedu/cli validate ./activities/
|
|
25
|
+
npx @novedu/cli validate ./activities/examples/shared/general-fragments.yaml --kind fragment
|
|
24
26
|
|
|
25
27
|
# Validate a quiz, a writing activity, or a coding activity
|
|
26
|
-
npx @novedu/cli validate ./activities/
|
|
27
|
-
npx @novedu/cli validate ./activities/
|
|
28
|
-
npx @novedu/cli validate ./activities/
|
|
28
|
+
npx @novedu/cli validate ./activities/examples/sorting-algorithms/sorting-quiz.yaml --kind quiz
|
|
29
|
+
npx @novedu/cli validate ./activities/examples/review-writing/restaurant-review-letter.yaml --kind writing
|
|
30
|
+
npx @novedu/cli validate ./activities/examples/sorting-algorithms/sorting-visualizer.yaml --kind coding
|
|
29
31
|
|
|
30
32
|
# Machine-readable output (the raw validation result)
|
|
31
|
-
npx @novedu/cli validate ./activities/
|
|
33
|
+
npx @novedu/cli validate ./activities/examples/sorting-algorithms/sorting-tutor.yaml --json
|
|
32
34
|
```
|
|
33
35
|
|
|
34
36
|
`--kind` accepts `tutor` (default), `fragment`, `quiz`, `writing`, or `coding`; it
|
|
@@ -37,12 +39,42 @@ is caller-declared, not auto-detected.
|
|
|
37
39
|
Exit code is `0` when the activity is valid and `1` when it has errors, so it works
|
|
38
40
|
as a pre-commit / CI gate.
|
|
39
41
|
|
|
42
|
+
## Authentication
|
|
43
|
+
|
|
44
|
+
Commands that talk to the running app authenticate with Microsoft Entra ID:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
npx @novedu/cli login # opens your browser for the Microsoft sign-in
|
|
48
|
+
npx @novedu/cli whoami # verify: calls the app's GET /api/me with your token
|
|
49
|
+
npx @novedu/cli logout # remove the cached credentials from this machine
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
- `login` opens a browser window for the Microsoft sign-in (and prints the URL
|
|
53
|
+
as a fallback). **First-time users see a one-time consent prompt** ("Access
|
|
54
|
+
Novedu APIs from the CLI") — accept it once and it never reappears. When
|
|
55
|
+
already signed in, `login` just says so and exits.
|
|
56
|
+
- On a machine without a browser, `login --device-code` prints a verification
|
|
57
|
+
URL and a code to enter from any other device. Note that tenants commonly
|
|
58
|
+
block the device code flow by Conditional Access policy (error 53003) — the
|
|
59
|
+
default browser flow is not affected.
|
|
60
|
+
- Credentials are cached in `~/.novedu/token-cache.json` (directory `0700`,
|
|
61
|
+
file `0600`). The cache holds a refresh token, so after the one sign-in every
|
|
62
|
+
command runs non-interactively; treat the file like a credential. `logout`
|
|
63
|
+
is purely local — issued tokens expire on their own (~1 h).
|
|
64
|
+
- The server defaults to the production app; override per command with
|
|
65
|
+
`--server <url>` or the `NOVEDU_SERVER` env var (e.g.
|
|
66
|
+
`http://localhost:3000` for development). Other deployments of the app can
|
|
67
|
+
point the CLI at their own tenant/app registration via `NOVEDU_TENANT_ID` /
|
|
68
|
+
`NOVEDU_CLIENT_ID`.
|
|
69
|
+
- Not signed in (or the cached token expired for good)? Commands exit 1 with
|
|
70
|
+
`Not signed in — run "novedu-cli login".`
|
|
71
|
+
|
|
40
72
|
## Development
|
|
41
73
|
|
|
42
74
|
The CLI lives in the app repo as an npm workspace.
|
|
43
75
|
|
|
44
76
|
```bash
|
|
45
|
-
npm run cli -- validate ./activities/
|
|
77
|
+
npm run cli -- validate ./activities/examples/sorting-algorithms/sorting-tutor.yaml # run from source via tsx
|
|
46
78
|
npm run cli:build # bundle to cli/dist via tsdown
|
|
47
79
|
npm run test:cli # build + integration tests (local & live URLs)
|
|
48
80
|
```
|
package/dist/main.js
CHANGED
|
@@ -1,12 +1,226 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
2
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { Command } from "commander";
|
|
4
|
-
import {
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { createServer } from "node:http";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { dirname, join, resolve } from "node:path";
|
|
8
|
+
import { CryptoProvider, PublicClientApplication } from "@azure/msal-node";
|
|
5
9
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
10
|
import Handlebars from "handlebars";
|
|
7
11
|
import { parse } from "yaml";
|
|
8
12
|
import { z } from "zod";
|
|
9
13
|
import { readFile } from "node:fs/promises";
|
|
14
|
+
//#region src/auth.ts
|
|
15
|
+
const DEFAULT_TENANT_ID = "91fc072c-edef-4f97-bdc5-cfb67718ae3a";
|
|
16
|
+
const DEFAULT_CLIENT_ID = "4d44fc4b-0434-4981-9765-62e2074ceecb";
|
|
17
|
+
function tenantId() {
|
|
18
|
+
return process.env.NOVEDU_TENANT_ID || DEFAULT_TENANT_ID;
|
|
19
|
+
}
|
|
20
|
+
function clientId() {
|
|
21
|
+
return process.env.NOVEDU_CLIENT_ID || DEFAULT_CLIENT_ID;
|
|
22
|
+
}
|
|
23
|
+
/** The delegated scope every token is requested for; msal-node adds the OIDC scopes itself. */
|
|
24
|
+
function scopes() {
|
|
25
|
+
return [`api://${clientId()}/cli.access`];
|
|
26
|
+
}
|
|
27
|
+
const TOKEN_CACHE_PATH = join(join(homedir(), ".novedu"), "token-cache.json");
|
|
28
|
+
/** Thrown when a command needs a token but no (usable) cached account exists. */
|
|
29
|
+
var NotSignedInError = class extends Error {
|
|
30
|
+
constructor() {
|
|
31
|
+
super("Not signed in — run \"novedu-cli login\".");
|
|
32
|
+
this.name = "NotSignedInError";
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* File-backed MSAL cache (az-CLI model): plain JSON, directory 0700, file
|
|
37
|
+
* 0600. A missing file simply means an empty cache. Exported for tests.
|
|
38
|
+
*/
|
|
39
|
+
function buildCachePlugin(cachePath = TOKEN_CACHE_PATH) {
|
|
40
|
+
const cacheDir = dirname(cachePath);
|
|
41
|
+
return {
|
|
42
|
+
beforeCacheAccess: async (context) => {
|
|
43
|
+
let data;
|
|
44
|
+
try {
|
|
45
|
+
data = readFileSync(cachePath, "utf8");
|
|
46
|
+
} catch {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
context.tokenCache.deserialize(data);
|
|
50
|
+
},
|
|
51
|
+
afterCacheAccess: async (context) => {
|
|
52
|
+
if (!context.cacheHasChanged) return;
|
|
53
|
+
mkdirSync(cacheDir, {
|
|
54
|
+
recursive: true,
|
|
55
|
+
mode: 448
|
|
56
|
+
});
|
|
57
|
+
writeFileSync(cachePath, context.tokenCache.serialize(), { mode: 384 });
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function buildPca(cachePath = TOKEN_CACHE_PATH) {
|
|
62
|
+
return new PublicClientApplication({
|
|
63
|
+
auth: {
|
|
64
|
+
clientId: clientId(),
|
|
65
|
+
authority: `https://login.microsoftonline.com/${tenantId()}`
|
|
66
|
+
},
|
|
67
|
+
cache: { cachePlugin: buildCachePlugin(cachePath) }
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Acquires a token silently from the cached account (MSAL refreshes via the
|
|
72
|
+
* cached refresh token when needed). Returns null when there is no account or
|
|
73
|
+
* the silent acquisition fails (expired/revoked refresh token) — callers
|
|
74
|
+
* decide between falling back to interactive (`login`) and NotSignedInError.
|
|
75
|
+
*/
|
|
76
|
+
async function acquireSilent(pca) {
|
|
77
|
+
const [account] = await pca.getTokenCache().getAllAccounts();
|
|
78
|
+
if (!account) return null;
|
|
79
|
+
try {
|
|
80
|
+
return await pca.acquireTokenSilent({
|
|
81
|
+
account,
|
|
82
|
+
scopes: scopes()
|
|
83
|
+
});
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function defaultOpenBrowser(url) {
|
|
89
|
+
const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", [
|
|
90
|
+
"/c",
|
|
91
|
+
"start",
|
|
92
|
+
"",
|
|
93
|
+
url
|
|
94
|
+
]] : ["xdg-open", [url]];
|
|
95
|
+
try {
|
|
96
|
+
spawn(command, args, {
|
|
97
|
+
stdio: "ignore",
|
|
98
|
+
detached: true
|
|
99
|
+
}).unref();
|
|
100
|
+
} catch {}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Runs the interactive authorization-code + PKCE flow with a loopback
|
|
104
|
+
* redirect (the az-CLI model): opens the system browser and receives the code
|
|
105
|
+
* on a short-lived localhost server. This is the DEFAULT login flow — tenant
|
|
106
|
+
* Conditional Access policies commonly block the device code flow (error
|
|
107
|
+
* 53003) but permit this one, since it is the same flow the web sign-in uses.
|
|
108
|
+
*
|
|
109
|
+
* Entra matches any localhost port against the registered `http://localhost`
|
|
110
|
+
* public-client redirect URI, so the receiver binds an ephemeral port.
|
|
111
|
+
* `onUrl` always receives the sign-in URL (fallback when no browser opens).
|
|
112
|
+
*/
|
|
113
|
+
async function acquireInteractive(pca, onUrl, openBrowser = defaultOpenBrowser) {
|
|
114
|
+
const { verifier, challenge } = await new CryptoProvider().generatePkceCodes();
|
|
115
|
+
const server = createServer();
|
|
116
|
+
const redirectUri = `http://localhost:${await new Promise((resolve, reject) => {
|
|
117
|
+
server.once("error", reject);
|
|
118
|
+
server.listen(0, () => resolve(server.address().port));
|
|
119
|
+
})}`;
|
|
120
|
+
try {
|
|
121
|
+
const authCode = new Promise((resolve, reject) => {
|
|
122
|
+
const timeout = setTimeout(() => reject(/* @__PURE__ */ new Error("Sign-in timed out after 5 minutes.")), 5 * 6e4);
|
|
123
|
+
server.on("request", (req, res) => {
|
|
124
|
+
const url = new URL(req.url ?? "/", redirectUri);
|
|
125
|
+
const code = url.searchParams.get("code");
|
|
126
|
+
const error = url.searchParams.get("error");
|
|
127
|
+
if (!code && !error) {
|
|
128
|
+
res.writeHead(404);
|
|
129
|
+
res.end();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
133
|
+
res.end(code ? "<p>Signed in — you can close this tab and return to the terminal.</p>" : "<p>Sign-in failed — you can close this tab.</p>");
|
|
134
|
+
clearTimeout(timeout);
|
|
135
|
+
if (code) resolve(code);
|
|
136
|
+
else reject(/* @__PURE__ */ new Error(`Sign-in failed: ${url.searchParams.get("error_description") ?? error}`));
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
const authUrl = await pca.getAuthCodeUrl({
|
|
140
|
+
scopes: scopes(),
|
|
141
|
+
redirectUri,
|
|
142
|
+
codeChallenge: challenge,
|
|
143
|
+
codeChallengeMethod: "S256"
|
|
144
|
+
});
|
|
145
|
+
onUrl(authUrl);
|
|
146
|
+
openBrowser(authUrl);
|
|
147
|
+
const code = await authCode;
|
|
148
|
+
return await pca.acquireTokenByCode({
|
|
149
|
+
code,
|
|
150
|
+
scopes: scopes(),
|
|
151
|
+
redirectUri,
|
|
152
|
+
codeVerifier: verifier
|
|
153
|
+
});
|
|
154
|
+
} finally {
|
|
155
|
+
server.close();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Runs the device code flow — for machines without a local browser; the
|
|
160
|
+
* tenant's Conditional Access policy must allow it. `onMessage` receives
|
|
161
|
+
* Entra's instruction line (verification URL + user code) the moment the flow
|
|
162
|
+
* starts — print it immediately so it can be relayed to the human while MSAL
|
|
163
|
+
* keeps polling.
|
|
164
|
+
*/
|
|
165
|
+
async function acquireByDeviceCode(pca, onMessage) {
|
|
166
|
+
const result = await pca.acquireTokenByDeviceCode({
|
|
167
|
+
deviceCodeCallback: (response) => onMessage(response.message),
|
|
168
|
+
scopes: scopes()
|
|
169
|
+
});
|
|
170
|
+
if (!result) throw new Error("Device code sign-in did not return a token.");
|
|
171
|
+
return result;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* The one call every API command makes: a silently-acquired access token for
|
|
175
|
+
* the Authorization header. Throws NotSignedInError when interactive login is
|
|
176
|
+
* required first.
|
|
177
|
+
*/
|
|
178
|
+
async function getAccessToken() {
|
|
179
|
+
const result = await acquireSilent(buildPca());
|
|
180
|
+
if (!result) throw new NotSignedInError();
|
|
181
|
+
return result.accessToken;
|
|
182
|
+
}
|
|
183
|
+
/** Human-readable account label for command output. */
|
|
184
|
+
function displayName(result) {
|
|
185
|
+
return result.account?.name ?? result.account?.username ?? "(unknown account)";
|
|
186
|
+
}
|
|
187
|
+
//#endregion
|
|
188
|
+
//#region src/commands/login.ts
|
|
189
|
+
function registerLogin(program) {
|
|
190
|
+
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", `
|
|
191
|
+
Sign-in is the one human-assisted step: by default a browser window opens for
|
|
192
|
+
the Microsoft sign-in (first-time users see a one-time consent prompt). On a
|
|
193
|
+
machine without a browser, --device-code prints a verification URL and a code
|
|
194
|
+
to enter from any other device — note that some tenants block the device code
|
|
195
|
+
flow by policy (error 53003). Every other command then works non-interactively
|
|
196
|
+
from the cached credentials. Already signed in? The command says so and exits
|
|
197
|
+
— it never blocks.`).action(async (options) => {
|
|
198
|
+
const pca = buildPca();
|
|
199
|
+
const cached = await acquireSilent(pca);
|
|
200
|
+
if (cached) {
|
|
201
|
+
console.log(`Already signed in as ${displayName(cached)}.`);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const result = options.deviceCode ? await acquireByDeviceCode(pca, (message) => console.log(message)) : await acquireInteractive(pca, (url) => {
|
|
205
|
+
console.log("A browser window should open for the Microsoft sign-in.");
|
|
206
|
+
console.log(`If it does not, open this URL yourself:\n${url}`);
|
|
207
|
+
});
|
|
208
|
+
console.log(`Signed in as ${displayName(result)}.`);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
//#endregion
|
|
212
|
+
//#region src/commands/logout.ts
|
|
213
|
+
function registerLogout(program) {
|
|
214
|
+
program.command("logout").description("Sign out: remove the cached credentials from this machine").addHelpText("after", `
|
|
215
|
+
Purely local — already-issued access tokens stay valid until they expire
|
|
216
|
+
(about an hour). Running it while signed out is fine.`).action(async () => {
|
|
217
|
+
const cache = buildPca().getTokenCache();
|
|
218
|
+
for (const account of await cache.getAllAccounts()) await cache.removeAccount(account);
|
|
219
|
+
rmSync(TOKEN_CACHE_PATH, { force: true });
|
|
220
|
+
console.log("Signed out.");
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
//#endregion
|
|
10
224
|
//#region ../lib/tutors/assemble.ts
|
|
11
225
|
const COMPILE_OPTIONS = {
|
|
12
226
|
strict: true,
|
|
@@ -242,6 +456,7 @@ function validate(value, schema, code, url) {
|
|
|
242
456
|
})
|
|
243
457
|
};
|
|
244
458
|
}
|
|
459
|
+
const providerSchema = z.enum(["SCCH", "Azure Foundry"]).default("SCCH");
|
|
245
460
|
//#endregion
|
|
246
461
|
//#region ../lib/tutors/schemas.ts
|
|
247
462
|
/**
|
|
@@ -329,6 +544,7 @@ const TutorSchema = z.strictObject({
|
|
|
329
544
|
anonymous: z.boolean().optional(),
|
|
330
545
|
llm: z.strictObject({
|
|
331
546
|
model: z.string(),
|
|
547
|
+
provider: providerSchema,
|
|
332
548
|
imageInput: z.boolean().optional()
|
|
333
549
|
}),
|
|
334
550
|
prompt: z.strictObject({
|
|
@@ -445,8 +661,8 @@ function resolveRelativeUrl(ref, baseUrl) {
|
|
|
445
661
|
/**
|
|
446
662
|
* Resolve a fragment-file reference to an absolute URL. An absolute http(s) ref is used
|
|
447
663
|
* as-is; anything else is treated as relative to the tutor URL — standard URL resolution
|
|
448
|
-
* drops the tutor's filename and appends the relative path (so `
|
|
449
|
-
* next to `.../
|
|
664
|
+
* drops the tutor's filename and appends the relative path (so `my-fragments.yaml`
|
|
665
|
+
* next to `.../tutors/my-tutor.yaml` becomes `.../tutors/my-fragments.yaml`,
|
|
450
666
|
* and `./` / `../` segments work too). Throws if a relative ref is unparseable; the schema
|
|
451
667
|
* already guarantees the only inputs here are http(s) URLs or relative paths.
|
|
452
668
|
*/
|
|
@@ -596,6 +812,7 @@ async function loadAndBuildTutorPrompt(url, fetchImpl, opts = {}) {
|
|
|
596
812
|
ok: true,
|
|
597
813
|
prompt: assembleSystemPrompt(consistency.plan, tutor),
|
|
598
814
|
model: tutor.llm.model,
|
|
815
|
+
provider: tutor.llm.provider,
|
|
599
816
|
imageInput: tutor.llm.imageInput ?? true,
|
|
600
817
|
anonymous: tutor.anonymous ?? true,
|
|
601
818
|
title: tutor.title,
|
|
@@ -633,7 +850,10 @@ const CodingYamlSchema = z.strictObject({
|
|
|
633
850
|
id: z.string().min(1),
|
|
634
851
|
name: z.string().optional(),
|
|
635
852
|
title: z.string().optional(),
|
|
636
|
-
llm: z.strictObject({
|
|
853
|
+
llm: z.strictObject({
|
|
854
|
+
model: z.string().min(1),
|
|
855
|
+
provider: providerSchema
|
|
856
|
+
}),
|
|
637
857
|
instructions: z.string().min(1)
|
|
638
858
|
});
|
|
639
859
|
//#endregion
|
|
@@ -655,6 +875,7 @@ function checkCodingValue(parsed, url) {
|
|
|
655
875
|
ok: true,
|
|
656
876
|
codingId: coding.id,
|
|
657
877
|
model: coding.llm.model,
|
|
878
|
+
provider: coding.llm.provider,
|
|
658
879
|
title: coding.title ?? null,
|
|
659
880
|
warnings: []
|
|
660
881
|
};
|
|
@@ -701,7 +922,10 @@ const QuizYamlSchema = z.strictObject({
|
|
|
701
922
|
description: z.string().optional(),
|
|
702
923
|
anonymous: z.boolean().optional(),
|
|
703
924
|
shuffle: z.boolean().optional(),
|
|
704
|
-
llm: z.strictObject({
|
|
925
|
+
llm: z.strictObject({
|
|
926
|
+
model: z.string().min(1),
|
|
927
|
+
provider: providerSchema
|
|
928
|
+
}),
|
|
705
929
|
discussion: z.strictObject({ instructions: z.string().min(1) }).optional(),
|
|
706
930
|
questions: z.array(QuizQuestionSchema).min(1)
|
|
707
931
|
});
|
|
@@ -745,6 +969,7 @@ function checkQuizValue(parsed, url) {
|
|
|
745
969
|
ok: true,
|
|
746
970
|
quizId: quiz.id,
|
|
747
971
|
model: quiz.llm.model,
|
|
972
|
+
provider: quiz.llm.provider,
|
|
748
973
|
questionCount: quiz.questions.length,
|
|
749
974
|
anonymous: quiz.anonymous ?? DEFAULT_ANONYMOUS$1,
|
|
750
975
|
title: quiz.title ?? null,
|
|
@@ -773,7 +998,10 @@ const WritingYamlSchema = z.strictObject({
|
|
|
773
998
|
title: z.string().optional(),
|
|
774
999
|
description: z.string().optional(),
|
|
775
1000
|
anonymous: z.boolean().optional(),
|
|
776
|
-
llm: z.strictObject({
|
|
1001
|
+
llm: z.strictObject({
|
|
1002
|
+
model: z.string().min(1),
|
|
1003
|
+
provider: providerSchema
|
|
1004
|
+
}),
|
|
777
1005
|
instructions: z.string().min(1),
|
|
778
1006
|
placeholder: z.string().optional()
|
|
779
1007
|
});
|
|
@@ -798,6 +1026,7 @@ function checkWritingValue(parsed, url) {
|
|
|
798
1026
|
ok: true,
|
|
799
1027
|
writingId: writing.id,
|
|
800
1028
|
model: writing.llm.model,
|
|
1029
|
+
provider: writing.llm.provider,
|
|
801
1030
|
anonymous: writing.anonymous ?? DEFAULT_ANONYMOUS,
|
|
802
1031
|
title: writing.title ?? null,
|
|
803
1032
|
warnings: []
|
|
@@ -1081,11 +1310,55 @@ function formatOutcome(outcome, source) {
|
|
|
1081
1310
|
}
|
|
1082
1311
|
}
|
|
1083
1312
|
//#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
|
+
//#region src/commands/whoami.ts
|
|
1320
|
+
function registerWhoami(program) {
|
|
1321
|
+
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) => {
|
|
1322
|
+
let token;
|
|
1323
|
+
try {
|
|
1324
|
+
token = await getAccessToken();
|
|
1325
|
+
} catch (error) {
|
|
1326
|
+
if (error instanceof NotSignedInError) {
|
|
1327
|
+
console.error(error.message);
|
|
1328
|
+
process.exitCode = 1;
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
throw error;
|
|
1332
|
+
}
|
|
1333
|
+
const server = resolveServerUrl(options.server);
|
|
1334
|
+
let response;
|
|
1335
|
+
try {
|
|
1336
|
+
response = await fetch(new URL("/api/me", server), { headers: { authorization: `Bearer ${token}` } });
|
|
1337
|
+
} catch (error) {
|
|
1338
|
+
console.error(`Could not reach ${server}: ${error instanceof Error ? error.message : error}`);
|
|
1339
|
+
process.exitCode = 1;
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
if (!response.ok) {
|
|
1343
|
+
console.error(`${server} rejected the request: HTTP ${response.status}`);
|
|
1344
|
+
process.exitCode = 1;
|
|
1345
|
+
return;
|
|
1346
|
+
}
|
|
1347
|
+
const me = await response.json();
|
|
1348
|
+
console.log(`Signed in as ${me.name ?? "(no name)"}`);
|
|
1349
|
+
console.log(`User id: ${me.userId}`);
|
|
1350
|
+
console.log(`Teacher: ${me.isTeacher ? "yes" : "no"}`);
|
|
1351
|
+
});
|
|
1352
|
+
}
|
|
1353
|
+
//#endregion
|
|
1084
1354
|
//#region src/main.ts
|
|
1085
1355
|
const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
1086
1356
|
const program = new Command();
|
|
1087
1357
|
program.name("novedu-cli").description("Command-line companion for the Novedu chat app").version(version);
|
|
1088
1358
|
registerValidate(program);
|
|
1359
|
+
registerLogin(program);
|
|
1360
|
+
registerLogout(program);
|
|
1361
|
+
registerWhoami(program);
|
|
1089
1362
|
program.parseAsync().catch((err) => {
|
|
1090
1363
|
console.error(err instanceof Error ? err.message : err);
|
|
1091
1364
|
process.exitCode = 1;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@novedu/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Command-line companion for the Novedu chat app. Validates tutor, fragment, quiz, writing and coding YAML definitions (more commands to follow).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"dist"
|
|
16
16
|
],
|
|
17
17
|
"engines": {
|
|
18
|
-
"node": ">=
|
|
18
|
+
"node": ">=20"
|
|
19
19
|
},
|
|
20
20
|
"publishConfig": {
|
|
21
21
|
"access": "public"
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"prepublishOnly": "npm run build"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
+
"@azure/msal-node": "^5.3.1",
|
|
28
29
|
"commander": "^15.0.0",
|
|
29
30
|
"handlebars": "^4.7.9",
|
|
30
31
|
"yaml": "^2.9.0",
|