@wilm-ai/wilma-cli 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/LICENSE +21 -0
  2. package/dist/index.js +110 -29
  3. package/package.json +10 -11
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Wilm.ai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/dist/index.js CHANGED
@@ -1,10 +1,11 @@
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";
4
5
  import { readFile, writeFile, mkdir } from "node:fs/promises";
5
6
  import { spawn } from "node:child_process";
6
7
  import { dirname, resolve } from "node:path";
7
- import { WilmaClient, listTenants, } from "@wilm-ai/wilma-client";
8
+ import { WilmaClient, MfaRequiredError, listTenants, } from "@wilm-ai/wilma-client";
8
9
  import { clearConfig, getConfigPath, loadConfig, obfuscateSecret, revealSecret, saveConfig, } from "./config.js";
9
10
  // Enable keypress events for escape key detection
10
11
  if (process.stdin.isTTY) {
@@ -142,7 +143,8 @@ async function runInteractive(config) {
142
143
  if (!profile) {
143
144
  return;
144
145
  }
145
- const client = await WilmaClient.login(profile);
146
+ const interactiveMfa = createInteractiveMfaCallback();
147
+ const client = await WilmaClient.login(profile, interactiveMfa);
146
148
  let nextAction = await selectOrCancel({
147
149
  message: "What do you want to view?",
148
150
  pageSize: 15,
@@ -377,7 +379,8 @@ async function handleCommand(args, config) {
377
379
  const profile = await getProfileForCommandNonInteractive(config, flags);
378
380
  if (!profile)
379
381
  return;
380
- const client = await WilmaClient.login(profile);
382
+ const mfaCallback = createNonInteractiveMfaCallback(flags.totpSecret);
383
+ const client = await WilmaClient.login(profile, mfaCallback);
381
384
  if (command === "kids") {
382
385
  const students = await getStudentsForCommand(profile, config);
383
386
  if (flags.json) {
@@ -409,12 +412,12 @@ async function handleCommand(args, config) {
409
412
  const perStudentClient = await WilmaClient.login({
410
413
  ...profile,
411
414
  studentNumber: studentInfo?.studentNumber ?? profile.studentNumber,
412
- });
415
+ }, mfaCallback);
413
416
  await outputNewsItem(perStudentClient, newsId, flags.json);
414
417
  return;
415
418
  }
416
419
  if (flags.allStudents) {
417
- await outputAllNews(profile, config, flags.limit ?? 20, flags.json);
420
+ await outputAllNews(profile, config, flags.limit ?? 20, flags.json, mfaCallback);
418
421
  return;
419
422
  }
420
423
  const studentInfo = await resolveStudentForFlags(profile, config, flags.student);
@@ -425,7 +428,7 @@ async function handleCommand(args, config) {
425
428
  const perStudentClient = await WilmaClient.login({
426
429
  ...profile,
427
430
  studentNumber: studentInfo?.studentNumber ?? profile.studentNumber,
428
- });
431
+ }, mfaCallback);
429
432
  await outputNews(perStudentClient, {
430
433
  limit: flags.limit ?? 20,
431
434
  json: flags.json,
@@ -452,7 +455,7 @@ async function handleCommand(args, config) {
452
455
  const perStudentClient = await WilmaClient.login({
453
456
  ...profile,
454
457
  studentNumber: studentInfo?.studentNumber ?? profile.studentNumber,
455
- });
458
+ }, mfaCallback);
456
459
  await outputMessageItem(perStudentClient, messageId, flags.json);
457
460
  return;
458
461
  }
@@ -461,7 +464,7 @@ async function handleCommand(args, config) {
461
464
  folder: flags.folder ?? "inbox",
462
465
  limit: flags.limit ?? 20,
463
466
  json: flags.json,
464
- });
467
+ }, mfaCallback);
465
468
  return;
466
469
  }
467
470
  const studentInfo = await resolveStudentForFlags(profile, config, flags.student);
@@ -472,7 +475,7 @@ async function handleCommand(args, config) {
472
475
  const perStudentClient = await WilmaClient.login({
473
476
  ...profile,
474
477
  studentNumber: studentInfo?.studentNumber ?? profile.studentNumber,
475
- });
478
+ }, mfaCallback);
476
479
  await outputMessages(perStudentClient, {
477
480
  folder: flags.folder ?? "inbox",
478
481
  limit: flags.limit ?? 20,
@@ -483,7 +486,7 @@ async function handleCommand(args, config) {
483
486
  }
484
487
  if (command === "exams") {
485
488
  if (flags.allStudents) {
486
- await outputAllExams(profile, config, flags.limit ?? 20, flags.json);
489
+ await outputAllExams(profile, config, flags.limit ?? 20, flags.json, mfaCallback);
487
490
  return;
488
491
  }
489
492
  const studentInfo = await resolveStudentForFlags(profile, config, flags.student);
@@ -494,7 +497,7 @@ async function handleCommand(args, config) {
494
497
  const perStudentClient = await WilmaClient.login({
495
498
  ...profile,
496
499
  studentNumber: studentInfo?.studentNumber ?? profile.studentNumber,
497
- });
500
+ }, mfaCallback);
498
501
  await outputUpcomingExams(perStudentClient, {
499
502
  limit: flags.limit ?? 20,
500
503
  json: flags.json,
@@ -504,7 +507,7 @@ async function handleCommand(args, config) {
504
507
  }
505
508
  if (command === "schedule") {
506
509
  if (flags.allStudents) {
507
- await outputAllOverviewCommand(profile, config, "schedule", flags);
510
+ await outputAllOverviewCommand(profile, config, "schedule", flags, mfaCallback);
508
511
  return;
509
512
  }
510
513
  const studentInfo = await resolveStudentForFlags(profile, config, flags.student);
@@ -515,7 +518,7 @@ async function handleCommand(args, config) {
515
518
  const perStudentClient = await WilmaClient.login({
516
519
  ...profile,
517
520
  studentNumber: studentInfo?.studentNumber ?? profile.studentNumber,
518
- });
521
+ }, mfaCallback);
519
522
  await outputSchedule(perStudentClient, {
520
523
  when: flags.when ?? "week",
521
524
  date: flags.date,
@@ -527,7 +530,7 @@ async function handleCommand(args, config) {
527
530
  }
528
531
  if (command === "homework") {
529
532
  if (flags.allStudents) {
530
- await outputAllOverviewCommand(profile, config, "homework", flags);
533
+ await outputAllOverviewCommand(profile, config, "homework", flags, mfaCallback);
531
534
  return;
532
535
  }
533
536
  const studentInfo = await resolveStudentForFlags(profile, config, flags.student);
@@ -538,7 +541,7 @@ async function handleCommand(args, config) {
538
541
  const perStudentClient = await WilmaClient.login({
539
542
  ...profile,
540
543
  studentNumber: studentInfo?.studentNumber ?? profile.studentNumber,
541
- });
544
+ }, mfaCallback);
542
545
  await outputHomework(perStudentClient, {
543
546
  limit: flags.limit ?? 10,
544
547
  json: flags.json,
@@ -548,7 +551,7 @@ async function handleCommand(args, config) {
548
551
  }
549
552
  if (command === "grades") {
550
553
  if (flags.allStudents) {
551
- await outputAllOverviewCommand(profile, config, "grades", flags);
554
+ await outputAllOverviewCommand(profile, config, "grades", flags, mfaCallback);
552
555
  return;
553
556
  }
554
557
  const studentInfo = await resolveStudentForFlags(profile, config, flags.student);
@@ -559,7 +562,7 @@ async function handleCommand(args, config) {
559
562
  const perStudentClient = await WilmaClient.login({
560
563
  ...profile,
561
564
  studentNumber: studentInfo?.studentNumber ?? profile.studentNumber,
562
- });
565
+ }, mfaCallback);
563
566
  await outputGrades(perStudentClient, {
564
567
  limit: flags.limit ?? 20,
565
568
  json: flags.json,
@@ -569,7 +572,7 @@ async function handleCommand(args, config) {
569
572
  }
570
573
  if (command === "summary") {
571
574
  if (flags.allStudents) {
572
- await outputAllOverviewCommand(profile, config, "summary", flags);
575
+ await outputAllOverviewCommand(profile, config, "summary", flags, mfaCallback);
573
576
  return;
574
577
  }
575
578
  const studentInfo = await resolveStudentForFlags(profile, config, flags.student);
@@ -580,7 +583,7 @@ async function handleCommand(args, config) {
580
583
  const perStudentClient = await WilmaClient.login({
581
584
  ...profile,
582
585
  studentNumber: studentInfo?.studentNumber ?? profile.studentNumber,
583
- });
586
+ }, mfaCallback);
584
587
  await outputSummary(perStudentClient, {
585
588
  days: flags.days ?? 7,
586
589
  json: flags.json,
@@ -606,6 +609,8 @@ function printUsage() {
606
609
  console.log(" wilma config clear");
607
610
  console.log(" wilma --help | -h");
608
611
  console.log(" wilma --version | -v");
612
+ console.log("");
613
+ console.log("MFA: --totp-secret <base32-key|otpauth://...>");
609
614
  }
610
615
  async function getProfileForCommandNonInteractive(config, flags) {
611
616
  if (!config.lastProfileId) {
@@ -695,6 +700,11 @@ function parseArgs(args) {
695
700
  i += 2;
696
701
  continue;
697
702
  }
703
+ if (arg === "--totp-secret") {
704
+ flags.totpSecret = rest[i + 1];
705
+ i += 2;
706
+ continue;
707
+ }
698
708
  if (!flags.id && !arg.startsWith("--")) {
699
709
  flags.id = arg;
700
710
  i += 1;
@@ -1269,6 +1279,68 @@ async function selectOrCancel(opts, clearScreen = true) {
1269
1279
  process.stdin.removeListener("keypress", onKeypress);
1270
1280
  }
1271
1281
  }
1282
+ function createInteractiveMfaCallback() {
1283
+ return async (_formkey) => {
1284
+ const code = await input({ message: "Enter MFA code from authenticator app" });
1285
+ if (!code) {
1286
+ throw new Error("MFA cancelled");
1287
+ }
1288
+ return code.trim();
1289
+ };
1290
+ }
1291
+ function createNonInteractiveMfaCallback(totpSecret) {
1292
+ if (!totpSecret)
1293
+ return undefined;
1294
+ const secret = parseTotpSecret(totpSecret);
1295
+ return async (_formkey) => {
1296
+ const code = generateTOTP(secret);
1297
+ return code;
1298
+ };
1299
+ }
1300
+ function parseTotpSecret(raw) {
1301
+ // Accept either a bare base32 key or an otpauth:// URI
1302
+ if (raw.startsWith("otpauth://")) {
1303
+ const url = new URL(raw);
1304
+ const secret = url.searchParams.get("secret");
1305
+ if (!secret) {
1306
+ console.error("No 'secret' parameter found in otpauth:// URI.");
1307
+ process.exit(1);
1308
+ }
1309
+ return secret;
1310
+ }
1311
+ return raw.replace(/[\s-]/g, "");
1312
+ }
1313
+ function generateTOTP(secret) {
1314
+ // TOTP: RFC 6238 — HMAC-SHA1 based, 6-digit, 30-second period
1315
+ const base32Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
1316
+ // Decode base32 secret
1317
+ const cleanSecret = secret.replace(/[\s=-]+/g, "").toUpperCase();
1318
+ let bits = "";
1319
+ for (const c of cleanSecret) {
1320
+ const val = base32Chars.indexOf(c);
1321
+ if (val === -1)
1322
+ throw new Error(`Invalid base32 character: ${c}`);
1323
+ bits += val.toString(2).padStart(5, "0");
1324
+ }
1325
+ const bytes = new Uint8Array(Math.floor(bits.length / 8));
1326
+ for (let i = 0; i < bytes.length; i++) {
1327
+ bytes[i] = parseInt(bits.slice(i * 8, i * 8 + 8), 2);
1328
+ }
1329
+ const epoch = Math.floor(Date.now() / 1000);
1330
+ const counter = Math.floor(epoch / 30);
1331
+ const counterBuf = Buffer.alloc(8);
1332
+ counterBuf.writeUInt32BE(Math.floor(counter / 0x100000000), 0);
1333
+ counterBuf.writeUInt32BE(counter >>> 0, 4);
1334
+ const hmac = createHmac("sha1", Buffer.from(bytes));
1335
+ hmac.update(counterBuf);
1336
+ const hash = hmac.digest();
1337
+ const offset = hash[hash.length - 1] & 0x0f;
1338
+ const code = ((hash[offset] & 0x7f) << 24) |
1339
+ ((hash[offset + 1] & 0xff) << 16) |
1340
+ ((hash[offset + 2] & 0xff) << 8) |
1341
+ (hash[offset + 3] & 0xff);
1342
+ return (code % 1000000).toString().padStart(6, "0");
1343
+ }
1272
1344
  async function inputOrCancel(opts) {
1273
1345
  console.clear();
1274
1346
  const prompt = input(opts, { clearPromptOnDone: true });
@@ -1384,11 +1456,11 @@ async function resolveStudentForFlags(profile, config, student) {
1384
1456
  const students = await getStudentsForCommand(profile, config);
1385
1457
  return students[0] ?? null;
1386
1458
  }
1387
- async function outputAllNews(profile, config, limit, json) {
1459
+ async function outputAllNews(profile, config, limit, json, onMfa) {
1388
1460
  const students = await getStudentsForCommand(profile, config);
1389
1461
  const results = [];
1390
1462
  for (const student of students) {
1391
- const client = await WilmaClient.login({ ...profile, studentNumber: student.studentNumber });
1463
+ const client = await WilmaClient.login({ ...profile, studentNumber: student.studentNumber }, onMfa);
1392
1464
  const news = await client.news.list();
1393
1465
  results.push({ student, items: news.slice(0, limit) });
1394
1466
  }
@@ -1404,11 +1476,11 @@ async function outputAllNews(profile, config, limit, json) {
1404
1476
  });
1405
1477
  });
1406
1478
  }
1407
- async function outputAllMessages(profile, config, opts) {
1479
+ async function outputAllMessages(profile, config, opts, onMfa) {
1408
1480
  const students = await getStudentsForCommand(profile, config);
1409
1481
  const results = [];
1410
1482
  for (const student of students) {
1411
- const client = await WilmaClient.login({ ...profile, studentNumber: student.studentNumber });
1483
+ const client = await WilmaClient.login({ ...profile, studentNumber: student.studentNumber }, onMfa);
1412
1484
  const messages = await client.messages.list(opts.folder);
1413
1485
  results.push({ student, items: messages.slice(0, opts.limit) });
1414
1486
  }
@@ -1424,11 +1496,11 @@ async function outputAllMessages(profile, config, opts) {
1424
1496
  });
1425
1497
  });
1426
1498
  }
1427
- async function outputAllExams(profile, config, limit, json) {
1499
+ async function outputAllExams(profile, config, limit, json, onMfa) {
1428
1500
  const students = await getStudentsForCommand(profile, config);
1429
1501
  const results = [];
1430
1502
  for (const student of students) {
1431
- const client = await WilmaClient.login({ ...profile, studentNumber: student.studentNumber });
1503
+ const client = await WilmaClient.login({ ...profile, studentNumber: student.studentNumber }, onMfa);
1432
1504
  const overview = await client.overview.get();
1433
1505
  results.push({ student, items: overview.upcomingExams.slice(0, limit) });
1434
1506
  }
@@ -1444,13 +1516,13 @@ async function outputAllExams(profile, config, limit, json) {
1444
1516
  });
1445
1517
  });
1446
1518
  }
1447
- async function outputAllOverviewCommand(profile, config, command, flags) {
1519
+ async function outputAllOverviewCommand(profile, config, command, flags, onMfa) {
1448
1520
  const students = await getStudentsForCommand(profile, config);
1449
1521
  if (command === "summary") {
1450
1522
  if (flags.json) {
1451
1523
  const summaries = [];
1452
1524
  for (const student of students) {
1453
- const client = await WilmaClient.login({ ...profile, studentNumber: student.studentNumber });
1525
+ const client = await WilmaClient.login({ ...profile, studentNumber: student.studentNumber }, onMfa);
1454
1526
  const [overview, news, messages] = await Promise.all([
1455
1527
  client.overview.get(),
1456
1528
  client.news.list(),
@@ -1469,7 +1541,7 @@ async function outputAllOverviewCommand(profile, config, command, flags) {
1469
1541
  }
1470
1542
  // Human-readable summary output per student
1471
1543
  for (const student of students) {
1472
- const client = await WilmaClient.login({ ...profile, studentNumber: student.studentNumber });
1544
+ const client = await WilmaClient.login({ ...profile, studentNumber: student.studentNumber }, onMfa);
1473
1545
  await outputSummary(client, {
1474
1546
  days: flags.days ?? 7,
1475
1547
  json: false,
@@ -1480,7 +1552,7 @@ async function outputAllOverviewCommand(profile, config, command, flags) {
1480
1552
  }
1481
1553
  const results = [];
1482
1554
  for (const student of students) {
1483
- const client = await WilmaClient.login({ ...profile, studentNumber: student.studentNumber });
1555
+ const client = await WilmaClient.login({ ...profile, studentNumber: student.studentNumber }, onMfa);
1484
1556
  const overview = await client.overview.get();
1485
1557
  if (command === "schedule") {
1486
1558
  const when = flags.when || "week";
@@ -1545,6 +1617,15 @@ main().catch((err) => {
1545
1617
  if (isPromptCancel(err)) {
1546
1618
  process.exit(0);
1547
1619
  }
1620
+ if (err instanceof MfaRequiredError) {
1621
+ console.error("MFA is enabled on this Wilma account.");
1622
+ console.error("For non-interactive use, provide your TOTP secret:");
1623
+ console.error(" --totp-secret <base32-key>");
1624
+ console.error(" --totp-secret 'otpauth://totp/...'");
1625
+ console.error("Tip: export the key from your authenticator app (base32 string or otpauth:// URI).");
1626
+ console.error("For interactive use, run 'wilma' without arguments.");
1627
+ process.exit(1);
1628
+ }
1548
1629
  console.error("CLI error:", err instanceof Error ? err.message : err);
1549
1630
  process.exit(1);
1550
1631
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wilm-ai/wilma-cli",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -8,17 +8,9 @@
8
8
  "wilmai": "dist/index.js",
9
9
  "wilma": "dist/index.js"
10
10
  },
11
- "scripts": {
12
- "build": "tsc -p tsconfig.json",
13
- "lint": "echo 'add lint'",
14
- "test": "echo 'add tests'",
15
- "start": "node dist/index.js",
16
- "prepack": "pnpm --filter @wilm-ai/wilma-client build && pnpm --filter @wilm-ai/wilma-cli build",
17
- "test:live": "tsc -p tsconfig.json && node test/live.spec.mjs"
18
- },
19
11
  "dependencies": {
20
12
  "@inquirer/prompts": "^5.3.8",
21
- "@wilm-ai/wilma-client": "workspace:^"
13
+ "@wilm-ai/wilma-client": "^1.2.1"
22
14
  },
23
15
  "devDependencies": {
24
16
  "typescript": "^5.6.3"
@@ -29,5 +21,12 @@
29
21
  ],
30
22
  "publishConfig": {
31
23
  "access": "public"
24
+ },
25
+ "scripts": {
26
+ "build": "tsc -p tsconfig.json",
27
+ "lint": "echo 'add lint'",
28
+ "test": "echo 'add tests'",
29
+ "start": "node dist/index.js",
30
+ "test:live": "tsc -p tsconfig.json && node test/live.spec.mjs"
32
31
  }
33
- }
32
+ }