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/README.md +8 -0
- package/dist/moodle.js +1153 -281
- package/dist/worker/recovery.js +26 -2
- package/dist/worker/worker.js +26 -2
- package/package.json +1 -1
- package/references/command-reference.md +1 -1
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
|
|
102
|
-
import { constants as
|
|
103
|
-
import { homedir as
|
|
104
|
-
import { join as
|
|
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
|
-
|
|
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 {
|
|
155
|
-
import {
|
|
156
|
-
import {
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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 ??
|
|
763
|
-
const fallbackDirectory = platform === "win32" ? windowsPath.join(home, "AppData", "Local", "moodle-cli", "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
|
|
901
|
-
import { homedir as
|
|
902
|
-
import { dirname as dirname2, join as
|
|
903
|
-
var nodeFs = { readFile: readFile2, writeFile: writeFile2, mkdir:
|
|
904
|
-
function sessionCachePath(homeDir =
|
|
905
|
-
return
|
|
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,
|
|
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 ??
|
|
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
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
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
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
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
|
-
|
|
1126
|
-
|
|
1127
|
-
`
|
|
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 ??
|
|
1766
|
+
const home = options.homeDir ?? homedir5();
|
|
1181
1767
|
const platform = options.platform ?? process.platform;
|
|
1182
1768
|
const roots = platform === "linux" ? [
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
] : platform === "win32" ? [
|
|
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
|
|
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" ?
|
|
1206
|
-
|
|
1207
|
-
`
|
|
1208
|
-
] :
|
|
1209
|
-
|
|
1210
|
-
|
|
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
|
-
|
|
1217
|
-
"",
|
|
1218
|
-
"Cookie store diagnostics:",
|
|
1219
|
-
...warnings.map((warning) => ` - ${warning}`)
|
|
1827
|
+
...cookieDiagnostics(warnings, unreadable)
|
|
1220
1828
|
].join("\n");
|
|
1221
1829
|
}
|
|
1222
|
-
function
|
|
1223
|
-
|
|
1224
|
-
return cookieAccessHint(cookieWarnings, platform);
|
|
1225
|
-
}
|
|
1830
|
+
function cookieDiagnostics(warnings, unreadable) {
|
|
1831
|
+
const probed = unreadable.map((store) => store.path);
|
|
1226
1832
|
const lines = [
|
|
1227
|
-
|
|
1228
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1237
|
-
|
|
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),
|
|
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(/&/g, "&").replace(/"/g, '"').replace(/'/g, "'").replace(/</g, "<").replace(/>/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
|
|
1395
|
-
import { homedir as
|
|
1396
|
-
import { dirname as dirname3, join as
|
|
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:
|
|
2052
|
+
var nodeFs2 = { readFile: readFile3, writeFile: writeFile3, mkdir: mkdir4 };
|
|
1417
2053
|
function cwdConfigPath(cwd = process.cwd()) {
|
|
1418
|
-
return
|
|
2054
|
+
return join6(cwd, CONFIG_FILENAME);
|
|
1419
2055
|
}
|
|
1420
|
-
function userConfigPath(homeDir =
|
|
1421
|
-
return
|
|
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
|
|
1497
|
-
if (
|
|
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: ${
|
|
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
|
|
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 =
|
|
2239
|
+
const file2 = join7(dir, name + suffix);
|
|
1604
2240
|
try {
|
|
1605
|
-
|
|
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
|
|
1636
|
-
import { homedir as
|
|
1637
|
-
import { dirname as dirname4, join as
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
1758
|
-
return
|
|
2414
|
+
function keepalivePlistPath(homeDir = homedir7()) {
|
|
2415
|
+
return join8(homeDir, "Library/LaunchAgents", `${KEEPALIVE_LAUNCH_AGENT_LABEL}.plist`);
|
|
1759
2416
|
}
|
|
1760
|
-
function keepaliveLogPath(homeDir =
|
|
1761
|
-
return
|
|
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 ??
|
|
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
|
|
1809
|
-
await
|
|
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 ??
|
|
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 =
|
|
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 =
|
|
1846
|
-
const root =
|
|
1847
|
-
const files = await
|
|
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 =
|
|
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 ??
|
|
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 ?
|
|
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
|
|
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
|
|
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
|
|
1926
|
-
import { homedir as
|
|
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
|
|
2216
|
-
import { chmod as chmod3, mkdir as
|
|
2217
|
-
import { homedir as
|
|
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(
|
|
2854
|
+
var execFile = promisify(execFileCallback);
|
|
2221
2855
|
var NodeRenewalInstallerIO = class {
|
|
2222
2856
|
async writePrivate(path5, content, mode) {
|
|
2223
|
-
await
|
|
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 ??
|
|
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
|
|
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
|
|
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 ?
|
|
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
|
|
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"
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
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,
|
|
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: () =>
|
|
5616
|
-
|
|
5617
|
-
...
|
|
5618
|
-
|
|
5619
|
-
|
|
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.
|
|
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
|
|
6501
|
-
import { join as
|
|
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
|
|
7036
|
-
import { homedir as
|
|
7037
|
-
import { dirname as dirname6, join as
|
|
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
|
|
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 ??
|
|
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" ?
|
|
7076
|
-
const vscodeUser = platform === "darwin" ?
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
8667
|
+
import { mkdir as mkdir8, writeFile as writeFile7 } from "fs/promises";
|
|
7850
8668
|
import { existsSync } from "fs";
|
|
7851
|
-
import { homedir as
|
|
7852
|
-
import { join as
|
|
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 =
|
|
7864
|
-
const script =
|
|
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
|
|
7881
|
-
await
|
|
7882
|
-
|
|
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
|
|
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
|
|
7897
|
-
import { homedir as
|
|
7898
|
-
import { basename, dirname as dirname7, join as
|
|
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 =
|
|
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
|
|
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
|
|
8145
|
-
const artifactDirectory = await mkdtemp(
|
|
8973
|
+
await mkdir9(temporaryRoot, { recursive: true });
|
|
8974
|
+
const artifactDirectory = await mkdtemp(join12(temporaryRoot, "moodle-mcp-"));
|
|
8146
8975
|
await chmod5(artifactDirectory, 448);
|
|
8147
|
-
const workerFile =
|
|
8976
|
+
const workerFile = join12(artifactDirectory, basename(this.options.workerBundlePath));
|
|
8148
8977
|
await copyReleaseBundle(this.options.workerBundlePath, workerFile);
|
|
8149
|
-
const wranglerConfigPath =
|
|
8150
|
-
const secretsFilePath =
|
|
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
|
|
9019
|
+
await writeFile8(wranglerConfigPath, `${JSON.stringify(config, null, 2)}
|
|
8191
9020
|
`, { mode: 384 });
|
|
8192
|
-
await
|
|
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 ??
|
|
8199
|
-
await copyReleaseBundle(recoveryBundle,
|
|
8200
|
-
recoveryConfigPath =
|
|
8201
|
-
await
|
|
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 =
|
|
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
|
|
8427
|
-
await
|
|
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
|
|
9267
|
+
return join12(this.baseDirectory, `${profile}.json`);
|
|
8439
9268
|
}
|
|
8440
9269
|
};
|
|
8441
9270
|
function createDefaultManagedDeployment(options) {
|
|
8442
|
-
const homeDirectory = options.homeDirectory ??
|
|
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(
|
|
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
|
|
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 ??
|
|
9837
|
+
this.homeDirectory = options.homeDir ?? homedir14();
|
|
9009
9838
|
this.wranglerInstance = options.wrangler;
|
|
9010
|
-
this.receipts = options.receipts ?? new PrivateDeploymentReceiptStore(
|
|
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
|
|
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
|
|
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 ") ? "" :
|
|
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
|
-
|
|
9856
|
-
|
|
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
|
-
|
|
9865
|
-
|
|
9866
|
-
|
|
9867
|
-
|
|
9868
|
-
|
|
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
|
|
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") +
|
|
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 ??
|
|
10948
|
+
const home = io.homeDir ?? homedir15();
|
|
10107
10949
|
const jobs = await ownedJobs(home);
|
|
10108
|
-
const receipts = await
|
|
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(
|
|
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
|
-
|
|
10151
|
-
|
|
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 {
|