@novedu/cli 0.6.0 → 0.8.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 +396 -5
- package/package.json +4 -3
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,363 @@
|
|
|
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";
|
|
9
|
+
import { readFile } from "node:fs/promises";
|
|
5
10
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
11
|
import Handlebars from "handlebars";
|
|
7
12
|
import { parse } from "yaml";
|
|
8
13
|
import { z } from "zod";
|
|
9
|
-
|
|
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/server-url.ts
|
|
189
|
+
const DEFAULT_SERVER = "https://novedu-chat-mvp-at.azurewebsites.net";
|
|
190
|
+
function resolveServerUrl(cliOption) {
|
|
191
|
+
return cliOption || process.env.NOVEDU_SERVER || DEFAULT_SERVER;
|
|
192
|
+
}
|
|
193
|
+
//#endregion
|
|
194
|
+
//#region src/api.ts
|
|
195
|
+
/** Pretty-prints a success payload to stdout. */
|
|
196
|
+
function printJson(value) {
|
|
197
|
+
console.log(JSON.stringify(value, null, 2));
|
|
198
|
+
}
|
|
199
|
+
/** Prints a failure payload as JSON to stderr and marks the process failed. */
|
|
200
|
+
function failJson(value) {
|
|
201
|
+
console.error(JSON.stringify(value, null, 2));
|
|
202
|
+
process.exitCode = 1;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Performs one authenticated API request and prints the outcome per the JSON
|
|
206
|
+
* contract. Server error bodies (`{ message }` — incl. the generic 401/403 —
|
|
207
|
+
* or `{ errors }`) are passed through VERBATIM to stderr — the server's
|
|
208
|
+
* structured validation detail is the CLI's error message. No client-side
|
|
209
|
+
* pre-validation: the server runs the identical pipeline; offline checking is
|
|
210
|
+
* the `validate` command's job.
|
|
211
|
+
*/
|
|
212
|
+
async function runApiRequest(options) {
|
|
213
|
+
let token;
|
|
214
|
+
try {
|
|
215
|
+
token = await getAccessToken();
|
|
216
|
+
} catch (error) {
|
|
217
|
+
if (error instanceof NotSignedInError) {
|
|
218
|
+
failJson({ message: error.message });
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
223
|
+
const server = resolveServerUrl(options.server);
|
|
224
|
+
let response;
|
|
225
|
+
try {
|
|
226
|
+
response = await fetch(new URL(options.path, server), {
|
|
227
|
+
method: options.method ?? "GET",
|
|
228
|
+
headers: {
|
|
229
|
+
authorization: `Bearer ${token}`,
|
|
230
|
+
...options.body === void 0 ? {} : { "content-type": "application/json" }
|
|
231
|
+
},
|
|
232
|
+
body: options.body === void 0 ? void 0 : JSON.stringify(options.body)
|
|
233
|
+
});
|
|
234
|
+
} catch (error) {
|
|
235
|
+
failJson({ message: `Could not reach ${server}: ${error instanceof Error ? error.message : error}` });
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
let payload;
|
|
239
|
+
try {
|
|
240
|
+
payload = await response.json();
|
|
241
|
+
} catch {
|
|
242
|
+
payload = void 0;
|
|
243
|
+
}
|
|
244
|
+
if (!response.ok) {
|
|
245
|
+
failJson(payload ?? { message: `${server} rejected the request: HTTP ${response.status}` });
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
printJson(payload ?? null);
|
|
249
|
+
}
|
|
250
|
+
//#endregion
|
|
251
|
+
//#region src/commands/codes.ts
|
|
252
|
+
const SERVER_OPTION$1 = ["--server <url>", "Novedu server base URL (defaults to the NOVEDU_SERVER env var, then production)"];
|
|
253
|
+
function registerCodes(program) {
|
|
254
|
+
const codes = program.command("codes").description("Manage activity codes on the Novedu server");
|
|
255
|
+
codes.command("create").description("Create a code for an activity YAML (validated server-side before storing)").requiredOption("--module <module>", "activity module: tutor, quiz, writing or coding").requiredOption("--file <url>", "public http(s) URL of the activity YAML").option("--start <iso>", "window start, ISO 8601 with explicit offset (e.g. 2026-07-07T08:00:00Z)").option("--end <iso>", "window end, ISO 8601 with explicit offset").option("--note <text>", "note shown in the codes list").option("--llm-provider <provider>", "LLM override provider (\"SCCH\" or \"Azure Foundry\"; needs --llm-model)").option("--llm-model <model>", "LLM override model id (needs --llm-provider)").option(...SERVER_OPTION$1).action(async (options) => {
|
|
256
|
+
await runApiRequest({
|
|
257
|
+
server: options.server,
|
|
258
|
+
path: "/api/codes",
|
|
259
|
+
method: "POST",
|
|
260
|
+
body: {
|
|
261
|
+
module: options.module,
|
|
262
|
+
fileUrl: options.file,
|
|
263
|
+
...options.start === void 0 ? {} : { validFrom: options.start },
|
|
264
|
+
...options.end === void 0 ? {} : { validUntil: options.end },
|
|
265
|
+
...options.note === void 0 ? {} : { note: options.note },
|
|
266
|
+
...options.llmProvider === void 0 && options.llmModel === void 0 ? {} : { llm: {
|
|
267
|
+
provider: options.llmProvider ?? "",
|
|
268
|
+
model: options.llmModel ?? ""
|
|
269
|
+
} }
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
codes.command("list").description("List codes (defaults to only your own, like the web list)").option("--search <q>", "contains-filter over note/code").option("--module <module>", "only codes for one activity module").option("--all", "include codes created by other teachers").option(...SERVER_OPTION$1).action(async (options) => {
|
|
274
|
+
const params = new URLSearchParams();
|
|
275
|
+
if (options.search) params.set("q", options.search);
|
|
276
|
+
if (options.module) params.set("module", options.module);
|
|
277
|
+
if (options.all) params.set("mine", "0");
|
|
278
|
+
const query = params.toString();
|
|
279
|
+
await runApiRequest({
|
|
280
|
+
server: options.server,
|
|
281
|
+
path: `/api/codes${query ? `?${query}` : ""}`
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
//#endregion
|
|
286
|
+
//#region src/commands/files.ts
|
|
287
|
+
const SERVER_OPTION = ["--server <url>", "Novedu server base URL (defaults to the NOVEDU_SERVER env var, then production)"];
|
|
288
|
+
async function readStdin() {
|
|
289
|
+
const chunks = [];
|
|
290
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
291
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
292
|
+
}
|
|
293
|
+
function registerFiles(program) {
|
|
294
|
+
const files = program.command("files").description("Manage app-hosted YAML files on the Novedu server");
|
|
295
|
+
files.command("upload <name>").description("Create or update an app-hosted YAML file from --file or stdin (validated server-side)").option("--kind <kind>", "file kind (tutor, fragment, quiz, writing, coding) — required when creating").option("--file <path>", "read the YAML from this path instead of stdin").option(...SERVER_OPTION).action(async (name, options) => {
|
|
296
|
+
let content;
|
|
297
|
+
try {
|
|
298
|
+
content = options.file === void 0 ? await readStdin() : await readFile(options.file, "utf8");
|
|
299
|
+
} catch (error) {
|
|
300
|
+
failJson({ message: `Could not read ${options.file}: ${error instanceof Error ? error.message : error}` });
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
await runApiRequest({
|
|
304
|
+
server: options.server,
|
|
305
|
+
path: `/api/files/${encodeURIComponent(name)}`,
|
|
306
|
+
method: "PUT",
|
|
307
|
+
body: {
|
|
308
|
+
...options.kind === void 0 ? {} : { kind: options.kind },
|
|
309
|
+
content
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
files.command("list").description("List app-hosted YAML files (defaults to only your own, like the web list)").option("--search <q>", "contains-filter over name/title/description").option("--all", "include files last written by other teachers").option(...SERVER_OPTION).action(async (options) => {
|
|
314
|
+
const params = new URLSearchParams();
|
|
315
|
+
if (options.search) params.set("q", options.search);
|
|
316
|
+
if (options.all) params.set("mine", "0");
|
|
317
|
+
const query = params.toString();
|
|
318
|
+
await runApiRequest({
|
|
319
|
+
server: options.server,
|
|
320
|
+
path: `/api/files${query ? `?${query}` : ""}`
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
//#endregion
|
|
325
|
+
//#region src/commands/login.ts
|
|
326
|
+
function registerLogin(program) {
|
|
327
|
+
program.command("login").description("Sign in to Microsoft Entra ID (opens your browser)").option("--device-code", "sign in with the device code flow instead (for machines without a browser; the tenant must allow it)").addHelpText("after", `
|
|
328
|
+
Sign-in is the one human-assisted step: by default a browser window opens for
|
|
329
|
+
the Microsoft sign-in (first-time users see a one-time consent prompt). On a
|
|
330
|
+
machine without a browser, --device-code prints a verification URL and a code
|
|
331
|
+
to enter from any other device — note that some tenants block the device code
|
|
332
|
+
flow by policy (error 53003). Every other command then works non-interactively
|
|
333
|
+
from the cached credentials. Already signed in? The command says so and exits
|
|
334
|
+
— it never blocks.`).action(async (options) => {
|
|
335
|
+
const pca = buildPca();
|
|
336
|
+
const cached = await acquireSilent(pca);
|
|
337
|
+
if (cached) {
|
|
338
|
+
console.log(`Already signed in as ${displayName(cached)}.`);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
const result = options.deviceCode ? await acquireByDeviceCode(pca, (message) => console.log(message)) : await acquireInteractive(pca, (url) => {
|
|
342
|
+
console.log("A browser window should open for the Microsoft sign-in.");
|
|
343
|
+
console.log(`If it does not, open this URL yourself:\n${url}`);
|
|
344
|
+
});
|
|
345
|
+
console.log(`Signed in as ${displayName(result)}.`);
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
//#endregion
|
|
349
|
+
//#region src/commands/logout.ts
|
|
350
|
+
function registerLogout(program) {
|
|
351
|
+
program.command("logout").description("Sign out: remove the cached credentials from this machine").addHelpText("after", `
|
|
352
|
+
Purely local — already-issued access tokens stay valid until they expire
|
|
353
|
+
(about an hour). Running it while signed out is fine.`).action(async () => {
|
|
354
|
+
const cache = buildPca().getTokenCache();
|
|
355
|
+
for (const account of await cache.getAllAccounts()) await cache.removeAccount(account);
|
|
356
|
+
rmSync(TOKEN_CACHE_PATH, { force: true });
|
|
357
|
+
console.log("Signed out.");
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
//#endregion
|
|
10
361
|
//#region ../lib/tutors/assemble.ts
|
|
11
362
|
const COMPILE_OPTIONS = {
|
|
12
363
|
strict: true,
|
|
@@ -447,8 +798,8 @@ function resolveRelativeUrl(ref, baseUrl) {
|
|
|
447
798
|
/**
|
|
448
799
|
* Resolve a fragment-file reference to an absolute URL. An absolute http(s) ref is used
|
|
449
800
|
* 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 `
|
|
451
|
-
* next to `.../
|
|
801
|
+
* drops the tutor's filename and appends the relative path (so `my-fragments.yaml`
|
|
802
|
+
* next to `.../tutors/my-tutor.yaml` becomes `.../tutors/my-fragments.yaml`,
|
|
452
803
|
* and `./` / `../` segments work too). Throws if a relative ref is unparseable; the schema
|
|
453
804
|
* already guarantees the only inputs here are http(s) URLs or relative paths.
|
|
454
805
|
*/
|
|
@@ -1096,11 +1447,51 @@ function formatOutcome(outcome, source) {
|
|
|
1096
1447
|
}
|
|
1097
1448
|
}
|
|
1098
1449
|
//#endregion
|
|
1450
|
+
//#region src/commands/whoami.ts
|
|
1451
|
+
function registerWhoami(program) {
|
|
1452
|
+
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) => {
|
|
1453
|
+
let token;
|
|
1454
|
+
try {
|
|
1455
|
+
token = await getAccessToken();
|
|
1456
|
+
} catch (error) {
|
|
1457
|
+
if (error instanceof NotSignedInError) {
|
|
1458
|
+
console.error(error.message);
|
|
1459
|
+
process.exitCode = 1;
|
|
1460
|
+
return;
|
|
1461
|
+
}
|
|
1462
|
+
throw error;
|
|
1463
|
+
}
|
|
1464
|
+
const server = resolveServerUrl(options.server);
|
|
1465
|
+
let response;
|
|
1466
|
+
try {
|
|
1467
|
+
response = await fetch(new URL("/api/me", server), { headers: { authorization: `Bearer ${token}` } });
|
|
1468
|
+
} catch (error) {
|
|
1469
|
+
console.error(`Could not reach ${server}: ${error instanceof Error ? error.message : error}`);
|
|
1470
|
+
process.exitCode = 1;
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
if (!response.ok) {
|
|
1474
|
+
console.error(`${server} rejected the request: HTTP ${response.status}`);
|
|
1475
|
+
process.exitCode = 1;
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1478
|
+
const me = await response.json();
|
|
1479
|
+
console.log(`Signed in as ${me.name ?? "(no name)"}`);
|
|
1480
|
+
console.log(`User id: ${me.userId}`);
|
|
1481
|
+
console.log(`Teacher: ${me.isTeacher ? "yes" : "no"}`);
|
|
1482
|
+
});
|
|
1483
|
+
}
|
|
1484
|
+
//#endregion
|
|
1099
1485
|
//#region src/main.ts
|
|
1100
1486
|
const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
1101
1487
|
const program = new Command();
|
|
1102
1488
|
program.name("novedu-cli").description("Command-line companion for the Novedu chat app").version(version);
|
|
1103
1489
|
registerValidate(program);
|
|
1490
|
+
registerLogin(program);
|
|
1491
|
+
registerLogout(program);
|
|
1492
|
+
registerWhoami(program);
|
|
1493
|
+
registerCodes(program);
|
|
1494
|
+
registerFiles(program);
|
|
1104
1495
|
program.parseAsync().catch((err) => {
|
|
1105
1496
|
console.error(err instanceof Error ? err.message : err);
|
|
1106
1497
|
process.exitCode = 1;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@novedu/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Command-line companion for the Novedu chat app. Validates tutor, fragment, quiz, writing and coding YAML definitions
|
|
3
|
+
"version": "0.8.0",
|
|
4
|
+
"description": "Command-line companion for the Novedu chat app. Validates tutor, fragment, quiz, writing and coding YAML definitions; signs in with Entra ID and manages codes and app-hosted files over the app's API.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -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",
|