@wilm-ai/wilma-cli 2.3.2 → 2.5.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 +20 -3
- package/dist/access.d.ts +40 -1
- package/dist/access.js +105 -6
- package/dist/agent-data.d.ts +200 -21
- package/dist/agent-data.js +261 -56
- package/dist/cli-args.js +12 -3
- package/dist/cli-help.js +29 -13
- package/dist/cli-output.d.ts +4 -1
- package/dist/cli-output.js +19 -5
- package/dist/config.d.ts +36 -6
- package/dist/config.js +26 -0
- package/dist/credentials.d.ts +49 -2
- package/dist/credentials.js +199 -64
- package/dist/index.js +214 -108
- package/dist/login-server.d.ts +28 -6
- package/dist/login-server.js +468 -239
- package/dist/mcp-tools.d.ts +2 -2
- package/dist/mcp-tools.js +46 -15
- package/dist/mcp.js +26 -15
- package/dist/secret-moves.d.ts +44 -0
- package/dist/secret-moves.js +297 -0
- package/dist/secret-store.d.ts +45 -0
- package/dist/secret-store.js +487 -0
- package/dist/session-keys.d.ts +19 -0
- package/dist/session-keys.js +26 -0
- package/dist/session-store.d.ts +16 -0
- package/dist/session-store.js +69 -0
- package/package.json +8 -3
package/dist/index.js
CHANGED
|
@@ -6,14 +6,16 @@ import { spawn } from "node:child_process";
|
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
7
|
import { dirname, resolve } from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
|
-
import { APIError, AuthenticationError, MfaRequiredError, NetworkError, WilmaClient, listTenants, } from "@wilm-ai/wilma-client";
|
|
10
|
-
import { clearConfig, getConfigPath, loadConfig,
|
|
11
|
-
import { WilmaAccess, WilmaAiError,
|
|
12
|
-
import { createAccess, logoutAccounts } from "./access.js";
|
|
9
|
+
import { APIError, AuthenticationError, CredentialsUnavailableError, MfaRequiredError, NetworkError, WilmaClient, listTenants, } from "@wilm-ai/wilma-client";
|
|
10
|
+
import { clearConfig, editConfig, getConfigPath, loadConfig, revealSecret, savedStudent } from "./config.js";
|
|
11
|
+
import { WilmaAccess, WilmaAiError, flushSessions, roleOf, useSessionStore, } from "./agent-data.js";
|
|
12
|
+
import { createAccess, logoutAccounts, removeLogin, saveVerifiedLogin } from "./access.js";
|
|
13
13
|
import { parseCliArgs } from "./cli-args.js";
|
|
14
14
|
import { commandHelp, generalHelp } from "./cli-help.js";
|
|
15
15
|
import { compactText, formatText, messageLine, newsLine } from "./cli-output.js";
|
|
16
|
-
import { ENV_VARS, isMfaFailure, mfaCallbackFor, resolveAccounts,
|
|
16
|
+
import { ENV_VARS, accountFor, chooseSecretStore, isMfaFailure, mfaCallbackFor, resolveAccounts, saveTotpSecret, verifyLoginSession } from "./credentials.js";
|
|
17
|
+
import { deleteKnownEntries, moveLoginToFile, moveLoginToKeychain, removeStrayTempFiles, runBackgroundMoves, whereSecretsAre } from "./secret-moves.js";
|
|
18
|
+
import { abandonedKeychainCalls, effectiveSecretStore, keychainKind, keychainProblem, readBackLoginSecrets, SecretStoreError, secretErrorMessage } from "./secret-store.js";
|
|
17
19
|
import { createUniqueDownloadFile } from "./downloads.js";
|
|
18
20
|
import { HOME, homeText } from "./home.js";
|
|
19
21
|
import { openBrowser, startLoginServer } from "./login-server.js";
|
|
@@ -52,6 +54,7 @@ const ACTIONS = [
|
|
|
52
54
|
{ value: "grades", name: "Exam grades" },
|
|
53
55
|
{ value: "notes", name: "Lesson notes (last 14 days)" },
|
|
54
56
|
{ value: "gradebook", name: "Gradebook" },
|
|
57
|
+
{ value: "contacts", name: "Class and teachers" },
|
|
55
58
|
{ value: "news", name: "List news" },
|
|
56
59
|
{ value: "messages", name: "List messages" },
|
|
57
60
|
{ value: "exit", name: "Exit" },
|
|
@@ -130,11 +133,19 @@ async function runCommand(argv) {
|
|
|
130
133
|
case "update":
|
|
131
134
|
await handleUpdate();
|
|
132
135
|
return;
|
|
133
|
-
case "config":
|
|
136
|
+
case "config": {
|
|
137
|
+
if (action === "secrets") {
|
|
138
|
+
await handleSecrets(args[0], out);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
// The documented way out of a damaged config: read what can be read, delete the keychain entries it knows, then the files.
|
|
142
|
+
const notDeleted = await deleteKnownEntries(await readFile(getConfigPath(), "utf8").catch(() => null));
|
|
134
143
|
await clearConfig();
|
|
135
144
|
await clearSessions();
|
|
136
|
-
out.data({ status: "cleared", configPath: getConfigPath() }, () => `Cleared saved logins and sessions (${getConfigPath()}).`
|
|
145
|
+
out.data({ status: "cleared", configPath: getConfigPath(), ...(notDeleted.length ? { keychainEntriesNotDeleted: notDeleted.length } : {}) }, () => `Cleared saved logins and sessions (${getConfigPath()}).` +
|
|
146
|
+
(notDeleted.length ? `\n${notDeleted.length} keychain entr${notDeleted.length === 1 ? "y" : "ies"} named ai.wilm.wilma-cli couldn't be deleted; remove them in your keychain app.` : ""));
|
|
137
147
|
return;
|
|
148
|
+
}
|
|
138
149
|
default:
|
|
139
150
|
try {
|
|
140
151
|
await runDataCommand(parsed, out);
|
|
@@ -142,9 +153,78 @@ async function runCommand(argv) {
|
|
|
142
153
|
finally {
|
|
143
154
|
// Also after an error: the session is still good for the next command.
|
|
144
155
|
await flushSessions();
|
|
156
|
+
await runBackgroundMoves();
|
|
145
157
|
}
|
|
146
158
|
}
|
|
147
159
|
}
|
|
160
|
+
/** `wilma config secrets [file|auto|keychain]`: where saved passwords are kept, or a change of it (moving the logins). */
|
|
161
|
+
async function handleSecrets(setting, out) {
|
|
162
|
+
let moved;
|
|
163
|
+
let note;
|
|
164
|
+
if (setting) {
|
|
165
|
+
const problem = setting === "file" ? null : await keychainProblem();
|
|
166
|
+
if (setting === "keychain" && problem) {
|
|
167
|
+
throw new SecretStoreError("unavailable", `There's no keychain WilmAI can use on this computer (${problem}). Nothing was changed.`);
|
|
168
|
+
}
|
|
169
|
+
await editConfig((config) => {
|
|
170
|
+
config.secretStore = setting;
|
|
171
|
+
});
|
|
172
|
+
const config = await loadConfig();
|
|
173
|
+
moved = [];
|
|
174
|
+
if (setting === "file") {
|
|
175
|
+
for (const p of config.profiles)
|
|
176
|
+
if (p.secrets || config.keychainLogins?.[p.id])
|
|
177
|
+
moved.push(await moveLoginToFile(p.id));
|
|
178
|
+
}
|
|
179
|
+
else if (problem) {
|
|
180
|
+
note = `There's no keychain WilmAI can use on this computer (${problem}). Saved passwords stay in WilmAI's file.`;
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
for (const p of config.profiles)
|
|
184
|
+
if (!p.secrets)
|
|
185
|
+
moved.push(await moveLoginToKeychain(p.id));
|
|
186
|
+
if (moved.some((m) => m.outcome === "moved")) {
|
|
187
|
+
await removeStrayTempFiles();
|
|
188
|
+
note = "Older copies of WilmAI's file (backups, synced dotfiles) may still hold the passwords; changing your Wilma password makes them useless.";
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (moved.some((m) => m.outcome === "failed"))
|
|
192
|
+
process.exitCode = 4;
|
|
193
|
+
}
|
|
194
|
+
const config = await loadConfig();
|
|
195
|
+
const problem = await keychainProblem();
|
|
196
|
+
const envOverride = process.env.WILMAI_SECRET_STORE?.trim() || null;
|
|
197
|
+
const logins = whereSecretsAre(config).map(({ id: _id, ...rest }) => rest);
|
|
198
|
+
const result = {
|
|
199
|
+
setting: effectiveSecretStore(config.secretStore, {}),
|
|
200
|
+
...(envOverride ? { envOverride } : {}),
|
|
201
|
+
keychain: { kind: keychainKind(), usable: !problem, ...(problem ? { problem } : {}) },
|
|
202
|
+
logins,
|
|
203
|
+
...(moved ? { moved: moved.map(({ id: _id, ...m }) => m) } : {}),
|
|
204
|
+
...(note ? { note } : {}),
|
|
205
|
+
};
|
|
206
|
+
out.data(result, () => {
|
|
207
|
+
const place = (store) => (store === "keychain" ? `the ${keychainKind()}` : store === "file" ? "WilmAI's file" : "somewhere this version doesn't know");
|
|
208
|
+
const lines = [
|
|
209
|
+
`New logins are saved in: ${result.setting === "file" ? "WilmAI's file" : result.setting === "keychain" ? `the ${keychainKind()}` : `the ${keychainKind()} where it can be used, else WilmAI's file`} (setting: ${result.setting}${envOverride ? `; WILMAI_SECRET_STORE=${envOverride} for this process` : ""}).`,
|
|
210
|
+
`Keychain on this computer: ${problem ? `can't be used (${problem})` : `${keychainKind()}, can be used`}.`,
|
|
211
|
+
];
|
|
212
|
+
if (logins.length) {
|
|
213
|
+
lines.push("Saved logins:");
|
|
214
|
+
for (const login of logins)
|
|
215
|
+
lines.push(` ${login.username} @ ${login.wilma}: ${place(login.store)}${login.hasTotp ? ", with an authenticator key" : ""}`);
|
|
216
|
+
}
|
|
217
|
+
for (const m of moved ?? []) {
|
|
218
|
+
if (m.outcome === "moved")
|
|
219
|
+
lines.push(`Moved ${m.username} @ ${m.wilma} to ${m.to === "keychain" ? `the ${keychainKind()}` : "WilmAI's file"}.${m.message ? ` ${m.message}` : ""}`);
|
|
220
|
+
else if (m.outcome !== "already")
|
|
221
|
+
lines.push(`Not moved: ${m.username} @ ${m.wilma}: ${m.message ?? m.outcome}`);
|
|
222
|
+
}
|
|
223
|
+
if (note)
|
|
224
|
+
lines.push(note);
|
|
225
|
+
return lines.join("\n");
|
|
226
|
+
});
|
|
227
|
+
}
|
|
148
228
|
/** Data commands: every child by default, the same results as the MCP tools. */
|
|
149
229
|
async function runDataCommand(parsed, out) {
|
|
150
230
|
const { command, action, args, flags } = parsed;
|
|
@@ -158,15 +238,17 @@ async function runDataCommand(parsed, out) {
|
|
|
158
238
|
case "summary":
|
|
159
239
|
return out.data(await access.summary({ student, days: flags.days, since: flags.since }));
|
|
160
240
|
case "schedule":
|
|
161
|
-
return out.data(await access.schedule({ student, when: flags.when, date: flags.date, weekday: flags.weekday }));
|
|
241
|
+
return out.data(await access.schedule({ student, when: flags.when, date: flags.date, weekday: flags.weekday, from: flags.from, to: flags.to }));
|
|
162
242
|
case "homework":
|
|
163
243
|
return out.data(await access.homework({ student, limit: flags.limit }));
|
|
164
244
|
case "exams":
|
|
165
245
|
return out.data(await access.upcomingExams({ student, limit: flags.limit }));
|
|
166
246
|
case "grades":
|
|
167
|
-
return out.data(await access.grades({ student, limit: flags.limit }));
|
|
247
|
+
return out.data(await access.grades({ student, limit: flags.limit, from: flags.from, to: flags.to }));
|
|
168
248
|
case "gradebook":
|
|
169
249
|
return out.data(await access.gradebook({ student }));
|
|
250
|
+
case "contacts":
|
|
251
|
+
return out.data(await access.contacts({ student }));
|
|
170
252
|
case "notes":
|
|
171
253
|
if (action === "summary")
|
|
172
254
|
return out.data(await access.lessonNotesSummary({ student, from: flags.from, to: flags.to }));
|
|
@@ -224,13 +306,17 @@ async function handleFindSchool(query, out) {
|
|
|
224
306
|
out.data({ wilmas, ...(hint ? { hint } : {}) }, () => (hint ? hint : wilmas.map((t) => `${t.url} ${t.name}`).join("\n")));
|
|
225
307
|
}
|
|
226
308
|
async function runInteractive(config) {
|
|
227
|
-
const mfa = createInteractiveMfa(
|
|
309
|
+
const mfa = createInteractiveMfa();
|
|
310
|
+
let first = true;
|
|
228
311
|
while (true) {
|
|
229
|
-
|
|
230
|
-
|
|
312
|
+
// A fresh read on every pass: a login just added here, or a login another process moved, shows as it is now.
|
|
313
|
+
const chosen = await chooseProfile(first ? config : await loadConfig(), mfa);
|
|
314
|
+
first = false;
|
|
315
|
+
if (!chosen) {
|
|
231
316
|
return;
|
|
232
317
|
}
|
|
233
|
-
const
|
|
318
|
+
const { profile } = chosen;
|
|
319
|
+
const access = new WilmaAccess({ profile: { ...profile, studentNumber: null }, password: chosen.password, keys: chosen.keys, mfa: mfa.callback });
|
|
234
320
|
const student = profile.studentNumber ?? undefined;
|
|
235
321
|
const show = (command, result, action = null) => {
|
|
236
322
|
console.clear();
|
|
@@ -266,6 +352,8 @@ async function runInteractive(config) {
|
|
|
266
352
|
show("notes", await access.lessonNotes({ student, days: 14 }));
|
|
267
353
|
if (nextAction === "gradebook")
|
|
268
354
|
show("gradebook", await access.gradebook({ student }));
|
|
355
|
+
if (nextAction === "contacts")
|
|
356
|
+
show("contacts", await access.contacts({ student }));
|
|
269
357
|
if (nextAction === "news")
|
|
270
358
|
await selectNewsToRead(access, student);
|
|
271
359
|
if (nextAction === "messages") {
|
|
@@ -380,29 +468,30 @@ async function chooseProfile(config, mfa) {
|
|
|
380
468
|
}
|
|
381
469
|
mfa.profile = stored;
|
|
382
470
|
mfa.typedSecret = null;
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
}, onMfa);
|
|
471
|
+
// A keychain login's password is read only if Wilma needs a new login.
|
|
472
|
+
const account = accountFor(stored);
|
|
473
|
+
const login = {
|
|
474
|
+
profile: account.profile,
|
|
475
|
+
password: account.secrets ? async () => (await account.secrets()).password : undefined,
|
|
476
|
+
keys: account.keys,
|
|
477
|
+
};
|
|
478
|
+
const selectedStudent = await chooseStudentFromProfile(stored, login, onMfa);
|
|
392
479
|
if (!selectedStudent) {
|
|
393
480
|
return null;
|
|
394
481
|
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
482
|
+
// Onto a fresh read: another process may have changed the config (moved this login, saved another).
|
|
483
|
+
await editConfig((fresh) => {
|
|
484
|
+
const target = fresh.profiles.find((p) => p.id === stored.id);
|
|
485
|
+
if (!target)
|
|
486
|
+
return false;
|
|
487
|
+
target.lastUsedAt = new Date().toISOString();
|
|
488
|
+
target.lastStudentNumber = selectedStudent?.studentNumber ?? null;
|
|
489
|
+
target.lastStudentName = selectedStudent?.name ?? null;
|
|
490
|
+
if (stored.students)
|
|
491
|
+
target.students = stored.students;
|
|
492
|
+
fresh.lastProfileId = stored.id;
|
|
493
|
+
});
|
|
494
|
+
return { ...login, profile: { ...login.profile, studentNumber: selectedStudent?.studentNumber ?? undefined } };
|
|
406
495
|
}
|
|
407
496
|
}
|
|
408
497
|
const tenant = await selectTenant();
|
|
@@ -417,32 +506,30 @@ async function chooseProfile(config, mfa) {
|
|
|
417
506
|
// Codes for a new login can't come from another saved login's key.
|
|
418
507
|
mfa.profile = undefined;
|
|
419
508
|
mfa.typedSecret = null;
|
|
509
|
+
// Where the password goes is settled before Wilma is contacted.
|
|
510
|
+
const choice = await chooseSecretStore(await loadConfig(), tenant.url, username);
|
|
420
511
|
const newProfile = { baseUrl: tenant.url, username, password: passwordValue };
|
|
421
512
|
const verified = await WilmaClient.login(newProfile, onMfa);
|
|
422
513
|
const students = await verified.students();
|
|
423
|
-
// The commands that follow continue this session instead of logging in again.
|
|
424
|
-
adoptSession(newProfile, verified);
|
|
425
514
|
const student = await chooseStudent(students);
|
|
426
515
|
if (!student)
|
|
427
516
|
return null;
|
|
428
517
|
// Same rules as `wilma login`: one entry per account, whatever the username's case.
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
518
|
+
// The commands that follow continue this session instead of logging in again.
|
|
519
|
+
const stored = await saveVerifiedLogin({ tenantUrl: tenant.url, tenantName: tenant.name, username, password: passwordValue, totpSecret: mfa.typedSecret, students }, verified, choice);
|
|
520
|
+
if (stored.note)
|
|
521
|
+
console.log(stored.note);
|
|
522
|
+
await editConfig((fresh) => {
|
|
523
|
+
const target = fresh.profiles.find((p) => p.id === stored.id);
|
|
524
|
+
if (!target)
|
|
525
|
+
return false;
|
|
526
|
+
target.lastStudentNumber = student.studentNumber;
|
|
527
|
+
target.lastStudentName = student.name;
|
|
436
528
|
});
|
|
437
|
-
stored.lastStudentNumber = student.studentNumber;
|
|
438
|
-
stored.lastStudentName = student.name;
|
|
439
|
-
await saveConfig(config);
|
|
440
529
|
mfa.profile = stored;
|
|
441
530
|
return {
|
|
442
|
-
baseUrl: stored.tenantUrl,
|
|
443
|
-
|
|
444
|
-
password: passwordValue,
|
|
445
|
-
studentNumber: student.studentNumber,
|
|
531
|
+
profile: { baseUrl: stored.tenantUrl, username, password: passwordValue, studentNumber: student.studentNumber },
|
|
532
|
+
keys: accountFor(stored).keys,
|
|
446
533
|
};
|
|
447
534
|
}
|
|
448
535
|
async function selectTenant() {
|
|
@@ -519,16 +606,16 @@ async function chooseStudent(students) {
|
|
|
519
606
|
return null;
|
|
520
607
|
return students.find((s) => s.studentNumber === selected) ?? null;
|
|
521
608
|
}
|
|
522
|
-
async function chooseStudentFromProfile(stored,
|
|
609
|
+
async function chooseStudentFromProfile(stored, login, onMfa) {
|
|
523
610
|
let students = [];
|
|
524
|
-
const fresh = await new WilmaAccess({ profile:
|
|
611
|
+
const fresh = await new WilmaAccess({ profile: login.profile, password: login.password, keys: login.keys, mfa: onMfa }).students().catch((err) => {
|
|
525
612
|
if (stored.students?.length)
|
|
526
613
|
return [];
|
|
527
614
|
throw err;
|
|
528
615
|
});
|
|
529
616
|
if (fresh.length) {
|
|
530
617
|
students = fresh;
|
|
531
|
-
stored.students = fresh.map(
|
|
618
|
+
stored.students = fresh.map(savedStudent);
|
|
532
619
|
}
|
|
533
620
|
else if (stored.students && stored.students.length) {
|
|
534
621
|
students = stored.students.map((s) => ({
|
|
@@ -582,14 +669,17 @@ async function handleLogin(args, out) {
|
|
|
582
669
|
}
|
|
583
670
|
const report = (stored) => {
|
|
584
671
|
const students = (stored.students ?? []).map((s) => s.name);
|
|
672
|
+
const secrets = stored.secrets === "keychain" ? "keychain" : "file";
|
|
585
673
|
if (out.json) {
|
|
586
|
-
out.data({ status: "ok", wilma: stored.tenantName ?? stored.tenantUrl, username: stored.username, students, configPath: getConfigPath() });
|
|
674
|
+
out.data({ status: "ok", wilma: stored.tenantName ?? stored.tenantUrl, username: stored.username, students, configPath: getConfigPath(), secrets, ...(stored.note ? { note: stored.note } : {}) });
|
|
587
675
|
return;
|
|
588
676
|
}
|
|
589
677
|
console.log(`Logged in to ${stored.tenantName ?? stored.tenantUrl} as ${stored.username}.`);
|
|
590
678
|
if (students.length)
|
|
591
679
|
console.log(`Children: ${students.join(", ")}`);
|
|
592
|
-
console.log(`Saved to ${getConfigPath()}`);
|
|
680
|
+
console.log(secrets === "keychain" ? `Saved to ${getConfigPath()}; the password is in the ${keychainKind()}.` : `Saved to ${getConfigPath()}`);
|
|
681
|
+
if (stored.note)
|
|
682
|
+
console.log(stored.note);
|
|
593
683
|
};
|
|
594
684
|
// Non-interactive: flags (with env fallback) — for agents and scripts.
|
|
595
685
|
if (flags.tenant || flags.username || flags.passwordStdin) {
|
|
@@ -604,17 +694,11 @@ async function handleLogin(args, out) {
|
|
|
604
694
|
}
|
|
605
695
|
const tenant = await resolveTenant(tenantQuery);
|
|
606
696
|
const totpSecret = flags.totpSecret ?? process.env[ENV_VARS.totpSecret] ?? null;
|
|
697
|
+
// Where the password goes is settled before Wilma is contacted (a login that can't be saved costs nothing).
|
|
698
|
+
const choice = await chooseSecretStore(await loadConfig(), tenant.url, username);
|
|
607
699
|
const { client, students } = await verifyLoginSession({ tenantUrl: tenant.url, username, password, totpSecret });
|
|
608
700
|
// The next commands continue this session: no second login (or code).
|
|
609
|
-
|
|
610
|
-
const stored = await saveLogin(await loadConfig(), {
|
|
611
|
-
tenantUrl: tenant.url,
|
|
612
|
-
tenantName: tenant.name,
|
|
613
|
-
username,
|
|
614
|
-
password,
|
|
615
|
-
totpSecret,
|
|
616
|
-
students,
|
|
617
|
-
});
|
|
701
|
+
const stored = await saveVerifiedLogin({ tenantUrl: tenant.url, tenantName: tenant.name, username, password, totpSecret, students }, client, choice);
|
|
618
702
|
report(stored);
|
|
619
703
|
await flushSessions();
|
|
620
704
|
return;
|
|
@@ -632,7 +716,7 @@ async function handleLogin(args, out) {
|
|
|
632
716
|
console.log(`\nLogged in to ${stored.tenantName ?? stored.tenantUrl} as ${stored.username}.`);
|
|
633
717
|
if (students.length)
|
|
634
718
|
console.log(`Children: ${students.join(", ")}`);
|
|
635
|
-
console.log("Add another Wilma in the browser if a child is on a different one, or press Done there (or Enter here) to finish.");
|
|
719
|
+
console.log("Add another school's Wilma in the browser if a child is on a different one, or press Done there (or Enter here) to finish.");
|
|
636
720
|
},
|
|
637
721
|
});
|
|
638
722
|
const opened = flags.browser && openBrowser(server.url) === "opened";
|
|
@@ -655,22 +739,27 @@ async function handleLogin(args, out) {
|
|
|
655
739
|
process.stdin.off("data", finishOnEnter);
|
|
656
740
|
process.stdin.pause();
|
|
657
741
|
}
|
|
658
|
-
|
|
742
|
+
// Done without a new login is fine when logins are saved already: the page also logs in again, unticks and removes.
|
|
743
|
+
const saved = (await loadConfig()).profiles;
|
|
744
|
+
if (!saved.length) {
|
|
659
745
|
throw new WilmaAiError("login_cancelled", "Login page closed before logging in. Run `wilma login` again.");
|
|
660
746
|
}
|
|
661
747
|
await flushSessions();
|
|
662
748
|
if (out.json) {
|
|
749
|
+
// Every saved login, as the page left them.
|
|
663
750
|
out.data({
|
|
664
751
|
status: "ok",
|
|
665
|
-
logins:
|
|
752
|
+
logins: saved.map((stored) => ({
|
|
666
753
|
wilma: stored.tenantName ?? stored.tenantUrl,
|
|
667
754
|
username: stored.username,
|
|
668
|
-
students: (stored.students ?? []).map((s) => s.name),
|
|
755
|
+
students: (stored.students ?? []).filter((s) => !stored.hidden?.includes(s.studentNumber)).map((s) => s.name),
|
|
669
756
|
})),
|
|
670
757
|
configPath: getConfigPath(),
|
|
671
758
|
});
|
|
672
759
|
return;
|
|
673
760
|
}
|
|
761
|
+
if (!logins.length)
|
|
762
|
+
console.log(`\nNo new login. On this computer: ${saved.map((p) => `${p.tenantName ?? p.tenantUrl} — ${p.username}`).join(", ")}.`);
|
|
674
763
|
console.log(`\nSaved to ${getConfigPath()}`);
|
|
675
764
|
}
|
|
676
765
|
const HEADLESS_LOGIN_HELP = [
|
|
@@ -687,53 +776,39 @@ const HEADLESS_LOGIN_HELP = [
|
|
|
687
776
|
async function handleAccounts(action, args, out) {
|
|
688
777
|
const config = await loadConfig();
|
|
689
778
|
if (action === "remove") {
|
|
690
|
-
const removed = findSavedLogin(config.profiles, args.join(" "));
|
|
691
|
-
config.profiles = config.profiles.filter((p) => p.id !== removed.id);
|
|
692
|
-
if (config.lastProfileId === removed.id)
|
|
693
|
-
config.lastProfileId = config.profiles[0]?.id ?? null;
|
|
694
|
-
await saveConfig(config);
|
|
695
|
-
const session = await endRemovedSession(removed);
|
|
779
|
+
const { removed, ...session } = await removeLogin(findSavedLogin(config.profiles, args.join(" ")).id);
|
|
696
780
|
const result = { status: "removed", wilma: removed.tenantName ?? removed.tenantUrl, username: removed.username, ...session };
|
|
697
|
-
out.data(result, () => session.session === "ended"
|
|
781
|
+
out.data(result, () => (session.session === "ended"
|
|
698
782
|
? `Removed ${removed.username} @ ${result.wilma} and ended its Wilma session.`
|
|
699
783
|
: session.session === "not_ended"
|
|
700
784
|
? `Removed ${removed.username} @ ${result.wilma}. Its Wilma session couldn't be ended (${session.message}); Wilma ends it when it expires.`
|
|
701
|
-
: `Removed ${removed.username} @ ${result.wilma}.`)
|
|
785
|
+
: `Removed ${removed.username} @ ${result.wilma}.`) +
|
|
786
|
+
(session.keychainEntryLeft ? `\nIts saved password couldn't be deleted from the keychain (${session.keychainEntryLeft}); \`wilma config clear\` tries again.` : ""));
|
|
702
787
|
return;
|
|
703
788
|
}
|
|
704
|
-
const accounts = config.profiles.map((p, i) =>
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
789
|
+
const accounts = config.profiles.map((p, i) => {
|
|
790
|
+
// As Wilma said when the login's children were last read.
|
|
791
|
+
const role = roleOf(p.students ?? []);
|
|
792
|
+
const hidden = (p.students ?? []).filter((st) => p.hidden?.includes(st.studentNumber)).map((st) => st.name);
|
|
793
|
+
return {
|
|
794
|
+
number: i + 1,
|
|
795
|
+
wilma: p.tenantName ?? p.tenantUrl,
|
|
796
|
+
url: p.tenantUrl,
|
|
797
|
+
username: p.username,
|
|
798
|
+
...(role ? { role } : {}),
|
|
799
|
+
children: (p.students ?? []).filter((st) => !p.hidden?.includes(st.studentNumber)).map((st) => st.name),
|
|
800
|
+
// Unticked on the login page: not read.
|
|
801
|
+
...(hidden.length ? { hidden } : {}),
|
|
802
|
+
};
|
|
803
|
+
});
|
|
711
804
|
out.data({ accounts }, () => accounts.length
|
|
712
805
|
? [
|
|
713
|
-
...accounts.map((a) => `${a.number}. ${a.wilma} — ${a.username}${a.children.length ? ` (${a.children.join(", ")})` : ""}`),
|
|
806
|
+
...accounts.map((a) => `${a.number}. ${a.wilma} — ${a.username}${a.children.length ? ` (${a.children.join(", ")})` : ""}${a.role === "student" ? ", the student's own login" : ""}${a.hidden ? `; not read: ${a.hidden.join(", ")}` : ""}`),
|
|
714
807
|
"",
|
|
715
808
|
"Add another with `wilma login`; remove one with `wilma accounts remove <number>`.",
|
|
716
809
|
].join("\n")
|
|
717
810
|
: "No saved Wilma logins. Run `wilma login`.");
|
|
718
811
|
}
|
|
719
|
-
/**
|
|
720
|
-
* End a removed login's Wilma session and forget it; the other logins keep
|
|
721
|
-
* theirs. The login is removed even when Wilma can't be reached: its saved
|
|
722
|
-
* session is forgotten anyway, and Wilma ends the session when it expires.
|
|
723
|
-
*/
|
|
724
|
-
async function endRemovedSession(removed) {
|
|
725
|
-
const password = revealSecret(removed.passwordObfuscated);
|
|
726
|
-
if (!password)
|
|
727
|
-
return { session: "none" };
|
|
728
|
-
const profile = { baseUrl: removed.tenantUrl, username: removed.username, password };
|
|
729
|
-
try {
|
|
730
|
-
return { session: (await logoutSession(profile)) ? "ended" : "none" };
|
|
731
|
-
}
|
|
732
|
-
catch (err) {
|
|
733
|
-
await forgetSession(profile);
|
|
734
|
-
return { session: "not_ended", message: err instanceof Error ? err.message : String(err) };
|
|
735
|
-
}
|
|
736
|
-
}
|
|
737
812
|
/** A saved login by its number in `wilma accounts`, its Wilma's name or address, or its username. */
|
|
738
813
|
function findSavedLogin(profiles, rawTarget) {
|
|
739
814
|
const target = rawTarget.trim().toLowerCase();
|
|
@@ -849,7 +924,18 @@ async function selectOrCancel(opts, clearScreen = true) {
|
|
|
849
924
|
process.stdin.removeListener("keypress", onKeypress);
|
|
850
925
|
}
|
|
851
926
|
}
|
|
852
|
-
|
|
927
|
+
/** A saved login's authenticator key: from the file, or (a keychain login) read from its entry now. */
|
|
928
|
+
async function savedTotpSecret(profile) {
|
|
929
|
+
if (!profile)
|
|
930
|
+
return null;
|
|
931
|
+
if (profile.secrets === "keychain" && profile.secretId) {
|
|
932
|
+
if (!profile.hasTotp)
|
|
933
|
+
return null;
|
|
934
|
+
return (await readBackLoginSecrets(profile.secretId))?.totpSecret ?? null;
|
|
935
|
+
}
|
|
936
|
+
return revealSecret(profile.totpSecretObfuscated);
|
|
937
|
+
}
|
|
938
|
+
function createInteractiveMfa() {
|
|
853
939
|
let lastCode = null;
|
|
854
940
|
let lastCodeTime = 0;
|
|
855
941
|
let lastCodeFor;
|
|
@@ -864,9 +950,7 @@ function createInteractiveMfa(saveProfile) {
|
|
|
864
950
|
const retry = formkey === lastFormkey;
|
|
865
951
|
lastFormkey = formkey;
|
|
866
952
|
// A saved or just-typed key generates codes (a retry waits for the next one).
|
|
867
|
-
const secret = state.profile
|
|
868
|
-
? revealSecret(state.profile.totpSecretObfuscated)
|
|
869
|
-
: state.typedSecret;
|
|
953
|
+
const secret = (await savedTotpSecret(state.profile)) ?? state.typedSecret;
|
|
870
954
|
if (secret) {
|
|
871
955
|
if (!generators.has(secret))
|
|
872
956
|
generators.set(secret, mfaCallbackFor(secret));
|
|
@@ -895,8 +979,9 @@ function createInteractiveMfa(saveProfile) {
|
|
|
895
979
|
throw new Error("MFA cancelled");
|
|
896
980
|
const parsed = parseTotpSecret(secretInput);
|
|
897
981
|
if (state.profile) {
|
|
898
|
-
|
|
899
|
-
await
|
|
982
|
+
// Into the keychain entry for a keychain login, never the file.
|
|
983
|
+
await saveTotpSecret(state.profile.id, secretInput);
|
|
984
|
+
state.profile = (await loadConfig()).profiles.find((p) => p.id === state.profile.id) ?? state.profile;
|
|
900
985
|
}
|
|
901
986
|
else {
|
|
902
987
|
// A new login: saved together with the login once it succeeds.
|
|
@@ -997,8 +1082,25 @@ function terminalSafe(value) {
|
|
|
997
1082
|
function jsonSafe(value) {
|
|
998
1083
|
return value.replace(/[\u007f-\u009f]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
999
1084
|
}
|
|
1085
|
+
/** A keychain failure, whether the CLI read the keychain itself or the client did while logging in again. */
|
|
1086
|
+
function secretFailure(err) {
|
|
1087
|
+
if (err instanceof SecretStoreError)
|
|
1088
|
+
return err;
|
|
1089
|
+
if (err instanceof CredentialsUnavailableError) {
|
|
1090
|
+
if (err.cause instanceof SecretStoreError)
|
|
1091
|
+
return err.cause;
|
|
1092
|
+
const reason = ["missing", "refused", "unavailable"].find((r) => r === err.reason) ?? "unavailable";
|
|
1093
|
+
return new SecretStoreError(reason, err.message || secretErrorMessage(reason));
|
|
1094
|
+
}
|
|
1095
|
+
return null;
|
|
1096
|
+
}
|
|
1000
1097
|
/** An error as agents and scripts get it: a stable code, a message, and the exit code. */
|
|
1001
1098
|
function describeError(err) {
|
|
1099
|
+
const secret = secretFailure(err);
|
|
1100
|
+
if (secret) {
|
|
1101
|
+
// Exit code 4: the password is saved, but this computer didn't hand it over (or it's gone: then log in again).
|
|
1102
|
+
return { code: `secret_${secret.reason}`, message: secret.message, exitCode: 4 };
|
|
1103
|
+
}
|
|
1002
1104
|
if (err instanceof WilmaAiError) {
|
|
1003
1105
|
const exitCode = err.code === "invalid_argument" || err.code === "unknown_command" ? 2 : err.code === "not_logged_in" ? 3 : 1;
|
|
1004
1106
|
return { code: err.code, message: err.message, details: err.details, exitCode };
|
|
@@ -1038,7 +1140,11 @@ function describeError(err) {
|
|
|
1038
1140
|
return { code: "config_invalid", message, exitCode: 1 };
|
|
1039
1141
|
return { code: "error", message, exitCode: 1 };
|
|
1040
1142
|
}
|
|
1041
|
-
main().
|
|
1143
|
+
main().then(() => {
|
|
1144
|
+
// A keychain call that timed out can't be cancelled (Windows, Linux) and would keep the process alive: finish once the output is written.
|
|
1145
|
+
if (abandonedKeychainCalls())
|
|
1146
|
+
process.stdout.write("", () => process.exit(process.exitCode ?? 0));
|
|
1147
|
+
}).catch((err) => {
|
|
1042
1148
|
if (isPromptCancel(err)) {
|
|
1043
1149
|
process.exit(0);
|
|
1044
1150
|
}
|
package/dist/login-server.d.ts
CHANGED
|
@@ -8,11 +8,13 @@ export interface LoginServer {
|
|
|
8
8
|
close(): void;
|
|
9
9
|
}
|
|
10
10
|
/**
|
|
11
|
-
* Serve a one-time login page on 127.0.0.1
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* the
|
|
11
|
+
* Serve a one-time login page on 127.0.0.1, laid out like WilmAI Home's Wilma
|
|
12
|
+
* page: who uses WilmAI, one row per Wilma (a saved one with its children to
|
|
13
|
+
* tick, Log in again and Remove; a new one to fill in), Add another school's
|
|
14
|
+
* Wilma, and one button that logs in every new row. Each verified login is
|
|
15
|
+
* saved to the local config file — the password never passes through an
|
|
16
|
+
* agent's chat. One login per Wilma, as in Home. The page closes when the
|
|
17
|
+
* parent presses Done or after 15 idle minutes.
|
|
16
18
|
*/
|
|
17
19
|
/**
|
|
18
20
|
* The assistant app that asked for the login (by the name it gives when it
|
|
@@ -37,12 +39,32 @@ export type BrowserOpenResult = "opened" | "skipped" | "headless";
|
|
|
37
39
|
export declare function openBrowser(url: string, opts?: {
|
|
38
40
|
dryRun?: boolean;
|
|
39
41
|
}): BrowserOpenResult;
|
|
42
|
+
/** A saved login as the page shows it (see accountsSummary). */
|
|
43
|
+
export interface SavedLogin {
|
|
44
|
+
id: string;
|
|
45
|
+
wilma: string;
|
|
46
|
+
url: string;
|
|
47
|
+
username: string;
|
|
48
|
+
role?: "guardian" | "student";
|
|
49
|
+
children: {
|
|
50
|
+
number: string;
|
|
51
|
+
name: string;
|
|
52
|
+
hidden?: boolean;
|
|
53
|
+
}[];
|
|
54
|
+
}
|
|
40
55
|
export interface LoginPageOptions {
|
|
41
56
|
nonce: string;
|
|
42
57
|
apiBase: string;
|
|
43
58
|
/** Shown in the privacy note under the form. */
|
|
44
59
|
configPath?: string;
|
|
60
|
+
/** The keychain new passwords go to (the privacy note names it), or undefined for the file. */
|
|
61
|
+
keychain?: string;
|
|
45
62
|
openedBy?: OpenedBy;
|
|
63
|
+
/** The logins already saved. */
|
|
64
|
+
accounts?: SavedLogin[];
|
|
46
65
|
}
|
|
47
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* The login page, laid out like WilmAI Home's Wilma page. The API answers
|
|
68
|
+
* {status: "ok", accounts} after a login, a removal or a tick.
|
|
69
|
+
*/
|
|
48
70
|
export declare function renderLoginPage(opts: LoginPageOptions): string;
|