@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 +21 -0
- package/README.md +15 -1
- package/dist/config.d.ts +1 -0
- package/dist/index.js +66 -12
- 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
|
@@ -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
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": "
|
|
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
|
+
}
|