@wilm-ai/wilma-cli 1.3.1 → 1.4.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/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.1",
3
+ "version": "1.4.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "dependencies": {
12
12
  "@inquirer/prompts": "^5.3.8",
13
- "@wilm-ai/wilma-client": "^1.2.1"
13
+ "@wilm-ai/wilma-client": "^1.3.0"
14
14
  },
15
15
  "devDependencies": {
16
16
  "typescript": "^5.6.3"