@wilm-ai/wilma-cli 1.3.0 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE 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
@@ -71,11 +71,25 @@ wilma update
71
71
  wilma config clear
72
72
  ```
73
73
 
74
+ ## MFA (Multi-Factor Authentication)
75
+
76
+ If your Wilma account has MFA/TOTP enabled:
77
+
78
+ **Interactive (recommended):** Run `wilma` and choose "Save TOTP secret for automatic login" when prompted. Paste your base32 key or `otpauth://` URI from your authenticator app. Future logins auto-authenticate.
79
+
80
+ **Non-interactive:** Pass the secret directly:
81
+ ```bash
82
+ wilma schedule list --totp-secret <base32-key> --json
83
+ wilma schedule list --totp-secret 'otpauth://totp/...' --json
84
+ ```
85
+
86
+ If you've saved your TOTP secret via interactive setup, `--totp-secret` is not needed.
87
+
74
88
  ## Config
75
89
  Local config is stored in `~/.config/wilmai/config.json` (or `$XDG_CONFIG_HOME/wilmai/config.json`).
76
90
  Use `wilma config clear` to remove it. Override with `WILMAI_CONFIG_PATH`.
77
91
 
78
92
  ## Notes
79
- - Credentials are stored with lightweight obfuscation for convenience.
93
+ - Credentials and TOTP secrets are stored with lightweight obfuscation for convenience.
80
94
  - For multi-child accounts, you can pass `--student <id|name>` or `--all-students`.
81
95
  - All list commands support `--json` for agent-friendly structured output.
package/dist/config.d.ts CHANGED
@@ -4,6 +4,7 @@ export interface StoredProfile {
4
4
  tenantName?: string | null;
5
5
  username: string;
6
6
  passwordObfuscated: string;
7
+ totpSecretObfuscated?: string | null;
7
8
  students?: {
8
9
  studentNumber: string;
9
10
  name: string;
package/dist/index.js CHANGED
@@ -55,7 +55,7 @@ async function main() {
55
55
  await runInteractive(config);
56
56
  await showUpdateNotice(updateCheck);
57
57
  }
58
- async function chooseProfile(config) {
58
+ async function chooseProfile(config, onMfa, onStoredProfileResolved) {
59
59
  if (config.profiles.length) {
60
60
  const choices = config.profiles.map((p) => ({
61
61
  value: p.id,
@@ -74,6 +74,7 @@ async function chooseProfile(config) {
74
74
  if (!stored) {
75
75
  throw new Error("Stored profile not found");
76
76
  }
77
+ onStoredProfileResolved?.(stored);
77
78
  const secret = revealSecret(stored.passwordObfuscated);
78
79
  if (!secret) {
79
80
  throw new Error("Stored password could not be decoded");
@@ -82,7 +83,7 @@ async function chooseProfile(config) {
82
83
  baseUrl: stored.tenantUrl,
83
84
  username: stored.username,
84
85
  password: secret,
85
- });
86
+ }, onMfa);
86
87
  if (!selectedStudent) {
87
88
  return null;
88
89
  }
@@ -113,7 +114,7 @@ async function chooseProfile(config) {
113
114
  username,
114
115
  password: passwordValue,
115
116
  };
116
- const students = await WilmaClient.listStudents(profileBase);
117
+ const students = await WilmaClient.listStudents(profileBase, onMfa);
117
118
  const student = await chooseStudent(students);
118
119
  if (!student)
119
120
  return null;
@@ -134,16 +135,18 @@ async function chooseProfile(config) {
134
135
  };
135
136
  config.profiles = config.profiles.filter((p) => p.id !== stored.id).concat(stored);
136
137
  config.lastProfileId = stored.id;
138
+ onStoredProfileResolved?.(stored);
137
139
  await saveConfig(config);
138
140
  return finalProfile;
139
141
  }
140
142
  async function runInteractive(config) {
143
+ const mfaState = { storedProfile: undefined };
144
+ const interactiveMfa = createInteractiveMfaCallback(() => mfaState.storedProfile, () => saveConfig(config));
141
145
  while (true) {
142
- const profile = await chooseProfile(config);
146
+ const profile = await chooseProfile(config, interactiveMfa, (sp) => { mfaState.storedProfile = sp; });
143
147
  if (!profile) {
144
148
  return;
145
149
  }
146
- const interactiveMfa = createInteractiveMfaCallback();
147
150
  const client = await WilmaClient.login(profile, interactiveMfa);
148
151
  let nextAction = await selectOrCancel({
149
152
  message: "What do you want to view?",
@@ -334,9 +337,9 @@ async function chooseStudent(students) {
334
337
  return null;
335
338
  return students.find((s) => s.studentNumber === selected) ?? null;
336
339
  }
337
- async function chooseStudentFromProfile(stored, baseProfile) {
340
+ async function chooseStudentFromProfile(stored, baseProfile, onMfa) {
338
341
  let students = [];
339
- const fresh = await WilmaClient.listStudents(baseProfile);
342
+ const fresh = await WilmaClient.listStudents(baseProfile, onMfa);
340
343
  if (fresh.length) {
341
344
  students = fresh;
342
345
  stored.students = fresh.map((s) => ({ studentNumber: s.studentNumber, name: s.name }));
@@ -379,7 +382,15 @@ async function handleCommand(args, config) {
379
382
  const profile = await getProfileForCommandNonInteractive(config, flags);
380
383
  if (!profile)
381
384
  return;
382
- const mfaCallback = createNonInteractiveMfaCallback(flags.totpSecret);
385
+ // Use --totp-secret flag, or fall back to stored TOTP secret from config
386
+ let totpSecret = flags.totpSecret;
387
+ if (!totpSecret) {
388
+ const stored = config.profiles.find((p) => p.id === config.lastProfileId);
389
+ if (stored?.totpSecretObfuscated) {
390
+ totpSecret = revealSecret(stored.totpSecretObfuscated) ?? undefined;
391
+ }
392
+ }
393
+ const mfaCallback = createNonInteractiveMfaCallback(totpSecret);
383
394
  const client = await WilmaClient.login(profile, mfaCallback);
384
395
  if (command === "kids") {
385
396
  const students = await getStudentsForCommand(profile, config);
@@ -1279,13 +1290,56 @@ async function selectOrCancel(opts, clearScreen = true) {
1279
1290
  process.stdin.removeListener("keypress", onKeypress);
1280
1291
  }
1281
1292
  }
1282
- function createInteractiveMfaCallback() {
1293
+ function createInteractiveMfaCallback(getStoredProfile, saveProfile) {
1294
+ let lastCode = null;
1295
+ let lastCodeTime = 0;
1283
1296
  return async (_formkey) => {
1297
+ // TOTP codes are valid for 30s; reuse if within the same window
1298
+ const now = Math.floor(Date.now() / 30000);
1299
+ if (lastCode && now === lastCodeTime) {
1300
+ return lastCode;
1301
+ }
1302
+ // If a TOTP secret is stored, auto-generate
1303
+ const stored = getStoredProfile?.();
1304
+ if (stored?.totpSecretObfuscated) {
1305
+ const secret = revealSecret(stored.totpSecretObfuscated);
1306
+ if (secret) {
1307
+ const code = generateTOTP(parseTotpSecret(secret));
1308
+ lastCode = code;
1309
+ lastCodeTime = now;
1310
+ return code;
1311
+ }
1312
+ }
1313
+ const choice = await select({
1314
+ message: "MFA required. Choose how to authenticate:",
1315
+ choices: [
1316
+ { value: "code", name: "Enter one-time code from authenticator app" },
1317
+ { value: "secret", name: "Save TOTP secret for automatic login" },
1318
+ ],
1319
+ });
1320
+ if (choice === "secret") {
1321
+ const secretInput = await input({
1322
+ message: "Paste TOTP secret (base32 key or otpauth:// URI)",
1323
+ });
1324
+ if (!secretInput)
1325
+ throw new Error("MFA cancelled");
1326
+ const secret = parseTotpSecret(secretInput.trim());
1327
+ // Save to config
1328
+ if (stored && saveProfile) {
1329
+ stored.totpSecretObfuscated = obfuscateSecret(secretInput.trim());
1330
+ await saveProfile();
1331
+ }
1332
+ const code = generateTOTP(secret);
1333
+ lastCode = code;
1334
+ lastCodeTime = now;
1335
+ return code;
1336
+ }
1284
1337
  const code = await input({ message: "Enter MFA code from authenticator app" });
1285
- if (!code) {
1338
+ if (!code)
1286
1339
  throw new Error("MFA cancelled");
1287
- }
1288
- return code.trim();
1340
+ lastCode = code.trim();
1341
+ lastCodeTime = now;
1342
+ return lastCode;
1289
1343
  };
1290
1344
  }
1291
1345
  function createNonInteractiveMfaCallback(totpSecret) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wilm-ai/wilma-cli",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
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.2"
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
+ }