@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/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 { createHmac } from "node:crypto";
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 { WilmaClient, MfaRequiredError, NetworkError, listTenants, } from "@wilm-ai/wilma-client";
10
- import { clearConfig, getConfigPath, loadConfig, obfuscateSecret, revealSecret, saveConfig, } from "./config.js";
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: "Tomorrow's schedule" },
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 args = process.argv.slice(2);
28
- // Fire version check early (non-blocking)
29
- const updateCheck = checkForUpdate();
30
- if (args.includes("--help") || args.includes("-h")) {
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
- if (args.includes("--version") || args.includes("-v")) {
36
- const version = await readPackageVersion();
37
- console.log(version);
38
- await showUpdateNotice(updateCheck);
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 (args[0] === "update") {
42
- await handleUpdate();
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
- if (args[0] === "config" && args[1] === "clear") {
46
- await clearConfig();
47
- console.log(`Cleared config at ${getConfigPath()}`);
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 config = await loadConfig();
51
- if (args.length) {
52
- await handleCommand(args, config);
53
- await showUpdateNotice(updateCheck);
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
- await runInteractive(config);
57
- await showUpdateNotice(updateCheck);
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, onMfa, onStoredProfileResolved) {
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
- onStoredProfileResolved?.(stored);
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
- const profileBase = {
114
- baseUrl: tenant.url,
115
- username,
116
- password: passwordValue,
117
- };
118
- const students = await WilmaClient.listStudents(profileBase, onMfa);
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
- const finalProfile = {
123
- ...profileBase,
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
- passwordObfuscated: obfuscateSecret(passwordValue),
132
- students: students.map((s) => ({ studentNumber: s.studentNumber, name: s.name })),
133
- lastStudentNumber: student?.studentNumber ?? null,
134
- lastStudentName: student?.name ?? null,
135
- lastUsedAt: new Date().toISOString(),
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
- return finalProfile;
142
- }
143
- async function runInteractive(config) {
144
- const mfaState = { storedProfile: undefined };
145
- const interactiveMfa = createInteractiveMfaCallback(() => mfaState.storedProfile, () => saveConfig(config));
146
- while (true) {
147
- const profile = await chooseProfile(config, interactiveMfa, (sp) => { mfaState.storedProfile = sp; });
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
- let filtered = tenants;
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 WilmaClient.listStudents(baseProfile, onMfa);
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 handleCommand(args, config) {
377
- const { command, subcommand, resourceAction, flags } = parseArgs(args);
378
- if (command === "config" && subcommand === "clear") {
379
- await clearConfig();
380
- console.log(`Cleared config at ${getConfigPath()}`);
381
- return;
382
- }
383
- const profile = await getProfileForCommandNonInteractive(config, flags);
384
- if (!profile)
385
- return;
386
- // Use --totp-secret flag, or fall back to stored TOTP secret from config
387
- let totpSecret = flags.totpSecret;
388
- if (!totpSecret) {
389
- const stored = config.profiles.find((p) => p.id === config.lastProfileId);
390
- if (stored?.totpSecretObfuscated) {
391
- totpSecret = revealSecret(stored.totpSecretObfuscated) ?? undefined;
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 mfaCallback = createNonInteractiveMfaCallback(totpSecret);
395
- const client = await WilmaClient.login(profile, mfaCallback);
396
- if (command === "kids") {
397
- const students = await getStudentsForCommand(profile, config);
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("\nKids");
403
- students.forEach((s) => {
404
- console.log(`- ${s.studentNumber} ${s.name}`);
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
- if (command === "news") {
409
- if (subcommand === "resource") {
410
- if (resourceAction !== "download") {
411
- throw new Error('Expected "wilma news resource download <news-id> <resource-id>"');
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
- if (command === "messages") {
486
- if (subcommand === "read" && flags.id) {
487
- const messageId = parseReadId(flags.id, "message");
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
- const perStudentClient = await WilmaClient.login({
502
- ...profile,
503
- studentNumber: studentInfo?.studentNumber ?? profile.studentNumber,
504
- }, mfaCallback);
505
- await outputMessageItem(perStudentClient, messageId, flags.json);
506
- return;
507
- }
508
- if (flags.allStudents) {
509
- await outputAllMessages(profile, config, {
510
- folder: flags.folder ?? "inbox",
511
- limit: flags.limit ?? 20,
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
- if (command === "attendance") {
534
- if (flags.allStudents) {
535
- await outputAllAttendance(profile, config, { date: flags.date, json: flags.json }, mfaCallback);
536
- return;
537
- }
538
- const studentInfo = await resolveStudentForFlags(profile, config, flags.student);
539
- if (!studentInfo && !profile.studentNumber) {
540
- await printStudentSelectionHelp(profile, config);
541
- return;
542
- }
543
- const perStudentClient = await WilmaClient.login({
544
- ...profile,
545
- studentNumber: studentInfo?.studentNumber ?? profile.studentNumber,
546
- }, mfaCallback);
547
- await outputAttendance(perStudentClient, {
548
- date: flags.date,
549
- json: flags.json,
550
- label: studentInfo?.name ?? undefined,
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
- if (command === "exams") {
555
- if (flags.allStudents) {
556
- await outputAllExams(profile, config, flags.limit ?? 20, flags.json, mfaCallback);
557
- return;
558
- }
559
- const studentInfo = await resolveStudentForFlags(profile, config, flags.student);
560
- if (!studentInfo && !profile.studentNumber) {
561
- await printStudentSelectionHelp(profile, config);
562
- return;
563
- }
564
- const perStudentClient = await WilmaClient.login({
565
- ...profile,
566
- studentNumber: studentInfo?.studentNumber ?? profile.studentNumber,
567
- }, mfaCallback);
568
- await outputUpcomingExams(perStudentClient, {
569
- limit: flags.limit ?? 20,
570
- json: flags.json,
571
- label: studentInfo?.name ?? undefined,
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
- if (command === "schedule") {
576
- if (flags.allStudents) {
577
- await outputAllOverviewCommand(profile, config, "schedule", flags, mfaCallback);
578
- return;
579
- }
580
- const studentInfo = await resolveStudentForFlags(profile, config, flags.student);
581
- if (!studentInfo && !profile.studentNumber) {
582
- await printStudentSelectionHelp(profile, config);
583
- return;
584
- }
585
- const perStudentClient = await WilmaClient.login({
586
- ...profile,
587
- studentNumber: studentInfo?.studentNumber ?? profile.studentNumber,
588
- }, mfaCallback);
589
- await outputSchedule(perStudentClient, {
590
- when: flags.when ?? "week",
591
- date: flags.date,
592
- weekday: flags.weekday,
593
- json: flags.json,
594
- label: studentInfo?.name ?? undefined,
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
- return;
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
- if (command === "homework") {
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
- async function checkForUpdate() {
932
- try {
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
- clearTimeout(timeout);
942
- if (!response.ok)
943
- return cache?.latestVersion ?? null;
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
- clearTimeout(timeout);
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
- catch {
957
- return null;
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(versionCheckPromise) {
823
+ async function showUpdateNotice(check) {
824
+ let timer;
974
825
  try {
975
826
  const latestVersion = await Promise.race([
976
- versionCheckPromise,
977
- new Promise((resolve) => setTimeout(() => resolve(null), 1000)),
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
- async function outputNews(client, opts) {
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 createInteractiveMfaCallback(getStoredProfile, saveProfile) {
872
+ function createInteractiveMfa(saveProfile) {
1600
873
  let lastCode = null;
1601
874
  let lastCodeTime = 0;
1602
- return async (_formkey) => {
1603
- // TOTP codes are valid for 30s; reuse if within the same window
1604
- const now = Math.floor(Date.now() / 30000);
1605
- if (lastCode && now === lastCodeTime) {
1606
- return lastCode;
1607
- }
1608
- // If a TOTP secret is stored, auto-generate
1609
- const stored = getStoredProfile?.();
1610
- if (stored?.totpSecretObfuscated) {
1611
- const secret = revealSecret(stored.totpSecretObfuscated);
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
- const code = generateTOTP(parseTotpSecret(secret));
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
- const choice = await select({
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
- const secret = parseTotpSecret(secretInput.trim());
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
- return code;
1642
- }
1643
- const code = await input({ message: "Enter MFA code from authenticator app" });
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
- function compactText(value) {
1763
- return (value ?? "").replace(/\s+/g, " ").trim();
1764
- }
1765
- function formatContent(value) {
1766
- const lines = value
1767
- .replace(/\r/g, "")
1768
- .split("\n")
1769
- .map((line) => line.trim())
1770
- .filter((line, index, arr) => {
1771
- if (line !== "")
1772
- return true;
1773
- // Keep a single blank line between blocks
1774
- return arr[index - 1] !== "";
1775
- });
1776
- return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim();
1777
- }
1778
- async function getStudentsForCommand(profile, config) {
1779
- const stored = config.profiles.find((p) => p.id === config.lastProfileId);
1780
- const fresh = await WilmaClient.listStudents(profile);
1781
- if (stored) {
1782
- stored.students = fresh.map((s) => ({ studentNumber: s.studentNumber, name: s.name }));
1783
- await saveConfig(config);
1784
- }
1785
- return fresh;
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
- const stored = config.profiles.find((p) => p.id === config.lastProfileId);
1809
- if (stored?.lastStudentNumber) {
1026
+ if (err instanceof MfaRequiredError) {
1810
1027
  return {
1811
- studentNumber: stored.lastStudentNumber,
1812
- name: stored.lastStudentName ?? stored.lastStudentNumber,
1813
- href: `/!${stored.lastStudentNumber}/`,
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
- const students = await getStudentsForCommand(profile, config);
1817
- return students[0] ?? null;
1818
- }
1819
- async function outputAllNews(profile, config, limit, json, onMfa) {
1820
- const students = await getStudentsForCommand(profile, config);
1821
- const results = [];
1822
- for (const student of students) {
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
- const scheduleSelection = command === "schedule"
1941
- ? resolveScheduleDateSelection(flags)
1942
- : null;
1943
- const results = [];
1944
- for (const student of students) {
1945
- const client = await WilmaClient.login({ ...profile, studentNumber: student.studentNumber }, onMfa);
1946
- if (command === "schedule" && scheduleSelection) {
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 (flags.json) {
1963
- console.log(JSON.stringify({ students: results.map((r) => ({ student: r.student, items: r.data })) }, null, 2));
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
- for (const r of results) {
1967
- console.log(`\n[${r.student.name}]`);
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
- async function printStudentSelectionHelp(profile, config) {
1991
- const students = await getStudentsForCommand(profile, config);
1992
- console.error("Multiple students found. Use --student <id|name> or --all-students.");
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
- if (err instanceof MfaRequiredError) {
2002
- console.error("MFA is enabled on this Wilma account.");
2003
- console.error("For non-interactive use, provide your TOTP secret:");
2004
- console.error(" --totp-secret <base32-key>");
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("CLI error:", message);
2022
- if (isNetwork && err.hint) {
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(1);
1075
+ process.exit(exitCode);
2028
1076
  });