moodle-cli 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/moodle.js CHANGED
@@ -98,10 +98,10 @@ function postRow(p, subject, tz = "UTC") {
98
98
  }
99
99
 
100
100
  // src/doctor.ts
101
- import { access, readdir as readdir2, readFile as readFile4 } from "fs/promises";
102
- import { constants as constants2 } from "fs";
103
- import { homedir as homedir6 } from "os";
104
- import { join as join7 } from "path";
101
+ import { access as access3, readdir as readdir3, readFile as readFile4 } from "fs/promises";
102
+ import { constants as constants3 } from "fs";
103
+ import { homedir as homedir8 } from "os";
104
+ import { join as join9 } from "path";
105
105
 
106
106
  // src/session-fetch.ts
107
107
  var MAX_REDIRECTS = 5;
@@ -127,7 +127,12 @@ async function fetchWithSession(input2, init, moodleOrigin, cookie, fetchImpl =
127
127
  headers.delete("authorization");
128
128
  headers.delete("proxy-authorization");
129
129
  }
130
- const response = await fetchImpl(url.toString(), { ...init, method, body, headers: Object.fromEntries(headers), signal, redirect: "manual" });
130
+ let response;
131
+ try {
132
+ response = await fetchImpl(url.toString(), { ...init, method, body, headers: Object.fromEntries(headers), signal, redirect: "manual" });
133
+ } catch (error) {
134
+ throw requestFailure(error, url, deadline);
135
+ }
131
136
  if (!REDIRECT_STATUSES.has(response.status)) return response;
132
137
  const location = response.headers.get("location");
133
138
  if (!location) return response;
@@ -148,13 +153,31 @@ async function fetchWithSession(input2, init, moodleOrigin, cookie, fetchImpl =
148
153
  url = next;
149
154
  }
150
155
  }
156
+ var RequestFailed = class extends Error {
157
+ host;
158
+ timedOut;
159
+ constructor(host, timedOut, cause) {
160
+ super(timedOut ? `${host} did not respond within ${REQUEST_TIMEOUT_MS / 1e3}s.` : `Could not reach ${host}: ${cause}`);
161
+ this.name = "RequestFailed";
162
+ this.host = host;
163
+ this.timedOut = timedOut;
164
+ }
165
+ };
166
+ function requestFailure(error, url, deadline) {
167
+ if (deadline.aborted) {
168
+ return new RequestFailed(url.host, true);
169
+ }
170
+ if (!(error instanceof Error)) {
171
+ return error;
172
+ }
173
+ return new RequestFailed(url.host, false, error.cause instanceof Error ? error.cause.message : error.message);
174
+ }
151
175
 
152
176
  // src/auth.ts
153
177
  import { ALL_PROFILES, getCookies } from "@steipete/sweet-cookie";
154
- import { execFile as execFileCallback } from "child_process";
155
- import { readdir } from "fs/promises";
156
- import { homedir as homedir3 } from "os";
157
- import { join as join3 } from "path";
178
+ import { readdir as readdir2 } from "fs/promises";
179
+ import { homedir as homedir5 } from "os";
180
+ import { join as join5 } from "path";
158
181
 
159
182
  // src/constants.ts
160
183
  var PACKAGE_NAME = "moodle-cli";
@@ -174,6 +197,15 @@ var GRADE_REPORT_INDEX_PATH = "/grade/report/index.php";
174
197
  var GRADE_REPORT_OVERVIEW_PATH = "/grade/report/overview/index.php";
175
198
  var GRADE_REPORT_PATH = "/grade/report/user/index.php";
176
199
  var LOGIN_PATH = "/login/index.php";
200
+ var MOBILE_LAUNCH_PATH = "/admin/tool/mobile/launch.php";
201
+ var MOBILE_AUTOLOGIN_PATH = "/admin/tool/mobile/autologin.php";
202
+ var WEBSERVICE_REST_PATH = "/webservice/rest/server.php";
203
+ var SERVICE_NOLOGIN_PATH = "/lib/ajax/service-nologin.php";
204
+ var MOBILE_SERVICE_SHORTNAME = "moodle_mobile_app";
205
+ var MOBILE_URL_SCHEME = "moodlecli";
206
+ var MOBILE_USER_AGENT = "MoodleMobile 4.5.0 (moodle-cli)";
207
+ var FUNC_MOBILE_PUBLIC_CONFIG = "tool_mobile_get_public_config";
208
+ var FUNC_MOBILE_AUTOLOGIN_KEY = "tool_mobile_get_autologin_key";
177
209
  var FUNC_GET_SITE_INFO = "core_webservice_get_site_info";
178
210
  var FUNC_GET_COURSES = "core_enrol_get_users_courses";
179
211
  var FUNC_GET_COURSES_BY_TIMELINE = "core_course_get_enrolled_courses_by_timeline_classification";
@@ -192,6 +224,7 @@ var CONFIG_FILENAME = "config.yaml";
192
224
  var CONFIG_DIR_NAME = ".config/moodle-cli";
193
225
  var CACHE_DIR_NAME = ".cache/moodle-cli";
194
226
  var SESSION_CACHE_FILENAME = "session.json";
227
+ var CDP_PROFILE_DIR_NAME = ".cache/moodle-cli/browser-profile";
195
228
  var DEFAULT_SESSION_CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
196
229
  var KEEPALIVE_LAUNCH_AGENT_LABEL = "com.moodle-cli.keepalive";
197
230
  var KEEPALIVE_DEFAULT_INTERVAL_MINUTES = 30;
@@ -204,6 +237,466 @@ var ENV_MOODLE_TOKEN = "MOODLE_TOKEN";
204
237
  var MOODLE_SESSION_COOKIE_PREFIX = "MoodleSession";
205
238
  var WRANGLER_VERSION = "4.131.0";
206
239
 
240
+ // src/cookie-stores.ts
241
+ import { constants } from "fs";
242
+ import { access, readdir } from "fs/promises";
243
+ import { homedir } from "os";
244
+ import { join } from "path";
245
+ var CHROMIUM_ROOTS = [
246
+ ["Chrome", "Google/Chrome"],
247
+ ["Edge", "Microsoft Edge"],
248
+ ["Brave", "BraveSoftware/Brave-Browser"]
249
+ ];
250
+ var CHROMIUM_FILES = ["Cookies", "Network/Cookies"];
251
+ var SAFARI_FILES = [
252
+ "Library/Cookies/Cookies.binarycookies",
253
+ "Library/Containers/com.apple.Safari/Data/Library/Cookies/Cookies.binarycookies"
254
+ ];
255
+ function probe(path5, mode) {
256
+ return access(path5, mode).then(() => true, () => false);
257
+ }
258
+ async function chromiumProfiles(root) {
259
+ const entries = await readdir(root).catch(() => []);
260
+ const profiles = entries.filter((entry) => entry === "Default" || entry.startsWith("Profile "));
261
+ return profiles.length ? profiles : ["Default"];
262
+ }
263
+ async function browserCookieStores(options = {}) {
264
+ if ((options.platform ?? process.platform) !== "darwin") {
265
+ return [];
266
+ }
267
+ const home = options.homeDir ?? homedir();
268
+ const stores = [];
269
+ const add = async (browser, path5) => {
270
+ if (await probe(path5, constants.F_OK)) {
271
+ stores.push({ browser, path: path5, readable: await probe(path5, constants.R_OK) });
272
+ }
273
+ };
274
+ for (const [browser, directory] of CHROMIUM_ROOTS) {
275
+ const root = join(home, "Library/Application Support", directory);
276
+ for (const profile of await chromiumProfiles(root)) {
277
+ for (const file2 of CHROMIUM_FILES) {
278
+ await add(browser, join(root, profile, file2));
279
+ }
280
+ }
281
+ }
282
+ const firefoxRoot = join(home, "Library/Application Support/Firefox/Profiles");
283
+ for (const profile of await readdir(firefoxRoot).catch(() => [])) {
284
+ await add("Firefox", join(firefoxRoot, profile, "cookies.sqlite"));
285
+ }
286
+ for (const file2 of SAFARI_FILES) {
287
+ await add("Safari", join(home, file2));
288
+ }
289
+ return stores;
290
+ }
291
+ function unreadableCookieStores(stores) {
292
+ return stores.filter((store) => !store.readable);
293
+ }
294
+ function cookieStoresBlocked(stores) {
295
+ return stores.length > 0 && stores.every((store) => !store.readable);
296
+ }
297
+
298
+ // src/cdp-login.ts
299
+ import { spawn } from "child_process";
300
+ import { access as access2, mkdir } from "fs/promises";
301
+ import { accessSync, constants as fsConstants } from "fs";
302
+ import { homedir as homedir2 } from "os";
303
+ import { join as join2 } from "path";
304
+ var CdpError = class extends Error {
305
+ constructor(message, hint) {
306
+ super(message);
307
+ this.hint = hint;
308
+ this.name = "CdpError";
309
+ }
310
+ hint;
311
+ };
312
+ var NO_CHROMIUM_HINT = "Install Google Chrome, Microsoft Edge, Brave, or Chromium, or run `moodle auth login --paste` to hand over the cookie yourself.";
313
+ var DEFAULT_INTERACTIVE_TIMEOUT_MS = 3e5;
314
+ var DEFAULT_HEADLESS_TIMEOUT_MS = 45e3;
315
+ var HANDSHAKE_TIMEOUT_MS = 15e3;
316
+ var RPC_TIMEOUT_MS = 1e4;
317
+ var CLOSE_TIMEOUT_MS = 2e3;
318
+ var KILL_GRACE_MS = 1e3;
319
+ function withTimeout(promise, ms, onTimeout) {
320
+ return new Promise((resolve, reject) => {
321
+ const timer = setTimeout(() => resolve(onTimeout()), ms);
322
+ timer.unref?.();
323
+ promise.then(
324
+ (value) => {
325
+ clearTimeout(timer);
326
+ resolve(value);
327
+ },
328
+ (error) => {
329
+ clearTimeout(timer);
330
+ reject(error instanceof Error ? error : new Error(String(error)));
331
+ }
332
+ );
333
+ });
334
+ }
335
+ function cdpProfileDir(homeDir = homedir2()) {
336
+ return join2(homeDir, CDP_PROFILE_DIR_NAME);
337
+ }
338
+ function launchFlags(profileDir, url, headless) {
339
+ return [
340
+ `--user-data-dir=${profileDir}`,
341
+ "--remote-debugging-pipe",
342
+ "--no-first-run",
343
+ "--no-default-browser-check",
344
+ "--no-service-autorun",
345
+ "--disable-sync",
346
+ "--disable-background-networking",
347
+ "--disable-features=Translate,MediaRouter,OptimizationHints",
348
+ // macOS: use an in-memory key so decrypting cookies never prompts Keychain.
349
+ "--use-mock-keychain",
350
+ // Linux: avoid a gnome-keyring/kwallet unlock prompt for the same reason.
351
+ "--password-store=basic",
352
+ ...headless ? ["--headless=new"] : [],
353
+ url
354
+ ];
355
+ }
356
+ var MAC_BROWSERS = [
357
+ { name: "Google Chrome", path: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" },
358
+ { name: "Microsoft Edge", path: "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" },
359
+ { name: "Brave", path: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser" },
360
+ { name: "Chromium", path: "/Applications/Chromium.app/Contents/MacOS/Chromium" }
361
+ ];
362
+ var LINUX_BROWSERS = [
363
+ { name: "Google Chrome", path: "google-chrome" },
364
+ { name: "Google Chrome", path: "google-chrome-stable" },
365
+ { name: "Chromium", path: "chromium" },
366
+ { name: "Chromium", path: "chromium-browser" },
367
+ { name: "Microsoft Edge", path: "microsoft-edge" },
368
+ { name: "Brave", path: "brave-browser" }
369
+ ];
370
+ function windowsBrowsers(env) {
371
+ const roots = [env.PROGRAMFILES, env["PROGRAMFILES(X86)"], env.LOCALAPPDATA].filter(Boolean);
372
+ const relative = [
373
+ ["Google Chrome", "Google/Chrome/Application/chrome.exe"],
374
+ ["Microsoft Edge", "Microsoft/Edge/Application/msedge.exe"],
375
+ ["Brave", "BraveSoftware/Brave-Browser/Application/brave.exe"],
376
+ ["Chromium", "Chromium/Application/chrome.exe"]
377
+ ];
378
+ return roots.flatMap((root) => relative.map(([name, tail]) => ({ name, path: join2(root, tail) })));
379
+ }
380
+ async function findChromiumBrowser(options = {}) {
381
+ if (options.browserPath) {
382
+ return { name: "Chromium", path: options.browserPath };
383
+ }
384
+ const platform = options.platform ?? process.platform;
385
+ const env = options.env ?? process.env;
386
+ if (platform === "linux") {
387
+ for (const candidate of LINUX_BROWSERS) {
388
+ const resolved = resolveFromPath(candidate.path, env, platform);
389
+ if (resolved) return { name: candidate.name, path: resolved };
390
+ }
391
+ return null;
392
+ }
393
+ const candidates = platform === "win32" ? windowsBrowsers(env) : MAC_BROWSERS;
394
+ for (const candidate of candidates) {
395
+ if (await isExecutable(candidate.path)) return candidate;
396
+ }
397
+ return null;
398
+ }
399
+ async function loginWithCdp(options) {
400
+ const browser = await findChromiumBrowser(options);
401
+ if (!browser) {
402
+ throw new CdpError("No Chromium-family browser is installed.", NO_CHROMIUM_HINT);
403
+ }
404
+ const profileDir = options.profileDir ?? cdpProfileDir(options.homeDir);
405
+ await mkdir(profileDir, { recursive: true, mode: 448 });
406
+ const headless = options.headless ?? false;
407
+ const spawnImpl = options.spawn ?? spawn;
408
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
409
+ const now = options.now ?? Date.now;
410
+ const timeoutMs = options.timeoutMs ?? (headless ? DEFAULT_HEADLESS_TIMEOUT_MS : DEFAULT_INTERACTIVE_TIMEOUT_MS);
411
+ const pollIntervalMs = options.pollIntervalMs ?? 500;
412
+ const child = spawnImpl(browser.path, launchFlags(profileDir, options.url, headless), {
413
+ stdio: ["ignore", "ignore", "pipe", "pipe", "pipe"]
414
+ });
415
+ const connection = new CdpConnection(child, { rpc: options.rpcTimeoutMs, close: options.closeTimeoutMs });
416
+ try {
417
+ await connection.handshake(options.handshakeTimeoutMs ?? HANDSHAKE_TIMEOUT_MS);
418
+ options.onOpened?.();
419
+ const deadline = now() + timeoutMs;
420
+ while (now() < deadline) {
421
+ if (connection.closed) {
422
+ throw new CdpError(
423
+ "The browser closed before sign-in completed.",
424
+ "Another moodle login or renewal may be using the same browser profile. Wait for it to finish, then retry."
425
+ );
426
+ }
427
+ const cookies = await connection.getCookies();
428
+ if (await options.isDone(cookies)) {
429
+ return { cookies, browserName: browser.name };
430
+ }
431
+ await sleep(pollIntervalMs);
432
+ }
433
+ throw new CdpError(
434
+ headless ? "Timed out renewing the session in the background browser." : "Timed out waiting for sign-in.",
435
+ headless ? "Run `moodle auth login` to sign in again." : "Complete the sign-in in the browser window, then retry."
436
+ );
437
+ } finally {
438
+ await connection.close();
439
+ }
440
+ }
441
+ var CdpConnection = class {
442
+ constructor(child, timeouts = {}) {
443
+ this.child = child;
444
+ this.timeouts = timeouts;
445
+ child.on("exit", () => {
446
+ this.exited = true;
447
+ clearTimeout(this.hardKill);
448
+ this.markClosed();
449
+ });
450
+ child.on("error", () => this.markClosed());
451
+ const toBrowser = child.stdio[3];
452
+ toBrowser?.on("error", () => this.markClosed());
453
+ const fromBrowser = child.stdio[4];
454
+ fromBrowser?.on("error", () => this.markClosed());
455
+ fromBrowser?.on("data", (chunk) => this.consume(chunk));
456
+ }
457
+ child;
458
+ timeouts;
459
+ nextId = 1;
460
+ buffer = "";
461
+ pending = /* @__PURE__ */ new Map();
462
+ closed = false;
463
+ exited = false;
464
+ hardKill;
465
+ markClosed() {
466
+ if (this.closed) return;
467
+ this.closed = true;
468
+ for (const resolve of this.pending.values()) resolve({ error: { message: "browser closed" } });
469
+ this.pending.clear();
470
+ }
471
+ consume(chunk) {
472
+ this.buffer += chunk.toString("utf8");
473
+ let index;
474
+ while ((index = this.buffer.indexOf("\0")) !== -1) {
475
+ const raw = this.buffer.slice(0, index);
476
+ this.buffer = this.buffer.slice(index + 1);
477
+ let message;
478
+ try {
479
+ message = JSON.parse(raw);
480
+ } catch {
481
+ continue;
482
+ }
483
+ if (typeof message.id === "number") {
484
+ this.pending.get(message.id)?.(message);
485
+ this.pending.delete(message.id);
486
+ }
487
+ }
488
+ }
489
+ call(method, params = {}, timeoutMs = this.timeouts.rpc ?? RPC_TIMEOUT_MS) {
490
+ if (this.closed) return Promise.resolve({ error: { message: "browser closed" } });
491
+ const id2 = this.nextId++;
492
+ const toBrowser = this.child.stdio[3];
493
+ const request = new Promise((resolve) => {
494
+ this.pending.set(id2, resolve);
495
+ try {
496
+ toBrowser?.write(`${JSON.stringify({ id: id2, method, params })}\0`);
497
+ } catch {
498
+ this.pending.delete(id2);
499
+ resolve({ error: { message: "pipe write failed" } });
500
+ }
501
+ });
502
+ return withTimeout(request, timeoutMs, () => {
503
+ this.pending.delete(id2);
504
+ return { error: { message: "timeout" } };
505
+ });
506
+ }
507
+ async handshake(timeoutMs) {
508
+ const version = await this.call("Browser.getVersion", {}, timeoutMs);
509
+ if (version.error || this.closed) {
510
+ throw new CdpError(
511
+ "Could not talk to the browser over remote debugging.",
512
+ "Update the browser, or run `moodle auth login --paste` instead."
513
+ );
514
+ }
515
+ }
516
+ async getCookies() {
517
+ const response = await this.call("Storage.getCookies");
518
+ const cookies = response.result?.cookies ?? [];
519
+ return cookies.map((cookie) => ({
520
+ name: String(cookie.name ?? ""),
521
+ value: String(cookie.value ?? ""),
522
+ domain: String(cookie.domain ?? ""),
523
+ path: typeof cookie.path === "string" ? cookie.path : void 0,
524
+ secure: Boolean(cookie.secure),
525
+ httpOnly: Boolean(cookie.httpOnly)
526
+ }));
527
+ }
528
+ async close() {
529
+ if (!this.closed) {
530
+ await this.call("Browser.close", {}, this.timeouts.close ?? CLOSE_TIMEOUT_MS);
531
+ }
532
+ if (this.exited) return;
533
+ if (!this.child.killed) this.child.kill();
534
+ if (!this.exited) {
535
+ this.hardKill = setTimeout(() => {
536
+ if (!this.exited) this.child.kill("SIGKILL");
537
+ }, KILL_GRACE_MS);
538
+ this.hardKill.unref?.();
539
+ }
540
+ }
541
+ };
542
+ async function isExecutable(path5) {
543
+ try {
544
+ await access2(path5, fsConstants.X_OK);
545
+ return true;
546
+ } catch {
547
+ return false;
548
+ }
549
+ }
550
+ function resolveFromPath(name, env, platform) {
551
+ if (name.includes("/")) return null;
552
+ const separator = platform === "win32" ? ";" : ":";
553
+ for (const dir of (env.PATH ?? "").split(separator).filter(Boolean)) {
554
+ const candidate = join2(dir, name);
555
+ try {
556
+ accessSync(candidate, fsConstants.X_OK);
557
+ return candidate;
558
+ } catch {
559
+ continue;
560
+ }
561
+ }
562
+ return null;
563
+ }
564
+
565
+ // src/mobile-login-core.ts
566
+ function parseLaunchToken(location) {
567
+ const match = /token=([^&#]+)/.exec(location);
568
+ if (!match) return null;
569
+ let decoded;
570
+ try {
571
+ decoded = Buffer.from(decodeURIComponent(match[1]), "base64").toString("utf8");
572
+ } catch {
573
+ return null;
574
+ }
575
+ const parts = decoded.split(":::");
576
+ if (parts.length < 2 || !parts[1]) return null;
577
+ return { wstoken: parts[1], privatetoken: parts[2] || void 0 };
578
+ }
579
+ async function readMobilePublicConfig(baseUrl, fetchImpl = fetch) {
580
+ const url = new URL(SERVICE_NOLOGIN_PATH, ensureTrailingSlash(baseUrl));
581
+ url.searchParams.set("info", FUNC_MOBILE_PUBLIC_CONFIG);
582
+ const body = JSON.stringify([{ index: 0, methodname: FUNC_MOBILE_PUBLIC_CONFIG, args: {} }]);
583
+ let response;
584
+ try {
585
+ response = await fetchImpl(url.toString(), {
586
+ method: "POST",
587
+ headers: { "content-type": "application/json" },
588
+ body
589
+ });
590
+ } catch {
591
+ return null;
592
+ }
593
+ if (!response.ok) return null;
594
+ let payload;
595
+ try {
596
+ payload = await response.json();
597
+ } catch {
598
+ return null;
599
+ }
600
+ const data = Array.isArray(payload) ? payload[0] : void 0;
601
+ if (!data || data.error) return null;
602
+ const config = data.data;
603
+ if (!config) return null;
604
+ return {
605
+ webserviceEnabled: config.enablewebservices === 1 || config.enablewebservices === true,
606
+ mobileServiceEnabled: config.enablemobilewebservice === 1 || config.enablemobilewebservice === true
607
+ };
608
+ }
609
+ async function fetchMobileToken(baseUrl, cookie, fetchImpl = fetch) {
610
+ const url = new URL(MOBILE_LAUNCH_PATH, ensureTrailingSlash(baseUrl));
611
+ url.searchParams.set("service", MOBILE_SERVICE_SHORTNAME);
612
+ url.searchParams.set("passport", randomPassport());
613
+ url.searchParams.set("urlscheme", MOBILE_URL_SCHEME);
614
+ let response;
615
+ try {
616
+ response = await fetchImpl(url.toString(), {
617
+ method: "GET",
618
+ headers: { "user-agent": MOBILE_USER_AGENT, cookie: `${cookie.name}=${cookie.value}` },
619
+ redirect: "manual"
620
+ });
621
+ } catch {
622
+ return null;
623
+ }
624
+ const location = response.headers.get("location");
625
+ if (location) {
626
+ await response.body?.cancel();
627
+ return parseLaunchToken(location);
628
+ }
629
+ const html = await response.text().catch(() => "");
630
+ const inline = new RegExp(`${MOBILE_URL_SCHEME}://[^"'\\s]*token=[^"'\\s]+`).exec(html);
631
+ return inline ? parseLaunchToken(inline[0]) : null;
632
+ }
633
+ async function mintSessionFromMobileToken(baseUrl, userid, token, fetchImpl = fetch) {
634
+ if (!token.privatetoken) return null;
635
+ const key = await requestAutologinKey(baseUrl, token, fetchImpl);
636
+ if (!key) return null;
637
+ return exchangeAutologinKey(baseUrl, userid, key, fetchImpl);
638
+ }
639
+ async function requestAutologinKey(baseUrl, token, fetchImpl) {
640
+ const url = new URL(WEBSERVICE_REST_PATH, ensureTrailingSlash(baseUrl));
641
+ url.searchParams.set("moodlewsrestformat", "json");
642
+ url.searchParams.set("wsfunction", FUNC_MOBILE_AUTOLOGIN_KEY);
643
+ url.searchParams.set("wstoken", token.wstoken);
644
+ const form = new URLSearchParams({ privatetoken: token.privatetoken ?? "" });
645
+ let response;
646
+ try {
647
+ response = await fetchImpl(url.toString(), {
648
+ method: "POST",
649
+ headers: { "content-type": "application/x-www-form-urlencoded", "user-agent": MOBILE_USER_AGENT },
650
+ body: form.toString()
651
+ });
652
+ } catch {
653
+ return null;
654
+ }
655
+ if (!response.ok) return null;
656
+ let payload;
657
+ try {
658
+ payload = await response.json();
659
+ } catch {
660
+ return null;
661
+ }
662
+ if (!payload || typeof payload !== "object" || "exception" in payload) return null;
663
+ const key = payload.key;
664
+ return typeof key === "string" && key ? key : null;
665
+ }
666
+ async function exchangeAutologinKey(baseUrl, userid, key, fetchImpl) {
667
+ const url = new URL(MOBILE_AUTOLOGIN_PATH, ensureTrailingSlash(baseUrl));
668
+ url.searchParams.set("userid", String(userid));
669
+ url.searchParams.set("key", key);
670
+ let response;
671
+ try {
672
+ response = await fetchImpl(url.toString(), {
673
+ method: "GET",
674
+ headers: { "user-agent": MOBILE_USER_AGENT },
675
+ redirect: "manual"
676
+ });
677
+ } catch {
678
+ return null;
679
+ }
680
+ const value = extractSessionCookie(response);
681
+ return value ? { cookie: { name: value.name, value: value.value, source: "mobile-token" } } : null;
682
+ }
683
+ function extractSessionCookie(response) {
684
+ const headers = typeof response.headers.getSetCookie === "function" ? response.headers.getSetCookie() : [response.headers.get("set-cookie") ?? ""].filter(Boolean);
685
+ for (const header of headers) {
686
+ const match = new RegExp(`(${MOODLE_SESSION_COOKIE_PREFIX}\\w*)=([^;\\s]+)`).exec(header);
687
+ if (match && match[2] && match[2] !== "deleted") {
688
+ return { name: match[1], value: match[2] };
689
+ }
690
+ }
691
+ return null;
692
+ }
693
+ function ensureTrailingSlash(baseUrl) {
694
+ return `${baseUrl.replace(/\/+$/, "")}/`;
695
+ }
696
+ function randomPassport() {
697
+ return (Math.random() * 1e3).toFixed(10);
698
+ }
699
+
207
700
  // src/errors.ts
208
701
  import { CliError } from "@bunizao/cli-kit";
209
702
  import { CliError as CliError2 } from "@bunizao/cli-kit";
@@ -242,15 +735,18 @@ function isLoginRequiredError(error) {
242
735
  function isLoginErrorCode(code) {
243
736
  return ["servicerequireslogin", "sitepolicynotagreed"].includes(code ?? "");
244
737
  }
738
+ function asNetworkError(error) {
739
+ return error instanceof RequestFailed ? new CliError("network", error.message, "Check the connection or VPN, then retry.") : null;
740
+ }
245
741
 
246
742
  // src/session-cache.ts
247
743
  import { createHash, randomBytes } from "crypto";
248
744
 
249
745
  // src/mcp/credentials/node-store.ts
250
- import { spawn } from "child_process";
251
- import { chmod, mkdir, readFile, rename, rm, writeFile } from "fs/promises";
252
- import { homedir } from "os";
253
- import { dirname, join, win32 as windowsPath } from "path";
746
+ import { spawn as spawn2 } from "child_process";
747
+ import { chmod, mkdir as mkdir2, readFile, rename, rm, writeFile } from "fs/promises";
748
+ import { homedir as homedir3 } from "os";
749
+ import { dirname, join as join3, win32 as windowsPath } from "path";
254
750
 
255
751
  // src/mcp/credentials/store.ts
256
752
  var TOKEN_OVERLAP_MS = 10 * 60 * 1e3;
@@ -475,7 +971,7 @@ if ([IO.File]::Exists($payload.path)) { Remove-Item -LiteralPath $payload.path -
475
971
  var NodeCredentialCommandRunner = class {
476
972
  async run(command, args, input2) {
477
973
  return new Promise((resolve, reject) => {
478
- const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
974
+ const child = spawn2(command, args, { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
479
975
  let stdout = "";
480
976
  let stderr = "";
481
977
  child.stdout.setEncoding("utf8").on("data", (chunk) => {
@@ -720,7 +1216,7 @@ var UnavailableCredentialBackend = class {
720
1216
  }
721
1217
  };
722
1218
  var PrivateFileCredentialBackend = class {
723
- constructor(baseDirectory = join(homedir(), ".config", "moodle-cli", "mcp", "credentials")) {
1219
+ constructor(baseDirectory = join3(homedir3(), ".config", "moodle-cli", "mcp", "credentials")) {
724
1220
  this.baseDirectory = baseDirectory;
725
1221
  }
726
1222
  baseDirectory;
@@ -739,7 +1235,7 @@ var PrivateFileCredentialBackend = class {
739
1235
  async write(profile, credentials) {
740
1236
  const path5 = this.path(profile);
741
1237
  const temporary = `${path5}.${process.pid}.tmp`;
742
- await mkdir(dirname(path5), { recursive: true, mode: 448 });
1238
+ await mkdir2(dirname(path5), { recursive: true, mode: 448 });
743
1239
  await chmod(dirname(path5), 448);
744
1240
  await writeFile(temporary, `${JSON.stringify(credentials)}
745
1241
  `, { encoding: "utf8", mode: 384 });
@@ -752,15 +1248,15 @@ var PrivateFileCredentialBackend = class {
752
1248
  }
753
1249
  path(profile) {
754
1250
  validateProfile(profile);
755
- return join(this.baseDirectory, `${profile}.json`);
1251
+ return join3(this.baseDirectory, `${profile}.json`);
756
1252
  }
757
1253
  };
758
1254
  function createDefaultCredentialStore(options = {}) {
759
1255
  const platform = options.platform ?? process.platform;
760
1256
  const runner = options.runner ?? new NodeCredentialCommandRunner();
761
1257
  const preferred = platform === "darwin" ? new MacOSKeychainCredentialBackend(runner) : platform === "linux" ? new LinuxSecretServiceCredentialBackend(runner) : platform === "win32" ? new WindowsCredentialManagerBackend(runner) : new UnavailableCredentialBackend(`${platform} credential store`);
762
- const home = options.homeDirectory ?? homedir();
763
- const fallbackDirectory = platform === "win32" ? windowsPath.join(home, "AppData", "Local", "moodle-cli", "credentials") : join(home, ".config", "moodle-cli", "mcp", "credentials");
1258
+ const home = options.homeDirectory ?? homedir3();
1259
+ const fallbackDirectory = platform === "win32" ? windowsPath.join(home, "AppData", "Local", "moodle-cli", "credentials") : join3(home, ".config", "moodle-cli", "mcp", "credentials");
764
1260
  const fallback = platform === "win32" ? new WindowsDpapiFileCredentialBackend(fallbackDirectory, runner) : new PrivateFileCredentialBackend(fallbackDirectory);
765
1261
  return new SafeCredentialStore(preferred, fallback, platform !== "win32");
766
1262
  }
@@ -897,16 +1393,16 @@ function isRecord(value) {
897
1393
  }
898
1394
 
899
1395
  // src/session-cache.ts
900
- import { chmod as chmod2, mkdir as mkdir2, readFile as readFile2, rm as rm2, writeFile as writeFile2 } from "fs/promises";
901
- import { homedir as homedir2 } from "os";
902
- import { dirname as dirname2, join as join2 } from "path";
903
- var nodeFs = { readFile: readFile2, writeFile: writeFile2, mkdir: mkdir2, rm: rm2, chmod: chmod2 };
904
- function sessionCachePath(homeDir = homedir2()) {
905
- return join2(homeDir, CACHE_DIR_NAME, SESSION_CACHE_FILENAME);
1396
+ import { chmod as chmod2, mkdir as mkdir3, readFile as readFile2, rm as rm2, writeFile as writeFile2 } from "fs/promises";
1397
+ import { homedir as homedir4 } from "os";
1398
+ import { dirname as dirname2, join as join4 } from "path";
1399
+ var nodeFs = { readFile: readFile2, writeFile: writeFile2, mkdir: mkdir3, rm: rm2, chmod: chmod2 };
1400
+ function sessionCachePath(homeDir = homedir4()) {
1401
+ return join4(homeDir, CACHE_DIR_NAME, SESSION_CACHE_FILENAME);
906
1402
  }
907
1403
  function isCachedSessionFresh(session, ttlMs = DEFAULT_SESSION_CACHE_TTL_MS, now = Date.now) {
908
1404
  const age = now() - session.savedAt;
909
- return age >= 0 && age <= ttlMs;
1405
+ return !session.cookieInvalidated && age >= 0 && age <= ttlMs;
910
1406
  }
911
1407
  async function readCachedSession(baseUrl, options = {}) {
912
1408
  if (options.noCache) {
@@ -941,7 +1437,7 @@ async function readCachedSession(baseUrl, options = {}) {
941
1437
  return null;
942
1438
  }
943
1439
  const ttlMs = options.ttlMs ?? DEFAULT_SESSION_CACHE_TTL_MS;
944
- return isCachedSessionFresh(session, ttlMs, options.now ?? Date.now) ? session : null;
1440
+ return options.allowExpired || isCachedSessionFresh(session, ttlMs, options.now ?? Date.now) ? session : null;
945
1441
  }
946
1442
  async function writeCachedSession(session, options = {}) {
947
1443
  if (options.noCache) return;
@@ -955,7 +1451,7 @@ async function writeCachedSession(session, options = {}) {
955
1451
  await fs.chmod(path5, 384);
956
1452
  }
957
1453
  async function deleteCachedSession(baseUrl, options = {}) {
958
- const current2 = await readCachedSession(baseUrl, { ...options, noCache: false, ttlMs: Number.MAX_SAFE_INTEGER });
1454
+ const current2 = await readCachedSession(baseUrl, { ...options, noCache: false, allowExpired: true });
959
1455
  if (!current2) {
960
1456
  return;
961
1457
  }
@@ -989,9 +1485,12 @@ function parseCachedSession(raw) {
989
1485
  sesskey: session.sesskey,
990
1486
  userid: session.userid,
991
1487
  savedAt: session.savedAt,
1488
+ ...session.cookieInvalidated === true ? { cookieInvalidated: true } : {},
992
1489
  ...typeof session.cookieSource === "string" ? { cookieSource: session.cookieSource } : {},
993
1490
  ...Array.isArray(session.unavailable) && session.unavailable.every((name) => typeof name === "string") ? { unavailable: session.unavailable } : {},
994
- ...isRecord2(session.user) && typeof session.user.fullname === "string" && typeof session.user.userid === "number" ? { user: session.user } : {}
1491
+ ...isRecord2(session.user) && typeof session.user.fullname === "string" && typeof session.user.userid === "number" ? { user: session.user } : {},
1492
+ ...isRecord2(session.mobileToken) && typeof session.mobileToken.wstoken === "string" ? { mobileToken: { wstoken: session.mobileToken.wstoken, ...typeof session.mobileToken.privatetoken === "string" ? { privatetoken: session.mobileToken.privatetoken } : {} } } : {},
1493
+ ...typeof session.mobileServiceEnabled === "boolean" ? { mobileServiceEnabled: session.mobileServiceEnabled } : {}
995
1494
  };
996
1495
  }
997
1496
  function sameBaseUrl(left, right) {
@@ -1010,7 +1509,7 @@ function isMissingFileError(error) {
1010
1509
  var pendingCacheKeys = /* @__PURE__ */ new Map();
1011
1510
  async function cacheEncryptionKey(options) {
1012
1511
  if (options.encryptionKey) return options.encryptionKey();
1013
- const homeDirectory = options.homeDir ?? homedir2();
1512
+ const homeDirectory = options.homeDir ?? homedir4();
1014
1513
  let pending = pendingCacheKeys.get(homeDirectory);
1015
1514
  if (!pending) {
1016
1515
  pending = (async () => {
@@ -1029,6 +1528,23 @@ async function cacheEncryptionKey(options) {
1029
1528
  }
1030
1529
 
1031
1530
  // src/auth.ts
1531
+ var COOKIE_STORE_TIMEOUT_MS = 8e3;
1532
+ function withTimeoutValue(promise, ms, onTimeout) {
1533
+ return new Promise((resolve, reject) => {
1534
+ const timer = setTimeout(() => resolve(onTimeout()), ms);
1535
+ timer.unref?.();
1536
+ promise.then(
1537
+ (value) => {
1538
+ clearTimeout(timer);
1539
+ resolve(value);
1540
+ },
1541
+ (error) => {
1542
+ clearTimeout(timer);
1543
+ reject(error instanceof Error ? error : new Error(String(error)));
1544
+ }
1545
+ );
1546
+ });
1547
+ }
1032
1548
  async function getAuthenticatedSession(baseUrl, options = {}) {
1033
1549
  const envSession = loadSessionFromEnv(options.env);
1034
1550
  const validate = options.validateSession ?? validateSessionWithFetch(options);
@@ -1047,6 +1563,10 @@ async function getAuthenticatedSession(baseUrl, options = {}) {
1047
1563
  if (cached) {
1048
1564
  return cached;
1049
1565
  }
1566
+ const minted = await mintFromStoredToken(baseUrl, options, validate);
1567
+ if (minted) {
1568
+ return minted;
1569
+ }
1050
1570
  const cookieWarnings = [];
1051
1571
  const providerOptions = {
1052
1572
  ...options,
@@ -1062,17 +1582,25 @@ async function getAuthenticatedSession(baseUrl, options = {}) {
1062
1582
  await refreshSessionCache(baseUrl, browserSession.cookie, browserSession.context, options);
1063
1583
  return { baseUrl, cookie: browserSession.cookie, ...browserSession.context, fromCache: false };
1064
1584
  }
1585
+ const stores = await browserCookieStores({ homeDir: options.homeDir, platform: options.platform });
1065
1586
  throw new AuthError(
1066
1587
  `No usable MoodleSession found for ${baseUrl}.`,
1067
- authFailureHint(baseUrl, cookieWarnings, options.platform)
1588
+ authFailureHint(baseUrl, cookieWarnings, options.platform, unreadableCookieStores(stores), options.env)
1068
1589
  );
1069
1590
  }
1070
1591
  async function getAuthenticatedSessionWithBrowserFallback(baseUrl, options = {}) {
1071
1592
  const cookieWarnings = [];
1593
+ const rawProvider = options.browserCookieProvider ?? defaultBrowserCookieProvider;
1594
+ const cookieStoreTimeoutMs = options.cookieStoreTimeoutMs ?? COOKIE_STORE_TIMEOUT_MS;
1595
+ const boundedProvider = (url, opts) => withTimeoutValue(rawProvider(url, opts), cookieStoreTimeoutMs, () => {
1596
+ opts.onCookieWarnings?.(["Reading the browser cookie store timed out; opening a browser to sign in."]);
1597
+ return [];
1598
+ });
1072
1599
  const authOptions = {
1073
1600
  ...options,
1074
1601
  noCache: true,
1075
1602
  nonInteractive: true,
1603
+ browserCookieProvider: boundedProvider,
1076
1604
  onCookieWarnings: (warnings) => {
1077
1605
  cookieWarnings.push(...warnings);
1078
1606
  options.onCookieWarnings?.(warnings);
@@ -1098,34 +1626,92 @@ async function getAuthenticatedSessionWithBrowserFallback(baseUrl, options = {})
1098
1626
  }
1099
1627
  }
1100
1628
  }
1101
- if (cookieAccessBlocked(cookieWarnings)) {
1102
- throw new AuthError(
1103
- `Cannot read browser cookies for ${baseUrl}.`,
1104
- cookieAccessHint(cookieWarnings, options.platform)
1105
- );
1629
+ const probeBrowser = options.findBrowser ?? findChromiumBrowser;
1630
+ const hasBrowser = Boolean(await probeBrowser({ ...options }));
1631
+ if (!hasBrowser) {
1632
+ const stores = await browserCookieStores({ homeDir: options.homeDir, platform: options.platform });
1633
+ if (cookieAccessBlocked(cookieWarnings) || cookieStoresBlocked(stores)) {
1634
+ throw new AuthError(
1635
+ `Cannot read browser cookies for ${baseUrl}.`,
1636
+ cookieAccessHint(cookieWarnings, options.platform, unreadableCookieStores(stores), options.env)
1637
+ );
1638
+ }
1106
1639
  }
1640
+ return loginViaCdp(baseUrl, { ...options, ...browserAuthOptions });
1641
+ }
1642
+ function toSessionCookie(cookie) {
1643
+ return { name: cookie.name, value: cookie.value, domain: cookie.domain, path: cookie.path, source: "cdp" };
1644
+ }
1645
+ async function loginViaCdp(baseUrl, options) {
1646
+ const validate = options.validateSession ?? validateSessionWithFetch(options);
1647
+ const runCdp = options.cdpLogin ?? loginWithCdp;
1107
1648
  const url = loginUrl(baseUrl);
1108
- await (options.openBrowser ?? ((target) => openSystemBrowser(target, options)))(url);
1109
- options.onBrowserOpened?.(url);
1110
- const pollIntervalMs = options.browserLoginPollIntervalMs ?? 1e3;
1111
- const timeoutMs = options.browserLoginTimeoutMs ?? 12e4;
1112
- const attempts = Math.max(1, Math.ceil(timeoutMs / pollIntervalMs));
1113
- const sleep = options.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
1114
- const pollOptions = browserAuthOptions;
1115
- for (let attempt = 0; attempt < attempts; attempt += 1) {
1116
- await sleep(pollIntervalMs);
1117
- try {
1118
- return await getAuthenticatedSession(baseUrl, pollOptions);
1119
- } catch (error) {
1120
- if (!(error instanceof AuthError)) {
1121
- throw error;
1649
+ let resolved = null;
1650
+ let lastChecked = "";
1651
+ try {
1652
+ await runCdp({
1653
+ url,
1654
+ headless: options.headlessCdp ?? false,
1655
+ homeDir: options.homeDir,
1656
+ platform: options.platform,
1657
+ env: options.env,
1658
+ onOpened: () => options.onBrowserOpened?.(url),
1659
+ isDone: async (cookies) => {
1660
+ if (resolved) return true;
1661
+ const top = matchingMoodleSessionCookies(cookies.map(toSessionCookie), baseUrl)[0];
1662
+ if (!top || top.value === lastChecked) return false;
1663
+ lastChecked = top.value;
1664
+ const context = await validate(baseUrl, top);
1665
+ if (context) {
1666
+ resolved = { cookie: top, context };
1667
+ return true;
1668
+ }
1669
+ return false;
1122
1670
  }
1671
+ });
1672
+ } catch (error) {
1673
+ if (error instanceof CdpError) {
1674
+ throw new AuthError(error.message, error.hint ?? authFailureHint(baseUrl, [], options.platform, [], options.env));
1123
1675
  }
1676
+ throw error;
1124
1677
  }
1125
- throw new AuthError(
1126
- `Timed out waiting for browser login at ${baseUrl}.`,
1127
- `Complete the login in your browser, then rerun: moodle auth login`
1128
- );
1678
+ const session = resolved;
1679
+ if (!session) {
1680
+ throw new AuthError(`Sign-in did not complete for ${baseUrl}.`, `Run: moodle auth login`);
1681
+ }
1682
+ await refreshSessionCache(baseUrl, session.cookie, session.context, { ...options, noCache: false });
1683
+ return { baseUrl, cookie: session.cookie, ...session.context, fromCache: false };
1684
+ }
1685
+ function parsePastedSessionCookie(raw) {
1686
+ const text2 = raw.trim();
1687
+ if (!text2) {
1688
+ return null;
1689
+ }
1690
+ const pair = /\b(MoodleSession\w*)=([^;\s'"\\]+)/.exec(text2);
1691
+ if (pair) {
1692
+ return { name: pair[1], value: pair[2], source: "paste" };
1693
+ }
1694
+ return /[\s=;]/.test(text2) ? null : { name: MOODLE_SESSION_COOKIE_PREFIX, value: text2, source: "paste" };
1695
+ }
1696
+ async function authenticateWithPastedCookie(baseUrl, raw, options = {}) {
1697
+ const cookie = parsePastedSessionCookie(raw);
1698
+ if (!cookie) {
1699
+ throw new AuthError(`That is not a ${MOODLE_SESSION_COOKIE_PREFIX} cookie value.`, pastedCookieHint(baseUrl));
1700
+ }
1701
+ const validate = options.validateSession ?? validateSessionWithFetch(options);
1702
+ const context = await validate(baseUrl, cookie);
1703
+ if (!context) {
1704
+ throw new AuthError(`The pasted cookie did not authenticate for ${baseUrl}.`, pastedCookieHint(baseUrl));
1705
+ }
1706
+ await refreshSessionCache(baseUrl, cookie, context, { ...options, noCache: false });
1707
+ return { baseUrl, cookie, ...context, fromCache: false };
1708
+ }
1709
+ function pastedCookieHint(baseUrl) {
1710
+ return [
1711
+ `Sign in at ${loginUrl(baseUrl)}, then open the browser developer tools.`,
1712
+ 'In the Network tab, right-click any request to the site and choose "Copy as cURL", then paste the whole command.',
1713
+ `Copying the ${MOODLE_SESSION_COOKIE_PREFIX} value from Application (or Storage) > Cookies works too.`
1714
+ ].join("\n");
1129
1715
  }
1130
1716
  function loadSessionFromEnv(env = process.env) {
1131
1717
  const source = env[ENV_MOODLE_TOKEN] ? ENV_MOODLE_TOKEN : ENV_MOODLE_SESSION;
@@ -1177,17 +1763,17 @@ async function defaultBrowserCookieProvider(baseUrl, options = {}) {
1177
1763
  }));
1178
1764
  }
1179
1765
  async function braveProfilePaths(options = {}) {
1180
- const home = options.homeDir ?? homedir3();
1766
+ const home = options.homeDir ?? homedir5();
1181
1767
  const platform = options.platform ?? process.platform;
1182
1768
  const roots = platform === "linux" ? [
1183
- join3(home, ".config/BraveSoftware/Brave-Browser"),
1184
- join3(home, ".var/app/com.brave.Browser/config/BraveSoftware/Brave-Browser")
1185
- ] : platform === "win32" ? [join3(home, "AppData/Local/BraveSoftware/Brave-Browser/User Data")] : platform === "darwin" ? [join3(home, "Library/Application Support/BraveSoftware/Brave-Browser")] : [];
1769
+ join5(home, ".config/BraveSoftware/Brave-Browser"),
1770
+ join5(home, ".var/app/com.brave.Browser/config/BraveSoftware/Brave-Browser")
1771
+ ] : platform === "win32" ? [join5(home, "AppData/Local/BraveSoftware/Brave-Browser/User Data")] : platform === "darwin" ? [join5(home, "Library/Application Support/BraveSoftware/Brave-Browser")] : [];
1186
1772
  const profiles = [];
1187
1773
  for (const root of roots) {
1188
1774
  try {
1189
1775
  profiles.push(
1190
- ...(await readdir(root, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name).filter((name) => name === "Default" || name === "Guest Profile" || name.startsWith("Profile ")).sort().map((name) => join3(root, name))
1776
+ ...(await readdir2(root, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name).filter((name) => name === "Default" || name === "Guest Profile" || name.startsWith("Profile ")).sort().map((name) => join5(root, name))
1191
1777
  );
1192
1778
  } catch {
1193
1779
  continue;
@@ -1195,46 +1781,70 @@ async function braveProfilePaths(options = {}) {
1195
1781
  }
1196
1782
  return profiles;
1197
1783
  }
1784
+ var FULL_DISK_ACCESS_PANE = "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles";
1785
+ var TERMINAL_APPLICATIONS = {
1786
+ Apple_Terminal: "Terminal",
1787
+ ghostty: "Ghostty",
1788
+ Hyper: "Hyper",
1789
+ "iTerm.app": "iTerm",
1790
+ Tabby: "Tabby",
1791
+ vscode: "Visual Studio Code",
1792
+ WarpTerminal: "Warp",
1793
+ WezTerm: "WezTerm"
1794
+ };
1795
+ function hostApplicationName(env = process.env) {
1796
+ const program = env.TERM_PROGRAM?.trim();
1797
+ return program ? TERMINAL_APPLICATIONS[program] ?? program : null;
1798
+ }
1198
1799
  var COOKIE_ACCESS_DENIED = /EPERM|EACCES|operation not permitted|permission denied/i;
1199
1800
  var COOKIE_SQLITE_UNAVAILABLE = /No such built-in module: node:sqlite/i;
1200
1801
  var MINIMUM_NODE_FOR_BROWSER_COOKIES = "22.13.0";
1201
1802
  function cookieAccessBlocked(warnings) {
1202
1803
  return warnings.some((warning) => COOKIE_ACCESS_DENIED.test(warning) || COOKIE_SQLITE_UNAVAILABLE.test(warning));
1203
1804
  }
1204
- function cookieAccessHint(warnings, platform = process.platform) {
1205
- const grant = platform === "darwin" ? "Grant Full Disk Access to the application running this command (System Settings > Privacy & Security > Full Disk Access), then restart it." : "Run this command as the user that owns the browser profile, or grant it read access to the browser cookie store.";
1206
- const remedy = warnings.some((warning) => COOKIE_SQLITE_UNAVAILABLE.test(warning)) ? [
1207
- `This Node.js runtime has no node:sqlite, which is needed to read browser cookies. Use Node.js ${MINIMUM_NODE_FOR_BROWSER_COOKIES} or newer, or run the CLI with Bun (bunx --bun moodle-cli).`
1208
- ] : [
1209
- "If this runs inside a sandboxed app (an IDE or agent terminal), rerun it from a regular terminal first.",
1210
- grant
1211
- ];
1805
+ function cookieAccessHint(warnings, platform = process.platform, unreadable = [], env = process.env) {
1806
+ const grant = platform === "darwin" ? [
1807
+ `Grant Full Disk Access to ${hostApplicationName(env) ?? "the application running this command"}, then restart it:`,
1808
+ ` open "${FULL_DISK_ACCESS_PANE}"`
1809
+ ].join("\n") : "Run this command as the user that owns the browser profile, or grant it read access to the browser cookie store.";
1810
+ const remedy = [];
1811
+ if (warnings.some((warning) => COOKIE_SQLITE_UNAVAILABLE.test(warning))) {
1812
+ remedy.push(
1813
+ `This Node.js runtime has no node:sqlite, which is needed to read browser cookies. Use Node.js ${MINIMUM_NODE_FOR_BROWSER_COOKIES} or newer, or run the CLI with Bun (bunx --bun moodle-cli).`
1814
+ );
1815
+ }
1816
+ if (unreadable.length || !remedy.length) {
1817
+ remedy.push(
1818
+ "If this runs inside a sandboxed app (an IDE or agent terminal), rerun it from a regular terminal first.",
1819
+ grant
1820
+ );
1821
+ }
1212
1822
  return [
1213
1823
  "The browser cookie store could not be read, so the session could not be detected.",
1214
1824
  ...remedy,
1825
+ "Or skip the store entirely: `moodle auth login --paste` takes the cookie by hand and caches it.",
1215
1826
  "Run moodle doctor for runtime and browser diagnostics.",
1216
- `Alternatively set ${ENV_MOODLE_SESSION} to a valid MoodleSession cookie value.`,
1217
- "",
1218
- "Cookie store diagnostics:",
1219
- ...warnings.map((warning) => ` - ${warning}`)
1827
+ ...cookieDiagnostics(warnings, unreadable)
1220
1828
  ].join("\n");
1221
1829
  }
1222
- function authFailureHint(baseUrl, cookieWarnings = [], platform = process.platform) {
1223
- if (cookieAccessBlocked(cookieWarnings)) {
1224
- return cookieAccessHint(cookieWarnings, platform);
1225
- }
1830
+ function cookieDiagnostics(warnings, unreadable) {
1831
+ const probed = unreadable.map((store) => store.path);
1226
1832
  const lines = [
1227
- `Log in to ${loginUrl(baseUrl)} in your browser, then rerun the command.`,
1228
- "Or run `moodle auth login` to sign in through a browser window this command controls.",
1229
- `Or set ${ENV_MOODLE_SESSION} to a valid MoodleSession cookie value.`
1833
+ ...unreadable.map((store) => ` - ${store.browser} cookie store exists but cannot be opened: ${store.path}`),
1834
+ ...warnings.filter((warning) => COOKIE_ACCESS_DENIED.test(warning) || COOKIE_SQLITE_UNAVAILABLE.test(warning)).filter((warning) => !probed.some((path5) => warning.includes(path5))).map((warning) => ` - ${warning}`)
1230
1835
  ];
1231
- if (cookieWarnings.length) {
1232
- lines.push("", "Cookie store diagnostics:", ...cookieWarnings.map((warning) => ` - ${warning}`));
1233
- }
1234
- return lines.join("\n");
1836
+ return lines.length ? ["", "Cookie store diagnostics:", ...lines] : [];
1235
1837
  }
1236
- async function invalidateCachedSession(baseUrl, options = {}) {
1237
- await deleteCachedSession(baseUrl, cacheOptions(options));
1838
+ function authFailureHint(baseUrl, cookieWarnings = [], platform = process.platform, unreadable = [], env = process.env) {
1839
+ if (cookieAccessBlocked(cookieWarnings) || unreadable.length) {
1840
+ return cookieAccessHint(cookieWarnings, platform, unreadable, env);
1841
+ }
1842
+ return [
1843
+ `Log in to ${loginUrl(baseUrl)} in your browser, then rerun the command.`,
1844
+ "Or run `moodle auth login` to sign in through a browser window this command controls.",
1845
+ "Or run `moodle auth login --paste` to hand over the cookie yourself.",
1846
+ ...cookieDiagnostics(cookieWarnings, unreadable)
1847
+ ].join("\n");
1238
1848
  }
1239
1849
  function parseSessionContext(html) {
1240
1850
  const sesskey = firstMatch(html, [
@@ -1262,7 +1872,11 @@ function validateSessionWithFetch(options) {
1262
1872
  let response;
1263
1873
  try {
1264
1874
  response = await fetchWithSession(`${baseUrl}${DASHBOARD_PATH}`, {}, baseUrl, cookie, fetcher);
1265
- } catch {
1875
+ } catch (error) {
1876
+ const network = asNetworkError(error);
1877
+ if (network) {
1878
+ throw network;
1879
+ }
1266
1880
  return null;
1267
1881
  }
1268
1882
  if (response.status >= 400 || isLoginRedirect(response.url, baseUrl)) {
@@ -1303,16 +1917,59 @@ async function refreshSessionCache(baseUrl, cookie, context, options) {
1303
1917
  savedAt: (options.now ?? Date.now)()
1304
1918
  };
1305
1919
  try {
1306
- const previous = await readCachedSession(baseUrl, { ...cacheOptions(options), ttlMs: Number.MAX_SAFE_INTEGER });
1920
+ const previous = await readCachedSession(baseUrl, { ...cacheOptions(options), allowExpired: true });
1921
+ if (typeof previous?.mobileServiceEnabled === "boolean") session.mobileServiceEnabled = previous.mobileServiceEnabled;
1307
1922
  if (previous?.userid === context.userid) {
1308
1923
  if (previous.unavailable?.length) session.unavailable = previous.unavailable;
1309
1924
  if (previous.user) session.user = previous.user;
1925
+ if (previous.mobileToken?.privatetoken) session.mobileToken = previous.mobileToken;
1926
+ }
1927
+ const newCookie = !previous || previous.cookieValue !== cookie.value;
1928
+ if (!session.mobileToken && newCookie && options.captureMobileToken && session.mobileServiceEnabled !== false) {
1929
+ const captured = await captureMobileToken(baseUrl, cookie, options);
1930
+ session.mobileServiceEnabled = captured.supported;
1931
+ if (captured.token) session.mobileToken = captured.token;
1310
1932
  }
1311
1933
  await writeCachedSession(session, cacheOptions(options));
1312
1934
  } catch {
1313
1935
  return;
1314
1936
  }
1315
1937
  }
1938
+ async function captureMobileToken(baseUrl, cookie, options) {
1939
+ const fetchImpl = options.fetch ?? globalThis.fetch;
1940
+ try {
1941
+ const config = await readMobilePublicConfig(baseUrl, fetchImpl);
1942
+ if (config && !config.mobileServiceEnabled) return { supported: false };
1943
+ const token = await fetchMobileToken(baseUrl, cookie, fetchImpl) ?? void 0;
1944
+ return { supported: config?.mobileServiceEnabled ?? (token ? true : void 0), token };
1945
+ } catch {
1946
+ return {};
1947
+ }
1948
+ }
1949
+ async function mintValidatedSession(baseUrl, stored, options = {}) {
1950
+ if (!stored.mobileToken?.privatetoken) return null;
1951
+ const fetchImpl = options.fetch ?? globalThis.fetch;
1952
+ const minted = await mintSessionFromMobileToken(baseUrl, stored.userid, stored.mobileToken, fetchImpl);
1953
+ if (!minted) return null;
1954
+ const cookie = { name: minted.cookie.name, value: minted.cookie.value, source: minted.cookie.source };
1955
+ const validate = options.validateSession ?? validateSessionWithFetch(options);
1956
+ const context = await validate(baseUrl, cookie);
1957
+ if (!context || context.userid === 0 || context.userid !== stored.userid) return null;
1958
+ return { cookie, context };
1959
+ }
1960
+ async function mintFromStoredToken(baseUrl, options, validate) {
1961
+ let stored;
1962
+ try {
1963
+ stored = await readCachedSession(baseUrl, { ...cacheOptions(options), allowExpired: true });
1964
+ } catch {
1965
+ return null;
1966
+ }
1967
+ if (!stored?.mobileToken?.privatetoken) return null;
1968
+ const result = await mintValidatedSession(baseUrl, stored, { ...options, validateSession: validate });
1969
+ if (!result) return null;
1970
+ await refreshSessionCache(baseUrl, result.cookie, result.context, options);
1971
+ return { baseUrl, cookie: result.cookie, ...result.context, fromCache: false };
1972
+ }
1316
1973
  function cachedSessionToAuth(baseUrl, cached) {
1317
1974
  return {
1318
1975
  baseUrl,
@@ -1368,32 +2025,11 @@ function firstMatch(value, patterns) {
1368
2025
  function decodeHtml(value) {
1369
2026
  return value.replace(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">");
1370
2027
  }
1371
- async function openSystemBrowser(url, options) {
1372
- const platform = options.platform ?? process.platform;
1373
- const command = platform === "darwin" ? { file: "open", args: [url] } : platform === "win32" ? { file: "cmd", args: ["/c", "start", "", url] } : { file: "xdg-open", args: [url] };
1374
- const result = await (options.execFile ?? defaultExecFile)(command.file, command.args);
1375
- if (result.exitCode !== 0) {
1376
- throw new AuthError(
1377
- `Could not open the browser for Moodle login.`,
1378
- `Open ${url} manually, then rerun: moodle auth login`
1379
- );
1380
- }
1381
- }
1382
- var defaultExecFile = (file2, args) => new Promise((resolve) => {
1383
- execFileCallback(file2, args, { encoding: "utf8" }, (error, stdout, stderr) => {
1384
- const errorWithCode = error;
1385
- resolve({
1386
- stdout: String(stdout ?? ""),
1387
- stderr: String(stderr ?? ""),
1388
- exitCode: errorWithCode ? Number(errorWithCode.code) || 1 : 0
1389
- });
1390
- });
1391
- });
1392
2028
 
1393
2029
  // src/config.ts
1394
- import { mkdir as mkdir3, readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
1395
- import { homedir as homedir4 } from "os";
1396
- import { dirname as dirname3, join as join4 } from "path";
2030
+ import { mkdir as mkdir4, readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
2031
+ import { homedir as homedir6 } from "os";
2032
+ import { dirname as dirname3, join as join6 } from "path";
1397
2033
  import { createUi, isAgentEnvironment } from "@bunizao/cli-kit";
1398
2034
 
1399
2035
  // src/wordmark.ts
@@ -1413,12 +2049,12 @@ function showWordmark(ui) {
1413
2049
 
1414
2050
  // src/config.ts
1415
2051
  import YAML from "yaml";
1416
- var nodeFs2 = { readFile: readFile3, writeFile: writeFile3, mkdir: mkdir3 };
2052
+ var nodeFs2 = { readFile: readFile3, writeFile: writeFile3, mkdir: mkdir4 };
1417
2053
  function cwdConfigPath(cwd = process.cwd()) {
1418
- return join4(cwd, CONFIG_FILENAME);
2054
+ return join6(cwd, CONFIG_FILENAME);
1419
2055
  }
1420
- function userConfigPath(homeDir = homedir4()) {
1421
- return join4(homeDir, CONFIG_DIR_NAME, CONFIG_FILENAME);
2056
+ function userConfigPath(homeDir = homedir6()) {
2057
+ return join6(homeDir, CONFIG_DIR_NAME, CONFIG_FILENAME);
1422
2058
  }
1423
2059
  function normalizeBaseUrl(value) {
1424
2060
  const raw = value.trim();
@@ -1493,12 +2129,12 @@ async function promptForBaseUrl(options = {}) {
1493
2129
  }
1494
2130
  const spin = ui.spinner();
1495
2131
  spin.start(`Checking ${baseUrl}`);
1496
- const probe = await probeBaseUrl(baseUrl, options);
1497
- if (probe.ok) {
2132
+ const probe2 = await probeBaseUrl(baseUrl, options);
2133
+ if (probe2.ok) {
1498
2134
  spin.stop(`${baseUrl} looks like Moodle`);
1499
2135
  return baseUrl;
1500
2136
  }
1501
- spin.error(`Validation failed: ${probe.message ?? "site did not look like Moodle"}`);
2137
+ spin.error(`Validation failed: ${probe2.message ?? "site did not look like Moodle"}`);
1502
2138
  }
1503
2139
  }
1504
2140
  async function probeBaseUrl(baseUrl, options = {}) {
@@ -1594,15 +2230,15 @@ function isMissingFileError2(error) {
1594
2230
  }
1595
2231
 
1596
2232
  // src/mcp/self-command.ts
1597
- import { accessSync, constants, realpathSync } from "fs";
1598
- import { delimiter, join as join5 } from "path";
2233
+ import { accessSync as accessSync2, constants as constants2, realpathSync } from "fs";
2234
+ import { delimiter, join as join7 } from "path";
1599
2235
  import { spawnSync } from "child_process";
1600
2236
  function findExecutable(name, env = process.env) {
1601
2237
  for (const dir of (env.PATH ?? "").split(delimiter).filter(Boolean)) {
1602
2238
  for (const suffix of process.platform === "win32" ? [".exe", ".cmd", ""] : [""]) {
1603
- const file2 = join5(dir, name + suffix);
2239
+ const file2 = join7(dir, name + suffix);
1604
2240
  try {
1605
- accessSync(file2, constants.X_OK);
2241
+ accessSync2(file2, constants2.X_OK);
1606
2242
  return realpathSync(file2);
1607
2243
  } catch {
1608
2244
  }
@@ -1632,9 +2268,9 @@ function runtimeCommand(command, args) {
1632
2268
  // src/keepalive.ts
1633
2269
  import { spawnSync as spawnSync2 } from "child_process";
1634
2270
  import { realpathSync as realpathSync2 } from "fs";
1635
- import { mkdir as mkdir4, rm as rm3, stat, writeFile as writeFile4 } from "fs/promises";
1636
- import { homedir as homedir5 } from "os";
1637
- import { dirname as dirname4, join as join6 } from "path";
2271
+ import { mkdir as mkdir5, rm as rm3, stat, writeFile as writeFile4 } from "fs/promises";
2272
+ import { homedir as homedir7 } from "os";
2273
+ import { dirname as dirname4, join as join8 } from "path";
1638
2274
  async function touchMoodleSession(baseUrl, cookie, sesskey, fetchImpl = fetch, extend = true) {
1639
2275
  const methods = extend ? [FUNC_SESSION_TOUCH, FUNC_SESSION_TIME_REMAINING] : [FUNC_SESSION_TIME_REMAINING];
1640
2276
  const url = `${baseUrl.replace(/\/$/, "")}${AJAX_SERVICE_PATH}?sesskey=${encodeURIComponent(sesskey)}&info=${methods.join(",")}`;
@@ -1680,13 +2316,13 @@ async function touchMoodleSession(baseUrl, cookie, sesskey, fetchImpl = fetch, e
1680
2316
  async function keepAliveOnce(baseUrl, options = {}) {
1681
2317
  const session = await readCachedSession(baseUrl, {
1682
2318
  homeDir: options.homeDir,
1683
- ttlMs: Number.MAX_SAFE_INTEGER,
2319
+ allowExpired: true,
1684
2320
  now: options.now
1685
2321
  });
1686
2322
  if (!session) {
1687
2323
  return { status: "no_session", time_remaining_seconds: null };
1688
2324
  }
1689
- const touch = await touchMoodleSession(
2325
+ const touch = session.cookieInvalidated ? { alive: false, timeRemainingSeconds: null } : await touchMoodleSession(
1690
2326
  baseUrl,
1691
2327
  { name: session.cookieName, value: session.cookieValue },
1692
2328
  session.sesskey,
@@ -1702,6 +2338,10 @@ async function keepAliveOnce(baseUrl, options = {}) {
1702
2338
  if (options.renewOnExpiry === false) {
1703
2339
  return { status: "expired", time_remaining_seconds: null };
1704
2340
  }
2341
+ if (session.mobileToken) {
2342
+ const renewed = await renewViaMobileToken(baseUrl, session, options);
2343
+ if (renewed) return renewed;
2344
+ }
1705
2345
  const authenticate = options.authenticate ?? ((url) => getAuthenticatedSession(url, {
1706
2346
  homeDir: options.homeDir,
1707
2347
  fetch: options.fetchImpl,
@@ -1717,12 +2357,29 @@ async function keepAliveOnce(baseUrl, options = {}) {
1717
2357
  return { status: "expired", time_remaining_seconds: null };
1718
2358
  }
1719
2359
  }
2360
+ async function renewViaMobileToken(baseUrl, session, options) {
2361
+ const result = await mintValidatedSession(baseUrl, session, { fetch: options.fetchImpl }).catch(() => null);
2362
+ if (!result) return null;
2363
+ await writeCachedSession(
2364
+ {
2365
+ ...session,
2366
+ cookieInvalidated: false,
2367
+ cookieName: result.cookie.name,
2368
+ cookieValue: result.cookie.value,
2369
+ cookieSource: result.cookie.source,
2370
+ sesskey: result.context.sesskey,
2371
+ savedAt: (options.now ?? Date.now)()
2372
+ },
2373
+ { homeDir: options.homeDir }
2374
+ );
2375
+ return { status: "reauthenticated", time_remaining_seconds: null };
2376
+ }
1720
2377
  async function getAuthStatus(baseUrl, options = {}) {
1721
2378
  const now = options.now ?? Date.now;
1722
2379
  const keepalive = await keepaliveStatus(options.homeDir);
1723
2380
  const session = await readCachedSession(baseUrl, {
1724
2381
  homeDir: options.homeDir,
1725
- ttlMs: Number.MAX_SAFE_INTEGER,
2382
+ allowExpired: true,
1726
2383
  now: options.now
1727
2384
  });
1728
2385
  if (!session) {
@@ -1736,7 +2393,7 @@ async function getAuthStatus(baseUrl, options = {}) {
1736
2393
  keepalive_plist_path: keepalive.plist_path
1737
2394
  };
1738
2395
  }
1739
- const touch = await touchMoodleSession(
2396
+ const touch = session.cookieInvalidated ? { alive: false, timeRemainingSeconds: null } : await touchMoodleSession(
1740
2397
  baseUrl,
1741
2398
  { name: session.cookieName, value: session.cookieValue },
1742
2399
  session.sesskey,
@@ -1754,11 +2411,11 @@ async function getAuthStatus(baseUrl, options = {}) {
1754
2411
  keepalive_plist_path: keepalive.plist_path
1755
2412
  };
1756
2413
  }
1757
- function keepalivePlistPath(homeDir = homedir5()) {
1758
- return join6(homeDir, "Library/LaunchAgents", `${KEEPALIVE_LAUNCH_AGENT_LABEL}.plist`);
2414
+ function keepalivePlistPath(homeDir = homedir7()) {
2415
+ return join8(homeDir, "Library/LaunchAgents", `${KEEPALIVE_LAUNCH_AGENT_LABEL}.plist`);
1759
2416
  }
1760
- function keepaliveLogPath(homeDir = homedir5()) {
1761
- return join6(homeDir, CACHE_DIR_NAME, KEEPALIVE_LOG_FILENAME);
2417
+ function keepaliveLogPath(homeDir = homedir7()) {
2418
+ return join8(homeDir, CACHE_DIR_NAME, KEEPALIVE_LOG_FILENAME);
1762
2419
  }
1763
2420
  function buildKeepalivePlist(programArguments2, intervalMinutes, logPath) {
1764
2421
  const args = programArguments2.map((arg) => ` <string>${escapeXml(arg)}</string>`).join("\n");
@@ -1800,13 +2457,13 @@ async function installKeepalive(options = {}) {
1800
2457
  `This runtime (${process.version}) cannot read browser cookies, and the launch agent would be pinned to it. Reinstall with Node.js ${MINIMUM_NODE_FOR_BROWSER_COOKIES} or newer, or with Bun.`
1801
2458
  );
1802
2459
  }
1803
- const homeDir = options.homeDir ?? homedir5();
2460
+ const homeDir = options.homeDir ?? homedir7();
1804
2461
  const intervalMinutes = options.intervalMinutes ?? KEEPALIVE_DEFAULT_INTERVAL_MINUTES;
1805
2462
  const plistPath = keepalivePlistPath(homeDir);
1806
2463
  const logPath = keepaliveLogPath(homeDir);
1807
2464
  const command = [selectedRuntime.command, ...selectedRuntime.args, "auth", "keepalive", "--json"];
1808
- await mkdir4(dirname4(plistPath), { recursive: true });
1809
- await mkdir4(dirname4(logPath), { recursive: true, mode: 448 });
2465
+ await mkdir5(dirname4(plistPath), { recursive: true });
2466
+ await mkdir5(dirname4(logPath), { recursive: true, mode: 448 });
1810
2467
  await writeFile4(plistPath, buildKeepalivePlist(command, intervalMinutes, logPath), "utf8");
1811
2468
  const runCommand = options.runCommand ?? spawnSync2;
1812
2469
  const uid = options.uid ?? (typeof process.getuid === "function" ? process.getuid() : 0);
@@ -1821,7 +2478,7 @@ async function installKeepalive(options = {}) {
1821
2478
  return { plist_path: plistPath, interval_minutes: intervalMinutes, log_path: logPath, command };
1822
2479
  }
1823
2480
  async function uninstallKeepalive(options = {}) {
1824
- const homeDir = options.homeDir ?? homedir5();
2481
+ const homeDir = options.homeDir ?? homedir7();
1825
2482
  const plistPath = keepalivePlistPath(homeDir);
1826
2483
  const runCommand = options.runCommand ?? spawnSync2;
1827
2484
  const uid = options.uid ?? (typeof process.getuid === "function" ? process.getuid() : 0);
@@ -1829,7 +2486,7 @@ async function uninstallKeepalive(options = {}) {
1829
2486
  await rm3(plistPath, { force: true });
1830
2487
  return { installed: false, plist_path: plistPath };
1831
2488
  }
1832
- async function keepaliveStatus(homeDir = homedir5()) {
2489
+ async function keepaliveStatus(homeDir = homedir7()) {
1833
2490
  const plistPath = keepalivePlistPath(homeDir);
1834
2491
  try {
1835
2492
  return { installed: (await stat(plistPath)).isFile(), plist_path: plistPath };
@@ -1842,20 +2499,21 @@ function escapeXml(value) {
1842
2499
  }
1843
2500
 
1844
2501
  // src/doctor.ts
1845
- async function ownedJobs(homeDir = homedir6()) {
1846
- const root = join7(homeDir, "Library", "LaunchAgents");
1847
- const files = await readdir2(root).catch(() => []);
2502
+ async function ownedJobs(homeDir = homedir8()) {
2503
+ const root = join9(homeDir, "Library", "LaunchAgents");
2504
+ const files = await readdir3(root).catch(() => []);
1848
2505
  const jobs = [];
1849
2506
  for (const name of files.filter((n2) => n2 === "com.moodle-cli.keepalive.plist" || /^com\.moodle-cli\.mcp-renewal\.[a-z0-9_-]+\.plist$/u.test(n2))) {
1850
- const path5 = join7(root, name);
2507
+ const path5 = join9(root, name);
1851
2508
  const content = await readFile4(path5, "utf8");
1852
2509
  jobs.push({ path: path5, profile: name.match(/mcp-renewal\.(.+)\.plist$/u)?.[1], interpreter: content.match(/<key>ProgramArguments<\/key>\s*<array>\s*<string>([^<]+)<\/string>/u)?.[1] });
1853
2510
  }
1854
2511
  return jobs;
1855
2512
  }
1856
2513
  async function doctor(options = {}) {
1857
- const home = options.homeDir ?? homedir6();
2514
+ const home = options.homeDir ?? homedir8();
1858
2515
  const checks = [];
2516
+ const stores = await browserCookieStores({ homeDir: home });
1859
2517
  const cookies = runtimeSupportsCookies();
1860
2518
  checks.push({ name: "sqlite", status: cookies ? "pass" : "fail", detail: `${process.versions.bun ? "bun" : "node"} ${process.versions.bun ?? process.versions.node}`, ...cookies ? {} : { hint: "Install Bun or Node 22.13+; Safari cookie reads do not require SQLite." } });
1861
2519
  let baseUrl;
@@ -1875,43 +2533,19 @@ async function doctor(options = {}) {
1875
2533
  const warnings = [];
1876
2534
  try {
1877
2535
  const found = await defaultBrowserCookieProvider(baseUrl, { homeDir: home, onCookieWarnings: (items) => warnings.push(...items) });
1878
- const blocked = warnings.some((w) => /EPERM|EACCES|permission denied|operation not permitted/iu.test(w));
1879
- checks.push({ name: "browser", status: blocked ? "fail" : found.length ? "pass" : "warn", detail: blocked ? "Browser store access was denied." : found.length ? `Cookie sources: ${[...new Set(found.map((c) => c.source || "browser"))].join(", ")}` : "No browser session found.", ...blocked ? { hint: "System Settings > Privacy & Security > Full Disk Access: enable the app running this command, then restart it." } : !found.length ? { hint: "Sign in to Moodle in a supported browser, then run moodle auth login." } : {} });
2536
+ const blocked = !found.length && (cookieStoresBlocked(stores) || warnings.some((w) => /EPERM|EACCES|permission denied|operation not permitted/iu.test(w)));
2537
+ checks.push({ name: "browser", status: blocked ? "fail" : found.length ? "pass" : "warn", detail: blocked ? `Browser store access was denied (${unreadableCookieStores(stores).length} unreadable store(s)).` : found.length ? `Cookie sources: ${[...new Set(found.map((c) => c.source || "browser"))].join(", ")}` : "No browser session found.", ...blocked ? { hint: `System Settings > Privacy & Security > Full Disk Access: enable ${hostApplicationName(options.env) ?? "the app running this command"}, then restart it.` } : !found.length ? { hint: "Sign in to Moodle in a supported browser, then run moodle auth login." } : {} });
1880
2538
  } catch {
1881
2539
  checks.push({ name: "browser", status: "warn", detail: "Could not inspect browser stores.", hint: "Run moodle auth login from a regular terminal." });
1882
2540
  }
1883
2541
  }
1884
- const stores = [];
1885
- if (process.platform === "darwin") {
1886
- for (const [browser, directory] of [["Chrome", "Google/Chrome"], ["Edge", "Microsoft Edge"], ["Brave", "BraveSoftware/Brave-Browser"], ["Firefox", "Firefox/Profiles"]]) {
1887
- const root = join7(home, "Library/Application Support", directory);
1888
- for (const profile of await readdir2(root).catch(() => [])) {
1889
- if (browser !== "Firefox" && profile !== "Default" && !profile.startsWith("Profile ")) continue;
1890
- for (const name of browser === "Firefox" ? ["cookies.sqlite"] : ["Cookies", "Network/Cookies"]) {
1891
- const file2 = join7(root, profile, name);
1892
- try {
1893
- await access(file2);
1894
- stores.push({ browser, path: file2, readable: await access(file2, constants2.R_OK).then(() => true, () => false) });
1895
- } catch {
1896
- }
1897
- }
1898
- }
1899
- }
1900
- for (const file2 of [join7(home, "Library/Cookies/Cookies.binarycookies"), join7(home, "Library/Containers/com.apple.Safari/Data/Library/Cookies/Cookies.binarycookies")]) {
1901
- try {
1902
- await access(file2);
1903
- stores.push({ browser: "Safari", path: file2, readable: await access(file2, constants2.R_OK).then(() => true, () => false) });
1904
- } catch {
1905
- }
1906
- }
1907
- }
1908
2542
  const jobs = await ownedJobs(home);
1909
2543
  for (const job of jobs) {
1910
- const present = job.interpreter ? await access(job.interpreter, constants2.X_OK).then(() => true, () => false) : false;
2544
+ const present = job.interpreter ? await access3(job.interpreter, constants3.X_OK).then(() => true, () => false) : false;
1911
2545
  const supported = present && (job.interpreter === process.execPath && !runtimeCommand().args.length || runtimeSupportsCookies(job.interpreter));
1912
2546
  checks.push({ name: "job", status: supported ? "pass" : "warn", detail: `${job.path}: ${job.interpreter ?? "missing interpreter"}`, ...!supported ? { hint: job.profile ? "Run moodle mcp deploy to repair renewal." : "Run moodle auth keepalive install." } : {} });
1913
2547
  }
1914
- const profiles = await readdir2(join7(home, ".config", "moodle-cli", "mcp", "deployments")).catch(() => []);
2548
+ const profiles = await readdir3(join9(home, ".config", "moodle-cli", "mcp", "deployments")).catch(() => []);
1915
2549
  checks.push({ name: "mcp", status: profiles.length ? "pass" : "warn", detail: profiles.length ? `${profiles.length} local deployment receipts. Run moodle mcp status for remote readiness.` : "No managed MCP deployment; optional for CLI use." });
1916
2550
  let pin;
1917
2551
  try {
@@ -1922,8 +2556,8 @@ async function doctor(options = {}) {
1922
2556
  }
1923
2557
 
1924
2558
  // src/cli.ts
1925
- import { rm as rm7, readdir as readdir3 } from "fs/promises";
1926
- import { homedir as homedir13 } from "os";
2559
+ import { rm as rm7, readdir as readdir4 } from "fs/promises";
2560
+ import { homedir as homedir15 } from "os";
1927
2561
 
1928
2562
  // src/mcp/renewal/decision.ts
1929
2563
  function decideRenewal(snapshot) {
@@ -2212,15 +2846,15 @@ async function sendRenewalNotification(kind, sender) {
2212
2846
  }
2213
2847
 
2214
2848
  // src/mcp/renewal/node-renewal.ts
2215
- import { execFile as execFileCallback2 } from "child_process";
2216
- import { chmod as chmod3, mkdir as mkdir5, readFile as readFile5, rm as rm4, writeFile as writeFile5 } from "fs/promises";
2217
- import { homedir as homedir7 } from "os";
2849
+ import { execFile as execFileCallback } from "child_process";
2850
+ import { chmod as chmod3, mkdir as mkdir6, readFile as readFile5, rm as rm4, writeFile as writeFile5 } from "fs/promises";
2851
+ import { homedir as homedir9 } from "os";
2218
2852
  import { dirname as dirname5 } from "path";
2219
2853
  import { promisify } from "util";
2220
- var execFile = promisify(execFileCallback2);
2854
+ var execFile = promisify(execFileCallback);
2221
2855
  var NodeRenewalInstallerIO = class {
2222
2856
  async writePrivate(path5, content, mode) {
2223
- await mkdir5(dirname5(path5), { recursive: true, mode: 448 });
2857
+ await mkdir6(dirname5(path5), { recursive: true, mode: 448 });
2224
2858
  await writeFile5(path5, content, { encoding: "utf8", mode });
2225
2859
  await chmod3(path5, mode);
2226
2860
  }
@@ -2259,7 +2893,7 @@ function createDefaultRenewalInstaller(profile, options = {}) {
2259
2893
  profile,
2260
2894
  executable: runtime.command,
2261
2895
  executableArgs: runtime.args,
2262
- homeDirectory: options.homeDirectory ?? homedir7(),
2896
+ homeDirectory: options.homeDirectory ?? homedir9(),
2263
2897
  uid: options.uid ?? (typeof process.getuid === "function" ? process.getuid() : void 0),
2264
2898
  intervalMinutes: options.intervalMinutes
2265
2899
  });
@@ -2895,7 +3529,12 @@ function configureTerminalTables(options) {
2895
3529
  function renderTerminalTable(columns, rows, options = {}) {
2896
3530
  const theme = createTheme(colorEnabled());
2897
3531
  const width = Math.max(20, options.width ?? process.stdout.columns ?? DEFAULT_WIDTH);
2898
- const clean = rows.map((row) => columns.map((_, i) => sanitizeTerminalText(row[i] ?? "").replace(/\s+/gu, " ")));
3532
+ const cellText = (cell) => typeof cell === "string" ? cell : cell?.text ?? "";
3533
+ const tones = rows.map((row) => columns.map((_, i) => {
3534
+ const cell = row[i];
3535
+ return typeof cell === "object" ? cell.tone : void 0;
3536
+ }));
3537
+ const clean = rows.map((row) => columns.map((_, i) => sanitizeTerminalText(cellText(row[i])).replace(/\s+/gu, " ")));
2899
3538
  const lengths = columns.map((c, i) => Math.max(c.label.length, ...clean.map((r) => Array.from(r[i]).length)));
2900
3539
  const flexible = columns.map((c, i) => c.flex || /name|title|subject|feedback|description|value|course|unit/iu.test(c.label) ? i : -1).filter((i) => i >= 0);
2901
3540
  const widths = columns.map((c, i) => c.width ?? (flexible.includes(i) ? Math.min(lengths[i], 36) : lengths[i]));
@@ -2919,7 +3558,8 @@ function renderTerminalTable(columns, rows, options = {}) {
2919
3558
  const trimmed = chars.length > w ? `${chars.slice(0, w - 1).join("")}\u2026` : v;
2920
3559
  return trimmed + " ".repeat(Math.max(0, w - Array.from(trimmed).length));
2921
3560
  };
2922
- const paint = (cell, i, row) => {
3561
+ const paint = (cell, i, row, tone) => {
3562
+ if (tone) return theme.tone(tone, cell);
2923
3563
  if (options.keyValue) return i === 0 ? theme.dim(cell) : /status|grading|action/iu.test(row[0] ?? "") ? theme.status(cell) : cell;
2924
3564
  return i === 0 ? theme.key(cell) : TONED_LABEL.test(columns[i]?.label ?? "") ? theme.status(cell) : cell;
2925
3565
  };
@@ -2931,7 +3571,7 @@ function renderTerminalTable(columns, rows, options = {}) {
2931
3571
  border("\u250C", "\u252C", "\u2510"),
2932
3572
  line(columns.map((c) => c.label), (cell) => theme.dim(cell)),
2933
3573
  border("\u251C", "\u253C", "\u2524"),
2934
- ...clean.map((row) => line(row, (cell, i) => paint(cell, i, row))),
3574
+ ...clean.map((row, r) => line(row, (cell, i) => paint(cell, i, row, tones[r][i]))),
2935
3575
  border("\u2514", "\u2534", "\u2518")
2936
3576
  ].join("\n");
2937
3577
  }
@@ -2966,22 +3606,29 @@ function moment(value, now) {
2966
3606
  const sameYear = new Date(now).getFullYear() === Number(year);
2967
3607
  return `${weekday} ${Number(day)} ${MONTHS[Number(month) - 1]}${sameYear ? "" : ` ${year}`}${hour ? `, ${hour}:${minute}` : ""}`;
2968
3608
  }
3609
+ function tryLines(commands) {
3610
+ return commands.map((command, index) => `${index ? " " : "Try "}${command}`).join("\n");
3611
+ }
2969
3612
  function renderScreen(data, options = {}) {
2970
3613
  const lines = [];
2971
3614
  const now = options.now ?? Date.now();
2972
3615
  const theme = createTheme2(Boolean(options.color));
2973
- const dueText = (row) => {
2974
- if (!row.due_at) return theme.status(text(row.status || row.submission_status));
3616
+ const due = (row) => {
2975
3617
  const days = Math.ceil((Number(row.due_at) * 1e3 - now) / 864e5);
2976
3618
  const value = `${days < 0 ? `${-days} days overdue` : days === 0 ? "today" : days === 1 ? "tomorrow" : `in ${days} days`} \xB7 ${moment(row.due, now)}`;
2977
- return days < 0 ? theme.tone("danger", value) : days <= 2 ? theme.tone("warning", value) : theme.dim(value);
3619
+ return { text: value, tone: days < 0 ? "danger" : days <= 2 ? "warning" : "muted" };
3620
+ };
3621
+ const dueText = (row) => {
3622
+ if (!row.due_at) return theme.status(text(row.status || row.submission_status));
3623
+ const { text: value, tone } = due(row);
3624
+ return theme.tone(tone, value);
2978
3625
  };
2979
3626
  const rows = (items, title) => {
2980
3627
  lines.push(theme.subject(title));
2981
3628
  if (!items.length) lines.push(theme.dim(" None"));
2982
3629
  for (const r of items) lines.push(` ${theme.key(text(r.unit_code || r.type))} ${text(r.name)}${r.due_at ? ` ${dueText(r)}` : ""}${r.id ? ` ${theme.dim(`#${r.id}`)}` : ""}`);
2983
3630
  };
2984
- let next = "moodle due --days 30 \xB7 moodle grades";
3631
+ let next = ["moodle due --days 30", "moodle grades"];
2985
3632
  if (data.home) {
2986
3633
  const h = record(data.home);
2987
3634
  lines.push(`${text(h.name)} \xB7 ${moment(h.today, now)} \xB7 ${text(h.timezone)}${h.timezone_source === "site" ? "" : ` (${text(h.timezone_source)})`}`, text(h.siteurl), "");
@@ -3012,11 +3659,12 @@ function renderScreen(data, options = {}) {
3012
3659
  rows(array(data.news), "Latest news");
3013
3660
  }
3014
3661
  const unit = JSON.stringify(u.code || u.name);
3015
- next = array(data.sections).some((s2) => s2.activities) ? `moodle ${unit} "TASK" \xB7 moodle get "UNIT TASK" --to .` : `moodle ${unit} SECTION \xB7 moodle ${unit} grades`;
3662
+ next = array(data.sections).some((s2) => s2.activities) ? [`moodle ${unit} "TASK"`, `moodle get "UNIT TASK" --to .`] : [`moodle ${unit} SECTION`, `moodle ${unit} grades`];
3016
3663
  } else if (data.grades) {
3017
3664
  for (const g of array(data.grades)) {
3018
3665
  lines.push(`${text(g.code)} \xB7 ${g.graded} of ${g.total} graded`);
3019
- lines.push(renderTerminalTable([{ label: "Name", flex: true }, { label: "Grade" }, { label: "Range" }, { label: "Feedback", flex: true }], array(g.items).map((i) => [text(i.name), text(i.grade), text(i.range), text(i.feedback || (i.due ? dueText(i) : ""))]), { width: options.width }));
3666
+ const feedback = (i) => i.feedback ? text(i.feedback) : i.due_at ? due(i) : "";
3667
+ lines.push(renderTerminalTable([{ label: "Name", flex: true }, { label: "Grade" }, { label: "Range" }, { label: "Feedback", flex: true }], array(g.items).map((i) => [text(i.name), text(i.grade), text(i.range), feedback(i)]), { width: options.width }));
3020
3668
  }
3021
3669
  } else if (data.item) {
3022
3670
  const i = record(data.item);
@@ -3024,13 +3672,13 @@ function renderScreen(data, options = {}) {
3024
3672
  for (const [k, v] of Object.entries(i)) if (!["id", "name", "type", "files"].includes(k) && !k.endsWith("_at") && typeof v !== "object") lines.push(`${k.replaceAll("_", " ")}: ${moment(v, now)}`);
3025
3673
  for (const f of array(i.files)) lines.push(`File ${text(f.name)} ${text(f.url)}`);
3026
3674
  if (data.threads) rows(array(data.threads), "Threads");
3027
- next = `moodle get ${i.id} --to DIR`;
3675
+ next = [`moodle get ${i.id} --to DIR`];
3028
3676
  } else if (data.thread) {
3029
3677
  const t = record(data.thread);
3030
3678
  lines.push(text(t.name));
3031
3679
  for (const p of array(t.posts)) lines.push("", `${text(record(p.author).name)} \xB7 ${moment(p.created, now)}`, text(p.message_text), ...array(p.links).map((l) => `${text(l.text)} ${text(l.url)}`));
3032
3680
  lines.push(`Posts ${Number(t.offset) + array(t.posts).length} of ${t.posts_total}`);
3033
- next = `moodle threads show ${t.id} --offset ${Number(t.offset) + array(t.posts).length}`;
3681
+ next = [`moodle threads show ${t.id} --offset ${Number(t.offset) + array(t.posts).length}`];
3034
3682
  } else if (data.news) {
3035
3683
  for (const n2 of array(data.news)) {
3036
3684
  const p = record(n2.post);
@@ -3038,13 +3686,13 @@ function renderScreen(data, options = {}) {
3038
3686
  }
3039
3687
  } else if (data.units) {
3040
3688
  lines.push(renderTerminalTable([{ label: "ID" }, { label: "Code" }, { label: "Name", flex: true }], array(data.units).map((u) => [text(u.id), text(u.code), text(u.name)]), { width: options.width }));
3041
- next = "moodle UNIT \xB7 moodle find QUERY";
3689
+ next = ["moodle UNIT", "moodle find QUERY"];
3042
3690
  } else {
3043
3691
  const key = ["due", "results", "activities", "forums"].find((k) => k in data);
3044
3692
  rows(array(key ? data[key] : []), key === "due" ? "Due" : "Matches");
3045
3693
  if (data.total !== void 0) lines.push(`${data.total} total`);
3046
3694
  }
3047
- lines.push("", theme.dim(`Try ${next}`));
3695
+ lines.push("", ...tryLines(next).split("\n").map((line) => theme.dim(line)));
3048
3696
  const width = Math.max(40, options.width || 80);
3049
3697
  return lines.flatMap((line) => {
3050
3698
  if (line.includes("\x1B[") || line.startsWith("\u2502") || /^[┌└├]/u.test(line)) return [line];
@@ -3065,13 +3713,13 @@ function renderScreen(data, options = {}) {
3065
3713
  }
3066
3714
 
3067
3715
  // src/cli.ts
3068
- import { spawn as spawn3 } from "child_process";
3716
+ import { spawn as spawn4 } from "child_process";
3069
3717
  import {
3070
3718
  banner,
3071
3719
  colorEnabled as colorEnabled2,
3072
3720
  confirm,
3073
3721
  createProgram,
3074
- createTheme as createTheme3,
3722
+ createTheme as createTheme5,
3075
3723
  createUi as createUi3,
3076
3724
  detectAudience,
3077
3725
  examples,
@@ -5537,10 +6185,10 @@ function safeUrl(value) {
5537
6185
 
5538
6186
  // src/submit.ts
5539
6187
  import { readFile as readFile6, stat as stat2 } from "fs/promises";
5540
- import { homedir as homedir8 } from "os";
6188
+ import { homedir as homedir10 } from "os";
5541
6189
  import path from "path";
5542
6190
  function resolveSubmissionPath(given, cwd = process.cwd()) {
5543
- return path.resolve(cwd, given.startsWith("~/") ? path.join(homedir8(), given.slice(2)) : given);
6191
+ return path.resolve(cwd, given.startsWith("~/") ? path.join(homedir10(), given.slice(2)) : given);
5544
6192
  }
5545
6193
  async function readSubmissionFiles(paths, cwd = process.cwd()) {
5546
6194
  const files = [];
@@ -5599,7 +6247,7 @@ async function createMoodleClient(baseUrl, options = {}) {
5599
6247
  });
5600
6248
  }
5601
6249
  }
5602
- const stale = options.noCache ? null : await readCachedSession(baseUrl, { ...cacheOptions2, ttlMs: Number.MAX_SAFE_INTEGER }).catch(() => null);
6250
+ const stale = options.noCache ? null : await readCachedSession(baseUrl, { ...cacheOptions2, allowExpired: true }).catch(() => null);
5603
6251
  const session = authToClientSession(await getAuthenticatedSession(baseUrl, authOptions));
5604
6252
  return new MoodleClient(baseUrl, {
5605
6253
  fetchImpl: options.fetchImpl,
@@ -5612,11 +6260,21 @@ async function createMoodleClient(baseUrl, options = {}) {
5612
6260
  }
5613
6261
  function persistenceCallbacks(baseUrl, options) {
5614
6262
  return {
5615
- clearSessionCache: () => deleteCachedSession(baseUrl, options),
5616
- writeSessionCache: (session) => writeCachedSession({
5617
- ...session,
5618
- savedAt: (options.now ?? Date.now)()
5619
- }, options)
6263
+ clearSessionCache: async () => {
6264
+ const cached = await readCachedSession(baseUrl, { ...options, allowExpired: true });
6265
+ if (cached) await writeCachedSession({ ...cached, cookieInvalidated: true }, options);
6266
+ },
6267
+ writeSessionCache: async (session) => {
6268
+ const previous = await readCachedSession(baseUrl, { ...options, allowExpired: true });
6269
+ await writeCachedSession({
6270
+ ...session,
6271
+ // Client snapshots contain cookie state only. Keep renewal credentials
6272
+ // for the same account without carrying them across an account switch.
6273
+ ...previous?.userid === session.userid ? { mobileToken: previous.mobileToken } : {},
6274
+ mobileServiceEnabled: previous?.mobileServiceEnabled,
6275
+ savedAt: (options.now ?? Date.now)()
6276
+ }, options);
6277
+ }
5620
6278
  };
5621
6279
  }
5622
6280
  function authToClientSession(auth) {
@@ -6340,8 +6998,165 @@ function readSkillTemplate(relativePath = "skill.template.md") {
6340
6998
  return readFileSync(path3.join(process.cwd(), "src", relativePath), "utf8");
6341
6999
  }
6342
7000
 
7001
+ // src/onboarding.ts
7002
+ async function signInInteractively(ui, deps) {
7003
+ deps.showWordmark(ui);
7004
+ const login = loginUrl(deps.baseUrl);
7005
+ let blocked = await deps.storesBlocked();
7006
+ ui.note([
7007
+ `No Moodle session for ${deps.baseUrl}.`,
7008
+ "Sign in once; later commands reuse that session.",
7009
+ ...blocked ? ["", "This terminal cannot read your browser's cookies, so the sign-in happens in a browser window the CLI opens."] : []
7010
+ ].join("\n"), "One-time setup");
7011
+ let session;
7012
+ for (let attempt = 0; !session; attempt++) {
7013
+ const method = await ui.select(attempt ? "Try another way?" : "How do you want to sign in?", [
7014
+ ...blocked ? [] : [{ value: "own-browser", label: "Sign in in my own browser", hint: "the login page opens; press Enter here when done" }],
7015
+ { value: "cli-browser", label: "Open a browser window from here", hint: "a Chrome, Edge or Brave the CLI controls" },
7016
+ { value: "paste", label: "Paste the MoodleSession cookie", hint: "from the browser's developer tools" },
7017
+ { value: "stop", label: "Not now", hint: "moodle auth login works any time" }
7018
+ ]);
7019
+ if (method === "stop") throw new AuthError(`No usable MoodleSession found for ${deps.baseUrl}.`, "Run moodle auth login when you are ready.");
7020
+ if (method === "own-browser") {
7021
+ await deps.openInBrowser(login).catch(() => void 0);
7022
+ if (!await ui.confirm(`Signed in at ${login}? Enter reads the session from your browser`, { initial: true })) continue;
7023
+ const spin = ui.spinner();
7024
+ spin.start("Reading the session from your browser");
7025
+ try {
7026
+ session = await deps.readBrowserSession();
7027
+ spin.stop(`Signed in as userid ${session.userid}`);
7028
+ } catch (error) {
7029
+ if (!(error instanceof AuthError)) {
7030
+ spin.error("Could not read the browser session");
7031
+ throw error;
7032
+ }
7033
+ spin.error(error.message);
7034
+ blocked = await deps.storesBlocked();
7035
+ if (blocked) ui.warn(error.hint ?? "The browser cookie store could not be read.");
7036
+ else ui.info(`Finish signing in at ${login} in the browser you use, then choose the first option again.`);
7037
+ }
7038
+ } else if (method === "cli-browser") {
7039
+ const spin = ui.spinner();
7040
+ spin.start("Opening a browser window");
7041
+ try {
7042
+ session = await deps.browserLogin((url) => spin.message(`Finish signing in at ${url}`));
7043
+ spin.stop(`Signed in as userid ${session.userid}`);
7044
+ } catch (error) {
7045
+ if (!(error instanceof AuthError)) {
7046
+ spin.error("Sign-in did not complete");
7047
+ throw error;
7048
+ }
7049
+ spin.error(error.message);
7050
+ if (error.hint) ui.info(error.hint);
7051
+ }
7052
+ } else {
7053
+ try {
7054
+ session = await deps.pasteLogin();
7055
+ ui.step(`Signed in as userid ${session.userid}`);
7056
+ } catch (error) {
7057
+ if (!(error instanceof AuthError)) throw error;
7058
+ ui.warn([error.message, error.hint].filter(Boolean).join("\n"));
7059
+ }
7060
+ }
7061
+ }
7062
+ await offerRenewal(ui, deps);
7063
+ return session;
7064
+ }
7065
+ async function offerRenewal(ui, deps) {
7066
+ if (deps.platform !== "darwin" || await deps.keepaliveInstalled()) return;
7067
+ ui.note([
7068
+ `A background job can renew this session every ${KEEPALIVE_DEFAULT_INTERVAL_MINUTES} minutes, so it rarely expires.`,
7069
+ "It runs moodle auth keepalive; moodle auth keepalive uninstall removes it."
7070
+ ].join("\n"), "Stay signed in");
7071
+ const wanted = await ui.confirm("Renew the session automatically?", { initial: true }).catch((error) => {
7072
+ if (error instanceof CliError2 && error.code === "cancelled") return false;
7073
+ throw error;
7074
+ });
7075
+ if (!wanted) {
7076
+ ui.info("Later: moodle auth keepalive install");
7077
+ return;
7078
+ }
7079
+ try {
7080
+ const result = await deps.installKeepalive();
7081
+ ui.step(`Renewing every ${result.interval_minutes} min; agent at ${result.plist_path}`);
7082
+ } catch (error) {
7083
+ ui.warn(`${error instanceof Error ? error.message : String(error)}
7084
+ Later: moodle auth keepalive install`);
7085
+ }
7086
+ }
7087
+
7088
+ // src/secret-input.ts
7089
+ var PASTE_MODE_ON = "\x1B[?2004h";
7090
+ var PASTE_MODE_OFF = "\x1B[?2004l";
7091
+ var PASTE_START = "\x1B[200~";
7092
+ var PASTE_END = "\x1B[201~";
7093
+ async function readSecretLine(input2, output, prompt) {
7094
+ if (!input2.isTTY) {
7095
+ return (await readAll(input2)).trim();
7096
+ }
7097
+ const wasRaw = input2.isRaw;
7098
+ input2.setRawMode(true);
7099
+ input2.resume();
7100
+ output.write(prompt);
7101
+ output.write(PASTE_MODE_ON);
7102
+ try {
7103
+ return await new Promise((resolve) => {
7104
+ let content = "";
7105
+ let pasting = false;
7106
+ let esc = "";
7107
+ const finish = (value) => {
7108
+ input2.off("data", onData);
7109
+ resolve(value);
7110
+ };
7111
+ const onData = (chunk) => {
7112
+ for (const ch of chunk.toString("utf8")) {
7113
+ if (esc) {
7114
+ esc += ch;
7115
+ if (esc === PASTE_START) {
7116
+ pasting = true;
7117
+ esc = "";
7118
+ } else if (esc === PASTE_END) {
7119
+ esc = "";
7120
+ return finish(content.trim());
7121
+ } else if (!PASTE_START.startsWith(esc) && !PASTE_END.startsWith(esc)) {
7122
+ esc = "";
7123
+ }
7124
+ continue;
7125
+ }
7126
+ const code = ch.charCodeAt(0);
7127
+ if (code === 27) {
7128
+ esc = "\x1B";
7129
+ continue;
7130
+ }
7131
+ if (pasting) {
7132
+ content += ch;
7133
+ continue;
7134
+ }
7135
+ if (code === 3 || code === 4) return finish(null);
7136
+ if (code === 13 || code === 10) return finish(content.trim());
7137
+ if (code === 8 || code === 127) content = content.slice(0, -1);
7138
+ else if (code >= 32) content += ch;
7139
+ }
7140
+ };
7141
+ input2.on("data", onData);
7142
+ });
7143
+ } finally {
7144
+ output.write(PASTE_MODE_OFF);
7145
+ input2.setRawMode(wasRaw);
7146
+ input2.pause();
7147
+ output.write("\n");
7148
+ }
7149
+ }
7150
+ async function readAll(input2) {
7151
+ const chunks2 = [];
7152
+ for await (const chunk of input2) {
7153
+ chunks2.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
7154
+ }
7155
+ return chunks2.join("");
7156
+ }
7157
+
6343
7158
  // src/version.ts
6344
- var VERSION = "0.9.0";
7159
+ var VERSION = "0.9.2";
6345
7160
 
6346
7161
  // src/forum.ts
6347
7162
  function parseDiscussionReference(value) {
@@ -6495,10 +7310,11 @@ function resolveTopLevelUrl(baseUrlOrOptions, targetValue, resolveCourseIdForUrl
6495
7310
  }
6496
7311
 
6497
7312
  // src/mcp/cli.ts
7313
+ import { createTheme as createTheme4 } from "@bunizao/cli-kit";
6498
7314
  import { createHash as createHash3 } from "crypto";
6499
7315
  import { readFile as readFile9 } from "fs/promises";
6500
- import { homedir as homedir12 } from "os";
6501
- import { join as join11 } from "path";
7316
+ import { homedir as homedir14 } from "os";
7317
+ import { join as join13 } from "path";
6502
7318
  import { createInterface } from "readline/promises";
6503
7319
  import { fileURLToPath } from "url";
6504
7320
 
@@ -7032,9 +7848,9 @@ function resolveConnection(options) {
7032
7848
  }
7033
7849
 
7034
7850
  // src/mcp/connectors/node-connectors.ts
7035
- import { chmod as chmod4, mkdir as mkdir6, readFile as readFile7, rm as rm5, stat as stat3, writeFile as writeFile6 } from "fs/promises";
7036
- import { homedir as homedir9 } from "os";
7037
- import { dirname as dirname6, join as join8 } from "path";
7851
+ import { chmod as chmod4, mkdir as mkdir7, readFile as readFile7, rm as rm5, stat as stat3, writeFile as writeFile6 } from "fs/promises";
7852
+ import { homedir as homedir11 } from "os";
7853
+ import { dirname as dirname6, join as join10 } from "path";
7038
7854
  var NodeConnectorFileSystem = class {
7039
7855
  async exists(path5) {
7040
7856
  try {
@@ -7051,7 +7867,7 @@ var NodeConnectorFileSystem = class {
7051
7867
  return readFile7(path5, "utf8");
7052
7868
  }
7053
7869
  async writePrivate(path5, content) {
7054
- await mkdir6(dirname6(path5), { recursive: true, mode: 448 });
7870
+ await mkdir7(dirname6(path5), { recursive: true, mode: 448 });
7055
7871
  await writeFile6(path5, content, { encoding: "utf8", mode: 384 });
7056
7872
  await chmod4(path5, 384);
7057
7873
  }
@@ -7060,7 +7876,7 @@ var NodeConnectorFileSystem = class {
7060
7876
  }
7061
7877
  };
7062
7878
  function createDefaultClientConnectors(profile, options = {}) {
7063
- const home = options.homeDirectory ?? homedir9();
7879
+ const home = options.homeDirectory ?? homedir11();
7064
7880
  const platform = options.platform ?? process.platform;
7065
7881
  const fileSystem = options.fileSystem ?? new NodeConnectorFileSystem();
7066
7882
  const runtime = runtimeCommand(options.command, options.commandArgs);
@@ -7072,14 +7888,14 @@ function createDefaultClientConnectors(profile, options = {}) {
7072
7888
  endpoint: options.endpoint,
7073
7889
  accessToken: options.accessToken
7074
7890
  };
7075
- const claudeDesktop = platform === "darwin" ? join8(home, "Library", "Application Support", "Claude", "claude_desktop_config.json") : platform === "win32" ? join8(home, "AppData", "Roaming", "Claude", "claude_desktop_config.json") : join8(home, ".config", "Claude", "claude_desktop_config.json");
7076
- const vscodeUser = platform === "darwin" ? join8(home, "Library", "Application Support", "Code", "User", "mcp.json") : platform === "win32" ? join8(home, "AppData", "Roaming", "Code", "User", "mcp.json") : join8(home, ".config", "Code", "User", "mcp.json");
7891
+ const claudeDesktop = platform === "darwin" ? join10(home, "Library", "Application Support", "Claude", "claude_desktop_config.json") : platform === "win32" ? join10(home, "AppData", "Roaming", "Claude", "claude_desktop_config.json") : join10(home, ".config", "Claude", "claude_desktop_config.json");
7892
+ const vscodeUser = platform === "darwin" ? join10(home, "Library", "Application Support", "Code", "User", "mcp.json") : platform === "win32" ? join10(home, "AppData", "Roaming", "Code", "User", "mcp.json") : join10(home, ".config", "Code", "User", "mcp.json");
7077
7893
  return [
7078
- createCodexConnector({ ...shared, configPath: join8(home, ".codex", "config.toml"), detectionPath: join8(home, ".codex") }, fileSystem),
7894
+ createCodexConnector({ ...shared, configPath: join10(home, ".codex", "config.toml"), detectionPath: join10(home, ".codex") }, fileSystem),
7079
7895
  createClaudeDesktopConnector({ ...shared, configPath: claudeDesktop, detectionPath: dirname6(claudeDesktop) }, fileSystem),
7080
- createClaudeCodeConnector({ ...shared, configPath: join8(home, ".claude.json"), detectionPath: join8(home, ".claude") }, fileSystem),
7896
+ createClaudeCodeConnector({ ...shared, configPath: join10(home, ".claude.json"), detectionPath: join10(home, ".claude") }, fileSystem),
7081
7897
  createVsCodeConnector({ ...shared, configPath: vscodeUser, detectionPath: dirname6(vscodeUser) }, fileSystem),
7082
- createCursorConnector({ ...shared, configPath: join8(home, ".cursor", "mcp.json"), detectionPath: join8(home, ".cursor") }, fileSystem)
7898
+ createCursorConnector({ ...shared, configPath: join10(home, ".cursor", "mcp.json"), detectionPath: join10(home, ".cursor") }, fileSystem)
7083
7899
  ];
7084
7900
  }
7085
7901
  var DefaultClientIntegration = class {
@@ -7119,6 +7935,8 @@ function isMissing3(error) {
7119
7935
  }
7120
7936
 
7121
7937
  // src/mcp/deployment/onboarding.ts
7938
+ import { createTheme as createTheme3 } from "@bunizao/cli-kit";
7939
+ var PLAIN = createTheme3(false);
7122
7940
  var ONBOARDING_STAGES = [
7123
7941
  { id: "validate_moodle_session", index: 1, label: "Validating Moodle session" },
7124
7942
  { id: "check_cloudflare_access", index: 2, label: "Checking Cloudflare access" },
@@ -7210,39 +8028,41 @@ var ONBOARDING_COPY = {
7210
8028
  "Wrangler will request Cloudflare authorization again."
7211
8029
  ].join("\n")
7212
8030
  };
7213
- function formatOnboardingStage(stageId, status) {
8031
+ function formatOnboardingStage(stageId, status, theme = PLAIN) {
7214
8032
  const stage = ONBOARDING_STAGES.find((item) => item.id === stageId);
7215
8033
  if (!stage) {
7216
8034
  throw new Error(`Unknown onboarding stage: ${stageId}`);
7217
8035
  }
7218
8036
  const line = `[${stage.index}/8] ${stage.label}`;
7219
- return status === "completed" ? `\u2713 ${line}` : line;
8037
+ return status === "completed" ? `${theme.tone("success", "\u2713")} ${line}` : line;
7220
8038
  }
7221
- function successfulDeploymentCopy(input2) {
7222
- const connectedClients = input2.clients.length ? input2.clients.map((client) => ` \u2713 ${client}`).join("\n") : " No supported clients detected";
8039
+ function successfulDeploymentCopy(input2, theme = PLAIN) {
8040
+ const connectedClients = input2.clients.length ? input2.clients.map((client) => ` ${theme.tone("success", "\u2713")} ${client}`).join("\n") : ` ${theme.dim("No supported clients detected")}`;
8041
+ const field = (label, value) => ` ${theme.dim(`${label}:`)} ${value}`;
7223
8042
  return [
7224
- "Moodle MCP is ready.",
8043
+ theme.tone("success", "Moodle MCP is ready."),
7225
8044
  "",
7226
- "Endpoint",
7227
- ` ${input2.endpoint}`,
8045
+ theme.subject("Endpoint"),
8046
+ // The one line a person copies into a client, so it reads as an identifier, not a footnote.
8047
+ ` ${theme.key(input2.endpoint)}`,
7228
8048
  "",
7229
- "Protocol",
7230
- " MCP 2026-07-28",
7231
- " Stateless legacy compatibility: 2025-11-25",
8049
+ theme.subject("Protocol"),
8050
+ ` ${theme.dim("MCP")} ${MODERN_PROTOCOL_VERSION}`,
8051
+ ` ${theme.dim(`Stateless legacy compatibility: ${LEGACY_PROTOCOL_VERSION}`)}`,
7232
8052
  "",
7233
- "Moodle",
7234
- ` Site: ${input2.moodleSite}`,
7235
- ` User: ${input2.moodleUser}`,
7236
- " Session: ready",
8053
+ theme.subject("Moodle"),
8054
+ field("Site", theme.target(input2.moodleSite)),
8055
+ field("User", input2.moodleUser),
8056
+ field("Session", theme.status("ready", { ready: "success" })),
7237
8057
  "",
7238
- "Renewal",
7239
- " Installed on this computer",
7240
- " Next check: within 30 minutes",
8058
+ theme.subject("Renewal"),
8059
+ ` ${theme.dim("Installed on this computer")}`,
8060
+ ` ${theme.dim("Next check: within 30 minutes")}`,
7241
8061
  "",
7242
- "Connected clients",
8062
+ theme.subject("Connected clients"),
7243
8063
  connectedClients,
7244
8064
  "",
7245
- "Run `moodle mcp status` at any time."
8065
+ `Run ${theme.key("moodle mcp status")} at any time.`
7246
8066
  ].join("\n");
7247
8067
  }
7248
8068
 
@@ -7552,7 +8372,8 @@ var ManagedMcpDeployment = class {
7552
8372
  clientsConnected: await this.dependencies.clients.inspect(profile),
7553
8373
  readiness: "unknown",
7554
8374
  readinessReasonCode: "NOT_DEPLOYED",
7555
- sessionRevision: null
8375
+ sessionRevision: null,
8376
+ recoveryActive: false
7556
8377
  };
7557
8378
  }
7558
8379
  const [worker, credentials, renewalInstalled, clientsConnected] = await Promise.all([
@@ -7581,7 +8402,8 @@ var ManagedMcpDeployment = class {
7581
8402
  clientsConnected,
7582
8403
  readiness,
7583
8404
  readinessReasonCode,
7584
- sessionRevision
8405
+ sessionRevision,
8406
+ recoveryActive: receipt.releaseDigest.endsWith("-recovery")
7585
8407
  };
7586
8408
  }
7587
8409
  async recover(profile) {
@@ -7840,16 +8662,19 @@ function asDeploymentError(error) {
7840
8662
  if (error instanceof DeploymentApplyError) {
7841
8663
  return error;
7842
8664
  }
8665
+ if (error instanceof CliError2) {
8666
+ return error;
8667
+ }
7843
8668
  const detail = error instanceof Error && error.message ? `: ${error.message}` : "";
7844
8669
  return new DeploymentApplyError("DEPLOYMENT_FAILED", `The managed Moodle MCP deployment failed${detail}`, { cause: error });
7845
8670
  }
7846
8671
 
7847
8672
  // src/mcp/wrangler.ts
7848
8673
  import { createUi as createUi2 } from "@bunizao/cli-kit";
7849
- import { mkdir as mkdir7 } from "fs/promises";
8674
+ import { mkdir as mkdir8, writeFile as writeFile7 } from "fs/promises";
7850
8675
  import { existsSync } from "fs";
7851
- import { homedir as homedir10 } from "os";
7852
- import { join as join9 } from "path";
8676
+ import { homedir as homedir12 } from "os";
8677
+ import { join as join11 } from "path";
7853
8678
  async function resolveWrangler(runner, options = {}) {
7854
8679
  const env = options.env ?? process.env;
7855
8680
  const notice = options.notice ?? ((text2) => process.stderr.write(`${text2}
@@ -7860,8 +8685,8 @@ async function resolveWrangler(runner, options = {}) {
7860
8685
  if (version && sameMajorAtLeast(version, WRANGLER_VERSION)) return { command: existing, args: [] };
7861
8686
  notice(`Ignoring ${existing} (${version ?? "unknown version"}); Cloudflare management needs Wrangler ${WRANGLER_VERSION.split(".")[0]}.x.`);
7862
8687
  }
7863
- const root = join9(options.homeDir ?? homedir10(), ".config", "moodle-cli", "tools", `wrangler@${WRANGLER_VERSION}`);
7864
- const script = join9(root, "node_modules", "wrangler", "bin", "wrangler.js");
8688
+ const root = join11(options.homeDir ?? homedir12(), ".config", "moodle-cli", "tools", `wrangler@${WRANGLER_VERSION}`);
8689
+ const script = join11(root, "node_modules", "wrangler", "bin", "wrangler.js");
7865
8690
  const bun = findExecutable("bun", env);
7866
8691
  const node = findExecutable("node", env);
7867
8692
  if (!bun && !node) throw new Error("Cloudflare management needs Bun or Node 22.13+. Install either, then retry moodle mcp deploy.");
@@ -7877,9 +8702,16 @@ async function resolveWrangler(runner, options = {}) {
7877
8702
  }
7878
8703
  }
7879
8704
  notice(`Cloudflare management needs Wrangler ${WRANGLER_VERSION}; downloading once to ${root}.`);
7880
- await mkdir7(root, { recursive: true, mode: 448 });
7881
- await runner.run(bun ?? npm, bun ? ["install", "--cwd", root, "--no-save", `wrangler@${WRANGLER_VERSION}`] : ["install", "--prefix", root, "--no-save", "--package-lock=false", "--no-audit", "--no-fund", `wrangler@${WRANGLER_VERSION}`]);
7882
- if (!existsSync(script)) throw new Error("Wrangler installation did not create the expected executable. Retry moodle mcp deploy.");
8705
+ await mkdir8(root, { recursive: true, mode: 448 });
8706
+ await writeFile7(join11(root, "package.json"), '{ "private": true }\n');
8707
+ const result = await runner.run(bun ?? npm, bun ? ["install", "--cwd", root, "--no-save", `wrangler@${WRANGLER_VERSION}`] : ["install", "--prefix", root, "--no-save", "--package-lock=false", "--no-audit", "--no-fund", `wrangler@${WRANGLER_VERSION}`]);
8708
+ if (!existsSync(script)) {
8709
+ const output = `${result.stderr}
8710
+ ${result.stdout}`.trim().split(/\r?\n/u).slice(-5).join("\n");
8711
+ throw new Error(`Wrangler installation did not create ${script}.${output ? `
8712
+ ${output}` : ""}
8713
+ Remove ${root} and retry moodle mcp deploy.`);
8714
+ }
7883
8715
  }
7884
8716
  return { command: node ?? bun, args: [script] };
7885
8717
  }
@@ -7891,18 +8723,18 @@ function sameMajorAtLeast(actual, pinned) {
7891
8723
 
7892
8724
  // src/mcp/deployment/node-adapters.ts
7893
8725
  import { isDeepStrictEqual } from "util";
7894
- import { spawn as spawn2 } from "child_process";
8726
+ import { spawn as spawn3 } from "child_process";
7895
8727
  import { createHash as createHash2, randomBytes as randomBytes2 } from "crypto";
7896
- import { chmod as chmod5, mkdir as mkdir8, mkdtemp, readFile as readFile8, rm as rm6, writeFile as writeFile7 } from "fs/promises";
7897
- import { homedir as homedir11, tmpdir } from "os";
7898
- import { basename, dirname as dirname7, join as join10 } from "path";
8728
+ import { chmod as chmod5, mkdir as mkdir9, mkdtemp, readFile as readFile8, rm as rm6, writeFile as writeFile8 } from "fs/promises";
8729
+ import { homedir as homedir13, tmpdir } from "os";
8730
+ import { basename, dirname as dirname7, join as join12 } from "path";
7899
8731
  var MODERN_MCP_VERSION = "2026-07-28";
7900
8732
  var WORKER_PROPAGATION_ATTEMPTS = 10;
7901
8733
  var WORKER_PROPAGATION_MAX_DELAY_MS = 4e3;
7902
8734
  var NodeDeploymentCommandRunner = class {
7903
8735
  async run(command, args, environment = {}) {
7904
8736
  return new Promise((resolve, reject) => {
7905
- const child = spawn2(command, args, {
8737
+ const child = spawn3(command, args, {
7906
8738
  env: { ...process.env, ...environment },
7907
8739
  stdio: ["ignore", "pipe", "pipe"],
7908
8740
  windowsHide: true
@@ -8118,6 +8950,10 @@ ${error.stderr}`)) {
8118
8950
  }
8119
8951
  await this.wrangler(["delete", input2.workerName, "--force"], input2.accountId);
8120
8952
  }
8953
+ /** Locate or download Wrangler now, so a first-use prompt is not drawn under a spinner later. */
8954
+ async prepare(options = {}) {
8955
+ if (!this.wranglerBinPath) await resolveWrangler(this.runner, options);
8956
+ }
8121
8957
  async wrangler(args, accountId, environmentOverrides = {}) {
8122
8958
  const environment = { ...environmentOverrides };
8123
8959
  if (accountId) {
@@ -8132,7 +8968,7 @@ ${error.stderr}`)) {
8132
8968
  }
8133
8969
  };
8134
8970
  async function copyReleaseBundle(source, destination) {
8135
- await writeFile7(destination, await readFile8(source));
8971
+ await writeFile8(destination, await readFile8(source));
8136
8972
  }
8137
8973
  var NodeReleaseMaterializer = class {
8138
8974
  constructor(options) {
@@ -8141,13 +8977,13 @@ var NodeReleaseMaterializer = class {
8141
8977
  options;
8142
8978
  async prepare(plan, credentials) {
8143
8979
  const temporaryRoot = this.options.temporaryRoot ?? tmpdir();
8144
- await mkdir8(temporaryRoot, { recursive: true });
8145
- const artifactDirectory = await mkdtemp(join10(temporaryRoot, "moodle-mcp-"));
8980
+ await mkdir9(temporaryRoot, { recursive: true });
8981
+ const artifactDirectory = await mkdtemp(join12(temporaryRoot, "moodle-mcp-"));
8146
8982
  await chmod5(artifactDirectory, 448);
8147
- const workerFile = join10(artifactDirectory, basename(this.options.workerBundlePath));
8983
+ const workerFile = join12(artifactDirectory, basename(this.options.workerBundlePath));
8148
8984
  await copyReleaseBundle(this.options.workerBundlePath, workerFile);
8149
- const wranglerConfigPath = join10(artifactDirectory, "wrangler.json");
8150
- const secretsFilePath = join10(artifactDirectory, "secrets.json");
8985
+ const wranglerConfigPath = join12(artifactDirectory, "wrangler.json");
8986
+ const secretsFilePath = join12(artifactDirectory, "secrets.json");
8151
8987
  const expectedHosts = endpointHosts(plan.intent.workerName, plan.existing?.productionEndpoint);
8152
8988
  const config = {
8153
8989
  $schema: "node_modules/wrangler/config-schema.json",
@@ -8187,18 +9023,18 @@ var NodeReleaseMaterializer = class {
8187
9023
  if (credentials.previousTokensExpireAt !== void 0 && Number.isFinite(credentials.previousTokensExpireAt)) {
8188
9024
  secrets.TOKEN_OVERLAP_EXPIRES_AT = String(credentials.previousTokensExpireAt);
8189
9025
  }
8190
- await writeFile7(wranglerConfigPath, `${JSON.stringify(config, null, 2)}
9026
+ await writeFile8(wranglerConfigPath, `${JSON.stringify(config, null, 2)}
8191
9027
  `, { mode: 384 });
8192
- await writeFile7(secretsFilePath, `${JSON.stringify(secrets)}
9028
+ await writeFile8(secretsFilePath, `${JSON.stringify(secrets)}
8193
9029
  `, { mode: 384 });
8194
9030
  await chmod5(wranglerConfigPath, 384);
8195
9031
  await chmod5(secretsFilePath, 384);
8196
9032
  let recoveryConfigPath;
8197
9033
  try {
8198
- const recoveryBundle = process.env.MOODLE_BUNDLED_RECOVERY ?? join10(dirname7(this.options.workerBundlePath), "recovery.js");
8199
- await copyReleaseBundle(recoveryBundle, join10(artifactDirectory, "recovery.js"));
8200
- recoveryConfigPath = join10(artifactDirectory, "wrangler-recovery.json");
8201
- await writeFile7(recoveryConfigPath, `${JSON.stringify({ ...config, main: "./recovery.js" })}
9034
+ const recoveryBundle = process.env.MOODLE_BUNDLED_RECOVERY ?? join12(dirname7(this.options.workerBundlePath), "recovery.js");
9035
+ await copyReleaseBundle(recoveryBundle, join12(artifactDirectory, "recovery.js"));
9036
+ recoveryConfigPath = join12(artifactDirectory, "wrangler-recovery.json");
9037
+ await writeFile8(recoveryConfigPath, `${JSON.stringify({ ...config, main: "./recovery.js" })}
8202
9038
  `, { mode: 384 });
8203
9039
  } catch (error) {
8204
9040
  if (!isMissing4(error) || plan.existing) throw error;
@@ -8307,28 +9143,25 @@ var FetchManagedWorkerClient = class {
8307
9143
  }
8308
9144
  await this.mcpCall(input2.endpoint, input2.mcpAccessToken, "server/discover", {}, 1);
8309
9145
  await this.mcpCall(input2.endpoint, input2.mcpAccessToken, "tools/list", {}, 2);
8310
- const userResult = await this.mcpCall(
8311
- input2.endpoint,
8312
- input2.mcpAccessToken,
8313
- "tools/call",
8314
- { name: "get_user", arguments: {} },
8315
- 3
8316
- );
8317
- const moodleUser = mcpUserFullname(userResult);
9146
+ const homeResult = await this.mcpCall(input2.endpoint, input2.mcpAccessToken, "tools/call", { name: "home", arguments: {} }, 3);
9147
+ const moodleUser = smokeMoodleUser(homeResult);
8318
9148
  if (!moodleUser) {
8319
- throw new DeploymentApplyError("MCP_SMOKE_FAILED", "MCP get_user check returned no Moodle user");
9149
+ throw new DeploymentApplyError("MCP_SMOKE_FAILED", "MCP home check returned no Moodle user");
8320
9150
  }
8321
- const coursesResult = await this.mcpCall(input2.endpoint, input2.mcpAccessToken, "tools/call", { name: "list_courses", arguments: { limit: 1 } }, 4);
8322
- const courses = readableToolResult(coursesResult).courses;
8323
- if (!Array.isArray(courses)) throw new DeploymentApplyError("MCP_SMOKE_FAILED", "MCP list_courses returned no course list");
8324
- if (courses.length) {
8325
- const first2 = courses[0];
9151
+ const unitsResult = await this.mcpCall(input2.endpoint, input2.mcpAccessToken, "tools/call", { name: "units", arguments: { limit: 1 } }, 4);
9152
+ const units = readableToolResult(unitsResult).units;
9153
+ if (!Array.isArray(units)) throw new DeploymentApplyError("MCP_SMOKE_FAILED", "MCP units returned no unit list");
9154
+ if (units.length) {
9155
+ const first2 = units[0];
8326
9156
  if (!isRecord9(first2) || !Number.isSafeInteger(first2.id) || Number(first2.id) <= 0) {
8327
- throw new DeploymentApplyError("MCP_SMOKE_FAILED", "MCP list_courses returned no usable course ID");
9157
+ throw new DeploymentApplyError("MCP_SMOKE_FAILED", "MCP units returned no usable unit ID");
8328
9158
  }
8329
- const detail = readableToolResult(await this.mcpCall(input2.endpoint, input2.mcpAccessToken, "tools/call", { name: "get_course", arguments: { courseId: first2.id } }, 5));
8330
- if (!isRecord9(detail.course) || !isRecord9(detail.course.course) || detail.course.course.id !== first2.id || !Array.isArray(detail.course.sections)) {
8331
- throw new DeploymentApplyError("MCP_SMOKE_FAILED", "MCP course lookup did not match the listed course");
9159
+ const detail = readableToolResult(await this.mcpCall(input2.endpoint, input2.mcpAccessToken, "tools/call", { name: "unit", arguments: { unit: first2.id } }, 5));
9160
+ if (!isRecord9(detail.unit) || detail.unit.id !== first2.id) {
9161
+ throw new DeploymentApplyError("MCP_SMOKE_FAILED", "MCP unit lookup did not match the listed unit");
9162
+ }
9163
+ if (detail.sections !== void 0 && !Array.isArray(detail.sections)) {
9164
+ throw new DeploymentApplyError("MCP_SMOKE_FAILED", "MCP unit lookup returned an unusable section list");
8332
9165
  }
8333
9166
  }
8334
9167
  return { moodleUser };
@@ -8406,7 +9239,7 @@ var FetchManagedWorkerClient = class {
8406
9239
  }
8407
9240
  };
8408
9241
  var PrivateDeploymentReceiptStore = class {
8409
- constructor(baseDirectory = join10(homedir11(), ".config", "moodle-cli", "mcp", "deployments")) {
9242
+ constructor(baseDirectory = join12(homedir13(), ".config", "moodle-cli", "mcp", "deployments")) {
8410
9243
  this.baseDirectory = baseDirectory;
8411
9244
  }
8412
9245
  baseDirectory;
@@ -8423,8 +9256,8 @@ var PrivateDeploymentReceiptStore = class {
8423
9256
  }
8424
9257
  async write(receipt) {
8425
9258
  const path5 = this.path(receipt.profile);
8426
- await mkdir8(dirname7(path5), { recursive: true, mode: 448 });
8427
- await writeFile7(path5, `${JSON.stringify(receipt, null, 2)}
9259
+ await mkdir9(dirname7(path5), { recursive: true, mode: 448 });
9260
+ await writeFile8(path5, `${JSON.stringify(receipt, null, 2)}
8428
9261
  `, { mode: 384 });
8429
9262
  await chmod5(path5, 384);
8430
9263
  }
@@ -8435,11 +9268,11 @@ var PrivateDeploymentReceiptStore = class {
8435
9268
  if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(profile)) {
8436
9269
  throw new Error("Invalid Moodle MCP profile name");
8437
9270
  }
8438
- return join10(this.baseDirectory, `${profile}.json`);
9271
+ return join12(this.baseDirectory, `${profile}.json`);
8439
9272
  }
8440
9273
  };
8441
9274
  function createDefaultManagedDeployment(options) {
8442
- const homeDirectory = options.homeDirectory ?? homedir11();
9275
+ const homeDirectory = options.homeDirectory ?? homedir13();
8443
9276
  const platform = options.platform ?? process.platform;
8444
9277
  const runtime = runtimeCommand(options.executable, options.executableArgs);
8445
9278
  const defaults = {
@@ -8466,7 +9299,7 @@ function createDefaultManagedDeployment(options) {
8466
9299
  command: runtime.command,
8467
9300
  commandArgs: runtime.args
8468
9301
  }),
8469
- receipts: new PrivateDeploymentReceiptStore(join10(homeDirectory, ".config", "moodle-cli", "mcp", "deployments")),
9302
+ receipts: new PrivateDeploymentReceiptStore(join12(homeDirectory, ".config", "moodle-cli", "mcp", "deployments")),
8470
9303
  createToken: () => randomBytes2(32).toString("base64url")
8471
9304
  };
8472
9305
  return new ManagedMcpDeployment({ ...defaults, ...options.dependencies });
@@ -8495,7 +9328,7 @@ async function pinExpectedHosts(configPath, workerName, productionEndpoint) {
8495
9328
  throw new DeploymentApplyError("MISSING_ENDPOINT", "The Worker production endpoint is invalid");
8496
9329
  }
8497
9330
  config.vars.EXPECTED_HOSTS = hosts.join(",");
8498
- await writeFile7(configPath, `${JSON.stringify(config, null, 2)}
9331
+ await writeFile8(configPath, `${JSON.stringify(config, null, 2)}
8499
9332
  `, { mode: 384 });
8500
9333
  }
8501
9334
  function endpointHosts(workerName, endpoint) {
@@ -8544,9 +9377,9 @@ function readableToolResult(result) {
8544
9377
  }
8545
9378
  throw new DeploymentApplyError("MCP_CONTENT_INCOMPLETE", "MCP text content is missing the complete structured result");
8546
9379
  }
8547
- function mcpUserFullname(result) {
8548
- const user = readableToolResult(result).user;
8549
- return isRecord9(user) && typeof user.fullname === "string" && user.fullname.trim() ? user.fullname.trim() : null;
9380
+ function smokeMoodleUser(result) {
9381
+ const home = readableToolResult(result).home;
9382
+ return isRecord9(home) && typeof home.name === "string" && home.name.trim() ? home.name.trim() : null;
8550
9383
  }
8551
9384
  function parseJsonOutput(output) {
8552
9385
  const candidates = [output.indexOf("{"), output.indexOf("[")].filter((index) => index >= 0).sort((a, b) => a - b);
@@ -9005,9 +9838,9 @@ function deriveMcpWorkerName(moodleOrigin) {
9005
9838
  var DefaultMcpCommandService = class {
9006
9839
  constructor(options) {
9007
9840
  this.options = options;
9008
- this.homeDirectory = options.homeDir ?? homedir12();
9841
+ this.homeDirectory = options.homeDir ?? homedir14();
9009
9842
  this.wranglerInstance = options.wrangler;
9010
- this.receipts = options.receipts ?? new PrivateDeploymentReceiptStore(join11(this.homeDirectory, ".config", "moodle-cli", "mcp", "deployments"));
9843
+ this.receipts = options.receipts ?? new PrivateDeploymentReceiptStore(join13(this.homeDirectory, ".config", "moodle-cli", "mcp", "deployments"));
9011
9844
  this.credentials = options.credentials ?? createDefaultCredentialStore({ platform: process.platform, homeDirectory: this.homeDirectory });
9012
9845
  this.worker = options.worker ?? new FetchManagedWorkerClient(options.fetchImpl);
9013
9846
  this.renewal = options.renewal ?? new DefaultRenewalIntegration({
@@ -9032,6 +9865,8 @@ var DefaultMcpCommandService = class {
9032
9865
  notifyRenewalSignIn;
9033
9866
  progressReporter;
9034
9867
  async deploy(input2) {
9868
+ await this.prepareToolchain(input2.yes);
9869
+ const theme = this.theme();
9035
9870
  const progress = this.progress();
9036
9871
  try {
9037
9872
  progress.begin("Reading Cloudflare account and deployment state");
@@ -9074,8 +9909,8 @@ var DefaultMcpCommandService = class {
9074
9909
  const events = [];
9075
9910
  for await (const event2 of deployment.apply(plan)) {
9076
9911
  events.push(event2);
9077
- if (event2.status === "started") progress.begin(formatOnboardingStage(event2.stageId, "pending"));
9078
- else if (event2.status === "completed") progress.end(formatOnboardingStage(event2.stageId, "completed"));
9912
+ if (event2.status === "started") progress.begin(formatOnboardingStage(event2.stageId, "pending", theme));
9913
+ else if (event2.status === "completed") progress.end(formatOnboardingStage(event2.stageId, "completed", theme));
9079
9914
  else progress.clear();
9080
9915
  }
9081
9916
  progress.begin("Reading deployment status");
@@ -9098,7 +9933,7 @@ var DefaultMcpCommandService = class {
9098
9933
  moodleSite: identity.moodleOrigin,
9099
9934
  moodleUser: events.find((event2) => event2.moodleUser)?.moodleUser ?? "Unknown Moodle user",
9100
9935
  clients: await this.connectedClientNames(identity.profile)
9101
- })
9936
+ }, this.theme())
9102
9937
  };
9103
9938
  }
9104
9939
  async planDeployment(deployment, initialIntent) {
@@ -9142,6 +9977,7 @@ var DefaultMcpCommandService = class {
9142
9977
  async status(input2) {
9143
9978
  const config = await this.config();
9144
9979
  const profile = deriveMcpProfile(config.baseUrl);
9980
+ await this.prepareToolchain();
9145
9981
  const progress = this.progress();
9146
9982
  let managed;
9147
9983
  try {
@@ -9169,21 +10005,25 @@ var DefaultMcpCommandService = class {
9169
10005
  ...input2.verbose ? { serviceVersion: VERSION } : {},
9170
10006
  ...input2.logs ? { logs: { available: false, reason: "live_tail_required" } } : {}
9171
10007
  };
10008
+ const theme = this.theme();
10009
+ const row = (label, value) => `${theme.dim(`${label}:`)} ${theme.status(value, { pass: "success", warn: "warning", fail: "danger", unknown: "muted", "not deployed": "warning", missing: "warning", "not connected": "warning", stored: "success", installed: "success", connected: "success" })}`;
9172
10010
  return {
9173
10011
  data,
9174
10012
  text: [
9175
- `Moodle MCP: ${managed.readiness}`,
9176
- `Worker: ${managed.worker?.workerName ?? "not deployed"}`,
9177
- `Credentials: ${managed.credentialsStored ? "stored" : "missing"}`,
9178
- `Renewal: ${managed.renewalInstalled ? "installed" : "missing"}`,
9179
- `Clients: ${managed.clientsConnected ? "connected" : "not connected"}`,
9180
- ...updateAvailable ? ["Update: remote Worker is behind this CLI. Run `moodle mcp deploy` to update it."] : [],
9181
- ...input2.logs ? ["Logs: use a live sanitized tail from an interactive terminal"] : []
10013
+ row("Moodle MCP", managed.readiness),
10014
+ row("Worker", managed.worker?.workerName ?? "not deployed"),
10015
+ row("Credentials", managed.credentialsStored ? "stored" : "missing"),
10016
+ row("Renewal", managed.renewalInstalled ? "installed" : "missing"),
10017
+ row("Clients", managed.clientsConnected ? "connected" : "not connected"),
10018
+ ...managed.recoveryActive ? [`${theme.dim("Release:")} ${theme.tone("warning", "the recovery Worker is live; OAuth sign-in is disabled until")} ${theme.key("moodle mcp deploy")} ${theme.tone("warning", "succeeds.")}`] : [],
10019
+ ...updateAvailable ? [`${theme.dim("Update:")} remote Worker is behind this CLI. Run ${theme.key("moodle mcp deploy")} to update it.`] : [],
10020
+ ...input2.logs ? [`${theme.dim("Logs:")} use a live sanitized tail from an interactive terminal`] : []
9182
10021
  ].join("\n")
9183
10022
  };
9184
10023
  }
9185
10024
  async login() {
9186
10025
  const profile = deriveMcpProfile((await this.config()).baseUrl);
10026
+ await this.prepareToolchain();
9187
10027
  const progress = this.progress();
9188
10028
  try {
9189
10029
  progress.begin("Reading your Moodle session (a browser sign-in may be required)");
@@ -9417,7 +10257,7 @@ var DefaultMcpCommandService = class {
9417
10257
  async pushSessionFromStdin() {
9418
10258
  const config = await this.config();
9419
10259
  const profile = deriveMcpProfile(config.baseUrl);
9420
- const raw = (await readAll(this.options.stdin ?? process.stdin)).trim();
10260
+ const raw = (await readAll2(this.options.stdin ?? process.stdin)).trim();
9421
10261
  const cookieValue = raw.startsWith("MoodleSession=") ? raw.slice("MoodleSession=".length).trim() : raw;
9422
10262
  if (!cookieValue || /[\r\n]/u.test(cookieValue)) throw new UsageError("Standard input did not contain one Moodle session cookie.");
9423
10263
  const session = await getAuthenticatedSession(config.baseUrl, {
@@ -9572,6 +10412,12 @@ Selection: `)).trim());
9572
10412
  this.wranglerInstance ??= new NodeWranglerDeploymentAdapter();
9573
10413
  return this.wranglerInstance;
9574
10414
  }
10415
+ // The first-use Wrangler download asks a question, so it runs before a spinner
10416
+ // owns the terminal rather than drawing its prompt underneath one.
10417
+ async prepareToolchain(yes = false) {
10418
+ if (this.options.createDeployment || this.options.wrangler) return;
10419
+ await this.wrangler().prepare({ yes });
10420
+ }
9575
10421
  releaseDigest() {
9576
10422
  return readFile9(this.workerBundlePath()).then((content) => sha256(content));
9577
10423
  }
@@ -9593,6 +10439,9 @@ Selection: `)).trim());
9593
10439
  }
9594
10440
  // Progress belongs on stderr so that --json and --yaml keep stdout to themselves.
9595
10441
  // One shared reporter, so anything else that writes can stop the animation first.
10442
+ theme() {
10443
+ return createTheme4(this.options.color?.() ?? false);
10444
+ }
9596
10445
  progress() {
9597
10446
  this.progressReporter ??= createProgressReporter({ stream: this.options.stderr ?? process.stderr });
9598
10447
  return this.progressReporter;
@@ -9697,7 +10546,7 @@ function displayClientName(client) {
9697
10546
  };
9698
10547
  return names[client];
9699
10548
  }
9700
- async function readAll(input2) {
10549
+ async function readAll2(input2) {
9701
10550
  const decoder = new TextDecoder();
9702
10551
  let value = "";
9703
10552
  for await (const chunk of input2) value += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
@@ -9815,7 +10664,9 @@ function buildProgram(io = {}) {
9815
10664
  const merged = { ...program.opts(), ...options };
9816
10665
  const format = outputFormat(merged, stdout);
9817
10666
  const human2 = format === "table" ? formatter() : "";
9818
- const text2 = format === "table" ? `${human2}${human2.includes("Try ") ? "" : "\n\nTry moodle due \xB7 moodle units \xB7 moodle --help"}
10667
+ const text2 = format === "table" ? `${human2}${human2.includes("Try ") ? "" : `
10668
+
10669
+ ${tryLines(["moodle due", "moodle units", "moodle --help"])}`}
9819
10670
  ` : format === "json" ? `${JSON.stringify(JSON.parse(render(data, { format, fields: parseFields(data, merged.fields) })), null, merged.pretty ? 2 : void 0)}
9820
10671
  ` : render(data, { format, fields: parseFields(data, merged.fields) });
9821
10672
  if (io.stdout && !merged.output) {
@@ -9834,7 +10685,9 @@ function buildProgram(io = {}) {
9834
10685
  stdin: io.stdin,
9835
10686
  stdout,
9836
10687
  stderr,
9837
- fetchImpl: io.fetchImpl
10688
+ fetchImpl: io.fetchImpl,
10689
+ // Progress goes to stderr and the summary to stdout, so both streams must want colour.
10690
+ color: () => colorEnabled2(stdout, io.env) && colorEnabled2(stderr, io.env) && program.opts().color !== false
9838
10691
  });
9839
10692
  return mcpService;
9840
10693
  };
@@ -9849,23 +10702,26 @@ function buildProgram(io = {}) {
9849
10702
  env: io.env ?? process.env,
9850
10703
  format: outputFormat(program.opts(), stdout)
9851
10704
  }) === "human";
9852
- const theme = () => createTheme3(colorEnabled2(stderr, io.env) && program.opts().color !== false);
10705
+ const theme = () => createTheme5(colorEnabled2(stderr, io.env) && program.opts().color !== false);
9853
10706
  const signIn = async (baseUrl) => {
9854
10707
  const ui = createUi3({ input: io.stdin ?? process.stdin, output: stderr, interactive: true });
9855
- showWordmark(ui);
9856
- ui.note(`No Moodle session for ${baseUrl}.
9857
- Sign in once in your browser; later commands reuse that session.`, "One-time setup");
9858
- if (!await ui.confirm("Open the browser to sign in?", { initial: true })) {
9859
- throw new AuthError(`No usable MoodleSession found for ${baseUrl}.`, "Run moodle auth login when you are ready.");
9860
- }
9861
- const spin = ui.spinner();
9862
- spin.start("Opening the browser");
10708
+ const auth2 = { env: io.env, fetch: io.fetchImpl, homeDir: io.homeDir, captureMobileToken: true };
10709
+ runtime.busy = true;
9863
10710
  try {
9864
- const session = await getAuthenticatedSessionWithBrowserFallback(baseUrl, { env: io.env, fetch: io.fetchImpl, homeDir: io.homeDir, onBrowserOpened: (url) => spin.message(`Finish signing in at ${url}`) });
9865
- spin.stop(`Signed in as userid ${session.userid}`);
9866
- } catch (error) {
9867
- spin.error("Sign-in did not complete");
9868
- throw error;
10711
+ await signInInteractively(ui, {
10712
+ baseUrl,
10713
+ platform: process.platform,
10714
+ showWordmark,
10715
+ storesBlocked: async () => cookieStoresBlocked(await browserCookieStores({ homeDir: io.homeDir })),
10716
+ openInBrowser,
10717
+ readBrowserSession: () => getAuthenticatedSession(baseUrl, { ...auth2, noCache: true, nonInteractive: true }),
10718
+ browserLogin: (onBrowserOpened) => getAuthenticatedSessionWithBrowserFallback(baseUrl, { ...auth2, onBrowserOpened }),
10719
+ pasteLogin: () => pasteLogin(baseUrl, true),
10720
+ keepaliveInstalled: async () => (await keepaliveStatus(io.homeDir)).installed,
10721
+ installKeepalive: () => installKeepalive({ homeDir: io.homeDir })
10722
+ });
10723
+ } finally {
10724
+ runtime.busy = false;
9869
10725
  }
9870
10726
  };
9871
10727
  const choose = async (action, retry) => {
@@ -9992,11 +10848,7 @@ Sign in once in your browser; later commands reuse that session.`, "One-time set
9992
10848
  }
9993
10849
  }
9994
10850
  if (!url || !/^https?:/u.test(url)) throw new UsageError("This item has no browser URL.");
9995
- await new Promise((resolve, reject) => {
9996
- const child = spawn3(process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer.exe" : "xdg-open", [url], { stdio: "ignore" });
9997
- child.once("error", reject);
9998
- child.once("exit", (code) => code === 0 ? resolve() : reject(new Error("Could not open the browser.")));
9999
- });
10851
+ await openInBrowser(url);
10000
10852
  await runtime.output({ opened: url }, () => `Opened ${url}`, options);
10001
10853
  });
10002
10854
  addOutputOptions(program.command("user").description("Show authenticated user info.")).action(async (options) => {
@@ -10090,7 +10942,10 @@ Sign in once in your browser; later commands reuse that session.`, "One-time set
10090
10942
  addOutputOptions(program.command("doctor").description("Diagnose runtime, browser access, session, background jobs and MCP setup.").summary("Diagnose runtime, session and MCP setup")).action(async (options) => {
10091
10943
  const result = await doctor(io);
10092
10944
  await runtime.output(result, () => result.checks.map((c) => `${c.status.toUpperCase()} ${c.name}: ${c.detail}${c.hint ? `
10093
- ${c.hint}` : ""}`).join("\n") + "\n\nTry moodle auth login \xB7 moodle mcp status", options);
10945
+ ${c.hint}` : ""}`).join("\n") + `
10946
+
10947
+ ${tryLines(doctorNextSteps(result.checks))}`, options);
10948
+ if (result.checks.some((c) => c.status === "fail")) process.exitCode = 3;
10094
10949
  });
10095
10950
  program.command("completion").description("Print shell completion for zsh, bash or fish.").addArgument(program.createArgument("<shell>", "Shell to target").choices(["zsh", "bash", "fish"])).action((shell) => {
10096
10951
  const names = program.commands.filter((c) => c.name() !== "help").flatMap((c) => [c.name(), ...c.aliases()]);
@@ -10103,9 +10958,9 @@ _arguments '1:command:(${names.join(" ")})' '*:reference:'
10103
10958
  else throw new UsageError("Choose zsh, bash or fish.");
10104
10959
  });
10105
10960
  addOutputOptions(mutating(program.command("uninstall").description("Remove local background jobs; optionally remove the selected Worker and configuration.").summary("Remove background jobs, Worker and config"))).option("--remote", "Also remove the configured managed MCP deployment.").option("--purge", "Also delete local Moodle CLI configuration, receipts and cache.").action(async (options) => {
10106
- const home = io.homeDir ?? homedir13();
10961
+ const home = io.homeDir ?? homedir15();
10107
10962
  const jobs = await ownedJobs(home);
10108
- const receipts = await readdir3(path4.join(home, ".config", "moodle-cli", "mcp", "deployments")).catch(() => []);
10963
+ const receipts = await readdir4(path4.join(home, ".config", "moodle-cli", "mcp", "deployments")).catch(() => []);
10109
10964
  const result = { jobs: jobs.map((j) => j.path), remote: Boolean(options.remote), purge: Boolean(options.purge), config: path4.join(home, CONFIG_DIR_NAME), cache: path4.join(home, CACHE_DIR_NAME), package_command: "npm rm -g moodle-cli (or bun remove -g moodle-cli); for the standalone install: rm ~/.local/bin/moodle", remaining: options.remote ? "Only the configured Worker is removed. Other profiles remain remote." : "Remote Workers and credentials remain unless removed with moodle mcp remove." };
10110
10965
  if (program.opts().dryRun) return runtime.output(result, () => JSON.stringify(result, null, 2), options);
10111
10966
  if (options.purge && receipts.length && !options.remote) throw new UsageError("Managed deployment receipts exist; remove the Worker before purging its recovery information.", "Run moodle mcp remove for each configured site, then moodle uninstall --purge.");
@@ -10138,23 +10993,36 @@ ${result.package_command}`, options);
10138
10993
  ${note}`, options);
10139
10994
  }
10140
10995
  );
10141
- addOutputOptions(auth.command("login").description("Extract a fresh session, opening the browser when needed.")).action(
10996
+ addOutputOptions(
10997
+ auth.command("login").description("Sign in through a browser the CLI controls, then capture the session.").option("--paste", "Take the MoodleSession cookie from a prompt instead of the browser store.")
10998
+ ).action(
10142
10999
  async (options) => {
10143
11000
  const baseUrl = await runtime.baseUrl();
10144
- await invalidateCachedSession(baseUrl, { homeDir: io.homeDir });
10145
11001
  const humanOutput = outputFormat(options, stdout) === "table";
10146
- const session = await getAuthenticatedSessionWithBrowserFallback(baseUrl, {
11002
+ const session = options.paste ? await pasteLogin(baseUrl, humanOutput) : await getAuthenticatedSessionWithBrowserFallback(baseUrl, {
10147
11003
  env: io.env,
10148
11004
  fetch: io.fetchImpl,
10149
11005
  homeDir: io.homeDir,
10150
- onBrowserOpened: humanOutput ? (url) => stderr.write(`No active Moodle session found. Complete login in your browser:
10151
- ${url}
10152
- `) : void 0
11006
+ captureMobileToken: true,
11007
+ onBrowserOpened: humanOutput ? () => stderr.write("A browser window opened. Sign in there; I'll capture the session automatically.\n") : void 0
10153
11008
  });
10154
11009
  const result = { base_url: baseUrl, userid: session.userid, cookie_source: session.cookie.source ?? "unknown" };
10155
11010
  await runtime.output(result, () => `Authenticated as userid ${result.userid} via ${result.cookie_source}`, options);
10156
11011
  }
10157
11012
  );
11013
+ async function pasteLogin(baseUrl, humanOutput) {
11014
+ const input2 = io.stdin ?? process.stdin;
11015
+ if (humanOutput && input2.isTTY) {
11016
+ stderr.write(`Copy the ${MOODLE_SESSION_COOKIE_PREFIX} cookie for ${baseUrl} from your browser's developer tools.
11017
+ The value is not echoed and is stored in the encrypted session cache.
11018
+ `);
11019
+ }
11020
+ const raw = await readSecretLine(input2, stderr, input2.isTTY ? `${MOODLE_SESSION_COOKIE_PREFIX}: ` : "");
11021
+ if (raw === null) {
11022
+ throw new CliError2("cancelled", "Login cancelled.");
11023
+ }
11024
+ return authenticateWithPastedCookie(baseUrl, raw, { env: io.env, fetch: io.fetchImpl, homeDir: io.homeDir, captureMobileToken: true });
11025
+ }
10158
11026
  const keepalive = addOutputOptions(
10159
11027
  auth.command("keepalive").description("Renew the Moodle session once; used by the background keepalive agent.").summary("Renew the session once").option("--no-renew", "Only touch the session; skip re-login when it is expired.")
10160
11028
  ).action(async (options) => {
@@ -10171,7 +11039,7 @@ ${url}
10171
11039
  { yes: Boolean(globals.yes), dryRun: Boolean(globals.dryRun), interactive: human() }
10172
11040
  )) return;
10173
11041
  const baseUrl = await runtime.baseUrl();
10174
- await getAuthenticatedSessionWithBrowserFallback(baseUrl, { env: io.env, homeDir: io.homeDir, fetch: io.fetchImpl, noCache: true, nonInteractive: true });
11042
+ await getAuthenticatedSessionWithBrowserFallback(baseUrl, { env: io.env, homeDir: io.homeDir, fetch: io.fetchImpl, noCache: true, nonInteractive: true, captureMobileToken: true });
10175
11043
  const result = await installKeepalive({ homeDir: io.homeDir, intervalMinutes: options.interval });
10176
11044
  await runtime.output(result, () => `Keepalive installed: renews every ${result.interval_minutes} min
10177
11045
  Agent: ${result.plist_path}
@@ -10328,9 +11196,9 @@ async function runCli(argv = process.argv, io = {}) {
10328
11196
  return 0;
10329
11197
  }
10330
11198
  const format = errorOutputFormat(args, stdout);
10331
- const normalized = normalizeError(error);
11199
+ const normalized = normalizeError(asNetworkError(error) ?? error);
10332
11200
  const reference = error instanceof ReferenceError ? error : void 0;
10333
- const reported = reportError(error, "json");
11201
+ const reported = reportError(asNetworkError(error) ?? error, "json");
10334
11202
  const envelope = JSON.parse(reported.text);
10335
11203
  const hint = reference?.hint || normalized.hint || { auth: "Run moodle auth login, or moodle doctor.", config: "Run moodle doctor to check configuration.", not_found: "Run moodle units or moodle find QUERY.", usage: "Run moodle --help or moodle commands --json.", upstream: "Run moodle doctor, then retry.", network: "Check the connection, then retry.", unexpected: "Run moodle doctor; use --verbose for request timings.", cancelled: "Retry when ready." }[normalized.code];
10336
11204
  envelope.error.hint = hint;
@@ -10347,6 +11215,23 @@ ${hint}
10347
11215
  return envelope.exit_code;
10348
11216
  }
10349
11217
  }
11218
+ function openInBrowser(url) {
11219
+ return new Promise((resolve, reject) => {
11220
+ const child = spawn4(process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer.exe" : "xdg-open", [url], { stdio: "ignore" });
11221
+ child.once("error", reject);
11222
+ child.once("exit", (code) => code === 0 ? resolve() : reject(new Error("Could not open the browser.")));
11223
+ });
11224
+ }
11225
+ function doctorNextSteps(checks) {
11226
+ const failing = new Set(checks.filter((c) => c.status !== "pass").map((c) => c.name));
11227
+ const steps = [];
11228
+ if (failing.has("browser")) steps.push("grant Full Disk Access, then rerun moodle doctor");
11229
+ else if (failing.has("session")) steps.push("moodle auth login");
11230
+ if (failing.has("sqlite")) steps.push("install Node 22.13+ or Bun");
11231
+ if (failing.has("config")) steps.push("moodle units");
11232
+ if (failing.has("job")) steps.push("moodle auth keepalive install");
11233
+ return steps.length ? steps : ["moodle todo", "moodle mcp status"];
11234
+ }
10350
11235
  async function dispatchUrl(runtime, target, options) {
10351
11236
  const client = await runtime.getClient();
10352
11237
  const resolved = await resolveTopLevelUrl(client.baseUrl, target, (url) => client.resolveCourseIdForUrl(url));
@@ -10514,7 +11399,7 @@ function pathsReferToSameFile(moduleUrl, executable) {
10514
11399
  var isMain = import.meta.main === true || pathsReferToSameFile(import.meta.url, process.argv[1]);
10515
11400
  if (isMain) {
10516
11401
  runCli().then((code) => {
10517
- process.exitCode = code;
11402
+ process.exitCode = code || process.exitCode;
10518
11403
  });
10519
11404
  }
10520
11405
  export {