moodle-cli 0.9.0 → 0.9.1

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,7 +3713,7 @@ 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,
@@ -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.1";
6345
7160
 
6346
7161
  // src/forum.ts
6347
7162
  function parseDiscussionReference(value) {
@@ -6497,8 +7312,8 @@ function resolveTopLevelUrl(baseUrlOrOptions, targetValue, resolveCourseIdForUrl
6497
7312
  // src/mcp/cli.ts
6498
7313
  import { createHash as createHash3 } from "crypto";
6499
7314
  import { readFile as readFile9 } from "fs/promises";
6500
- import { homedir as homedir12 } from "os";
6501
- import { join as join11 } from "path";
7315
+ import { homedir as homedir14 } from "os";
7316
+ import { join as join13 } from "path";
6502
7317
  import { createInterface } from "readline/promises";
6503
7318
  import { fileURLToPath } from "url";
6504
7319
 
@@ -7032,9 +7847,9 @@ function resolveConnection(options) {
7032
7847
  }
7033
7848
 
7034
7849
  // 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";
7850
+ import { chmod as chmod4, mkdir as mkdir7, readFile as readFile7, rm as rm5, stat as stat3, writeFile as writeFile6 } from "fs/promises";
7851
+ import { homedir as homedir11 } from "os";
7852
+ import { dirname as dirname6, join as join10 } from "path";
7038
7853
  var NodeConnectorFileSystem = class {
7039
7854
  async exists(path5) {
7040
7855
  try {
@@ -7051,7 +7866,7 @@ var NodeConnectorFileSystem = class {
7051
7866
  return readFile7(path5, "utf8");
7052
7867
  }
7053
7868
  async writePrivate(path5, content) {
7054
- await mkdir6(dirname6(path5), { recursive: true, mode: 448 });
7869
+ await mkdir7(dirname6(path5), { recursive: true, mode: 448 });
7055
7870
  await writeFile6(path5, content, { encoding: "utf8", mode: 384 });
7056
7871
  await chmod4(path5, 384);
7057
7872
  }
@@ -7060,7 +7875,7 @@ var NodeConnectorFileSystem = class {
7060
7875
  }
7061
7876
  };
7062
7877
  function createDefaultClientConnectors(profile, options = {}) {
7063
- const home = options.homeDirectory ?? homedir9();
7878
+ const home = options.homeDirectory ?? homedir11();
7064
7879
  const platform = options.platform ?? process.platform;
7065
7880
  const fileSystem = options.fileSystem ?? new NodeConnectorFileSystem();
7066
7881
  const runtime = runtimeCommand(options.command, options.commandArgs);
@@ -7072,14 +7887,14 @@ function createDefaultClientConnectors(profile, options = {}) {
7072
7887
  endpoint: options.endpoint,
7073
7888
  accessToken: options.accessToken
7074
7889
  };
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");
7890
+ 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");
7891
+ 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
7892
  return [
7078
- createCodexConnector({ ...shared, configPath: join8(home, ".codex", "config.toml"), detectionPath: join8(home, ".codex") }, fileSystem),
7893
+ createCodexConnector({ ...shared, configPath: join10(home, ".codex", "config.toml"), detectionPath: join10(home, ".codex") }, fileSystem),
7079
7894
  createClaudeDesktopConnector({ ...shared, configPath: claudeDesktop, detectionPath: dirname6(claudeDesktop) }, fileSystem),
7080
- createClaudeCodeConnector({ ...shared, configPath: join8(home, ".claude.json"), detectionPath: join8(home, ".claude") }, fileSystem),
7895
+ createClaudeCodeConnector({ ...shared, configPath: join10(home, ".claude.json"), detectionPath: join10(home, ".claude") }, fileSystem),
7081
7896
  createVsCodeConnector({ ...shared, configPath: vscodeUser, detectionPath: dirname6(vscodeUser) }, fileSystem),
7082
- createCursorConnector({ ...shared, configPath: join8(home, ".cursor", "mcp.json"), detectionPath: join8(home, ".cursor") }, fileSystem)
7897
+ createCursorConnector({ ...shared, configPath: join10(home, ".cursor", "mcp.json"), detectionPath: join10(home, ".cursor") }, fileSystem)
7083
7898
  ];
7084
7899
  }
7085
7900
  var DefaultClientIntegration = class {
@@ -7840,16 +8655,19 @@ function asDeploymentError(error) {
7840
8655
  if (error instanceof DeploymentApplyError) {
7841
8656
  return error;
7842
8657
  }
8658
+ if (error instanceof CliError2) {
8659
+ return error;
8660
+ }
7843
8661
  const detail = error instanceof Error && error.message ? `: ${error.message}` : "";
7844
8662
  return new DeploymentApplyError("DEPLOYMENT_FAILED", `The managed Moodle MCP deployment failed${detail}`, { cause: error });
7845
8663
  }
7846
8664
 
7847
8665
  // src/mcp/wrangler.ts
7848
8666
  import { createUi as createUi2 } from "@bunizao/cli-kit";
7849
- import { mkdir as mkdir7 } from "fs/promises";
8667
+ import { mkdir as mkdir8, writeFile as writeFile7 } from "fs/promises";
7850
8668
  import { existsSync } from "fs";
7851
- import { homedir as homedir10 } from "os";
7852
- import { join as join9 } from "path";
8669
+ import { homedir as homedir12 } from "os";
8670
+ import { join as join11 } from "path";
7853
8671
  async function resolveWrangler(runner, options = {}) {
7854
8672
  const env = options.env ?? process.env;
7855
8673
  const notice = options.notice ?? ((text2) => process.stderr.write(`${text2}
@@ -7860,8 +8678,8 @@ async function resolveWrangler(runner, options = {}) {
7860
8678
  if (version && sameMajorAtLeast(version, WRANGLER_VERSION)) return { command: existing, args: [] };
7861
8679
  notice(`Ignoring ${existing} (${version ?? "unknown version"}); Cloudflare management needs Wrangler ${WRANGLER_VERSION.split(".")[0]}.x.`);
7862
8680
  }
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");
8681
+ const root = join11(options.homeDir ?? homedir12(), ".config", "moodle-cli", "tools", `wrangler@${WRANGLER_VERSION}`);
8682
+ const script = join11(root, "node_modules", "wrangler", "bin", "wrangler.js");
7865
8683
  const bun = findExecutable("bun", env);
7866
8684
  const node = findExecutable("node", env);
7867
8685
  if (!bun && !node) throw new Error("Cloudflare management needs Bun or Node 22.13+. Install either, then retry moodle mcp deploy.");
@@ -7877,9 +8695,16 @@ async function resolveWrangler(runner, options = {}) {
7877
8695
  }
7878
8696
  }
7879
8697
  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.");
8698
+ await mkdir8(root, { recursive: true, mode: 448 });
8699
+ await writeFile7(join11(root, "package.json"), '{ "private": true }\n');
8700
+ 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}`]);
8701
+ if (!existsSync(script)) {
8702
+ const output = `${result.stderr}
8703
+ ${result.stdout}`.trim().split(/\r?\n/u).slice(-5).join("\n");
8704
+ throw new Error(`Wrangler installation did not create ${script}.${output ? `
8705
+ ${output}` : ""}
8706
+ Remove ${root} and retry moodle mcp deploy.`);
8707
+ }
7883
8708
  }
7884
8709
  return { command: node ?? bun, args: [script] };
7885
8710
  }
@@ -7891,18 +8716,18 @@ function sameMajorAtLeast(actual, pinned) {
7891
8716
 
7892
8717
  // src/mcp/deployment/node-adapters.ts
7893
8718
  import { isDeepStrictEqual } from "util";
7894
- import { spawn as spawn2 } from "child_process";
8719
+ import { spawn as spawn3 } from "child_process";
7895
8720
  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";
8721
+ import { chmod as chmod5, mkdir as mkdir9, mkdtemp, readFile as readFile8, rm as rm6, writeFile as writeFile8 } from "fs/promises";
8722
+ import { homedir as homedir13, tmpdir } from "os";
8723
+ import { basename, dirname as dirname7, join as join12 } from "path";
7899
8724
  var MODERN_MCP_VERSION = "2026-07-28";
7900
8725
  var WORKER_PROPAGATION_ATTEMPTS = 10;
7901
8726
  var WORKER_PROPAGATION_MAX_DELAY_MS = 4e3;
7902
8727
  var NodeDeploymentCommandRunner = class {
7903
8728
  async run(command, args, environment = {}) {
7904
8729
  return new Promise((resolve, reject) => {
7905
- const child = spawn2(command, args, {
8730
+ const child = spawn3(command, args, {
7906
8731
  env: { ...process.env, ...environment },
7907
8732
  stdio: ["ignore", "pipe", "pipe"],
7908
8733
  windowsHide: true
@@ -8118,6 +8943,10 @@ ${error.stderr}`)) {
8118
8943
  }
8119
8944
  await this.wrangler(["delete", input2.workerName, "--force"], input2.accountId);
8120
8945
  }
8946
+ /** Locate or download Wrangler now, so a first-use prompt is not drawn under a spinner later. */
8947
+ async prepare(options = {}) {
8948
+ if (!this.wranglerBinPath) await resolveWrangler(this.runner, options);
8949
+ }
8121
8950
  async wrangler(args, accountId, environmentOverrides = {}) {
8122
8951
  const environment = { ...environmentOverrides };
8123
8952
  if (accountId) {
@@ -8132,7 +8961,7 @@ ${error.stderr}`)) {
8132
8961
  }
8133
8962
  };
8134
8963
  async function copyReleaseBundle(source, destination) {
8135
- await writeFile7(destination, await readFile8(source));
8964
+ await writeFile8(destination, await readFile8(source));
8136
8965
  }
8137
8966
  var NodeReleaseMaterializer = class {
8138
8967
  constructor(options) {
@@ -8141,13 +8970,13 @@ var NodeReleaseMaterializer = class {
8141
8970
  options;
8142
8971
  async prepare(plan, credentials) {
8143
8972
  const temporaryRoot = this.options.temporaryRoot ?? tmpdir();
8144
- await mkdir8(temporaryRoot, { recursive: true });
8145
- const artifactDirectory = await mkdtemp(join10(temporaryRoot, "moodle-mcp-"));
8973
+ await mkdir9(temporaryRoot, { recursive: true });
8974
+ const artifactDirectory = await mkdtemp(join12(temporaryRoot, "moodle-mcp-"));
8146
8975
  await chmod5(artifactDirectory, 448);
8147
- const workerFile = join10(artifactDirectory, basename(this.options.workerBundlePath));
8976
+ const workerFile = join12(artifactDirectory, basename(this.options.workerBundlePath));
8148
8977
  await copyReleaseBundle(this.options.workerBundlePath, workerFile);
8149
- const wranglerConfigPath = join10(artifactDirectory, "wrangler.json");
8150
- const secretsFilePath = join10(artifactDirectory, "secrets.json");
8978
+ const wranglerConfigPath = join12(artifactDirectory, "wrangler.json");
8979
+ const secretsFilePath = join12(artifactDirectory, "secrets.json");
8151
8980
  const expectedHosts = endpointHosts(plan.intent.workerName, plan.existing?.productionEndpoint);
8152
8981
  const config = {
8153
8982
  $schema: "node_modules/wrangler/config-schema.json",
@@ -8187,18 +9016,18 @@ var NodeReleaseMaterializer = class {
8187
9016
  if (credentials.previousTokensExpireAt !== void 0 && Number.isFinite(credentials.previousTokensExpireAt)) {
8188
9017
  secrets.TOKEN_OVERLAP_EXPIRES_AT = String(credentials.previousTokensExpireAt);
8189
9018
  }
8190
- await writeFile7(wranglerConfigPath, `${JSON.stringify(config, null, 2)}
9019
+ await writeFile8(wranglerConfigPath, `${JSON.stringify(config, null, 2)}
8191
9020
  `, { mode: 384 });
8192
- await writeFile7(secretsFilePath, `${JSON.stringify(secrets)}
9021
+ await writeFile8(secretsFilePath, `${JSON.stringify(secrets)}
8193
9022
  `, { mode: 384 });
8194
9023
  await chmod5(wranglerConfigPath, 384);
8195
9024
  await chmod5(secretsFilePath, 384);
8196
9025
  let recoveryConfigPath;
8197
9026
  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" })}
9027
+ const recoveryBundle = process.env.MOODLE_BUNDLED_RECOVERY ?? join12(dirname7(this.options.workerBundlePath), "recovery.js");
9028
+ await copyReleaseBundle(recoveryBundle, join12(artifactDirectory, "recovery.js"));
9029
+ recoveryConfigPath = join12(artifactDirectory, "wrangler-recovery.json");
9030
+ await writeFile8(recoveryConfigPath, `${JSON.stringify({ ...config, main: "./recovery.js" })}
8202
9031
  `, { mode: 384 });
8203
9032
  } catch (error) {
8204
9033
  if (!isMissing4(error) || plan.existing) throw error;
@@ -8406,7 +9235,7 @@ var FetchManagedWorkerClient = class {
8406
9235
  }
8407
9236
  };
8408
9237
  var PrivateDeploymentReceiptStore = class {
8409
- constructor(baseDirectory = join10(homedir11(), ".config", "moodle-cli", "mcp", "deployments")) {
9238
+ constructor(baseDirectory = join12(homedir13(), ".config", "moodle-cli", "mcp", "deployments")) {
8410
9239
  this.baseDirectory = baseDirectory;
8411
9240
  }
8412
9241
  baseDirectory;
@@ -8423,8 +9252,8 @@ var PrivateDeploymentReceiptStore = class {
8423
9252
  }
8424
9253
  async write(receipt) {
8425
9254
  const path5 = this.path(receipt.profile);
8426
- await mkdir8(dirname7(path5), { recursive: true, mode: 448 });
8427
- await writeFile7(path5, `${JSON.stringify(receipt, null, 2)}
9255
+ await mkdir9(dirname7(path5), { recursive: true, mode: 448 });
9256
+ await writeFile8(path5, `${JSON.stringify(receipt, null, 2)}
8428
9257
  `, { mode: 384 });
8429
9258
  await chmod5(path5, 384);
8430
9259
  }
@@ -8435,11 +9264,11 @@ var PrivateDeploymentReceiptStore = class {
8435
9264
  if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(profile)) {
8436
9265
  throw new Error("Invalid Moodle MCP profile name");
8437
9266
  }
8438
- return join10(this.baseDirectory, `${profile}.json`);
9267
+ return join12(this.baseDirectory, `${profile}.json`);
8439
9268
  }
8440
9269
  };
8441
9270
  function createDefaultManagedDeployment(options) {
8442
- const homeDirectory = options.homeDirectory ?? homedir11();
9271
+ const homeDirectory = options.homeDirectory ?? homedir13();
8443
9272
  const platform = options.platform ?? process.platform;
8444
9273
  const runtime = runtimeCommand(options.executable, options.executableArgs);
8445
9274
  const defaults = {
@@ -8466,7 +9295,7 @@ function createDefaultManagedDeployment(options) {
8466
9295
  command: runtime.command,
8467
9296
  commandArgs: runtime.args
8468
9297
  }),
8469
- receipts: new PrivateDeploymentReceiptStore(join10(homeDirectory, ".config", "moodle-cli", "mcp", "deployments")),
9298
+ receipts: new PrivateDeploymentReceiptStore(join12(homeDirectory, ".config", "moodle-cli", "mcp", "deployments")),
8470
9299
  createToken: () => randomBytes2(32).toString("base64url")
8471
9300
  };
8472
9301
  return new ManagedMcpDeployment({ ...defaults, ...options.dependencies });
@@ -8495,7 +9324,7 @@ async function pinExpectedHosts(configPath, workerName, productionEndpoint) {
8495
9324
  throw new DeploymentApplyError("MISSING_ENDPOINT", "The Worker production endpoint is invalid");
8496
9325
  }
8497
9326
  config.vars.EXPECTED_HOSTS = hosts.join(",");
8498
- await writeFile7(configPath, `${JSON.stringify(config, null, 2)}
9327
+ await writeFile8(configPath, `${JSON.stringify(config, null, 2)}
8499
9328
  `, { mode: 384 });
8500
9329
  }
8501
9330
  function endpointHosts(workerName, endpoint) {
@@ -9005,9 +9834,9 @@ function deriveMcpWorkerName(moodleOrigin) {
9005
9834
  var DefaultMcpCommandService = class {
9006
9835
  constructor(options) {
9007
9836
  this.options = options;
9008
- this.homeDirectory = options.homeDir ?? homedir12();
9837
+ this.homeDirectory = options.homeDir ?? homedir14();
9009
9838
  this.wranglerInstance = options.wrangler;
9010
- this.receipts = options.receipts ?? new PrivateDeploymentReceiptStore(join11(this.homeDirectory, ".config", "moodle-cli", "mcp", "deployments"));
9839
+ this.receipts = options.receipts ?? new PrivateDeploymentReceiptStore(join13(this.homeDirectory, ".config", "moodle-cli", "mcp", "deployments"));
9011
9840
  this.credentials = options.credentials ?? createDefaultCredentialStore({ platform: process.platform, homeDirectory: this.homeDirectory });
9012
9841
  this.worker = options.worker ?? new FetchManagedWorkerClient(options.fetchImpl);
9013
9842
  this.renewal = options.renewal ?? new DefaultRenewalIntegration({
@@ -9032,6 +9861,7 @@ var DefaultMcpCommandService = class {
9032
9861
  notifyRenewalSignIn;
9033
9862
  progressReporter;
9034
9863
  async deploy(input2) {
9864
+ await this.prepareToolchain(input2.yes);
9035
9865
  const progress = this.progress();
9036
9866
  try {
9037
9867
  progress.begin("Reading Cloudflare account and deployment state");
@@ -9142,6 +9972,7 @@ var DefaultMcpCommandService = class {
9142
9972
  async status(input2) {
9143
9973
  const config = await this.config();
9144
9974
  const profile = deriveMcpProfile(config.baseUrl);
9975
+ await this.prepareToolchain();
9145
9976
  const progress = this.progress();
9146
9977
  let managed;
9147
9978
  try {
@@ -9184,6 +10015,7 @@ var DefaultMcpCommandService = class {
9184
10015
  }
9185
10016
  async login() {
9186
10017
  const profile = deriveMcpProfile((await this.config()).baseUrl);
10018
+ await this.prepareToolchain();
9187
10019
  const progress = this.progress();
9188
10020
  try {
9189
10021
  progress.begin("Reading your Moodle session (a browser sign-in may be required)");
@@ -9417,7 +10249,7 @@ var DefaultMcpCommandService = class {
9417
10249
  async pushSessionFromStdin() {
9418
10250
  const config = await this.config();
9419
10251
  const profile = deriveMcpProfile(config.baseUrl);
9420
- const raw = (await readAll(this.options.stdin ?? process.stdin)).trim();
10252
+ const raw = (await readAll2(this.options.stdin ?? process.stdin)).trim();
9421
10253
  const cookieValue = raw.startsWith("MoodleSession=") ? raw.slice("MoodleSession=".length).trim() : raw;
9422
10254
  if (!cookieValue || /[\r\n]/u.test(cookieValue)) throw new UsageError("Standard input did not contain one Moodle session cookie.");
9423
10255
  const session = await getAuthenticatedSession(config.baseUrl, {
@@ -9572,6 +10404,12 @@ Selection: `)).trim());
9572
10404
  this.wranglerInstance ??= new NodeWranglerDeploymentAdapter();
9573
10405
  return this.wranglerInstance;
9574
10406
  }
10407
+ // The first-use Wrangler download asks a question, so it runs before a spinner
10408
+ // owns the terminal rather than drawing its prompt underneath one.
10409
+ async prepareToolchain(yes = false) {
10410
+ if (this.options.createDeployment || this.options.wrangler) return;
10411
+ await this.wrangler().prepare({ yes });
10412
+ }
9575
10413
  releaseDigest() {
9576
10414
  return readFile9(this.workerBundlePath()).then((content) => sha256(content));
9577
10415
  }
@@ -9697,7 +10535,7 @@ function displayClientName(client) {
9697
10535
  };
9698
10536
  return names[client];
9699
10537
  }
9700
- async function readAll(input2) {
10538
+ async function readAll2(input2) {
9701
10539
  const decoder = new TextDecoder();
9702
10540
  let value = "";
9703
10541
  for await (const chunk of input2) value += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
@@ -9815,7 +10653,9 @@ function buildProgram(io = {}) {
9815
10653
  const merged = { ...program.opts(), ...options };
9816
10654
  const format = outputFormat(merged, stdout);
9817
10655
  const human2 = format === "table" ? formatter() : "";
9818
- const text2 = format === "table" ? `${human2}${human2.includes("Try ") ? "" : "\n\nTry moodle due \xB7 moodle units \xB7 moodle --help"}
10656
+ const text2 = format === "table" ? `${human2}${human2.includes("Try ") ? "" : `
10657
+
10658
+ ${tryLines(["moodle due", "moodle units", "moodle --help"])}`}
9819
10659
  ` : format === "json" ? `${JSON.stringify(JSON.parse(render(data, { format, fields: parseFields(data, merged.fields) })), null, merged.pretty ? 2 : void 0)}
9820
10660
  ` : render(data, { format, fields: parseFields(data, merged.fields) });
9821
10661
  if (io.stdout && !merged.output) {
@@ -9852,20 +10692,23 @@ function buildProgram(io = {}) {
9852
10692
  const theme = () => createTheme3(colorEnabled2(stderr, io.env) && program.opts().color !== false);
9853
10693
  const signIn = async (baseUrl) => {
9854
10694
  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");
10695
+ const auth2 = { env: io.env, fetch: io.fetchImpl, homeDir: io.homeDir, captureMobileToken: true };
10696
+ runtime.busy = true;
9863
10697
  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;
10698
+ await signInInteractively(ui, {
10699
+ baseUrl,
10700
+ platform: process.platform,
10701
+ showWordmark,
10702
+ storesBlocked: async () => cookieStoresBlocked(await browserCookieStores({ homeDir: io.homeDir })),
10703
+ openInBrowser,
10704
+ readBrowserSession: () => getAuthenticatedSession(baseUrl, { ...auth2, noCache: true, nonInteractive: true }),
10705
+ browserLogin: (onBrowserOpened) => getAuthenticatedSessionWithBrowserFallback(baseUrl, { ...auth2, onBrowserOpened }),
10706
+ pasteLogin: () => pasteLogin(baseUrl, true),
10707
+ keepaliveInstalled: async () => (await keepaliveStatus(io.homeDir)).installed,
10708
+ installKeepalive: () => installKeepalive({ homeDir: io.homeDir })
10709
+ });
10710
+ } finally {
10711
+ runtime.busy = false;
9869
10712
  }
9870
10713
  };
9871
10714
  const choose = async (action, retry) => {
@@ -9992,11 +10835,7 @@ Sign in once in your browser; later commands reuse that session.`, "One-time set
9992
10835
  }
9993
10836
  }
9994
10837
  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
- });
10838
+ await openInBrowser(url);
10000
10839
  await runtime.output({ opened: url }, () => `Opened ${url}`, options);
10001
10840
  });
10002
10841
  addOutputOptions(program.command("user").description("Show authenticated user info.")).action(async (options) => {
@@ -10090,7 +10929,10 @@ Sign in once in your browser; later commands reuse that session.`, "One-time set
10090
10929
  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
10930
  const result = await doctor(io);
10092
10931
  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);
10932
+ ${c.hint}` : ""}`).join("\n") + `
10933
+
10934
+ ${tryLines(doctorNextSteps(result.checks))}`, options);
10935
+ if (result.checks.some((c) => c.status === "fail")) process.exitCode = 3;
10094
10936
  });
10095
10937
  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
10938
  const names = program.commands.filter((c) => c.name() !== "help").flatMap((c) => [c.name(), ...c.aliases()]);
@@ -10103,9 +10945,9 @@ _arguments '1:command:(${names.join(" ")})' '*:reference:'
10103
10945
  else throw new UsageError("Choose zsh, bash or fish.");
10104
10946
  });
10105
10947
  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();
10948
+ const home = io.homeDir ?? homedir15();
10107
10949
  const jobs = await ownedJobs(home);
10108
- const receipts = await readdir3(path4.join(home, ".config", "moodle-cli", "mcp", "deployments")).catch(() => []);
10950
+ const receipts = await readdir4(path4.join(home, ".config", "moodle-cli", "mcp", "deployments")).catch(() => []);
10109
10951
  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
10952
  if (program.opts().dryRun) return runtime.output(result, () => JSON.stringify(result, null, 2), options);
10111
10953
  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 +10980,36 @@ ${result.package_command}`, options);
10138
10980
  ${note}`, options);
10139
10981
  }
10140
10982
  );
10141
- addOutputOptions(auth.command("login").description("Extract a fresh session, opening the browser when needed.")).action(
10983
+ addOutputOptions(
10984
+ 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.")
10985
+ ).action(
10142
10986
  async (options) => {
10143
10987
  const baseUrl = await runtime.baseUrl();
10144
- await invalidateCachedSession(baseUrl, { homeDir: io.homeDir });
10145
10988
  const humanOutput = outputFormat(options, stdout) === "table";
10146
- const session = await getAuthenticatedSessionWithBrowserFallback(baseUrl, {
10989
+ const session = options.paste ? await pasteLogin(baseUrl, humanOutput) : await getAuthenticatedSessionWithBrowserFallback(baseUrl, {
10147
10990
  env: io.env,
10148
10991
  fetch: io.fetchImpl,
10149
10992
  homeDir: io.homeDir,
10150
- onBrowserOpened: humanOutput ? (url) => stderr.write(`No active Moodle session found. Complete login in your browser:
10151
- ${url}
10152
- `) : void 0
10993
+ captureMobileToken: true,
10994
+ onBrowserOpened: humanOutput ? () => stderr.write("A browser window opened. Sign in there; I'll capture the session automatically.\n") : void 0
10153
10995
  });
10154
10996
  const result = { base_url: baseUrl, userid: session.userid, cookie_source: session.cookie.source ?? "unknown" };
10155
10997
  await runtime.output(result, () => `Authenticated as userid ${result.userid} via ${result.cookie_source}`, options);
10156
10998
  }
10157
10999
  );
11000
+ async function pasteLogin(baseUrl, humanOutput) {
11001
+ const input2 = io.stdin ?? process.stdin;
11002
+ if (humanOutput && input2.isTTY) {
11003
+ stderr.write(`Copy the ${MOODLE_SESSION_COOKIE_PREFIX} cookie for ${baseUrl} from your browser's developer tools.
11004
+ The value is not echoed and is stored in the encrypted session cache.
11005
+ `);
11006
+ }
11007
+ const raw = await readSecretLine(input2, stderr, input2.isTTY ? `${MOODLE_SESSION_COOKIE_PREFIX}: ` : "");
11008
+ if (raw === null) {
11009
+ throw new CliError2("cancelled", "Login cancelled.");
11010
+ }
11011
+ return authenticateWithPastedCookie(baseUrl, raw, { env: io.env, fetch: io.fetchImpl, homeDir: io.homeDir, captureMobileToken: true });
11012
+ }
10158
11013
  const keepalive = addOutputOptions(
10159
11014
  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
11015
  ).action(async (options) => {
@@ -10171,7 +11026,7 @@ ${url}
10171
11026
  { yes: Boolean(globals.yes), dryRun: Boolean(globals.dryRun), interactive: human() }
10172
11027
  )) return;
10173
11028
  const baseUrl = await runtime.baseUrl();
10174
- await getAuthenticatedSessionWithBrowserFallback(baseUrl, { env: io.env, homeDir: io.homeDir, fetch: io.fetchImpl, noCache: true, nonInteractive: true });
11029
+ await getAuthenticatedSessionWithBrowserFallback(baseUrl, { env: io.env, homeDir: io.homeDir, fetch: io.fetchImpl, noCache: true, nonInteractive: true, captureMobileToken: true });
10175
11030
  const result = await installKeepalive({ homeDir: io.homeDir, intervalMinutes: options.interval });
10176
11031
  await runtime.output(result, () => `Keepalive installed: renews every ${result.interval_minutes} min
10177
11032
  Agent: ${result.plist_path}
@@ -10328,9 +11183,9 @@ async function runCli(argv = process.argv, io = {}) {
10328
11183
  return 0;
10329
11184
  }
10330
11185
  const format = errorOutputFormat(args, stdout);
10331
- const normalized = normalizeError(error);
11186
+ const normalized = normalizeError(asNetworkError(error) ?? error);
10332
11187
  const reference = error instanceof ReferenceError ? error : void 0;
10333
- const reported = reportError(error, "json");
11188
+ const reported = reportError(asNetworkError(error) ?? error, "json");
10334
11189
  const envelope = JSON.parse(reported.text);
10335
11190
  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
11191
  envelope.error.hint = hint;
@@ -10347,6 +11202,23 @@ ${hint}
10347
11202
  return envelope.exit_code;
10348
11203
  }
10349
11204
  }
11205
+ function openInBrowser(url) {
11206
+ return new Promise((resolve, reject) => {
11207
+ const child = spawn4(process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer.exe" : "xdg-open", [url], { stdio: "ignore" });
11208
+ child.once("error", reject);
11209
+ child.once("exit", (code) => code === 0 ? resolve() : reject(new Error("Could not open the browser.")));
11210
+ });
11211
+ }
11212
+ function doctorNextSteps(checks) {
11213
+ const failing = new Set(checks.filter((c) => c.status !== "pass").map((c) => c.name));
11214
+ const steps = [];
11215
+ if (failing.has("browser")) steps.push("grant Full Disk Access, then rerun moodle doctor");
11216
+ else if (failing.has("session")) steps.push("moodle auth login");
11217
+ if (failing.has("sqlite")) steps.push("install Node 22.13+ or Bun");
11218
+ if (failing.has("config")) steps.push("moodle units");
11219
+ if (failing.has("job")) steps.push("moodle auth keepalive install");
11220
+ return steps.length ? steps : ["moodle todo", "moodle mcp status"];
11221
+ }
10350
11222
  async function dispatchUrl(runtime, target, options) {
10351
11223
  const client = await runtime.getClient();
10352
11224
  const resolved = await resolveTopLevelUrl(client.baseUrl, target, (url) => client.resolveCourseIdForUrl(url));
@@ -10514,7 +11386,7 @@ function pathsReferToSameFile(moduleUrl, executable) {
10514
11386
  var isMain = import.meta.main === true || pathsReferToSameFile(import.meta.url, process.argv[1]);
10515
11387
  if (isMain) {
10516
11388
  runCli().then((code) => {
10517
- process.exitCode = code;
11389
+ process.exitCode = code || process.exitCode;
10518
11390
  });
10519
11391
  }
10520
11392
  export {