@novedu/cli 0.6.0 → 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.
Files changed (3) hide show
  1. package/README.md +46 -14
  2. package/dist/main.js +262 -4
  3. 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
- Today it validates every activity YAML the app accepts — **tutors**, **fragment
5
- libraries**, **quizzes**, **writing activities**, and **coding activities**; more
6
- commands will follow. Validating a tutor also fully validates every fragment library
7
- it references; pass `--kind` to validate any other kind on its own.
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 from the same folder)
17
- npx @novedu/cli validate ./activities/tutors/simple-tutor.yaml
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/tutors/simple-tutor.yaml
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/tutors/simple-fragments.yaml --kind fragment
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/quizzes/sample-quiz.yaml --kind quiz
27
- npx @novedu/cli validate ./activities/writings/human-animal-short-story.yaml --kind writing
28
- npx @novedu/cli validate ./activities/coding/beginner-typescript.yaml --kind coding
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/tutors/simple-tutor.yaml --json
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/tutors/simple-tutor.yaml # run from source via tsx
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 { resolve } from "node:path";
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,
@@ -447,8 +661,8 @@ function resolveRelativeUrl(ref, baseUrl) {
447
661
  /**
448
662
  * Resolve a fragment-file reference to an absolute URL. An absolute http(s) ref is used
449
663
  * as-is; anything else is treated as relative to the tutor URL — standard URL resolution
450
- * drops the tutor's filename and appends the relative path (so `general-fragments.yaml`
451
- * next to `.../activities/tutors/linked-list-tutor.yaml` becomes `.../activities/tutors/general-fragments.yaml`,
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`,
452
666
  * and `./` / `../` segments work too). Throws if a relative ref is unparseable; the schema
453
667
  * already guarantees the only inputs here are http(s) URLs or relative paths.
454
668
  */
@@ -1096,11 +1310,55 @@ function formatOutcome(outcome, source) {
1096
1310
  }
1097
1311
  }
1098
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
1099
1354
  //#region src/main.ts
1100
1355
  const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
1101
1356
  const program = new Command();
1102
1357
  program.name("novedu-cli").description("Command-line companion for the Novedu chat app").version(version);
1103
1358
  registerValidate(program);
1359
+ registerLogin(program);
1360
+ registerLogout(program);
1361
+ registerWhoami(program);
1104
1362
  program.parseAsync().catch((err) => {
1105
1363
  console.error(err instanceof Error ? err.message : err);
1106
1364
  process.exitCode = 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@novedu/cli",
3
- "version": "0.6.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"
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",