@wilm-ai/wilma-cli 1.6.1 → 2.0.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/LICENSE +21 -0
- package/README.md +57 -67
- package/dist/access.d.ts +12 -0
- package/dist/access.js +33 -0
- package/dist/agent-data.d.ts +496 -0
- package/dist/agent-data.js +578 -0
- package/dist/cli-args.d.ts +33 -0
- package/dist/cli-args.js +256 -0
- package/dist/cli-help.d.ts +2 -0
- package/dist/cli-help.js +141 -0
- package/dist/cli-output.d.ts +6 -0
- package/dist/cli-output.js +184 -0
- package/dist/config.js +63 -24
- package/dist/credentials.d.ts +69 -0
- package/dist/credentials.js +195 -0
- package/dist/downloads.d.ts +23 -0
- package/dist/downloads.js +105 -0
- package/dist/index.js +692 -1644
- package/dist/login-server.d.ts +52 -0
- package/dist/login-server.js +595 -0
- package/dist/mcp-tools.d.ts +23 -0
- package/dist/mcp-tools.js +235 -0
- package/dist/mcp.d.ts +1 -0
- package/dist/mcp.js +153 -0
- package/dist/output-json.d.ts +1 -0
- package/dist/output-json.js +26 -0
- package/dist/session-store.d.ts +7 -0
- package/dist/session-store.js +61 -0
- package/dist/tenant-search.d.ts +11 -0
- package/dist/tenant-search.js +127 -0
- package/dist/totp.d.ts +6 -0
- package/dist/totp.js +49 -0
- package/package.json +24 -8
package/dist/index.js
CHANGED
|
@@ -1,62 +1,356 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { emitKeypressEvents } from "node:readline";
|
|
3
3
|
import { select, input, password } from "@inquirer/prompts";
|
|
4
|
-
import {
|
|
5
|
-
import { readFile, writeFile, mkdir, open, rm } from "node:fs/promises";
|
|
4
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
6
5
|
import { spawn } from "node:child_process";
|
|
7
6
|
import { homedir } from "node:os";
|
|
8
7
|
import { dirname, resolve } from "node:path";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { APIError, AuthenticationError, MfaRequiredError, NetworkError, WilmaClient, listTenants, } from "@wilm-ai/wilma-client";
|
|
10
|
+
import { clearConfig, getConfigPath, loadConfig, obfuscateSecret, revealSecret, saveConfig } from "./config.js";
|
|
11
|
+
import { WilmaAccess, WilmaAiError, adoptSession, flushSessions, useSessionStore, } from "./agent-data.js";
|
|
12
|
+
import { createAccess } from "./access.js";
|
|
13
|
+
import { parseCliArgs } from "./cli-args.js";
|
|
14
|
+
import { commandHelp, generalHelp } from "./cli-help.js";
|
|
15
|
+
import { compactText, formatText, messageLine, newsLine } from "./cli-output.js";
|
|
16
|
+
import { ENV_VARS, isMfaFailure, mfaCallbackFor, resolveAccounts, saveLogin, verifyLoginSession } from "./credentials.js";
|
|
17
|
+
import { createUniqueDownloadFile } from "./downloads.js";
|
|
18
|
+
import { openBrowser, startLoginServer } from "./login-server.js";
|
|
19
|
+
import { runMcpServer } from "./mcp.js";
|
|
20
|
+
import { toAgentJson } from "./output-json.js";
|
|
21
|
+
import { clearSessions, fileSessionStore } from "./session-store.js";
|
|
22
|
+
import { normalizeTenantUrl, resolveTenant, searchTenants } from "./tenant-search.js";
|
|
23
|
+
import { generateTOTP, parseTotpSecret } from "./totp.js";
|
|
11
24
|
// Enable keypress events for escape key detection
|
|
12
25
|
if (process.stdin.isTTY) {
|
|
13
26
|
emitKeypressEvents(process.stdin);
|
|
14
27
|
}
|
|
28
|
+
function outputMode(argv) {
|
|
29
|
+
const json = argv.includes("--json") || (!argv.includes("--text") && !process.stdout.isTTY);
|
|
30
|
+
return { json, pretty: json && Boolean(process.stdout.isTTY) };
|
|
31
|
+
}
|
|
32
|
+
function makeOutput(argv, fallbackText) {
|
|
33
|
+
const mode = outputMode(argv);
|
|
34
|
+
return {
|
|
35
|
+
...mode,
|
|
36
|
+
data(result, text) {
|
|
37
|
+
if (mode.json)
|
|
38
|
+
console.log(toAgentJson(result, mode.pretty));
|
|
39
|
+
else
|
|
40
|
+
console.log((text ?? (() => (fallbackText ? fallbackText(result) : toAgentJson(result, true))))());
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
15
44
|
const ACTIONS = [
|
|
16
45
|
{ value: "summary", name: "Daily summary" },
|
|
17
46
|
{ value: "schedule-today", name: "Today's schedule" },
|
|
18
|
-
{ value: "schedule-tomorrow", name: "
|
|
47
|
+
{ value: "schedule-tomorrow", name: "Next school day's schedule" },
|
|
19
48
|
{ value: "homework", name: "Recent homework" },
|
|
20
49
|
{ value: "exams", name: "Upcoming exams" },
|
|
21
50
|
{ value: "grades", name: "Exam grades" },
|
|
51
|
+
{ value: "notes", name: "Lesson notes (last 14 days)" },
|
|
52
|
+
{ value: "gradebook", name: "Gradebook" },
|
|
22
53
|
{ value: "news", name: "List news" },
|
|
23
54
|
{ value: "messages", name: "List messages" },
|
|
24
55
|
{ value: "exit", name: "Exit" },
|
|
25
56
|
];
|
|
26
57
|
async function main() {
|
|
27
|
-
const
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
printUsage();
|
|
32
|
-
await showUpdateNotice(updateCheck);
|
|
58
|
+
const argv = process.argv.slice(2);
|
|
59
|
+
// MCP mode speaks JSON-RPC on stdout: no update checks or notices.
|
|
60
|
+
if (argv[0] === "mcp") {
|
|
61
|
+
await runMcpServer(await readPackageVersion().catch(() => "unknown"));
|
|
33
62
|
return;
|
|
34
63
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
64
|
+
// School data is printed in many places; filter every printed line so a
|
|
65
|
+
// message can't smuggle terminal escape sequences. JSON keeps the data and
|
|
66
|
+
// only escapes it. Prompts write to the terminal directly (and keep their
|
|
67
|
+
// colours); their choices go through compactText.
|
|
68
|
+
const clean = outputMode(argv).json ? jsonSafe : terminalSafe;
|
|
69
|
+
for (const method of ["log", "error"]) {
|
|
70
|
+
const print = console[method].bind(console);
|
|
71
|
+
console[method] = (...parts) => print(...parts.map((part) => (typeof part === "string" ? clean(part) : part)));
|
|
72
|
+
}
|
|
73
|
+
// Commands continue the last Wilma session instead of logging in each time.
|
|
74
|
+
useSessionStore(fileSessionStore);
|
|
75
|
+
// Update notices are for people; agents and scripts skip the check.
|
|
76
|
+
const updateCheck = process.stderr.isTTY ? startUpdateCheck() : null;
|
|
77
|
+
try {
|
|
78
|
+
await runCommand(argv);
|
|
79
|
+
if (updateCheck)
|
|
80
|
+
await showUpdateNotice(updateCheck);
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
updateCheck?.cancel();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async function runCommand(argv) {
|
|
87
|
+
const parsed = parseCliArgs(argv);
|
|
88
|
+
const { command, action, args, flags } = parsed;
|
|
89
|
+
const out = makeOutput(argv, (result) => formatText(command ?? "", action, result));
|
|
90
|
+
if (flags.version && !command) {
|
|
91
|
+
console.log(await readPackageVersion());
|
|
39
92
|
return;
|
|
40
93
|
}
|
|
41
|
-
if (
|
|
42
|
-
|
|
94
|
+
if (command === "help" || flags.help) {
|
|
95
|
+
const topic = command === "help" ? args[0] : command;
|
|
96
|
+
const text = topic ? commandHelp(topic) : generalHelp();
|
|
97
|
+
if (!text)
|
|
98
|
+
throw new WilmaAiError("unknown_command", `No help for "${topic}". See wilma --help.`);
|
|
99
|
+
console.log(text);
|
|
43
100
|
return;
|
|
44
101
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
102
|
+
switch (command) {
|
|
103
|
+
case null: {
|
|
104
|
+
// The interactive menu needs a person at a terminal; programs get the help.
|
|
105
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
106
|
+
console.log(generalHelp());
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
await runInteractive(await loadConfig());
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
case "login":
|
|
113
|
+
await handleLogin(parsed.raw, out);
|
|
114
|
+
return;
|
|
115
|
+
case "accounts":
|
|
116
|
+
await handleAccounts(action, args, out);
|
|
117
|
+
return;
|
|
118
|
+
case "find-school":
|
|
119
|
+
await handleFindSchool(args.join(" "), out);
|
|
120
|
+
return;
|
|
121
|
+
case "update":
|
|
122
|
+
await handleUpdate();
|
|
123
|
+
return;
|
|
124
|
+
case "config":
|
|
125
|
+
await clearConfig();
|
|
126
|
+
await clearSessions();
|
|
127
|
+
out.data({ status: "cleared", configPath: getConfigPath() }, () => `Cleared saved logins and sessions (${getConfigPath()}).`);
|
|
128
|
+
return;
|
|
129
|
+
default:
|
|
130
|
+
try {
|
|
131
|
+
await runDataCommand(parsed, out);
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
// Also after an error: the session is still good for the next command.
|
|
135
|
+
await flushSessions();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** Data commands: every child by default, the same results as the MCP tools. */
|
|
140
|
+
async function runDataCommand(parsed, out) {
|
|
141
|
+
const { command, action, args, flags } = parsed;
|
|
142
|
+
const accounts = await resolveAccounts(await loadConfig());
|
|
143
|
+
if (!accounts.length) {
|
|
144
|
+
throw new WilmaAiError("not_logged_in", `No saved Wilma login. Run \`wilma login\`, or set ${ENV_VARS.tenant}, ${ENV_VARS.username} and ${ENV_VARS.password}.`);
|
|
145
|
+
}
|
|
146
|
+
const access = createAccess(accounts, { debug: flags.debug, totpSecret: flags.totpSecret });
|
|
147
|
+
const student = flags.student;
|
|
148
|
+
switch (command) {
|
|
149
|
+
case "summary":
|
|
150
|
+
return out.data(await access.summary({ student, days: flags.days, since: flags.since }));
|
|
151
|
+
case "schedule":
|
|
152
|
+
return out.data(await access.schedule({ student, when: flags.when, date: flags.date, weekday: flags.weekday }));
|
|
153
|
+
case "homework":
|
|
154
|
+
return out.data(await access.homework({ student, limit: flags.limit }));
|
|
155
|
+
case "exams":
|
|
156
|
+
return out.data(await access.upcomingExams({ student, limit: flags.limit }));
|
|
157
|
+
case "grades":
|
|
158
|
+
return out.data(await access.grades({ student, limit: flags.limit }));
|
|
159
|
+
case "gradebook":
|
|
160
|
+
return out.data(await access.gradebook({ student }));
|
|
161
|
+
case "notes":
|
|
162
|
+
if (action === "summary")
|
|
163
|
+
return out.data(await access.lessonNotesSummary({ student, from: flags.from, to: flags.to }));
|
|
164
|
+
return out.data(await access.lessonNotes({ student, date: flags.date, days: flags.days, from: flags.from, to: flags.to }));
|
|
165
|
+
case "messages":
|
|
166
|
+
if (action === "read")
|
|
167
|
+
return out.data(await access.message({ id: Number(args[0]), student }));
|
|
168
|
+
return out.data(await access.messages({ student, folder: flags.folder, limit: flags.limit }));
|
|
169
|
+
case "news":
|
|
170
|
+
if (action === "read")
|
|
171
|
+
return out.data(await access.newsItem({ id: Number(args[0]), student }));
|
|
172
|
+
if (action === "download") {
|
|
173
|
+
const fetched = await access.newsAttachment({ newsId: Number(args[0]), resourceId: args[1], student });
|
|
174
|
+
return printDownload(fetched, flags.output, out);
|
|
175
|
+
}
|
|
176
|
+
return out.data(await access.news({ student, limit: flags.limit, includeOlder: flags.older }));
|
|
177
|
+
case "printouts":
|
|
178
|
+
if (action === "download")
|
|
179
|
+
return printDownload(await access.printout({ id: args[0], student }), flags.output, out);
|
|
180
|
+
return out.data(await access.printouts({ student }));
|
|
181
|
+
case "students":
|
|
182
|
+
return out.data({ students: await access.studentList() });
|
|
183
|
+
default:
|
|
184
|
+
throw new WilmaAiError("unknown_command", `Unknown command "${command}". See wilma --help.`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/** Save a fetched file in the output folder (default: the current one), never overwriting. */
|
|
188
|
+
async function saveDownload(fetched, outputOption) {
|
|
189
|
+
// "~/Downloads" typed at the interactive prompt (no shell to expand it).
|
|
190
|
+
const output = outputOption?.replace(/^~(?=$|[\\/])/, homedir());
|
|
191
|
+
const directory = resolve(output ?? process.cwd());
|
|
192
|
+
await mkdir(directory, { recursive: true });
|
|
193
|
+
const { path, handle } = await createUniqueDownloadFile(directory, fetched.fileName);
|
|
194
|
+
try {
|
|
195
|
+
await handle.writeFile(fetched.data);
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
await handle.close();
|
|
199
|
+
}
|
|
200
|
+
return path;
|
|
201
|
+
}
|
|
202
|
+
async function printDownload(fetched, outputOption, out) {
|
|
203
|
+
const printoutId = "printoutId" in fetched ? fetched.printoutId : undefined;
|
|
204
|
+
const source = printoutId ? { printoutId } : { newsId: fetched.newsId, resourceId: fetched.resource.id };
|
|
205
|
+
if (fetched.status === "not_a_file") {
|
|
206
|
+
out.data({ status: "not_a_file", student: fetched.student, ...source, label: fetched.resource.label, url: fetched.resource.url, message: fetched.message }, () => [fetched.resource.label, fetched.message, fetched.resource.url].join("\n"));
|
|
48
207
|
return;
|
|
49
208
|
}
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
209
|
+
const path = await saveDownload(fetched, outputOption);
|
|
210
|
+
out.data({ status: "downloaded", student: fetched.student, ...source, label: fetched.resource.label, path, contentType: fetched.contentType, sizeBytes: fetched.data.byteLength }, () => `Downloaded ${fetched.resource.label} to ${path}`);
|
|
211
|
+
}
|
|
212
|
+
async function handleFindSchool(query, out) {
|
|
213
|
+
const wilmas = (await searchTenants(query, 15)).map((t) => ({ url: t.url, name: t.name }));
|
|
214
|
+
const hint = wilmas.length ? undefined : `No Wilma found for "${query}". Many schools use their city's Wilma: try the city or municipality the school is in.`;
|
|
215
|
+
out.data({ wilmas, ...(hint ? { hint } : {}) }, () => (hint ? hint : wilmas.map((t) => `${t.url} ${t.name}`).join("\n")));
|
|
216
|
+
}
|
|
217
|
+
async function runInteractive(config) {
|
|
218
|
+
const mfa = createInteractiveMfa(() => saveConfig(config));
|
|
219
|
+
while (true) {
|
|
220
|
+
const profile = await chooseProfile(config, mfa);
|
|
221
|
+
if (!profile) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const access = new WilmaAccess({ profile: { ...profile, studentNumber: null }, mfa: mfa.callback });
|
|
225
|
+
const student = profile.studentNumber ?? undefined;
|
|
226
|
+
const show = (command, result, action = null) => {
|
|
227
|
+
console.clear();
|
|
228
|
+
console.log(formatText(command, action, result));
|
|
229
|
+
};
|
|
230
|
+
let nextAction = await selectOrCancel({
|
|
231
|
+
message: "What do you want to view?",
|
|
232
|
+
pageSize: 15,
|
|
233
|
+
choices: [
|
|
234
|
+
...ACTIONS.filter((a) => a.value !== "exit"),
|
|
235
|
+
{ value: "back", name: "Back to students" },
|
|
236
|
+
{ value: "exit", name: "Exit" },
|
|
237
|
+
],
|
|
238
|
+
});
|
|
239
|
+
if (nextAction === null) {
|
|
240
|
+
// Esc from main menu -> back to student picker
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
while (nextAction !== "exit" && nextAction !== "back") {
|
|
244
|
+
if (nextAction === "summary")
|
|
245
|
+
show("summary", await access.summary({ student }));
|
|
246
|
+
if (nextAction === "schedule-today")
|
|
247
|
+
show("schedule", await access.schedule({ student, when: "today" }));
|
|
248
|
+
if (nextAction === "schedule-tomorrow")
|
|
249
|
+
show("schedule", await access.schedule({ student, when: "tomorrow" }));
|
|
250
|
+
if (nextAction === "homework")
|
|
251
|
+
show("homework", await access.homework({ student }));
|
|
252
|
+
if (nextAction === "exams")
|
|
253
|
+
show("exams", await access.upcomingExams({ student }));
|
|
254
|
+
if (nextAction === "grades")
|
|
255
|
+
show("grades", await access.grades({ student }));
|
|
256
|
+
if (nextAction === "notes")
|
|
257
|
+
show("notes", await access.lessonNotes({ student, days: 14 }));
|
|
258
|
+
if (nextAction === "gradebook")
|
|
259
|
+
show("gradebook", await access.gradebook({ student }));
|
|
260
|
+
if (nextAction === "news")
|
|
261
|
+
await selectNewsToRead(access, student);
|
|
262
|
+
if (nextAction === "messages") {
|
|
263
|
+
const folder = await selectOrCancel({
|
|
264
|
+
message: "Select folder",
|
|
265
|
+
choices: [
|
|
266
|
+
{ value: "inbox", name: "Inbox" },
|
|
267
|
+
{ value: "archive", name: "Archive" },
|
|
268
|
+
{ value: "outbox", name: "Outbox" },
|
|
269
|
+
{ value: "drafts", name: "Drafts" },
|
|
270
|
+
{ value: "appointments", name: "Appointments" },
|
|
271
|
+
],
|
|
272
|
+
});
|
|
273
|
+
if (folder !== null) {
|
|
274
|
+
await selectMessageToRead(access, student, folder);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const followUp = await selectOrCancel({
|
|
278
|
+
message: "What next?",
|
|
279
|
+
pageSize: 15,
|
|
280
|
+
choices: [
|
|
281
|
+
...ACTIONS.filter((a) => a.value !== "exit"),
|
|
282
|
+
{ value: "back", name: "Back to students" },
|
|
283
|
+
{ value: "exit", name: "Exit" },
|
|
284
|
+
],
|
|
285
|
+
}, false);
|
|
286
|
+
nextAction = followUp ?? "back";
|
|
287
|
+
}
|
|
288
|
+
await flushSessions();
|
|
289
|
+
if (nextAction === "exit") {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
async function selectNewsToRead(access, student) {
|
|
295
|
+
const news = (await access.news({ student, limit: 30 })).students[0]?.news ?? [];
|
|
296
|
+
if (!news.length)
|
|
297
|
+
return;
|
|
298
|
+
const choices = [
|
|
299
|
+
{ value: "back", name: "Back" },
|
|
300
|
+
...news.map((item) => ({ value: String(item.wilmaId), name: newsLine(item) })),
|
|
301
|
+
];
|
|
302
|
+
const selected = await selectOrCancel({ message: "Read which bulletin?", choices });
|
|
303
|
+
if (!selected || selected === "back")
|
|
304
|
+
return;
|
|
305
|
+
const read = await access.newsItem({ id: Number(selected), student });
|
|
306
|
+
console.clear();
|
|
307
|
+
console.log(formatText("news", "read", read));
|
|
308
|
+
for (;;) {
|
|
309
|
+
const resources = read.news.resources ?? [];
|
|
310
|
+
if (!resources.length)
|
|
311
|
+
return;
|
|
312
|
+
const pick = await selectOrCancel({
|
|
313
|
+
message: "Links",
|
|
314
|
+
choices: [
|
|
315
|
+
{ value: "back", name: "Continue" },
|
|
316
|
+
...resources.map((resource) => ({ value: resource.id, name: `Download "${compactText(resource.label)}"` })),
|
|
317
|
+
],
|
|
318
|
+
}, false);
|
|
319
|
+
if (!pick || pick === "back")
|
|
320
|
+
return;
|
|
321
|
+
const directory = await inputOrCancel({ message: "Save to directory", default: resolve(homedir(), "Downloads") });
|
|
322
|
+
if (directory === null)
|
|
323
|
+
continue;
|
|
324
|
+
try {
|
|
325
|
+
const fetched = await access.newsAttachment({ newsId: read.news.wilmaId, resourceId: pick, student });
|
|
326
|
+
if (fetched.status === "not_a_file")
|
|
327
|
+
console.log(`${fetched.message}\n${fetched.resource.url}`);
|
|
328
|
+
else
|
|
329
|
+
console.log(`Downloaded ${fetched.resource.label} to ${await saveDownload(fetched, directory)}`);
|
|
330
|
+
}
|
|
331
|
+
catch (error) {
|
|
332
|
+
console.error("Download failed:", error instanceof Error ? error.message : error);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
async function selectMessageToRead(access, student, folder) {
|
|
337
|
+
const messages = (await access.messages({ student, folder, limit: 30 })).students[0]?.messages ?? [];
|
|
338
|
+
if (!messages.length) {
|
|
339
|
+
console.log(`\nNo messages in ${folder}.`);
|
|
54
340
|
return;
|
|
55
341
|
}
|
|
56
|
-
|
|
57
|
-
|
|
342
|
+
const choices = [
|
|
343
|
+
{ value: "back", name: "Back" },
|
|
344
|
+
...messages.map((msg) => ({ value: String(msg.wilmaId), name: messageLine(msg) })),
|
|
345
|
+
];
|
|
346
|
+
const selected = await selectOrCancel({ message: "Read which message?", choices });
|
|
347
|
+
if (!selected || selected === "back")
|
|
348
|
+
return;
|
|
349
|
+
console.clear();
|
|
350
|
+
console.log(formatText("messages", "read", await access.message({ id: Number(selected), student })));
|
|
58
351
|
}
|
|
59
|
-
async function chooseProfile(config,
|
|
352
|
+
async function chooseProfile(config, mfa) {
|
|
353
|
+
const onMfa = mfa.callback;
|
|
60
354
|
if (config.profiles.length) {
|
|
61
355
|
const choices = config.profiles.map((p) => ({
|
|
62
356
|
value: p.id,
|
|
@@ -75,7 +369,8 @@ async function chooseProfile(config, onMfa, onStoredProfileResolved) {
|
|
|
75
369
|
if (!stored) {
|
|
76
370
|
throw new Error("Stored profile not found");
|
|
77
371
|
}
|
|
78
|
-
|
|
372
|
+
mfa.profile = stored;
|
|
373
|
+
mfa.typedSecret = null;
|
|
79
374
|
const secret = revealSecret(stored.passwordObfuscated);
|
|
80
375
|
if (!secret) {
|
|
81
376
|
throw new Error("Stored password could not be decoded");
|
|
@@ -110,119 +405,36 @@ async function chooseProfile(config, onMfa, onStoredProfileResolved) {
|
|
|
110
405
|
const passwordValue = await passwordOrCancel({ message: "Wilma password" });
|
|
111
406
|
if (passwordValue === null)
|
|
112
407
|
return null;
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const students = await
|
|
408
|
+
// Codes for a new login can't come from another saved login's key.
|
|
409
|
+
mfa.profile = undefined;
|
|
410
|
+
mfa.typedSecret = null;
|
|
411
|
+
const newProfile = { baseUrl: tenant.url, username, password: passwordValue };
|
|
412
|
+
const verified = await WilmaClient.login(newProfile, onMfa);
|
|
413
|
+
const students = await verified.students();
|
|
414
|
+
// The commands that follow continue this session instead of logging in again.
|
|
415
|
+
adoptSession(newProfile, verified);
|
|
119
416
|
const student = await chooseStudent(students);
|
|
120
417
|
if (!student)
|
|
121
418
|
return null;
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
studentNumber: student?.studentNumber ?? undefined,
|
|
125
|
-
};
|
|
126
|
-
const stored = {
|
|
127
|
-
id: `${tenant.url}|${username}`,
|
|
419
|
+
// Same rules as `wilma login`: one entry per account, whatever the username's case.
|
|
420
|
+
const stored = await saveLogin(config, {
|
|
128
421
|
tenantUrl: tenant.url,
|
|
129
422
|
tenantName: tenant.name,
|
|
130
423
|
username,
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
config.profiles = config.profiles.filter((p) => p.id !== stored.id).concat(stored);
|
|
138
|
-
config.lastProfileId = stored.id;
|
|
139
|
-
onStoredProfileResolved?.(stored);
|
|
424
|
+
password: passwordValue,
|
|
425
|
+
totpSecret: mfa.typedSecret,
|
|
426
|
+
students,
|
|
427
|
+
});
|
|
428
|
+
stored.lastStudentNumber = student.studentNumber;
|
|
429
|
+
stored.lastStudentName = student.name;
|
|
140
430
|
await saveConfig(config);
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
if (!profile) {
|
|
149
|
-
return;
|
|
150
|
-
}
|
|
151
|
-
const client = await WilmaClient.login(profile, interactiveMfa);
|
|
152
|
-
let nextAction = await selectOrCancel({
|
|
153
|
-
message: "What do you want to view?",
|
|
154
|
-
pageSize: 15,
|
|
155
|
-
choices: [
|
|
156
|
-
...ACTIONS.filter((a) => a.value !== "exit"),
|
|
157
|
-
{ value: "back", name: "Back to students" },
|
|
158
|
-
{ value: "exit", name: "Exit" },
|
|
159
|
-
],
|
|
160
|
-
});
|
|
161
|
-
if (nextAction === null) {
|
|
162
|
-
// Esc from main menu -> back to student picker
|
|
163
|
-
continue;
|
|
164
|
-
}
|
|
165
|
-
while (nextAction !== "exit" && nextAction !== "back") {
|
|
166
|
-
if (nextAction === "summary") {
|
|
167
|
-
console.clear();
|
|
168
|
-
await outputSummary(client, { days: 7, json: false });
|
|
169
|
-
}
|
|
170
|
-
if (nextAction === "schedule-today") {
|
|
171
|
-
console.clear();
|
|
172
|
-
await outputSchedule(client, { when: "today", json: false });
|
|
173
|
-
}
|
|
174
|
-
if (nextAction === "schedule-tomorrow") {
|
|
175
|
-
console.clear();
|
|
176
|
-
await outputSchedule(client, { when: "tomorrow", json: false });
|
|
177
|
-
}
|
|
178
|
-
if (nextAction === "homework") {
|
|
179
|
-
console.clear();
|
|
180
|
-
await outputHomework(client, { limit: 10, json: false });
|
|
181
|
-
}
|
|
182
|
-
if (nextAction === "exams") {
|
|
183
|
-
console.clear();
|
|
184
|
-
await outputUpcomingExams(client, { limit: 20, json: false });
|
|
185
|
-
}
|
|
186
|
-
if (nextAction === "grades") {
|
|
187
|
-
console.clear();
|
|
188
|
-
await outputGrades(client, { limit: 20, json: false });
|
|
189
|
-
}
|
|
190
|
-
if (nextAction === "news") {
|
|
191
|
-
await selectNewsToRead(client);
|
|
192
|
-
}
|
|
193
|
-
if (nextAction === "messages") {
|
|
194
|
-
const folder = await selectOrCancel({
|
|
195
|
-
message: "Select folder",
|
|
196
|
-
choices: [
|
|
197
|
-
{ value: "inbox", name: "Inbox" },
|
|
198
|
-
{ value: "archive", name: "Archive" },
|
|
199
|
-
{ value: "outbox", name: "Outbox" },
|
|
200
|
-
{ value: "drafts", name: "Drafts" },
|
|
201
|
-
{ value: "appointments", name: "Appointments" },
|
|
202
|
-
],
|
|
203
|
-
});
|
|
204
|
-
if (folder !== null) {
|
|
205
|
-
await selectMessageToRead(client, folder);
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
nextAction = await selectOrCancel({
|
|
209
|
-
message: "What next?",
|
|
210
|
-
pageSize: 15,
|
|
211
|
-
choices: [
|
|
212
|
-
...ACTIONS.filter((a) => a.value !== "exit"),
|
|
213
|
-
{ value: "back", name: "Back to students" },
|
|
214
|
-
{ value: "exit", name: "Exit" },
|
|
215
|
-
],
|
|
216
|
-
}, false); // Don't clear screen - preserve content output
|
|
217
|
-
if (nextAction === null) {
|
|
218
|
-
// Esc from action menu -> back to student picker
|
|
219
|
-
nextAction = "back";
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
if (nextAction === "exit") {
|
|
223
|
-
return;
|
|
224
|
-
}
|
|
225
|
-
}
|
|
431
|
+
mfa.profile = stored;
|
|
432
|
+
return {
|
|
433
|
+
baseUrl: stored.tenantUrl,
|
|
434
|
+
username,
|
|
435
|
+
password: passwordValue,
|
|
436
|
+
studentNumber: student.studentNumber,
|
|
437
|
+
};
|
|
226
438
|
}
|
|
227
439
|
async function selectTenant() {
|
|
228
440
|
const tenants = await listTenants();
|
|
@@ -240,11 +452,7 @@ async function selectTenant() {
|
|
|
240
452
|
municipalities: [],
|
|
241
453
|
};
|
|
242
454
|
}
|
|
243
|
-
|
|
244
|
-
if (query.trim()) {
|
|
245
|
-
const search = query.trim().toLowerCase();
|
|
246
|
-
filtered = tenants.filter((t) => tenantMatches(search, t));
|
|
247
|
-
}
|
|
455
|
+
const filtered = query.trim() ? await searchTenants(query, 20) : tenants;
|
|
248
456
|
const choices = filtered.slice(0, 20).map((t) => ({
|
|
249
457
|
value: t.url,
|
|
250
458
|
name: `${t.name ?? t.url} (${t.url})`,
|
|
@@ -281,42 +489,6 @@ async function selectTenant() {
|
|
|
281
489
|
return tenant;
|
|
282
490
|
}
|
|
283
491
|
}
|
|
284
|
-
function tenantMatches(search, tenant) {
|
|
285
|
-
const needle = (search ?? "").toLowerCase();
|
|
286
|
-
if (fuzzyIncludes(tenant.name ?? "", needle)) {
|
|
287
|
-
return true;
|
|
288
|
-
}
|
|
289
|
-
if (fuzzyIncludes(tenant.url ?? "", needle)) {
|
|
290
|
-
return true;
|
|
291
|
-
}
|
|
292
|
-
const municipalities = Array.isArray(tenant.municipalities) ? tenant.municipalities : [];
|
|
293
|
-
for (const m of municipalities) {
|
|
294
|
-
if (!m)
|
|
295
|
-
continue;
|
|
296
|
-
if (fuzzyIncludes(m.nameFi ?? "", needle))
|
|
297
|
-
return true;
|
|
298
|
-
if (fuzzyIncludes(m.nameSv ?? "", needle))
|
|
299
|
-
return true;
|
|
300
|
-
}
|
|
301
|
-
return false;
|
|
302
|
-
}
|
|
303
|
-
function fuzzyIncludes(target, needle) {
|
|
304
|
-
const hay = (target ?? "").toLowerCase();
|
|
305
|
-
const search = (needle ?? "").toLowerCase();
|
|
306
|
-
if (!search)
|
|
307
|
-
return true;
|
|
308
|
-
if (hay.includes(search))
|
|
309
|
-
return true;
|
|
310
|
-
let i = 0;
|
|
311
|
-
for (const ch of hay) {
|
|
312
|
-
if (ch === search[i]) {
|
|
313
|
-
i += 1;
|
|
314
|
-
if (i >= search.length)
|
|
315
|
-
return true;
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
return false;
|
|
319
|
-
}
|
|
320
492
|
async function chooseStudent(students) {
|
|
321
493
|
if (!students.length) {
|
|
322
494
|
const manual = await inputOrCancel({ message: "Student number (not found automatically)" });
|
|
@@ -331,7 +503,7 @@ async function chooseStudent(students) {
|
|
|
331
503
|
message: "Select student",
|
|
332
504
|
choices: students.map((s) => ({
|
|
333
505
|
value: s.studentNumber,
|
|
334
|
-
name: `${s.name} (${s.studentNumber})`,
|
|
506
|
+
name: `${compactText(s.name)} (${s.studentNumber})`,
|
|
335
507
|
})),
|
|
336
508
|
});
|
|
337
509
|
if (selected === null)
|
|
@@ -340,7 +512,11 @@ async function chooseStudent(students) {
|
|
|
340
512
|
}
|
|
341
513
|
async function chooseStudentFromProfile(stored, baseProfile, onMfa) {
|
|
342
514
|
let students = [];
|
|
343
|
-
const fresh = await
|
|
515
|
+
const fresh = await new WilmaAccess({ profile: baseProfile, mfa: onMfa }).students().catch((err) => {
|
|
516
|
+
if (stored.students?.length)
|
|
517
|
+
return [];
|
|
518
|
+
throw err;
|
|
519
|
+
});
|
|
344
520
|
if (fresh.length) {
|
|
345
521
|
students = fresh;
|
|
346
522
|
stored.students = fresh.map((s) => ({ studentNumber: s.studentNumber, name: s.name }));
|
|
@@ -366,548 +542,212 @@ async function chooseStudentFromProfile(stored, baseProfile, onMfa) {
|
|
|
366
542
|
default: defaultStudent?.studentNumber,
|
|
367
543
|
choices: students.map((s) => ({
|
|
368
544
|
value: s.studentNumber,
|
|
369
|
-
name: `${s.name} (${s.studentNumber})`,
|
|
545
|
+
name: `${compactText(s.name)} (${s.studentNumber})`,
|
|
370
546
|
})),
|
|
371
547
|
});
|
|
372
548
|
if (selected === null)
|
|
373
549
|
return null;
|
|
374
550
|
return students.find((s) => s.studentNumber === selected) ?? null;
|
|
375
551
|
}
|
|
376
|
-
async function
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
if (
|
|
391
|
-
|
|
552
|
+
async function handleLogin(args, out) {
|
|
553
|
+
const flags = { browser: true };
|
|
554
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
555
|
+
const arg = args[i];
|
|
556
|
+
if (arg === "--tenant")
|
|
557
|
+
flags.tenant = args[++i];
|
|
558
|
+
else if (arg === "--username")
|
|
559
|
+
flags.username = args[++i];
|
|
560
|
+
else if (arg === "--password-stdin")
|
|
561
|
+
flags.passwordStdin = true;
|
|
562
|
+
else if (arg === "--totp-secret")
|
|
563
|
+
flags.totpSecret = args[++i];
|
|
564
|
+
else if (arg === "--no-browser")
|
|
565
|
+
flags.browser = false;
|
|
566
|
+
else if (arg === "--json" || arg === "--text")
|
|
567
|
+
continue;
|
|
568
|
+
else if (arg === "--password") {
|
|
569
|
+
throw new WilmaAiError("invalid_argument", `Passing the password as a flag would leave it in shell history. Use ${ENV_VARS.password} or --password-stdin.`);
|
|
392
570
|
}
|
|
571
|
+
else
|
|
572
|
+
throw new WilmaAiError("invalid_argument", `Unknown login option "${arg}". See wilma help login.`);
|
|
393
573
|
}
|
|
394
|
-
const
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
if (flags.json) {
|
|
399
|
-
console.log(JSON.stringify(students, null, 2));
|
|
574
|
+
const report = (stored) => {
|
|
575
|
+
const students = (stored.students ?? []).map((s) => s.name);
|
|
576
|
+
if (out.json) {
|
|
577
|
+
out.data({ status: "ok", wilma: stored.tenantName ?? stored.tenantUrl, username: stored.username, students, configPath: getConfigPath() });
|
|
400
578
|
return;
|
|
401
579
|
}
|
|
402
|
-
console.log(
|
|
403
|
-
students.
|
|
404
|
-
console.log(
|
|
580
|
+
console.log(`Logged in to ${stored.tenantName ?? stored.tenantUrl} as ${stored.username}.`);
|
|
581
|
+
if (students.length)
|
|
582
|
+
console.log(`Children: ${students.join(", ")}`);
|
|
583
|
+
console.log(`Saved to ${getConfigPath()}`);
|
|
584
|
+
};
|
|
585
|
+
// Non-interactive: flags (with env fallback) — for agents and scripts.
|
|
586
|
+
if (flags.tenant || flags.username || flags.passwordStdin) {
|
|
587
|
+
const tenantQuery = flags.tenant ?? process.env[ENV_VARS.tenant];
|
|
588
|
+
const username = flags.username ?? process.env[ENV_VARS.username];
|
|
589
|
+
if (!tenantQuery || !username) {
|
|
590
|
+
throw new WilmaAiError("invalid_argument", "Non-interactive login needs --tenant and --username (or WILMA_TENANT / WILMA_USERNAME).");
|
|
591
|
+
}
|
|
592
|
+
const password = flags.passwordStdin ? (await readStdin()).replace(/\r?\n$/, "") : process.env[ENV_VARS.password];
|
|
593
|
+
if (!password) {
|
|
594
|
+
throw new WilmaAiError("invalid_argument", `No password given. Pipe it with --password-stdin or set ${ENV_VARS.password}.`);
|
|
595
|
+
}
|
|
596
|
+
const tenant = await resolveTenant(tenantQuery);
|
|
597
|
+
const totpSecret = flags.totpSecret ?? process.env[ENV_VARS.totpSecret] ?? null;
|
|
598
|
+
const { client, students } = await verifyLoginSession({ tenantUrl: tenant.url, username, password, totpSecret });
|
|
599
|
+
// The next commands continue this session: no second login (or code).
|
|
600
|
+
adoptSession({ baseUrl: normalizeTenantUrl(tenant.url), username, password }, client);
|
|
601
|
+
const stored = await saveLogin(await loadConfig(), {
|
|
602
|
+
tenantUrl: tenant.url,
|
|
603
|
+
tenantName: tenant.name,
|
|
604
|
+
username,
|
|
605
|
+
password,
|
|
606
|
+
totpSecret,
|
|
607
|
+
students,
|
|
405
608
|
});
|
|
609
|
+
report(stored);
|
|
610
|
+
await flushSessions();
|
|
406
611
|
return;
|
|
407
612
|
}
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
}
|
|
413
|
-
const newsId = parseReadId(flags.id, "news");
|
|
414
|
-
const resourceId = normalizeResourceId(flags.resourceId);
|
|
415
|
-
if (!resourceId) {
|
|
416
|
-
throw new Error("Missing news resource id (for example, resource-1 or just 1)");
|
|
417
|
-
}
|
|
418
|
-
if (!flags.student) {
|
|
419
|
-
const students = await getStudentsForCommand(profile, config);
|
|
420
|
-
if (students.length > 1) {
|
|
421
|
-
console.error("Multiple students found. Use --student <id> to specify which one:");
|
|
422
|
-
students.forEach((s) => console.error(` ${s.studentNumber} ${s.name}`));
|
|
423
|
-
process.exit(1);
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
const studentInfo = await resolveStudentForFlags(profile, config, flags.student);
|
|
427
|
-
if (!studentInfo && !profile.studentNumber) {
|
|
428
|
-
await printStudentSelectionHelp(profile, config);
|
|
429
|
-
return;
|
|
430
|
-
}
|
|
431
|
-
const perStudentClient = await WilmaClient.login({
|
|
432
|
-
...profile,
|
|
433
|
-
studentNumber: studentInfo?.studentNumber ?? profile.studentNumber,
|
|
434
|
-
}, mfaCallback);
|
|
435
|
-
await outputNewsResourceDownload(perStudentClient, newsId, resourceId, {
|
|
436
|
-
output: flags.output,
|
|
437
|
-
json: flags.json,
|
|
438
|
-
});
|
|
439
|
-
return;
|
|
440
|
-
}
|
|
441
|
-
if (subcommand === "read" && flags.id) {
|
|
442
|
-
const newsId = parseReadId(flags.id, "news");
|
|
443
|
-
if (!flags.student) {
|
|
444
|
-
const students = await getStudentsForCommand(profile, config);
|
|
445
|
-
if (students.length > 1) {
|
|
446
|
-
console.error("Multiple students found. Use --student <id> to specify which one:");
|
|
447
|
-
students.forEach((s) => console.error(` ${s.studentNumber} ${s.name}`));
|
|
448
|
-
process.exit(1);
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
const studentInfo = await resolveStudentForFlags(profile, config, flags.student);
|
|
452
|
-
if (!studentInfo && !profile.studentNumber) {
|
|
453
|
-
await printStudentSelectionHelp(profile, config);
|
|
454
|
-
return;
|
|
455
|
-
}
|
|
456
|
-
const perStudentClient = await WilmaClient.login({
|
|
457
|
-
...profile,
|
|
458
|
-
studentNumber: studentInfo?.studentNumber ?? profile.studentNumber,
|
|
459
|
-
}, mfaCallback);
|
|
460
|
-
await outputNewsItem(perStudentClient, newsId, flags.json, {
|
|
461
|
-
studentNumber: studentInfo?.studentNumber ?? profile.studentNumber,
|
|
462
|
-
});
|
|
463
|
-
return;
|
|
464
|
-
}
|
|
465
|
-
if (flags.allStudents) {
|
|
466
|
-
await outputAllNews(profile, config, flags.limit ?? 20, flags.json, mfaCallback);
|
|
467
|
-
return;
|
|
468
|
-
}
|
|
469
|
-
const studentInfo = await resolveStudentForFlags(profile, config, flags.student);
|
|
470
|
-
if (!studentInfo && !profile.studentNumber) {
|
|
471
|
-
await printStudentSelectionHelp(profile, config);
|
|
472
|
-
return;
|
|
473
|
-
}
|
|
474
|
-
const perStudentClient = await WilmaClient.login({
|
|
475
|
-
...profile,
|
|
476
|
-
studentNumber: studentInfo?.studentNumber ?? profile.studentNumber,
|
|
477
|
-
}, mfaCallback);
|
|
478
|
-
await outputNews(perStudentClient, {
|
|
479
|
-
limit: flags.limit ?? 20,
|
|
480
|
-
json: flags.json,
|
|
481
|
-
label: studentInfo?.name ?? undefined,
|
|
482
|
-
});
|
|
483
|
-
return;
|
|
613
|
+
// Browser: a one-time page on 127.0.0.1 where the parent logs in.
|
|
614
|
+
if (flags.browser && openBrowser("about:blank", { dryRun: true }) === "headless") {
|
|
615
|
+
// A cloud computer: the parent can't reach a page served here.
|
|
616
|
+
throw new WilmaAiError("no_browser", HEADLESS_LOGIN_HELP);
|
|
484
617
|
}
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
if (!flags.student) {
|
|
489
|
-
const students = await getStudentsForCommand(profile, config);
|
|
490
|
-
if (students.length > 1) {
|
|
491
|
-
console.error("Multiple students found. Use --student <id> to specify which one:");
|
|
492
|
-
students.forEach((s) => console.error(` ${s.studentNumber} ${s.name}`));
|
|
493
|
-
process.exit(1);
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
const studentInfo = await resolveStudentForFlags(profile, config, flags.student);
|
|
497
|
-
if (!studentInfo && !profile.studentNumber) {
|
|
498
|
-
await printStudentSelectionHelp(profile, config);
|
|
618
|
+
const server = await startLoginServer({
|
|
619
|
+
onSaved: (stored) => {
|
|
620
|
+
if (out.json)
|
|
499
621
|
return;
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
json: flags.json,
|
|
513
|
-
}, mfaCallback);
|
|
514
|
-
return;
|
|
515
|
-
}
|
|
516
|
-
const studentInfo = await resolveStudentForFlags(profile, config, flags.student);
|
|
517
|
-
if (!studentInfo && !profile.studentNumber) {
|
|
518
|
-
await printStudentSelectionHelp(profile, config);
|
|
519
|
-
return;
|
|
520
|
-
}
|
|
521
|
-
const perStudentClient = await WilmaClient.login({
|
|
522
|
-
...profile,
|
|
523
|
-
studentNumber: studentInfo?.studentNumber ?? profile.studentNumber,
|
|
524
|
-
}, mfaCallback);
|
|
525
|
-
await outputMessages(perStudentClient, {
|
|
526
|
-
folder: flags.folder ?? "inbox",
|
|
527
|
-
limit: flags.limit ?? 20,
|
|
528
|
-
json: flags.json,
|
|
529
|
-
label: studentInfo?.name ?? undefined,
|
|
530
|
-
});
|
|
531
|
-
return;
|
|
622
|
+
const students = (stored.students ?? []).map((s) => s.name);
|
|
623
|
+
console.log(`\nLogged in to ${stored.tenantName ?? stored.tenantUrl} as ${stored.username}.`);
|
|
624
|
+
if (students.length)
|
|
625
|
+
console.log(`Children: ${students.join(", ")}`);
|
|
626
|
+
console.log("Add another Wilma in the browser if a child is on a different one, or press Done there (or Enter here) to finish.");
|
|
627
|
+
},
|
|
628
|
+
});
|
|
629
|
+
const opened = flags.browser && openBrowser(server.url) === "opened";
|
|
630
|
+
if (!out.json) {
|
|
631
|
+
console.log(opened ? "Opening your browser to log in to Wilma…" : "Open this link in a browser on this computer to log in to Wilma:");
|
|
632
|
+
console.log(` ${server.url}`);
|
|
633
|
+
console.log("Waiting for login (Ctrl+C to cancel)…");
|
|
532
634
|
}
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
635
|
+
else {
|
|
636
|
+
console.error(JSON.stringify({ status: "waiting", url: server.url }));
|
|
637
|
+
}
|
|
638
|
+
// Enter in the terminal finishes too, in case the browser tab was closed without pressing Done.
|
|
639
|
+
const finishOnEnter = () => server.close();
|
|
640
|
+
if (process.stdin.isTTY) {
|
|
641
|
+
process.stdin.resume();
|
|
642
|
+
process.stdin.once("data", finishOnEnter);
|
|
643
|
+
}
|
|
644
|
+
const logins = await server.closed;
|
|
645
|
+
if (process.stdin.isTTY) {
|
|
646
|
+
process.stdin.off("data", finishOnEnter);
|
|
647
|
+
process.stdin.pause();
|
|
648
|
+
}
|
|
649
|
+
if (!logins.length) {
|
|
650
|
+
throw new WilmaAiError("login_cancelled", "Login page closed before logging in. Run `wilma login` again.");
|
|
651
|
+
}
|
|
652
|
+
await flushSessions();
|
|
653
|
+
if (out.json) {
|
|
654
|
+
out.data({
|
|
655
|
+
status: "ok",
|
|
656
|
+
logins: logins.map((stored) => ({
|
|
657
|
+
wilma: stored.tenantName ?? stored.tenantUrl,
|
|
658
|
+
username: stored.username,
|
|
659
|
+
students: (stored.students ?? []).map((s) => s.name),
|
|
660
|
+
})),
|
|
661
|
+
configPath: getConfigPath(),
|
|
551
662
|
});
|
|
552
663
|
return;
|
|
553
664
|
}
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
665
|
+
console.log(`\nSaved to ${getConfigPath()}`);
|
|
666
|
+
}
|
|
667
|
+
const HEADLESS_LOGIN_HELP = [
|
|
668
|
+
"This computer has no browser, so the login page can't be shown here.",
|
|
669
|
+
"If you are an agent on a cloud computer, set up the login with the user instead:",
|
|
670
|
+
" 1. Ask the user which city their children's school is in (or the school's name),",
|
|
671
|
+
" run `wilma tenants <city>` and let them pick their Wilma from the list.",
|
|
672
|
+
` 2. Ask them to store their Wilma username and password in your secret settings as`,
|
|
673
|
+
` ${ENV_VARS.username} and ${ENV_VARS.password} (plus ${ENV_VARS.totpSecret} for two-step verification),`,
|
|
674
|
+
` and set ${ENV_VARS.tenant} to the address they picked. Commands then work without \`wilma login\`.`,
|
|
675
|
+
" Never ask the user to type their Wilma password into the chat.",
|
|
676
|
+
].join("\n");
|
|
677
|
+
/** List saved Wilma logins, or remove one: `wilma accounts remove <number|Wilma name|username>`. */
|
|
678
|
+
async function handleAccounts(action, args, out) {
|
|
679
|
+
const config = await loadConfig();
|
|
680
|
+
if (action === "remove") {
|
|
681
|
+
const target = args.join(" ").trim().toLowerCase();
|
|
682
|
+
if (!target)
|
|
683
|
+
throw new WilmaAiError("invalid_argument", "Which login? Use the number from `wilma accounts`, the Wilma name or the username.");
|
|
684
|
+
const byNumber = /^\d+$/.test(target) ? config.profiles[Number(target) - 1] : undefined;
|
|
685
|
+
const matches = byNumber
|
|
686
|
+
? [byNumber]
|
|
687
|
+
: config.profiles.filter((p) => [p.tenantName ?? "", p.tenantUrl, p.username].some((v) => v.toLowerCase() === target));
|
|
688
|
+
if (matches.length !== 1) {
|
|
689
|
+
throw new WilmaAiError(matches.length ? "ambiguous_account" : "unknown_account", matches.length ? `"${target}" matches several logins; use its number.` : `No saved login matches "${target}".`);
|
|
690
|
+
}
|
|
691
|
+
const removed = matches[0];
|
|
692
|
+
config.profiles = config.profiles.filter((p) => p.id !== removed.id);
|
|
693
|
+
if (config.lastProfileId === removed.id)
|
|
694
|
+
config.lastProfileId = config.profiles[0]?.id ?? null;
|
|
695
|
+
await saveConfig(config);
|
|
696
|
+
await clearSessions();
|
|
697
|
+
const result = { status: "removed", wilma: removed.tenantName ?? removed.tenantUrl, username: removed.username };
|
|
698
|
+
out.data(result, () => `Removed ${removed.username} @ ${result.wilma}.`);
|
|
573
699
|
return;
|
|
574
700
|
}
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
701
|
+
const accounts = config.profiles.map((p, i) => ({
|
|
702
|
+
number: i + 1,
|
|
703
|
+
wilma: p.tenantName ?? p.tenantUrl,
|
|
704
|
+
url: p.tenantUrl,
|
|
705
|
+
username: p.username,
|
|
706
|
+
children: (p.students ?? []).map((st) => st.name),
|
|
707
|
+
}));
|
|
708
|
+
out.data({ accounts }, () => accounts.length
|
|
709
|
+
? [
|
|
710
|
+
...accounts.map((a) => `${a.number}. ${a.wilma} — ${a.username}${a.children.length ? ` (${a.children.join(", ")})` : ""}`),
|
|
711
|
+
"",
|
|
712
|
+
"Add another with `wilma login`; remove one with `wilma accounts remove <number>`.",
|
|
713
|
+
].join("\n")
|
|
714
|
+
: "No saved Wilma logins. Run `wilma login`.");
|
|
715
|
+
}
|
|
716
|
+
async function readStdin() {
|
|
717
|
+
const chunks = [];
|
|
718
|
+
for await (const chunk of process.stdin)
|
|
719
|
+
chunks.push(chunk);
|
|
720
|
+
return Buffer.concat(chunks).toString("utf-8");
|
|
721
|
+
}
|
|
722
|
+
async function readPackageVersion() {
|
|
723
|
+
// fileURLToPath: install paths can have spaces, non-ASCII letters or a Windows drive.
|
|
724
|
+
const pkgPath = resolve(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
725
|
+
const raw = await readFile(pkgPath, "utf-8");
|
|
726
|
+
const data = JSON.parse(raw);
|
|
727
|
+
return data.version ?? "unknown";
|
|
728
|
+
}
|
|
729
|
+
async function handleUpdate() {
|
|
730
|
+
const currentVersion = await readPackageVersion();
|
|
731
|
+
console.log(`Current version: ${currentVersion}`);
|
|
732
|
+
console.log("Updating @wilm-ai/wilma-cli...\n");
|
|
733
|
+
// npm is npm.cmd on Windows, which only runs through a shell. The command is a
|
|
734
|
+
// fixed string, so the shell sees no user input.
|
|
735
|
+
const command = "npm install -g @wilm-ai/wilma-cli@latest";
|
|
736
|
+
const child = process.platform === "win32"
|
|
737
|
+
? spawn(command, { stdio: "inherit", shell: true })
|
|
738
|
+
: spawn("npm", command.split(" ").slice(1), { stdio: "inherit" });
|
|
739
|
+
const code = await new Promise((resolve, reject) => {
|
|
740
|
+
child.on("error", (err) => {
|
|
741
|
+
reject(err.code === "ENOENT"
|
|
742
|
+
? new Error("npm wasn't found. Install Node.js (it includes npm) from https://nodejs.org and run `wilma update` again.")
|
|
743
|
+
: err);
|
|
595
744
|
});
|
|
596
|
-
|
|
745
|
+
child.on("close", resolve);
|
|
746
|
+
});
|
|
747
|
+
if (code !== 0) {
|
|
748
|
+
throw new Error(`npm couldn't update WilmAI (exit code ${code}); see npm's message above.`);
|
|
597
749
|
}
|
|
598
|
-
|
|
599
|
-
if (flags.allStudents) {
|
|
600
|
-
await outputAllOverviewCommand(profile, config, "homework", flags, mfaCallback);
|
|
601
|
-
return;
|
|
602
|
-
}
|
|
603
|
-
const studentInfo = await resolveStudentForFlags(profile, config, flags.student);
|
|
604
|
-
if (!studentInfo && !profile.studentNumber) {
|
|
605
|
-
await printStudentSelectionHelp(profile, config);
|
|
606
|
-
return;
|
|
607
|
-
}
|
|
608
|
-
const perStudentClient = await WilmaClient.login({
|
|
609
|
-
...profile,
|
|
610
|
-
studentNumber: studentInfo?.studentNumber ?? profile.studentNumber,
|
|
611
|
-
}, mfaCallback);
|
|
612
|
-
await outputHomework(perStudentClient, {
|
|
613
|
-
limit: flags.limit ?? 10,
|
|
614
|
-
json: flags.json,
|
|
615
|
-
label: studentInfo?.name ?? undefined,
|
|
616
|
-
});
|
|
617
|
-
return;
|
|
618
|
-
}
|
|
619
|
-
if (command === "grades") {
|
|
620
|
-
if (flags.allStudents) {
|
|
621
|
-
await outputAllOverviewCommand(profile, config, "grades", flags, mfaCallback);
|
|
622
|
-
return;
|
|
623
|
-
}
|
|
624
|
-
const studentInfo = await resolveStudentForFlags(profile, config, flags.student);
|
|
625
|
-
if (!studentInfo && !profile.studentNumber) {
|
|
626
|
-
await printStudentSelectionHelp(profile, config);
|
|
627
|
-
return;
|
|
628
|
-
}
|
|
629
|
-
const perStudentClient = await WilmaClient.login({
|
|
630
|
-
...profile,
|
|
631
|
-
studentNumber: studentInfo?.studentNumber ?? profile.studentNumber,
|
|
632
|
-
}, mfaCallback);
|
|
633
|
-
await outputGrades(perStudentClient, {
|
|
634
|
-
limit: flags.limit ?? 20,
|
|
635
|
-
json: flags.json,
|
|
636
|
-
label: studentInfo?.name ?? undefined,
|
|
637
|
-
});
|
|
638
|
-
return;
|
|
639
|
-
}
|
|
640
|
-
if (command === "summary") {
|
|
641
|
-
if (flags.allStudents) {
|
|
642
|
-
await outputAllOverviewCommand(profile, config, "summary", flags, mfaCallback);
|
|
643
|
-
return;
|
|
644
|
-
}
|
|
645
|
-
const studentInfo = await resolveStudentForFlags(profile, config, flags.student);
|
|
646
|
-
if (!studentInfo && !profile.studentNumber) {
|
|
647
|
-
await printStudentSelectionHelp(profile, config);
|
|
648
|
-
return;
|
|
649
|
-
}
|
|
650
|
-
const perStudentClient = await WilmaClient.login({
|
|
651
|
-
...profile,
|
|
652
|
-
studentNumber: studentInfo?.studentNumber ?? profile.studentNumber,
|
|
653
|
-
}, mfaCallback);
|
|
654
|
-
await outputSummary(perStudentClient, {
|
|
655
|
-
days: flags.days ?? 7,
|
|
656
|
-
json: flags.json,
|
|
657
|
-
label: studentInfo?.name ?? undefined,
|
|
658
|
-
});
|
|
659
|
-
return;
|
|
660
|
-
}
|
|
661
|
-
printUsage();
|
|
662
|
-
}
|
|
663
|
-
function printUsage() {
|
|
664
|
-
console.log("Usage:");
|
|
665
|
-
console.log(" wilma summary [--days 7] [--student <id|name>] [--all-students] [--json]");
|
|
666
|
-
console.log(" wilma schedule list [--when today|tomorrow|week] [--date YYYY-MM-DD] [--weekday mon|tue|wed|thu|fri|sat|sun] [--student <id|name>] [--all-students] [--json]");
|
|
667
|
-
console.log(" wilma homework list [--limit 10] [--student <id|name>] [--all-students] [--json]");
|
|
668
|
-
console.log(" wilma exams list [--limit 20] [--student <id|name>] [--all-students] [--json]");
|
|
669
|
-
console.log(" wilma grades list [--limit 20] [--student <id|name>] [--all-students] [--json]");
|
|
670
|
-
console.log(" wilma kids list [--json]");
|
|
671
|
-
console.log(" wilma news list [--limit 20] [--student <id|name>] [--all-students] [--json]");
|
|
672
|
-
console.log(" wilma news read <id> [--student <id|name>] [--json]");
|
|
673
|
-
console.log(" wilma news resource download <news-id> <resource-id> [--student <id|name>] [--output <directory>] [--json]");
|
|
674
|
-
console.log(" wilma messages list [--folder inbox] [--limit 20] [--student <id|name>] [--all-students] [--json]");
|
|
675
|
-
console.log(" wilma messages read <id> [--student <id|name>] [--json]");
|
|
676
|
-
console.log(" wilma attendance list [--date YYYY-MM-DD] [--student <id|name>] [--all-students] [--json]");
|
|
677
|
-
console.log(" wilma update");
|
|
678
|
-
console.log(" wilma config clear");
|
|
679
|
-
console.log(" wilma --help | -h");
|
|
680
|
-
console.log(" wilma --version | -v");
|
|
681
|
-
console.log("");
|
|
682
|
-
console.log("MFA: --totp-secret <base32-key|otpauth://...>");
|
|
683
|
-
}
|
|
684
|
-
async function getProfileForCommandNonInteractive(config, flags) {
|
|
685
|
-
if (!config.lastProfileId) {
|
|
686
|
-
console.error("No saved profile found. Run the interactive CLI first.");
|
|
687
|
-
return null;
|
|
688
|
-
}
|
|
689
|
-
const stored = config.profiles.find((p) => p.id === config.lastProfileId);
|
|
690
|
-
if (!stored) {
|
|
691
|
-
console.error("Saved profile not found. Run the interactive CLI first.");
|
|
692
|
-
return null;
|
|
693
|
-
}
|
|
694
|
-
const secret = revealSecret(stored.passwordObfuscated);
|
|
695
|
-
if (!secret) {
|
|
696
|
-
console.error("Stored password could not be decoded. Re-login interactively.");
|
|
697
|
-
return null;
|
|
698
|
-
}
|
|
699
|
-
return {
|
|
700
|
-
baseUrl: stored.tenantUrl,
|
|
701
|
-
username: stored.username,
|
|
702
|
-
password: secret,
|
|
703
|
-
studentNumber: stored.lastStudentNumber ?? undefined,
|
|
704
|
-
debug: Boolean(flags.debug),
|
|
705
|
-
};
|
|
706
|
-
}
|
|
707
|
-
function parseArgs(args) {
|
|
708
|
-
const [command, rawSubcommand, ...rawRest] = args;
|
|
709
|
-
// If "subcommand" is actually a flag, push it back into rest
|
|
710
|
-
const subcommand = rawSubcommand?.startsWith("--") ? undefined : rawSubcommand;
|
|
711
|
-
const rest = rawSubcommand?.startsWith("--") ? [rawSubcommand, ...rawRest] : rawRest;
|
|
712
|
-
const flags = {};
|
|
713
|
-
const positionals = [];
|
|
714
|
-
let i = 0;
|
|
715
|
-
while (i < rest.length) {
|
|
716
|
-
const arg = rest[i];
|
|
717
|
-
if (arg === "--json") {
|
|
718
|
-
flags.json = true;
|
|
719
|
-
i += 1;
|
|
720
|
-
continue;
|
|
721
|
-
}
|
|
722
|
-
if (arg === "--all-students" || arg === "--all") {
|
|
723
|
-
flags.allStudents = true;
|
|
724
|
-
i += 1;
|
|
725
|
-
continue;
|
|
726
|
-
}
|
|
727
|
-
if (arg === "--debug") {
|
|
728
|
-
flags.debug = true;
|
|
729
|
-
i += 1;
|
|
730
|
-
continue;
|
|
731
|
-
}
|
|
732
|
-
if (arg === "--limit") {
|
|
733
|
-
const value = Number(rest[i + 1]);
|
|
734
|
-
if (!Number.isNaN(value)) {
|
|
735
|
-
flags.limit = value;
|
|
736
|
-
}
|
|
737
|
-
i += 2;
|
|
738
|
-
continue;
|
|
739
|
-
}
|
|
740
|
-
if (arg === "--student") {
|
|
741
|
-
flags.student = rest[i + 1];
|
|
742
|
-
i += 2;
|
|
743
|
-
continue;
|
|
744
|
-
}
|
|
745
|
-
if (arg === "--folder") {
|
|
746
|
-
flags.folder = rest[i + 1];
|
|
747
|
-
i += 2;
|
|
748
|
-
continue;
|
|
749
|
-
}
|
|
750
|
-
if (arg === "--when") {
|
|
751
|
-
flags.when = rest[i + 1];
|
|
752
|
-
i += 2;
|
|
753
|
-
continue;
|
|
754
|
-
}
|
|
755
|
-
if (arg === "--days") {
|
|
756
|
-
const value = Number(rest[i + 1]);
|
|
757
|
-
if (!Number.isNaN(value)) {
|
|
758
|
-
flags.days = value;
|
|
759
|
-
}
|
|
760
|
-
i += 2;
|
|
761
|
-
continue;
|
|
762
|
-
}
|
|
763
|
-
if (arg === "--date") {
|
|
764
|
-
flags.date = rest[i + 1];
|
|
765
|
-
i += 2;
|
|
766
|
-
continue;
|
|
767
|
-
}
|
|
768
|
-
if (arg === "--weekday") {
|
|
769
|
-
flags.weekday = rest[i + 1];
|
|
770
|
-
i += 2;
|
|
771
|
-
continue;
|
|
772
|
-
}
|
|
773
|
-
if (arg === "--totp-secret") {
|
|
774
|
-
flags.totpSecret = rest[i + 1];
|
|
775
|
-
i += 2;
|
|
776
|
-
continue;
|
|
777
|
-
}
|
|
778
|
-
if (arg === "--output") {
|
|
779
|
-
flags.output = rest[i + 1];
|
|
780
|
-
i += 2;
|
|
781
|
-
continue;
|
|
782
|
-
}
|
|
783
|
-
if (!arg.startsWith("--")) {
|
|
784
|
-
positionals.push(arg);
|
|
785
|
-
}
|
|
786
|
-
i += 1;
|
|
787
|
-
}
|
|
788
|
-
const resourceAction = command === "news" && subcommand === "resource"
|
|
789
|
-
? positionals[0]
|
|
790
|
-
: undefined;
|
|
791
|
-
if (resourceAction) {
|
|
792
|
-
flags.id = positionals[1];
|
|
793
|
-
flags.resourceId = positionals[2];
|
|
794
|
-
}
|
|
795
|
-
else {
|
|
796
|
-
flags.id = positionals[0];
|
|
797
|
-
}
|
|
798
|
-
return { command, subcommand, resourceAction, flags };
|
|
799
|
-
}
|
|
800
|
-
function parseReadId(raw, entity) {
|
|
801
|
-
if (!raw) {
|
|
802
|
-
console.error(`Missing ${entity} id.`);
|
|
803
|
-
process.exit(1);
|
|
804
|
-
}
|
|
805
|
-
const id = Number(raw);
|
|
806
|
-
if (!Number.isInteger(id) || id <= 0) {
|
|
807
|
-
console.error(`Invalid ${entity} id "${raw}". Expected a positive integer.`);
|
|
808
|
-
process.exit(1);
|
|
809
|
-
}
|
|
810
|
-
return id;
|
|
811
|
-
}
|
|
812
|
-
function parseIsoDateOrExit(raw) {
|
|
813
|
-
const value = (raw ?? "").trim();
|
|
814
|
-
// Accept YYYY-MM-DD only.
|
|
815
|
-
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
816
|
-
console.error(`Invalid date "${raw}". Expected YYYY-MM-DD.`);
|
|
817
|
-
process.exit(1);
|
|
818
|
-
}
|
|
819
|
-
// Validate date is real.
|
|
820
|
-
const d = new Date(value + "T12:00:00Z");
|
|
821
|
-
if (Number.isNaN(d.getTime())) {
|
|
822
|
-
console.error(`Invalid date "${raw}". Expected a real calendar date.`);
|
|
823
|
-
process.exit(1);
|
|
824
|
-
}
|
|
825
|
-
const iso = d.toISOString().slice(0, 10);
|
|
826
|
-
if (iso !== value) {
|
|
827
|
-
console.error(`Invalid date "${raw}". Expected a real calendar date.`);
|
|
828
|
-
process.exit(1);
|
|
829
|
-
}
|
|
830
|
-
return value;
|
|
831
|
-
}
|
|
832
|
-
function normalizeWeekdayOrExit(raw) {
|
|
833
|
-
const v = (raw ?? "").trim().toLowerCase();
|
|
834
|
-
const map = {
|
|
835
|
-
sun: 0,
|
|
836
|
-
su: 0,
|
|
837
|
-
sunday: 0,
|
|
838
|
-
mon: 1,
|
|
839
|
-
ma: 1,
|
|
840
|
-
monday: 1,
|
|
841
|
-
tue: 2,
|
|
842
|
-
ti: 2,
|
|
843
|
-
tuesday: 2,
|
|
844
|
-
wed: 3,
|
|
845
|
-
ke: 3,
|
|
846
|
-
wednesday: 3,
|
|
847
|
-
thu: 4,
|
|
848
|
-
to: 4,
|
|
849
|
-
thursday: 4,
|
|
850
|
-
fri: 5,
|
|
851
|
-
pe: 5,
|
|
852
|
-
friday: 5,
|
|
853
|
-
sat: 6,
|
|
854
|
-
la: 6,
|
|
855
|
-
saturday: 6,
|
|
856
|
-
};
|
|
857
|
-
const day = map[v];
|
|
858
|
-
if (day === undefined) {
|
|
859
|
-
console.error(`Invalid weekday "${raw}". Use mon|tue|wed|thu|fri|sat|sun (also accepts fi: ma|ti|ke|to|pe|la|su).`);
|
|
860
|
-
process.exit(1);
|
|
861
|
-
}
|
|
862
|
-
return day;
|
|
863
|
-
}
|
|
864
|
-
function nextDateForWeekday(rawWeekday) {
|
|
865
|
-
const target = normalizeWeekdayOrExit(rawWeekday);
|
|
866
|
-
const now = new Date();
|
|
867
|
-
// Use local time.
|
|
868
|
-
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 12, 0, 0);
|
|
869
|
-
const current = today.getDay();
|
|
870
|
-
let delta = (target - current + 7) % 7;
|
|
871
|
-
// If you ask for e.g. "thu" on a Saturday, you'll get next Thursday.
|
|
872
|
-
// If you ask for today's weekday, you'll get today.
|
|
873
|
-
const d = new Date(today);
|
|
874
|
-
d.setDate(d.getDate() + delta);
|
|
875
|
-
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
876
|
-
}
|
|
877
|
-
async function readPackageVersion() {
|
|
878
|
-
const pkgPath = resolve(dirname(new URL(import.meta.url).pathname), "..", "package.json");
|
|
879
|
-
const raw = await readFile(pkgPath, "utf-8");
|
|
880
|
-
const data = JSON.parse(raw);
|
|
881
|
-
return data.version ?? "unknown";
|
|
882
|
-
}
|
|
883
|
-
async function handleUpdate() {
|
|
884
|
-
const currentVersion = await readPackageVersion();
|
|
885
|
-
console.log(`Current version: ${currentVersion}`);
|
|
886
|
-
console.log("Updating @wilm-ai/wilma-cli...\n");
|
|
887
|
-
return new Promise((resolve, reject) => {
|
|
888
|
-
const child = spawn("npm", ["install", "-g", "@wilm-ai/wilma-cli@latest"], {
|
|
889
|
-
stdio: "inherit",
|
|
890
|
-
});
|
|
891
|
-
child.on("error", (err) => {
|
|
892
|
-
if (err.code === "ENOENT") {
|
|
893
|
-
console.error("Error: npm not found. Please install npm and try again.");
|
|
894
|
-
}
|
|
895
|
-
else {
|
|
896
|
-
console.error("Update failed:", err.message);
|
|
897
|
-
}
|
|
898
|
-
reject(err);
|
|
899
|
-
});
|
|
900
|
-
child.on("close", (code) => {
|
|
901
|
-
if (code === 0) {
|
|
902
|
-
console.log("\nUpdate complete.");
|
|
903
|
-
resolve();
|
|
904
|
-
}
|
|
905
|
-
else {
|
|
906
|
-
console.error(`\nnpm exited with code ${code}`);
|
|
907
|
-
reject(new Error(`npm exited with code ${code}`));
|
|
908
|
-
}
|
|
909
|
-
});
|
|
910
|
-
});
|
|
750
|
+
console.log("\nUpdate complete.");
|
|
911
751
|
}
|
|
912
752
|
// --- Version check / update notification ---
|
|
913
753
|
const VERSION_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
@@ -925,37 +765,47 @@ async function readVersionCache() {
|
|
|
925
765
|
}
|
|
926
766
|
async function writeVersionCache(cache) {
|
|
927
767
|
const cachePath = getVersionCachePath();
|
|
928
|
-
await mkdir(dirname(cachePath), { recursive: true });
|
|
929
|
-
await writeFile(cachePath, JSON.stringify(cache), "utf-8");
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
|
|
768
|
+
await mkdir(dirname(cachePath), { recursive: true, mode: 0o700 });
|
|
769
|
+
await writeFile(cachePath, JSON.stringify(cache), { encoding: "utf-8", mode: 0o600 });
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Ask npm for the latest version at most once a day. A failed check counts as
|
|
773
|
+
* a check too: agents in sandboxes without internet access shouldn't pay for
|
|
774
|
+
* a timeout on every command.
|
|
775
|
+
*/
|
|
776
|
+
function startUpdateCheck() {
|
|
777
|
+
const controller = new AbortController();
|
|
778
|
+
let cancelled = false;
|
|
779
|
+
const result = (async () => {
|
|
933
780
|
const cache = await readVersionCache();
|
|
934
781
|
if (cache && Date.now() - cache.checkedAt < VERSION_CHECK_INTERVAL_MS) {
|
|
935
782
|
return cache.latestVersion;
|
|
936
783
|
}
|
|
937
|
-
const controller = new AbortController();
|
|
938
784
|
const timeout = setTimeout(() => controller.abort(), 3000);
|
|
939
785
|
try {
|
|
940
786
|
const response = await fetch("https://registry.npmjs.org/@wilm-ai/wilma-cli/latest", { signal: controller.signal });
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
const data = (await response.json());
|
|
945
|
-
const latestVersion = data.version ?? null;
|
|
946
|
-
if (latestVersion) {
|
|
947
|
-
await writeVersionCache({ latestVersion, checkedAt: Date.now() });
|
|
948
|
-
}
|
|
787
|
+
const data = response.ok ? (await response.json()) : {};
|
|
788
|
+
const latestVersion = data.version ?? cache?.latestVersion ?? null;
|
|
789
|
+
await writeVersionCache({ latestVersion, checkedAt: Date.now() });
|
|
949
790
|
return latestVersion;
|
|
950
791
|
}
|
|
951
792
|
catch {
|
|
952
|
-
|
|
793
|
+
// Cancelled because the command finished: try again next time.
|
|
794
|
+
if (!cancelled)
|
|
795
|
+
await writeVersionCache({ latestVersion: cache?.latestVersion ?? null, checkedAt: Date.now() }).catch(() => { });
|
|
953
796
|
return cache?.latestVersion ?? null;
|
|
954
797
|
}
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
}
|
|
798
|
+
finally {
|
|
799
|
+
clearTimeout(timeout);
|
|
800
|
+
}
|
|
801
|
+
})().catch(() => null);
|
|
802
|
+
return {
|
|
803
|
+
result,
|
|
804
|
+
cancel() {
|
|
805
|
+
cancelled = true;
|
|
806
|
+
controller.abort();
|
|
807
|
+
},
|
|
808
|
+
};
|
|
959
809
|
}
|
|
960
810
|
function isNewerVersion(latest, current) {
|
|
961
811
|
const latestParts = latest.split(".").map(Number);
|
|
@@ -970,11 +820,14 @@ function isNewerVersion(latest, current) {
|
|
|
970
820
|
}
|
|
971
821
|
return false;
|
|
972
822
|
}
|
|
973
|
-
async function showUpdateNotice(
|
|
823
|
+
async function showUpdateNotice(check) {
|
|
824
|
+
let timer;
|
|
974
825
|
try {
|
|
975
826
|
const latestVersion = await Promise.race([
|
|
976
|
-
|
|
977
|
-
new Promise((resolve) =>
|
|
827
|
+
check.result,
|
|
828
|
+
new Promise((resolve) => {
|
|
829
|
+
timer = setTimeout(() => resolve(null), 1000);
|
|
830
|
+
}),
|
|
978
831
|
]);
|
|
979
832
|
if (!latestVersion)
|
|
980
833
|
return;
|
|
@@ -987,589 +840,9 @@ async function showUpdateNotice(versionCheckPromise) {
|
|
|
987
840
|
catch {
|
|
988
841
|
// Silently ignore any errors
|
|
989
842
|
}
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
const news = await client.news.list();
|
|
993
|
-
const slice = news.slice(0, opts.limit);
|
|
994
|
-
if (opts.json) {
|
|
995
|
-
console.log(JSON.stringify(slice, null, 2));
|
|
996
|
-
return;
|
|
997
|
-
}
|
|
998
|
-
console.log(`\nNews (${news.length})`);
|
|
999
|
-
slice.forEach((item) => {
|
|
1000
|
-
const date = item.published ? item.published.toISOString().slice(0, 10) : "";
|
|
1001
|
-
const prefix = opts.label ? `[${opts.label}] ` : "";
|
|
1002
|
-
console.log(`- ${prefix}${date} ${compactText(item.title)} (id:${item.wilmaId})`.trim());
|
|
1003
|
-
});
|
|
1004
|
-
}
|
|
1005
|
-
async function outputNewsItem(client, id, json, opts) {
|
|
1006
|
-
const item = await client.news.get(id);
|
|
1007
|
-
if (json) {
|
|
1008
|
-
console.log(JSON.stringify(item, null, 2));
|
|
1009
|
-
return item;
|
|
1010
|
-
}
|
|
1011
|
-
console.log(`\n${item.title}`);
|
|
1012
|
-
if (item.subtitle)
|
|
1013
|
-
console.log(item.subtitle);
|
|
1014
|
-
if (item.published)
|
|
1015
|
-
console.log(item.published.toISOString());
|
|
1016
|
-
if (item.content)
|
|
1017
|
-
console.log(`\n${formatContent(item.content)}`);
|
|
1018
|
-
if (item.resources?.length) {
|
|
1019
|
-
console.log(`\nResources (${item.resources.length})`);
|
|
1020
|
-
for (const resource of item.resources) {
|
|
1021
|
-
const origin = resource.authContext === "wilma" ? "Wilma" : new URL(resource.url).host;
|
|
1022
|
-
console.log(`\n[${resource.id}] ${resource.label} (${origin})`);
|
|
1023
|
-
console.log(` ${resource.url}`);
|
|
1024
|
-
}
|
|
1025
|
-
if (!opts?.suppressDownloadHint) {
|
|
1026
|
-
const student = opts?.studentNumber ?? "<id|name>";
|
|
1027
|
-
console.log(`\nDownload any resource with:\n wilma news resource download ${item.wilmaId} <resource-id> --student ${student} --output <directory>`);
|
|
1028
|
-
}
|
|
1029
|
-
}
|
|
1030
|
-
return item;
|
|
1031
|
-
}
|
|
1032
|
-
const MAX_NEWS_RESOURCE_BYTES = 50 * 1024 * 1024;
|
|
1033
|
-
async function outputNewsResourceDownload(client, newsId, resourceId, opts) {
|
|
1034
|
-
const item = await client.news.get(newsId);
|
|
1035
|
-
const resource = item.resources?.find((candidate) => candidate.id === resourceId);
|
|
1036
|
-
if (!resource) {
|
|
1037
|
-
const known = (item.resources ?? []).map((r) => r.id).join(", ") || "none";
|
|
1038
|
-
throw new Error(`News resource "${resourceId}" not found in news item ${newsId} (available: ${known})`);
|
|
1039
|
-
}
|
|
1040
|
-
const fetched = await client.news.fetchResource(newsId, resourceId, { item });
|
|
1041
|
-
if (fetched.status === "not_a_file" || !fetched.response) {
|
|
1042
|
-
const result = {
|
|
1043
|
-
status: "not_a_file",
|
|
1044
|
-
newsId,
|
|
1045
|
-
resource,
|
|
1046
|
-
availableActions: ["open_in_browser"],
|
|
1047
|
-
message: "The link answered with a web page instead of a file. It may require signing in — open the URL in a browser instead.",
|
|
1048
|
-
};
|
|
1049
|
-
if (opts.json) {
|
|
1050
|
-
console.log(JSON.stringify(result, null, 2));
|
|
1051
|
-
}
|
|
1052
|
-
else {
|
|
1053
|
-
console.log(`\n${resource.label}`);
|
|
1054
|
-
console.log(result.message);
|
|
1055
|
-
console.log(resource.url);
|
|
1056
|
-
}
|
|
1057
|
-
return result;
|
|
1058
|
-
}
|
|
1059
|
-
const { response } = fetched;
|
|
1060
|
-
if (!response.ok) {
|
|
1061
|
-
await response.body?.cancel();
|
|
1062
|
-
throw new Error(`News resource download failed with HTTP ${response.status}`);
|
|
1063
|
-
}
|
|
1064
|
-
const contentType = response.headers.get("content-type")?.split(";", 1)[0]?.trim() || null;
|
|
1065
|
-
const declaredLength = Number(response.headers.get("content-length") ?? "0");
|
|
1066
|
-
if (Number.isFinite(declaredLength) && declaredLength > MAX_NEWS_RESOURCE_BYTES) {
|
|
1067
|
-
await response.body?.cancel();
|
|
1068
|
-
throw new Error("News resource exceeds the 50 MB download limit");
|
|
1069
|
-
}
|
|
1070
|
-
const outputDirectory = resolve(opts.output ?? process.cwd());
|
|
1071
|
-
await mkdir(outputDirectory, { recursive: true });
|
|
1072
|
-
const preferredName = fileNameFromResponse(resource, response.headers.get("content-disposition"), contentType);
|
|
1073
|
-
const { path, handle } = await createUniqueDownloadFile(outputDirectory, preferredName);
|
|
1074
|
-
let sizeBytes = 0;
|
|
1075
|
-
try {
|
|
1076
|
-
if (!response.body) {
|
|
1077
|
-
throw new Error("News resource response had no body");
|
|
1078
|
-
}
|
|
1079
|
-
const reader = response.body.getReader();
|
|
1080
|
-
while (true) {
|
|
1081
|
-
const { done, value } = await reader.read();
|
|
1082
|
-
if (done)
|
|
1083
|
-
break;
|
|
1084
|
-
sizeBytes += value.byteLength;
|
|
1085
|
-
if (sizeBytes > MAX_NEWS_RESOURCE_BYTES) {
|
|
1086
|
-
await reader.cancel();
|
|
1087
|
-
throw new Error("News resource exceeds the 50 MB download limit");
|
|
1088
|
-
}
|
|
1089
|
-
let offset = 0;
|
|
1090
|
-
while (offset < value.byteLength) {
|
|
1091
|
-
const { bytesWritten } = await handle.write(value, offset, value.byteLength - offset);
|
|
1092
|
-
if (bytesWritten === 0) {
|
|
1093
|
-
throw new Error("Could not write news resource to disk");
|
|
1094
|
-
}
|
|
1095
|
-
offset += bytesWritten;
|
|
1096
|
-
}
|
|
1097
|
-
}
|
|
1098
|
-
await handle.close();
|
|
1099
|
-
}
|
|
1100
|
-
catch (error) {
|
|
1101
|
-
await handle.close().catch(() => undefined);
|
|
1102
|
-
await rm(path, { force: true });
|
|
1103
|
-
throw error;
|
|
1104
|
-
}
|
|
1105
|
-
const result = {
|
|
1106
|
-
status: "downloaded",
|
|
1107
|
-
newsId,
|
|
1108
|
-
resourceId,
|
|
1109
|
-
path,
|
|
1110
|
-
contentType,
|
|
1111
|
-
sizeBytes,
|
|
1112
|
-
};
|
|
1113
|
-
if (opts.json) {
|
|
1114
|
-
console.log(JSON.stringify(result, null, 2));
|
|
1115
|
-
}
|
|
1116
|
-
else {
|
|
1117
|
-
console.log(`Downloaded ${resource.label} to ${path}`);
|
|
1118
|
-
}
|
|
1119
|
-
return result;
|
|
1120
|
-
}
|
|
1121
|
-
function normalizeResourceId(raw) {
|
|
1122
|
-
if (!raw)
|
|
1123
|
-
return undefined;
|
|
1124
|
-
return /^\d+$/.test(raw) ? `resource-${raw}` : raw;
|
|
1125
|
-
}
|
|
1126
|
-
function fileNameFromResponse(resource, contentDisposition, contentType) {
|
|
1127
|
-
const encoded = /filename\*=UTF-8''([^;]+)/i.exec(contentDisposition ?? "")?.[1];
|
|
1128
|
-
const quoted = /filename="([^"]+)"/i.exec(contentDisposition ?? "")?.[1];
|
|
1129
|
-
const plain = /filename=([^;]+)/i.exec(contentDisposition ?? "")?.[1]?.trim();
|
|
1130
|
-
let name = encoded ? decodeURIComponentSafely(encoded) : quoted ?? plain ?? resource.fileName ?? resource.label;
|
|
1131
|
-
name = sanitizeFileName(name);
|
|
1132
|
-
if (!/\.[A-Za-z0-9]{1,8}$/.test(name)) {
|
|
1133
|
-
const extension = extensionForContentType(contentType);
|
|
1134
|
-
if (extension)
|
|
1135
|
-
name += extension;
|
|
1136
|
-
}
|
|
1137
|
-
return name || "wilma-resource";
|
|
1138
|
-
}
|
|
1139
|
-
function sanitizeFileName(value) {
|
|
1140
|
-
return value
|
|
1141
|
-
.replace(/[\\/:*?"<>|\u0000-\u001f]/g, "_")
|
|
1142
|
-
.replace(/^\.+|\.+$/g, "")
|
|
1143
|
-
.trim()
|
|
1144
|
-
.slice(0, 180);
|
|
1145
|
-
}
|
|
1146
|
-
function extensionForContentType(contentType) {
|
|
1147
|
-
const extensions = {
|
|
1148
|
-
"application/pdf": ".pdf",
|
|
1149
|
-
"application/zip": ".zip",
|
|
1150
|
-
"image/jpeg": ".jpg",
|
|
1151
|
-
"image/png": ".png",
|
|
1152
|
-
"text/plain": ".txt",
|
|
1153
|
-
"text/csv": ".csv",
|
|
1154
|
-
};
|
|
1155
|
-
return contentType ? extensions[contentType] ?? "" : "";
|
|
1156
|
-
}
|
|
1157
|
-
function decodeURIComponentSafely(value) {
|
|
1158
|
-
try {
|
|
1159
|
-
return decodeURIComponent(value);
|
|
1160
|
-
}
|
|
1161
|
-
catch {
|
|
1162
|
-
return value;
|
|
1163
|
-
}
|
|
1164
|
-
}
|
|
1165
|
-
async function createUniqueDownloadFile(directory, preferredName) {
|
|
1166
|
-
const dot = preferredName.lastIndexOf(".");
|
|
1167
|
-
const hasExtension = dot > 0;
|
|
1168
|
-
const stem = hasExtension ? preferredName.slice(0, dot) : preferredName;
|
|
1169
|
-
const extension = hasExtension ? preferredName.slice(dot) : "";
|
|
1170
|
-
for (let index = 0; index < 1000; index += 1) {
|
|
1171
|
-
const candidate = index === 0 ? preferredName : `${stem}-${index}${extension}`;
|
|
1172
|
-
const path = resolve(directory, candidate);
|
|
1173
|
-
try {
|
|
1174
|
-
const handle = await open(path, "wx", 0o600);
|
|
1175
|
-
return { path, handle };
|
|
1176
|
-
}
|
|
1177
|
-
catch (error) {
|
|
1178
|
-
if (error.code !== "EEXIST") {
|
|
1179
|
-
throw error;
|
|
1180
|
-
}
|
|
1181
|
-
}
|
|
1182
|
-
}
|
|
1183
|
-
throw new Error("Could not choose a unique output filename");
|
|
1184
|
-
}
|
|
1185
|
-
async function outputExams(client, opts) {
|
|
1186
|
-
const exams = await client.exams.list();
|
|
1187
|
-
const slice = exams.slice(0, opts.limit);
|
|
1188
|
-
if (opts.json) {
|
|
1189
|
-
console.log(JSON.stringify(slice, null, 2));
|
|
1190
|
-
return;
|
|
1191
|
-
}
|
|
1192
|
-
console.log(`\nExams (${exams.length})`);
|
|
1193
|
-
slice.forEach((exam) => {
|
|
1194
|
-
const date = exam.examDate.toISOString().slice(0, 10);
|
|
1195
|
-
const prefix = opts.label ? `[${opts.label}] ` : "";
|
|
1196
|
-
console.log(`- ${prefix}${date} ${compactText(exam.subject)}`);
|
|
1197
|
-
});
|
|
1198
|
-
}
|
|
1199
|
-
/* ------------------------------------------------------------------ */
|
|
1200
|
-
/* Overview-powered output functions */
|
|
1201
|
-
/* ------------------------------------------------------------------ */
|
|
1202
|
-
function todayString() {
|
|
1203
|
-
const d = new Date();
|
|
1204
|
-
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
1205
|
-
}
|
|
1206
|
-
function nextSchoolDay(from) {
|
|
1207
|
-
const d = from ? new Date(from + "T12:00:00") : new Date();
|
|
1208
|
-
d.setDate(d.getDate() + 1);
|
|
1209
|
-
// Skip Saturday (6) and Sunday (0)
|
|
1210
|
-
while (d.getDay() === 0 || d.getDay() === 6) {
|
|
1211
|
-
d.setDate(d.getDate() + 1);
|
|
1212
|
-
}
|
|
1213
|
-
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
1214
|
-
}
|
|
1215
|
-
function currentWeekBounds() {
|
|
1216
|
-
const now = new Date();
|
|
1217
|
-
const dayOfWeek = now.getDay(); // 0=Sun, 1=Mon, ...
|
|
1218
|
-
const diffToMonday = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
|
|
1219
|
-
const monday = new Date(now);
|
|
1220
|
-
monday.setDate(now.getDate() + diffToMonday);
|
|
1221
|
-
const friday = new Date(monday);
|
|
1222
|
-
friday.setDate(monday.getDate() + 4);
|
|
1223
|
-
const fmt = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
1224
|
-
return [fmt(monday), fmt(friday)];
|
|
1225
|
-
}
|
|
1226
|
-
const DAY_NAMES = ["Su", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
1227
|
-
function resolveScheduleDateSelection(opts) {
|
|
1228
|
-
const when = opts.when || "week";
|
|
1229
|
-
if (opts.date && opts.weekday) {
|
|
1230
|
-
console.error("Use either --date or --weekday, not both.");
|
|
1231
|
-
process.exit(1);
|
|
1232
|
-
}
|
|
1233
|
-
if (opts.date) {
|
|
1234
|
-
const parsed = parseIsoDateOrExit(opts.date);
|
|
1235
|
-
return {
|
|
1236
|
-
when,
|
|
1237
|
-
startDate: parsed,
|
|
1238
|
-
endDate: parsed,
|
|
1239
|
-
queryDate: parsed,
|
|
1240
|
-
outputWhen: "date",
|
|
1241
|
-
};
|
|
1242
|
-
}
|
|
1243
|
-
if (opts.weekday) {
|
|
1244
|
-
const parsed = nextDateForWeekday(opts.weekday);
|
|
1245
|
-
return {
|
|
1246
|
-
when,
|
|
1247
|
-
startDate: parsed,
|
|
1248
|
-
endDate: parsed,
|
|
1249
|
-
queryDate: parsed,
|
|
1250
|
-
outputWhen: "weekday",
|
|
1251
|
-
};
|
|
1252
|
-
}
|
|
1253
|
-
if (when === "today") {
|
|
1254
|
-
const date = todayString();
|
|
1255
|
-
return { when, startDate: date, endDate: date, outputWhen: when };
|
|
1256
|
-
}
|
|
1257
|
-
if (when === "tomorrow") {
|
|
1258
|
-
const date = nextSchoolDay();
|
|
1259
|
-
return { when, startDate: date, endDate: date, outputWhen: when };
|
|
1260
|
-
}
|
|
1261
|
-
const [startDate, endDate] = currentWeekBounds();
|
|
1262
|
-
return { when, startDate, endDate, outputWhen: when };
|
|
1263
|
-
}
|
|
1264
|
-
async function outputSchedule(client, opts) {
|
|
1265
|
-
const selection = resolveScheduleDateSelection(opts);
|
|
1266
|
-
const { when, startDate, endDate } = selection;
|
|
1267
|
-
const schedule = await client.schedule.list(selection.queryDate ? { date: selection.queryDate } : undefined);
|
|
1268
|
-
const lessons = schedule.filter((l) => l.date >= startDate && l.date <= endDate);
|
|
1269
|
-
if (opts.json) {
|
|
1270
|
-
const result = !opts.date && !opts.weekday && when === "week"
|
|
1271
|
-
? { when, weekStart: startDate, weekEnd: endDate, lessons }
|
|
1272
|
-
: { when: selection.outputWhen, date: startDate, lessons };
|
|
1273
|
-
console.log(JSON.stringify(result, null, 2));
|
|
1274
|
-
return;
|
|
1275
|
-
}
|
|
1276
|
-
const prefix = opts.label ? `[${opts.label}] ` : "";
|
|
1277
|
-
if (opts.date || opts.weekday) {
|
|
1278
|
-
console.log(`\n${prefix}Schedule for ${startDate}`);
|
|
1279
|
-
}
|
|
1280
|
-
else if (when === "today") {
|
|
1281
|
-
console.log(`\n${prefix}Schedule for today (${startDate})`);
|
|
1282
|
-
}
|
|
1283
|
-
else if (when === "tomorrow") {
|
|
1284
|
-
console.log(`\n${prefix}Schedule for tomorrow (${startDate})`);
|
|
1285
|
-
}
|
|
1286
|
-
else {
|
|
1287
|
-
console.log(`\n${prefix}Schedule for ${startDate} – ${endDate}`);
|
|
1288
|
-
}
|
|
1289
|
-
if (!lessons.length) {
|
|
1290
|
-
console.log(" No lessons found.");
|
|
1291
|
-
return;
|
|
1292
|
-
}
|
|
1293
|
-
let currentDate = "";
|
|
1294
|
-
for (const l of lessons) {
|
|
1295
|
-
if (l.date !== currentDate) {
|
|
1296
|
-
currentDate = l.date;
|
|
1297
|
-
const d = new Date(l.date + "T12:00:00");
|
|
1298
|
-
console.log(` ${DAY_NAMES[d.getDay()]} ${l.date}`);
|
|
1299
|
-
}
|
|
1300
|
-
const teacher = l.teacherCode ? ` - ${l.teacher}` : "";
|
|
1301
|
-
console.log(` ${l.start}-${l.end} ${l.subject}${teacher}`);
|
|
1302
|
-
}
|
|
1303
|
-
}
|
|
1304
|
-
async function outputHomework(client, opts) {
|
|
1305
|
-
const overview = await client.overview.get();
|
|
1306
|
-
const slice = overview.homework.slice(0, opts.limit);
|
|
1307
|
-
if (opts.json) {
|
|
1308
|
-
console.log(JSON.stringify(slice, null, 2));
|
|
1309
|
-
return;
|
|
1310
|
-
}
|
|
1311
|
-
const prefix = opts.label ? `[${opts.label}] ` : "";
|
|
1312
|
-
console.log(`\n${prefix}Homework (${overview.homework.length})`);
|
|
1313
|
-
slice.forEach((hw) => {
|
|
1314
|
-
const text = compactText(hw.homework);
|
|
1315
|
-
console.log(`- ${hw.date} ${hw.subject}: ${text}`);
|
|
1316
|
-
});
|
|
1317
|
-
}
|
|
1318
|
-
async function outputUpcomingExams(client, opts) {
|
|
1319
|
-
const overview = await client.overview.get();
|
|
1320
|
-
const slice = overview.upcomingExams.slice(0, opts.limit);
|
|
1321
|
-
if (opts.json) {
|
|
1322
|
-
console.log(JSON.stringify(slice, null, 2));
|
|
1323
|
-
return;
|
|
1324
|
-
}
|
|
1325
|
-
const prefix = opts.label ? `[${opts.label}] ` : "";
|
|
1326
|
-
console.log(`\n${prefix}Upcoming exams (${overview.upcomingExams.length})`);
|
|
1327
|
-
slice.forEach((exam) => {
|
|
1328
|
-
const topic = exam.topic ? ` — ${compactText(exam.topic)}` : "";
|
|
1329
|
-
console.log(`- ${exam.date} ${exam.subject}: ${exam.name}${topic}`);
|
|
1330
|
-
});
|
|
1331
|
-
}
|
|
1332
|
-
async function outputAttendance(client, opts) {
|
|
1333
|
-
const notes = await client.attendance.list({ date: opts.date });
|
|
1334
|
-
if (opts.json) {
|
|
1335
|
-
console.log(JSON.stringify(notes, null, 2));
|
|
1336
|
-
return;
|
|
1337
|
-
}
|
|
1338
|
-
const date = opts.date ?? new Date().toISOString().slice(0, 10);
|
|
1339
|
-
const prefix = opts.label ? `[${opts.label}] ` : "";
|
|
1340
|
-
console.log(`\n${prefix}Lesson notes for ${date} (${notes.length})`);
|
|
1341
|
-
if (!notes.length) {
|
|
1342
|
-
console.log(" No lesson notes found.");
|
|
1343
|
-
return;
|
|
1344
|
-
}
|
|
1345
|
-
notes.forEach((note) => {
|
|
1346
|
-
const time = note.start && note.end ? ` (${note.start}-${note.end})` : "";
|
|
1347
|
-
const type = note.typeLabel ? ` [${note.typeLabel}]` : "";
|
|
1348
|
-
const teacher = note.teacher ? ` ${note.teacher}` : "";
|
|
1349
|
-
const subject = note.subject ? ` [${note.subject}]` : "";
|
|
1350
|
-
console.log(`-${time}${type}${teacher}${subject}`);
|
|
1351
|
-
});
|
|
1352
|
-
}
|
|
1353
|
-
async function outputGrades(client, opts) {
|
|
1354
|
-
const overview = await client.overview.get();
|
|
1355
|
-
const slice = overview.grades.slice(0, opts.limit);
|
|
1356
|
-
if (opts.json) {
|
|
1357
|
-
console.log(JSON.stringify(slice, null, 2));
|
|
1358
|
-
return;
|
|
1359
|
-
}
|
|
1360
|
-
const prefix = opts.label ? `[${opts.label}] ` : "";
|
|
1361
|
-
console.log(`\n${prefix}Grades (${overview.grades.length})`);
|
|
1362
|
-
slice.forEach((g) => {
|
|
1363
|
-
console.log(`- ${g.date} ${g.subject}: ${g.name} — ${g.grade}`);
|
|
1364
|
-
});
|
|
1365
|
-
}
|
|
1366
|
-
async function outputSummary(client, opts) {
|
|
1367
|
-
const [overview, news, messages] = await Promise.all([
|
|
1368
|
-
client.overview.get(),
|
|
1369
|
-
client.news.list(),
|
|
1370
|
-
client.messages.list("inbox"),
|
|
1371
|
-
]);
|
|
1372
|
-
const summary = buildSummaryData(overview, news, messages, opts.days, opts.label);
|
|
1373
|
-
if (opts.json) {
|
|
1374
|
-
console.log(JSON.stringify(summary, null, 2));
|
|
1375
|
-
return;
|
|
1376
|
-
}
|
|
1377
|
-
const label = summary.student ? ` for ${summary.student}` : "";
|
|
1378
|
-
console.log(`\nSummary${label} (${summary.today})`);
|
|
1379
|
-
console.log(`\nTODAY (${summary.today})`);
|
|
1380
|
-
if (summary.todaySchedule.length) {
|
|
1381
|
-
summary.todaySchedule.forEach((l) => {
|
|
1382
|
-
console.log(` ${l.start}-${l.end} ${l.subject} (${l.subjectCode})`);
|
|
1383
|
-
});
|
|
1384
|
-
}
|
|
1385
|
-
else {
|
|
1386
|
-
console.log(" No lessons today.");
|
|
1387
|
-
}
|
|
1388
|
-
console.log(`\nTOMORROW (${summary.tomorrow})`);
|
|
1389
|
-
if (summary.tomorrowSchedule.length) {
|
|
1390
|
-
summary.tomorrowSchedule.forEach((l) => {
|
|
1391
|
-
console.log(` ${l.start}-${l.end} ${l.subject} (${l.subjectCode})`);
|
|
1392
|
-
});
|
|
1393
|
-
}
|
|
1394
|
-
else {
|
|
1395
|
-
console.log(" No lessons tomorrow.");
|
|
1396
|
-
}
|
|
1397
|
-
if (summary.upcomingExams.length) {
|
|
1398
|
-
console.log("\nUPCOMING EXAMS");
|
|
1399
|
-
summary.upcomingExams.forEach((exam) => {
|
|
1400
|
-
const topic = exam.topic ? ` — ${compactText(exam.topic)}` : "";
|
|
1401
|
-
console.log(` ${exam.date} ${exam.subject}: ${exam.name}${topic}`);
|
|
1402
|
-
});
|
|
1403
|
-
}
|
|
1404
|
-
if (summary.recentHomework.length) {
|
|
1405
|
-
console.log("\nRECENT HOMEWORK");
|
|
1406
|
-
summary.recentHomework.forEach((hw) => {
|
|
1407
|
-
console.log(` ${hw.date} ${hw.subject}: ${compactText(hw.homework)}`);
|
|
1408
|
-
});
|
|
1409
|
-
}
|
|
1410
|
-
if (summary.recentNews.length) {
|
|
1411
|
-
console.log(`\nNEWS (last ${opts.days} days)`);
|
|
1412
|
-
summary.recentNews.forEach((n) => {
|
|
1413
|
-
const date = n.published ? n.published.slice(0, 10) : "";
|
|
1414
|
-
console.log(` ${date} ${compactText(n.title)} (id:${n.wilmaId})`);
|
|
1415
|
-
});
|
|
1416
|
-
}
|
|
1417
|
-
if (summary.recentMessages.length) {
|
|
1418
|
-
console.log(`\nMESSAGES (last ${opts.days} days)`);
|
|
1419
|
-
summary.recentMessages.forEach((m) => {
|
|
1420
|
-
const date = m.sentAt.slice(0, 10);
|
|
1421
|
-
console.log(` ${date} ${compactText(m.subject)} (id:${m.wilmaId})`);
|
|
1422
|
-
});
|
|
1423
|
-
}
|
|
1424
|
-
}
|
|
1425
|
-
function buildSummaryData(overview, news, messages, days, studentLabel) {
|
|
1426
|
-
const today = todayString();
|
|
1427
|
-
const tomorrow = nextSchoolDay();
|
|
1428
|
-
const cutoffDate = (() => {
|
|
1429
|
-
const d = new Date();
|
|
1430
|
-
d.setDate(d.getDate() - days);
|
|
1431
|
-
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
1432
|
-
})();
|
|
1433
|
-
const homeworkCutoff = (() => {
|
|
1434
|
-
const d = new Date();
|
|
1435
|
-
d.setDate(d.getDate() - 3);
|
|
1436
|
-
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
1437
|
-
})();
|
|
1438
|
-
const todaySchedule = overview.schedule.filter((l) => l.date === today);
|
|
1439
|
-
const tomorrowSchedule = overview.schedule.filter((l) => l.date === tomorrow);
|
|
1440
|
-
const upcomingExams = overview.upcomingExams;
|
|
1441
|
-
const recentHomework = overview.homework.filter((h) => h.date >= homeworkCutoff);
|
|
1442
|
-
const recentNews = news
|
|
1443
|
-
.filter((n) => n.published && n.published.toISOString().slice(0, 10) >= cutoffDate)
|
|
1444
|
-
.slice(0, 5)
|
|
1445
|
-
.map((n) => ({
|
|
1446
|
-
wilmaId: n.wilmaId,
|
|
1447
|
-
title: n.title,
|
|
1448
|
-
published: n.published?.toISOString() ?? null,
|
|
1449
|
-
}));
|
|
1450
|
-
const recentMessages = messages
|
|
1451
|
-
.filter((m) => m.sentAt.toISOString().slice(0, 10) >= cutoffDate)
|
|
1452
|
-
.slice(0, 5)
|
|
1453
|
-
.map((m) => ({
|
|
1454
|
-
wilmaId: m.wilmaId,
|
|
1455
|
-
subject: m.subject,
|
|
1456
|
-
sentAt: m.sentAt.toISOString(),
|
|
1457
|
-
senderName: m.senderName ?? null,
|
|
1458
|
-
}));
|
|
1459
|
-
return {
|
|
1460
|
-
generatedAt: new Date().toISOString(),
|
|
1461
|
-
student: studentLabel ?? null,
|
|
1462
|
-
today,
|
|
1463
|
-
tomorrow,
|
|
1464
|
-
todaySchedule,
|
|
1465
|
-
tomorrowSchedule,
|
|
1466
|
-
upcomingExams,
|
|
1467
|
-
recentHomework,
|
|
1468
|
-
recentNews,
|
|
1469
|
-
recentMessages,
|
|
1470
|
-
};
|
|
1471
|
-
}
|
|
1472
|
-
async function outputMessages(client, opts) {
|
|
1473
|
-
const messages = await client.messages.list(opts.folder);
|
|
1474
|
-
const slice = messages.slice(0, opts.limit);
|
|
1475
|
-
if (opts.json) {
|
|
1476
|
-
console.log(JSON.stringify(slice, null, 2));
|
|
1477
|
-
return;
|
|
1478
|
-
}
|
|
1479
|
-
console.log(`\nMessages (${messages.length})`);
|
|
1480
|
-
slice.forEach((msg) => {
|
|
1481
|
-
const date = msg.sentAt.toISOString().slice(0, 10);
|
|
1482
|
-
const prefix = opts.label ? `[${opts.label}] ` : "";
|
|
1483
|
-
console.log(`- ${prefix}${date} ${compactText(msg.subject)} (id:${msg.wilmaId})`);
|
|
1484
|
-
});
|
|
1485
|
-
}
|
|
1486
|
-
async function outputMessageItem(client, id, json) {
|
|
1487
|
-
const msg = await client.messages.get(id);
|
|
1488
|
-
if (json) {
|
|
1489
|
-
console.log(JSON.stringify(msg, null, 2));
|
|
1490
|
-
return;
|
|
1491
|
-
}
|
|
1492
|
-
console.log(`\n${msg.subject}`);
|
|
1493
|
-
if (msg.senderName)
|
|
1494
|
-
console.log(`From: ${msg.senderName}`);
|
|
1495
|
-
console.log(`Sent: ${msg.sentAt.toISOString()}`);
|
|
1496
|
-
if (msg.content)
|
|
1497
|
-
console.log(`\n${formatContent(msg.content)}`);
|
|
1498
|
-
}
|
|
1499
|
-
async function selectNewsToRead(client) {
|
|
1500
|
-
const news = await client.news.list();
|
|
1501
|
-
if (!news.length)
|
|
1502
|
-
return;
|
|
1503
|
-
const choices = news.slice(0, 30).map((item) => {
|
|
1504
|
-
const date = item.published ? item.published.toISOString().slice(0, 10) : "";
|
|
1505
|
-
return {
|
|
1506
|
-
value: String(item.wilmaId),
|
|
1507
|
-
name: `${date} ${compactText(item.title)}`.trim(),
|
|
1508
|
-
};
|
|
1509
|
-
});
|
|
1510
|
-
choices.unshift({ value: "back", name: "Back" });
|
|
1511
|
-
const selected = await selectOrCancel({
|
|
1512
|
-
message: "Read which news item?",
|
|
1513
|
-
choices,
|
|
1514
|
-
});
|
|
1515
|
-
if (!selected || selected === "back")
|
|
1516
|
-
return;
|
|
1517
|
-
const item = await outputNewsItem(client, Number(selected), false, { suppressDownloadHint: true });
|
|
1518
|
-
if (item?.resources?.length) {
|
|
1519
|
-
await offerResourceDownloads(client, item);
|
|
1520
|
-
}
|
|
1521
|
-
}
|
|
1522
|
-
async function offerResourceDownloads(client, item) {
|
|
1523
|
-
while (true) {
|
|
1524
|
-
const resources = item.resources ?? [];
|
|
1525
|
-
const choices = [
|
|
1526
|
-
{ value: "back", name: "Continue" },
|
|
1527
|
-
...resources.map((resource) => ({
|
|
1528
|
-
value: resource.id,
|
|
1529
|
-
name: `Download "${compactText(resource.label)}"`,
|
|
1530
|
-
})),
|
|
1531
|
-
];
|
|
1532
|
-
const selected = await selectOrCancel({ message: "Resources", choices }, false);
|
|
1533
|
-
if (!selected || selected === "back")
|
|
1534
|
-
return;
|
|
1535
|
-
const outputDirectory = await inputOrCancel({
|
|
1536
|
-
message: "Save to directory",
|
|
1537
|
-
default: resolve(homedir(), "Downloads"),
|
|
1538
|
-
});
|
|
1539
|
-
if (outputDirectory === null)
|
|
1540
|
-
continue;
|
|
1541
|
-
try {
|
|
1542
|
-
await outputNewsResourceDownload(client, item.wilmaId, selected, {
|
|
1543
|
-
output: outputDirectory,
|
|
1544
|
-
json: false,
|
|
1545
|
-
});
|
|
1546
|
-
}
|
|
1547
|
-
catch (error) {
|
|
1548
|
-
console.error("Download failed:", error instanceof Error ? error.message : error);
|
|
1549
|
-
}
|
|
1550
|
-
}
|
|
1551
|
-
}
|
|
1552
|
-
async function selectMessageToRead(client, folder) {
|
|
1553
|
-
const messages = await client.messages.list(folder);
|
|
1554
|
-
if (!messages.length) {
|
|
1555
|
-
console.log(`\nNo messages found in ${folder}.`);
|
|
1556
|
-
return;
|
|
843
|
+
finally {
|
|
844
|
+
clearTimeout(timer);
|
|
1557
845
|
}
|
|
1558
|
-
const choices = messages.slice(0, 30).map((msg) => {
|
|
1559
|
-
const date = msg.sentAt.toISOString().slice(0, 10);
|
|
1560
|
-
return {
|
|
1561
|
-
value: String(msg.wilmaId),
|
|
1562
|
-
name: `${date} ${compactText(msg.subject)}`.trim(),
|
|
1563
|
-
};
|
|
1564
|
-
});
|
|
1565
|
-
choices.unshift({ value: "back", name: "Back" });
|
|
1566
|
-
const selected = await selectOrCancel({
|
|
1567
|
-
message: "Read which message?",
|
|
1568
|
-
choices,
|
|
1569
|
-
});
|
|
1570
|
-
if (!selected || selected === "back")
|
|
1571
|
-
return;
|
|
1572
|
-
await outputMessageItem(client, Number(selected), false);
|
|
1573
846
|
}
|
|
1574
847
|
async function selectOrCancel(opts, clearScreen = true) {
|
|
1575
848
|
if (clearScreen) {
|
|
@@ -1596,110 +869,76 @@ async function selectOrCancel(opts, clearScreen = true) {
|
|
|
1596
869
|
process.stdin.removeListener("keypress", onKeypress);
|
|
1597
870
|
}
|
|
1598
871
|
}
|
|
1599
|
-
function
|
|
872
|
+
function createInteractiveMfa(saveProfile) {
|
|
1600
873
|
let lastCode = null;
|
|
1601
874
|
let lastCodeTime = 0;
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
const
|
|
875
|
+
let lastCodeFor;
|
|
876
|
+
let lastFormkey = null;
|
|
877
|
+
// One code generator per key: switching logins must not reuse another login's key.
|
|
878
|
+
const generators = new Map();
|
|
879
|
+
const state = {
|
|
880
|
+
profile: undefined,
|
|
881
|
+
typedSecret: null,
|
|
882
|
+
callback: async (formkey) => {
|
|
883
|
+
// Asked again for the same challenge means Wilma rejected the code.
|
|
884
|
+
const retry = formkey === lastFormkey;
|
|
885
|
+
lastFormkey = formkey;
|
|
886
|
+
// A saved or just-typed key generates codes (a retry waits for the next one).
|
|
887
|
+
const secret = state.profile?.totpSecretObfuscated
|
|
888
|
+
? revealSecret(state.profile.totpSecretObfuscated)
|
|
889
|
+
: state.typedSecret;
|
|
1612
890
|
if (secret) {
|
|
1613
|
-
|
|
891
|
+
if (!generators.has(secret))
|
|
892
|
+
generators.set(secret, mfaCallbackFor(secret));
|
|
893
|
+
return generators.get(secret)(formkey);
|
|
894
|
+
}
|
|
895
|
+
// TOTP codes are valid for 30s: a second login in the same window reuses the typed code.
|
|
896
|
+
const now = Math.floor(Date.now() / 30000);
|
|
897
|
+
if (!retry && lastCode && now === lastCodeTime && lastCodeFor === state.profile) {
|
|
898
|
+
return lastCode;
|
|
899
|
+
}
|
|
900
|
+
if (retry) {
|
|
901
|
+
console.log("That code wasn't accepted. Wait for the next code in your authenticator app.");
|
|
902
|
+
}
|
|
903
|
+
const choice = await select({
|
|
904
|
+
message: "MFA required. Choose how to authenticate:",
|
|
905
|
+
choices: [
|
|
906
|
+
{ value: "code", name: "Enter one-time code from authenticator app" },
|
|
907
|
+
{ value: "secret", name: "Save TOTP secret for automatic login" },
|
|
908
|
+
],
|
|
909
|
+
});
|
|
910
|
+
if (choice === "secret") {
|
|
911
|
+
const secretInput = (await input({
|
|
912
|
+
message: "Paste TOTP secret (base32 key or otpauth:// URI)",
|
|
913
|
+
})).trim();
|
|
914
|
+
if (!secretInput)
|
|
915
|
+
throw new Error("MFA cancelled");
|
|
916
|
+
const parsed = parseTotpSecret(secretInput);
|
|
917
|
+
if (state.profile) {
|
|
918
|
+
state.profile.totpSecretObfuscated = obfuscateSecret(secretInput);
|
|
919
|
+
await saveProfile();
|
|
920
|
+
}
|
|
921
|
+
else {
|
|
922
|
+
// A new login: saved together with the login once it succeeds.
|
|
923
|
+
state.typedSecret = secretInput;
|
|
924
|
+
}
|
|
925
|
+
generators.set(secretInput, mfaCallbackFor(secretInput));
|
|
926
|
+
const code = generateTOTP(parsed);
|
|
1614
927
|
lastCode = code;
|
|
1615
928
|
lastCodeTime = now;
|
|
929
|
+
lastCodeFor = state.profile;
|
|
1616
930
|
return code;
|
|
1617
931
|
}
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
message: "MFA required. Choose how to authenticate:",
|
|
1621
|
-
choices: [
|
|
1622
|
-
{ value: "code", name: "Enter one-time code from authenticator app" },
|
|
1623
|
-
{ value: "secret", name: "Save TOTP secret for automatic login" },
|
|
1624
|
-
],
|
|
1625
|
-
});
|
|
1626
|
-
if (choice === "secret") {
|
|
1627
|
-
const secretInput = await input({
|
|
1628
|
-
message: "Paste TOTP secret (base32 key or otpauth:// URI)",
|
|
1629
|
-
});
|
|
1630
|
-
if (!secretInput)
|
|
932
|
+
const code = await input({ message: "Enter MFA code from authenticator app" });
|
|
933
|
+
if (!code)
|
|
1631
934
|
throw new Error("MFA cancelled");
|
|
1632
|
-
|
|
1633
|
-
// Save to config
|
|
1634
|
-
if (stored && saveProfile) {
|
|
1635
|
-
stored.totpSecretObfuscated = obfuscateSecret(secretInput.trim());
|
|
1636
|
-
await saveProfile();
|
|
1637
|
-
}
|
|
1638
|
-
const code = generateTOTP(secret);
|
|
1639
|
-
lastCode = code;
|
|
935
|
+
lastCode = code.trim();
|
|
1640
936
|
lastCodeTime = now;
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
if (!code)
|
|
1645
|
-
throw new Error("MFA cancelled");
|
|
1646
|
-
lastCode = code.trim();
|
|
1647
|
-
lastCodeTime = now;
|
|
1648
|
-
return lastCode;
|
|
1649
|
-
};
|
|
1650
|
-
}
|
|
1651
|
-
function createNonInteractiveMfaCallback(totpSecret) {
|
|
1652
|
-
if (!totpSecret)
|
|
1653
|
-
return undefined;
|
|
1654
|
-
const secret = parseTotpSecret(totpSecret);
|
|
1655
|
-
return async (_formkey) => {
|
|
1656
|
-
const code = generateTOTP(secret);
|
|
1657
|
-
return code;
|
|
937
|
+
lastCodeFor = state.profile;
|
|
938
|
+
return lastCode;
|
|
939
|
+
},
|
|
1658
940
|
};
|
|
1659
|
-
|
|
1660
|
-
function parseTotpSecret(raw) {
|
|
1661
|
-
// Accept either a bare base32 key or an otpauth:// URI
|
|
1662
|
-
if (raw.startsWith("otpauth://")) {
|
|
1663
|
-
const url = new URL(raw);
|
|
1664
|
-
const secret = url.searchParams.get("secret");
|
|
1665
|
-
if (!secret) {
|
|
1666
|
-
console.error("No 'secret' parameter found in otpauth:// URI.");
|
|
1667
|
-
process.exit(1);
|
|
1668
|
-
}
|
|
1669
|
-
return secret;
|
|
1670
|
-
}
|
|
1671
|
-
return raw.replace(/[\s-]/g, "");
|
|
1672
|
-
}
|
|
1673
|
-
function generateTOTP(secret) {
|
|
1674
|
-
// TOTP: RFC 6238 — HMAC-SHA1 based, 6-digit, 30-second period
|
|
1675
|
-
const base32Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
1676
|
-
// Decode base32 secret
|
|
1677
|
-
const cleanSecret = secret.replace(/[\s=-]+/g, "").toUpperCase();
|
|
1678
|
-
let bits = "";
|
|
1679
|
-
for (const c of cleanSecret) {
|
|
1680
|
-
const val = base32Chars.indexOf(c);
|
|
1681
|
-
if (val === -1)
|
|
1682
|
-
throw new Error(`Invalid base32 character: ${c}`);
|
|
1683
|
-
bits += val.toString(2).padStart(5, "0");
|
|
1684
|
-
}
|
|
1685
|
-
const bytes = new Uint8Array(Math.floor(bits.length / 8));
|
|
1686
|
-
for (let i = 0; i < bytes.length; i++) {
|
|
1687
|
-
bytes[i] = parseInt(bits.slice(i * 8, i * 8 + 8), 2);
|
|
1688
|
-
}
|
|
1689
|
-
const epoch = Math.floor(Date.now() / 1000);
|
|
1690
|
-
const counter = Math.floor(epoch / 30);
|
|
1691
|
-
const counterBuf = Buffer.alloc(8);
|
|
1692
|
-
counterBuf.writeUInt32BE(Math.floor(counter / 0x100000000), 0);
|
|
1693
|
-
counterBuf.writeUInt32BE(counter >>> 0, 4);
|
|
1694
|
-
const hmac = createHmac("sha1", Buffer.from(bytes));
|
|
1695
|
-
hmac.update(counterBuf);
|
|
1696
|
-
const hash = hmac.digest();
|
|
1697
|
-
const offset = hash[hash.length - 1] & 0x0f;
|
|
1698
|
-
const code = ((hash[offset] & 0x7f) << 24) |
|
|
1699
|
-
((hash[offset + 1] & 0xff) << 16) |
|
|
1700
|
-
((hash[offset + 2] & 0xff) << 8) |
|
|
1701
|
-
(hash[offset + 3] & 0xff);
|
|
1702
|
-
return (code % 1000000).toString().padStart(6, "0");
|
|
941
|
+
return state;
|
|
1703
942
|
}
|
|
1704
943
|
async function inputOrCancel(opts) {
|
|
1705
944
|
console.clear();
|
|
@@ -1759,270 +998,79 @@ function isPromptCancel(err) {
|
|
|
1759
998
|
message.toLowerCase().includes("cancel") ||
|
|
1760
999
|
message.toLowerCase().includes("aborted"));
|
|
1761
1000
|
}
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
.
|
|
1770
|
-
.
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
}
|
|
1787
|
-
async function resolveStudentForFlags(profile, config, student) {
|
|
1788
|
-
if (student) {
|
|
1789
|
-
const students = await getStudentsForCommand(profile, config);
|
|
1790
|
-
const exact = students.find((s) => s.studentNumber === student);
|
|
1791
|
-
if (exact)
|
|
1792
|
-
return exact;
|
|
1793
|
-
const needle = student.toLowerCase();
|
|
1794
|
-
const substring = students.find((s) => s.name.toLowerCase().includes(needle));
|
|
1795
|
-
if (substring)
|
|
1796
|
-
return substring;
|
|
1797
|
-
const match = students.find((s) => fuzzyIncludes(s.name, student));
|
|
1798
|
-
if (match)
|
|
1799
|
-
return match;
|
|
1800
|
-
// No match found - show available students and exit
|
|
1801
|
-
console.error(`Error: No student matching "${student}" found.`);
|
|
1802
|
-
if (students.length > 0) {
|
|
1803
|
-
console.error("Available students:");
|
|
1804
|
-
students.forEach((s) => console.error(` ${s.studentNumber} ${s.name}`));
|
|
1805
|
-
}
|
|
1806
|
-
process.exit(1);
|
|
1001
|
+
/**
|
|
1002
|
+
* Remove terminal escape sequences and control characters (keeping newlines
|
|
1003
|
+
* and tabs), so text from Wilma or a bulletin link can't retitle the window,
|
|
1004
|
+
* clear the screen, write the clipboard or overwrite a line.
|
|
1005
|
+
*/
|
|
1006
|
+
function terminalSafe(value) {
|
|
1007
|
+
return value
|
|
1008
|
+
.replace(/\u001b(\[[0-?]*[ -/]*[@-~]|\][^\u0007\u001b]*(\u0007|\u001b\\)?|[@-Z\\-_])/g, "")
|
|
1009
|
+
.replace(/\r\n?/g, "\n")
|
|
1010
|
+
.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, "");
|
|
1011
|
+
}
|
|
1012
|
+
/**
|
|
1013
|
+
* JSON output keeps every character: JSON.stringify already escapes control
|
|
1014
|
+
* characters except DEL and the C1 range, which some terminals act on, so
|
|
1015
|
+
* escape those too (inside JSON they can only occur in strings).
|
|
1016
|
+
*/
|
|
1017
|
+
function jsonSafe(value) {
|
|
1018
|
+
return value.replace(/[\u007f-\u009f]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
1019
|
+
}
|
|
1020
|
+
/** An error as agents and scripts get it: a stable code, a message, and the exit code. */
|
|
1021
|
+
function describeError(err) {
|
|
1022
|
+
if (err instanceof WilmaAiError) {
|
|
1023
|
+
const exitCode = err.code === "invalid_argument" || err.code === "unknown_command" ? 2 : err.code === "not_logged_in" ? 3 : 1;
|
|
1024
|
+
return { code: err.code, message: err.message, details: err.details, exitCode };
|
|
1807
1025
|
}
|
|
1808
|
-
|
|
1809
|
-
if (stored?.lastStudentNumber) {
|
|
1026
|
+
if (err instanceof MfaRequiredError) {
|
|
1810
1027
|
return {
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1028
|
+
code: "mfa_required",
|
|
1029
|
+
message: "Two-step verification is on for this Wilma account.",
|
|
1030
|
+
hint: `Run \`wilma login\` and give the authenticator setup key on the login page, or set ${ENV_VARS.totpSecret} to the key (base32 or otpauth:// URI).`,
|
|
1031
|
+
exitCode: 1,
|
|
1814
1032
|
};
|
|
1815
1033
|
}
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
const client = await WilmaClient.login({ ...profile, studentNumber: student.studentNumber }, onMfa);
|
|
1824
|
-
const news = await client.news.list();
|
|
1825
|
-
results.push({ student, items: news.slice(0, limit) });
|
|
1826
|
-
}
|
|
1827
|
-
if (json) {
|
|
1828
|
-
console.log(JSON.stringify({ students: results }, null, 2));
|
|
1829
|
-
return;
|
|
1830
|
-
}
|
|
1831
|
-
results.forEach((entry) => {
|
|
1832
|
-
console.log(`\n[${entry.student.name}]`);
|
|
1833
|
-
entry.items.forEach((item) => {
|
|
1834
|
-
const date = item.published ? item.published.toISOString().slice(0, 10) : "";
|
|
1835
|
-
console.log(`- ${date} ${item.title} (id:${item.wilmaId})`.trim());
|
|
1836
|
-
});
|
|
1837
|
-
});
|
|
1838
|
-
}
|
|
1839
|
-
async function outputAllMessages(profile, config, opts, onMfa) {
|
|
1840
|
-
const students = await getStudentsForCommand(profile, config);
|
|
1841
|
-
const results = [];
|
|
1842
|
-
for (const student of students) {
|
|
1843
|
-
const client = await WilmaClient.login({ ...profile, studentNumber: student.studentNumber }, onMfa);
|
|
1844
|
-
const messages = await client.messages.list(opts.folder);
|
|
1845
|
-
results.push({ student, items: messages.slice(0, opts.limit) });
|
|
1846
|
-
}
|
|
1847
|
-
if (opts.json) {
|
|
1848
|
-
console.log(JSON.stringify({ students: results }, null, 2));
|
|
1849
|
-
return;
|
|
1850
|
-
}
|
|
1851
|
-
results.forEach((entry) => {
|
|
1852
|
-
console.log(`\n[${entry.student.name}]`);
|
|
1853
|
-
entry.items.forEach((msg) => {
|
|
1854
|
-
const date = msg.sentAt.toISOString().slice(0, 10);
|
|
1855
|
-
console.log(`- ${date} ${msg.subject} (id:${msg.wilmaId})`);
|
|
1856
|
-
});
|
|
1857
|
-
});
|
|
1858
|
-
}
|
|
1859
|
-
async function outputAllExams(profile, config, limit, json, onMfa) {
|
|
1860
|
-
const students = await getStudentsForCommand(profile, config);
|
|
1861
|
-
const results = [];
|
|
1862
|
-
for (const student of students) {
|
|
1863
|
-
const client = await WilmaClient.login({ ...profile, studentNumber: student.studentNumber }, onMfa);
|
|
1864
|
-
const overview = await client.overview.get();
|
|
1865
|
-
results.push({ student, items: overview.upcomingExams.slice(0, limit) });
|
|
1866
|
-
}
|
|
1867
|
-
if (json) {
|
|
1868
|
-
console.log(JSON.stringify({ students: results }, null, 2));
|
|
1869
|
-
return;
|
|
1870
|
-
}
|
|
1871
|
-
results.forEach((entry) => {
|
|
1872
|
-
console.log(`\n[${entry.student.name}]`);
|
|
1873
|
-
entry.items.forEach((exam) => {
|
|
1874
|
-
const topic = exam.topic ? ` — ${compactText(exam.topic)}` : "";
|
|
1875
|
-
console.log(`- ${exam.date} ${exam.subject}: ${exam.name}${topic}`);
|
|
1876
|
-
});
|
|
1877
|
-
});
|
|
1878
|
-
}
|
|
1879
|
-
async function outputAllAttendance(profile, config, opts, onMfa) {
|
|
1880
|
-
const students = await getStudentsForCommand(profile, config);
|
|
1881
|
-
const results = [];
|
|
1882
|
-
for (const student of students) {
|
|
1883
|
-
const client = await WilmaClient.login({ ...profile, studentNumber: student.studentNumber }, onMfa);
|
|
1884
|
-
const notes = await client.attendance.list({ date: opts.date });
|
|
1885
|
-
results.push({ student, notes });
|
|
1886
|
-
}
|
|
1887
|
-
if (opts.json) {
|
|
1888
|
-
console.log(JSON.stringify({ students: results.map((r) => ({ student: r.student, items: r.notes })) }, null, 2));
|
|
1889
|
-
return;
|
|
1890
|
-
}
|
|
1891
|
-
for (const r of results) {
|
|
1892
|
-
if (!r.notes.length) {
|
|
1893
|
-
console.log(`\n[${r.student.name}] No lesson notes found.`);
|
|
1894
|
-
continue;
|
|
1895
|
-
}
|
|
1896
|
-
console.log(`\n[${r.student.name}]`);
|
|
1897
|
-
r.notes.forEach((note) => {
|
|
1898
|
-
const time = note.start && note.end ? ` (${note.start}-${note.end})` : "";
|
|
1899
|
-
const type = note.typeLabel ? ` [${note.typeLabel}]` : "";
|
|
1900
|
-
const teacher = note.teacher ? ` ${note.teacher}` : "";
|
|
1901
|
-
const subject = note.subject ? ` [${note.subject}]` : "";
|
|
1902
|
-
console.log(`-${time}${type}${teacher}${subject}`);
|
|
1903
|
-
});
|
|
1904
|
-
}
|
|
1905
|
-
}
|
|
1906
|
-
async function outputAllOverviewCommand(profile, config, command, flags, onMfa) {
|
|
1907
|
-
const students = await getStudentsForCommand(profile, config);
|
|
1908
|
-
if (command === "summary") {
|
|
1909
|
-
if (flags.json) {
|
|
1910
|
-
const summaries = [];
|
|
1911
|
-
for (const student of students) {
|
|
1912
|
-
const client = await WilmaClient.login({ ...profile, studentNumber: student.studentNumber }, onMfa);
|
|
1913
|
-
const [overview, news, messages] = await Promise.all([
|
|
1914
|
-
client.overview.get(),
|
|
1915
|
-
client.news.list(),
|
|
1916
|
-
client.messages.list("inbox"),
|
|
1917
|
-
]);
|
|
1918
|
-
summaries.push({
|
|
1919
|
-
student,
|
|
1920
|
-
summary: buildSummaryData(overview, news, messages, flags.days ?? 7, student.name),
|
|
1921
|
-
});
|
|
1922
|
-
}
|
|
1923
|
-
console.log(JSON.stringify({
|
|
1924
|
-
generatedAt: new Date().toISOString(),
|
|
1925
|
-
students: summaries,
|
|
1926
|
-
}, null, 2));
|
|
1927
|
-
return;
|
|
1928
|
-
}
|
|
1929
|
-
// Human-readable summary output per student
|
|
1930
|
-
for (const student of students) {
|
|
1931
|
-
const client = await WilmaClient.login({ ...profile, studentNumber: student.studentNumber }, onMfa);
|
|
1932
|
-
await outputSummary(client, {
|
|
1933
|
-
days: flags.days ?? 7,
|
|
1934
|
-
json: false,
|
|
1935
|
-
label: student.name,
|
|
1936
|
-
});
|
|
1937
|
-
}
|
|
1938
|
-
return;
|
|
1034
|
+
if (isMfaFailure(err)) {
|
|
1035
|
+
return {
|
|
1036
|
+
code: "mfa_failed",
|
|
1037
|
+
message: "Wilma didn't accept the two-step verification code.",
|
|
1038
|
+
hint: "The saved authenticator setup key may be wrong or changed: run `wilma login` again.",
|
|
1039
|
+
exitCode: 1,
|
|
1040
|
+
};
|
|
1939
1041
|
}
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
const schedule = await client.schedule.list(scheduleSelection.queryDate ? { date: scheduleSelection.queryDate } : undefined);
|
|
1948
|
-
results.push({
|
|
1949
|
-
student,
|
|
1950
|
-
data: schedule.filter((l) => l.date >= scheduleSelection.startDate && l.date <= scheduleSelection.endDate),
|
|
1951
|
-
});
|
|
1952
|
-
}
|
|
1953
|
-
else if (command === "homework") {
|
|
1954
|
-
const overview = await client.overview.get();
|
|
1955
|
-
results.push({ student, data: overview.homework.slice(0, flags.limit ?? 10) });
|
|
1956
|
-
}
|
|
1957
|
-
else if (command === "grades") {
|
|
1958
|
-
const overview = await client.overview.get();
|
|
1959
|
-
results.push({ student, data: overview.grades.slice(0, flags.limit ?? 20) });
|
|
1960
|
-
}
|
|
1042
|
+
if (err instanceof AuthenticationError) {
|
|
1043
|
+
return {
|
|
1044
|
+
code: "login_failed",
|
|
1045
|
+
message: "Wilma didn't accept the saved login (has the password changed?).",
|
|
1046
|
+
hint: "Run `wilma login` again.",
|
|
1047
|
+
exitCode: 1,
|
|
1048
|
+
};
|
|
1961
1049
|
}
|
|
1962
|
-
if (
|
|
1963
|
-
|
|
1964
|
-
return;
|
|
1050
|
+
if (err instanceof NetworkError) {
|
|
1051
|
+
return { code: "network", message: err.message, ...(err.code ? { cause: err.code } : {}), ...(err.hint ? { hint: err.hint } : {}), exitCode: 1 };
|
|
1965
1052
|
}
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
const items = r.data;
|
|
1969
|
-
if (!items.length) {
|
|
1970
|
-
console.log(" (none)");
|
|
1971
|
-
continue;
|
|
1972
|
-
}
|
|
1973
|
-
if (command === "schedule") {
|
|
1974
|
-
for (const l of items) {
|
|
1975
|
-
console.log(` ${l.date} ${l.start}-${l.end} ${l.subject} - ${l.teacher}`);
|
|
1976
|
-
}
|
|
1977
|
-
}
|
|
1978
|
-
else if (command === "homework") {
|
|
1979
|
-
for (const hw of items) {
|
|
1980
|
-
console.log(` ${hw.date} ${hw.subject}: ${compactText(hw.homework)}`);
|
|
1981
|
-
}
|
|
1982
|
-
}
|
|
1983
|
-
else if (command === "grades") {
|
|
1984
|
-
for (const g of items) {
|
|
1985
|
-
console.log(` ${g.date} ${g.subject}: ${g.name} — ${g.grade}`);
|
|
1986
|
-
}
|
|
1987
|
-
}
|
|
1053
|
+
if (err instanceof APIError) {
|
|
1054
|
+
return { code: err.status === 404 ? "not_found" : "wilma_error", message: err.message, exitCode: 1 };
|
|
1988
1055
|
}
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
students.forEach((s) => {
|
|
1994
|
-
console.error(`- ${s.studentNumber} ${s.name}`);
|
|
1995
|
-
});
|
|
1056
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1057
|
+
if (/isn't valid JSON/.test(message))
|
|
1058
|
+
return { code: "config_invalid", message, exitCode: 1 };
|
|
1059
|
+
return { code: "error", message, exitCode: 1 };
|
|
1996
1060
|
}
|
|
1997
1061
|
main().catch((err) => {
|
|
1998
1062
|
if (isPromptCancel(err)) {
|
|
1999
1063
|
process.exit(0);
|
|
2000
1064
|
}
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
console.
|
|
2005
|
-
console.error(" --totp-secret 'otpauth://totp/...'");
|
|
2006
|
-
console.error("Tip: export the key from your authenticator app (base32 string or otpauth:// URI).");
|
|
2007
|
-
console.error("For interactive use, run 'wilma' without arguments.");
|
|
2008
|
-
process.exit(1);
|
|
2009
|
-
}
|
|
2010
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
2011
|
-
const isNetwork = err instanceof NetworkError;
|
|
2012
|
-
if (process.argv.includes("--json")) {
|
|
2013
|
-
console.log(JSON.stringify({
|
|
2014
|
-
status: "error",
|
|
2015
|
-
message,
|
|
2016
|
-
...(isNetwork && err.code ? { code: err.code } : {}),
|
|
2017
|
-
...(isNetwork && err.hint ? { hint: err.hint } : {}),
|
|
2018
|
-
}, null, 2));
|
|
1065
|
+
const { exitCode, details, ...failure } = describeError(err);
|
|
1066
|
+
const mode = outputMode(process.argv.slice(2));
|
|
1067
|
+
if (mode.json) {
|
|
1068
|
+
console.log(toAgentJson({ status: "error", ...failure, ...(details ?? {}) }, mode.pretty));
|
|
2019
1069
|
}
|
|
2020
1070
|
else {
|
|
2021
|
-
console.error(
|
|
2022
|
-
if (
|
|
2023
|
-
console.error(
|
|
2024
|
-
console.error(err.hint);
|
|
2025
|
-
}
|
|
1071
|
+
console.error(`Error: ${failure.message}`);
|
|
1072
|
+
if (failure.hint)
|
|
1073
|
+
console.error(failure.hint);
|
|
2026
1074
|
}
|
|
2027
|
-
process.exit(
|
|
1075
|
+
process.exit(exitCode);
|
|
2028
1076
|
});
|