@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/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, obfuscateSecret, revealSecret, saveConfig } from "./config.js";
11
- import { WilmaAccess, WilmaAiError, adoptSession, flushSessions, forgetSession, logoutSession, useSessionStore, } from "./agent-data.js";
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, saveLogin, verifyLoginSession } from "./credentials.js";
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(() => saveConfig(config));
309
+ const mfa = createInteractiveMfa();
310
+ let first = true;
228
311
  while (true) {
229
- const profile = await chooseProfile(config, mfa);
230
- if (!profile) {
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 access = new WilmaAccess({ profile: { ...profile, studentNumber: null }, mfa: mfa.callback });
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
- const secret = revealSecret(stored.passwordObfuscated);
384
- if (!secret) {
385
- throw new Error("Stored password could not be decoded");
386
- }
387
- const selectedStudent = await chooseStudentFromProfile(stored, {
388
- baseUrl: stored.tenantUrl,
389
- username: stored.username,
390
- password: secret,
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
- stored.lastUsedAt = new Date().toISOString();
396
- stored.lastStudentNumber = selectedStudent?.studentNumber ?? null;
397
- stored.lastStudentName = selectedStudent?.name ?? null;
398
- config.lastProfileId = stored.id;
399
- await saveConfig(config);
400
- return {
401
- baseUrl: stored.tenantUrl,
402
- username: stored.username,
403
- password: secret,
404
- studentNumber: selectedStudent?.studentNumber ?? undefined,
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
- const stored = await saveLogin(config, {
430
- tenantUrl: tenant.url,
431
- tenantName: tenant.name,
432
- username,
433
- password: passwordValue,
434
- totpSecret: mfa.typedSecret,
435
- students,
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
- username,
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, baseProfile, onMfa) {
609
+ async function chooseStudentFromProfile(stored, login, onMfa) {
523
610
  let students = [];
524
- const fresh = await new WilmaAccess({ profile: baseProfile, mfa: onMfa }).students().catch((err) => {
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((s) => ({ studentNumber: s.studentNumber, name: s.name }));
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
- adoptSession({ baseUrl: normalizeTenantUrl(tenant.url), username, password }, client);
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
- if (!logins.length) {
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: logins.map((stored) => ({
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
- number: i + 1,
706
- wilma: p.tenantName ?? p.tenantUrl,
707
- url: p.tenantUrl,
708
- username: p.username,
709
- children: (p.students ?? []).map((st) => st.name),
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
- function createInteractiveMfa(saveProfile) {
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?.totpSecretObfuscated
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
- state.profile.totpSecretObfuscated = obfuscateSecret(secretInput);
899
- await saveProfile();
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().catch((err) => {
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
  }
@@ -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. The parent picks their school's
12
- * Wilma, logs in, and the verified login is saved to the local config file —
13
- * the password never passes through an agent's chat. Families with children
14
- * on different Wilmas can add another login on the same page; it closes when
15
- * the parent presses Done or after 15 idle minutes.
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
- /** The login page. The login API answers {status: "ok", students, accounts}. */
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;