@wilm-ai/wilma-cli 1.5.2 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +3 -0
  3. package/dist/index.js +266 -11
  4. 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/README.md CHANGED
@@ -60,10 +60,13 @@ wilma grades list [--limit 20] [--student <id|name>] [--all-students] [--json]
60
60
  ```bash
61
61
  wilma news list [--limit 20] [--student <id|name>] [--all-students] [--json]
62
62
  wilma news read <id> [--student <id|name>] [--json]
63
+ wilma news resource download <news-id> <resource-id> [--student <id|name>] [--output <directory>] [--json]
63
64
  wilma messages list [--folder inbox] [--limit 20] [--student <id|name>] [--all-students] [--json]
64
65
  wilma messages read <id> [--student <id|name>] [--json]
65
66
  ```
66
67
 
68
+ `news read --json` includes a `resources` array with every link in the bulletin. Any resource can be passed to the download command (the resource id also accepts a bare number, e.g. `1` for `resource-1`). Wilma-hosted files download through the authenticated session; external URLs are fetched with an isolated, unauthenticated request — no Wilma credentials are ever sent to external hosts. The result `status` reports what actually happened: `downloaded` (use the returned `path`), or `not_a_file` when the URL answered with a web page instead of a file (for example a sharing link that requires signing in — open it in a browser instead). `--output` defaults to the current directory; existing files are never overwritten.
69
+
67
70
  ### Attendance / lesson notes
68
71
  ```bash
69
72
  wilma attendance list [--date YYYY-MM-DD] [--student <id|name>] [--all-students] [--json]
package/dist/index.js CHANGED
@@ -2,8 +2,9 @@
2
2
  import { emitKeypressEvents } from "node:readline";
3
3
  import { select, input, password } from "@inquirer/prompts";
4
4
  import { createHmac } from "node:crypto";
5
- import { readFile, writeFile, mkdir } from "node:fs/promises";
5
+ import { readFile, writeFile, mkdir, open, rm } from "node:fs/promises";
6
6
  import { spawn } from "node:child_process";
7
+ import { homedir } from "node:os";
7
8
  import { dirname, resolve } from "node:path";
8
9
  import { WilmaClient, MfaRequiredError, listTenants, } from "@wilm-ai/wilma-client";
9
10
  import { clearConfig, getConfigPath, loadConfig, obfuscateSecret, revealSecret, saveConfig, } from "./config.js";
@@ -373,7 +374,7 @@ async function chooseStudentFromProfile(stored, baseProfile, onMfa) {
373
374
  return students.find((s) => s.studentNumber === selected) ?? null;
374
375
  }
375
376
  async function handleCommand(args, config) {
376
- const { command, subcommand, flags } = parseArgs(args);
377
+ const { command, subcommand, resourceAction, flags } = parseArgs(args);
377
378
  if (command === "config" && subcommand === "clear") {
378
379
  await clearConfig();
379
380
  console.log(`Cleared config at ${getConfigPath()}`);
@@ -405,6 +406,38 @@ async function handleCommand(args, config) {
405
406
  return;
406
407
  }
407
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
+ }
408
441
  if (subcommand === "read" && flags.id) {
409
442
  const newsId = parseReadId(flags.id, "news");
410
443
  if (!flags.student) {
@@ -424,7 +457,9 @@ async function handleCommand(args, config) {
424
457
  ...profile,
425
458
  studentNumber: studentInfo?.studentNumber ?? profile.studentNumber,
426
459
  }, mfaCallback);
427
- await outputNewsItem(perStudentClient, newsId, flags.json);
460
+ await outputNewsItem(perStudentClient, newsId, flags.json, {
461
+ studentNumber: studentInfo?.studentNumber ?? profile.studentNumber,
462
+ });
428
463
  return;
429
464
  }
430
465
  if (flags.allStudents) {
@@ -635,6 +670,7 @@ function printUsage() {
635
670
  console.log(" wilma kids list [--json]");
636
671
  console.log(" wilma news list [--limit 20] [--student <id|name>] [--all-students] [--json]");
637
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]");
638
674
  console.log(" wilma messages list [--folder inbox] [--limit 20] [--student <id|name>] [--all-students] [--json]");
639
675
  console.log(" wilma messages read <id> [--student <id|name>] [--json]");
640
676
  console.log(" wilma attendance list [--date YYYY-MM-DD] [--student <id|name>] [--all-students] [--json]");
@@ -674,6 +710,7 @@ function parseArgs(args) {
674
710
  const subcommand = rawSubcommand?.startsWith("--") ? undefined : rawSubcommand;
675
711
  const rest = rawSubcommand?.startsWith("--") ? [rawSubcommand, ...rawRest] : rawRest;
676
712
  const flags = {};
713
+ const positionals = [];
677
714
  let i = 0;
678
715
  while (i < rest.length) {
679
716
  const arg = rest[i];
@@ -738,14 +775,27 @@ function parseArgs(args) {
738
775
  i += 2;
739
776
  continue;
740
777
  }
741
- if (!flags.id && !arg.startsWith("--")) {
742
- flags.id = arg;
743
- i += 1;
778
+ if (arg === "--output") {
779
+ flags.output = rest[i + 1];
780
+ i += 2;
744
781
  continue;
745
782
  }
783
+ if (!arg.startsWith("--")) {
784
+ positionals.push(arg);
785
+ }
746
786
  i += 1;
747
787
  }
748
- return { command, subcommand, flags };
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 };
749
799
  }
750
800
  function parseReadId(raw, entity) {
751
801
  if (!raw) {
@@ -952,11 +1002,11 @@ async function outputNews(client, opts) {
952
1002
  console.log(`- ${prefix}${date} ${compactText(item.title)} (id:${item.wilmaId})`.trim());
953
1003
  });
954
1004
  }
955
- async function outputNewsItem(client, id, json) {
1005
+ async function outputNewsItem(client, id, json, opts) {
956
1006
  const item = await client.news.get(id);
957
1007
  if (json) {
958
1008
  console.log(JSON.stringify(item, null, 2));
959
- return;
1009
+ return item;
960
1010
  }
961
1011
  console.log(`\n${item.title}`);
962
1012
  if (item.subtitle)
@@ -965,6 +1015,172 @@ async function outputNewsItem(client, id, json) {
965
1015
  console.log(item.published.toISOString());
966
1016
  if (item.content)
967
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");
968
1184
  }
969
1185
  async function outputExams(client, opts) {
970
1186
  const exams = await client.exams.list();
@@ -1298,7 +1514,40 @@ async function selectNewsToRead(client) {
1298
1514
  });
1299
1515
  if (!selected || selected === "back")
1300
1516
  return;
1301
- await outputNewsItem(client, Number(selected), false);
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
+ }
1302
1551
  }
1303
1552
  async function selectMessageToRead(client, folder) {
1304
1553
  const messages = await client.messages.list(folder);
@@ -1758,6 +2007,12 @@ main().catch((err) => {
1758
2007
  console.error("For interactive use, run 'wilma' without arguments.");
1759
2008
  process.exit(1);
1760
2009
  }
1761
- console.error("CLI error:", err instanceof Error ? err.message : err);
2010
+ const message = err instanceof Error ? err.message : String(err);
2011
+ if (process.argv.includes("--json")) {
2012
+ console.log(JSON.stringify({ status: "error", message }, null, 2));
2013
+ }
2014
+ else {
2015
+ console.error("CLI error:", message);
2016
+ }
1762
2017
  process.exit(1);
1763
2018
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wilm-ai/wilma-cli",
3
- "version": "1.5.2",
3
+ "version": "1.6.0",
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.5.0"
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": "pnpm --filter @wilm-ai/wilma-client build && pnpm run build && node test/news-resources.mjs",
29
+ "start": "node dist/index.js",
30
+ "test:live": "tsc -p tsconfig.json && node test/live.spec.mjs"
32
31
  }
33
- }
32
+ }