@wilm-ai/wilma-cli 2.1.1 → 2.2.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 +9 -3
- package/dist/access.d.ts +14 -0
- package/dist/access.js +24 -1
- package/dist/agent-data.d.ts +16 -1
- package/dist/agent-data.js +64 -16
- package/dist/cli-args.js +3 -1
- package/dist/cli-help.js +8 -1
- package/dist/cli-output.js +2 -1
- package/dist/index.js +71 -16
- package/dist/login-server.d.ts +15 -0
- package/dist/login-server.js +135 -33
- package/dist/mcp-tools.d.ts +1 -1
- package/dist/mcp-tools.js +1 -0
- package/dist/mcp.js +23 -4
- package/dist/session-store.d.ts +10 -1
- package/dist/session-store.js +32 -8
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -18,6 +18,12 @@ Opens a one-time login page in your browser: pick your school's Wilma and log in
|
|
|
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
|
|
|
21
|
+
## Log out
|
|
22
|
+
```bash
|
|
23
|
+
wilma logout # every saved login; `wilma logout 2` for one (numbers from `wilma accounts`)
|
|
24
|
+
```
|
|
25
|
+
Wilma allows one session per account: when WilmAI logs in, Wilma logs you out in your browser, and the other way round. `wilma logout` ends WilmAI's session, as Wilma's own Log out does, so you can use Wilma in the browser. The logins stay saved, and the next command logs in again. `wilma config clear` deletes the logins as well.
|
|
26
|
+
|
|
21
27
|
## Use it
|
|
22
28
|
```bash
|
|
23
29
|
wilma summary # the daily briefing for every child — start here
|
|
@@ -53,7 +59,7 @@ Running `wilma` without arguments in a terminal opens an interactive menu.
|
|
|
53
59
|
```bash
|
|
54
60
|
wilma mcp
|
|
55
61
|
```
|
|
56
|
-
Starts a stdio MCP server with read-only Wilma tools (`wilma_summary`, `wilma_schedule`, `wilma_read_news`, …) that return the same data as the commands
|
|
62
|
+
Starts a stdio MCP server with read-only Wilma tools (`wilma_summary`, `wilma_schedule`, `wilma_read_news`, …) that return the same data as the commands, plus `wilma_logout`, which ends WilmAI's Wilma session like `wilma logout`. Add it to any MCP client as `npx -y @wilm-ai/wilma-cli@2 mcp`. Setup guides for Claude, ChatGPT, Grok Bot and OpenClaw: https://wilm.ai
|
|
57
63
|
|
|
58
64
|
## Bulletin attachments
|
|
59
65
|
`wilma news <id>` lists every link in a bulletin (`resources`). Any resource can be downloaded with `wilma news <id> download <resource> [--output <directory>]` (the resource id also accepts a bare number, e.g. `1` for `resource-1`). Wilma-hosted files download through the session; external links are fetched without your Wilma login, and only from public websites. The result's `status` says what happened: `downloaded` (use the returned `path`) or `not_a_file` when the link answered with a web page (for example a sharing link that needs signing in — open it in a browser instead). `--output` defaults to the current directory; existing files are never overwritten.
|
|
@@ -62,7 +68,7 @@ Starts a stdio MCP server with read-only Wilma tools (`wilma_summary`, `wilma_sc
|
|
|
62
68
|
Children at schools on different Wilmas need one login per Wilma. Run `wilma login` again (or press "Add another Wilma" on the login page); commands then cover every child, and each child carries the `wilma` it belongs to.
|
|
63
69
|
```bash
|
|
64
70
|
wilma accounts # saved logins, numbered
|
|
65
|
-
wilma accounts remove <number> # or the Wilma name or username
|
|
71
|
+
wilma accounts remove <number> # or the Wilma name or username; also ends that login's Wilma session
|
|
66
72
|
```
|
|
67
73
|
|
|
68
74
|
## Find your Wilma
|
|
@@ -86,7 +92,7 @@ If your Wilma account has MFA/TOTP enabled:
|
|
|
86
92
|
**Without a saved login:** set `WILMA_TOTP_SECRET` along with the other `WILMA_*` variables. `--totp-secret <key>` also works, but a key on the command line ends up in shell history and is visible to other programs on the computer.
|
|
87
93
|
|
|
88
94
|
## Config
|
|
89
|
-
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 config clear` deletes both.
|
|
95
|
+
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.
|
|
90
96
|
|
|
91
97
|
## Troubleshooting: TLS errors on a managed laptop
|
|
92
98
|
|
package/dist/access.d.ts
CHANGED
|
@@ -10,3 +10,17 @@ export declare function createAccess(accounts: ActiveAccount[], opts?: {
|
|
|
10
10
|
debug?: boolean;
|
|
11
11
|
totpSecret?: string;
|
|
12
12
|
}): WilmaAccess;
|
|
13
|
+
/**
|
|
14
|
+
* End WilmAI's Wilma session for each login (see logoutSession), for `wilma
|
|
15
|
+
* logout` and the wilma_logout tool. The logins stay saved. Tries every login
|
|
16
|
+
* (different Wilmas don't affect each other), then throws the first failure.
|
|
17
|
+
*/
|
|
18
|
+
export declare function logoutAccounts(accounts: ActiveAccount[]): Promise<{
|
|
19
|
+
status: "ok";
|
|
20
|
+
logins: {
|
|
21
|
+
wilma: string;
|
|
22
|
+
username: string;
|
|
23
|
+
session: "none" | "ended";
|
|
24
|
+
}[];
|
|
25
|
+
hint: string;
|
|
26
|
+
}>;
|
package/dist/access.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { WilmaAccess } from "./agent-data.js";
|
|
1
|
+
import { WilmaAccess, logoutSession } from "./agent-data.js";
|
|
2
2
|
import { loadConfig, saveConfig } from "./config.js";
|
|
3
3
|
import { mfaCallbackFor } from "./credentials.js";
|
|
4
4
|
/**
|
|
@@ -31,3 +31,26 @@ export function createAccess(accounts, opts = {}) {
|
|
|
31
31
|
},
|
|
32
32
|
})));
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* End WilmAI's Wilma session for each login (see logoutSession), for `wilma
|
|
36
|
+
* logout` and the wilma_logout tool. The logins stay saved. Tries every login
|
|
37
|
+
* (different Wilmas don't affect each other), then throws the first failure.
|
|
38
|
+
*/
|
|
39
|
+
export async function logoutAccounts(accounts) {
|
|
40
|
+
const results = await Promise.allSettled(accounts.map((account) => logoutSession(account.profile)));
|
|
41
|
+
const failed = results.find((result) => result.status === "rejected");
|
|
42
|
+
if (failed)
|
|
43
|
+
throw failed.reason;
|
|
44
|
+
const logins = accounts.map((account, index) => ({
|
|
45
|
+
wilma: account.stored?.tenantName ?? account.profile.baseUrl,
|
|
46
|
+
username: account.profile.username,
|
|
47
|
+
session: results[index].value ? "ended" : "none",
|
|
48
|
+
}));
|
|
49
|
+
return {
|
|
50
|
+
status: "ok",
|
|
51
|
+
logins,
|
|
52
|
+
hint: logins.length
|
|
53
|
+
? "The logins stay saved. The next time WilmAI reads Wilma it logs in again, and that ends the user's Wilma session in the browser."
|
|
54
|
+
: "No saved Wilma login, so there was nothing to log out.",
|
|
55
|
+
};
|
|
56
|
+
}
|
package/dist/agent-data.d.ts
CHANGED
|
@@ -73,8 +73,23 @@ export declare function useSessionStore(store: SessionStore | undefined): void;
|
|
|
73
73
|
* verification, no second code).
|
|
74
74
|
*/
|
|
75
75
|
export declare function adoptSession(profile: WilmaProfile, client: WilmaClient): void;
|
|
76
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* Finish saving sessions (call before a short-lived process exits). Only
|
|
78
|
+
* logins made here are saved, never an older session over a newer one
|
|
79
|
+
* another process saved meanwhile.
|
|
80
|
+
*/
|
|
77
81
|
export declare function flushSessions(): Promise<void>;
|
|
82
|
+
/**
|
|
83
|
+
* End WilmAI's Wilma session for one login (Wilma's own "Log out") and forget
|
|
84
|
+
* it, in this process and in the session store, so nothing continues it. The
|
|
85
|
+
* login itself stays saved; the next command or tool call logs in again.
|
|
86
|
+
* Never logs in. Returns true when a live session was ended, false when there
|
|
87
|
+
* was none. If Wilma can't be reached, throws and keeps the saved session, so
|
|
88
|
+
* logging out can be tried again.
|
|
89
|
+
*/
|
|
90
|
+
export declare function logoutSession(profile: WilmaProfile): Promise<boolean>;
|
|
91
|
+
/** Forget one login's session without ending it in Wilma (when the login itself is removed). */
|
|
92
|
+
export declare function forgetSession(profile: WilmaProfile): Promise<void>;
|
|
78
93
|
/**
|
|
79
94
|
* Read-only Wilma access for agent tools (MCP). Covers every Wilma login the
|
|
80
95
|
* family has saved; every method defaults to all children, and a student
|
package/dist/agent-data.js
CHANGED
|
@@ -237,14 +237,29 @@ let sessionStore;
|
|
|
237
237
|
export function useSessionStore(store) {
|
|
238
238
|
sessionStore = store;
|
|
239
239
|
}
|
|
240
|
-
|
|
240
|
+
/**
|
|
241
|
+
* Save the session for the other processes, and share theirs: when Wilma ends
|
|
242
|
+
* this one, the client first continues the newest session another process
|
|
243
|
+
* saved (a CLI command, an assistant's connector, the desktop app), and logs
|
|
244
|
+
* in only when that one has ended too. Each login ends the others, so this
|
|
245
|
+
* keeps WilmAI's programs from logging each other out.
|
|
246
|
+
*/
|
|
247
|
+
function rememberSession(key, client, opts) {
|
|
241
248
|
const store = sessionStore;
|
|
242
249
|
if (!store)
|
|
243
250
|
return;
|
|
244
|
-
const save = () =>
|
|
245
|
-
|
|
246
|
-
|
|
251
|
+
const save = (state) => {
|
|
252
|
+
const saving = store.save(key, state).catch(() => { });
|
|
253
|
+
pendingSaves.add(saving);
|
|
254
|
+
void saving.finally(() => pendingSaves.delete(saving));
|
|
255
|
+
return saving;
|
|
256
|
+
};
|
|
257
|
+
client.useSharedSession({ load: () => store.load(key), save });
|
|
258
|
+
// A resumed session is already saved (and another process may have saved a newer one since).
|
|
259
|
+
if (opts.fresh)
|
|
260
|
+
void save(client.exportSession());
|
|
247
261
|
}
|
|
262
|
+
const pendingSaves = new Set();
|
|
248
263
|
/** Identifies one Wilma account and password (a changed password starts fresh). */
|
|
249
264
|
function poolKey(profile) {
|
|
250
265
|
const { baseUrl, username, password } = profile;
|
|
@@ -261,17 +276,50 @@ function poolKey(profile) {
|
|
|
261
276
|
export function adoptSession(profile, client) {
|
|
262
277
|
const key = poolKey(profile);
|
|
263
278
|
pool.set(key, { client: Promise.resolve(client), lastUsed: Date.now() });
|
|
264
|
-
rememberSession(key, client);
|
|
279
|
+
rememberSession(key, client, { fresh: true });
|
|
265
280
|
}
|
|
266
|
-
/**
|
|
281
|
+
/**
|
|
282
|
+
* Finish saving sessions (call before a short-lived process exits). Only
|
|
283
|
+
* logins made here are saved, never an older session over a newer one
|
|
284
|
+
* another process saved meanwhile.
|
|
285
|
+
*/
|
|
267
286
|
export async function flushSessions() {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
287
|
+
for (const entry of pool.values())
|
|
288
|
+
await entry.client.catch(() => null);
|
|
289
|
+
await Promise.all([...pendingSaves]);
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* End WilmAI's Wilma session for one login (Wilma's own "Log out") and forget
|
|
293
|
+
* it, in this process and in the session store, so nothing continues it. The
|
|
294
|
+
* login itself stays saved; the next command or tool call logs in again.
|
|
295
|
+
* Never logs in. Returns true when a live session was ended, false when there
|
|
296
|
+
* was none. If Wilma can't be reached, throws and keeps the saved session, so
|
|
297
|
+
* logging out can be tried again.
|
|
298
|
+
*/
|
|
299
|
+
export async function logoutSession(profile) {
|
|
300
|
+
const key = poolKey(profile);
|
|
301
|
+
const entry = pool.get(key);
|
|
302
|
+
// The session in use here (a login in progress finishes first) and the saved
|
|
303
|
+
// one, which another process may have replaced since. At most one is live.
|
|
304
|
+
const pooled = entry ? await entry.client.catch(() => null) : null;
|
|
305
|
+
const pooledState = pooled?.exportSession();
|
|
306
|
+
const saved = sessionStore ? await sessionStore.load(key).catch(() => null) : null;
|
|
307
|
+
let ended = pooled ? await pooled.logout() : false;
|
|
308
|
+
if (pool.get(key) === entry)
|
|
309
|
+
pool.delete(key);
|
|
310
|
+
if (saved && saved !== pooledState) {
|
|
311
|
+
const resumed = WilmaClient.resume({ ...profile, studentNumber: null }, saved);
|
|
312
|
+
if (resumed && (await resumed.logout()))
|
|
313
|
+
ended = true;
|
|
314
|
+
}
|
|
315
|
+
await sessionStore?.remove(key);
|
|
316
|
+
return ended;
|
|
317
|
+
}
|
|
318
|
+
/** Forget one login's session without ending it in Wilma (when the login itself is removed). */
|
|
319
|
+
export async function forgetSession(profile) {
|
|
320
|
+
const key = poolKey(profile);
|
|
321
|
+
pool.delete(key);
|
|
322
|
+
await sessionStore?.remove(key);
|
|
275
323
|
}
|
|
276
324
|
function pooledSession(account) {
|
|
277
325
|
const now = Date.now();
|
|
@@ -287,9 +335,9 @@ function pooledSession(account) {
|
|
|
287
335
|
// A saved session first; it logs in again by itself if Wilma has ended it.
|
|
288
336
|
const saved = sessionStore ? await sessionStore.load(key).catch(() => null) : null;
|
|
289
337
|
const resumed = saved ? WilmaClient.resume(profile, saved, account.mfa) : null;
|
|
290
|
-
const
|
|
291
|
-
rememberSession(key, fresh);
|
|
292
|
-
return
|
|
338
|
+
const client = resumed ?? (await WilmaClient.login(profile, account.mfa));
|
|
339
|
+
rememberSession(key, client, { fresh: !resumed });
|
|
340
|
+
return client;
|
|
293
341
|
})();
|
|
294
342
|
const created = { client, lastUsed: now };
|
|
295
343
|
entry = created;
|
package/dist/cli-args.js
CHANGED
|
@@ -13,7 +13,7 @@ const DATA_COMMANDS = [
|
|
|
13
13
|
"printouts",
|
|
14
14
|
"students",
|
|
15
15
|
];
|
|
16
|
-
const OTHER_COMMANDS = ["accounts", "find-school", "login", "mcp", "update", "config", "help"];
|
|
16
|
+
const OTHER_COMMANDS = ["accounts", "find-school", "login", "logout", "mcp", "update", "config", "help"];
|
|
17
17
|
const ALIASES = {
|
|
18
18
|
kids: "students",
|
|
19
19
|
tenants: "find-school",
|
|
@@ -226,6 +226,8 @@ export function parseCliArgs(argv) {
|
|
|
226
226
|
return parsed("remove", args.slice(1));
|
|
227
227
|
noMore(args);
|
|
228
228
|
return parsed(null);
|
|
229
|
+
case "logout":
|
|
230
|
+
return parsed(null, args);
|
|
229
231
|
case "config":
|
|
230
232
|
if (args[0] !== "clear" || args.length > 1)
|
|
231
233
|
throw usage('Expected "wilma config clear".');
|
package/dist/cli-help.js
CHANGED
|
@@ -68,9 +68,15 @@ const COMMAND_HELP = {
|
|
|
68
68
|
},
|
|
69
69
|
accounts: {
|
|
70
70
|
usage: ["wilma accounts", "wilma accounts remove <number|Wilma name|username>"],
|
|
71
|
-
about: "Saved Wilma logins, numbered (one per Wilma the children use)
|
|
71
|
+
about: "Saved Wilma logins, numbered (one per Wilma the children use). `remove` deletes one and ends its Wilma session; the other logins keep theirs.",
|
|
72
72
|
examples: ["wilma accounts", "wilma accounts remove 2"],
|
|
73
73
|
},
|
|
74
|
+
logout: {
|
|
75
|
+
usage: ["wilma logout [<number|Wilma name|username>]"],
|
|
76
|
+
about: "End WilmAI's Wilma session, as Wilma's own Log out does, so you can use Wilma in the browser (Wilma allows one session per account). Every saved login by default, or one by its number from `wilma accounts`. The logins stay saved: the next command logs in again, and that ends your Wilma session in the browser. To delete the logins as well, use `wilma config clear`.",
|
|
77
|
+
examples: ["wilma logout", "wilma logout 2"],
|
|
78
|
+
output: '{ status: "ok", logins: [{ wilma, username, session: "ended" | "none" }], hint }. "none": there was no open session to end.',
|
|
79
|
+
},
|
|
74
80
|
"find-school": {
|
|
75
81
|
usage: ["wilma find-school <city or school>"],
|
|
76
82
|
about: "Find a Wilma address. Most city schools share their city's Wilma (big cities don't list each school); colleges, private schools and many small municipalities' schools have their own.",
|
|
@@ -98,6 +104,7 @@ const OVERVIEW = [
|
|
|
98
104
|
["students", "the children"],
|
|
99
105
|
["login", "log in (opens a page in the browser)"],
|
|
100
106
|
["accounts", "saved logins; accounts remove <n>"],
|
|
107
|
+
["logout", "end WilmAI's Wilma session (the logins stay saved)"],
|
|
101
108
|
["find-school <query>", "find a Wilma address by city or school"],
|
|
102
109
|
["mcp", "MCP server for AI assistants"],
|
|
103
110
|
["update", "update the CLI"],
|
package/dist/cli-output.js
CHANGED
|
@@ -8,7 +8,8 @@ function formatContent(value) {
|
|
|
8
8
|
const lines = value
|
|
9
9
|
.replace(/\r/g, "")
|
|
10
10
|
.split("\n")
|
|
11
|
-
|
|
11
|
+
// Leading spaces stay: they indent nested list items.
|
|
12
|
+
.map((line) => line.trimEnd())
|
|
12
13
|
.filter((line, index, arr) => line !== "" || arr[index - 1] !== "");
|
|
13
14
|
return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
14
15
|
}
|
package/dist/index.js
CHANGED
|
@@ -8,8 +8,8 @@ import { dirname, resolve } from "node:path";
|
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { APIError, AuthenticationError, MfaRequiredError, NetworkError, WilmaClient, listTenants, } from "@wilm-ai/wilma-client";
|
|
10
10
|
import { clearConfig, getConfigPath, loadConfig, obfuscateSecret, revealSecret, saveConfig } from "./config.js";
|
|
11
|
-
import { WilmaAccess, WilmaAiError, adoptSession, flushSessions, useSessionStore, } from "./agent-data.js";
|
|
12
|
-
import { createAccess } from "./access.js";
|
|
11
|
+
import { WilmaAccess, WilmaAiError, adoptSession, flushSessions, forgetSession, logoutSession, useSessionStore, } from "./agent-data.js";
|
|
12
|
+
import { createAccess, logoutAccounts } 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";
|
|
@@ -117,6 +117,9 @@ async function runCommand(argv) {
|
|
|
117
117
|
case "accounts":
|
|
118
118
|
await handleAccounts(action, args, out);
|
|
119
119
|
return;
|
|
120
|
+
case "logout":
|
|
121
|
+
await handleLogout(args.join(" "), flags, out);
|
|
122
|
+
return;
|
|
120
123
|
case "find-school":
|
|
121
124
|
await handleFindSchool(args.join(" "), out);
|
|
122
125
|
return;
|
|
@@ -680,24 +683,18 @@ const HEADLESS_LOGIN_HELP = [
|
|
|
680
683
|
async function handleAccounts(action, args, out) {
|
|
681
684
|
const config = await loadConfig();
|
|
682
685
|
if (action === "remove") {
|
|
683
|
-
const
|
|
684
|
-
if (!target)
|
|
685
|
-
throw new WilmaAiError("invalid_argument", "Which login? Use the number from `wilma accounts`, the Wilma name or the username.");
|
|
686
|
-
const byNumber = /^\d+$/.test(target) ? config.profiles[Number(target) - 1] : undefined;
|
|
687
|
-
const matches = byNumber
|
|
688
|
-
? [byNumber]
|
|
689
|
-
: config.profiles.filter((p) => [p.tenantName ?? "", p.tenantUrl, p.username].some((v) => v.toLowerCase() === target));
|
|
690
|
-
if (matches.length !== 1) {
|
|
691
|
-
throw new WilmaAiError(matches.length ? "ambiguous_account" : "unknown_account", matches.length ? `"${target}" matches several logins; use its number.` : `No saved login matches "${target}".`);
|
|
692
|
-
}
|
|
693
|
-
const removed = matches[0];
|
|
686
|
+
const removed = findSavedLogin(config.profiles, args.join(" "));
|
|
694
687
|
config.profiles = config.profiles.filter((p) => p.id !== removed.id);
|
|
695
688
|
if (config.lastProfileId === removed.id)
|
|
696
689
|
config.lastProfileId = config.profiles[0]?.id ?? null;
|
|
697
690
|
await saveConfig(config);
|
|
698
|
-
await
|
|
699
|
-
const result = { status: "removed", wilma: removed.tenantName ?? removed.tenantUrl, username: removed.username };
|
|
700
|
-
out.data(result, () =>
|
|
691
|
+
const session = await endRemovedSession(removed);
|
|
692
|
+
const result = { status: "removed", wilma: removed.tenantName ?? removed.tenantUrl, username: removed.username, ...session };
|
|
693
|
+
out.data(result, () => session.session === "ended"
|
|
694
|
+
? `Removed ${removed.username} @ ${result.wilma} and ended its Wilma session.`
|
|
695
|
+
: session.session === "not_ended"
|
|
696
|
+
? `Removed ${removed.username} @ ${result.wilma}. Its Wilma session couldn't be ended (${session.message}); Wilma ends it when it expires.`
|
|
697
|
+
: `Removed ${removed.username} @ ${result.wilma}.`);
|
|
701
698
|
return;
|
|
702
699
|
}
|
|
703
700
|
const accounts = config.profiles.map((p, i) => ({
|
|
@@ -715,6 +712,64 @@ async function handleAccounts(action, args, out) {
|
|
|
715
712
|
].join("\n")
|
|
716
713
|
: "No saved Wilma logins. Run `wilma login`.");
|
|
717
714
|
}
|
|
715
|
+
/**
|
|
716
|
+
* End a removed login's Wilma session and forget it; the other logins keep
|
|
717
|
+
* theirs. The login is removed even when Wilma can't be reached: its saved
|
|
718
|
+
* session is forgotten anyway, and Wilma ends the session when it expires.
|
|
719
|
+
*/
|
|
720
|
+
async function endRemovedSession(removed) {
|
|
721
|
+
const password = revealSecret(removed.passwordObfuscated);
|
|
722
|
+
if (!password)
|
|
723
|
+
return { session: "none" };
|
|
724
|
+
const profile = { baseUrl: removed.tenantUrl, username: removed.username, password };
|
|
725
|
+
try {
|
|
726
|
+
return { session: (await logoutSession(profile)) ? "ended" : "none" };
|
|
727
|
+
}
|
|
728
|
+
catch (err) {
|
|
729
|
+
await forgetSession(profile);
|
|
730
|
+
return { session: "not_ended", message: err instanceof Error ? err.message : String(err) };
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
/** A saved login by its number in `wilma accounts`, its Wilma's name or address, or its username. */
|
|
734
|
+
function findSavedLogin(profiles, rawTarget) {
|
|
735
|
+
const target = rawTarget.trim().toLowerCase();
|
|
736
|
+
if (!target)
|
|
737
|
+
throw new WilmaAiError("invalid_argument", "Which login? Use the number from `wilma accounts`, the Wilma name or the username.");
|
|
738
|
+
const byNumber = /^\d+$/.test(target) ? profiles[Number(target) - 1] : undefined;
|
|
739
|
+
const matches = byNumber
|
|
740
|
+
? [byNumber]
|
|
741
|
+
: profiles.filter((p) => [p.tenantName ?? "", p.tenantUrl, p.username].some((v) => v.toLowerCase() === target));
|
|
742
|
+
if (matches.length !== 1) {
|
|
743
|
+
throw new WilmaAiError(matches.length ? "ambiguous_account" : "unknown_account", matches.length ? `"${target}" matches several logins; use its number.` : `No saved login matches "${target}".`);
|
|
744
|
+
}
|
|
745
|
+
return matches[0];
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* End WilmAI's Wilma sessions so the parent can use Wilma in the browser:
|
|
749
|
+
* every login commands use, or one saved login. The logins stay saved.
|
|
750
|
+
*/
|
|
751
|
+
async function handleLogout(target, flags, out) {
|
|
752
|
+
if (flags.student) {
|
|
753
|
+
throw new WilmaAiError("invalid_argument", "A Wilma session belongs to a login, not to one child. Use `wilma logout <number>` with the number from `wilma accounts`, or no number for every login.");
|
|
754
|
+
}
|
|
755
|
+
const config = await loadConfig();
|
|
756
|
+
// The logins commands use (the environment's, or every saved one); a picked
|
|
757
|
+
// login is one of the saved ones, numbered as in `wilma accounts`.
|
|
758
|
+
let accounts = await resolveAccounts(config, target ? {} : process.env);
|
|
759
|
+
if (target) {
|
|
760
|
+
const login = findSavedLogin(config.profiles, target);
|
|
761
|
+
const account = (p) => `${normalizeTenantUrl(p.tenantUrl)}|${p.username.toLowerCase()}`;
|
|
762
|
+
accounts = accounts.filter((a) => a.stored && account(a.stored) === account(login));
|
|
763
|
+
}
|
|
764
|
+
const result = await logoutAccounts(accounts);
|
|
765
|
+
out.data(result, () => result.logins.length
|
|
766
|
+
? [
|
|
767
|
+
...result.logins.map((l) => `${l.session === "ended" ? "Logged out" : "No open session"}: ${l.username} @ ${l.wilma}`),
|
|
768
|
+
"",
|
|
769
|
+
"The logins stay saved. The next wilma command logs in again, and that ends your Wilma session in the browser.",
|
|
770
|
+
].join("\n")
|
|
771
|
+
: result.hint);
|
|
772
|
+
}
|
|
718
773
|
async function readStdin() {
|
|
719
774
|
const chunks = [];
|
|
720
775
|
for await (const chunk of process.stdin)
|
package/dist/login-server.d.ts
CHANGED
|
@@ -14,9 +14,23 @@ export interface LoginServer {
|
|
|
14
14
|
* on different Wilmas can add another login on the same page; it closes when
|
|
15
15
|
* the parent presses Done or after 15 idle minutes.
|
|
16
16
|
*/
|
|
17
|
+
/**
|
|
18
|
+
* The assistant app that asked for the login (by the name it gives when it
|
|
19
|
+
* connects to the MCP server), named in the page's intro so the parent can
|
|
20
|
+
* connect the page to what they just did.
|
|
21
|
+
*/
|
|
22
|
+
export type OpenedBy = {
|
|
23
|
+
app: string;
|
|
24
|
+
};
|
|
25
|
+
/** A plain app name from the name an MCP client reports, or undefined if unknown. */
|
|
26
|
+
export declare function appName(client?: {
|
|
27
|
+
name?: string;
|
|
28
|
+
title?: string;
|
|
29
|
+
}): string | undefined;
|
|
17
30
|
export declare function startLoginServer(opts?: {
|
|
18
31
|
timeoutMs?: number;
|
|
19
32
|
onSaved?: (profile: StoredProfile) => void;
|
|
33
|
+
openedBy?: OpenedBy;
|
|
20
34
|
}): Promise<LoginServer>;
|
|
21
35
|
export type BrowserOpenResult = "opened" | "skipped" | "headless";
|
|
22
36
|
/** Best-effort: open a URL in the user's default browser. */
|
|
@@ -28,6 +42,7 @@ export interface LoginPageOptions {
|
|
|
28
42
|
apiBase: string;
|
|
29
43
|
/** Shown in the privacy note under the form. */
|
|
30
44
|
configPath?: string;
|
|
45
|
+
openedBy?: OpenedBy;
|
|
31
46
|
}
|
|
32
47
|
/** The login page. The login API answers {status: "ok", students, accounts}. */
|
|
33
48
|
export declare function renderLoginPage(opts: LoginPageOptions): string;
|
package/dist/login-server.js
CHANGED
|
@@ -16,13 +16,25 @@ function accountsSummary(profiles) {
|
|
|
16
16
|
students: (p.students ?? []).map((s) => s.name),
|
|
17
17
|
}));
|
|
18
18
|
}
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
19
|
+
/** A plain app name from the name an MCP client reports, or undefined if unknown. */
|
|
20
|
+
export function appName(client) {
|
|
21
|
+
const raw = `${client?.title ?? ""} ${client?.name ?? ""}`.toLowerCase();
|
|
22
|
+
if (raw.includes("claude-code") || raw.includes("claude code"))
|
|
23
|
+
return "Claude Code";
|
|
24
|
+
if (raw.includes("claude"))
|
|
25
|
+
return "Claude";
|
|
26
|
+
if (raw.includes("codex"))
|
|
27
|
+
return "Codex";
|
|
28
|
+
if (raw.includes("chatgpt") || raw.includes("openai"))
|
|
29
|
+
return "ChatGPT";
|
|
30
|
+
if (raw.includes("cursor"))
|
|
31
|
+
return "Cursor";
|
|
32
|
+
if (raw.includes("openclaw"))
|
|
33
|
+
return "OpenClaw";
|
|
34
|
+
// Another app: its own display name, if it gave one.
|
|
35
|
+
const title = client?.title?.trim();
|
|
36
|
+
return title && title.length <= 40 ? title : undefined;
|
|
37
|
+
}
|
|
26
38
|
export async function startLoginServer(opts = {}) {
|
|
27
39
|
const token = randomBytes(18).toString("base64url");
|
|
28
40
|
const nonce = randomBytes(16).toString("base64");
|
|
@@ -64,7 +76,7 @@ export async function startLoginServer(opts = {}) {
|
|
|
64
76
|
"base-uri 'none'",
|
|
65
77
|
].join("; "),
|
|
66
78
|
});
|
|
67
|
-
res.end(renderLoginPage({ nonce, apiBase: `${base}/api`, configPath: getConfigPath() }));
|
|
79
|
+
res.end(renderLoginPage({ nonce, apiBase: `${base}/api`, configPath: getConfigPath(), openedBy: opts.openedBy }));
|
|
68
80
|
return;
|
|
69
81
|
}
|
|
70
82
|
if (url.pathname === `${base}/api/tenants` && req.method === "GET") {
|
|
@@ -260,7 +272,18 @@ export function renderLoginPage(opts) {
|
|
|
260
272
|
tooMany: "Too many attempts. Close this tab and start again from your assistant.",
|
|
261
273
|
missing: "Pick your school's Wilma and fill in both fields.",
|
|
262
274
|
expired: "This login page is no longer active. Start the login again from your assistant, or run wilma login.",
|
|
263
|
-
privacy: "Your login is saved only on this computer, in {path}
|
|
275
|
+
privacy: "Your login is saved only on this computer, in {path}, readable only by your user account. To remove it, run wilma config clear or delete that file.",
|
|
276
|
+
introApp: "{app} asked to connect to your Wilma. Log in once, and {app} can then check schedules, homework, exams, messages and news for you.",
|
|
277
|
+
trustLocalTitle: "On your own computer",
|
|
278
|
+
trustLocal: "This page isn't a website. It runs on this computer (that's what 127.0.0.1 in the address bar means), and WilmAI has no server.",
|
|
279
|
+
trustWilmaTitle: "Only to your school's Wilma",
|
|
280
|
+
trustWilma: "Your password goes from this computer straight to your school's Wilma. Your assistant never sees it.",
|
|
281
|
+
trustWilmaHost: "Your password goes from this computer straight to {host}. Your assistant never sees it.",
|
|
282
|
+
trustReadTitle: "The same information you see",
|
|
283
|
+
trustRead: "WilmAI reads schedules, messages, news and the rest just as you see them in Wilma.",
|
|
284
|
+
sentTo: "Sent only to {host}",
|
|
285
|
+
learnMore: "How WilmAI handles your login",
|
|
286
|
+
sourceCode: "Source code",
|
|
264
287
|
disclaimer: "WilmAI is an independent open-source project, not affiliated with Visma or the Wilma service.",
|
|
265
288
|
},
|
|
266
289
|
fi: {
|
|
@@ -293,7 +316,18 @@ export function renderLoginPage(opts) {
|
|
|
293
316
|
tooMany: "Liian monta yritystä. Sulje välilehti ja aloita uudelleen avustajasi kautta.",
|
|
294
317
|
missing: "Valitse koulusi Wilma ja täytä molemmat kentät.",
|
|
295
318
|
expired: "Tämä kirjautumissivu ei ole enää käytössä. Aloita kirjautuminen uudelleen avustajasi kautta tai komennolla wilma login.",
|
|
296
|
-
privacy: "Kirjautumistietosi tallennetaan vain tälle tietokoneelle, tiedostoon {path}.
|
|
319
|
+
privacy: "Kirjautumistietosi tallennetaan vain tälle tietokoneelle, tiedostoon {path}, jota vain sinun käyttäjätilisi voi lukea. Voit poistaa ne komennolla wilma config clear tai poistamalla tiedoston.",
|
|
320
|
+
introApp: "{app} pyysi yhteyttä Wilmaasi. Kirjaudu kerran, niin {app} voi sen jälkeen tarkistaa lukujärjestykset, läksyt, kokeet, viestit ja tiedotteet puolestasi.",
|
|
321
|
+
trustLocalTitle: "Omalla koneellasi",
|
|
322
|
+
trustLocal: "Tämä sivu ei ole verkkosivusto. Se toimii tällä koneella (sitä osoiterivin 127.0.0.1 tarkoittaa), eikä WilmAI:lla ole palvelinta.",
|
|
323
|
+
trustWilmaTitle: "Vain koulusi Wilmaan",
|
|
324
|
+
trustWilma: "Salasanasi lähtee tältä koneelta suoraan koulusi Wilmaan. Avustajasi ei näe sitä.",
|
|
325
|
+
trustWilmaHost: "Salasanasi lähtee tältä koneelta suoraan osoitteeseen {host}. Avustajasi ei näe sitä.",
|
|
326
|
+
trustReadTitle: "Samat tiedot kuin sinulla",
|
|
327
|
+
trustRead: "WilmAI lukee lukujärjestykset, viestit, tiedotteet ja muut tiedot samoin kuin näet ne Wilmassa.",
|
|
328
|
+
sentTo: "Lähetetään vain osoitteeseen {host}",
|
|
329
|
+
learnMore: "Miten WilmAI käsittelee kirjautumisen (englanniksi)",
|
|
330
|
+
sourceCode: "Lähdekoodi",
|
|
297
331
|
disclaimer: "WilmAI on itsenäinen avoimen lähdekoodin projekti, eikä se liity Vismaan tai Wilma-palveluun.",
|
|
298
332
|
},
|
|
299
333
|
};
|
|
@@ -310,9 +344,14 @@ export function renderLoginPage(opts) {
|
|
|
310
344
|
@media (prefers-color-scheme: dark) { :root { --ink:#eef2f3; --muted:#a8b5bb; --paper:#141c21; --line:#2c3a42; --card:#1b252b; --err:#ff8a80; } }
|
|
311
345
|
* { box-sizing:border-box; }
|
|
312
346
|
body { margin:0; background:var(--paper); color:var(--ink); font:16px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; }
|
|
313
|
-
main { max-width:
|
|
347
|
+
main { max-width:920px; margin:0 auto; padding:32px 16px 48px; }
|
|
348
|
+
.layout { display:grid; grid-template-columns:minmax(0, 1fr) minmax(0, 1fr); gap:24px 48px; align-items:start; }
|
|
349
|
+
.layout .card { margin:0 0 16px; }
|
|
350
|
+
.foot { max-width:560px; margin-top:28px; }
|
|
351
|
+
@media (max-width:760px) { main { max-width:480px; } .layout { grid-template-columns:minmax(0, 1fr); } }
|
|
314
352
|
h1 { font-size:28px; line-height:1.2; margin:0 0 8px; }
|
|
315
353
|
h1 mark { background:linear-gradient(transparent 55%, var(--yellow) 55%); color:inherit; padding:0 2px; }
|
|
354
|
+
@media (prefers-color-scheme: dark) { h1 mark { background:linear-gradient(transparent 60%, rgba(255,216,77,.4) 60%); } }
|
|
316
355
|
p { margin:0 0 16px; }
|
|
317
356
|
.muted { color:var(--muted); font-size:14px; }
|
|
318
357
|
.card { background:var(--card); border:1px solid var(--line); border-radius:12px; padding:20px; margin:20px 0; }
|
|
@@ -343,13 +382,41 @@ export function renderLoginPage(opts) {
|
|
|
343
382
|
.add-hint { margin-top:8px; }
|
|
344
383
|
.error { color:var(--err); margin-top:12px; }
|
|
345
384
|
.done h2 { margin:0 0 8px; }
|
|
385
|
+
.top { display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:28px; }
|
|
386
|
+
.brand { font-weight:800; font-size:18px; letter-spacing:-0.01em; }
|
|
387
|
+
.lang { display:flex; gap:4px; }
|
|
388
|
+
.lang button { font:inherit; font-size:13px; padding:3px 10px; border-radius:999px; border:1px solid var(--line); background:transparent; color:var(--muted); cursor:pointer; }
|
|
389
|
+
.lang button[aria-pressed="true"] { background:var(--yellow); border-color:var(--yellow); color:#1b2b34; font-weight:700; }
|
|
390
|
+
.trust { list-style:none; margin:20px 0 0; padding:0; display:flex; flex-direction:column; gap:12px; }
|
|
391
|
+
.trust li { display:flex; gap:12px; align-items:flex-start; }
|
|
392
|
+
.trust svg { flex:none; width:22px; height:22px; margin-top:1px; fill:none; stroke:var(--teal); stroke-width:1.8; stroke-linecap:round; stroke-linejoin:round; }
|
|
393
|
+
.trust strong { display:block; font-size:15px; line-height:1.35; }
|
|
394
|
+
.trust span { display:block; font-size:14px; line-height:1.45; color:var(--muted); }
|
|
395
|
+
.sent-to { font-size:13px; color:var(--muted); margin:6px 0 0; }
|
|
396
|
+
.links a { color:var(--teal); }
|
|
346
397
|
[hidden] { display:none !important; }
|
|
347
398
|
</style>
|
|
348
399
|
</head>
|
|
349
400
|
<body>
|
|
350
401
|
<main>
|
|
402
|
+
<header class="top">
|
|
403
|
+
<span class="brand">WilmAI</span>
|
|
404
|
+
<span class="lang" role="group" aria-label="Kieli / Language">
|
|
405
|
+
<button type="button" id="lang-fi" lang="fi">Suomi</button>
|
|
406
|
+
<button type="button" id="lang-en" lang="en">English</button>
|
|
407
|
+
</span>
|
|
408
|
+
</header>
|
|
409
|
+
<div class="layout">
|
|
410
|
+
<div class="about">
|
|
351
411
|
<h1 id="heading"></h1>
|
|
352
412
|
<p id="intro"></p>
|
|
413
|
+
<ul class="trust">
|
|
414
|
+
<li><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/></svg><div><strong id="trust-local-title"></strong><span id="trust-local"></span></div></li>
|
|
415
|
+
<li><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg><div><strong id="trust-wilma-title"></strong><span id="trust-wilma"></span></div></li>
|
|
416
|
+
<li><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg><div><strong id="trust-read-title"></strong><span id="trust-read"></span></div></li>
|
|
417
|
+
</ul>
|
|
418
|
+
</div>
|
|
419
|
+
<div class="forms">
|
|
353
420
|
<section class="card" id="form-card">
|
|
354
421
|
<div id="school-pick">
|
|
355
422
|
<label for="school" id="school-label"></label>
|
|
@@ -367,6 +434,7 @@ export function renderLoginPage(opts) {
|
|
|
367
434
|
<input id="username" autocomplete="username" autocapitalize="none" spellcheck="false">
|
|
368
435
|
<label for="password" id="password-label"></label>
|
|
369
436
|
<input id="password" type="password" autocomplete="current-password">
|
|
437
|
+
<p class="sent-to" id="sent-to" hidden></p>
|
|
370
438
|
<div id="mfa" hidden>
|
|
371
439
|
<label id="mfa-title"></label>
|
|
372
440
|
<p class="muted" id="mfa-body"></p>
|
|
@@ -388,37 +456,75 @@ export function renderLoginPage(opts) {
|
|
|
388
456
|
<h2 id="finished-title"></h2>
|
|
389
457
|
<p id="finished-body"></p>
|
|
390
458
|
</section>
|
|
459
|
+
</div>
|
|
460
|
+
</div>
|
|
461
|
+
<div class="foot">
|
|
391
462
|
<p class="muted" id="privacy"></p>
|
|
463
|
+
<p class="muted links"><a id="learn-more" href="https://github.com/aikarjal/wilmai#credentials--privacy" target="_blank" rel="noreferrer"></a> · <a id="source-code" href="https://github.com/aikarjal/wilmai" target="_blank" rel="noreferrer"></a></p>
|
|
392
464
|
<p class="muted" id="disclaimer"></p>
|
|
465
|
+
</div>
|
|
393
466
|
</main>
|
|
394
467
|
<script nonce="${nonce}">
|
|
395
468
|
(() => {
|
|
396
469
|
const STRINGS = ${jsonForScript(strings)};
|
|
397
470
|
const API = ${jsonForScript(opts.apiBase)};
|
|
398
471
|
const CONFIG_PATH = ${jsonForScript(opts.configPath ?? "")};
|
|
399
|
-
const
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
472
|
+
const OPENED_BY = ${jsonForScript(opts.openedBy ?? null)};
|
|
473
|
+
let lang = (navigator.language || "en").toLowerCase().startsWith("fi") ? "fi" : "en";
|
|
474
|
+
let t = STRINGS[lang];
|
|
475
|
+
let tenant = null;
|
|
476
|
+
let timer = null;
|
|
477
|
+
let seq = 0;
|
|
478
|
+
let shownAccounts = null;
|
|
403
479
|
const $ = (id) => document.getElementById(id);
|
|
404
480
|
const text = (id, value) => { $(id).textContent = value; };
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
text
|
|
408
|
-
|
|
409
|
-
|
|
481
|
+
const hostOf = (url) => { try { return new URL(url).host; } catch { return url; } };
|
|
482
|
+
|
|
483
|
+
// Every text on the page, in the chosen language (the switch at the top re-runs this).
|
|
484
|
+
function applyText() {
|
|
485
|
+
t = STRINGS[lang];
|
|
486
|
+
document.documentElement.lang = lang;
|
|
487
|
+
document.title = t.title;
|
|
488
|
+
$("heading").replaceChildren(t.heading.split(" ").slice(0, -1).join(" ") + " ");
|
|
489
|
+
const mark = document.createElement("mark"); mark.textContent = t.heading.split(" ").slice(-1)[0]; $("heading").append(mark);
|
|
490
|
+
text("intro", OPENED_BY ? t.introApp.split("{app}").join(OPENED_BY.app) : t.intro);
|
|
491
|
+
text("trust-local-title", t.trustLocalTitle); text("trust-local", t.trustLocal);
|
|
492
|
+
text("trust-wilma-title", t.trustWilmaTitle);
|
|
493
|
+
text("trust-read-title", t.trustReadTitle); text("trust-read", t.trustRead);
|
|
494
|
+
text("school-label", t.school); text("school-label-2", t.school);
|
|
495
|
+
$("school").placeholder = t.schoolHint; text("no-results", t.noResults); text("change", t.change);
|
|
496
|
+
text("school-help", t.schoolHelp);
|
|
497
|
+
text("username-label", t.username); text("password-label", t.password);
|
|
498
|
+
text("mfa-title", t.mfaTitle); text("mfa-body", t.mfaBody); text("mfa-label", t.mfaLabel); text("mfa-help", t.mfaHelp);
|
|
499
|
+
if (!$("submit").disabled) text("submit", t.submit);
|
|
500
|
+
text("done-title", t.doneTitle);
|
|
501
|
+
text("finish", t.finish); text("add-another", t.addAnother); text("add-hint", t.addAnotherHint);
|
|
502
|
+
text("finished-title", t.finishedTitle); text("finished-body", t.finishedBody);
|
|
503
|
+
text("privacy", t.privacy.replace("{path}", CONFIG_PATH));
|
|
504
|
+
text("learn-more", t.learnMore); text("source-code", t.sourceCode);
|
|
505
|
+
text("disclaimer", t.disclaimer);
|
|
506
|
+
for (const code of ["fi", "en"]) $("lang-" + code).setAttribute("aria-pressed", String(code === lang));
|
|
507
|
+
showHost();
|
|
508
|
+
if (shownAccounts) renderAccounts(shownAccounts);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Where the password goes, once the parent has picked their Wilma.
|
|
512
|
+
function showHost() {
|
|
513
|
+
const host = tenant ? hostOf(tenant.url) : "";
|
|
514
|
+
text("trust-wilma", host ? t.trustWilmaHost.replace("{host}", host) : t.trustWilma);
|
|
515
|
+
$("sent-to").hidden = !host;
|
|
516
|
+
if (host) text("sent-to", t.sentTo.replace("{host}", host));
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
for (const code of ["fi", "en"]) $("lang-" + code).addEventListener("click", () => { lang = code; applyText(); });
|
|
410
520
|
for (const city of ["Helsinki", "Espoo", "Tampere", "Vantaa", "Oulu", "Turku", "Jyväskylä"]) {
|
|
411
521
|
const b = document.createElement("button"); b.type = "button"; b.textContent = city;
|
|
412
522
|
b.addEventListener("click", () => { $("school").value = city; search(city).catch(() => showError(t.expired)); $("school").focus(); });
|
|
413
523
|
$("chips").append(b);
|
|
414
524
|
}
|
|
415
|
-
text("username-label", t.username); text("password-label", t.password);
|
|
416
|
-
text("mfa-title", t.mfaTitle); text("mfa-body", t.mfaBody); text("mfa-label", t.mfaLabel); text("mfa-help", t.mfaHelp);
|
|
417
|
-
text("submit", t.submit); text("done-title", t.doneTitle);
|
|
418
|
-
text("finish", t.finish); text("add-another", t.addAnother); text("add-hint", t.addAnotherHint);
|
|
419
|
-
text("finished-title", t.finishedTitle); text("finished-body", t.finishedBody);
|
|
420
525
|
|
|
421
526
|
function renderAccounts(accounts) {
|
|
527
|
+
shownAccounts = accounts;
|
|
422
528
|
const box = $("accounts"); box.replaceChildren();
|
|
423
529
|
const intro = document.createElement("p"); intro.textContent = t.doneIntro; box.append(intro);
|
|
424
530
|
for (const account of accounts) {
|
|
@@ -434,7 +540,7 @@ export function renderLoginPage(opts) {
|
|
|
434
540
|
}
|
|
435
541
|
|
|
436
542
|
$("add-another").addEventListener("click", () => {
|
|
437
|
-
tenant = null;
|
|
543
|
+
tenant = null; shownAccounts = null; showHost();
|
|
438
544
|
for (const id of ["school", "username", "password", "totp"]) $(id).value = "";
|
|
439
545
|
render([]); showError("");
|
|
440
546
|
$("mfa").hidden = true; $("school-chosen").hidden = true; $("school-pick").hidden = false;
|
|
@@ -457,21 +563,16 @@ export function renderLoginPage(opts) {
|
|
|
457
563
|
}
|
|
458
564
|
$("done").hidden = true; $("finished").hidden = false;
|
|
459
565
|
});
|
|
460
|
-
text("privacy", t.privacy.replace("{path}", CONFIG_PATH)); text("disclaimer", t.disclaimer);
|
|
461
|
-
|
|
462
|
-
let tenant = null;
|
|
463
|
-
let timer = null;
|
|
464
|
-
let seq = 0;
|
|
465
566
|
const showError = (msg) => { const el = $("error"); el.textContent = msg; el.hidden = !msg; };
|
|
466
567
|
|
|
467
568
|
function choose(t2) {
|
|
468
569
|
tenant = t2;
|
|
469
|
-
text("chosen-name", t2.name || t2.url); text("chosen-url", t2.url);
|
|
570
|
+
text("chosen-name", t2.name || t2.url); text("chosen-url", t2.url); showHost();
|
|
470
571
|
$("school-pick").hidden = true; $("school-chosen").hidden = false;
|
|
471
572
|
$("username").focus();
|
|
472
573
|
}
|
|
473
574
|
$("change").addEventListener("click", () => {
|
|
474
|
-
tenant = null; $("school-chosen").hidden = true; $("school-pick").hidden = false; $("school").focus();
|
|
575
|
+
tenant = null; showHost(); $("school-chosen").hidden = true; $("school-pick").hidden = false; $("school").focus();
|
|
475
576
|
});
|
|
476
577
|
|
|
477
578
|
async function search(q) {
|
|
@@ -565,6 +666,7 @@ export function renderLoginPage(opts) {
|
|
|
565
666
|
btn.disabled = false; btn.textContent = t.submit;
|
|
566
667
|
}
|
|
567
668
|
}
|
|
669
|
+
applyText();
|
|
568
670
|
$("school").focus();
|
|
569
671
|
})();
|
|
570
672
|
</script>
|
package/dist/mcp-tools.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
3
3
|
import type { FetchedAttachment, WilmaAccess } from "./agent-data.js";
|
|
4
|
-
export declare const INSTRUCTIONS = "Read-only access to Finland's Wilma school system for a parent/guardian.\n- Start with wilma_summary: today's and tomorrow's lessons, upcoming exams, recent homework, news and messages for every child in one call.\n- Tools cover all children by default; pass \"student\" (name or student number) to narrow to one child.\n- Wilma content is usually in Finnish. Answer in the user's language and translate as needed. Write dates the way that language does: Finnish \"ke 7.10.\" (with the final period), English \"Wed 7 Oct\".\n- Message and news lists return ids; use wilma_read_message / wilma_read_news for full text. Bulletins can link attachments; fetch them with wilma_get_news_attachment.\n- Teachers' feedback (\"forgot books\", \"did well\") and absences are lesson notes: wilma_lesson_notes for recent days, wilma_lesson_notes_summary for counts. Course and report-card grades are in wilma_gradebook.\n- If a tool says the user isn't logged in, relay the login link or instructions to the user exactly; never ask the user to type their Wilma password into the chat.\n- If a result ends with an \"Update note\", answer the question first, then pass the note on in one sentence.";
|
|
4
|
+
export declare const INSTRUCTIONS = "Read-only access to Finland's Wilma school system for a parent/guardian.\n- Start with wilma_summary: today's and tomorrow's lessons, upcoming exams, recent homework, news and messages for every child in one call.\n- Tools cover all children by default; pass \"student\" (name or student number) to narrow to one child.\n- Wilma content is usually in Finnish. Answer in the user's language and translate as needed. Write dates the way that language does: Finnish \"ke 7.10.\" (with the final period), English \"Wed 7 Oct\".\n- Message and news lists return ids; use wilma_read_message / wilma_read_news for full text. Bulletins can link attachments; fetch them with wilma_get_news_attachment.\n- Teachers' feedback (\"forgot books\", \"did well\") and absences are lesson notes: wilma_lesson_notes for recent days, wilma_lesson_notes_summary for counts. Course and report-card grades are in wilma_gradebook.\n- If a tool says the user isn't logged in, relay the login link or instructions to the user exactly; never ask the user to type their Wilma password into the chat.\n- Wilma allows one session per account, so each WilmAI login logs the user out of Wilma in their browser. If they want to use Wilma in the browser, call wilma_logout and then call Wilma tools only when they ask again.\n- If a result ends with an \"Update note\", answer the question first, then pass the note on in one sentence.";
|
|
5
5
|
export declare const READ_ONLY: {
|
|
6
6
|
readonly readOnlyHint: true;
|
|
7
7
|
readonly destructiveHint: false;
|
package/dist/mcp-tools.js
CHANGED
|
@@ -12,6 +12,7 @@ export const INSTRUCTIONS = `Read-only access to Finland's Wilma school system f
|
|
|
12
12
|
- Message and news lists return ids; use wilma_read_message / wilma_read_news for full text. Bulletins can link attachments; fetch them with wilma_get_news_attachment.
|
|
13
13
|
- Teachers' feedback ("forgot books", "did well") and absences are lesson notes: wilma_lesson_notes for recent days, wilma_lesson_notes_summary for counts. Course and report-card grades are in wilma_gradebook.
|
|
14
14
|
- If a tool says the user isn't logged in, relay the login link or instructions to the user exactly; never ask the user to type their Wilma password into the chat.
|
|
15
|
+
- Wilma allows one session per account, so each WilmAI login logs the user out of Wilma in their browser. If they want to use Wilma in the browser, call wilma_logout and then call Wilma tools only when they ask again.
|
|
15
16
|
- If a result ends with an "Update note", answer the question first, then pass the note on in one sentence.`;
|
|
16
17
|
export const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
|
17
18
|
const INLINE_ATTACHMENT_LIMIT = 10 * 1024 * 1024;
|
package/dist/mcp.js
CHANGED
|
@@ -5,12 +5,12 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
5
5
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
6
|
import { AuthenticationError } from "@wilm-ai/wilma-client";
|
|
7
7
|
import { useSessionStore } from "./agent-data.js";
|
|
8
|
-
import { fileSessionStore } from "./session-store.js";
|
|
9
|
-
import { createAccess } from "./access.js";
|
|
8
|
+
import { fileSessionStore, nameSessionOwner } from "./session-store.js";
|
|
9
|
+
import { createAccess, logoutAccounts } from "./access.js";
|
|
10
10
|
import { loadConfig } from "./config.js";
|
|
11
11
|
import { ENV_VARS, isMfaFailure, resolveAccounts } from "./credentials.js";
|
|
12
12
|
import { createUniqueDownloadFile } from "./downloads.js";
|
|
13
|
-
import { openBrowser, startLoginServer } from "./login-server.js";
|
|
13
|
+
import { appName, openBrowser, startLoginServer } from "./login-server.js";
|
|
14
14
|
import { z } from "zod";
|
|
15
15
|
import { INSTRUCTIONS, READ_ONLY, json, registerWilmaTools, textResult } from "./mcp-tools.js";
|
|
16
16
|
import { searchTenants } from "./tenant-search.js";
|
|
@@ -23,6 +23,8 @@ const SECRET_SETTINGS_HINT = [
|
|
|
23
23
|
"Never ask the user to type their Wilma password into the chat.",
|
|
24
24
|
].join("\n");
|
|
25
25
|
let loginServerPromise = null;
|
|
26
|
+
/** The running server, so the login page can name the app that asked for it. */
|
|
27
|
+
let mcpServer = null;
|
|
26
28
|
/** One login page at a time. Parallel tool calls share it; an explicit wilma_login starts a fresh one. */
|
|
27
29
|
async function ensureLoginServer(fresh) {
|
|
28
30
|
if (fresh && loginServerPromise) {
|
|
@@ -31,7 +33,8 @@ async function ensureLoginServer(fresh) {
|
|
|
31
33
|
}
|
|
32
34
|
if (loginServerPromise)
|
|
33
35
|
return { server: await loginServerPromise, created: false };
|
|
34
|
-
const
|
|
36
|
+
const app = appName(mcpServer?.server.getClientVersion());
|
|
37
|
+
const pending = startLoginServer(app ? { openedBy: { app } } : {}).then((server) => {
|
|
35
38
|
server.result.finally(() => {
|
|
36
39
|
if (loginServerPromise === pending)
|
|
37
40
|
loginServerPromise = null;
|
|
@@ -158,6 +161,20 @@ function createWilmaMcpServer(version) {
|
|
|
158
161
|
inputSchema: {},
|
|
159
162
|
annotations: { title: "Log in to Wilma", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
160
163
|
}, async () => startBrowserLogin(true));
|
|
164
|
+
server.registerTool("wilma_logout", {
|
|
165
|
+
title: "Log out of Wilma",
|
|
166
|
+
description: "End WilmAI's Wilma session, as Wilma's own Log out does. Wilma allows one session per account, so use this when the user wants to use Wilma in their browser or asks to log out. The login stays saved: the next Wilma tool call logs in again, and that ends the user's session in the browser, so don't call other Wilma tools afterwards unless the user asks.",
|
|
167
|
+
inputSchema: {},
|
|
168
|
+
annotations: { title: "Log out of Wilma", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
169
|
+
}, async () => {
|
|
170
|
+
try {
|
|
171
|
+
return json(await logoutAccounts(await resolveAccounts(await loadConfig())));
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
175
|
+
return textResult(["Couldn't log out of Wilma; an open session was kept, so this can be tried again.", message].join("\n"), true);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
161
178
|
return server;
|
|
162
179
|
}
|
|
163
180
|
export async function runMcpServer(version) {
|
|
@@ -165,10 +182,12 @@ export async function runMcpServer(version) {
|
|
|
165
182
|
console.log = (...args) => console.error(...args);
|
|
166
183
|
console.info = (...args) => console.error(...args);
|
|
167
184
|
// Share saved sessions with the CLI: no login per process, no extra codes.
|
|
185
|
+
nameSessionOwner("mcp");
|
|
168
186
|
useSessionStore(fileSessionStore);
|
|
169
187
|
// Checked once a day at most; npx already runs the newest version.
|
|
170
188
|
if (updateChecksEnabled())
|
|
171
189
|
updateCheck = startUpdateCheck();
|
|
172
190
|
const server = createWilmaMcpServer(version);
|
|
191
|
+
mcpServer = server;
|
|
173
192
|
await server.connect(new StdioServerTransport());
|
|
174
193
|
}
|
package/dist/session-store.d.ts
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
export interface SessionStore {
|
|
2
2
|
load(key: string): Promise<string | null>;
|
|
3
3
|
save(key: string, state: string): Promise<void>;
|
|
4
|
+
/** Forget one session (after logging out). */
|
|
5
|
+
remove(key: string): Promise<void>;
|
|
4
6
|
}
|
|
7
|
+
/** Name this program in the sessions it saves (the MCP server says "mcp"). */
|
|
8
|
+
export declare function nameSessionOwner(name: string): void;
|
|
5
9
|
export declare const fileSessionStore: SessionStore;
|
|
6
|
-
/**
|
|
10
|
+
/** When one login's session was saved, and by which program (null when there's none to try). */
|
|
11
|
+
export declare function sessionInfo(key: string): Promise<{
|
|
12
|
+
savedAt: number;
|
|
13
|
+
by: string | null;
|
|
14
|
+
} | null>;
|
|
15
|
+
/** Forget every saved session (after clearing the config). */
|
|
7
16
|
export declare function clearSessions(): Promise<void>;
|
package/dist/session-store.js
CHANGED
|
@@ -16,6 +16,11 @@ import { getConfigPath } from "./config.js";
|
|
|
16
16
|
*/
|
|
17
17
|
/** A session older than this is not tried (Wilma ends idle sessions anyway). */
|
|
18
18
|
const SESSION_TTL_MS = 6 * 60 * 60 * 1000;
|
|
19
|
+
let savedBy = "cli";
|
|
20
|
+
/** Name this program in the sessions it saves (the MCP server says "mcp"). */
|
|
21
|
+
export function nameSessionOwner(name) {
|
|
22
|
+
savedBy = name;
|
|
23
|
+
}
|
|
19
24
|
function sessionsPath() {
|
|
20
25
|
return resolve(dirname(getConfigPath()), "wilmai-sessions.json");
|
|
21
26
|
}
|
|
@@ -46,16 +51,35 @@ export const fileSessionStore = {
|
|
|
46
51
|
if (now - entry.savedAt > SESSION_TTL_MS)
|
|
47
52
|
delete all[k];
|
|
48
53
|
}
|
|
49
|
-
all[key] = { state, savedAt: now };
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
all[key] = { state, savedAt: now, by: savedBy };
|
|
55
|
+
await writeAll(all);
|
|
56
|
+
},
|
|
57
|
+
async remove(key) {
|
|
58
|
+
const all = await readAll();
|
|
59
|
+
if (!(key in all))
|
|
60
|
+
return;
|
|
61
|
+
delete all[key];
|
|
62
|
+
await writeAll(all);
|
|
56
63
|
},
|
|
57
64
|
};
|
|
58
|
-
|
|
65
|
+
async function writeAll(all) {
|
|
66
|
+
const path = sessionsPath();
|
|
67
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
68
|
+
// Temp file + rename: a parallel command never reads half a file.
|
|
69
|
+
const tmp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
70
|
+
await writeFile(tmp, JSON.stringify(all), { encoding: "utf-8", mode: 0o600 });
|
|
71
|
+
await rename(tmp, path);
|
|
72
|
+
}
|
|
73
|
+
/** When one login's session was saved, and by which program (null when there's none to try). */
|
|
74
|
+
export async function sessionInfo(key) {
|
|
75
|
+
if (process.env.WILMAI_NO_SESSION_CACHE === "1")
|
|
76
|
+
return null;
|
|
77
|
+
const entry = (await readAll())[key];
|
|
78
|
+
if (!entry || typeof entry.state !== "string" || Date.now() - entry.savedAt > SESSION_TTL_MS)
|
|
79
|
+
return null;
|
|
80
|
+
return { savedAt: entry.savedAt, by: typeof entry.by === "string" ? entry.by : null };
|
|
81
|
+
}
|
|
82
|
+
/** Forget every saved session (after clearing the config). */
|
|
59
83
|
export async function clearSessions() {
|
|
60
84
|
await rm(sessionsPath(), { force: true });
|
|
61
85
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wilm-ai/wilma-cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Read your children's Wilma (Finnish school system) from the terminal or an AI assistant: schedule, homework, exams, messages and news.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"scripts": {
|
|
22
22
|
"build": "tsc -p tsconfig.json",
|
|
23
23
|
"lint": "tsc -p tsconfig.json --noEmit",
|
|
24
|
-
"test": "pnpm --filter @wilm-ai/wilma-client build && pnpm run build && node test/news-resources.mjs && node test/mcp-login.mjs && node test/mfa.mjs && node test/audit.mjs && node test/update-notice.mjs",
|
|
24
|
+
"test": "pnpm --filter @wilm-ai/wilma-client build && pnpm run build && node test/news-resources.mjs && node test/mcp-login.mjs && node test/mfa.mjs && node test/logout.mjs && node test/shared-session.mjs && node test/audit.mjs && node test/update-notice.mjs",
|
|
25
25
|
"start": "node dist/index.js",
|
|
26
26
|
"prepack": "pnpm --filter @wilm-ai/wilma-client build && pnpm --filter @wilm-ai/wilma-cli build",
|
|
27
27
|
"test:live": "tsc -p tsconfig.json && node test/live.spec.mjs",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@inquirer/prompts": "^5.3.8",
|
|
32
32
|
"@modelcontextprotocol/sdk": "^1.32.0",
|
|
33
|
-
"@wilm-ai/wilma-client": "^1.
|
|
33
|
+
"@wilm-ai/wilma-client": "^1.7.0",
|
|
34
34
|
"zod": "^4.1.0"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|