@wilm-ai/wilma-cli 2.4.0 → 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 +19 -2
- package/dist/access.d.ts +23 -2
- package/dist/access.js +66 -12
- package/dist/agent-data.d.ts +16 -7
- package/dist/agent-data.js +66 -40
- package/dist/cli-args.js +11 -3
- package/dist/cli-help.js +6 -1
- package/dist/config.d.ts +25 -2
- package/dist/config.js +22 -0
- package/dist/credentials.d.ts +49 -2
- package/dist/credentials.js +199 -64
- package/dist/index.js +180 -69
- package/dist/login-server.d.ts +2 -0
- package/dist/login-server.js +31 -19
- package/dist/mcp.js +24 -1
- 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/README.md
CHANGED
|
@@ -14,7 +14,7 @@ pnpm add -g @wilm-ai/wilma-cli
|
|
|
14
14
|
```bash
|
|
15
15
|
wilma login
|
|
16
16
|
```
|
|
17
|
-
Opens a one-time login page in your browser: pick your school's Wilma and log in. The login is saved to `~/.config/wilmai/config.json
|
|
17
|
+
Opens a one-time login page in your browser: pick your school's Wilma and log in. The login is saved to `~/.config/wilmai/config.json` (the password can go to the keychain instead: see Config).
|
|
18
18
|
|
|
19
19
|
Without a browser: `wilma login --tenant <url|city> --username <name>` with the password in `WILMA_PASSWORD` or piped via `--password-stdin`. Or set `WILMA_TENANT`, `WILMA_USERNAME`, `WILMA_PASSWORD` (and `WILMA_TOTP_SECRET`) and skip the saved login.
|
|
20
20
|
|
|
@@ -79,6 +79,7 @@ wilma find-school <city or school>
|
|
|
79
79
|
## Other
|
|
80
80
|
```bash
|
|
81
81
|
wilma update # when a newer version is out, a command says so on stderr, at most once a day (WILMAI_NO_UPDATE_CHECK=1 turns it off)
|
|
82
|
+
wilma config secrets # where saved passwords are kept; `wilma config secrets auto` moves them to the keychain
|
|
82
83
|
wilma config clear # deletes every saved login and session
|
|
83
84
|
wilma home # where to download WilmAI Home, the Wilma app for Mac and Windows
|
|
84
85
|
wilma --version
|
|
@@ -95,6 +96,22 @@ If your Wilma account has MFA/TOTP enabled:
|
|
|
95
96
|
## Config
|
|
96
97
|
Logins are saved in `~/.config/wilmai/config.json` (or `$XDG_CONFIG_HOME/wilmai/config.json`; override with `WILMAI_CONFIG_PATH`) and the current Wilma sessions in `wilmai-sessions.json` next to it, both readable only by you. `wilma logout` ends the sessions and deletes them, keeping the logins; `wilma config clear` deletes both.
|
|
97
98
|
|
|
99
|
+
### Passwords in the keychain (optional)
|
|
100
|
+
By default the password and authenticator key are in that file too, scrambled (not encrypted), like aws, npm or kubectl keep theirs. To keep them in the operating system's keychain instead:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
wilma config secrets auto # the keychain where there is one, the file where there isn't
|
|
104
|
+
wilma config secrets keychain # the keychain only; saving fails where there is none
|
|
105
|
+
wilma config secrets file # back to the file
|
|
106
|
+
wilma config secrets # where each login's password is now
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The file stays the default, and stays available: on a server or anything unattended (cron, CI, an agent such as OpenClaw) there's no logged-in user to unlock a keychain, so keep the file there (`auto` does that by itself). Changing the setting moves the saved logins without logging in again. The keychain is the macOS Keychain (through `/usr/bin/security`, so no prompts after a Node.js upgrade), Windows Credential Manager, or a Linux desktop's Secret Service (headless Linux has none: `auto` keeps the file there). `WILMAI_SECRET_STORE=file|auto|keychain` decides where new logins go for one process, for tools that run the CLI; it never moves saved logins.
|
|
110
|
+
|
|
111
|
+
What stays in files: the rest of the login (Wilma, username, children) and the current Wilma sessions, which give the same access as the password while they last (Wilma ends them after a few hours). The keychain keeps the password out of backups, synced dotfiles and anything that reads files; it doesn't protect against a program running as you, which can ask the keychain too. On Windows, saved credentials roam with a roaming profile. Older copies of the config (backups, synced dotfiles) still hold the password; changing the Wilma password makes them useless. A keychain entry left behind by a config deleted by hand is named `ai.wilm.wilma-cli` in Keychain Access or Credential Manager.
|
|
112
|
+
|
|
113
|
+
Errors: `secret_missing` (the entry is gone: `wilma login`), `secret_refused` (the keychain is locked or said no), `secret_unavailable` (no keychain this copy can use), all with exit code 4. The Linux binaries of the optional `@napi-rs/keyring` dependency statically link libdbus (`AFL-2.1 OR GPL-2.0-or-later`).
|
|
114
|
+
|
|
98
115
|
## Troubleshooting: TLS errors on a managed laptop
|
|
99
116
|
|
|
100
117
|
A TLS certificate error means the network is intercepting TLS and re-signing with a
|
|
@@ -125,6 +142,6 @@ In JSON, transport failures have the code `network`, with the underlying `cause`
|
|
|
125
142
|
```
|
|
126
143
|
|
|
127
144
|
## Notes
|
|
128
|
-
- Credentials and TOTP secrets are stored with lightweight obfuscation (not encryption) in a file only your user can read.
|
|
145
|
+
- Credentials and TOTP secrets are stored with lightweight obfuscation (not encryption) in a file only your user can read, or in the OS keychain with `wilma config secrets auto` (see Config).
|
|
129
146
|
- Bulletin links are fetched only from public websites (never local or private network addresses), without your Wilma login.
|
|
130
147
|
- Commands cover every child; `--student <number|name>` narrows to one.
|
package/dist/access.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import type { StudentInfo, WilmaClient } from "@wilm-ai/wilma-client";
|
|
1
2
|
import { WilmaAccess } from "./agent-data.js";
|
|
2
3
|
import { type StoredProfile } from "./config.js";
|
|
3
|
-
import { type ActiveAccount } from "./credentials.js";
|
|
4
|
+
import { type ActiveAccount, type StoreChoice } from "./credentials.js";
|
|
4
5
|
/**
|
|
5
6
|
* Wilma access for the saved (or environment) logins — the same for CLI
|
|
6
7
|
* commands and the local MCP server. Refreshed child lists are written back
|
|
@@ -25,11 +26,14 @@ export declare function logoutAccounts(accounts: ActiveAccount[]): Promise<{
|
|
|
25
26
|
}[];
|
|
26
27
|
hint: string;
|
|
27
28
|
}>;
|
|
28
|
-
export type RemovedSession = {
|
|
29
|
+
export type RemovedSession = ({
|
|
29
30
|
session: "ended" | "none";
|
|
30
31
|
} | {
|
|
31
32
|
session: "not_ended";
|
|
32
33
|
message: string;
|
|
34
|
+
}) & {
|
|
35
|
+
/** Its keychain entry couldn't be deleted (it stays recorded, so `wilma config clear` can try again). */
|
|
36
|
+
keychainEntryLeft?: string;
|
|
33
37
|
};
|
|
34
38
|
/**
|
|
35
39
|
* Remove a saved login (`wilma accounts remove`, the login page's Remove) and
|
|
@@ -42,3 +46,20 @@ export declare function removeLogin(id: string): Promise<{
|
|
|
42
46
|
} & RemovedSession>;
|
|
43
47
|
/** Which of a login's children aren't read (unticked on the login page); only children the login lists count. */
|
|
44
48
|
export declare function setHiddenChildren(id: string, hidden: string[]): Promise<void>;
|
|
49
|
+
/**
|
|
50
|
+
* After Wilma accepted a login: save it where `choice` (chooseSecretStore,
|
|
51
|
+
* decided before contacting Wilma) says, and only then keep the verified
|
|
52
|
+
* session, under the saved login's keys, so the next command or tool call
|
|
53
|
+
* needs no second login (or code). A login that couldn't be saved leaves no
|
|
54
|
+
* session behind.
|
|
55
|
+
*/
|
|
56
|
+
export declare function saveVerifiedLogin(input: {
|
|
57
|
+
tenantUrl: string;
|
|
58
|
+
tenantName?: string | null;
|
|
59
|
+
username: string;
|
|
60
|
+
password: string;
|
|
61
|
+
totpSecret?: string | null;
|
|
62
|
+
students: StudentInfo[];
|
|
63
|
+
}, client: WilmaClient, choice: StoreChoice): Promise<StoredProfile & {
|
|
64
|
+
note?: string;
|
|
65
|
+
}>;
|
package/dist/access.js
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
|
-
import { WilmaAccess, WilmaAiError, forgetSession, logoutSession } from "./agent-data.js";
|
|
2
|
-
import {
|
|
3
|
-
import { mfaCallbackFor } from "./credentials.js";
|
|
1
|
+
import { WilmaAccess, WilmaAiError, adoptSession, forgetSession, logoutSession } from "./agent-data.js";
|
|
2
|
+
import { editConfig, loadConfig, saveConfig, savedStudent } from "./config.js";
|
|
3
|
+
import { accountFor, mfaCallbackFor, saveLogin } from "./credentials.js";
|
|
4
|
+
import { deleteLoginSecrets } from "./secret-store.js";
|
|
5
|
+
/**
|
|
6
|
+
* Codes from the saved authenticator key. A keychain login's key comes from
|
|
7
|
+
* the same single read as its password (no second keychain access), and one
|
|
8
|
+
* code generator serves the whole process, so a rejected code waits for the
|
|
9
|
+
* next one instead of repeating it.
|
|
10
|
+
*/
|
|
11
|
+
function mfaFor(account) {
|
|
12
|
+
if (!account.secrets)
|
|
13
|
+
return mfaCallbackFor(account.totpSecret);
|
|
14
|
+
if (!account.hasTotp)
|
|
15
|
+
return undefined;
|
|
16
|
+
const read = account.secrets;
|
|
17
|
+
let generator = null;
|
|
18
|
+
return async (formkey) => {
|
|
19
|
+
const pending = (generator ??= read().then((secrets) => mfaCallbackFor(secrets.totpSecret)));
|
|
20
|
+
pending.catch(() => {
|
|
21
|
+
if (generator === pending)
|
|
22
|
+
generator = null;
|
|
23
|
+
});
|
|
24
|
+
const callback = await pending;
|
|
25
|
+
if (!callback)
|
|
26
|
+
throw new WilmaAiError("mfa_required", "Wilma asks for a two-step verification code, and no authenticator key is saved for this login. Run `wilma login`.");
|
|
27
|
+
return callback(formkey);
|
|
28
|
+
};
|
|
29
|
+
}
|
|
4
30
|
/**
|
|
5
31
|
* Wilma access for the saved (or environment) logins — the same for CLI
|
|
6
32
|
* commands and the local MCP server. Refreshed child lists are written back
|
|
@@ -10,8 +36,10 @@ import { mfaCallbackFor } from "./credentials.js";
|
|
|
10
36
|
export function createAccess(accounts, opts = {}) {
|
|
11
37
|
return new WilmaAccess(accounts.map((account, index) => ({
|
|
12
38
|
profile: { ...account.profile, debug: opts.debug ?? account.profile.debug },
|
|
39
|
+
password: account.secrets ? async () => (await account.secrets()).password : undefined,
|
|
40
|
+
keys: account.keys,
|
|
13
41
|
// A --totp-secret given on the command line belongs to the first (active) login.
|
|
14
|
-
mfa:
|
|
42
|
+
mfa: index === 0 && opts.totpSecret ? mfaCallbackFor(opts.totpSecret) : mfaFor(account),
|
|
15
43
|
label: account.stored?.tenantName ?? account.profile.baseUrl,
|
|
16
44
|
knownStudents: account.stored?.students,
|
|
17
45
|
hidden: account.stored?.hidden,
|
|
@@ -38,7 +66,7 @@ export function createAccess(accounts, opts = {}) {
|
|
|
38
66
|
* (different Wilmas don't affect each other), then throws the first failure.
|
|
39
67
|
*/
|
|
40
68
|
export async function logoutAccounts(accounts) {
|
|
41
|
-
const results = await Promise.allSettled(accounts.map((account) => logoutSession(account.profile)));
|
|
69
|
+
const results = await Promise.allSettled(accounts.map((account) => logoutSession(account.profile, account.keys)));
|
|
42
70
|
const failed = results.find((result) => result.status === "rejected");
|
|
43
71
|
if (failed)
|
|
44
72
|
throw failed.reason;
|
|
@@ -69,19 +97,33 @@ export async function removeLogin(id) {
|
|
|
69
97
|
config.profiles = config.profiles.filter((p) => p.id !== id);
|
|
70
98
|
if (config.lastProfileId === id)
|
|
71
99
|
config.lastProfileId = config.profiles[0]?.id ?? null;
|
|
100
|
+
const entry = removed.secretId ?? config.keychainLogins?.[id];
|
|
72
101
|
await saveConfig(config);
|
|
73
|
-
|
|
102
|
+
// The session first (it needs no password), then the keychain entry; its record goes only once it's deleted.
|
|
103
|
+
const session = await endSession(removed);
|
|
104
|
+
if (!entry)
|
|
105
|
+
return { removed, ...session };
|
|
106
|
+
try {
|
|
107
|
+
await deleteLoginSecrets(entry);
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
return { removed, ...session, keychainEntryLeft: err instanceof Error ? err.message : String(err) };
|
|
111
|
+
}
|
|
112
|
+
await editConfig((fresh) => {
|
|
113
|
+
if (fresh.keychainLogins?.[id] !== entry)
|
|
114
|
+
return false;
|
|
115
|
+
const { [id]: _gone, ...rest } = fresh.keychainLogins;
|
|
116
|
+
fresh.keychainLogins = rest;
|
|
117
|
+
});
|
|
118
|
+
return { removed, ...session };
|
|
74
119
|
}
|
|
75
120
|
async function endSession(removed) {
|
|
76
|
-
const
|
|
77
|
-
if (!password)
|
|
78
|
-
return { session: "none" };
|
|
79
|
-
const profile = { baseUrl: removed.tenantUrl, username: removed.username, password };
|
|
121
|
+
const { profile, keys } = accountFor(removed);
|
|
80
122
|
try {
|
|
81
|
-
return { session: (await logoutSession(profile)) ? "ended" : "none" };
|
|
123
|
+
return { session: (await logoutSession(profile, keys)) ? "ended" : "none" };
|
|
82
124
|
}
|
|
83
125
|
catch (err) {
|
|
84
|
-
await forgetSession(profile);
|
|
126
|
+
await forgetSession(profile, keys);
|
|
85
127
|
return { session: "not_ended", message: err instanceof Error ? err.message : String(err) };
|
|
86
128
|
}
|
|
87
129
|
}
|
|
@@ -99,3 +141,15 @@ export async function setHiddenChildren(id, hidden) {
|
|
|
99
141
|
delete target.hidden;
|
|
100
142
|
await saveConfig(config);
|
|
101
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* After Wilma accepted a login: save it where `choice` (chooseSecretStore,
|
|
146
|
+
* decided before contacting Wilma) says, and only then keep the verified
|
|
147
|
+
* session, under the saved login's keys, so the next command or tool call
|
|
148
|
+
* needs no second login (or code). A login that couldn't be saved leaves no
|
|
149
|
+
* session behind.
|
|
150
|
+
*/
|
|
151
|
+
export async function saveVerifiedLogin(input, client, choice) {
|
|
152
|
+
const stored = await saveLogin(await loadConfig(), input, choice);
|
|
153
|
+
adoptSession(accountFor(stored).keys, client);
|
|
154
|
+
return stored;
|
|
155
|
+
}
|
package/dist/agent-data.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { WilmaClient, type LessonNote, type Message, type MessageFolder, type MfaCallback, type NewsItem, type NewsResource, type OverviewData, type ScheduleLesson, type StudentInfo, type WilmaProfile } from "@wilm-ai/wilma-client";
|
|
2
2
|
import type { SessionStore } from "./session-store.js";
|
|
3
|
+
import { type SessionKeys } from "./session-keys.js";
|
|
3
4
|
/**
|
|
4
5
|
* An error with a stable code for agents and scripts (the CLI's JSON errors
|
|
5
6
|
* and exit codes use it): e.g. "unknown_student", "invalid_argument".
|
|
@@ -46,7 +47,12 @@ export type StudentRef = {
|
|
|
46
47
|
};
|
|
47
48
|
/** One Wilma login. A family can have several (children on different Wilmas). */
|
|
48
49
|
export interface AccessAccount {
|
|
50
|
+
/** For a keychain login the password is empty here and `password` gives it. */
|
|
49
51
|
profile: WilmaProfile;
|
|
52
|
+
/** The password when Wilma needs a new login (a keychain read), never while a saved session works. */
|
|
53
|
+
password?: () => Promise<string>;
|
|
54
|
+
/** Where its session is saved and pooled; by default today's key (address, username and password). */
|
|
55
|
+
keys?: SessionKeys;
|
|
50
56
|
mfa?: MfaCallback;
|
|
51
57
|
/** The Wilma's name, shown to tell children apart when there are several logins. */
|
|
52
58
|
label?: string | null;
|
|
@@ -84,26 +90,29 @@ export declare function useSessionStore(store: SessionStore | undefined): void;
|
|
|
84
90
|
/**
|
|
85
91
|
* Keep a session that is already logged in — e.g. the one the login page just
|
|
86
92
|
* verified — so the first tool call needs no second login (and, with two-step
|
|
87
|
-
* verification, no second code).
|
|
93
|
+
* verification, no second code). `keys` are the saved login's (v2 for a
|
|
94
|
+
* keychain login); without them, today's keys from the profile.
|
|
88
95
|
*/
|
|
89
|
-
export declare function adoptSession(
|
|
96
|
+
export declare function adoptSession(profileOrKeys: WilmaProfile | SessionKeys, client: WilmaClient): void;
|
|
90
97
|
/**
|
|
91
98
|
* Finish saving sessions (call before a short-lived process exits). Only
|
|
92
99
|
* logins made here are saved, never an older session over a newer one
|
|
93
100
|
* another process saved meanwhile.
|
|
94
101
|
*/
|
|
95
102
|
export declare function flushSessions(): Promise<void>;
|
|
103
|
+
/** Drop this process's client for a login (after its secrets moved, so the next use starts from its new keys). */
|
|
104
|
+
export declare function dropPooledSession(keys: SessionKeys): void;
|
|
96
105
|
/**
|
|
97
106
|
* End WilmAI's Wilma session for one login (Wilma's own "Log out") and forget
|
|
98
107
|
* it, in this process and in the session store, so nothing continues it. The
|
|
99
108
|
* login itself stays saved; the next command or tool call logs in again.
|
|
100
|
-
* Never logs in. Returns true when a live session
|
|
101
|
-
* was none. If Wilma can't be reached, throws and
|
|
102
|
-
* logging out can be tried again.
|
|
109
|
+
* Never logs in (so it needs no password). Returns true when a live session
|
|
110
|
+
* was ended, false when there was none. If Wilma can't be reached, throws and
|
|
111
|
+
* keeps the saved session, so logging out can be tried again.
|
|
103
112
|
*/
|
|
104
|
-
export declare function logoutSession(profile: WilmaProfile): Promise<boolean>;
|
|
113
|
+
export declare function logoutSession(profile: WilmaProfile, keys?: SessionKeys): Promise<boolean>;
|
|
105
114
|
/** Forget one login's session without ending it in Wilma (when the login itself is removed). */
|
|
106
|
-
export declare function forgetSession(profile: WilmaProfile): Promise<void>;
|
|
115
|
+
export declare function forgetSession(profile: WilmaProfile, keys?: SessionKeys): Promise<void>;
|
|
107
116
|
/**
|
|
108
117
|
* Read-only Wilma access for agent tools (MCP). Covers every Wilma login the
|
|
109
118
|
* family has saved; every method defaults to all children, and a student
|
package/dist/agent-data.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { NetworkError, WilmaClient, addExamTimes, } from "@wilm-ai/wilma-client";
|
|
2
|
-
import { createHash } from "node:crypto";
|
|
3
2
|
import { fileNameFromResponse, readResponseCapped } from "./downloads.js";
|
|
3
|
+
import { fileLoginKeys } from "./session-keys.js";
|
|
4
4
|
/**
|
|
5
5
|
* An error with a stable code for agents and scripts (the CLI's JSON errors
|
|
6
6
|
* and exit codes use it): e.g. "unknown_student", "invalid_argument".
|
|
@@ -304,39 +304,59 @@ export function useSessionStore(store) {
|
|
|
304
304
|
* in only when that one has ended too. Each login ends the others, so this
|
|
305
305
|
* keeps WilmAI's programs from logging each other out.
|
|
306
306
|
*/
|
|
307
|
-
function rememberSession(
|
|
307
|
+
function rememberSession(keys, client, opts) {
|
|
308
308
|
const store = sessionStore;
|
|
309
309
|
if (!store)
|
|
310
310
|
return;
|
|
311
311
|
const save = (state) => {
|
|
312
|
-
const saving = store
|
|
312
|
+
const saving = saveUnder(store, keys, state).catch(() => { });
|
|
313
313
|
pendingSaves.add(saving);
|
|
314
314
|
void saving.finally(() => pendingSaves.delete(saving));
|
|
315
315
|
return saving;
|
|
316
316
|
};
|
|
317
|
-
client.useSharedSession({ load: () => store
|
|
317
|
+
client.useSharedSession({ load: () => loadNewest(store, keys), save });
|
|
318
318
|
// A resumed session is already saved (and another process may have saved a newer one since).
|
|
319
319
|
if (opts.fresh)
|
|
320
320
|
void save(client.exportSession());
|
|
321
321
|
}
|
|
322
|
+
/** A login's session under any of its labels, the newest (a store without that falls back to the first found). */
|
|
323
|
+
async function loadNewest(store, keys) {
|
|
324
|
+
if (store.loadNewest)
|
|
325
|
+
return store.loadNewest(keys);
|
|
326
|
+
for (const key of keys) {
|
|
327
|
+
const state = await store.load(key);
|
|
328
|
+
if (state)
|
|
329
|
+
return state;
|
|
330
|
+
}
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
async function saveUnder(store, keys, state) {
|
|
334
|
+
if (store.saveAll)
|
|
335
|
+
return store.saveAll(keys, state);
|
|
336
|
+
for (const key of keys)
|
|
337
|
+
await store.save(key, state);
|
|
338
|
+
}
|
|
339
|
+
async function removeUnder(store, keys) {
|
|
340
|
+
if (store.removeAll)
|
|
341
|
+
return store.removeAll(keys);
|
|
342
|
+
for (const key of keys)
|
|
343
|
+
await store.remove(key);
|
|
344
|
+
}
|
|
322
345
|
const pendingSaves = new Set();
|
|
323
|
-
/**
|
|
324
|
-
function
|
|
325
|
-
|
|
326
|
-
// The password is part of the key, so a changed password starts a fresh session.
|
|
327
|
-
return createHash("sha256")
|
|
328
|
-
.update(`${baseUrl.replace(/\/$/, "")}\n${username.toLowerCase()}\n${password}`)
|
|
329
|
-
.digest("hex");
|
|
346
|
+
/** The keys of a login known by its password (file and environment logins): both labels, v1 in the pool. */
|
|
347
|
+
function defaultKeys(profile) {
|
|
348
|
+
return fileLoginKeys(profile.baseUrl, profile.username, profile.password);
|
|
330
349
|
}
|
|
331
350
|
/**
|
|
332
351
|
* Keep a session that is already logged in — e.g. the one the login page just
|
|
333
352
|
* verified — so the first tool call needs no second login (and, with two-step
|
|
334
|
-
* verification, no second code).
|
|
353
|
+
* verification, no second code). `keys` are the saved login's (v2 for a
|
|
354
|
+
* keychain login); without them, today's keys from the profile.
|
|
335
355
|
*/
|
|
336
|
-
export function adoptSession(
|
|
337
|
-
const
|
|
338
|
-
pool.set(
|
|
339
|
-
rememberSession(
|
|
356
|
+
export function adoptSession(profileOrKeys, client) {
|
|
357
|
+
const keys = "sessionKeys" in profileOrKeys ? profileOrKeys : defaultKeys(profileOrKeys);
|
|
358
|
+
pool.set(keys.poolKey, { client: Promise.resolve(client), lastUsed: Date.now() });
|
|
359
|
+
rememberSession(keys.sessionKeys, client, { fresh: true });
|
|
340
360
|
}
|
|
341
361
|
/**
|
|
342
362
|
* Finish saving sessions (call before a short-lived process exits). Only
|
|
@@ -348,38 +368,42 @@ export async function flushSessions() {
|
|
|
348
368
|
await entry.client.catch(() => null);
|
|
349
369
|
await Promise.all([...pendingSaves]);
|
|
350
370
|
}
|
|
371
|
+
/** Drop this process's client for a login (after its secrets moved, so the next use starts from its new keys). */
|
|
372
|
+
export function dropPooledSession(keys) {
|
|
373
|
+
pool.delete(keys.poolKey);
|
|
374
|
+
}
|
|
351
375
|
/**
|
|
352
376
|
* End WilmAI's Wilma session for one login (Wilma's own "Log out") and forget
|
|
353
377
|
* it, in this process and in the session store, so nothing continues it. The
|
|
354
378
|
* login itself stays saved; the next command or tool call logs in again.
|
|
355
|
-
* Never logs in. Returns true when a live session
|
|
356
|
-
* was none. If Wilma can't be reached, throws and
|
|
357
|
-
* logging out can be tried again.
|
|
379
|
+
* Never logs in (so it needs no password). Returns true when a live session
|
|
380
|
+
* was ended, false when there was none. If Wilma can't be reached, throws and
|
|
381
|
+
* keeps the saved session, so logging out can be tried again.
|
|
358
382
|
*/
|
|
359
|
-
export async function logoutSession(profile) {
|
|
360
|
-
const
|
|
361
|
-
const entry = pool.get(key);
|
|
383
|
+
export async function logoutSession(profile, keys = defaultKeys(profile)) {
|
|
384
|
+
const entry = pool.get(keys.poolKey);
|
|
362
385
|
// The session in use here (a login in progress finishes first) and the saved
|
|
363
386
|
// one, which another process may have replaced since. At most one is live.
|
|
364
387
|
const pooled = entry ? await entry.client.catch(() => null) : null;
|
|
365
388
|
const pooledState = pooled?.exportSession();
|
|
366
|
-
const saved = sessionStore ? await sessionStore.
|
|
389
|
+
const saved = sessionStore ? await loadNewest(sessionStore, keys.sessionKeys).catch(() => null) : null;
|
|
367
390
|
let ended = pooled ? await pooled.logout() : false;
|
|
368
|
-
if (pool.get(
|
|
369
|
-
pool.delete(
|
|
391
|
+
if (pool.get(keys.poolKey) === entry)
|
|
392
|
+
pool.delete(keys.poolKey);
|
|
370
393
|
if (saved && saved !== pooledState) {
|
|
371
394
|
const resumed = WilmaClient.resume({ ...profile, studentNumber: null }, saved);
|
|
372
395
|
if (resumed && (await resumed.logout()))
|
|
373
396
|
ended = true;
|
|
374
397
|
}
|
|
375
|
-
|
|
398
|
+
if (sessionStore)
|
|
399
|
+
await removeUnder(sessionStore, keys.sessionKeys);
|
|
376
400
|
return ended;
|
|
377
401
|
}
|
|
378
402
|
/** Forget one login's session without ending it in Wilma (when the login itself is removed). */
|
|
379
|
-
export async function forgetSession(profile) {
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
403
|
+
export async function forgetSession(profile, keys = defaultKeys(profile)) {
|
|
404
|
+
pool.delete(keys.poolKey);
|
|
405
|
+
if (sessionStore)
|
|
406
|
+
await removeUnder(sessionStore, keys.sessionKeys);
|
|
383
407
|
}
|
|
384
408
|
function pooledSession(account) {
|
|
385
409
|
const now = Date.now();
|
|
@@ -387,24 +411,26 @@ function pooledSession(account) {
|
|
|
387
411
|
if (now - entry.lastUsed > POOL_IDLE_MS)
|
|
388
412
|
pool.delete(key);
|
|
389
413
|
}
|
|
390
|
-
const
|
|
391
|
-
let entry = pool.get(
|
|
414
|
+
const keys = account.keys ?? defaultKeys(account.profile);
|
|
415
|
+
let entry = pool.get(keys.poolKey);
|
|
392
416
|
if (!entry) {
|
|
393
417
|
const profile = { ...account.profile, studentNumber: null };
|
|
418
|
+
const lazy = account.password;
|
|
394
419
|
const client = (async () => {
|
|
395
|
-
// A saved session first; it logs in again by itself if Wilma has ended it
|
|
396
|
-
|
|
397
|
-
const
|
|
398
|
-
const
|
|
399
|
-
|
|
420
|
+
// A saved session first; it logs in again by itself if Wilma has ended it
|
|
421
|
+
// (reading a keychain login's password only then).
|
|
422
|
+
const saved = sessionStore ? await loadNewest(sessionStore, keys.sessionKeys).catch(() => null) : null;
|
|
423
|
+
const resumed = saved ? WilmaClient.resume({ ...profile, password: lazy ?? profile.password }, saved, account.mfa) : null;
|
|
424
|
+
const client = resumed ?? (await WilmaClient.login({ ...profile, password: lazy ? await lazy() : profile.password }, account.mfa));
|
|
425
|
+
rememberSession(keys.sessionKeys, client, { fresh: !resumed });
|
|
400
426
|
return client;
|
|
401
427
|
})();
|
|
402
428
|
const created = { client, lastUsed: now };
|
|
403
429
|
entry = created;
|
|
404
|
-
pool.set(
|
|
430
|
+
pool.set(keys.poolKey, created);
|
|
405
431
|
client.catch(() => {
|
|
406
|
-
if (pool.get(
|
|
407
|
-
pool.delete(
|
|
432
|
+
if (pool.get(keys.poolKey) === created)
|
|
433
|
+
pool.delete(keys.poolKey);
|
|
408
434
|
});
|
|
409
435
|
}
|
|
410
436
|
entry.lastUsed = now;
|
package/dist/cli-args.js
CHANGED
|
@@ -230,9 +230,17 @@ export function parseCliArgs(argv) {
|
|
|
230
230
|
case "logout":
|
|
231
231
|
return parsed(null, args);
|
|
232
232
|
case "config":
|
|
233
|
-
if (args[0]
|
|
234
|
-
|
|
235
|
-
|
|
233
|
+
if (args[0] === "clear" && args.length === 1)
|
|
234
|
+
return parsed("clear");
|
|
235
|
+
if (args[0] === "secrets") {
|
|
236
|
+
const [, setting, ...extra] = args;
|
|
237
|
+
noMore(extra);
|
|
238
|
+
if (setting !== undefined && !["file", "auto", "keychain"].includes(setting)) {
|
|
239
|
+
throw usage('Expected "wilma config secrets [file|auto|keychain]".');
|
|
240
|
+
}
|
|
241
|
+
return parsed("secrets", setting === undefined ? [] : [setting]);
|
|
242
|
+
}
|
|
243
|
+
throw usage('Expected "wilma config clear" or "wilma config secrets [file|auto|keychain]".');
|
|
236
244
|
case "find-school":
|
|
237
245
|
if (!args.length)
|
|
238
246
|
throw usage('Expected "wilma find-school <city or school>".');
|
package/dist/cli-help.js
CHANGED
|
@@ -104,7 +104,12 @@ const COMMAND_HELP = {
|
|
|
104
104
|
examples: ["npx -y @wilm-ai/wilma-cli@2 mcp"],
|
|
105
105
|
},
|
|
106
106
|
update: { usage: ["wilma update"], about: "Update the CLI with npm.", examples: ["wilma update"] },
|
|
107
|
-
config: {
|
|
107
|
+
config: {
|
|
108
|
+
usage: ["wilma config clear", "wilma config secrets [file|auto|keychain]"],
|
|
109
|
+
about: "`clear` deletes every saved login and session on this computer (and their keychain entries). `secrets` shows where saved passwords are kept, or changes it: `file` (the default, WilmAI's own file readable only by you), `keychain` (the macOS Keychain, Windows Credential Manager or a Linux desktop's Secret Service; refuses to save anywhere else) or `auto` (the keychain where there is one, the file where there isn't). Changing it moves the saved logins without logging in again. WILMAI_SECRET_STORE sets where new logins go for one process.",
|
|
110
|
+
examples: ["wilma config secrets", "wilma config secrets auto", "wilma config secrets file", "wilma config clear"],
|
|
111
|
+
output: "secrets: { setting, envOverride, keychain: { kind, usable, problem }, logins: [{ wilma, username, store: \"file\" | \"keychain\", hasTotp }], moved? }",
|
|
112
|
+
},
|
|
108
113
|
};
|
|
109
114
|
const OVERVIEW = [
|
|
110
115
|
["summary", "daily briefing for every child (start here)"],
|
package/dist/config.d.ts
CHANGED
|
@@ -3,8 +3,19 @@ export interface StoredProfile {
|
|
|
3
3
|
tenantUrl: string;
|
|
4
4
|
tenantName?: string | null;
|
|
5
5
|
username: string;
|
|
6
|
-
|
|
6
|
+
/** Scrambled password: a login kept in this file (absent for a keychain login). */
|
|
7
|
+
passwordObfuscated?: string;
|
|
7
8
|
totpSecretObfuscated?: string | null;
|
|
9
|
+
/** "keychain": the password and authenticator key are in the OS keychain, entry `secretId`. Absent: in this file. */
|
|
10
|
+
secrets?: "keychain" | string;
|
|
11
|
+
/** The login's keychain entry (random; also kept in CliConfig.keychainLogins). */
|
|
12
|
+
secretId?: string;
|
|
13
|
+
/** Random, new whenever the login's secrets change; not secret. Part of a keychain login's pool key. */
|
|
14
|
+
credentialsStamp?: string;
|
|
15
|
+
/** Whether an authenticator key is saved (so listings don't read the keychain). */
|
|
16
|
+
hasTotp?: boolean;
|
|
17
|
+
/** When moving the login to the keychain last failed (background moves wait a day). */
|
|
18
|
+
moveFailedAt?: string;
|
|
8
19
|
/** The children the login lists; `role` says whose login it is for each (a guardian's, or the student's own). */
|
|
9
20
|
students?: SavedStudent[];
|
|
10
21
|
/** Student numbers of children unticked on the login page (a profile Wilma lists twice): not read. */
|
|
@@ -23,10 +34,22 @@ export declare function savedStudent(s: SavedStudent): SavedStudent;
|
|
|
23
34
|
export interface CliConfig {
|
|
24
35
|
profiles: StoredProfile[];
|
|
25
36
|
lastProfileId?: string | null;
|
|
37
|
+
/** Where new secrets go: "file" (default), "auto" or "keychain" (`wilma config secrets`). */
|
|
38
|
+
secretStore?: string;
|
|
39
|
+
/** Every keychain entry this config made, by login id, written before the entry itself. */
|
|
40
|
+
keychainLogins?: Record<string, string>;
|
|
26
41
|
}
|
|
27
42
|
export declare function getConfigPath(): string;
|
|
28
43
|
export declare function loadConfig(): Promise<CliConfig>;
|
|
29
44
|
export declare function saveConfig(config: CliConfig): Promise<void>;
|
|
45
|
+
/**
|
|
46
|
+
* Change the saved config from a fresh read, so a save never overwrites what
|
|
47
|
+
* another process wrote meanwhile (moves, logins, refreshed child lists).
|
|
48
|
+
* `edit` returns false to leave the file as it is.
|
|
49
|
+
*/
|
|
50
|
+
export declare function editConfig(edit: (config: CliConfig) => boolean | void | Promise<boolean | void>): Promise<CliConfig>;
|
|
51
|
+
/** The config as far as it can be read, or null (for `wilma config clear`, the way out of a damaged file). */
|
|
52
|
+
export declare function loadConfigLenient(): Promise<CliConfig | null>;
|
|
30
53
|
export declare function clearConfig(): Promise<void>;
|
|
31
54
|
export declare function obfuscateSecret(value: string): string;
|
|
32
|
-
export declare function revealSecret(value: string): string | null;
|
|
55
|
+
export declare function revealSecret(value: string | null | undefined): string | null;
|
package/dist/config.js
CHANGED
|
@@ -89,6 +89,26 @@ export async function saveConfig(config) {
|
|
|
89
89
|
await writeFile(tmp, JSON.stringify(config, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });
|
|
90
90
|
await rename(tmp, path);
|
|
91
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* Change the saved config from a fresh read, so a save never overwrites what
|
|
94
|
+
* another process wrote meanwhile (moves, logins, refreshed child lists).
|
|
95
|
+
* `edit` returns false to leave the file as it is.
|
|
96
|
+
*/
|
|
97
|
+
export async function editConfig(edit) {
|
|
98
|
+
const config = await loadConfig();
|
|
99
|
+
if ((await edit(config)) !== false)
|
|
100
|
+
await saveConfig(config);
|
|
101
|
+
return config;
|
|
102
|
+
}
|
|
103
|
+
/** The config as far as it can be read, or null (for `wilma config clear`, the way out of a damaged file). */
|
|
104
|
+
export async function loadConfigLenient() {
|
|
105
|
+
try {
|
|
106
|
+
return await loadConfig();
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
92
112
|
export async function clearConfig() {
|
|
93
113
|
const path = getConfigPath();
|
|
94
114
|
await rm(path, { force: true });
|
|
@@ -97,6 +117,8 @@ export function obfuscateSecret(value) {
|
|
|
97
117
|
return Buffer.from(SALT + value, "utf-8").toString("base64");
|
|
98
118
|
}
|
|
99
119
|
export function revealSecret(value) {
|
|
120
|
+
if (!value)
|
|
121
|
+
return null;
|
|
100
122
|
try {
|
|
101
123
|
const decoded = Buffer.from(value, "base64").toString("utf-8");
|
|
102
124
|
if (!decoded.startsWith(SALT)) {
|