moodle-cli 0.8.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 +32 -3
- package/SKILL.md +4 -3
- package/agents/openai.yaml +1 -1
- package/dist/moodle.js +2305 -844
- package/dist/worker/recovery.js +802 -385
- package/dist/worker/worker.js +802 -385
- package/package.json +2 -2
- package/references/command-reference.md +7 -2
package/dist/moodle.js
CHANGED
|
@@ -20,6 +20,8 @@ var counts = z.object(Object.fromEntries(["notification_count", "unread_notifica
|
|
|
20
20
|
var postSchema = z.object({ id, parent_id: n, name: s, author: z.object({ id, name: s }), time_created: id, created: s, message_text: s, links: z.array(z.object({ text: s, url: s })).optional() });
|
|
21
21
|
var gradeSchema = z.object({ unit_id: id, code: s, graded: id, total: id, total_grade: s, total_range: s, total_percentage: s, items: z.array(z.object({ name: s, type: s, grade: s, range: s, percentage: s, weight: s, contribution: s, feedback: s, status: s, due: s, due_at: n })).optional() });
|
|
22
22
|
var searchSchema = z.object({ unit_id: id, forum_id: id, discussion_id: id, name: s, post_id: id, snippet: s, time_created: n, created: s, url: s });
|
|
23
|
+
var submissionFile = z.object({ name: z.string(), bytes: n, url: s });
|
|
24
|
+
var receiptSchema = z.object({ id, name: s, unit_id: n, url: s, action: z.enum(["planned", "saved", "submitted"]), submission_status: s, grading_status: s, due: s, time_remaining: s, last_modified: s, statement: s, statement_accepted: z.boolean().optional(), files: z.array(submissionFile).optional(), uploads: z.array(z.object({ name: z.string(), bytes: id, path: s })).optional(), removed: z.array(z.string()).optional(), limits: z.object({ max_bytes: n, max_files: n, area_max_bytes: n, accepted_types: z.array(z.string()).optional() }).optional(), checked_at: z.string() });
|
|
23
25
|
var input = (shape) => z.object(shape).strict();
|
|
24
26
|
var list = (key, value) => z.object({ [key]: z.array(value).optional(), total: id });
|
|
25
27
|
var intentContracts = {
|
|
@@ -33,6 +35,7 @@ var intentContracts = {
|
|
|
33
35
|
news: { when: "announcements", command: "moodle news [UNIT]", what: "Latest announcement threads with first-post text.", instead: "search_forums for other discussions", refs: "optional unit code, name, id or URL", then: "thread with discussion id", cost: "up to 5 announcements by default", input: input({ unit: ref.optional(), limit: limit.default(5) }), output: list("news", z.object({ id, name: s, unit_id: id, unit_code: s, forum_id: id, post: postSchema.optional() })) },
|
|
34
36
|
thread: { when: "discussion posts", command: "moodle threads show ID", what: "A discussion and a page of compact posts, with attachment links.", instead: "news for announcements", refs: "discussion_id; offset and limit", then: "increase offset while posts_total exceeds returned", cost: "up to 20 posts by default", input: input({ discussion_id: z.number().int().positive(), limit, offset: z.number().int().nonnegative().default(0) }), output: z.object({ thread: z.object({ id, name: s, unit_id: id, forum_id: id, url: s, posts: z.array(postSchema).optional(), posts_total: id, offset: id }) }) },
|
|
35
37
|
search_forums: { when: "forum post text", command: 'moodle forums search "QUERY" --unit UNIT', what: "Matching posts with unit and forum name maps.", instead: "find for activity names", refs: "query, optional unit or courseId and forumId", then: "thread with discussion_id", cost: "bounded forum scan; total covers scanned scope", input: input({ query: z.string().trim().min(1), unit: ref.optional(), courseId: z.number().int().positive().optional(), forumId: z.number().int().positive().optional(), limit, includePostText: z.boolean().default(true), titlesOnly: z.boolean().default(false), unreadOnly: z.boolean().default(false), sortBy: z.enum(["relevance", "recent"]).default("relevance"), maxForums: limit.default(20), maxDiscussionsPerForum: limit.default(50) }), output: z.object({ results: z.array(searchSchema).optional(), total: id, forums: z.record(z.string(), z.string()).optional(), units: z.record(z.string(), z.string()).optional(), scope: z.object({ max_forums: id, max_discussions_per_forum: id }) }) },
|
|
38
|
+
submit: { when: "upload assignment files", command: 'moodle submit "UNIT TASK" FILE... [--final]', what: "Upload local files into an assignment; returns the receipt Moodle shows afterwards.", instead: "item for status only", refs: "assignment id, same-site URL or UNIT TASK phrase; local file paths", then: "dry_run (default) only plans; show the plan to the person, then rerun with dry_run false; final submits for grading and cannot be undone", cost: "writes to Moodle", input: input({ ref, files: z.array(z.string().trim().min(1)).max(20).default([]), final: z.boolean().default(false), replace: z.boolean().default(false), accept_statement: z.boolean().default(false), dry_run: z.boolean().default(true) }), output: z.object({ submission: receiptSchema }) },
|
|
36
39
|
file: { when: "download a file", command: 'moodle get "UNIT TASK" --to DIR', what: "One authenticated file as embedded content, at most 16 MiB.", instead: "item for file choices", refs: "resource id, same-site URL, or UNIT TASK phrase", then: "read the returned resource", cost: "binary content up to 16 MiB", input: input({ ref }), output: z.object({ file: z.object({ name: z.string(), mime_type: z.string(), bytes: id, uri: z.string() }) }) }
|
|
37
40
|
};
|
|
38
41
|
function humanDescription(name) {
|
|
@@ -95,10 +98,10 @@ function postRow(p, subject, tz = "UTC") {
|
|
|
95
98
|
}
|
|
96
99
|
|
|
97
100
|
// src/doctor.ts
|
|
98
|
-
import { access, readdir as
|
|
99
|
-
import { constants as
|
|
100
|
-
import { homedir as
|
|
101
|
-
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";
|
|
102
105
|
|
|
103
106
|
// src/session-fetch.ts
|
|
104
107
|
var MAX_REDIRECTS = 5;
|
|
@@ -124,7 +127,12 @@ async function fetchWithSession(input2, init, moodleOrigin, cookie, fetchImpl =
|
|
|
124
127
|
headers.delete("authorization");
|
|
125
128
|
headers.delete("proxy-authorization");
|
|
126
129
|
}
|
|
127
|
-
|
|
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
|
+
}
|
|
128
136
|
if (!REDIRECT_STATUSES.has(response.status)) return response;
|
|
129
137
|
const location = response.headers.get("location");
|
|
130
138
|
if (!location) return response;
|
|
@@ -145,13 +153,31 @@ async function fetchWithSession(input2, init, moodleOrigin, cookie, fetchImpl =
|
|
|
145
153
|
url = next;
|
|
146
154
|
}
|
|
147
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
|
+
}
|
|
148
175
|
|
|
149
176
|
// src/auth.ts
|
|
150
177
|
import { ALL_PROFILES, getCookies } from "@steipete/sweet-cookie";
|
|
151
|
-
import {
|
|
152
|
-
import {
|
|
153
|
-
import {
|
|
154
|
-
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";
|
|
155
181
|
|
|
156
182
|
// src/constants.ts
|
|
157
183
|
var PACKAGE_NAME = "moodle-cli";
|
|
@@ -171,6 +197,15 @@ var GRADE_REPORT_INDEX_PATH = "/grade/report/index.php";
|
|
|
171
197
|
var GRADE_REPORT_OVERVIEW_PATH = "/grade/report/overview/index.php";
|
|
172
198
|
var GRADE_REPORT_PATH = "/grade/report/user/index.php";
|
|
173
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";
|
|
174
209
|
var FUNC_GET_SITE_INFO = "core_webservice_get_site_info";
|
|
175
210
|
var FUNC_GET_COURSES = "core_enrol_get_users_courses";
|
|
176
211
|
var FUNC_GET_COURSES_BY_TIMELINE = "core_course_get_enrolled_courses_by_timeline_classification";
|
|
@@ -189,6 +224,7 @@ var CONFIG_FILENAME = "config.yaml";
|
|
|
189
224
|
var CONFIG_DIR_NAME = ".config/moodle-cli";
|
|
190
225
|
var CACHE_DIR_NAME = ".cache/moodle-cli";
|
|
191
226
|
var SESSION_CACHE_FILENAME = "session.json";
|
|
227
|
+
var CDP_PROFILE_DIR_NAME = ".cache/moodle-cli/browser-profile";
|
|
192
228
|
var DEFAULT_SESSION_CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
193
229
|
var KEEPALIVE_LAUNCH_AGENT_LABEL = "com.moodle-cli.keepalive";
|
|
194
230
|
var KEEPALIVE_DEFAULT_INTERVAL_MINUTES = 30;
|
|
@@ -201,6 +237,466 @@ var ENV_MOODLE_TOKEN = "MOODLE_TOKEN";
|
|
|
201
237
|
var MOODLE_SESSION_COOKIE_PREFIX = "MoodleSession";
|
|
202
238
|
var WRANGLER_VERSION = "4.131.0";
|
|
203
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
|
+
|
|
204
700
|
// src/errors.ts
|
|
205
701
|
import { CliError } from "@bunizao/cli-kit";
|
|
206
702
|
import { CliError as CliError2 } from "@bunizao/cli-kit";
|
|
@@ -239,15 +735,18 @@ function isLoginRequiredError(error) {
|
|
|
239
735
|
function isLoginErrorCode(code) {
|
|
240
736
|
return ["servicerequireslogin", "sitepolicynotagreed"].includes(code ?? "");
|
|
241
737
|
}
|
|
738
|
+
function asNetworkError(error) {
|
|
739
|
+
return error instanceof RequestFailed ? new CliError("network", error.message, "Check the connection or VPN, then retry.") : null;
|
|
740
|
+
}
|
|
242
741
|
|
|
243
742
|
// src/session-cache.ts
|
|
244
743
|
import { createHash, randomBytes } from "crypto";
|
|
245
744
|
|
|
246
745
|
// src/mcp/credentials/node-store.ts
|
|
247
|
-
import { spawn } from "child_process";
|
|
248
|
-
import { chmod, mkdir, readFile, rename, rm, writeFile } from "fs/promises";
|
|
249
|
-
import { homedir } from "os";
|
|
250
|
-
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";
|
|
251
750
|
|
|
252
751
|
// src/mcp/credentials/store.ts
|
|
253
752
|
var TOKEN_OVERLAP_MS = 10 * 60 * 1e3;
|
|
@@ -472,7 +971,7 @@ if ([IO.File]::Exists($payload.path)) { Remove-Item -LiteralPath $payload.path -
|
|
|
472
971
|
var NodeCredentialCommandRunner = class {
|
|
473
972
|
async run(command, args, input2) {
|
|
474
973
|
return new Promise((resolve, reject) => {
|
|
475
|
-
const child =
|
|
974
|
+
const child = spawn2(command, args, { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
|
|
476
975
|
let stdout = "";
|
|
477
976
|
let stderr = "";
|
|
478
977
|
child.stdout.setEncoding("utf8").on("data", (chunk) => {
|
|
@@ -717,15 +1216,15 @@ var UnavailableCredentialBackend = class {
|
|
|
717
1216
|
}
|
|
718
1217
|
};
|
|
719
1218
|
var PrivateFileCredentialBackend = class {
|
|
720
|
-
constructor(baseDirectory =
|
|
1219
|
+
constructor(baseDirectory = join3(homedir3(), ".config", "moodle-cli", "mcp", "credentials")) {
|
|
721
1220
|
this.baseDirectory = baseDirectory;
|
|
722
1221
|
}
|
|
723
1222
|
baseDirectory;
|
|
724
1223
|
name = "private credential file";
|
|
725
1224
|
async read(profile) {
|
|
726
|
-
const
|
|
1225
|
+
const path5 = this.path(profile);
|
|
727
1226
|
try {
|
|
728
|
-
return parseCredentials(await readFile(
|
|
1227
|
+
return parseCredentials(await readFile(path5, "utf8"));
|
|
729
1228
|
} catch (error) {
|
|
730
1229
|
if (isMissing(error)) {
|
|
731
1230
|
return null;
|
|
@@ -734,30 +1233,30 @@ var PrivateFileCredentialBackend = class {
|
|
|
734
1233
|
}
|
|
735
1234
|
}
|
|
736
1235
|
async write(profile, credentials) {
|
|
737
|
-
const
|
|
738
|
-
const temporary = `${
|
|
739
|
-
await
|
|
740
|
-
await chmod(dirname(
|
|
1236
|
+
const path5 = this.path(profile);
|
|
1237
|
+
const temporary = `${path5}.${process.pid}.tmp`;
|
|
1238
|
+
await mkdir2(dirname(path5), { recursive: true, mode: 448 });
|
|
1239
|
+
await chmod(dirname(path5), 448);
|
|
741
1240
|
await writeFile(temporary, `${JSON.stringify(credentials)}
|
|
742
1241
|
`, { encoding: "utf8", mode: 384 });
|
|
743
1242
|
await chmod(temporary, 384);
|
|
744
|
-
await rename(temporary,
|
|
745
|
-
await chmod(
|
|
1243
|
+
await rename(temporary, path5);
|
|
1244
|
+
await chmod(path5, 384);
|
|
746
1245
|
}
|
|
747
1246
|
async delete(profile) {
|
|
748
1247
|
await rm(this.path(profile), { force: true });
|
|
749
1248
|
}
|
|
750
1249
|
path(profile) {
|
|
751
1250
|
validateProfile(profile);
|
|
752
|
-
return
|
|
1251
|
+
return join3(this.baseDirectory, `${profile}.json`);
|
|
753
1252
|
}
|
|
754
1253
|
};
|
|
755
1254
|
function createDefaultCredentialStore(options = {}) {
|
|
756
1255
|
const platform = options.platform ?? process.platform;
|
|
757
1256
|
const runner = options.runner ?? new NodeCredentialCommandRunner();
|
|
758
1257
|
const preferred = platform === "darwin" ? new MacOSKeychainCredentialBackend(runner) : platform === "linux" ? new LinuxSecretServiceCredentialBackend(runner) : platform === "win32" ? new WindowsCredentialManagerBackend(runner) : new UnavailableCredentialBackend(`${platform} credential store`);
|
|
759
|
-
const home = options.homeDirectory ??
|
|
760
|
-
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");
|
|
761
1260
|
const fallback = platform === "win32" ? new WindowsDpapiFileCredentialBackend(fallbackDirectory, runner) : new PrivateFileCredentialBackend(fallbackDirectory);
|
|
762
1261
|
return new SafeCredentialStore(preferred, fallback, platform !== "win32");
|
|
763
1262
|
}
|
|
@@ -798,9 +1297,9 @@ function windowsCredentialInput(profile, credentials) {
|
|
|
798
1297
|
...credentials ? { credentials: JSON.stringify(credentials) } : {}
|
|
799
1298
|
});
|
|
800
1299
|
}
|
|
801
|
-
function windowsDpapiInput(
|
|
1300
|
+
function windowsDpapiInput(path5, credentials) {
|
|
802
1301
|
return JSON.stringify({
|
|
803
|
-
path:
|
|
1302
|
+
path: path5,
|
|
804
1303
|
...credentials ? { credentials: JSON.stringify(credentials) } : {}
|
|
805
1304
|
});
|
|
806
1305
|
}
|
|
@@ -894,26 +1393,26 @@ function isRecord(value) {
|
|
|
894
1393
|
}
|
|
895
1394
|
|
|
896
1395
|
// src/session-cache.ts
|
|
897
|
-
import { chmod as chmod2, mkdir as
|
|
898
|
-
import { homedir as
|
|
899
|
-
import { dirname as dirname2, join as
|
|
900
|
-
var nodeFs = { readFile: readFile2, writeFile: writeFile2, mkdir:
|
|
901
|
-
function sessionCachePath(homeDir =
|
|
902
|
-
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);
|
|
903
1402
|
}
|
|
904
1403
|
function isCachedSessionFresh(session, ttlMs = DEFAULT_SESSION_CACHE_TTL_MS, now = Date.now) {
|
|
905
1404
|
const age = now() - session.savedAt;
|
|
906
|
-
return age >= 0 && age <= ttlMs;
|
|
1405
|
+
return !session.cookieInvalidated && age >= 0 && age <= ttlMs;
|
|
907
1406
|
}
|
|
908
1407
|
async function readCachedSession(baseUrl, options = {}) {
|
|
909
1408
|
if (options.noCache) {
|
|
910
1409
|
return null;
|
|
911
1410
|
}
|
|
912
1411
|
const fs = options.fs ?? nodeFs;
|
|
913
|
-
const
|
|
1412
|
+
const path5 = sessionCachePath(options.homeDir);
|
|
914
1413
|
let raw;
|
|
915
1414
|
try {
|
|
916
|
-
raw = await fs.readFile(
|
|
1415
|
+
raw = await fs.readFile(path5, "utf8");
|
|
917
1416
|
} catch (error) {
|
|
918
1417
|
if (isMissingFileError(error)) {
|
|
919
1418
|
return null;
|
|
@@ -931,28 +1430,28 @@ async function readCachedSession(baseUrl, options = {}) {
|
|
|
931
1430
|
if (session) await writeCachedSession(session, { ...options, noCache: false });
|
|
932
1431
|
}
|
|
933
1432
|
} catch {
|
|
934
|
-
if (parseCachedSession(raw)) await fs.rm(
|
|
1433
|
+
if (parseCachedSession(raw)) await fs.rm(path5, { force: true });
|
|
935
1434
|
return null;
|
|
936
1435
|
}
|
|
937
1436
|
if (!session || !sameBaseUrl(session.baseUrl, baseUrl)) {
|
|
938
1437
|
return null;
|
|
939
1438
|
}
|
|
940
1439
|
const ttlMs = options.ttlMs ?? DEFAULT_SESSION_CACHE_TTL_MS;
|
|
941
|
-
return isCachedSessionFresh(session, ttlMs, options.now ?? Date.now) ? session : null;
|
|
1440
|
+
return options.allowExpired || isCachedSessionFresh(session, ttlMs, options.now ?? Date.now) ? session : null;
|
|
942
1441
|
}
|
|
943
1442
|
async function writeCachedSession(session, options = {}) {
|
|
944
1443
|
if (options.noCache) return;
|
|
945
1444
|
const fs = options.fs ?? nodeFs;
|
|
946
|
-
const
|
|
1445
|
+
const path5 = sessionCachePath(options.homeDir);
|
|
947
1446
|
const keyring = await createEncryptionKeyring(await cacheEncryptionKey(options));
|
|
948
1447
|
const encrypted = { version: 2, encrypted_session: await encryptValue(JSON.stringify(session), keyring) };
|
|
949
|
-
await fs.mkdir(dirname2(
|
|
950
|
-
await fs.writeFile(
|
|
1448
|
+
await fs.mkdir(dirname2(path5), { recursive: true, mode: 448 });
|
|
1449
|
+
await fs.writeFile(path5, `${JSON.stringify(encrypted)}
|
|
951
1450
|
`, { encoding: "utf8", mode: 384 });
|
|
952
|
-
await fs.chmod(
|
|
1451
|
+
await fs.chmod(path5, 384);
|
|
953
1452
|
}
|
|
954
1453
|
async function deleteCachedSession(baseUrl, options = {}) {
|
|
955
|
-
const current2 = await readCachedSession(baseUrl, { ...options, noCache: false,
|
|
1454
|
+
const current2 = await readCachedSession(baseUrl, { ...options, noCache: false, allowExpired: true });
|
|
956
1455
|
if (!current2) {
|
|
957
1456
|
return;
|
|
958
1457
|
}
|
|
@@ -986,9 +1485,12 @@ function parseCachedSession(raw) {
|
|
|
986
1485
|
sesskey: session.sesskey,
|
|
987
1486
|
userid: session.userid,
|
|
988
1487
|
savedAt: session.savedAt,
|
|
1488
|
+
...session.cookieInvalidated === true ? { cookieInvalidated: true } : {},
|
|
989
1489
|
...typeof session.cookieSource === "string" ? { cookieSource: session.cookieSource } : {},
|
|
990
1490
|
...Array.isArray(session.unavailable) && session.unavailable.every((name) => typeof name === "string") ? { unavailable: session.unavailable } : {},
|
|
991
|
-
...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 } : {}
|
|
992
1494
|
};
|
|
993
1495
|
}
|
|
994
1496
|
function sameBaseUrl(left, right) {
|
|
@@ -1007,7 +1509,7 @@ function isMissingFileError(error) {
|
|
|
1007
1509
|
var pendingCacheKeys = /* @__PURE__ */ new Map();
|
|
1008
1510
|
async function cacheEncryptionKey(options) {
|
|
1009
1511
|
if (options.encryptionKey) return options.encryptionKey();
|
|
1010
|
-
const homeDirectory = options.homeDir ??
|
|
1512
|
+
const homeDirectory = options.homeDir ?? homedir4();
|
|
1011
1513
|
let pending = pendingCacheKeys.get(homeDirectory);
|
|
1012
1514
|
if (!pending) {
|
|
1013
1515
|
pending = (async () => {
|
|
@@ -1026,6 +1528,23 @@ async function cacheEncryptionKey(options) {
|
|
|
1026
1528
|
}
|
|
1027
1529
|
|
|
1028
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
|
+
}
|
|
1029
1548
|
async function getAuthenticatedSession(baseUrl, options = {}) {
|
|
1030
1549
|
const envSession = loadSessionFromEnv(options.env);
|
|
1031
1550
|
const validate = options.validateSession ?? validateSessionWithFetch(options);
|
|
@@ -1044,6 +1563,10 @@ async function getAuthenticatedSession(baseUrl, options = {}) {
|
|
|
1044
1563
|
if (cached) {
|
|
1045
1564
|
return cached;
|
|
1046
1565
|
}
|
|
1566
|
+
const minted = await mintFromStoredToken(baseUrl, options, validate);
|
|
1567
|
+
if (minted) {
|
|
1568
|
+
return minted;
|
|
1569
|
+
}
|
|
1047
1570
|
const cookieWarnings = [];
|
|
1048
1571
|
const providerOptions = {
|
|
1049
1572
|
...options,
|
|
@@ -1059,17 +1582,25 @@ async function getAuthenticatedSession(baseUrl, options = {}) {
|
|
|
1059
1582
|
await refreshSessionCache(baseUrl, browserSession.cookie, browserSession.context, options);
|
|
1060
1583
|
return { baseUrl, cookie: browserSession.cookie, ...browserSession.context, fromCache: false };
|
|
1061
1584
|
}
|
|
1585
|
+
const stores = await browserCookieStores({ homeDir: options.homeDir, platform: options.platform });
|
|
1062
1586
|
throw new AuthError(
|
|
1063
1587
|
`No usable MoodleSession found for ${baseUrl}.`,
|
|
1064
|
-
authFailureHint(baseUrl, cookieWarnings, options.platform)
|
|
1588
|
+
authFailureHint(baseUrl, cookieWarnings, options.platform, unreadableCookieStores(stores), options.env)
|
|
1065
1589
|
);
|
|
1066
1590
|
}
|
|
1067
1591
|
async function getAuthenticatedSessionWithBrowserFallback(baseUrl, options = {}) {
|
|
1068
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
|
+
});
|
|
1069
1599
|
const authOptions = {
|
|
1070
1600
|
...options,
|
|
1071
1601
|
noCache: true,
|
|
1072
1602
|
nonInteractive: true,
|
|
1603
|
+
browserCookieProvider: boundedProvider,
|
|
1073
1604
|
onCookieWarnings: (warnings) => {
|
|
1074
1605
|
cookieWarnings.push(...warnings);
|
|
1075
1606
|
options.onCookieWarnings?.(warnings);
|
|
@@ -1095,34 +1626,92 @@ async function getAuthenticatedSessionWithBrowserFallback(baseUrl, options = {})
|
|
|
1095
1626
|
}
|
|
1096
1627
|
}
|
|
1097
1628
|
}
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
)
|
|
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
|
+
}
|
|
1103
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;
|
|
1104
1648
|
const url = loginUrl(baseUrl);
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
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;
|
|
1119
1670
|
}
|
|
1671
|
+
});
|
|
1672
|
+
} catch (error) {
|
|
1673
|
+
if (error instanceof CdpError) {
|
|
1674
|
+
throw new AuthError(error.message, error.hint ?? authFailureHint(baseUrl, [], options.platform, [], options.env));
|
|
1120
1675
|
}
|
|
1676
|
+
throw error;
|
|
1121
1677
|
}
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
`
|
|
1125
|
-
|
|
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");
|
|
1126
1715
|
}
|
|
1127
1716
|
function loadSessionFromEnv(env = process.env) {
|
|
1128
1717
|
const source = env[ENV_MOODLE_TOKEN] ? ENV_MOODLE_TOKEN : ENV_MOODLE_SESSION;
|
|
@@ -1174,17 +1763,17 @@ async function defaultBrowserCookieProvider(baseUrl, options = {}) {
|
|
|
1174
1763
|
}));
|
|
1175
1764
|
}
|
|
1176
1765
|
async function braveProfilePaths(options = {}) {
|
|
1177
|
-
const home = options.homeDir ??
|
|
1766
|
+
const home = options.homeDir ?? homedir5();
|
|
1178
1767
|
const platform = options.platform ?? process.platform;
|
|
1179
1768
|
const roots = platform === "linux" ? [
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
] : 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")] : [];
|
|
1183
1772
|
const profiles = [];
|
|
1184
1773
|
for (const root of roots) {
|
|
1185
1774
|
try {
|
|
1186
1775
|
profiles.push(
|
|
1187
|
-
...(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))
|
|
1188
1777
|
);
|
|
1189
1778
|
} catch {
|
|
1190
1779
|
continue;
|
|
@@ -1192,46 +1781,70 @@ async function braveProfilePaths(options = {}) {
|
|
|
1192
1781
|
}
|
|
1193
1782
|
return profiles;
|
|
1194
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
|
+
}
|
|
1195
1799
|
var COOKIE_ACCESS_DENIED = /EPERM|EACCES|operation not permitted|permission denied/i;
|
|
1196
1800
|
var COOKIE_SQLITE_UNAVAILABLE = /No such built-in module: node:sqlite/i;
|
|
1197
1801
|
var MINIMUM_NODE_FOR_BROWSER_COOKIES = "22.13.0";
|
|
1198
1802
|
function cookieAccessBlocked(warnings) {
|
|
1199
1803
|
return warnings.some((warning) => COOKIE_ACCESS_DENIED.test(warning) || COOKIE_SQLITE_UNAVAILABLE.test(warning));
|
|
1200
1804
|
}
|
|
1201
|
-
function cookieAccessHint(warnings, platform = process.platform) {
|
|
1202
|
-
const grant = platform === "darwin" ?
|
|
1203
|
-
|
|
1204
|
-
`
|
|
1205
|
-
] :
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
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
|
+
}
|
|
1209
1822
|
return [
|
|
1210
1823
|
"The browser cookie store could not be read, so the session could not be detected.",
|
|
1211
1824
|
...remedy,
|
|
1825
|
+
"Or skip the store entirely: `moodle auth login --paste` takes the cookie by hand and caches it.",
|
|
1212
1826
|
"Run moodle doctor for runtime and browser diagnostics.",
|
|
1213
|
-
|
|
1214
|
-
"",
|
|
1215
|
-
"Cookie store diagnostics:",
|
|
1216
|
-
...warnings.map((warning) => ` - ${warning}`)
|
|
1827
|
+
...cookieDiagnostics(warnings, unreadable)
|
|
1217
1828
|
].join("\n");
|
|
1218
1829
|
}
|
|
1219
|
-
function
|
|
1220
|
-
|
|
1221
|
-
return cookieAccessHint(cookieWarnings, platform);
|
|
1222
|
-
}
|
|
1830
|
+
function cookieDiagnostics(warnings, unreadable) {
|
|
1831
|
+
const probed = unreadable.map((store) => store.path);
|
|
1223
1832
|
const lines = [
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
`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}`)
|
|
1227
1835
|
];
|
|
1228
|
-
|
|
1229
|
-
lines.push("", "Cookie store diagnostics:", ...cookieWarnings.map((warning) => ` - ${warning}`));
|
|
1230
|
-
}
|
|
1231
|
-
return lines.join("\n");
|
|
1836
|
+
return lines.length ? ["", "Cookie store diagnostics:", ...lines] : [];
|
|
1232
1837
|
}
|
|
1233
|
-
|
|
1234
|
-
|
|
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");
|
|
1235
1848
|
}
|
|
1236
1849
|
function parseSessionContext(html) {
|
|
1237
1850
|
const sesskey = firstMatch(html, [
|
|
@@ -1259,7 +1872,11 @@ function validateSessionWithFetch(options) {
|
|
|
1259
1872
|
let response;
|
|
1260
1873
|
try {
|
|
1261
1874
|
response = await fetchWithSession(`${baseUrl}${DASHBOARD_PATH}`, {}, baseUrl, cookie, fetcher);
|
|
1262
|
-
} catch {
|
|
1875
|
+
} catch (error) {
|
|
1876
|
+
const network = asNetworkError(error);
|
|
1877
|
+
if (network) {
|
|
1878
|
+
throw network;
|
|
1879
|
+
}
|
|
1263
1880
|
return null;
|
|
1264
1881
|
}
|
|
1265
1882
|
if (response.status >= 400 || isLoginRedirect(response.url, baseUrl)) {
|
|
@@ -1300,16 +1917,59 @@ async function refreshSessionCache(baseUrl, cookie, context, options) {
|
|
|
1300
1917
|
savedAt: (options.now ?? Date.now)()
|
|
1301
1918
|
};
|
|
1302
1919
|
try {
|
|
1303
|
-
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;
|
|
1304
1922
|
if (previous?.userid === context.userid) {
|
|
1305
1923
|
if (previous.unavailable?.length) session.unavailable = previous.unavailable;
|
|
1306
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;
|
|
1307
1932
|
}
|
|
1308
1933
|
await writeCachedSession(session, cacheOptions(options));
|
|
1309
1934
|
} catch {
|
|
1310
1935
|
return;
|
|
1311
1936
|
}
|
|
1312
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
|
+
}
|
|
1313
1973
|
function cachedSessionToAuth(baseUrl, cached) {
|
|
1314
1974
|
return {
|
|
1315
1975
|
baseUrl,
|
|
@@ -1347,8 +2007,8 @@ function isLoginRedirect(responseUrl, baseUrl) {
|
|
|
1347
2007
|
if (!responseUrl) {
|
|
1348
2008
|
return false;
|
|
1349
2009
|
}
|
|
1350
|
-
const
|
|
1351
|
-
return
|
|
2010
|
+
const path5 = new URL(responseUrl, baseUrl).pathname;
|
|
2011
|
+
return path5 === LOGIN_PATH || path5.startsWith("/login/");
|
|
1352
2012
|
}
|
|
1353
2013
|
function looksLikeLoginPage(html) {
|
|
1354
2014
|
return /name=["']username["']/i.test(html) && /name=["']password["']/i.test(html);
|
|
@@ -1365,40 +2025,36 @@ function firstMatch(value, patterns) {
|
|
|
1365
2025
|
function decodeHtml(value) {
|
|
1366
2026
|
return value.replace(/&/g, "&").replace(/"/g, '"').replace(/'/g, "'").replace(/</g, "<").replace(/>/g, ">");
|
|
1367
2027
|
}
|
|
1368
|
-
async function openSystemBrowser(url, options) {
|
|
1369
|
-
const platform = options.platform ?? process.platform;
|
|
1370
|
-
const command = platform === "darwin" ? { file: "open", args: [url] } : platform === "win32" ? { file: "cmd", args: ["/c", "start", "", url] } : { file: "xdg-open", args: [url] };
|
|
1371
|
-
const result = await (options.execFile ?? defaultExecFile)(command.file, command.args);
|
|
1372
|
-
if (result.exitCode !== 0) {
|
|
1373
|
-
throw new AuthError(
|
|
1374
|
-
`Could not open the browser for Moodle login.`,
|
|
1375
|
-
`Open ${url} manually, then rerun: moodle auth login`
|
|
1376
|
-
);
|
|
1377
|
-
}
|
|
1378
|
-
}
|
|
1379
|
-
var defaultExecFile = (file2, args) => new Promise((resolve) => {
|
|
1380
|
-
execFileCallback(file2, args, { encoding: "utf8" }, (error, stdout, stderr) => {
|
|
1381
|
-
const errorWithCode = error;
|
|
1382
|
-
resolve({
|
|
1383
|
-
stdout: String(stdout ?? ""),
|
|
1384
|
-
stderr: String(stderr ?? ""),
|
|
1385
|
-
exitCode: errorWithCode ? Number(errorWithCode.code) || 1 : 0
|
|
1386
|
-
});
|
|
1387
|
-
});
|
|
1388
|
-
});
|
|
1389
2028
|
|
|
1390
2029
|
// src/config.ts
|
|
1391
|
-
import { mkdir as
|
|
1392
|
-
import { homedir as
|
|
1393
|
-
import { dirname as dirname3, join as
|
|
1394
|
-
import {
|
|
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";
|
|
2033
|
+
import { createUi, isAgentEnvironment } from "@bunizao/cli-kit";
|
|
2034
|
+
|
|
2035
|
+
// src/wordmark.ts
|
|
2036
|
+
var MOODLE_TAGLINE = "Read Moodle and submit work from the command line.";
|
|
2037
|
+
var MOODLE_WORDMARK = [
|
|
2038
|
+
" _ _",
|
|
2039
|
+
" _ __ ___ ___ __| | |___",
|
|
2040
|
+
" | ' \\/ _ \\/ _ \\/ _` | / -_)",
|
|
2041
|
+
" |_|_|_\\___/\\___/\\__,_|_\\___|"
|
|
2042
|
+
].join("\n");
|
|
2043
|
+
var shown = false;
|
|
2044
|
+
function showWordmark(ui) {
|
|
2045
|
+
if (shown) return;
|
|
2046
|
+
shown = true;
|
|
2047
|
+
ui.banner(MOODLE_WORDMARK, MOODLE_TAGLINE);
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
// src/config.ts
|
|
1395
2051
|
import YAML from "yaml";
|
|
1396
|
-
var nodeFs2 = { readFile: readFile3, writeFile: writeFile3, mkdir:
|
|
2052
|
+
var nodeFs2 = { readFile: readFile3, writeFile: writeFile3, mkdir: mkdir4 };
|
|
1397
2053
|
function cwdConfigPath(cwd = process.cwd()) {
|
|
1398
|
-
return
|
|
2054
|
+
return join6(cwd, CONFIG_FILENAME);
|
|
1399
2055
|
}
|
|
1400
|
-
function userConfigPath(homeDir =
|
|
1401
|
-
return
|
|
2056
|
+
function userConfigPath(homeDir = homedir6()) {
|
|
2057
|
+
return join6(homeDir, CONFIG_DIR_NAME, CONFIG_FILENAME);
|
|
1402
2058
|
}
|
|
1403
2059
|
function normalizeBaseUrl(value) {
|
|
1404
2060
|
const raw = value.trim();
|
|
@@ -1453,29 +2109,32 @@ async function loadConfig(options = {}) {
|
|
|
1453
2109
|
return toMoodleConfig(loaded.config, baseUrl);
|
|
1454
2110
|
}
|
|
1455
2111
|
async function promptForBaseUrl(options = {}) {
|
|
1456
|
-
const
|
|
1457
|
-
const
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
2112
|
+
const ui = createUi({ input: process.stdin, output: options.stderr ?? process.stderr, ...options.prompt ? { interactive: false } : {} });
|
|
2113
|
+
const prompt = options.prompt ?? defaultPrompt(ui);
|
|
2114
|
+
showWordmark(ui);
|
|
2115
|
+
ui.intro("Moodle setup");
|
|
2116
|
+
ui.note([
|
|
2117
|
+
"Moodle base URL is not configured yet.",
|
|
2118
|
+
`Runtime: ${process.versions.bun ? `bun ${process.versions.bun}` : `node ${process.versions.node}`}. Browser SQLite needs Bun or Node 22.13+. Run moodle doctor for diagnostics.`,
|
|
2119
|
+
"Use the site root only, for example https://school.example.edu.",
|
|
2120
|
+
"Do not include paths like /login/index.php or /my/."
|
|
2121
|
+
].join("\n"), "Configuration required");
|
|
1464
2122
|
while (true) {
|
|
1465
2123
|
let baseUrl;
|
|
1466
2124
|
try {
|
|
1467
2125
|
baseUrl = normalizeBaseUrl(await prompt("Moodle base URL"));
|
|
1468
2126
|
} catch (error) {
|
|
1469
|
-
|
|
1470
|
-
`);
|
|
2127
|
+
ui.warn(`Invalid URL: ${error instanceof Error ? error.message : String(error)}`);
|
|
1471
2128
|
continue;
|
|
1472
2129
|
}
|
|
1473
|
-
const
|
|
1474
|
-
|
|
2130
|
+
const spin = ui.spinner();
|
|
2131
|
+
spin.start(`Checking ${baseUrl}`);
|
|
2132
|
+
const probe2 = await probeBaseUrl(baseUrl, options);
|
|
2133
|
+
if (probe2.ok) {
|
|
2134
|
+
spin.stop(`${baseUrl} looks like Moodle`);
|
|
1475
2135
|
return baseUrl;
|
|
1476
2136
|
}
|
|
1477
|
-
|
|
1478
|
-
`);
|
|
2137
|
+
spin.error(`Validation failed: ${probe2.message ?? "site did not look like Moodle"}`);
|
|
1479
2138
|
}
|
|
1480
2139
|
}
|
|
1481
2140
|
async function probeBaseUrl(baseUrl, options = {}) {
|
|
@@ -1523,19 +2182,19 @@ async function loadExistingConfig(options) {
|
|
|
1523
2182
|
const paths = [explicitPath, cwdConfigPath(options.cwd), userConfigPath(options.homeDir)].filter(
|
|
1524
2183
|
(value) => Boolean(value)
|
|
1525
2184
|
);
|
|
1526
|
-
for (const
|
|
1527
|
-
const config = await readConfigFile(
|
|
2185
|
+
for (const path5 of paths) {
|
|
2186
|
+
const config = await readConfigFile(path5, options);
|
|
1528
2187
|
if (config) {
|
|
1529
|
-
return { config, path:
|
|
2188
|
+
return { config, path: path5 };
|
|
1530
2189
|
}
|
|
1531
2190
|
}
|
|
1532
2191
|
return { config: {}, path: explicitPath ?? null };
|
|
1533
2192
|
}
|
|
1534
|
-
async function readConfigFile(
|
|
2193
|
+
async function readConfigFile(path5, options) {
|
|
1535
2194
|
const fs = options.fs ?? nodeFs2;
|
|
1536
2195
|
let raw;
|
|
1537
2196
|
try {
|
|
1538
|
-
raw = await fs.readFile(
|
|
2197
|
+
raw = await fs.readFile(path5, "utf8");
|
|
1539
2198
|
} catch (error) {
|
|
1540
2199
|
if (isMissingFileError2(error)) {
|
|
1541
2200
|
return null;
|
|
@@ -1544,34 +2203,24 @@ async function readConfigFile(path4, options) {
|
|
|
1544
2203
|
}
|
|
1545
2204
|
const parsed = YAML.parse(raw) ?? {};
|
|
1546
2205
|
if (!isRecord3(parsed)) {
|
|
1547
|
-
throw new ConfigError(`${
|
|
2206
|
+
throw new ConfigError(`${path5} must contain a YAML object.`);
|
|
1548
2207
|
}
|
|
1549
2208
|
return parsed;
|
|
1550
2209
|
}
|
|
1551
|
-
async function saveConfigFile(
|
|
2210
|
+
async function saveConfigFile(path5, config, options) {
|
|
1552
2211
|
const fs = options.fs ?? nodeFs2;
|
|
1553
|
-
await fs.mkdir(dirname3(
|
|
1554
|
-
await fs.writeFile(
|
|
2212
|
+
await fs.mkdir(dirname3(path5), { recursive: true });
|
|
2213
|
+
await fs.writeFile(path5, YAML.stringify(config, { sortMapEntries: true }), "utf8");
|
|
1555
2214
|
}
|
|
1556
2215
|
function toMoodleConfig(config, baseUrl) {
|
|
1557
2216
|
const { base_url: _baseUrl, ...rest } = config;
|
|
1558
2217
|
return { ...rest, baseUrl };
|
|
1559
2218
|
}
|
|
1560
|
-
function defaultPrompt(
|
|
1561
|
-
return
|
|
1562
|
-
const rl = createInterface({
|
|
1563
|
-
input: process.stdin,
|
|
1564
|
-
output: options.stdout ?? process.stdout
|
|
1565
|
-
});
|
|
1566
|
-
try {
|
|
1567
|
-
return await rl.question(`${label} > `);
|
|
1568
|
-
} finally {
|
|
1569
|
-
rl.close();
|
|
1570
|
-
}
|
|
1571
|
-
};
|
|
2219
|
+
function defaultPrompt(ui) {
|
|
2220
|
+
return (label) => ui.text(label, { placeholder: "https://school.example.edu" });
|
|
1572
2221
|
}
|
|
1573
2222
|
function isInteractive(options) {
|
|
1574
|
-
return Boolean((options.stdin ?? process.stdin).isTTY);
|
|
2223
|
+
return Boolean((options.stdin ?? process.stdin).isTTY) && !isAgentEnvironment(options.env ?? process.env);
|
|
1575
2224
|
}
|
|
1576
2225
|
function isRecord3(value) {
|
|
1577
2226
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
@@ -1581,15 +2230,15 @@ function isMissingFileError2(error) {
|
|
|
1581
2230
|
}
|
|
1582
2231
|
|
|
1583
2232
|
// src/mcp/self-command.ts
|
|
1584
|
-
import { accessSync, constants, realpathSync } from "fs";
|
|
1585
|
-
import { delimiter, join as
|
|
2233
|
+
import { accessSync as accessSync2, constants as constants2, realpathSync } from "fs";
|
|
2234
|
+
import { delimiter, join as join7 } from "path";
|
|
1586
2235
|
import { spawnSync } from "child_process";
|
|
1587
2236
|
function findExecutable(name, env = process.env) {
|
|
1588
2237
|
for (const dir of (env.PATH ?? "").split(delimiter).filter(Boolean)) {
|
|
1589
2238
|
for (const suffix of process.platform === "win32" ? [".exe", ".cmd", ""] : [""]) {
|
|
1590
|
-
const file2 =
|
|
2239
|
+
const file2 = join7(dir, name + suffix);
|
|
1591
2240
|
try {
|
|
1592
|
-
|
|
2241
|
+
accessSync2(file2, constants2.X_OK);
|
|
1593
2242
|
return realpathSync(file2);
|
|
1594
2243
|
} catch {
|
|
1595
2244
|
}
|
|
@@ -1619,9 +2268,9 @@ function runtimeCommand(command, args) {
|
|
|
1619
2268
|
// src/keepalive.ts
|
|
1620
2269
|
import { spawnSync as spawnSync2 } from "child_process";
|
|
1621
2270
|
import { realpathSync as realpathSync2 } from "fs";
|
|
1622
|
-
import { mkdir as
|
|
1623
|
-
import { homedir as
|
|
1624
|
-
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";
|
|
1625
2274
|
async function touchMoodleSession(baseUrl, cookie, sesskey, fetchImpl = fetch, extend = true) {
|
|
1626
2275
|
const methods = extend ? [FUNC_SESSION_TOUCH, FUNC_SESSION_TIME_REMAINING] : [FUNC_SESSION_TIME_REMAINING];
|
|
1627
2276
|
const url = `${baseUrl.replace(/\/$/, "")}${AJAX_SERVICE_PATH}?sesskey=${encodeURIComponent(sesskey)}&info=${methods.join(",")}`;
|
|
@@ -1667,13 +2316,13 @@ async function touchMoodleSession(baseUrl, cookie, sesskey, fetchImpl = fetch, e
|
|
|
1667
2316
|
async function keepAliveOnce(baseUrl, options = {}) {
|
|
1668
2317
|
const session = await readCachedSession(baseUrl, {
|
|
1669
2318
|
homeDir: options.homeDir,
|
|
1670
|
-
|
|
2319
|
+
allowExpired: true,
|
|
1671
2320
|
now: options.now
|
|
1672
2321
|
});
|
|
1673
2322
|
if (!session) {
|
|
1674
2323
|
return { status: "no_session", time_remaining_seconds: null };
|
|
1675
2324
|
}
|
|
1676
|
-
const touch = await touchMoodleSession(
|
|
2325
|
+
const touch = session.cookieInvalidated ? { alive: false, timeRemainingSeconds: null } : await touchMoodleSession(
|
|
1677
2326
|
baseUrl,
|
|
1678
2327
|
{ name: session.cookieName, value: session.cookieValue },
|
|
1679
2328
|
session.sesskey,
|
|
@@ -1689,6 +2338,10 @@ async function keepAliveOnce(baseUrl, options = {}) {
|
|
|
1689
2338
|
if (options.renewOnExpiry === false) {
|
|
1690
2339
|
return { status: "expired", time_remaining_seconds: null };
|
|
1691
2340
|
}
|
|
2341
|
+
if (session.mobileToken) {
|
|
2342
|
+
const renewed = await renewViaMobileToken(baseUrl, session, options);
|
|
2343
|
+
if (renewed) return renewed;
|
|
2344
|
+
}
|
|
1692
2345
|
const authenticate = options.authenticate ?? ((url) => getAuthenticatedSession(url, {
|
|
1693
2346
|
homeDir: options.homeDir,
|
|
1694
2347
|
fetch: options.fetchImpl,
|
|
@@ -1704,12 +2357,29 @@ async function keepAliveOnce(baseUrl, options = {}) {
|
|
|
1704
2357
|
return { status: "expired", time_remaining_seconds: null };
|
|
1705
2358
|
}
|
|
1706
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
|
+
}
|
|
1707
2377
|
async function getAuthStatus(baseUrl, options = {}) {
|
|
1708
2378
|
const now = options.now ?? Date.now;
|
|
1709
2379
|
const keepalive = await keepaliveStatus(options.homeDir);
|
|
1710
2380
|
const session = await readCachedSession(baseUrl, {
|
|
1711
2381
|
homeDir: options.homeDir,
|
|
1712
|
-
|
|
2382
|
+
allowExpired: true,
|
|
1713
2383
|
now: options.now
|
|
1714
2384
|
});
|
|
1715
2385
|
if (!session) {
|
|
@@ -1723,7 +2393,7 @@ async function getAuthStatus(baseUrl, options = {}) {
|
|
|
1723
2393
|
keepalive_plist_path: keepalive.plist_path
|
|
1724
2394
|
};
|
|
1725
2395
|
}
|
|
1726
|
-
const touch = await touchMoodleSession(
|
|
2396
|
+
const touch = session.cookieInvalidated ? { alive: false, timeRemainingSeconds: null } : await touchMoodleSession(
|
|
1727
2397
|
baseUrl,
|
|
1728
2398
|
{ name: session.cookieName, value: session.cookieValue },
|
|
1729
2399
|
session.sesskey,
|
|
@@ -1741,11 +2411,11 @@ async function getAuthStatus(baseUrl, options = {}) {
|
|
|
1741
2411
|
keepalive_plist_path: keepalive.plist_path
|
|
1742
2412
|
};
|
|
1743
2413
|
}
|
|
1744
|
-
function keepalivePlistPath(homeDir =
|
|
1745
|
-
return
|
|
2414
|
+
function keepalivePlistPath(homeDir = homedir7()) {
|
|
2415
|
+
return join8(homeDir, "Library/LaunchAgents", `${KEEPALIVE_LAUNCH_AGENT_LABEL}.plist`);
|
|
1746
2416
|
}
|
|
1747
|
-
function keepaliveLogPath(homeDir =
|
|
1748
|
-
return
|
|
2417
|
+
function keepaliveLogPath(homeDir = homedir7()) {
|
|
2418
|
+
return join8(homeDir, CACHE_DIR_NAME, KEEPALIVE_LOG_FILENAME);
|
|
1749
2419
|
}
|
|
1750
2420
|
function buildKeepalivePlist(programArguments2, intervalMinutes, logPath) {
|
|
1751
2421
|
const args = programArguments2.map((arg) => ` <string>${escapeXml(arg)}</string>`).join("\n");
|
|
@@ -1787,13 +2457,13 @@ async function installKeepalive(options = {}) {
|
|
|
1787
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.`
|
|
1788
2458
|
);
|
|
1789
2459
|
}
|
|
1790
|
-
const homeDir = options.homeDir ??
|
|
2460
|
+
const homeDir = options.homeDir ?? homedir7();
|
|
1791
2461
|
const intervalMinutes = options.intervalMinutes ?? KEEPALIVE_DEFAULT_INTERVAL_MINUTES;
|
|
1792
2462
|
const plistPath = keepalivePlistPath(homeDir);
|
|
1793
2463
|
const logPath = keepaliveLogPath(homeDir);
|
|
1794
2464
|
const command = [selectedRuntime.command, ...selectedRuntime.args, "auth", "keepalive", "--json"];
|
|
1795
|
-
await
|
|
1796
|
-
await
|
|
2465
|
+
await mkdir5(dirname4(plistPath), { recursive: true });
|
|
2466
|
+
await mkdir5(dirname4(logPath), { recursive: true, mode: 448 });
|
|
1797
2467
|
await writeFile4(plistPath, buildKeepalivePlist(command, intervalMinutes, logPath), "utf8");
|
|
1798
2468
|
const runCommand = options.runCommand ?? spawnSync2;
|
|
1799
2469
|
const uid = options.uid ?? (typeof process.getuid === "function" ? process.getuid() : 0);
|
|
@@ -1808,7 +2478,7 @@ async function installKeepalive(options = {}) {
|
|
|
1808
2478
|
return { plist_path: plistPath, interval_minutes: intervalMinutes, log_path: logPath, command };
|
|
1809
2479
|
}
|
|
1810
2480
|
async function uninstallKeepalive(options = {}) {
|
|
1811
|
-
const homeDir = options.homeDir ??
|
|
2481
|
+
const homeDir = options.homeDir ?? homedir7();
|
|
1812
2482
|
const plistPath = keepalivePlistPath(homeDir);
|
|
1813
2483
|
const runCommand = options.runCommand ?? spawnSync2;
|
|
1814
2484
|
const uid = options.uid ?? (typeof process.getuid === "function" ? process.getuid() : 0);
|
|
@@ -1816,7 +2486,7 @@ async function uninstallKeepalive(options = {}) {
|
|
|
1816
2486
|
await rm3(plistPath, { force: true });
|
|
1817
2487
|
return { installed: false, plist_path: plistPath };
|
|
1818
2488
|
}
|
|
1819
|
-
async function keepaliveStatus(homeDir =
|
|
2489
|
+
async function keepaliveStatus(homeDir = homedir7()) {
|
|
1820
2490
|
const plistPath = keepalivePlistPath(homeDir);
|
|
1821
2491
|
try {
|
|
1822
2492
|
return { installed: (await stat(plistPath)).isFile(), plist_path: plistPath };
|
|
@@ -1829,20 +2499,21 @@ function escapeXml(value) {
|
|
|
1829
2499
|
}
|
|
1830
2500
|
|
|
1831
2501
|
// src/doctor.ts
|
|
1832
|
-
async function ownedJobs(homeDir =
|
|
1833
|
-
const root =
|
|
1834
|
-
const files = await
|
|
2502
|
+
async function ownedJobs(homeDir = homedir8()) {
|
|
2503
|
+
const root = join9(homeDir, "Library", "LaunchAgents");
|
|
2504
|
+
const files = await readdir3(root).catch(() => []);
|
|
1835
2505
|
const jobs = [];
|
|
1836
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))) {
|
|
1837
|
-
const
|
|
1838
|
-
const content = await readFile4(
|
|
1839
|
-
jobs.push({ path:
|
|
2507
|
+
const path5 = join9(root, name);
|
|
2508
|
+
const content = await readFile4(path5, "utf8");
|
|
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] });
|
|
1840
2510
|
}
|
|
1841
2511
|
return jobs;
|
|
1842
2512
|
}
|
|
1843
2513
|
async function doctor(options = {}) {
|
|
1844
|
-
const home = options.homeDir ??
|
|
2514
|
+
const home = options.homeDir ?? homedir8();
|
|
1845
2515
|
const checks = [];
|
|
2516
|
+
const stores = await browserCookieStores({ homeDir: home });
|
|
1846
2517
|
const cookies = runtimeSupportsCookies();
|
|
1847
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." } });
|
|
1848
2519
|
let baseUrl;
|
|
@@ -1862,43 +2533,19 @@ async function doctor(options = {}) {
|
|
|
1862
2533
|
const warnings = [];
|
|
1863
2534
|
try {
|
|
1864
2535
|
const found = await defaultBrowserCookieProvider(baseUrl, { homeDir: home, onCookieWarnings: (items) => warnings.push(...items) });
|
|
1865
|
-
const blocked = warnings.some((w) => /EPERM|EACCES|permission denied|operation not permitted/iu.test(w));
|
|
1866
|
-
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." } : {} });
|
|
1867
2538
|
} catch {
|
|
1868
2539
|
checks.push({ name: "browser", status: "warn", detail: "Could not inspect browser stores.", hint: "Run moodle auth login from a regular terminal." });
|
|
1869
2540
|
}
|
|
1870
2541
|
}
|
|
1871
|
-
const stores = [];
|
|
1872
|
-
if (process.platform === "darwin") {
|
|
1873
|
-
for (const [browser, directory] of [["Chrome", "Google/Chrome"], ["Edge", "Microsoft Edge"], ["Brave", "BraveSoftware/Brave-Browser"], ["Firefox", "Firefox/Profiles"]]) {
|
|
1874
|
-
const root = join7(home, "Library/Application Support", directory);
|
|
1875
|
-
for (const profile of await readdir2(root).catch(() => [])) {
|
|
1876
|
-
if (browser !== "Firefox" && profile !== "Default" && !profile.startsWith("Profile ")) continue;
|
|
1877
|
-
for (const name of browser === "Firefox" ? ["cookies.sqlite"] : ["Cookies", "Network/Cookies"]) {
|
|
1878
|
-
const file2 = join7(root, profile, name);
|
|
1879
|
-
try {
|
|
1880
|
-
await access(file2);
|
|
1881
|
-
stores.push({ browser, path: file2, readable: await access(file2, constants2.R_OK).then(() => true, () => false) });
|
|
1882
|
-
} catch {
|
|
1883
|
-
}
|
|
1884
|
-
}
|
|
1885
|
-
}
|
|
1886
|
-
}
|
|
1887
|
-
for (const file2 of [join7(home, "Library/Cookies/Cookies.binarycookies"), join7(home, "Library/Containers/com.apple.Safari/Data/Library/Cookies/Cookies.binarycookies")]) {
|
|
1888
|
-
try {
|
|
1889
|
-
await access(file2);
|
|
1890
|
-
stores.push({ browser: "Safari", path: file2, readable: await access(file2, constants2.R_OK).then(() => true, () => false) });
|
|
1891
|
-
} catch {
|
|
1892
|
-
}
|
|
1893
|
-
}
|
|
1894
|
-
}
|
|
1895
2542
|
const jobs = await ownedJobs(home);
|
|
1896
2543
|
for (const job of jobs) {
|
|
1897
|
-
const present = job.interpreter ? await
|
|
2544
|
+
const present = job.interpreter ? await access3(job.interpreter, constants3.X_OK).then(() => true, () => false) : false;
|
|
1898
2545
|
const supported = present && (job.interpreter === process.execPath && !runtimeCommand().args.length || runtimeSupportsCookies(job.interpreter));
|
|
1899
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." } : {} });
|
|
1900
2547
|
}
|
|
1901
|
-
const profiles = await
|
|
2548
|
+
const profiles = await readdir3(join9(home, ".config", "moodle-cli", "mcp", "deployments")).catch(() => []);
|
|
1902
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." });
|
|
1903
2550
|
let pin;
|
|
1904
2551
|
try {
|
|
@@ -1909,8 +2556,8 @@ async function doctor(options = {}) {
|
|
|
1909
2556
|
}
|
|
1910
2557
|
|
|
1911
2558
|
// src/cli.ts
|
|
1912
|
-
import { rm as rm7, readdir as
|
|
1913
|
-
import { homedir as
|
|
2559
|
+
import { rm as rm7, readdir as readdir4 } from "fs/promises";
|
|
2560
|
+
import { homedir as homedir15 } from "os";
|
|
1914
2561
|
|
|
1915
2562
|
// src/mcp/renewal/decision.ts
|
|
1916
2563
|
function decideRenewal(snapshot) {
|
|
@@ -2047,7 +2694,7 @@ function macOSPlan(options, intervalMinutes) {
|
|
|
2047
2694
|
throw new Error("macOS renewal installation requires the current user ID");
|
|
2048
2695
|
}
|
|
2049
2696
|
const label = `com.moodle-cli.mcp-renewal.${options.profile}`;
|
|
2050
|
-
const
|
|
2697
|
+
const path5 = `${trimEnd(options.homeDirectory, "/")}/Library/LaunchAgents/${label}.plist`;
|
|
2051
2698
|
const target = `gui/${options.uid}`;
|
|
2052
2699
|
const logPath = `${trimEnd(options.homeDirectory, "/")}/Library/Logs/${label}.log`;
|
|
2053
2700
|
const plist = [
|
|
@@ -2069,12 +2716,12 @@ function macOSPlan(options, intervalMinutes) {
|
|
|
2069
2716
|
platform: "darwin",
|
|
2070
2717
|
profile: options.profile,
|
|
2071
2718
|
label,
|
|
2072
|
-
files: [{ path:
|
|
2719
|
+
files: [{ path: path5, content: plist, mode: 384 }],
|
|
2073
2720
|
installCommands: [
|
|
2074
|
-
{ command: "launchctl", args: ["bootout", target,
|
|
2075
|
-
{ command: "launchctl", args: ["bootstrap", target,
|
|
2721
|
+
{ command: "launchctl", args: ["bootout", target, path5], ignoreFailure: true },
|
|
2722
|
+
{ command: "launchctl", args: ["bootstrap", target, path5] }
|
|
2076
2723
|
],
|
|
2077
|
-
removeCommands: [{ command: "launchctl", args: ["bootout", target,
|
|
2724
|
+
removeCommands: [{ command: "launchctl", args: ["bootout", target, path5], ignoreFailure: true }]
|
|
2078
2725
|
};
|
|
2079
2726
|
}
|
|
2080
2727
|
function linuxPlan(options, intervalMinutes) {
|
|
@@ -2125,7 +2772,7 @@ function linuxPlan(options, intervalMinutes) {
|
|
|
2125
2772
|
}
|
|
2126
2773
|
function windowsPlan(options, intervalMinutes) {
|
|
2127
2774
|
const label = `Moodle CLI MCP Renewal (${options.profile})`;
|
|
2128
|
-
const
|
|
2775
|
+
const path5 = `${trimEnd(options.homeDirectory, "\\/")}\\AppData\\Local\\moodle-cli\\renewal\\${options.profile}.xml`;
|
|
2129
2776
|
const argumentsText = [...options.executableArgs ?? [], ...renewalArgs(options.profile)].map(windowsArgument).join(" ");
|
|
2130
2777
|
const task = [
|
|
2131
2778
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
@@ -2145,8 +2792,8 @@ function windowsPlan(options, intervalMinutes) {
|
|
|
2145
2792
|
platform: "win32",
|
|
2146
2793
|
profile: options.profile,
|
|
2147
2794
|
label,
|
|
2148
|
-
files: [{ path:
|
|
2149
|
-
installCommands: [{ command: "schtasks.exe", args: ["/Create", "/TN", label, "/XML",
|
|
2795
|
+
files: [{ path: path5, content: task, mode: 384 }],
|
|
2796
|
+
installCommands: [{ command: "schtasks.exe", args: ["/Create", "/TN", label, "/XML", path5, "/F"] }],
|
|
2150
2797
|
removeCommands: [{ command: "schtasks.exe", args: ["/Delete", "/TN", label, "/F"], ignoreFailure: true }]
|
|
2151
2798
|
};
|
|
2152
2799
|
}
|
|
@@ -2199,24 +2846,24 @@ async function sendRenewalNotification(kind, sender) {
|
|
|
2199
2846
|
}
|
|
2200
2847
|
|
|
2201
2848
|
// src/mcp/renewal/node-renewal.ts
|
|
2202
|
-
import { execFile as
|
|
2203
|
-
import { chmod as chmod3, mkdir as
|
|
2204
|
-
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";
|
|
2205
2852
|
import { dirname as dirname5 } from "path";
|
|
2206
2853
|
import { promisify } from "util";
|
|
2207
|
-
var execFile = promisify(
|
|
2854
|
+
var execFile = promisify(execFileCallback);
|
|
2208
2855
|
var NodeRenewalInstallerIO = class {
|
|
2209
|
-
async writePrivate(
|
|
2210
|
-
await
|
|
2211
|
-
await writeFile5(
|
|
2212
|
-
await chmod3(
|
|
2856
|
+
async writePrivate(path5, content, mode) {
|
|
2857
|
+
await mkdir6(dirname5(path5), { recursive: true, mode: 448 });
|
|
2858
|
+
await writeFile5(path5, content, { encoding: "utf8", mode });
|
|
2859
|
+
await chmod3(path5, mode);
|
|
2213
2860
|
}
|
|
2214
|
-
async removeFile(
|
|
2215
|
-
await rm4(
|
|
2861
|
+
async removeFile(path5) {
|
|
2862
|
+
await rm4(path5, { force: true });
|
|
2216
2863
|
}
|
|
2217
|
-
async exists(
|
|
2864
|
+
async exists(path5) {
|
|
2218
2865
|
try {
|
|
2219
|
-
await readFile5(
|
|
2866
|
+
await readFile5(path5);
|
|
2220
2867
|
return true;
|
|
2221
2868
|
} catch (error) {
|
|
2222
2869
|
if (isMissing2(error)) {
|
|
@@ -2246,7 +2893,7 @@ function createDefaultRenewalInstaller(profile, options = {}) {
|
|
|
2246
2893
|
profile,
|
|
2247
2894
|
executable: runtime.command,
|
|
2248
2895
|
executableArgs: runtime.args,
|
|
2249
|
-
homeDirectory: options.homeDirectory ??
|
|
2896
|
+
homeDirectory: options.homeDirectory ?? homedir9(),
|
|
2250
2897
|
uid: options.uid ?? (typeof process.getuid === "function" ? process.getuid() : void 0),
|
|
2251
2898
|
intervalMinutes: options.intervalMinutes
|
|
2252
2899
|
});
|
|
@@ -2334,13 +2981,14 @@ var MoodleGatewayError = class extends Error {
|
|
|
2334
2981
|
this.code = code;
|
|
2335
2982
|
}
|
|
2336
2983
|
};
|
|
2337
|
-
function createMoodleGateway(client) {
|
|
2984
|
+
function createMoodleGateway(client, hooks = {}) {
|
|
2338
2985
|
return {
|
|
2339
2986
|
getUser: () => client.getSiteInfo(),
|
|
2340
2987
|
listThreads: (id2) => client.getForumDiscussionRefs ? client.getForumDiscussionRefs(id2) : Promise.resolve([]),
|
|
2341
2988
|
listNewsForums: (id2) => client.getNewsForums ? client.getNewsForums(id2) : Promise.resolve([]),
|
|
2342
2989
|
getOverview: (input2) => client.getOverview(input2.todoLimit, input2.todoDays, input2.alertsLimit),
|
|
2343
2990
|
...client.getTodo ? { getDue: (days, courseId) => client.getTodo(Number.MAX_SAFE_INTEGER, days, courseId) } : {},
|
|
2991
|
+
...client.submitAssignmentFiles ? { submitAssignment: (input2) => client.submitAssignmentFiles({ ...input2, ...hooks.onSubmitProgress ? { onProgress: hooks.onSubmitProgress } : {} }) } : {},
|
|
2344
2992
|
listCourses: () => client.getCourses(),
|
|
2345
2993
|
async getCourse({ courseId }) {
|
|
2346
2994
|
const [courses, sections] = await Promise.all([
|
|
@@ -2658,9 +3306,9 @@ function splitUnitPhrase(phrase, courses) {
|
|
|
2658
3306
|
}
|
|
2659
3307
|
|
|
2660
3308
|
// src/intents.ts
|
|
2661
|
-
async function inParallel(items,
|
|
3309
|
+
async function inParallel(items, size2, run) {
|
|
2662
3310
|
const results = [];
|
|
2663
|
-
for (let index = 0; index < items.length; index +=
|
|
3311
|
+
for (let index = 0; index < items.length; index += size2) results.push(...await Promise.all(items.slice(index, index + size2).map(run)));
|
|
2664
3312
|
return results;
|
|
2665
3313
|
}
|
|
2666
3314
|
function createIntentService(gateway, now = () => Date.now()) {
|
|
@@ -2851,6 +3499,12 @@ function createIntentService(gateway, now = () => Date.now()) {
|
|
|
2851
3499
|
result = { file: { name: file2.name, mime_type: file2.mimeType, bytes: file2.bytes, uri: file2.uri } };
|
|
2852
3500
|
break;
|
|
2853
3501
|
}
|
|
3502
|
+
case "submit": {
|
|
3503
|
+
if (!gateway.submitAssignment) throw new MoodleGatewayError("MOODLE_TOOL_UNAVAILABLE", "Submitting needs local files; run moodle submit or the local MCP server on the machine that holds them.");
|
|
3504
|
+
const activityId = await resolveItem(input2.ref);
|
|
3505
|
+
result = { submission: await gateway.submitAssignment({ activityId, files: input2.files, final: Boolean(input2.final), replace: Boolean(input2.replace), acceptStatement: Boolean(input2.accept_statement), dryRun: Boolean(input2.dry_run) }) };
|
|
3506
|
+
break;
|
|
3507
|
+
}
|
|
2854
3508
|
}
|
|
2855
3509
|
return intentContracts[name].output.parse(stripEmpty(result));
|
|
2856
3510
|
}
|
|
@@ -2861,16 +3515,31 @@ function createIntentService(gateway, now = () => Date.now()) {
|
|
|
2861
3515
|
return { run, resolveItem, fileSource, find, sections };
|
|
2862
3516
|
}
|
|
2863
3517
|
|
|
3518
|
+
// src/screens.ts
|
|
3519
|
+
import { createTheme as createTheme2 } from "@bunizao/cli-kit";
|
|
3520
|
+
|
|
2864
3521
|
// src/terminal-table.ts
|
|
3522
|
+
import { createTheme } from "@bunizao/cli-kit";
|
|
2865
3523
|
var DEFAULT_WIDTH = 120;
|
|
3524
|
+
var TONED_LABEL = /status|state|type|grade|due|ok|action|grading/iu;
|
|
3525
|
+
var colorEnabled = () => false;
|
|
3526
|
+
function configureTerminalTables(options) {
|
|
3527
|
+
colorEnabled = options.color;
|
|
3528
|
+
}
|
|
2866
3529
|
function renderTerminalTable(columns, rows, options = {}) {
|
|
3530
|
+
const theme = createTheme(colorEnabled());
|
|
2867
3531
|
const width = Math.max(20, options.width ?? process.stdout.columns ?? DEFAULT_WIDTH);
|
|
2868
|
-
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, " ")));
|
|
2869
3538
|
const lengths = columns.map((c, i) => Math.max(c.label.length, ...clean.map((r) => Array.from(r[i]).length)));
|
|
2870
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);
|
|
2871
3540
|
const widths = columns.map((c, i) => c.width ?? (flexible.includes(i) ? Math.min(lengths[i], 36) : lengths[i]));
|
|
2872
3541
|
const minimum = widths.reduce((n2, w, i) => n2 + (flexible.includes(i) ? 8 : w), 0) + columns.length * 3 + 1;
|
|
2873
|
-
const title = options.title ? [sanitizeTerminalText(options.title)] : [];
|
|
3542
|
+
const title = options.title ? [theme.subject(sanitizeTerminalText(options.title))] : [];
|
|
2874
3543
|
if (width < 60 || minimum > width) {
|
|
2875
3544
|
const wrap = (line2) => Array.from(line2).reduce((lines, char) => {
|
|
2876
3545
|
if (!lines.length || Array.from(lines.at(-1)).length >= width) lines.push("");
|
|
@@ -2889,9 +3558,22 @@ function renderTerminalTable(columns, rows, options = {}) {
|
|
|
2889
3558
|
const trimmed = chars.length > w ? `${chars.slice(0, w - 1).join("")}\u2026` : v;
|
|
2890
3559
|
return trimmed + " ".repeat(Math.max(0, w - Array.from(trimmed).length));
|
|
2891
3560
|
};
|
|
2892
|
-
const
|
|
2893
|
-
|
|
2894
|
-
|
|
3561
|
+
const paint = (cell, i, row, tone) => {
|
|
3562
|
+
if (tone) return theme.tone(tone, cell);
|
|
3563
|
+
if (options.keyValue) return i === 0 ? theme.dim(cell) : /status|grading|action/iu.test(row[0] ?? "") ? theme.status(cell) : cell;
|
|
3564
|
+
return i === 0 ? theme.key(cell) : TONED_LABEL.test(columns[i]?.label ?? "") ? theme.status(cell) : cell;
|
|
3565
|
+
};
|
|
3566
|
+
const border = (l, m, r) => theme.dim(l + widths.map((w) => "\u2500".repeat(w + 2)).join(m) + r);
|
|
3567
|
+
const bar = theme.dim("\u2502");
|
|
3568
|
+
const line = (row, paintCell) => bar + widths.map((w, i) => ` ${paintCell(fit(row[i] ?? "", w), i)} `).join(bar) + bar;
|
|
3569
|
+
return [
|
|
3570
|
+
...title,
|
|
3571
|
+
border("\u250C", "\u252C", "\u2510"),
|
|
3572
|
+
line(columns.map((c) => c.label), (cell) => theme.dim(cell)),
|
|
3573
|
+
border("\u251C", "\u253C", "\u2524"),
|
|
3574
|
+
...clean.map((row, r) => line(row, (cell, i) => paint(cell, i, row, tones[r][i]))),
|
|
3575
|
+
border("\u2514", "\u2534", "\u2518")
|
|
3576
|
+
].join("\n");
|
|
2895
3577
|
}
|
|
2896
3578
|
function renderKeyValueTable(rows, options = {}) {
|
|
2897
3579
|
const present = rows.filter(([, value]) => value !== "");
|
|
@@ -2903,10 +3585,10 @@ No details` : "No details";
|
|
|
2903
3585
|
{ label: "Field" },
|
|
2904
3586
|
{ label: "Value" }
|
|
2905
3587
|
];
|
|
2906
|
-
return renderTerminalTable(columns, present, options);
|
|
3588
|
+
return renderTerminalTable(columns, present, { ...options, keyValue: true });
|
|
2907
3589
|
}
|
|
2908
3590
|
function sanitizeTerminalText(value) {
|
|
2909
|
-
return value.replace(/\r\n?/gu, "\n").replace(/\t/gu, " ").replace(/[
|
|
3591
|
+
return value.replace(/\r\n?/gu, "\n").replace(/\t/gu, " ").replace(/[---]/gu, "");
|
|
2910
3592
|
}
|
|
2911
3593
|
|
|
2912
3594
|
// src/screens.ts
|
|
@@ -2924,21 +3606,29 @@ function moment(value, now) {
|
|
|
2924
3606
|
const sameYear = new Date(now).getFullYear() === Number(year);
|
|
2925
3607
|
return `${weekday} ${Number(day)} ${MONTHS[Number(month) - 1]}${sameYear ? "" : ` ${year}`}${hour ? `, ${hour}:${minute}` : ""}`;
|
|
2926
3608
|
}
|
|
3609
|
+
function tryLines(commands) {
|
|
3610
|
+
return commands.map((command, index) => `${index ? " " : "Try "}${command}`).join("\n");
|
|
3611
|
+
}
|
|
2927
3612
|
function renderScreen(data, options = {}) {
|
|
2928
3613
|
const lines = [];
|
|
2929
3614
|
const now = options.now ?? Date.now();
|
|
2930
|
-
const
|
|
2931
|
-
|
|
3615
|
+
const theme = createTheme2(Boolean(options.color));
|
|
3616
|
+
const due = (row) => {
|
|
2932
3617
|
const days = Math.ceil((Number(row.due_at) * 1e3 - now) / 864e5);
|
|
2933
3618
|
const value = `${days < 0 ? `${-days} days overdue` : days === 0 ? "today" : days === 1 ? "tomorrow" : `in ${days} days`} \xB7 ${moment(row.due, now)}`;
|
|
2934
|
-
return
|
|
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);
|
|
2935
3625
|
};
|
|
2936
3626
|
const rows = (items, title) => {
|
|
2937
|
-
lines.push(title);
|
|
2938
|
-
if (!items.length) lines.push(" None");
|
|
2939
|
-
for (const r of items) lines.push(` ${text(r.unit_code || r.type)} ${text(r.name)}${r.due_at ? ` ${dueText(r)}` : ""}${r.id ? `
|
|
3627
|
+
lines.push(theme.subject(title));
|
|
3628
|
+
if (!items.length) lines.push(theme.dim(" None"));
|
|
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}`)}` : ""}`);
|
|
2940
3630
|
};
|
|
2941
|
-
let next = "moodle due --days 30
|
|
3631
|
+
let next = ["moodle due --days 30", "moodle grades"];
|
|
2942
3632
|
if (data.home) {
|
|
2943
3633
|
const h = record(data.home);
|
|
2944
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), "");
|
|
@@ -2969,11 +3659,12 @@ function renderScreen(data, options = {}) {
|
|
|
2969
3659
|
rows(array(data.news), "Latest news");
|
|
2970
3660
|
}
|
|
2971
3661
|
const unit = JSON.stringify(u.code || u.name);
|
|
2972
|
-
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`];
|
|
2973
3663
|
} else if (data.grades) {
|
|
2974
3664
|
for (const g of array(data.grades)) {
|
|
2975
3665
|
lines.push(`${text(g.code)} \xB7 ${g.graded} of ${g.total} graded`);
|
|
2976
|
-
|
|
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 }));
|
|
2977
3668
|
}
|
|
2978
3669
|
} else if (data.item) {
|
|
2979
3670
|
const i = record(data.item);
|
|
@@ -2981,13 +3672,13 @@ function renderScreen(data, options = {}) {
|
|
|
2981
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)}`);
|
|
2982
3673
|
for (const f of array(i.files)) lines.push(`File ${text(f.name)} ${text(f.url)}`);
|
|
2983
3674
|
if (data.threads) rows(array(data.threads), "Threads");
|
|
2984
|
-
next = `moodle get ${i.id} --to DIR
|
|
3675
|
+
next = [`moodle get ${i.id} --to DIR`];
|
|
2985
3676
|
} else if (data.thread) {
|
|
2986
3677
|
const t = record(data.thread);
|
|
2987
3678
|
lines.push(text(t.name));
|
|
2988
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)}`));
|
|
2989
3680
|
lines.push(`Posts ${Number(t.offset) + array(t.posts).length} of ${t.posts_total}`);
|
|
2990
|
-
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}`];
|
|
2991
3682
|
} else if (data.news) {
|
|
2992
3683
|
for (const n2 of array(data.news)) {
|
|
2993
3684
|
const p = record(n2.post);
|
|
@@ -2995,13 +3686,13 @@ function renderScreen(data, options = {}) {
|
|
|
2995
3686
|
}
|
|
2996
3687
|
} else if (data.units) {
|
|
2997
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 }));
|
|
2998
|
-
next = "moodle UNIT
|
|
3689
|
+
next = ["moodle UNIT", "moodle find QUERY"];
|
|
2999
3690
|
} else {
|
|
3000
3691
|
const key = ["due", "results", "activities", "forums"].find((k) => k in data);
|
|
3001
3692
|
rows(array(key ? data[key] : []), key === "due" ? "Due" : "Matches");
|
|
3002
3693
|
if (data.total !== void 0) lines.push(`${data.total} total`);
|
|
3003
3694
|
}
|
|
3004
|
-
lines.push("",
|
|
3695
|
+
lines.push("", ...tryLines(next).split("\n").map((line) => theme.dim(line)));
|
|
3005
3696
|
const width = Math.max(40, options.width || 80);
|
|
3006
3697
|
return lines.flatMap((line) => {
|
|
3007
3698
|
if (line.includes("\x1B[") || line.startsWith("\u2502") || /^[┌└├]/u.test(line)) return [line];
|
|
@@ -3022,12 +3713,20 @@ function renderScreen(data, options = {}) {
|
|
|
3022
3713
|
}
|
|
3023
3714
|
|
|
3024
3715
|
// src/cli.ts
|
|
3025
|
-
import {
|
|
3026
|
-
import { spawn as spawn3 } from "child_process";
|
|
3716
|
+
import { spawn as spawn4 } from "child_process";
|
|
3027
3717
|
import {
|
|
3718
|
+
banner,
|
|
3719
|
+
colorEnabled as colorEnabled2,
|
|
3028
3720
|
confirm,
|
|
3029
3721
|
createProgram,
|
|
3722
|
+
createTheme as createTheme3,
|
|
3723
|
+
createUi as createUi3,
|
|
3724
|
+
detectAudience,
|
|
3725
|
+
examples,
|
|
3726
|
+
helpSection,
|
|
3030
3727
|
insertDefaultVerb,
|
|
3728
|
+
isInformationalExit,
|
|
3729
|
+
parseWithPrompts,
|
|
3031
3730
|
render,
|
|
3032
3731
|
reportError,
|
|
3033
3732
|
normalizeError,
|
|
@@ -3036,12 +3735,15 @@ import {
|
|
|
3036
3735
|
writeOutput
|
|
3037
3736
|
} from "@bunizao/cli-kit";
|
|
3038
3737
|
import { realpathSync as realpathSync3 } from "fs";
|
|
3039
|
-
import
|
|
3738
|
+
import path4 from "path";
|
|
3040
3739
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
3041
3740
|
|
|
3042
3741
|
// src/moodle-client-core.ts
|
|
3043
3742
|
import { z as z2 } from "zod";
|
|
3044
3743
|
|
|
3744
|
+
// src/moodle-assign-core.ts
|
|
3745
|
+
import { parse as parse4 } from "node-html-parser";
|
|
3746
|
+
|
|
3045
3747
|
// src/html-utils.ts
|
|
3046
3748
|
import { parse as parse2 } from "node-html-parser";
|
|
3047
3749
|
function htmlToStructuredContent(html, baseUrl) {
|
|
@@ -3111,262 +3813,6 @@ function decodeHtml2(value) {
|
|
|
3111
3813
|
return value.replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'");
|
|
3112
3814
|
}
|
|
3113
3815
|
|
|
3114
|
-
// src/parsers.ts
|
|
3115
|
-
function schema(parser) {
|
|
3116
|
-
return { parse: parser };
|
|
3117
|
-
}
|
|
3118
|
-
var UserInfoSchema = schema(parseUserInfo);
|
|
3119
|
-
var CourseSchema = schema(parseCourse);
|
|
3120
|
-
var CoursesSchema = schema(parseCourses);
|
|
3121
|
-
var ActivitySchema = schema(parseActivity);
|
|
3122
|
-
var SectionSchema = schema(parseSection);
|
|
3123
|
-
var CourseContentsSchema = schema(parseCourseContents);
|
|
3124
|
-
var TodoItemSchema = schema(parseTodoItem);
|
|
3125
|
-
function parseUserInfo(value) {
|
|
3126
|
-
const data = asRecord(value);
|
|
3127
|
-
return {
|
|
3128
|
-
userid: numberValue(data.userid),
|
|
3129
|
-
username: stringValue(data.username),
|
|
3130
|
-
fullname: stringValue(data.fullname),
|
|
3131
|
-
sitename: stringValue(data.sitename),
|
|
3132
|
-
siteurl: stringValue(data.siteurl),
|
|
3133
|
-
lang: stringValue(data.lang),
|
|
3134
|
-
...data.timezone ? { timezone: String(data.timezone) } : {}
|
|
3135
|
-
};
|
|
3136
|
-
}
|
|
3137
|
-
function parseCourse(value, nowSeconds = Math.floor(Date.now() / 1e3)) {
|
|
3138
|
-
const data = asRecord(value);
|
|
3139
|
-
const course = {
|
|
3140
|
-
id: numberValue(data.id),
|
|
3141
|
-
shortname: stringValue(data.shortname),
|
|
3142
|
-
fullname: stringValue(data.fullname),
|
|
3143
|
-
category: numberValue(data.category),
|
|
3144
|
-
visible: booleanValue(data.visible, true),
|
|
3145
|
-
startdate: numberValue(data.startdate)
|
|
3146
|
-
};
|
|
3147
|
-
const enddate = numberValue(data.enddate);
|
|
3148
|
-
if (enddate > 0) {
|
|
3149
|
-
course.enddate = enddate;
|
|
3150
|
-
}
|
|
3151
|
-
return course;
|
|
3152
|
-
}
|
|
3153
|
-
function parseCourses(value) {
|
|
3154
|
-
return asArray(value).map((item) => parseCourse(item));
|
|
3155
|
-
}
|
|
3156
|
-
function parseActivity(value) {
|
|
3157
|
-
const data = asRecord(value);
|
|
3158
|
-
return {
|
|
3159
|
-
id: numberValue(data.id),
|
|
3160
|
-
name: stringValue(data.name),
|
|
3161
|
-
modname: stringValue(data.modname),
|
|
3162
|
-
url: stringValue(data.url),
|
|
3163
|
-
visible: booleanValue(data.visible, true),
|
|
3164
|
-
description: stringValue(data.description),
|
|
3165
|
-
...data.completiondata && typeof data.completiondata === "object" ? { completion: numberValue(asRecord(data.completiondata).state) } : {},
|
|
3166
|
-
...Array.isArray(data.contents) ? { file_entries: data.contents.filter((f) => asRecord(f).fileurl).map((f) => ({ name: stringValue(asRecord(f).filename), url: stringValue(asRecord(f).fileurl), requires_authentication: true })) } : {}
|
|
3167
|
-
};
|
|
3168
|
-
}
|
|
3169
|
-
function parseSection(value) {
|
|
3170
|
-
const data = asRecord(value);
|
|
3171
|
-
return {
|
|
3172
|
-
id: numberValue(data.id),
|
|
3173
|
-
name: stringValue(data.name),
|
|
3174
|
-
section: numberValue(data.section),
|
|
3175
|
-
visible: booleanValue(data.visible, true),
|
|
3176
|
-
summary: stringValue(data.summary),
|
|
3177
|
-
...data.current !== void 0 ? { current: booleanValue(data.current) } : {},
|
|
3178
|
-
activities: asArray(data.modules).map((item) => parseActivity(item))
|
|
3179
|
-
};
|
|
3180
|
-
}
|
|
3181
|
-
function parseCourseContents(value) {
|
|
3182
|
-
return asArray(value).map((item) => parseSection(item));
|
|
3183
|
-
}
|
|
3184
|
-
function parseCourseFormatState(value, baseUrl) {
|
|
3185
|
-
const state = asRecord(parseJsonValue(value));
|
|
3186
|
-
const activities = /* @__PURE__ */ new Map();
|
|
3187
|
-
const activitiesBySection = /* @__PURE__ */ new Map();
|
|
3188
|
-
for (const item of asArray(state.cm)) {
|
|
3189
|
-
const data = asRecord(item);
|
|
3190
|
-
const id2 = numberValue(data.id);
|
|
3191
|
-
const sectionId = stringValue(data.sectionid);
|
|
3192
|
-
const module = stringValue(data.module) || stringValue(data.plugin).replace(/^mod_/u, "") || stringValue(data.modname).toLowerCase();
|
|
3193
|
-
const activity = {
|
|
3194
|
-
id: id2,
|
|
3195
|
-
name: htmlText(data.name, baseUrl),
|
|
3196
|
-
modname: module.toLowerCase(),
|
|
3197
|
-
url: stringValue(data.url) ? resolveUrl(baseUrl, stringValue(data.url)) : "",
|
|
3198
|
-
visible: booleanValue(data.visible, true) && booleanValue(data.uservisible, true) && !booleanValue(data.stealth),
|
|
3199
|
-
description: htmlText(data.content ?? data.description, baseUrl),
|
|
3200
|
-
...data.completionstate !== void 0 && data.completionstate !== null ? { completion: numberValue(data.completionstate) } : {}
|
|
3201
|
-
};
|
|
3202
|
-
activities.set(String(id2), activity);
|
|
3203
|
-
const sectionActivities = activitiesBySection.get(sectionId) ?? [];
|
|
3204
|
-
sectionActivities.push(activity);
|
|
3205
|
-
activitiesBySection.set(sectionId, sectionActivities);
|
|
3206
|
-
}
|
|
3207
|
-
return asArray(state.section).map((item) => {
|
|
3208
|
-
const data = asRecord(item);
|
|
3209
|
-
const id2 = numberValue(data.id);
|
|
3210
|
-
const hasActivityList = Array.isArray(data.cmlist);
|
|
3211
|
-
const listedActivities = asArray(data.cmlist).map((activityId) => activities.get(stringValue(activityId))).filter((activity) => activity !== void 0);
|
|
3212
|
-
return {
|
|
3213
|
-
id: id2,
|
|
3214
|
-
name: htmlText(data.title || data.rawtitle, baseUrl),
|
|
3215
|
-
section: numberValue(data.section ?? data.number),
|
|
3216
|
-
visible: booleanValue(data.visible, true),
|
|
3217
|
-
summary: htmlText(data.summary, baseUrl),
|
|
3218
|
-
...data.current !== void 0 ? { current: booleanValue(data.current) } : {},
|
|
3219
|
-
activities: hasActivityList ? listedActivities : activitiesBySection.get(String(id2)) ?? []
|
|
3220
|
-
};
|
|
3221
|
-
});
|
|
3222
|
-
}
|
|
3223
|
-
function parseTodoItem(value) {
|
|
3224
|
-
const data = asRecord(value);
|
|
3225
|
-
const course = asRecord(data.course);
|
|
3226
|
-
const action = asRecord(data.action);
|
|
3227
|
-
const progress = course.progress;
|
|
3228
|
-
return {
|
|
3229
|
-
id: numberValue(data.id),
|
|
3230
|
-
name: stringValue(data.name),
|
|
3231
|
-
activity_name: stringValue(data.activityname),
|
|
3232
|
-
modname: stringValue(data.modulename),
|
|
3233
|
-
course_id: numberValue(course.id),
|
|
3234
|
-
course_name: stringValue(course.fullname),
|
|
3235
|
-
due_at: numberValue(data.timesort) || numberValue(data.timestart),
|
|
3236
|
-
overdue: booleanValue(data.overdue),
|
|
3237
|
-
actionable: booleanValue(action.actionable),
|
|
3238
|
-
action_name: stringValue(action.name),
|
|
3239
|
-
action_url: stringValue(action.url),
|
|
3240
|
-
url: stringValue(data.url),
|
|
3241
|
-
event_type: stringValue(data.eventtype),
|
|
3242
|
-
course_progress: typeof progress === "number" ? progress : void 0
|
|
3243
|
-
};
|
|
3244
|
-
}
|
|
3245
|
-
function parseTodoItems(value) {
|
|
3246
|
-
return asArray(value).map((item) => parseTodoItem(item));
|
|
3247
|
-
}
|
|
3248
|
-
function parseAlertNotification(value) {
|
|
3249
|
-
const data = asRecord(value);
|
|
3250
|
-
return {
|
|
3251
|
-
id: numberValue(data.id),
|
|
3252
|
-
subject: stringValue(data.subject),
|
|
3253
|
-
short_subject: stringValue(data.shortenedsubject),
|
|
3254
|
-
event_type: stringValue(data.eventtype),
|
|
3255
|
-
component: stringValue(data.component),
|
|
3256
|
-
created_at: numberValue(data.timecreated),
|
|
3257
|
-
created_pretty: stringValue(data.timecreatedpretty),
|
|
3258
|
-
read: booleanValue(data.read),
|
|
3259
|
-
context_url: stringValue(data.contexturl),
|
|
3260
|
-
context_name: stringValue(data.contexturlname)
|
|
3261
|
-
};
|
|
3262
|
-
}
|
|
3263
|
-
function parseAlertSummary(notificationsData, countsData, unreadCountsData) {
|
|
3264
|
-
const notificationsRecord = asRecord(notificationsData);
|
|
3265
|
-
const counts2 = asRecord(countsData);
|
|
3266
|
-
const unreadCounts = asRecord(unreadCountsData);
|
|
3267
|
-
const types = asRecord(counts2.types);
|
|
3268
|
-
const unreadTypes = asRecord(unreadCounts.types);
|
|
3269
|
-
const notifications = asArray(notificationsRecord.notifications).map((item) => parseAlertNotification(item));
|
|
3270
|
-
return {
|
|
3271
|
-
notifications,
|
|
3272
|
-
notification_count: notifications.length,
|
|
3273
|
-
unread_notification_count: notifications.filter((notification) => !notification.read).length,
|
|
3274
|
-
starred_message_count: numberValue(counts2.favourites),
|
|
3275
|
-
direct_message_count: numberValue(types["1"]),
|
|
3276
|
-
group_message_count: numberValue(types["2"]),
|
|
3277
|
-
self_message_count: numberValue(types["3"]),
|
|
3278
|
-
unread_starred_message_count: numberValue(unreadCounts.favourites),
|
|
3279
|
-
unread_direct_message_count: numberValue(unreadTypes["1"]),
|
|
3280
|
-
unread_group_message_count: numberValue(unreadTypes["2"]),
|
|
3281
|
-
unread_self_message_count: numberValue(unreadTypes["3"])
|
|
3282
|
-
};
|
|
3283
|
-
}
|
|
3284
|
-
function parseForumPostAuthor(value) {
|
|
3285
|
-
const data = asRecord(value);
|
|
3286
|
-
const urls = asRecord(data.urls);
|
|
3287
|
-
return {
|
|
3288
|
-
id: numberValue(data.id),
|
|
3289
|
-
fullname: stringValue(data.fullname),
|
|
3290
|
-
profile_url: stringValue(urls.profile),
|
|
3291
|
-
profile_image_url: stringValue(urls.profileimage)
|
|
3292
|
-
};
|
|
3293
|
-
}
|
|
3294
|
-
function parseForumPost(value, baseUrl = "") {
|
|
3295
|
-
const data = asRecord(value);
|
|
3296
|
-
const urls = asRecord(data.urls);
|
|
3297
|
-
const messageHtml = stringValue(data.message);
|
|
3298
|
-
const structured = htmlToStructuredContent(messageHtml, stringValue(urls.view || urls.discuss) || baseUrl);
|
|
3299
|
-
return {
|
|
3300
|
-
id: numberValue(data.id),
|
|
3301
|
-
discussion_id: numberValue(data.discussionid),
|
|
3302
|
-
subject: stringValue(data.subject),
|
|
3303
|
-
message_html: messageHtml,
|
|
3304
|
-
message_text: structured.text,
|
|
3305
|
-
image_urls: structured.image_urls,
|
|
3306
|
-
links: structured.links,
|
|
3307
|
-
tables: structured.tables,
|
|
3308
|
-
author: parseForumPostAuthor(data.author),
|
|
3309
|
-
parent_id: numberValue(data.parentid),
|
|
3310
|
-
time_created: numberValue(data.timecreated),
|
|
3311
|
-
time_modified: numberValue(data.timemodified),
|
|
3312
|
-
created_pretty: "",
|
|
3313
|
-
unread: booleanValue(data.unread),
|
|
3314
|
-
is_deleted: booleanValue(data.isdeleted),
|
|
3315
|
-
is_private_reply: booleanValue(data.isprivatereply),
|
|
3316
|
-
url: stringValue(urls.view || urls.viewisolated),
|
|
3317
|
-
reply_url: stringValue(urls.reply)
|
|
3318
|
-
};
|
|
3319
|
-
}
|
|
3320
|
-
function parseForumDiscussion(value, discussionId, baseUrl = "") {
|
|
3321
|
-
const data = asRecord(value);
|
|
3322
|
-
const posts = asArray(data.posts).map((item) => parseForumPost(item, baseUrl));
|
|
3323
|
-
return {
|
|
3324
|
-
id: discussionId,
|
|
3325
|
-
subject: posts[0]?.subject ?? "",
|
|
3326
|
-
course_id: numberValue(data.courseid),
|
|
3327
|
-
forum_id: numberValue(data.forumid),
|
|
3328
|
-
group_id: numberValue(data.groupid),
|
|
3329
|
-
group_name: stringValue(data.groupname),
|
|
3330
|
-
url: posts[0]?.url ? posts[0].url.split("#", 1)[0] : "",
|
|
3331
|
-
posts
|
|
3332
|
-
};
|
|
3333
|
-
}
|
|
3334
|
-
function asRecord(value) {
|
|
3335
|
-
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
3336
|
-
}
|
|
3337
|
-
function asArray(value) {
|
|
3338
|
-
return Array.isArray(value) ? value : [];
|
|
3339
|
-
}
|
|
3340
|
-
function stringValue(value) {
|
|
3341
|
-
return typeof value === "string" ? value : value == null ? "" : String(value);
|
|
3342
|
-
}
|
|
3343
|
-
function numberValue(value) {
|
|
3344
|
-
if (typeof value === "number" && Number.isFinite(value)) {
|
|
3345
|
-
return value;
|
|
3346
|
-
}
|
|
3347
|
-
if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))) {
|
|
3348
|
-
return Number(value);
|
|
3349
|
-
}
|
|
3350
|
-
return 0;
|
|
3351
|
-
}
|
|
3352
|
-
function booleanValue(value, defaultValue = false) {
|
|
3353
|
-
if (value === void 0 || value === null) {
|
|
3354
|
-
return defaultValue;
|
|
3355
|
-
}
|
|
3356
|
-
return Boolean(value);
|
|
3357
|
-
}
|
|
3358
|
-
function parseJsonValue(value) {
|
|
3359
|
-
if (typeof value !== "string") return value;
|
|
3360
|
-
try {
|
|
3361
|
-
return JSON.parse(value);
|
|
3362
|
-
} catch {
|
|
3363
|
-
return {};
|
|
3364
|
-
}
|
|
3365
|
-
}
|
|
3366
|
-
function htmlText(value, baseUrl) {
|
|
3367
|
-
return htmlToStructuredContent(stringValue(value), baseUrl).text;
|
|
3368
|
-
}
|
|
3369
|
-
|
|
3370
3816
|
// src/scraper.ts
|
|
3371
3817
|
import { parse as parse3 } from "node-html-parser";
|
|
3372
3818
|
function parseMoodleErrorHtml(html) {
|
|
@@ -3398,8 +3844,8 @@ function parseMoodleErrorHtml(html) {
|
|
|
3398
3844
|
function parsePageContext(html, baseUrl) {
|
|
3399
3845
|
const root = parse3(html);
|
|
3400
3846
|
const config = parseMoodleConfig(html);
|
|
3401
|
-
const sesskey =
|
|
3402
|
-
const userid =
|
|
3847
|
+
const sesskey = stringValue(config.sesskey).trim();
|
|
3848
|
+
const userid = numberValue(config.userId) || numberValue(root.querySelector("[data-user-id]")?.getAttribute("data-user-id"));
|
|
3403
3849
|
if (!sesskey || !userid) {
|
|
3404
3850
|
throw new Error("Session appears invalid: could not load authenticated Moodle context");
|
|
3405
3851
|
}
|
|
@@ -3411,8 +3857,8 @@ function parsePageContext(html, baseUrl) {
|
|
|
3411
3857
|
fullname: cleanNodeText(root.querySelector(".userfullname")),
|
|
3412
3858
|
sitename: extractSitename(root),
|
|
3413
3859
|
siteurl: baseUrl,
|
|
3414
|
-
...config.timezone ? { timezone:
|
|
3415
|
-
lang:
|
|
3860
|
+
...config.timezone ? { timezone: stringValue(config.timezone) } : {},
|
|
3861
|
+
lang: stringValue(config.language) || root.querySelector("html")?.getAttribute("lang") || ""
|
|
3416
3862
|
}
|
|
3417
3863
|
};
|
|
3418
3864
|
}
|
|
@@ -3895,62 +4341,671 @@ function extractSitename(root) {
|
|
|
3895
4341
|
function pageTitle(html) {
|
|
3896
4342
|
return cleanNodeText(parse3(html).querySelector("h1"));
|
|
3897
4343
|
}
|
|
3898
|
-
function activityContext(html) {
|
|
3899
|
-
const root = parse3(html);
|
|
3900
|
-
const context = { course_id: parseCourseIdFromPageHtml(html) ?? 0, course_name: "", section_name: "" };
|
|
3901
|
-
const breadcrumbs = root.querySelectorAll('nav[aria-label="Breadcrumb"] a[href], #page-navbar .breadcrumb a[href]');
|
|
3902
|
-
const links = breadcrumbs.length ? breadcrumbs : root.querySelectorAll('a[href*="/course/view.php?id="]');
|
|
3903
|
-
for (const link2 of links) {
|
|
3904
|
-
const href = link2.getAttribute("href") ?? "";
|
|
3905
|
-
const courseId = numberQueryValue(href, "id");
|
|
3906
|
-
if (courseId !== null) {
|
|
3907
|
-
context.course_id = courseId;
|
|
3908
|
-
}
|
|
3909
|
-
if (numberQueryValue(href, "section") === null) {
|
|
3910
|
-
context.course_name ||= cleanNodeText(link2);
|
|
3911
|
-
} else {
|
|
3912
|
-
context.section_name = cleanNodeText(link2);
|
|
3913
|
-
}
|
|
3914
|
-
}
|
|
3915
|
-
return context;
|
|
4344
|
+
function activityContext(html) {
|
|
4345
|
+
const root = parse3(html);
|
|
4346
|
+
const context = { course_id: parseCourseIdFromPageHtml(html) ?? 0, course_name: "", section_name: "" };
|
|
4347
|
+
const breadcrumbs = root.querySelectorAll('nav[aria-label="Breadcrumb"] a[href], #page-navbar .breadcrumb a[href]');
|
|
4348
|
+
const links = breadcrumbs.length ? breadcrumbs : root.querySelectorAll('a[href*="/course/view.php?id="]');
|
|
4349
|
+
for (const link2 of links) {
|
|
4350
|
+
const href = link2.getAttribute("href") ?? "";
|
|
4351
|
+
const courseId = numberQueryValue(href, "id");
|
|
4352
|
+
if (courseId !== null) {
|
|
4353
|
+
context.course_id = courseId;
|
|
4354
|
+
}
|
|
4355
|
+
if (numberQueryValue(href, "section") === null) {
|
|
4356
|
+
context.course_name ||= cleanNodeText(link2);
|
|
4357
|
+
} else {
|
|
4358
|
+
context.section_name = cleanNodeText(link2);
|
|
4359
|
+
}
|
|
4360
|
+
}
|
|
4361
|
+
return context;
|
|
4362
|
+
}
|
|
4363
|
+
function extractLabeledText(html, label) {
|
|
4364
|
+
const root = parse3(html);
|
|
4365
|
+
for (const node of root.querySelectorAll("strong, b")) {
|
|
4366
|
+
if (cleanNodeText(node) !== label) {
|
|
4367
|
+
continue;
|
|
4368
|
+
}
|
|
4369
|
+
const parent = node.parentNode;
|
|
4370
|
+
return cleanText(parent?.textContent.replace(label, "") ?? "");
|
|
4371
|
+
}
|
|
4372
|
+
return "";
|
|
4373
|
+
}
|
|
4374
|
+
function findTableValue(html, label) {
|
|
4375
|
+
const root = parse3(html);
|
|
4376
|
+
for (const row of root.querySelectorAll("tr")) {
|
|
4377
|
+
const cells = row.querySelectorAll("th, td");
|
|
4378
|
+
if (cleanNodeText(cells[0]) === label) {
|
|
4379
|
+
return cleanTableCell(cells[1]);
|
|
4380
|
+
}
|
|
4381
|
+
}
|
|
4382
|
+
return "";
|
|
4383
|
+
}
|
|
4384
|
+
function cleanTableCell(node) {
|
|
4385
|
+
if (!node) {
|
|
4386
|
+
return "";
|
|
4387
|
+
}
|
|
4388
|
+
const clone = parse3(node.toString());
|
|
4389
|
+
for (const unwanted of clone.querySelectorAll(".action-menu, .dropdown, script, style")) {
|
|
4390
|
+
unwanted.remove();
|
|
4391
|
+
}
|
|
4392
|
+
return cleanText(clone.textContent.replace("( Empty )", "(Empty)"));
|
|
4393
|
+
}
|
|
4394
|
+
function numberQueryValue(href, key) {
|
|
4395
|
+
try {
|
|
4396
|
+
return numericQueryValue(new URL(href, "https://moodle.invalid"), key);
|
|
4397
|
+
} catch {
|
|
4398
|
+
return null;
|
|
4399
|
+
}
|
|
4400
|
+
}
|
|
4401
|
+
function numberValue(value) {
|
|
4402
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
4403
|
+
return value;
|
|
4404
|
+
}
|
|
4405
|
+
if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))) {
|
|
4406
|
+
return Number(value);
|
|
4407
|
+
}
|
|
4408
|
+
return 0;
|
|
4409
|
+
}
|
|
4410
|
+
function stringValue(value) {
|
|
4411
|
+
return typeof value === "string" ? value : value == null ? "" : String(value);
|
|
4412
|
+
}
|
|
4413
|
+
function fileEntry(name, url, baseUrl) {
|
|
4414
|
+
return {
|
|
4415
|
+
name,
|
|
4416
|
+
url,
|
|
4417
|
+
requires_authentication: new URL(url).origin === new URL(baseUrl).origin
|
|
4418
|
+
};
|
|
4419
|
+
}
|
|
4420
|
+
function unique(items) {
|
|
4421
|
+
return [...new Set(items)];
|
|
4422
|
+
}
|
|
4423
|
+
|
|
4424
|
+
// src/moodle-assign-core.ts
|
|
4425
|
+
async function submitAssignmentFiles(deps, request) {
|
|
4426
|
+
const id2 = request.activityId;
|
|
4427
|
+
if (!Number.isSafeInteger(id2) || id2 <= 0) throw deps.usage("The assignment id must be a positive integer.");
|
|
4428
|
+
if (!request.files.length && !request.final) throw deps.usage("Give at least one file to upload, or use --final to submit the existing draft.");
|
|
4429
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4430
|
+
for (const file2 of request.files) {
|
|
4431
|
+
if (!file2.name || /[\\/]/u.test(file2.name)) throw deps.usage(`'${file2.name}' is not a plain file name.`);
|
|
4432
|
+
if (seen.has(file2.name.toLowerCase())) throw deps.usage(`'${file2.name}' is given twice; Moodle keeps one file per name.`);
|
|
4433
|
+
seen.add(file2.name.toLowerCase());
|
|
4434
|
+
}
|
|
4435
|
+
const viewUrl = `${deps.baseUrl}${ASSIGN_VIEW_PATH}?id=${id2}`;
|
|
4436
|
+
request.onProgress?.("Reading the assignment");
|
|
4437
|
+
const before = parseReceiptPage(await pageText(deps, viewUrl), id2, deps.baseUrl);
|
|
4438
|
+
const form = parseSubmissionForm(await pageText(deps, `${viewUrl}&action=editsubmission`), deps);
|
|
4439
|
+
const draft = await listDraftFiles(deps, form);
|
|
4440
|
+
const removed = request.replace ? draft.map((file2) => file2.name) : [];
|
|
4441
|
+
const kept = request.replace ? [] : draft.filter((file2) => !seen.has(file2.name.toLowerCase()));
|
|
4442
|
+
checkLimits(deps, form, kept, request.files);
|
|
4443
|
+
let statement = form.statement;
|
|
4444
|
+
let confirm2;
|
|
4445
|
+
if (request.final && !statement) {
|
|
4446
|
+
confirm2 = parseConfirmForm(await pageText(deps, `${viewUrl}&action=submit`), deps);
|
|
4447
|
+
statement = confirm2.statement;
|
|
4448
|
+
}
|
|
4449
|
+
if (statement && !request.acceptStatement) {
|
|
4450
|
+
throw deps.usage(`Moodle requires you to accept this statement: "${statement}"`, "Re-run with --accept-statement once you agree.");
|
|
4451
|
+
}
|
|
4452
|
+
const limits = describeLimits(form);
|
|
4453
|
+
const uploads = request.files.map((file2) => ({ name: file2.name, bytes: file2.bytes.byteLength, ...file2.path ? { path: file2.path } : {} }));
|
|
4454
|
+
if (request.dryRun) {
|
|
4455
|
+
return {
|
|
4456
|
+
...before,
|
|
4457
|
+
action: "planned",
|
|
4458
|
+
files: draft.map((file2) => ({ name: file2.name, bytes: file2.bytes })),
|
|
4459
|
+
uploads,
|
|
4460
|
+
removed,
|
|
4461
|
+
limits,
|
|
4462
|
+
...statement ? { statement, statement_accepted: true } : {},
|
|
4463
|
+
checked_at: timestamp(deps)
|
|
4464
|
+
};
|
|
4465
|
+
}
|
|
4466
|
+
if (removed.length) {
|
|
4467
|
+
request.onProgress?.(`Removing ${removed.join(", ")}`);
|
|
4468
|
+
await deleteDraftFiles(deps, form, draft);
|
|
4469
|
+
}
|
|
4470
|
+
const storedNames = [];
|
|
4471
|
+
for (const [index, file2] of request.files.entries()) {
|
|
4472
|
+
request.onProgress?.(`Uploading ${file2.name} (${index + 1}/${request.files.length})`);
|
|
4473
|
+
storedNames.push(await uploadDraftFile(deps, form, file2));
|
|
4474
|
+
}
|
|
4475
|
+
if (storedNames.length || removed.length) {
|
|
4476
|
+
request.onProgress?.("Saving the submission");
|
|
4477
|
+
const savedHtml = await postForm(deps, form.action, [...form.fields, ...form.statement ? [["submissionstatement", "1"]] : [], ["submitbutton", "Save changes"]]);
|
|
4478
|
+
if (savedHtml !== null) throw deps.fail(`Moodle did not save the submission: ${noticesOf(savedHtml) || "it returned the edit form again without a reason"}`);
|
|
4479
|
+
}
|
|
4480
|
+
request.onProgress?.("Reading the receipt");
|
|
4481
|
+
let receipt = parseReceiptPage(await pageText(deps, viewUrl), id2, deps.baseUrl);
|
|
4482
|
+
const listed = new Set(receipt.files.map((file2) => file2.name.toLowerCase()));
|
|
4483
|
+
const missing = storedNames.filter((name) => !listed.has(name.toLowerCase()));
|
|
4484
|
+
if (missing.length) throw deps.fail(`Moodle saved the submission but its page does not list ${missing.join(", ")}; check the assignment in a browser before submitting.`);
|
|
4485
|
+
let action = "saved";
|
|
4486
|
+
if (isSubmitted(receipt.submission_status)) action = "submitted";
|
|
4487
|
+
else if (request.final) {
|
|
4488
|
+
request.onProgress?.("Submitting for grading");
|
|
4489
|
+
confirm2 ??= parseConfirmForm(await pageText(deps, `${viewUrl}&action=submit`), deps);
|
|
4490
|
+
const errorHtml = await postForm(deps, confirm2.action, [...confirm2.fields, ...confirm2.statement ? [["submissionstatement", "1"]] : [], ["submitbutton", "Continue"]]);
|
|
4491
|
+
if (errorHtml !== null) throw deps.fail(`Moodle did not submit the assignment for grading: ${noticesOf(errorHtml) || "it returned the confirmation page again without a reason"}`);
|
|
4492
|
+
receipt = parseReceiptPage(await pageText(deps, viewUrl), id2, deps.baseUrl);
|
|
4493
|
+
if (!isSubmitted(receipt.submission_status)) throw deps.fail(`Moodle accepted the confirmation but still reports "${receipt.submission_status || "no status"}"; check the assignment in a browser.`);
|
|
4494
|
+
action = "submitted";
|
|
4495
|
+
}
|
|
4496
|
+
return {
|
|
4497
|
+
...receipt,
|
|
4498
|
+
action,
|
|
4499
|
+
uploads,
|
|
4500
|
+
removed,
|
|
4501
|
+
limits,
|
|
4502
|
+
...statement ? { statement, statement_accepted: true } : {},
|
|
4503
|
+
checked_at: timestamp(deps)
|
|
4504
|
+
};
|
|
4505
|
+
}
|
|
4506
|
+
function parseSubmissionForm(html, deps) {
|
|
4507
|
+
const root = parse4(html);
|
|
4508
|
+
const form = formWithAction(root, "savesubmission");
|
|
4509
|
+
if (!form) {
|
|
4510
|
+
const notice = noticesOf(html);
|
|
4511
|
+
if (root.querySelectorAll("input[type=submit], button").some((button) => /begin assignment/iu.test(cleanText(button.getAttribute("value") ?? button.textContent)))) {
|
|
4512
|
+
throw deps.fail("This is a timed assignment; start it in a browser before uploading files.");
|
|
4513
|
+
}
|
|
4514
|
+
throw deps.fail(notice ? `Moodle is not accepting a submission: ${notice}` : "Moodle did not show a submission form for this assignment.");
|
|
4515
|
+
}
|
|
4516
|
+
const itemid = form.querySelector("input[name=files_filemanager]")?.getAttribute("value")?.trim() ?? "";
|
|
4517
|
+
if (!itemid) throw deps.fail("This assignment does not accept file uploads.");
|
|
4518
|
+
const options = filemanagerOptions(html, itemid);
|
|
4519
|
+
if (!options) throw deps.fail("Moodle did not describe the file upload area for this assignment.");
|
|
4520
|
+
const fields2 = formFields(form);
|
|
4521
|
+
const sesskey = fields2.find(([name]) => name === "sesskey")?.[1] ?? "";
|
|
4522
|
+
if (!sesskey) throw deps.fail("The submission form has no session key.");
|
|
4523
|
+
const picker = record2(options.filepicker);
|
|
4524
|
+
const repositories = (Array.isArray(picker.repositories) ? picker.repositories : Object.values(record2(picker.repositories))).map(record2);
|
|
4525
|
+
const upload = repositories.find((repo) => repo.type === "upload");
|
|
4526
|
+
if (!upload || upload.id === void 0) throw deps.fail("The site does not allow direct file uploads for this assignment.");
|
|
4527
|
+
const accepted = options.accepted_types;
|
|
4528
|
+
const acceptedTypes = Array.isArray(accepted) ? accepted.map(String).filter(Boolean) : accepted === void 0 || accepted === "*" ? "*" : [String(accepted)];
|
|
4529
|
+
return {
|
|
4530
|
+
action: resolveUrl(deps.baseUrl, form.getAttribute("action") || `${deps.baseUrl}${ASSIGN_VIEW_PATH}`),
|
|
4531
|
+
fields: fields2,
|
|
4532
|
+
sesskey,
|
|
4533
|
+
itemid,
|
|
4534
|
+
clientId: String(options.client_id ?? ""),
|
|
4535
|
+
contextId: String(record2(options.context).id ?? ""),
|
|
4536
|
+
repoId: String(upload.id),
|
|
4537
|
+
author: String(picker.author ?? ""),
|
|
4538
|
+
license: String(picker.defaultlicense ?? ""),
|
|
4539
|
+
maxBytes: integer(options.maxbytes),
|
|
4540
|
+
areaMaxBytes: integer(options.areamaxbytes),
|
|
4541
|
+
maxFiles: integer(options.maxfiles),
|
|
4542
|
+
acceptedTypes: acceptedTypes.length === 1 && acceptedTypes[0] === "*" ? "*" : acceptedTypes,
|
|
4543
|
+
...statementOf(form)
|
|
4544
|
+
};
|
|
4545
|
+
}
|
|
4546
|
+
function parseConfirmForm(html, deps) {
|
|
4547
|
+
const root = parse4(html);
|
|
4548
|
+
const form = formWithAction(root, "confirmsubmit");
|
|
4549
|
+
if (!form) {
|
|
4550
|
+
const notice = noticesOf(html);
|
|
4551
|
+
throw deps.fail(notice ? `Moodle is not accepting a submission for grading: ${notice}` : "Moodle did not show the submit-for-grading confirmation.");
|
|
4552
|
+
}
|
|
4553
|
+
return { action: resolveUrl(deps.baseUrl, form.getAttribute("action") || `${deps.baseUrl}${ASSIGN_VIEW_PATH}`), fields: formFields(form), ...statementOf(form) };
|
|
4554
|
+
}
|
|
4555
|
+
function parseReceiptPage(html, activityId, baseUrl) {
|
|
4556
|
+
const page = parseAssignmentHtml(html, activityId, baseUrl);
|
|
4557
|
+
const root = parse4(html);
|
|
4558
|
+
const files = /* @__PURE__ */ new Map();
|
|
4559
|
+
const add = (link2) => {
|
|
4560
|
+
const name = cleanText(link2.textContent);
|
|
4561
|
+
const href = link2.getAttribute("href") ?? "";
|
|
4562
|
+
if (name && !files.has(name.toLowerCase())) files.set(name.toLowerCase(), { name, ...href ? { url: resolveUrl(baseUrl, href) } : {} });
|
|
4563
|
+
};
|
|
4564
|
+
for (const link2 of root.querySelectorAll(".fileuploadsubmission a[href]")) add(link2);
|
|
4565
|
+
for (const link2 of tableCell(root, "File submissions")?.querySelectorAll("a[href]") ?? []) add(link2);
|
|
4566
|
+
return {
|
|
4567
|
+
id: activityId,
|
|
4568
|
+
name: page.name,
|
|
4569
|
+
...page.course_id ? { unit_id: page.course_id } : {},
|
|
4570
|
+
url: page.url,
|
|
4571
|
+
submission_status: page.submission_status,
|
|
4572
|
+
grading_status: page.grading_status,
|
|
4573
|
+
due: page.due_pretty || cleanText(tableCell(root, "Due date")?.textContent),
|
|
4574
|
+
time_remaining: page.time_remaining,
|
|
4575
|
+
last_modified: cleanText(tableCell(root, "Last modified")?.textContent),
|
|
4576
|
+
files: [...files.values()]
|
|
4577
|
+
};
|
|
4578
|
+
}
|
|
4579
|
+
function noticesOf(html) {
|
|
4580
|
+
const root = parse4(html);
|
|
4581
|
+
const texts = [];
|
|
4582
|
+
for (const node of root.querySelectorAll(".alert, .invalid-feedback, .form-control-feedback, .error, [data-fieldtype] .text-danger")) {
|
|
4583
|
+
for (const junk of node.querySelectorAll("button, .close")) junk.remove();
|
|
4584
|
+
const text2 = cleanText(node.textContent);
|
|
4585
|
+
if (text2 && !texts.includes(text2)) texts.push(text2);
|
|
4586
|
+
}
|
|
4587
|
+
return texts.join(" ");
|
|
4588
|
+
}
|
|
4589
|
+
function formWithAction(root, action) {
|
|
4590
|
+
for (const form of root.querySelectorAll("form")) {
|
|
4591
|
+
if (form.querySelectorAll("input[name=action]").some((input2) => input2.getAttribute("value") === action)) return form;
|
|
4592
|
+
}
|
|
4593
|
+
return null;
|
|
4594
|
+
}
|
|
4595
|
+
function formFields(form) {
|
|
4596
|
+
const fields2 = [];
|
|
4597
|
+
for (const element of form.querySelectorAll("input, textarea, select")) {
|
|
4598
|
+
const name = element.getAttribute("name");
|
|
4599
|
+
if (!name) continue;
|
|
4600
|
+
const tag = element.tagName.toLowerCase();
|
|
4601
|
+
if (tag === "textarea") {
|
|
4602
|
+
fields2.push([name, element.textContent]);
|
|
4603
|
+
continue;
|
|
4604
|
+
}
|
|
4605
|
+
if (tag === "select") {
|
|
4606
|
+
const options = element.querySelectorAll("option");
|
|
4607
|
+
const chosen = options.find((option) => option.hasAttribute("selected")) ?? options[0];
|
|
4608
|
+
if (chosen) fields2.push([name, chosen.getAttribute("value") ?? cleanText(chosen.textContent)]);
|
|
4609
|
+
continue;
|
|
4610
|
+
}
|
|
4611
|
+
const type = (element.getAttribute("type") ?? "text").toLowerCase();
|
|
4612
|
+
if (["submit", "button", "image", "file", "reset"].includes(type)) continue;
|
|
4613
|
+
if ((type === "checkbox" || type === "radio") && !element.hasAttribute("checked")) continue;
|
|
4614
|
+
fields2.push([name, element.getAttribute("value") ?? (type === "checkbox" ? "on" : "")]);
|
|
4615
|
+
}
|
|
4616
|
+
return fields2;
|
|
4617
|
+
}
|
|
4618
|
+
function statementOf(form) {
|
|
4619
|
+
const box = form.querySelector("input[name=submissionstatement]");
|
|
4620
|
+
if (!box) return {};
|
|
4621
|
+
const id2 = box.getAttribute("id");
|
|
4622
|
+
const label = (id2 ? form.querySelector(`label[for="${id2}"]`) : null) ?? box.closest("label") ?? box.parentNode?.querySelector("label") ?? null;
|
|
4623
|
+
const text2 = cleanText(label?.textContent).replace(/\s*Required\s*$/u, "").trim();
|
|
4624
|
+
return { statement: text2 || "Submission statement" };
|
|
4625
|
+
}
|
|
4626
|
+
function filemanagerOptions(html, itemid) {
|
|
4627
|
+
const pattern = /M\.form_filemanager\.init\(\s*Y\s*,\s*/gu;
|
|
4628
|
+
let match;
|
|
4629
|
+
while (match = pattern.exec(html)) {
|
|
4630
|
+
const json = balancedObject(html, match.index + match[0].length);
|
|
4631
|
+
if (!json) continue;
|
|
4632
|
+
try {
|
|
4633
|
+
const options = JSON.parse(json);
|
|
4634
|
+
if (isRecord4(options) && String(options.itemid) === itemid) return options;
|
|
4635
|
+
} catch {
|
|
4636
|
+
}
|
|
4637
|
+
}
|
|
4638
|
+
return null;
|
|
4639
|
+
}
|
|
4640
|
+
function balancedObject(text2, start) {
|
|
4641
|
+
if (text2[start] !== "{") return null;
|
|
4642
|
+
let depth = 0;
|
|
4643
|
+
let quoted = false;
|
|
4644
|
+
for (let index = start; index < text2.length; index += 1) {
|
|
4645
|
+
const char = text2[index];
|
|
4646
|
+
if (quoted) {
|
|
4647
|
+
if (char === "\\") index += 1;
|
|
4648
|
+
else if (char === '"') quoted = false;
|
|
4649
|
+
} else if (char === '"') quoted = true;
|
|
4650
|
+
else if (char === "{") depth += 1;
|
|
4651
|
+
else if (char === "}") {
|
|
4652
|
+
depth -= 1;
|
|
4653
|
+
if (depth === 0) return text2.slice(start, index + 1);
|
|
4654
|
+
}
|
|
4655
|
+
}
|
|
4656
|
+
return null;
|
|
4657
|
+
}
|
|
4658
|
+
function tableCell(root, label) {
|
|
4659
|
+
for (const row of root.querySelectorAll("tr")) {
|
|
4660
|
+
const cells = row.querySelectorAll("th, td");
|
|
4661
|
+
if (cells.length > 1 && cleanText(cells[0].textContent) === label) return cells[1];
|
|
4662
|
+
}
|
|
4663
|
+
return null;
|
|
4664
|
+
}
|
|
4665
|
+
async function pageText(deps, url) {
|
|
4666
|
+
return (await deps.request(url)).text();
|
|
4667
|
+
}
|
|
4668
|
+
async function postForm(deps, action, fields2) {
|
|
4669
|
+
const response = await deps.request(action, {
|
|
4670
|
+
method: "POST",
|
|
4671
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
4672
|
+
body: new URLSearchParams(fields2).toString()
|
|
4673
|
+
});
|
|
4674
|
+
const html = await response.text();
|
|
4675
|
+
return landedOnView(response.url) ? null : html;
|
|
4676
|
+
}
|
|
4677
|
+
function landedOnView(url) {
|
|
4678
|
+
try {
|
|
4679
|
+
const parsed = new URL(url);
|
|
4680
|
+
return parsed.pathname.endsWith(ASSIGN_VIEW_PATH) && (parsed.searchParams.get("action") ?? "view") === "view";
|
|
4681
|
+
} catch {
|
|
4682
|
+
return false;
|
|
4683
|
+
}
|
|
4684
|
+
}
|
|
4685
|
+
async function draftAjax(deps, form, action, params) {
|
|
4686
|
+
const body = new URLSearchParams({ sesskey: form.sesskey, client_id: form.clientId, itemid: form.itemid, ...params });
|
|
4687
|
+
const response = await deps.request(`${deps.baseUrl}/repository/draftfiles_ajax.php?action=${action}`, {
|
|
4688
|
+
method: "POST",
|
|
4689
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
4690
|
+
body: body.toString()
|
|
4691
|
+
}, { allowErrorStatus: true });
|
|
4692
|
+
return jsonOf(deps, response, `draft file ${action}`);
|
|
4693
|
+
}
|
|
4694
|
+
async function listDraftFiles(deps, form) {
|
|
4695
|
+
const data = record2(await draftAjax(deps, form, "list", { filepath: "/" }));
|
|
4696
|
+
const list2 = Array.isArray(data.list) ? data.list.map(record2) : [];
|
|
4697
|
+
return list2.filter((item) => item.type !== "folder").map((item) => ({ name: String(item.filename ?? item.fullname ?? ""), path: String(item.filepath ?? "/"), bytes: integer(item.size) })).filter((item) => item.name);
|
|
4698
|
+
}
|
|
4699
|
+
async function deleteDraftFiles(deps, form, files) {
|
|
4700
|
+
const selected = JSON.stringify(files.map((file2) => ({ filename: file2.name, filepath: file2.path })));
|
|
4701
|
+
const result = await draftAjax(deps, form, "deleteselected", { selected });
|
|
4702
|
+
if (result === false) throw deps.fail("Moodle did not remove the existing submission files.");
|
|
4703
|
+
}
|
|
4704
|
+
async function uploadDraftFile(deps, form, file2) {
|
|
4705
|
+
const body = new FormData();
|
|
4706
|
+
body.set("sesskey", form.sesskey);
|
|
4707
|
+
body.set("client_id", form.clientId);
|
|
4708
|
+
body.set("repo_id", form.repoId);
|
|
4709
|
+
body.set("itemid", form.itemid);
|
|
4710
|
+
body.set("env", "filemanager");
|
|
4711
|
+
body.set("ctx_id", form.contextId);
|
|
4712
|
+
body.set("title", file2.name);
|
|
4713
|
+
body.set("author", form.author);
|
|
4714
|
+
body.set("license", form.license);
|
|
4715
|
+
body.set("savepath", "/");
|
|
4716
|
+
body.set("maxbytes", String(form.maxBytes));
|
|
4717
|
+
body.set("areamaxbytes", String(form.areaMaxBytes));
|
|
4718
|
+
for (const type of form.acceptedTypes === "*" ? ["*"] : form.acceptedTypes) body.append("accepted_types[]", type);
|
|
4719
|
+
body.set("overwrite", "1");
|
|
4720
|
+
body.set("repo_upload_file", new Blob([Uint8Array.from(file2.bytes)]), file2.name);
|
|
4721
|
+
const response = await deps.request(`${deps.baseUrl}/repository/repository_ajax.php?action=upload`, { method: "POST", body }, { allowErrorStatus: true });
|
|
4722
|
+
const data = record2(await jsonOf(deps, response, `upload of ${file2.name}`));
|
|
4723
|
+
if (typeof data.error === "string" && data.error) throw deps.fail(`Moodle refused ${file2.name}: ${data.error}`, typeof data.errorcode === "string" ? data.errorcode : void 0);
|
|
4724
|
+
if (data.event === "fileexists") throw deps.fail(`Moodle reports ${file2.name} already exists and did not overwrite it.`);
|
|
4725
|
+
const stored = typeof data.file === "string" && data.file ? data.file : file2.name;
|
|
4726
|
+
if (!data.url && !data.id && !data.file) throw deps.fail(`Moodle did not confirm the upload of ${file2.name}.`);
|
|
4727
|
+
return stored;
|
|
4728
|
+
}
|
|
4729
|
+
async function jsonOf(deps, response, step) {
|
|
4730
|
+
const text2 = await response.text();
|
|
4731
|
+
try {
|
|
4732
|
+
return JSON.parse(text2);
|
|
4733
|
+
} catch {
|
|
4734
|
+
const notice = noticesOf(text2);
|
|
4735
|
+
throw deps.fail(`Moodle did not answer the ${step} with JSON (HTTP ${response.status})${notice ? `: ${notice}` : ""}`);
|
|
4736
|
+
}
|
|
4737
|
+
}
|
|
4738
|
+
function checkLimits(deps, form, kept, files) {
|
|
4739
|
+
if (form.maxFiles > 0 && kept.length + files.length > form.maxFiles) {
|
|
4740
|
+
throw deps.usage(`This assignment allows ${form.maxFiles} file${form.maxFiles === 1 ? "" : "s"}; the submission would hold ${kept.length + files.length}.`, kept.length ? "Use --replace to drop the existing files first." : void 0);
|
|
4741
|
+
}
|
|
4742
|
+
for (const file2 of files) {
|
|
4743
|
+
if (form.maxBytes > 0 && file2.bytes.byteLength > form.maxBytes) throw deps.usage(`${file2.name} is ${size(file2.bytes.byteLength)}; the limit is ${size(form.maxBytes)}.`);
|
|
4744
|
+
if (form.acceptedTypes !== "*" && form.acceptedTypes.every((type) => type.startsWith(".")) && !form.acceptedTypes.some((type) => file2.name.toLowerCase().endsWith(type.toLowerCase()))) {
|
|
4745
|
+
throw deps.usage(`${file2.name} is not an accepted type; allowed: ${form.acceptedTypes.join(", ")}.`);
|
|
4746
|
+
}
|
|
4747
|
+
}
|
|
4748
|
+
const total = kept.reduce((sum, file2) => sum + file2.bytes, 0) + files.reduce((sum, file2) => sum + file2.bytes.byteLength, 0);
|
|
4749
|
+
if (form.areaMaxBytes > 0 && total > form.areaMaxBytes) throw deps.usage(`The submission would total ${size(total)}; the limit is ${size(form.areaMaxBytes)}.`, kept.length ? "Use --replace to drop the existing files first." : void 0);
|
|
4750
|
+
}
|
|
4751
|
+
function describeLimits(form) {
|
|
4752
|
+
return {
|
|
4753
|
+
...form.maxBytes > 0 ? { max_bytes: form.maxBytes } : {},
|
|
4754
|
+
...form.maxFiles > 0 ? { max_files: form.maxFiles } : {},
|
|
4755
|
+
...form.areaMaxBytes > 0 ? { area_max_bytes: form.areaMaxBytes } : {},
|
|
4756
|
+
...form.acceptedTypes !== "*" ? { accepted_types: form.acceptedTypes } : {}
|
|
4757
|
+
};
|
|
4758
|
+
}
|
|
4759
|
+
function isSubmitted(status) {
|
|
4760
|
+
return /\bsubmitted\b/iu.test(status) && !/\bnot submitted\b/iu.test(status);
|
|
4761
|
+
}
|
|
4762
|
+
function size(bytes) {
|
|
4763
|
+
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
|
|
4764
|
+
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
|
|
4765
|
+
return `${bytes} B`;
|
|
4766
|
+
}
|
|
4767
|
+
function timestamp(deps) {
|
|
4768
|
+
return (deps.now?.() ?? /* @__PURE__ */ new Date()).toISOString();
|
|
4769
|
+
}
|
|
4770
|
+
function integer(value) {
|
|
4771
|
+
const number = Number(value);
|
|
4772
|
+
return Number.isSafeInteger(number) ? number : 0;
|
|
4773
|
+
}
|
|
4774
|
+
function isRecord4(value) {
|
|
4775
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4776
|
+
}
|
|
4777
|
+
function record2(value) {
|
|
4778
|
+
return isRecord4(value) ? value : {};
|
|
4779
|
+
}
|
|
4780
|
+
|
|
4781
|
+
// src/parsers.ts
|
|
4782
|
+
function schema(parser) {
|
|
4783
|
+
return { parse: parser };
|
|
4784
|
+
}
|
|
4785
|
+
var UserInfoSchema = schema(parseUserInfo);
|
|
4786
|
+
var CourseSchema = schema(parseCourse);
|
|
4787
|
+
var CoursesSchema = schema(parseCourses);
|
|
4788
|
+
var ActivitySchema = schema(parseActivity);
|
|
4789
|
+
var SectionSchema = schema(parseSection);
|
|
4790
|
+
var CourseContentsSchema = schema(parseCourseContents);
|
|
4791
|
+
var TodoItemSchema = schema(parseTodoItem);
|
|
4792
|
+
function parseUserInfo(value) {
|
|
4793
|
+
const data = asRecord(value);
|
|
4794
|
+
return {
|
|
4795
|
+
userid: numberValue2(data.userid),
|
|
4796
|
+
username: stringValue2(data.username),
|
|
4797
|
+
fullname: stringValue2(data.fullname),
|
|
4798
|
+
sitename: stringValue2(data.sitename),
|
|
4799
|
+
siteurl: stringValue2(data.siteurl),
|
|
4800
|
+
lang: stringValue2(data.lang),
|
|
4801
|
+
...data.timezone ? { timezone: String(data.timezone) } : {}
|
|
4802
|
+
};
|
|
4803
|
+
}
|
|
4804
|
+
function parseCourse(value, nowSeconds = Math.floor(Date.now() / 1e3)) {
|
|
4805
|
+
const data = asRecord(value);
|
|
4806
|
+
const course = {
|
|
4807
|
+
id: numberValue2(data.id),
|
|
4808
|
+
shortname: stringValue2(data.shortname),
|
|
4809
|
+
fullname: stringValue2(data.fullname),
|
|
4810
|
+
category: numberValue2(data.category),
|
|
4811
|
+
visible: booleanValue(data.visible, true),
|
|
4812
|
+
startdate: numberValue2(data.startdate)
|
|
4813
|
+
};
|
|
4814
|
+
const enddate = numberValue2(data.enddate);
|
|
4815
|
+
if (enddate > 0) {
|
|
4816
|
+
course.enddate = enddate;
|
|
4817
|
+
}
|
|
4818
|
+
return course;
|
|
4819
|
+
}
|
|
4820
|
+
function parseCourses(value) {
|
|
4821
|
+
return asArray(value).map((item) => parseCourse(item));
|
|
4822
|
+
}
|
|
4823
|
+
function parseActivity(value) {
|
|
4824
|
+
const data = asRecord(value);
|
|
4825
|
+
return {
|
|
4826
|
+
id: numberValue2(data.id),
|
|
4827
|
+
name: stringValue2(data.name),
|
|
4828
|
+
modname: stringValue2(data.modname),
|
|
4829
|
+
url: stringValue2(data.url),
|
|
4830
|
+
visible: booleanValue(data.visible, true),
|
|
4831
|
+
description: stringValue2(data.description),
|
|
4832
|
+
...data.completiondata && typeof data.completiondata === "object" ? { completion: numberValue2(asRecord(data.completiondata).state) } : {},
|
|
4833
|
+
...Array.isArray(data.contents) ? { file_entries: data.contents.filter((f) => asRecord(f).fileurl).map((f) => ({ name: stringValue2(asRecord(f).filename), url: stringValue2(asRecord(f).fileurl), requires_authentication: true })) } : {}
|
|
4834
|
+
};
|
|
4835
|
+
}
|
|
4836
|
+
function parseSection(value) {
|
|
4837
|
+
const data = asRecord(value);
|
|
4838
|
+
return {
|
|
4839
|
+
id: numberValue2(data.id),
|
|
4840
|
+
name: stringValue2(data.name),
|
|
4841
|
+
section: numberValue2(data.section),
|
|
4842
|
+
visible: booleanValue(data.visible, true),
|
|
4843
|
+
summary: stringValue2(data.summary),
|
|
4844
|
+
...data.current !== void 0 ? { current: booleanValue(data.current) } : {},
|
|
4845
|
+
activities: asArray(data.modules).map((item) => parseActivity(item))
|
|
4846
|
+
};
|
|
4847
|
+
}
|
|
4848
|
+
function parseCourseContents(value) {
|
|
4849
|
+
return asArray(value).map((item) => parseSection(item));
|
|
4850
|
+
}
|
|
4851
|
+
function parseCourseFormatState(value, baseUrl) {
|
|
4852
|
+
const state = asRecord(parseJsonValue(value));
|
|
4853
|
+
const activities = /* @__PURE__ */ new Map();
|
|
4854
|
+
const activitiesBySection = /* @__PURE__ */ new Map();
|
|
4855
|
+
for (const item of asArray(state.cm)) {
|
|
4856
|
+
const data = asRecord(item);
|
|
4857
|
+
const id2 = numberValue2(data.id);
|
|
4858
|
+
const sectionId = stringValue2(data.sectionid);
|
|
4859
|
+
const module = stringValue2(data.module) || stringValue2(data.plugin).replace(/^mod_/u, "") || stringValue2(data.modname).toLowerCase();
|
|
4860
|
+
const activity = {
|
|
4861
|
+
id: id2,
|
|
4862
|
+
name: htmlText(data.name, baseUrl),
|
|
4863
|
+
modname: module.toLowerCase(),
|
|
4864
|
+
url: stringValue2(data.url) ? resolveUrl(baseUrl, stringValue2(data.url)) : "",
|
|
4865
|
+
visible: booleanValue(data.visible, true) && booleanValue(data.uservisible, true) && !booleanValue(data.stealth),
|
|
4866
|
+
description: htmlText(data.content ?? data.description, baseUrl),
|
|
4867
|
+
...data.completionstate !== void 0 && data.completionstate !== null ? { completion: numberValue2(data.completionstate) } : {}
|
|
4868
|
+
};
|
|
4869
|
+
activities.set(String(id2), activity);
|
|
4870
|
+
const sectionActivities = activitiesBySection.get(sectionId) ?? [];
|
|
4871
|
+
sectionActivities.push(activity);
|
|
4872
|
+
activitiesBySection.set(sectionId, sectionActivities);
|
|
4873
|
+
}
|
|
4874
|
+
return asArray(state.section).map((item) => {
|
|
4875
|
+
const data = asRecord(item);
|
|
4876
|
+
const id2 = numberValue2(data.id);
|
|
4877
|
+
const hasActivityList = Array.isArray(data.cmlist);
|
|
4878
|
+
const listedActivities = asArray(data.cmlist).map((activityId) => activities.get(stringValue2(activityId))).filter((activity) => activity !== void 0);
|
|
4879
|
+
return {
|
|
4880
|
+
id: id2,
|
|
4881
|
+
name: htmlText(data.title || data.rawtitle, baseUrl),
|
|
4882
|
+
section: numberValue2(data.section ?? data.number),
|
|
4883
|
+
visible: booleanValue(data.visible, true),
|
|
4884
|
+
summary: htmlText(data.summary, baseUrl),
|
|
4885
|
+
...data.current !== void 0 ? { current: booleanValue(data.current) } : {},
|
|
4886
|
+
activities: hasActivityList ? listedActivities : activitiesBySection.get(String(id2)) ?? []
|
|
4887
|
+
};
|
|
4888
|
+
});
|
|
4889
|
+
}
|
|
4890
|
+
function parseTodoItem(value) {
|
|
4891
|
+
const data = asRecord(value);
|
|
4892
|
+
const course = asRecord(data.course);
|
|
4893
|
+
const action = asRecord(data.action);
|
|
4894
|
+
const progress = course.progress;
|
|
4895
|
+
return {
|
|
4896
|
+
id: numberValue2(data.id),
|
|
4897
|
+
name: stringValue2(data.name),
|
|
4898
|
+
activity_name: stringValue2(data.activityname),
|
|
4899
|
+
modname: stringValue2(data.modulename),
|
|
4900
|
+
course_id: numberValue2(course.id),
|
|
4901
|
+
course_name: stringValue2(course.fullname),
|
|
4902
|
+
due_at: numberValue2(data.timesort) || numberValue2(data.timestart),
|
|
4903
|
+
overdue: booleanValue(data.overdue),
|
|
4904
|
+
actionable: booleanValue(action.actionable),
|
|
4905
|
+
action_name: stringValue2(action.name),
|
|
4906
|
+
action_url: stringValue2(action.url),
|
|
4907
|
+
url: stringValue2(data.url),
|
|
4908
|
+
event_type: stringValue2(data.eventtype),
|
|
4909
|
+
course_progress: typeof progress === "number" ? progress : void 0
|
|
4910
|
+
};
|
|
4911
|
+
}
|
|
4912
|
+
function parseTodoItems(value) {
|
|
4913
|
+
return asArray(value).map((item) => parseTodoItem(item));
|
|
4914
|
+
}
|
|
4915
|
+
function parseAlertNotification(value) {
|
|
4916
|
+
const data = asRecord(value);
|
|
4917
|
+
return {
|
|
4918
|
+
id: numberValue2(data.id),
|
|
4919
|
+
subject: stringValue2(data.subject),
|
|
4920
|
+
short_subject: stringValue2(data.shortenedsubject),
|
|
4921
|
+
event_type: stringValue2(data.eventtype),
|
|
4922
|
+
component: stringValue2(data.component),
|
|
4923
|
+
created_at: numberValue2(data.timecreated),
|
|
4924
|
+
created_pretty: stringValue2(data.timecreatedpretty),
|
|
4925
|
+
read: booleanValue(data.read),
|
|
4926
|
+
context_url: stringValue2(data.contexturl),
|
|
4927
|
+
context_name: stringValue2(data.contexturlname)
|
|
4928
|
+
};
|
|
4929
|
+
}
|
|
4930
|
+
function parseAlertSummary(notificationsData, countsData, unreadCountsData) {
|
|
4931
|
+
const notificationsRecord = asRecord(notificationsData);
|
|
4932
|
+
const counts2 = asRecord(countsData);
|
|
4933
|
+
const unreadCounts = asRecord(unreadCountsData);
|
|
4934
|
+
const types = asRecord(counts2.types);
|
|
4935
|
+
const unreadTypes = asRecord(unreadCounts.types);
|
|
4936
|
+
const notifications = asArray(notificationsRecord.notifications).map((item) => parseAlertNotification(item));
|
|
4937
|
+
return {
|
|
4938
|
+
notifications,
|
|
4939
|
+
notification_count: notifications.length,
|
|
4940
|
+
unread_notification_count: notifications.filter((notification) => !notification.read).length,
|
|
4941
|
+
starred_message_count: numberValue2(counts2.favourites),
|
|
4942
|
+
direct_message_count: numberValue2(types["1"]),
|
|
4943
|
+
group_message_count: numberValue2(types["2"]),
|
|
4944
|
+
self_message_count: numberValue2(types["3"]),
|
|
4945
|
+
unread_starred_message_count: numberValue2(unreadCounts.favourites),
|
|
4946
|
+
unread_direct_message_count: numberValue2(unreadTypes["1"]),
|
|
4947
|
+
unread_group_message_count: numberValue2(unreadTypes["2"]),
|
|
4948
|
+
unread_self_message_count: numberValue2(unreadTypes["3"])
|
|
4949
|
+
};
|
|
4950
|
+
}
|
|
4951
|
+
function parseForumPostAuthor(value) {
|
|
4952
|
+
const data = asRecord(value);
|
|
4953
|
+
const urls = asRecord(data.urls);
|
|
4954
|
+
return {
|
|
4955
|
+
id: numberValue2(data.id),
|
|
4956
|
+
fullname: stringValue2(data.fullname),
|
|
4957
|
+
profile_url: stringValue2(urls.profile),
|
|
4958
|
+
profile_image_url: stringValue2(urls.profileimage)
|
|
4959
|
+
};
|
|
4960
|
+
}
|
|
4961
|
+
function parseForumPost(value, baseUrl = "") {
|
|
4962
|
+
const data = asRecord(value);
|
|
4963
|
+
const urls = asRecord(data.urls);
|
|
4964
|
+
const messageHtml = stringValue2(data.message);
|
|
4965
|
+
const structured = htmlToStructuredContent(messageHtml, stringValue2(urls.view || urls.discuss) || baseUrl);
|
|
4966
|
+
return {
|
|
4967
|
+
id: numberValue2(data.id),
|
|
4968
|
+
discussion_id: numberValue2(data.discussionid),
|
|
4969
|
+
subject: stringValue2(data.subject),
|
|
4970
|
+
message_html: messageHtml,
|
|
4971
|
+
message_text: structured.text,
|
|
4972
|
+
image_urls: structured.image_urls,
|
|
4973
|
+
links: structured.links,
|
|
4974
|
+
tables: structured.tables,
|
|
4975
|
+
author: parseForumPostAuthor(data.author),
|
|
4976
|
+
parent_id: numberValue2(data.parentid),
|
|
4977
|
+
time_created: numberValue2(data.timecreated),
|
|
4978
|
+
time_modified: numberValue2(data.timemodified),
|
|
4979
|
+
created_pretty: "",
|
|
4980
|
+
unread: booleanValue(data.unread),
|
|
4981
|
+
is_deleted: booleanValue(data.isdeleted),
|
|
4982
|
+
is_private_reply: booleanValue(data.isprivatereply),
|
|
4983
|
+
url: stringValue2(urls.view || urls.viewisolated),
|
|
4984
|
+
reply_url: stringValue2(urls.reply)
|
|
4985
|
+
};
|
|
3916
4986
|
}
|
|
3917
|
-
function
|
|
3918
|
-
const
|
|
3919
|
-
|
|
3920
|
-
|
|
3921
|
-
|
|
3922
|
-
|
|
3923
|
-
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
|
|
4987
|
+
function parseForumDiscussion(value, discussionId, baseUrl = "") {
|
|
4988
|
+
const data = asRecord(value);
|
|
4989
|
+
const posts = asArray(data.posts).map((item) => parseForumPost(item, baseUrl));
|
|
4990
|
+
return {
|
|
4991
|
+
id: discussionId,
|
|
4992
|
+
subject: posts[0]?.subject ?? "",
|
|
4993
|
+
course_id: numberValue2(data.courseid),
|
|
4994
|
+
forum_id: numberValue2(data.forumid),
|
|
4995
|
+
group_id: numberValue2(data.groupid),
|
|
4996
|
+
group_name: stringValue2(data.groupname),
|
|
4997
|
+
url: posts[0]?.url ? posts[0].url.split("#", 1)[0] : "",
|
|
4998
|
+
posts
|
|
4999
|
+
};
|
|
3927
5000
|
}
|
|
3928
|
-
function
|
|
3929
|
-
|
|
3930
|
-
for (const row of root.querySelectorAll("tr")) {
|
|
3931
|
-
const cells = row.querySelectorAll("th, td");
|
|
3932
|
-
if (cleanNodeText(cells[0]) === label) {
|
|
3933
|
-
return cleanTableCell(cells[1]);
|
|
3934
|
-
}
|
|
3935
|
-
}
|
|
3936
|
-
return "";
|
|
5001
|
+
function asRecord(value) {
|
|
5002
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
3937
5003
|
}
|
|
3938
|
-
function
|
|
3939
|
-
|
|
3940
|
-
return "";
|
|
3941
|
-
}
|
|
3942
|
-
const clone = parse3(node.toString());
|
|
3943
|
-
for (const unwanted of clone.querySelectorAll(".action-menu, .dropdown, script, style")) {
|
|
3944
|
-
unwanted.remove();
|
|
3945
|
-
}
|
|
3946
|
-
return cleanText(clone.textContent.replace("( Empty )", "(Empty)"));
|
|
5004
|
+
function asArray(value) {
|
|
5005
|
+
return Array.isArray(value) ? value : [];
|
|
3947
5006
|
}
|
|
3948
|
-
function
|
|
3949
|
-
|
|
3950
|
-
return numericQueryValue(new URL(href, "https://moodle.invalid"), key);
|
|
3951
|
-
} catch {
|
|
3952
|
-
return null;
|
|
3953
|
-
}
|
|
5007
|
+
function stringValue2(value) {
|
|
5008
|
+
return typeof value === "string" ? value : value == null ? "" : String(value);
|
|
3954
5009
|
}
|
|
3955
5010
|
function numberValue2(value) {
|
|
3956
5011
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
@@ -3961,18 +5016,22 @@ function numberValue2(value) {
|
|
|
3961
5016
|
}
|
|
3962
5017
|
return 0;
|
|
3963
5018
|
}
|
|
3964
|
-
function
|
|
3965
|
-
|
|
5019
|
+
function booleanValue(value, defaultValue = false) {
|
|
5020
|
+
if (value === void 0 || value === null) {
|
|
5021
|
+
return defaultValue;
|
|
5022
|
+
}
|
|
5023
|
+
return Boolean(value);
|
|
3966
5024
|
}
|
|
3967
|
-
function
|
|
3968
|
-
return
|
|
3969
|
-
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
|
|
5025
|
+
function parseJsonValue(value) {
|
|
5026
|
+
if (typeof value !== "string") return value;
|
|
5027
|
+
try {
|
|
5028
|
+
return JSON.parse(value);
|
|
5029
|
+
} catch {
|
|
5030
|
+
return {};
|
|
5031
|
+
}
|
|
3973
5032
|
}
|
|
3974
|
-
function
|
|
3975
|
-
return
|
|
5033
|
+
function htmlText(value, baseUrl) {
|
|
5034
|
+
return htmlToStructuredContent(stringValue2(value), baseUrl).text;
|
|
3976
5035
|
}
|
|
3977
5036
|
|
|
3978
5037
|
// src/moodle-forum-core.ts
|
|
@@ -4109,13 +5168,13 @@ var ForumModule = class {
|
|
|
4109
5168
|
}
|
|
4110
5169
|
};
|
|
4111
5170
|
function shouldFallbackForumAjax(error) {
|
|
4112
|
-
if (!
|
|
5171
|
+
if (!isRecord5(error)) {
|
|
4113
5172
|
return false;
|
|
4114
5173
|
}
|
|
4115
5174
|
const code = typeof error.moodleErrorCode === "string" ? error.moodleErrorCode : "";
|
|
4116
5175
|
return code === "servicenotavailable" || code === "accessexception" || error instanceof Error && error.message.includes("Web service is not available");
|
|
4117
5176
|
}
|
|
4118
|
-
function
|
|
5177
|
+
function isRecord5(value) {
|
|
4119
5178
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4120
5179
|
}
|
|
4121
5180
|
|
|
@@ -4247,9 +5306,9 @@ async function searchForumContent(source, query, options = {}) {
|
|
|
4247
5306
|
hits.sort(sortBy === "recent" ? sortRecent : sortRelevant);
|
|
4248
5307
|
return hits.slice(0, options.limit ?? 20).map(([, hit]) => includePostText ? hit : { ...hit, snippet: "" });
|
|
4249
5308
|
}
|
|
4250
|
-
async function inParallel2(items,
|
|
5309
|
+
async function inParallel2(items, size2, run) {
|
|
4251
5310
|
const results = [];
|
|
4252
|
-
for (let index = 0; index < items.length; index +=
|
|
5311
|
+
for (let index = 0; index < items.length; index += size2) results.push(...await Promise.all(items.slice(index, index + size2).map(run)));
|
|
4253
5312
|
return results;
|
|
4254
5313
|
}
|
|
4255
5314
|
function normalizeQuery(value) {
|
|
@@ -4425,7 +5484,7 @@ var MoodleClientCore = class {
|
|
|
4425
5484
|
await this.ensureSession();
|
|
4426
5485
|
return this.call(functionName, args);
|
|
4427
5486
|
},
|
|
4428
|
-
getPage: (
|
|
5487
|
+
getPage: (path5, params) => this.get(path5, params),
|
|
4429
5488
|
getCourses: () => this.getCourses(),
|
|
4430
5489
|
getCourseContents: (courseId) => this.getCourseContents(courseId)
|
|
4431
5490
|
});
|
|
@@ -4435,7 +5494,7 @@ var MoodleClientCore = class {
|
|
|
4435
5494
|
try {
|
|
4436
5495
|
if (this.unavailable.has(FUNC_GET_SITE_INFO) && this.userInfo?.fullname) return this.userInfo;
|
|
4437
5496
|
const data = await this.call(FUNC_GET_SITE_INFO);
|
|
4438
|
-
if (
|
|
5497
|
+
if (isRecord6(data) && "userid" in data) {
|
|
4439
5498
|
const info = parseUserInfo(data);
|
|
4440
5499
|
this.sesskey = typeof data.sesskey === "string" ? data.sesskey : this.sesskey;
|
|
4441
5500
|
this.userid = info.userid;
|
|
@@ -4534,9 +5593,9 @@ var MoodleClientCore = class {
|
|
|
4534
5593
|
let type = "";
|
|
4535
5594
|
try {
|
|
4536
5595
|
const data = await this.call(FUNC_GET_COURSE_MODULE, { cmid: id2 });
|
|
4537
|
-
const module =
|
|
4538
|
-
type =
|
|
4539
|
-
courseId =
|
|
5596
|
+
const module = isRecord6(data) && isRecord6(data.cm) ? data.cm : data;
|
|
5597
|
+
type = isRecord6(module) && typeof module.modname === "string" ? module.modname : "";
|
|
5598
|
+
courseId = isRecord6(module) && typeof module.course === "number" ? module.course : void 0;
|
|
4540
5599
|
} catch (error) {
|
|
4541
5600
|
if (!this.errors.isApi(error) || error.moodleErrorCode !== "servicenotavailable") {
|
|
4542
5601
|
throw error;
|
|
@@ -4580,14 +5639,14 @@ var MoodleClientCore = class {
|
|
|
4580
5639
|
} else {
|
|
4581
5640
|
data = await this.call(FUNC_GET_ACTION_EVENTS, { ...window, limittononsuspendedevents: true });
|
|
4582
5641
|
}
|
|
4583
|
-
const events =
|
|
5642
|
+
const events = isRecord6(data) && Array.isArray(data.events) ? data.events : [];
|
|
4584
5643
|
for (const item of parseTodoItems(events)) if (!seen.has(item.id)) {
|
|
4585
5644
|
seen.add(item.id);
|
|
4586
5645
|
items.push(item);
|
|
4587
5646
|
}
|
|
4588
5647
|
if (events.length < batchSize) break;
|
|
4589
5648
|
const last = events.at(-1);
|
|
4590
|
-
const next =
|
|
5649
|
+
const next = isRecord6(last) && typeof last.id === "number" ? last.id : void 0;
|
|
4591
5650
|
if (!next || next === aftereventid) throw this.errors.api("Moodle repeated a calendar page; refine the date window.");
|
|
4592
5651
|
aftereventid = next;
|
|
4593
5652
|
}
|
|
@@ -4754,8 +5813,18 @@ var MoodleClientCore = class {
|
|
|
4754
5813
|
async getFolder(id2) {
|
|
4755
5814
|
return parseFolderHtml(await this.get(FOLDER_VIEW_PATH, { id: id2 }), id2, this.baseUrl);
|
|
4756
5815
|
}
|
|
4757
|
-
async requestAbsolute(url, init = {}) {
|
|
4758
|
-
return this.requestAbsoluteInternal(url, init, true);
|
|
5816
|
+
async requestAbsolute(url, init = {}, options = {}) {
|
|
5817
|
+
return this.requestAbsoluteInternal(url, init, true, Boolean(options.allowErrorStatus));
|
|
5818
|
+
}
|
|
5819
|
+
/** Uploads files into an assignment and reads the receipt back from the site. */
|
|
5820
|
+
async submitAssignment(request) {
|
|
5821
|
+
await this.ensureSession();
|
|
5822
|
+
return submitAssignmentFiles({
|
|
5823
|
+
baseUrl: this.baseUrl,
|
|
5824
|
+
request: (url, init, options) => this.requestAbsolute(url, init, options),
|
|
5825
|
+
fail: (message, moodleErrorCode) => this.errors.api(message, moodleErrorCode),
|
|
5826
|
+
usage: (message, hint) => this.errors.usage ? this.errors.usage(message, hint) : new MoodleClientCoreError("usage", message, hint)
|
|
5827
|
+
}, request);
|
|
4759
5828
|
}
|
|
4760
5829
|
async getNewsForums(courseId) {
|
|
4761
5830
|
const units = courseId === void 0 ? await this.getCourses() : (await this.getCourses()).filter((c) => c.id === courseId);
|
|
@@ -4765,7 +5834,7 @@ var MoodleClientCore = class {
|
|
|
4765
5834
|
await this.ensureSession();
|
|
4766
5835
|
const data = await this.call("mod_forum_get_forums_by_courses", { courseids: units.map((c) => c.id) });
|
|
4767
5836
|
for (const f of Array.isArray(data) ? data : []) {
|
|
4768
|
-
if (!
|
|
5837
|
+
if (!isRecord6(f) || f.type !== "news" || typeof f.cmid !== "number") continue;
|
|
4769
5838
|
const c = units.find((c2) => c2.id === f.course);
|
|
4770
5839
|
forums.push({ id: f.cmid, name: String(f.name || ""), course_id: Number(f.course), course_name: c?.fullname || "", url: `${this.baseUrl}/mod/forum/view.php?id=${f.cmid}` });
|
|
4771
5840
|
}
|
|
@@ -4893,16 +5962,16 @@ var MoodleClientCore = class {
|
|
|
4893
5962
|
async getAbsolute(url) {
|
|
4894
5963
|
return (await this.requestAbsolute(url)).text();
|
|
4895
5964
|
}
|
|
4896
|
-
async requestAbsoluteInternal(url, init, allowRetry) {
|
|
5965
|
+
async requestAbsoluteInternal(url, init, allowRetry, allowErrorStatus = false) {
|
|
4897
5966
|
const response = await fetchWithSession(url, init, this.baseUrl, this.cookie, this.fetchImpl);
|
|
4898
5967
|
if (response.url.includes("/login/")) {
|
|
4899
5968
|
if (this.onLoginRequired && allowRetry && !this.retryingLogin) {
|
|
4900
5969
|
await this.reauthenticate();
|
|
4901
|
-
return this.requestAbsoluteInternal(url, init, false);
|
|
5970
|
+
return this.requestAbsoluteInternal(url, init, false, allowErrorStatus);
|
|
4902
5971
|
}
|
|
4903
5972
|
throw this.errors.api("Session expired", "servicerequireslogin");
|
|
4904
5973
|
}
|
|
4905
|
-
if (!response.ok) {
|
|
5974
|
+
if (!response.ok && !allowErrorStatus) {
|
|
4906
5975
|
const context = `HTTP ${response.status} loading ${safeUrl(url)}`;
|
|
4907
5976
|
const contentType3 = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
4908
5977
|
if (contentType3.includes("text/html") || contentType3.includes("application/xhtml+xml")) {
|
|
@@ -4920,7 +5989,7 @@ var MoodleClientCore = class {
|
|
|
4920
5989
|
let offset = 0;
|
|
4921
5990
|
while (true) {
|
|
4922
5991
|
const data = await this.call(FUNC_GET_COURSES_BY_TIMELINE, { classification: "all", limit: 100, offset });
|
|
4923
|
-
if (!
|
|
5992
|
+
if (!isRecord6(data) || !Array.isArray(data.courses) || !data.courses.length) {
|
|
4924
5993
|
break;
|
|
4925
5994
|
}
|
|
4926
5995
|
courses.push(...data.courses);
|
|
@@ -5080,19 +6149,19 @@ function placeholderUserInfo(baseUrl, userid) {
|
|
|
5080
6149
|
};
|
|
5081
6150
|
}
|
|
5082
6151
|
function parseTodoPayload(value) {
|
|
5083
|
-
return parseTodoItems(
|
|
6152
|
+
return parseTodoItems(isRecord6(value) && Array.isArray(value.events) ? value.events : []);
|
|
5084
6153
|
}
|
|
5085
6154
|
function errorMessage(value) {
|
|
5086
6155
|
return value instanceof Error ? value.message : "Unknown Moodle error";
|
|
5087
6156
|
}
|
|
5088
|
-
function chunks(values,
|
|
6157
|
+
function chunks(values, size2) {
|
|
5089
6158
|
const result = [];
|
|
5090
|
-
for (let index = 0; index < values.length; index +=
|
|
5091
|
-
result.push(values.slice(index, index +
|
|
6159
|
+
for (let index = 0; index < values.length; index += size2) {
|
|
6160
|
+
result.push(values.slice(index, index + size2));
|
|
5092
6161
|
}
|
|
5093
6162
|
return result;
|
|
5094
6163
|
}
|
|
5095
|
-
function
|
|
6164
|
+
function isRecord6(value) {
|
|
5096
6165
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
5097
6166
|
}
|
|
5098
6167
|
function isLoginErrorCode2(code) {
|
|
@@ -5114,10 +6183,30 @@ function safeUrl(value) {
|
|
|
5114
6183
|
}
|
|
5115
6184
|
}
|
|
5116
6185
|
|
|
6186
|
+
// src/submit.ts
|
|
6187
|
+
import { readFile as readFile6, stat as stat2 } from "fs/promises";
|
|
6188
|
+
import { homedir as homedir10 } from "os";
|
|
6189
|
+
import path from "path";
|
|
6190
|
+
function resolveSubmissionPath(given, cwd = process.cwd()) {
|
|
6191
|
+
return path.resolve(cwd, given.startsWith("~/") ? path.join(homedir10(), given.slice(2)) : given);
|
|
6192
|
+
}
|
|
6193
|
+
async function readSubmissionFiles(paths, cwd = process.cwd()) {
|
|
6194
|
+
const files = [];
|
|
6195
|
+
for (const given of paths) {
|
|
6196
|
+
const resolved = resolveSubmissionPath(given, cwd);
|
|
6197
|
+
const info = await stat2(resolved).catch(() => null);
|
|
6198
|
+
if (!info) throw new UsageError(`File not found: ${given}`);
|
|
6199
|
+
if (!info.isFile()) throw new UsageError(`Not a file: ${given}`, "Zip a folder before uploading it.");
|
|
6200
|
+
files.push({ name: path.basename(resolved), bytes: await readFile6(resolved), path: resolved });
|
|
6201
|
+
}
|
|
6202
|
+
return files;
|
|
6203
|
+
}
|
|
6204
|
+
|
|
5117
6205
|
// src/client.ts
|
|
5118
6206
|
var NODE_ERROR_ADAPTER = {
|
|
5119
6207
|
api: (message, moodleErrorCode) => new MoodleAPIError(message, moodleErrorCode),
|
|
5120
6208
|
notFound: (message) => new NotFoundError(message),
|
|
6209
|
+
usage: (message, hint) => new UsageError(message, hint),
|
|
5121
6210
|
isApi: (error) => error instanceof MoodleAPIError,
|
|
5122
6211
|
isLoginRequired: isLoginRequiredError
|
|
5123
6212
|
};
|
|
@@ -5126,6 +6215,11 @@ var MoodleClient = class extends MoodleClientCore {
|
|
|
5126
6215
|
const resolvedOptions = typeof options === "string" ? { cookie: { name: "MoodleSession", value: options } } : options;
|
|
5127
6216
|
super(baseUrl, { ...resolvedOptions, errorAdapter: NODE_ERROR_ADAPTER });
|
|
5128
6217
|
}
|
|
6218
|
+
/** Reads local files, then uploads them into the assignment. Only the Node client has a filesystem. */
|
|
6219
|
+
async submitAssignmentFiles(request) {
|
|
6220
|
+
const { files, cwd, ...rest } = request;
|
|
6221
|
+
return this.submitAssignment({ ...rest, files: await readSubmissionFiles(files, cwd) });
|
|
6222
|
+
}
|
|
5129
6223
|
};
|
|
5130
6224
|
async function createMoodleClient(baseUrl, options = {}) {
|
|
5131
6225
|
const cacheOptions2 = {
|
|
@@ -5153,7 +6247,7 @@ async function createMoodleClient(baseUrl, options = {}) {
|
|
|
5153
6247
|
});
|
|
5154
6248
|
}
|
|
5155
6249
|
}
|
|
5156
|
-
const stale = options.noCache ? null : await readCachedSession(baseUrl, { ...cacheOptions2,
|
|
6250
|
+
const stale = options.noCache ? null : await readCachedSession(baseUrl, { ...cacheOptions2, allowExpired: true }).catch(() => null);
|
|
5157
6251
|
const session = authToClientSession(await getAuthenticatedSession(baseUrl, authOptions));
|
|
5158
6252
|
return new MoodleClient(baseUrl, {
|
|
5159
6253
|
fetchImpl: options.fetchImpl,
|
|
@@ -5166,11 +6260,21 @@ async function createMoodleClient(baseUrl, options = {}) {
|
|
|
5166
6260
|
}
|
|
5167
6261
|
function persistenceCallbacks(baseUrl, options) {
|
|
5168
6262
|
return {
|
|
5169
|
-
clearSessionCache: () =>
|
|
5170
|
-
|
|
5171
|
-
...
|
|
5172
|
-
|
|
5173
|
-
|
|
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
|
+
}
|
|
5174
6278
|
};
|
|
5175
6279
|
}
|
|
5176
6280
|
function authToClientSession(auth) {
|
|
@@ -5234,6 +6338,35 @@ function formatDownloadReceipt(receipt) {
|
|
|
5234
6338
|
["Final URL", receipt.final_url]
|
|
5235
6339
|
], { title: "Download" });
|
|
5236
6340
|
}
|
|
6341
|
+
function formatSubmissionReceipt(receipt) {
|
|
6342
|
+
const size2 = (bytes) => bytes >= 1024 * 1024 ? `${(bytes / (1024 * 1024)).toFixed(1)} MiB` : bytes >= 1024 ? `${(bytes / 1024).toFixed(1)} KiB` : `${bytes} B`;
|
|
6343
|
+
const limits = [
|
|
6344
|
+
receipt.limits.max_files ? `${receipt.limits.max_files} files` : "",
|
|
6345
|
+
receipt.limits.max_bytes ? `${size2(receipt.limits.max_bytes)} each` : "",
|
|
6346
|
+
receipt.limits.area_max_bytes ? `${size2(receipt.limits.area_max_bytes)} total` : "",
|
|
6347
|
+
receipt.limits.accepted_types?.length ? receipt.limits.accepted_types.join(" ") : ""
|
|
6348
|
+
].filter(Boolean).join(", ");
|
|
6349
|
+
const table = renderKeyValueTable([
|
|
6350
|
+
["Assignment", receipt.name],
|
|
6351
|
+
["Unit id", receipt.unit_id ? String(receipt.unit_id) : ""],
|
|
6352
|
+
["URL", receipt.url],
|
|
6353
|
+
["Action", receipt.action],
|
|
6354
|
+
["Status", receipt.submission_status],
|
|
6355
|
+
["Grading", receipt.grading_status],
|
|
6356
|
+
["Due", receipt.due],
|
|
6357
|
+
["Time remaining", receipt.time_remaining],
|
|
6358
|
+
["Last modified", receipt.last_modified],
|
|
6359
|
+
[receipt.action === "planned" ? "Files now" : "Files", receipt.files.map((file2) => file2.bytes ? `${file2.name} (${size2(file2.bytes)})` : file2.name).join(", ")],
|
|
6360
|
+
["Uploads", receipt.uploads.map((file2) => `${file2.name} (${size2(file2.bytes)})`).join(", ")],
|
|
6361
|
+
["Removed", receipt.removed.join(", ")],
|
|
6362
|
+
["Statement", receipt.statement ? `${receipt.statement_accepted ? "accepted" : "not accepted"}: ${receipt.statement}` : ""],
|
|
6363
|
+
["Limits", limits],
|
|
6364
|
+
["Checked", receipt.checked_at]
|
|
6365
|
+
], { title: receipt.action === "planned" ? "Submission plan" : "Submission" });
|
|
6366
|
+
const note = receipt.action === "planned" ? "Plan only; nothing was uploaded. Re-run without --dry-run to upload." : receipt.action === "saved" && /draft|not submitted/iu.test(receipt.submission_status) ? "Saved as a draft. Re-run with --final to submit it for grading." : "";
|
|
6367
|
+
return note ? `${table}
|
|
6368
|
+
${note}` : table;
|
|
6369
|
+
}
|
|
5237
6370
|
function formatForumDiscussion(discussion, options = {}) {
|
|
5238
6371
|
const lines = [`Discussion: ${discussion.id}`];
|
|
5239
6372
|
if (discussion.subject) {
|
|
@@ -5351,10 +6484,10 @@ function formatTimestamp(value) {
|
|
|
5351
6484
|
import { createWriteStream } from "fs";
|
|
5352
6485
|
import { link, lstat, rename as rename2, unlink } from "fs/promises";
|
|
5353
6486
|
import { randomUUID } from "crypto";
|
|
5354
|
-
import
|
|
6487
|
+
import path2 from "path";
|
|
5355
6488
|
import { Readable, Transform } from "stream";
|
|
5356
6489
|
import { pipeline } from "stream/promises";
|
|
5357
|
-
import { parse as
|
|
6490
|
+
import { parse as parse5 } from "node-html-parser";
|
|
5358
6491
|
var ACCEPTED_SOURCE_HINT = "Use a positive resource activity ID, a same-site resource URL, or a same-site pluginfile URL.";
|
|
5359
6492
|
var FILE_SYSTEM_ERROR_CODES = /* @__PURE__ */ new Set([
|
|
5360
6493
|
"EACCES",
|
|
@@ -5374,14 +6507,14 @@ var FILE_SYSTEM_ERROR_CODES = /* @__PURE__ */ new Set([
|
|
|
5374
6507
|
]);
|
|
5375
6508
|
async function downloadMoodleFile(client, request, signal) {
|
|
5376
6509
|
throwIfCancelled(signal);
|
|
5377
|
-
const explicitDestination = request.destination ?
|
|
6510
|
+
const explicitDestination = request.destination ? path2.resolve(request.destination) : void 0;
|
|
5378
6511
|
if (explicitDestination && !request.force) {
|
|
5379
6512
|
await ensureDestinationAvailable(explicitDestination);
|
|
5380
6513
|
}
|
|
5381
6514
|
const resolved = await resolveDownload(client, request.source, signal);
|
|
5382
6515
|
throwIfCancelled(signal);
|
|
5383
|
-
const filename = explicitDestination ?
|
|
5384
|
-
const destination = explicitDestination ??
|
|
6516
|
+
const filename = explicitDestination ? path2.basename(explicitDestination) : chooseUpstreamFilename(resolved);
|
|
6517
|
+
const destination = explicitDestination ?? path2.resolve(request.directory ?? process.cwd(), filename);
|
|
5385
6518
|
if (!explicitDestination && !request.force) {
|
|
5386
6519
|
await ensureDestinationAvailable(destination);
|
|
5387
6520
|
}
|
|
@@ -5476,7 +6609,7 @@ async function responseOrWrapper(client, requestUrl, sourceUrl, targetName, sign
|
|
|
5476
6609
|
};
|
|
5477
6610
|
}
|
|
5478
6611
|
function resourceLinks2(html, baseUrl) {
|
|
5479
|
-
const root =
|
|
6612
|
+
const root = parse5(html);
|
|
5480
6613
|
const entries = root.querySelectorAll(".resourceworkaround a[href], .resourcecontent a[href], a.resourceworkaround[href]").map((linkNode) => ({
|
|
5481
6614
|
name: linkNode.textContent.trim(),
|
|
5482
6615
|
url: new URL(linkNode.getAttribute("href") ?? "", baseUrl).toString()
|
|
@@ -5492,7 +6625,7 @@ function isHtmlWrapper(response) {
|
|
|
5492
6625
|
return type.includes("text/html") || type.includes("application/xhtml+xml");
|
|
5493
6626
|
}
|
|
5494
6627
|
function looksLikeLoginPage3(html) {
|
|
5495
|
-
const root =
|
|
6628
|
+
const root = parse5(html);
|
|
5496
6629
|
return root.querySelector('form[action*="/login/"], input[name="password"], #page-login-index') !== null || /<title>\s*(?:log in|login)/iu.test(html);
|
|
5497
6630
|
}
|
|
5498
6631
|
function chooseUpstreamFilename(resolved) {
|
|
@@ -5553,7 +6686,7 @@ async function ensureDestinationAvailable(destination) {
|
|
|
5553
6686
|
throw new UsageError(`Destination already exists: ${destination}`, "Choose another --dest path or pass --force to replace this exact file.");
|
|
5554
6687
|
}
|
|
5555
6688
|
async function writeResponse(response, destination, force, signal) {
|
|
5556
|
-
const temporaryPath =
|
|
6689
|
+
const temporaryPath = path2.join(path2.dirname(destination), `.${path2.basename(destination)}.moodle-${randomUUID()}.tmp`);
|
|
5557
6690
|
let bytesWritten = 0;
|
|
5558
6691
|
const counter = new Transform({
|
|
5559
6692
|
transform(chunk, _encoding, callback) {
|
|
@@ -5625,7 +6758,7 @@ function isFileSystemError(error) {
|
|
|
5625
6758
|
// src/skills.ts
|
|
5626
6759
|
import { spawnSync as spawnSync3 } from "child_process";
|
|
5627
6760
|
import { mkdirSync, readFileSync, writeFileSync, rmSync } from "fs";
|
|
5628
|
-
import
|
|
6761
|
+
import path3 from "path";
|
|
5629
6762
|
|
|
5630
6763
|
// src/command-contract.ts
|
|
5631
6764
|
import { VERBS } from "@bunizao/cli-kit";
|
|
@@ -5682,7 +6815,7 @@ function isMutating(command) {
|
|
|
5682
6815
|
var SKILL_NAME = "moodle-cli";
|
|
5683
6816
|
var SKILL_SOURCE = "https://github.com/bunizao/moodle-cli";
|
|
5684
6817
|
var SKILLS_SPEC_URL = "https://github.com/vercel-labs/skills";
|
|
5685
|
-
var SKILL_DESCRIPTION = "Read Moodle units, deadlines, grades, announcements and files; diagnose sign-in and manage a private MCP server.";
|
|
6818
|
+
var SKILL_DESCRIPTION = "Read Moodle units, deadlines, grades, announcements and files; submit assignment files; diagnose sign-in and manage a private MCP server.";
|
|
5686
6819
|
var SKILL_BUNDLE_TEMPLATES = [
|
|
5687
6820
|
["SKILL.md", "skill.template.md"],
|
|
5688
6821
|
["references/setup-and-auth.md", "skill-references/setup-and-auth.md"],
|
|
@@ -5729,10 +6862,10 @@ function extractCommanderCommands(program) {
|
|
|
5729
6862
|
return describeProgram(program).commands.flatMap((command) => commandDescriptionRows(command));
|
|
5730
6863
|
}
|
|
5731
6864
|
function commandDescriptionRows(command, parentPath = []) {
|
|
5732
|
-
const
|
|
6865
|
+
const path5 = [...parentPath, command.name];
|
|
5733
6866
|
const row = {
|
|
5734
6867
|
name: command.name,
|
|
5735
|
-
path:
|
|
6868
|
+
path: path5,
|
|
5736
6869
|
description: command.description,
|
|
5737
6870
|
arguments: command.positionals.map((argument) => ({
|
|
5738
6871
|
name: argument.name,
|
|
@@ -5746,15 +6879,15 @@ function commandDescriptionRows(command, parentPath = []) {
|
|
|
5746
6879
|
return { name, alias, description: option.description, required: option.required };
|
|
5747
6880
|
})
|
|
5748
6881
|
};
|
|
5749
|
-
return [row, ...command.commands.flatMap((child) => commandDescriptionRows(child,
|
|
6882
|
+
return [row, ...command.commands.flatMap((child) => commandDescriptionRows(child, path5))];
|
|
5750
6883
|
}
|
|
5751
6884
|
function writeGeneratedSkill(program, target = "SKILL.md") {
|
|
5752
6885
|
const commands = extractCommanderCommands(program);
|
|
5753
|
-
const targetDir =
|
|
5754
|
-
for (const obsolete of ["profile-and-courses", "deadlines-and-alerts", "coursework-and-grades", "downloads", "forums", "output-and-errors", "maintenance"]) rmSync(
|
|
6886
|
+
const targetDir = path3.dirname(target);
|
|
6887
|
+
for (const obsolete of ["profile-and-courses", "deadlines-and-alerts", "coursework-and-grades", "downloads", "forums", "output-and-errors", "maintenance"]) rmSync(path3.join(targetDir, "references", `${obsolete}.md`), { force: true });
|
|
5755
6888
|
for (const [relativeTarget, relativeTemplate] of SKILL_BUNDLE_TEMPLATES) {
|
|
5756
|
-
const outputPath = relativeTarget === "SKILL.md" ? target :
|
|
5757
|
-
mkdirSync(
|
|
6889
|
+
const outputPath = relativeTarget === "SKILL.md" ? target : path3.join(targetDir, relativeTarget);
|
|
6890
|
+
mkdirSync(path3.dirname(outputPath), { recursive: true });
|
|
5758
6891
|
const template = readSkillTemplate(relativeTemplate);
|
|
5759
6892
|
writeFileSync(outputPath, renderSkillMarkdown(commands, template), "utf8");
|
|
5760
6893
|
}
|
|
@@ -5862,11 +6995,168 @@ function isCommandAvailable(name, runCommand) {
|
|
|
5862
6995
|
return !result.error && result.status === 0;
|
|
5863
6996
|
}
|
|
5864
6997
|
function readSkillTemplate(relativePath = "skill.template.md") {
|
|
5865
|
-
return readFileSync(
|
|
6998
|
+
return readFileSync(path3.join(process.cwd(), "src", relativePath), "utf8");
|
|
6999
|
+
}
|
|
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("");
|
|
5866
7156
|
}
|
|
5867
7157
|
|
|
5868
7158
|
// src/version.ts
|
|
5869
|
-
var VERSION = "0.
|
|
7159
|
+
var VERSION = "0.9.1";
|
|
5870
7160
|
|
|
5871
7161
|
// src/forum.ts
|
|
5872
7162
|
function parseDiscussionReference(value) {
|
|
@@ -5957,7 +7247,7 @@ function resolveTopLevelUrl(baseUrlOrOptions, targetValue, resolveCourseIdForUrl
|
|
|
5957
7247
|
if (parsed.host.toLowerCase() !== configuredHost) {
|
|
5958
7248
|
throw new UsageError(`URL host '${parsed.host.toLowerCase()}' does not match configured Moodle site '${configuredHost}'.`);
|
|
5959
7249
|
}
|
|
5960
|
-
const
|
|
7250
|
+
const path5 = parsed.pathname.replace(/\/$/, "");
|
|
5961
7251
|
const intParam = (key, label) => {
|
|
5962
7252
|
const value = parsed.searchParams.get(key);
|
|
5963
7253
|
if (!value || !/^\d+$/.test(value)) {
|
|
@@ -5965,45 +7255,45 @@ function resolveTopLevelUrl(baseUrlOrOptions, targetValue, resolveCourseIdForUrl
|
|
|
5965
7255
|
}
|
|
5966
7256
|
return value;
|
|
5967
7257
|
};
|
|
5968
|
-
if (
|
|
7258
|
+
if (path5.endsWith("/mod/forum/discuss.php")) {
|
|
5969
7259
|
return objectMode ? { commandName: "forum_discussion", kwargs: { discussion: intParam("d", "discussion ID"), postId: parsed.hash, asJson: false, asYaml: false } } : { commandName: "forum:discussion", args: [intParam("d", "discussion ID"), parsed.hash] };
|
|
5970
7260
|
}
|
|
5971
|
-
if (
|
|
7261
|
+
if (path5.endsWith("/mod/forum/view.php")) {
|
|
5972
7262
|
return objectMode ? { commandName: "forum_discussions", kwargs: { forum: intParam("id", "forum module ID"), asJson: false, asYaml: false } } : { commandName: "forum:discussions", args: [intParam("id", "forum module ID")] };
|
|
5973
7263
|
}
|
|
5974
|
-
if (
|
|
7264
|
+
if (path5.endsWith("/mod/assign/view.php")) {
|
|
5975
7265
|
const id2 = intParam("id", "assignment module ID");
|
|
5976
7266
|
return objectMode ? { commandName: "assign", kwargs: { assign: id2, asJson: false, asYaml: false } } : { commandName: "assign", args: [id2] };
|
|
5977
7267
|
}
|
|
5978
|
-
if (
|
|
7268
|
+
if (path5.endsWith("/mod/quiz/view.php")) {
|
|
5979
7269
|
const id2 = intParam("id", "quiz module ID");
|
|
5980
7270
|
return objectMode ? { commandName: "quiz", kwargs: { quiz: id2, asJson: false, asYaml: false } } : { commandName: "quiz", args: [id2] };
|
|
5981
7271
|
}
|
|
5982
|
-
if (
|
|
7272
|
+
if (path5.endsWith("/mod/resource/view.php")) {
|
|
5983
7273
|
const id2 = intParam("id", "resource module ID");
|
|
5984
7274
|
return objectMode ? { commandName: "resource", kwargs: { resource: id2, asJson: false, asYaml: false } } : { commandName: "resource", args: [id2] };
|
|
5985
7275
|
}
|
|
5986
|
-
if (
|
|
7276
|
+
if (path5.endsWith("/mod/url/view.php")) {
|
|
5987
7277
|
const id2 = intParam("id", "link module ID");
|
|
5988
7278
|
return objectMode ? { commandName: "link", kwargs: { link: id2, asJson: false, asYaml: false } } : { commandName: "link", args: [id2] };
|
|
5989
7279
|
}
|
|
5990
|
-
if (
|
|
7280
|
+
if (path5.endsWith("/mod/page/view.php")) {
|
|
5991
7281
|
const id2 = intParam("id", "page module ID");
|
|
5992
7282
|
return objectMode ? { commandName: "page", kwargs: { page: id2, asJson: false, asYaml: false } } : { commandName: "page", args: [id2] };
|
|
5993
7283
|
}
|
|
5994
|
-
if (
|
|
7284
|
+
if (path5.endsWith("/mod/folder/view.php")) {
|
|
5995
7285
|
const id2 = intParam("id", "folder module ID");
|
|
5996
7286
|
return objectMode ? { commandName: "folder", kwargs: { folder: id2, asJson: false, asYaml: false } } : { commandName: "folder", args: [id2] };
|
|
5997
7287
|
}
|
|
5998
|
-
if (
|
|
7288
|
+
if (path5.endsWith("/course/view.php")) {
|
|
5999
7289
|
const id2 = intParam("id", "course ID");
|
|
6000
7290
|
return objectMode ? { commandName: "course", kwargs: { course: id2, asJson: false, asYaml: false } } : { commandName: "course", args: [id2] };
|
|
6001
7291
|
}
|
|
6002
|
-
if (
|
|
7292
|
+
if (path5.endsWith("/course/user.php") && parsed.searchParams.get("mode") === "grade" || path5.includes("/grade/report/")) {
|
|
6003
7293
|
const id2 = intParam("id", "course ID");
|
|
6004
7294
|
return objectMode ? { commandName: "grades", kwargs: { course: id2, asJson: false, asYaml: false } } : { commandName: "grades", args: [id2] };
|
|
6005
7295
|
}
|
|
6006
|
-
if (
|
|
7296
|
+
if (path5.includes("/mod/") && path5.endsWith("/view.php")) {
|
|
6007
7297
|
if (!resolveCourseIdForUrl) {
|
|
6008
7298
|
throw new UsageError("Could not resolve course ID from the activity page.");
|
|
6009
7299
|
}
|
|
@@ -6021,10 +7311,10 @@ function resolveTopLevelUrl(baseUrlOrOptions, targetValue, resolveCourseIdForUrl
|
|
|
6021
7311
|
|
|
6022
7312
|
// src/mcp/cli.ts
|
|
6023
7313
|
import { createHash as createHash3 } from "crypto";
|
|
6024
|
-
import { readFile as
|
|
6025
|
-
import { homedir as
|
|
6026
|
-
import { join as
|
|
6027
|
-
import { createInterface
|
|
7314
|
+
import { readFile as readFile9 } from "fs/promises";
|
|
7315
|
+
import { homedir as homedir14 } from "os";
|
|
7316
|
+
import { join as join13 } from "path";
|
|
7317
|
+
import { createInterface } from "readline/promises";
|
|
6028
7318
|
import { fileURLToPath } from "url";
|
|
6029
7319
|
|
|
6030
7320
|
// src/mcp/protocol.ts
|
|
@@ -6076,7 +7366,7 @@ function parseJsonRpcRequest(input2) {
|
|
|
6076
7366
|
return JsonRpcRequestSchema.parse(input2);
|
|
6077
7367
|
}
|
|
6078
7368
|
function resolveProtocolVersion(request, context = {}) {
|
|
6079
|
-
const meta =
|
|
7369
|
+
const meta = isRecord7(request.params?._meta) ? request.params._meta : void 0;
|
|
6080
7370
|
const initializeVersion = request.method === "initialize" && typeof request.params?.protocolVersion === "string" ? request.params.protocolVersion : void 0;
|
|
6081
7371
|
const requested = context.protocolVersion ?? stringValue3(meta?.["io.modelcontextprotocol/protocolVersion"]) ?? initializeVersion ?? MODERN_PROTOCOL_VERSION;
|
|
6082
7372
|
if (!isSupportedProtocolVersion(requested)) {
|
|
@@ -6085,7 +7375,7 @@ function resolveProtocolVersion(request, context = {}) {
|
|
|
6085
7375
|
return requested;
|
|
6086
7376
|
}
|
|
6087
7377
|
function assertRequestMetadata(request, protocolVersion2, context = {}) {
|
|
6088
|
-
const meta =
|
|
7378
|
+
const meta = isRecord7(request.params?._meta) ? request.params._meta : void 0;
|
|
6089
7379
|
const metaVersion = stringValue3(meta?.["io.modelcontextprotocol/protocolVersion"]);
|
|
6090
7380
|
if (protocolVersion2 === MODERN_PROTOCOL_VERSION) {
|
|
6091
7381
|
const parsed = ModernClientMetadataSchema.safeParse(meta);
|
|
@@ -6116,7 +7406,7 @@ function jsonRpcSuccess(id2, result) {
|
|
|
6116
7406
|
function jsonRpcFailure(id2, error) {
|
|
6117
7407
|
return { jsonrpc: "2.0", id: id2, error };
|
|
6118
7408
|
}
|
|
6119
|
-
function
|
|
7409
|
+
function isRecord7(value) {
|
|
6120
7410
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6121
7411
|
}
|
|
6122
7412
|
function stringValue3(value) {
|
|
@@ -6191,7 +7481,7 @@ async function bridgeRemoteMcp(options) {
|
|
|
6191
7481
|
await writeRemoteError(options.output, request.id, response.status);
|
|
6192
7482
|
return;
|
|
6193
7483
|
}
|
|
6194
|
-
if (request.method === "initialize" &&
|
|
7484
|
+
if (request.method === "initialize" && isRecord8(payload) && "result" in payload) {
|
|
6195
7485
|
negotiatedProtocolVersion = responseProtocolVersion(payload) ?? initializedProtocolVersion(request) ?? negotiatedProtocolVersion;
|
|
6196
7486
|
}
|
|
6197
7487
|
await writeJson(options.output, payload);
|
|
@@ -6216,7 +7506,7 @@ function normalizeMcpEndpoint(value) {
|
|
|
6216
7506
|
return url.toString();
|
|
6217
7507
|
}
|
|
6218
7508
|
function protocolVersion(request, negotiatedVersion) {
|
|
6219
|
-
const meta =
|
|
7509
|
+
const meta = isRecord8(request.params?._meta) ? request.params?._meta : void 0;
|
|
6220
7510
|
const metadataVersion = meta?.["io.modelcontextprotocol/protocolVersion"];
|
|
6221
7511
|
if (typeof metadataVersion === "string") return metadataVersion;
|
|
6222
7512
|
return initializedProtocolVersion(request) ?? negotiatedVersion;
|
|
@@ -6226,7 +7516,7 @@ function initializedProtocolVersion(request) {
|
|
|
6226
7516
|
return typeof initialized === "string" ? initialized : void 0;
|
|
6227
7517
|
}
|
|
6228
7518
|
function responseProtocolVersion(payload) {
|
|
6229
|
-
const result =
|
|
7519
|
+
const result = isRecord8(payload.result) ? payload.result : void 0;
|
|
6230
7520
|
return typeof result?.protocolVersion === "string" ? result.protocolVersion : void 0;
|
|
6231
7521
|
}
|
|
6232
7522
|
async function writeSseMessages(output, body, id2) {
|
|
@@ -6260,10 +7550,10 @@ async function forwardProtocolNegotiationError(output, response, id2) {
|
|
|
6260
7550
|
}
|
|
6261
7551
|
try {
|
|
6262
7552
|
const payload = await response.json();
|
|
6263
|
-
if (!
|
|
7553
|
+
if (!isRecord8(payload) || payload.jsonrpc !== "2.0" || payload.id !== id2 || !isRecord8(payload.error)) {
|
|
6264
7554
|
return false;
|
|
6265
7555
|
}
|
|
6266
|
-
const data =
|
|
7556
|
+
const data = isRecord8(payload.error.data) ? payload.error.data : void 0;
|
|
6267
7557
|
const supported = Array.isArray(data?.supported) ? data.supported.filter((version) => typeof version === "string") : [];
|
|
6268
7558
|
if (payload.error.code !== -32022 || typeof data?.requested !== "string" || supported.length === 0) {
|
|
6269
7559
|
return false;
|
|
@@ -6283,9 +7573,9 @@ async function writeJson(output, value) {
|
|
|
6283
7573
|
`);
|
|
6284
7574
|
}
|
|
6285
7575
|
function isBridgeRequest(value) {
|
|
6286
|
-
return
|
|
7576
|
+
return isRecord8(value) && value.jsonrpc === "2.0" && typeof value.method === "string";
|
|
6287
7577
|
}
|
|
6288
|
-
function
|
|
7578
|
+
function isRecord8(value) {
|
|
6289
7579
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6290
7580
|
}
|
|
6291
7581
|
|
|
@@ -6557,13 +7847,13 @@ function resolveConnection(options) {
|
|
|
6557
7847
|
}
|
|
6558
7848
|
|
|
6559
7849
|
// src/mcp/connectors/node-connectors.ts
|
|
6560
|
-
import { chmod as chmod4, mkdir as
|
|
6561
|
-
import { homedir as
|
|
6562
|
-
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";
|
|
6563
7853
|
var NodeConnectorFileSystem = class {
|
|
6564
|
-
async exists(
|
|
7854
|
+
async exists(path5) {
|
|
6565
7855
|
try {
|
|
6566
|
-
await
|
|
7856
|
+
await stat3(path5);
|
|
6567
7857
|
return true;
|
|
6568
7858
|
} catch (error) {
|
|
6569
7859
|
if (isMissing3(error)) {
|
|
@@ -6572,20 +7862,20 @@ var NodeConnectorFileSystem = class {
|
|
|
6572
7862
|
throw error;
|
|
6573
7863
|
}
|
|
6574
7864
|
}
|
|
6575
|
-
async readText(
|
|
6576
|
-
return
|
|
7865
|
+
async readText(path5) {
|
|
7866
|
+
return readFile7(path5, "utf8");
|
|
6577
7867
|
}
|
|
6578
|
-
async writePrivate(
|
|
6579
|
-
await
|
|
6580
|
-
await writeFile6(
|
|
6581
|
-
await chmod4(
|
|
7868
|
+
async writePrivate(path5, content) {
|
|
7869
|
+
await mkdir7(dirname6(path5), { recursive: true, mode: 448 });
|
|
7870
|
+
await writeFile6(path5, content, { encoding: "utf8", mode: 384 });
|
|
7871
|
+
await chmod4(path5, 384);
|
|
6582
7872
|
}
|
|
6583
|
-
async remove(
|
|
6584
|
-
await rm5(
|
|
7873
|
+
async remove(path5) {
|
|
7874
|
+
await rm5(path5, { force: true });
|
|
6585
7875
|
}
|
|
6586
7876
|
};
|
|
6587
7877
|
function createDefaultClientConnectors(profile, options = {}) {
|
|
6588
|
-
const home = options.homeDirectory ??
|
|
7878
|
+
const home = options.homeDirectory ?? homedir11();
|
|
6589
7879
|
const platform = options.platform ?? process.platform;
|
|
6590
7880
|
const fileSystem = options.fileSystem ?? new NodeConnectorFileSystem();
|
|
6591
7881
|
const runtime = runtimeCommand(options.command, options.commandArgs);
|
|
@@ -6597,14 +7887,14 @@ function createDefaultClientConnectors(profile, options = {}) {
|
|
|
6597
7887
|
endpoint: options.endpoint,
|
|
6598
7888
|
accessToken: options.accessToken
|
|
6599
7889
|
};
|
|
6600
|
-
const claudeDesktop = platform === "darwin" ?
|
|
6601
|
-
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");
|
|
6602
7892
|
return [
|
|
6603
|
-
createCodexConnector({ ...shared, configPath:
|
|
7893
|
+
createCodexConnector({ ...shared, configPath: join10(home, ".codex", "config.toml"), detectionPath: join10(home, ".codex") }, fileSystem),
|
|
6604
7894
|
createClaudeDesktopConnector({ ...shared, configPath: claudeDesktop, detectionPath: dirname6(claudeDesktop) }, fileSystem),
|
|
6605
|
-
createClaudeCodeConnector({ ...shared, configPath:
|
|
7895
|
+
createClaudeCodeConnector({ ...shared, configPath: join10(home, ".claude.json"), detectionPath: join10(home, ".claude") }, fileSystem),
|
|
6606
7896
|
createVsCodeConnector({ ...shared, configPath: vscodeUser, detectionPath: dirname6(vscodeUser) }, fileSystem),
|
|
6607
|
-
createCursorConnector({ ...shared, configPath:
|
|
7897
|
+
createCursorConnector({ ...shared, configPath: join10(home, ".cursor", "mcp.json"), detectionPath: join10(home, ".cursor") }, fileSystem)
|
|
6608
7898
|
];
|
|
6609
7899
|
}
|
|
6610
7900
|
var DefaultClientIntegration = class {
|
|
@@ -7365,16 +8655,19 @@ function asDeploymentError(error) {
|
|
|
7365
8655
|
if (error instanceof DeploymentApplyError) {
|
|
7366
8656
|
return error;
|
|
7367
8657
|
}
|
|
8658
|
+
if (error instanceof CliError2) {
|
|
8659
|
+
return error;
|
|
8660
|
+
}
|
|
7368
8661
|
const detail = error instanceof Error && error.message ? `: ${error.message}` : "";
|
|
7369
8662
|
return new DeploymentApplyError("DEPLOYMENT_FAILED", `The managed Moodle MCP deployment failed${detail}`, { cause: error });
|
|
7370
8663
|
}
|
|
7371
8664
|
|
|
7372
8665
|
// src/mcp/wrangler.ts
|
|
7373
|
-
import {
|
|
7374
|
-
import { mkdir as
|
|
8666
|
+
import { createUi as createUi2 } from "@bunizao/cli-kit";
|
|
8667
|
+
import { mkdir as mkdir8, writeFile as writeFile7 } from "fs/promises";
|
|
7375
8668
|
import { existsSync } from "fs";
|
|
7376
|
-
import { homedir as
|
|
7377
|
-
import { join as
|
|
8669
|
+
import { homedir as homedir12 } from "os";
|
|
8670
|
+
import { join as join11 } from "path";
|
|
7378
8671
|
async function resolveWrangler(runner, options = {}) {
|
|
7379
8672
|
const env = options.env ?? process.env;
|
|
7380
8673
|
const notice = options.notice ?? ((text2) => process.stderr.write(`${text2}
|
|
@@ -7385,8 +8678,8 @@ async function resolveWrangler(runner, options = {}) {
|
|
|
7385
8678
|
if (version && sameMajorAtLeast(version, WRANGLER_VERSION)) return { command: existing, args: [] };
|
|
7386
8679
|
notice(`Ignoring ${existing} (${version ?? "unknown version"}); Cloudflare management needs Wrangler ${WRANGLER_VERSION.split(".")[0]}.x.`);
|
|
7387
8680
|
}
|
|
7388
|
-
const root =
|
|
7389
|
-
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");
|
|
7390
8683
|
const bun = findExecutable("bun", env);
|
|
7391
8684
|
const node = findExecutable("node", env);
|
|
7392
8685
|
if (!bun && !node) throw new Error("Cloudflare management needs Bun or Node 22.13+. Install either, then retry moodle mcp deploy.");
|
|
@@ -7395,19 +8688,23 @@ async function resolveWrangler(runner, options = {}) {
|
|
|
7395
8688
|
if (!bun && !npm) throw new Error("Install Bun or npm to download the pinned Cloudflare toolchain.");
|
|
7396
8689
|
const yes = options.yes ?? (process.argv.includes("--yes") || process.argv.includes("-y"));
|
|
7397
8690
|
if (!yes) {
|
|
7398
|
-
|
|
7399
|
-
|
|
7400
|
-
|
|
7401
|
-
|
|
7402
|
-
if (answer.trim() && !/^y(?:es)?$/iu.test(answer.trim())) throw new UsageError("Wrangler download cancelled.", "Retry when ready to install Cloudflare's toolchain.");
|
|
7403
|
-
} finally {
|
|
7404
|
-
reader.close();
|
|
8691
|
+
const ui = createUi2({ input: process.stdin, output: process.stderr });
|
|
8692
|
+
if (!ui.interactive) throw new UsageError("Cloudflare management needs a first-use Wrangler download.", "Rerun with --yes to download and cache the pinned toolchain.");
|
|
8693
|
+
if (!await ui.confirm(`Download Cloudflare Wrangler ${WRANGLER_VERSION} (cached for next time)?`, { initial: true })) {
|
|
8694
|
+
throw new UsageError("Wrangler download cancelled.", "Retry when ready to install Cloudflare's toolchain.");
|
|
7405
8695
|
}
|
|
7406
8696
|
}
|
|
7407
8697
|
notice(`Cloudflare management needs Wrangler ${WRANGLER_VERSION}; downloading once to ${root}.`);
|
|
7408
|
-
await
|
|
7409
|
-
await
|
|
7410
|
-
|
|
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
|
+
}
|
|
7411
8708
|
}
|
|
7412
8709
|
return { command: node ?? bun, args: [script] };
|
|
7413
8710
|
}
|
|
@@ -7419,18 +8716,18 @@ function sameMajorAtLeast(actual, pinned) {
|
|
|
7419
8716
|
|
|
7420
8717
|
// src/mcp/deployment/node-adapters.ts
|
|
7421
8718
|
import { isDeepStrictEqual } from "util";
|
|
7422
|
-
import { spawn as
|
|
8719
|
+
import { spawn as spawn3 } from "child_process";
|
|
7423
8720
|
import { createHash as createHash2, randomBytes as randomBytes2 } from "crypto";
|
|
7424
|
-
import { chmod as chmod5, mkdir as
|
|
7425
|
-
import { homedir as
|
|
7426
|
-
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";
|
|
7427
8724
|
var MODERN_MCP_VERSION = "2026-07-28";
|
|
7428
8725
|
var WORKER_PROPAGATION_ATTEMPTS = 10;
|
|
7429
8726
|
var WORKER_PROPAGATION_MAX_DELAY_MS = 4e3;
|
|
7430
8727
|
var NodeDeploymentCommandRunner = class {
|
|
7431
8728
|
async run(command, args, environment = {}) {
|
|
7432
8729
|
return new Promise((resolve, reject) => {
|
|
7433
|
-
const child =
|
|
8730
|
+
const child = spawn3(command, args, {
|
|
7434
8731
|
env: { ...process.env, ...environment },
|
|
7435
8732
|
stdio: ["ignore", "pipe", "pipe"],
|
|
7436
8733
|
windowsHide: true
|
|
@@ -7614,7 +8911,7 @@ ${error.stderr}`)) {
|
|
|
7614
8911
|
const binding = (value, name) => {
|
|
7615
8912
|
let result2 = null;
|
|
7616
8913
|
visit(value, (_key, item) => {
|
|
7617
|
-
if (
|
|
8914
|
+
if (isRecord9(item) && item.name === name && typeof item.text === "string") result2 = item.text;
|
|
7618
8915
|
});
|
|
7619
8916
|
return result2;
|
|
7620
8917
|
};
|
|
@@ -7646,6 +8943,10 @@ ${error.stderr}`)) {
|
|
|
7646
8943
|
}
|
|
7647
8944
|
await this.wrangler(["delete", input2.workerName, "--force"], input2.accountId);
|
|
7648
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
|
+
}
|
|
7649
8950
|
async wrangler(args, accountId, environmentOverrides = {}) {
|
|
7650
8951
|
const environment = { ...environmentOverrides };
|
|
7651
8952
|
if (accountId) {
|
|
@@ -7660,7 +8961,7 @@ ${error.stderr}`)) {
|
|
|
7660
8961
|
}
|
|
7661
8962
|
};
|
|
7662
8963
|
async function copyReleaseBundle(source, destination) {
|
|
7663
|
-
await
|
|
8964
|
+
await writeFile8(destination, await readFile8(source));
|
|
7664
8965
|
}
|
|
7665
8966
|
var NodeReleaseMaterializer = class {
|
|
7666
8967
|
constructor(options) {
|
|
@@ -7669,13 +8970,13 @@ var NodeReleaseMaterializer = class {
|
|
|
7669
8970
|
options;
|
|
7670
8971
|
async prepare(plan, credentials) {
|
|
7671
8972
|
const temporaryRoot = this.options.temporaryRoot ?? tmpdir();
|
|
7672
|
-
await
|
|
7673
|
-
const artifactDirectory = await mkdtemp(
|
|
8973
|
+
await mkdir9(temporaryRoot, { recursive: true });
|
|
8974
|
+
const artifactDirectory = await mkdtemp(join12(temporaryRoot, "moodle-mcp-"));
|
|
7674
8975
|
await chmod5(artifactDirectory, 448);
|
|
7675
|
-
const workerFile =
|
|
8976
|
+
const workerFile = join12(artifactDirectory, basename(this.options.workerBundlePath));
|
|
7676
8977
|
await copyReleaseBundle(this.options.workerBundlePath, workerFile);
|
|
7677
|
-
const wranglerConfigPath =
|
|
7678
|
-
const secretsFilePath =
|
|
8978
|
+
const wranglerConfigPath = join12(artifactDirectory, "wrangler.json");
|
|
8979
|
+
const secretsFilePath = join12(artifactDirectory, "secrets.json");
|
|
7679
8980
|
const expectedHosts = endpointHosts(plan.intent.workerName, plan.existing?.productionEndpoint);
|
|
7680
8981
|
const config = {
|
|
7681
8982
|
$schema: "node_modules/wrangler/config-schema.json",
|
|
@@ -7715,18 +9016,18 @@ var NodeReleaseMaterializer = class {
|
|
|
7715
9016
|
if (credentials.previousTokensExpireAt !== void 0 && Number.isFinite(credentials.previousTokensExpireAt)) {
|
|
7716
9017
|
secrets.TOKEN_OVERLAP_EXPIRES_AT = String(credentials.previousTokensExpireAt);
|
|
7717
9018
|
}
|
|
7718
|
-
await
|
|
9019
|
+
await writeFile8(wranglerConfigPath, `${JSON.stringify(config, null, 2)}
|
|
7719
9020
|
`, { mode: 384 });
|
|
7720
|
-
await
|
|
9021
|
+
await writeFile8(secretsFilePath, `${JSON.stringify(secrets)}
|
|
7721
9022
|
`, { mode: 384 });
|
|
7722
9023
|
await chmod5(wranglerConfigPath, 384);
|
|
7723
9024
|
await chmod5(secretsFilePath, 384);
|
|
7724
9025
|
let recoveryConfigPath;
|
|
7725
9026
|
try {
|
|
7726
|
-
const recoveryBundle = process.env.MOODLE_BUNDLED_RECOVERY ??
|
|
7727
|
-
await copyReleaseBundle(recoveryBundle,
|
|
7728
|
-
recoveryConfigPath =
|
|
7729
|
-
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" })}
|
|
7730
9031
|
`, { mode: 384 });
|
|
7731
9032
|
} catch (error) {
|
|
7732
9033
|
if (!isMissing4(error) || plan.existing) throw error;
|
|
@@ -7782,10 +9083,10 @@ var FetchManagedWorkerClient = class {
|
|
|
7782
9083
|
})
|
|
7783
9084
|
}, isRetryableSessionUpload);
|
|
7784
9085
|
const body = await safeJson(response);
|
|
7785
|
-
if (response.ok &&
|
|
9086
|
+
if (response.ok && isRecord9(body) && typeof body.revision === "number") {
|
|
7786
9087
|
return { revision: body.revision };
|
|
7787
9088
|
}
|
|
7788
|
-
const code =
|
|
9089
|
+
const code = isRecord9(body) && typeof body.code === "string" ? body.code : "SESSION_UPLOAD_FAILED";
|
|
7789
9090
|
throw new DeploymentApplyError(code, `The Worker rejected the Moodle session update (${code})`);
|
|
7790
9091
|
}
|
|
7791
9092
|
async getReadiness(input2) {
|
|
@@ -7793,7 +9094,7 @@ var FetchManagedWorkerClient = class {
|
|
|
7793
9094
|
headers: { authorization: `Bearer ${input2.sessionSyncToken}` }
|
|
7794
9095
|
}, isRetryableSessionUpload);
|
|
7795
9096
|
const body = await safeJson(response);
|
|
7796
|
-
if (
|
|
9097
|
+
if (isRecord9(body) && (body.status === "pass" || body.status === "warn" || body.status === "fail")) {
|
|
7797
9098
|
const session = firstHealthCheck(body, "moodle:session");
|
|
7798
9099
|
const upstream = firstHealthCheck(body, "moodle:upstream");
|
|
7799
9100
|
return {
|
|
@@ -7820,7 +9121,7 @@ var FetchManagedWorkerClient = class {
|
|
|
7820
9121
|
isRetryableWorkerPropagation
|
|
7821
9122
|
);
|
|
7822
9123
|
const healthBody = await safeJson(health);
|
|
7823
|
-
if (!health.ok || !
|
|
9124
|
+
if (!health.ok || !isRecord9(healthBody) || healthBody.status !== "pass") {
|
|
7824
9125
|
throw new DeploymentApplyError("HEALTH_CHECK_FAILED", "Worker liveness check failed");
|
|
7825
9126
|
}
|
|
7826
9127
|
let readiness = await this.getReadiness(input2);
|
|
@@ -7851,11 +9152,11 @@ var FetchManagedWorkerClient = class {
|
|
|
7851
9152
|
if (!Array.isArray(courses)) throw new DeploymentApplyError("MCP_SMOKE_FAILED", "MCP list_courses returned no course list");
|
|
7852
9153
|
if (courses.length) {
|
|
7853
9154
|
const first2 = courses[0];
|
|
7854
|
-
if (!
|
|
9155
|
+
if (!isRecord9(first2) || !Number.isSafeInteger(first2.id) || Number(first2.id) <= 0) {
|
|
7855
9156
|
throw new DeploymentApplyError("MCP_SMOKE_FAILED", "MCP list_courses returned no usable course ID");
|
|
7856
9157
|
}
|
|
7857
9158
|
const detail = readableToolResult(await this.mcpCall(input2.endpoint, input2.mcpAccessToken, "tools/call", { name: "get_course", arguments: { courseId: first2.id } }, 5));
|
|
7858
|
-
if (!
|
|
9159
|
+
if (!isRecord9(detail.course) || !isRecord9(detail.course.course) || detail.course.course.id !== first2.id || !Array.isArray(detail.course.sections)) {
|
|
7859
9160
|
throw new DeploymentApplyError("MCP_SMOKE_FAILED", "MCP course lookup did not match the listed course");
|
|
7860
9161
|
}
|
|
7861
9162
|
}
|
|
@@ -7879,7 +9180,7 @@ var FetchManagedWorkerClient = class {
|
|
|
7879
9180
|
headers: { authorization: `Bearer ${input2.sessionSyncToken}` }
|
|
7880
9181
|
});
|
|
7881
9182
|
const body = await safeJson(response);
|
|
7882
|
-
if (!response.ok || !
|
|
9183
|
+
if (!response.ok || !isRecord9(body) || typeof body.code !== "string" || typeof body.expiresAt !== "string") {
|
|
7883
9184
|
throw new DeploymentApplyError("PAIRING_UNAVAILABLE", "The Worker could not open a pairing window");
|
|
7884
9185
|
}
|
|
7885
9186
|
return {
|
|
@@ -7916,7 +9217,7 @@ var FetchManagedWorkerClient = class {
|
|
|
7916
9217
|
})
|
|
7917
9218
|
}, isRetryableSessionUpload);
|
|
7918
9219
|
const body = await safeJson(response);
|
|
7919
|
-
if (!response.ok || !
|
|
9220
|
+
if (!response.ok || !isRecord9(body) || body.jsonrpc !== "2.0" || body.id !== id2 || "error" in body || !("result" in body)) {
|
|
7920
9221
|
throw new DeploymentApplyError("MCP_SMOKE_FAILED", `MCP ${method} check failed`);
|
|
7921
9222
|
}
|
|
7922
9223
|
if (method === "tools/call") readableToolResult(body.result);
|
|
@@ -7934,13 +9235,13 @@ var FetchManagedWorkerClient = class {
|
|
|
7934
9235
|
}
|
|
7935
9236
|
};
|
|
7936
9237
|
var PrivateDeploymentReceiptStore = class {
|
|
7937
|
-
constructor(baseDirectory =
|
|
9238
|
+
constructor(baseDirectory = join12(homedir13(), ".config", "moodle-cli", "mcp", "deployments")) {
|
|
7938
9239
|
this.baseDirectory = baseDirectory;
|
|
7939
9240
|
}
|
|
7940
9241
|
baseDirectory;
|
|
7941
9242
|
async read(profile) {
|
|
7942
9243
|
try {
|
|
7943
|
-
const parsed = JSON.parse(await
|
|
9244
|
+
const parsed = JSON.parse(await readFile8(this.path(profile), "utf8"));
|
|
7944
9245
|
return isReceipt(parsed) ? parsed : null;
|
|
7945
9246
|
} catch (error) {
|
|
7946
9247
|
if (isMissing4(error)) {
|
|
@@ -7950,11 +9251,11 @@ var PrivateDeploymentReceiptStore = class {
|
|
|
7950
9251
|
}
|
|
7951
9252
|
}
|
|
7952
9253
|
async write(receipt) {
|
|
7953
|
-
const
|
|
7954
|
-
await
|
|
7955
|
-
await
|
|
9254
|
+
const path5 = this.path(receipt.profile);
|
|
9255
|
+
await mkdir9(dirname7(path5), { recursive: true, mode: 448 });
|
|
9256
|
+
await writeFile8(path5, `${JSON.stringify(receipt, null, 2)}
|
|
7956
9257
|
`, { mode: 384 });
|
|
7957
|
-
await chmod5(
|
|
9258
|
+
await chmod5(path5, 384);
|
|
7958
9259
|
}
|
|
7959
9260
|
async delete(profile) {
|
|
7960
9261
|
await rm6(this.path(profile), { force: true });
|
|
@@ -7963,11 +9264,11 @@ var PrivateDeploymentReceiptStore = class {
|
|
|
7963
9264
|
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(profile)) {
|
|
7964
9265
|
throw new Error("Invalid Moodle MCP profile name");
|
|
7965
9266
|
}
|
|
7966
|
-
return
|
|
9267
|
+
return join12(this.baseDirectory, `${profile}.json`);
|
|
7967
9268
|
}
|
|
7968
9269
|
};
|
|
7969
9270
|
function createDefaultManagedDeployment(options) {
|
|
7970
|
-
const homeDirectory = options.homeDirectory ??
|
|
9271
|
+
const homeDirectory = options.homeDirectory ?? homedir13();
|
|
7971
9272
|
const platform = options.platform ?? process.platform;
|
|
7972
9273
|
const runtime = runtimeCommand(options.executable, options.executableArgs);
|
|
7973
9274
|
const defaults = {
|
|
@@ -7994,7 +9295,7 @@ function createDefaultManagedDeployment(options) {
|
|
|
7994
9295
|
command: runtime.command,
|
|
7995
9296
|
commandArgs: runtime.args
|
|
7996
9297
|
}),
|
|
7997
|
-
receipts: new PrivateDeploymentReceiptStore(
|
|
9298
|
+
receipts: new PrivateDeploymentReceiptStore(join12(homeDirectory, ".config", "moodle-cli", "mcp", "deployments")),
|
|
7998
9299
|
createToken: () => randomBytes2(32).toString("base64url")
|
|
7999
9300
|
};
|
|
8000
9301
|
return new ManagedMcpDeployment({ ...defaults, ...options.dependencies });
|
|
@@ -8005,17 +9306,17 @@ function digest(value) {
|
|
|
8005
9306
|
function ownershipId(accountId, workerName) {
|
|
8006
9307
|
return `moodle-cli:${accountId}:${workerName}`;
|
|
8007
9308
|
}
|
|
8008
|
-
function endpointUrl(endpoint,
|
|
8009
|
-
return `${endpoint.replace(/\/$/u, "")}${
|
|
9309
|
+
function endpointUrl(endpoint, path5) {
|
|
9310
|
+
return `${endpoint.replace(/\/$/u, "")}${path5}`;
|
|
8010
9311
|
}
|
|
8011
9312
|
async function pinExpectedHosts(configPath, workerName, productionEndpoint) {
|
|
8012
9313
|
let config;
|
|
8013
9314
|
try {
|
|
8014
|
-
config = JSON.parse(await
|
|
9315
|
+
config = JSON.parse(await readFile8(configPath, "utf8"));
|
|
8015
9316
|
} catch {
|
|
8016
9317
|
throw new DeploymentApplyError("RELEASE_CONFIG_INVALID", "The generated Wrangler configuration is invalid");
|
|
8017
9318
|
}
|
|
8018
|
-
if (!
|
|
9319
|
+
if (!isRecord9(config) || !isRecord9(config.vars)) {
|
|
8019
9320
|
throw new DeploymentApplyError("RELEASE_CONFIG_INVALID", "The generated Wrangler configuration is invalid");
|
|
8020
9321
|
}
|
|
8021
9322
|
const hosts = endpointHosts(workerName, productionEndpoint);
|
|
@@ -8023,7 +9324,7 @@ async function pinExpectedHosts(configPath, workerName, productionEndpoint) {
|
|
|
8023
9324
|
throw new DeploymentApplyError("MISSING_ENDPOINT", "The Worker production endpoint is invalid");
|
|
8024
9325
|
}
|
|
8025
9326
|
config.vars.EXPECTED_HOSTS = hosts.join(",");
|
|
8026
|
-
await
|
|
9327
|
+
await writeFile8(configPath, `${JSON.stringify(config, null, 2)}
|
|
8027
9328
|
`, { mode: 384 });
|
|
8028
9329
|
}
|
|
8029
9330
|
function endpointHosts(workerName, endpoint) {
|
|
@@ -8055,18 +9356,18 @@ async function safeJson(response) {
|
|
|
8055
9356
|
}
|
|
8056
9357
|
function firstHealthCheck(body, name) {
|
|
8057
9358
|
const checks = body.checks;
|
|
8058
|
-
if (!
|
|
9359
|
+
if (!isRecord9(checks) || !Array.isArray(checks[name])) {
|
|
8059
9360
|
return null;
|
|
8060
9361
|
}
|
|
8061
9362
|
const check = checks[name][0];
|
|
8062
|
-
return
|
|
9363
|
+
return isRecord9(check) ? check : null;
|
|
8063
9364
|
}
|
|
8064
9365
|
function readableToolResult(result) {
|
|
8065
|
-
if (
|
|
9366
|
+
if (isRecord9(result) && result.isError !== true && Array.isArray(result.content)) {
|
|
8066
9367
|
try {
|
|
8067
|
-
const text2 = result.content.filter((block) =>
|
|
9368
|
+
const text2 = result.content.filter((block) => isRecord9(block) && block.type === "text").map((block) => block.text).join("\n");
|
|
8068
9369
|
const parsed = JSON.parse(text2);
|
|
8069
|
-
if (
|
|
9370
|
+
if (isRecord9(parsed) && isDeepStrictEqual(parsed, result.structuredContent)) return parsed;
|
|
8070
9371
|
} catch {
|
|
8071
9372
|
}
|
|
8072
9373
|
}
|
|
@@ -8074,7 +9375,7 @@ function readableToolResult(result) {
|
|
|
8074
9375
|
}
|
|
8075
9376
|
function mcpUserFullname(result) {
|
|
8076
9377
|
const user = readableToolResult(result).user;
|
|
8077
|
-
return
|
|
9378
|
+
return isRecord9(user) && typeof user.fullname === "string" && user.fullname.trim() ? user.fullname.trim() : null;
|
|
8078
9379
|
}
|
|
8079
9380
|
function parseJsonOutput(output) {
|
|
8080
9381
|
const candidates = [output.indexOf("{"), output.indexOf("[")].filter((index) => index >= 0).sort((a, b) => a - b);
|
|
@@ -8090,7 +9391,7 @@ function parseJsonOutput(output) {
|
|
|
8090
9391
|
function deploymentHistory(value) {
|
|
8091
9392
|
const entries = [];
|
|
8092
9393
|
visit(value, (_key, item) => {
|
|
8093
|
-
if (!
|
|
9394
|
+
if (!isRecord9(item) || !Array.isArray(item.versions)) {
|
|
8094
9395
|
return;
|
|
8095
9396
|
}
|
|
8096
9397
|
const versionId = activeVersionId(item.versions);
|
|
@@ -8098,7 +9399,7 @@ function deploymentHistory(value) {
|
|
|
8098
9399
|
return;
|
|
8099
9400
|
}
|
|
8100
9401
|
const createdOn = typeof item.created_on === "string" ? Date.parse(item.created_on) : Number.NaN;
|
|
8101
|
-
const message =
|
|
9402
|
+
const message = isRecord9(item.annotations) ? item.annotations["workers/message"] : void 0;
|
|
8102
9403
|
entries.push({
|
|
8103
9404
|
versionId,
|
|
8104
9405
|
message: typeof message === "string" ? message : null,
|
|
@@ -8109,7 +9410,7 @@ function deploymentHistory(value) {
|
|
|
8109
9410
|
return entries.sort((a, b) => b.createdOn - a.createdOn || b.index - a.index).map(({ versionId, message }) => ({ versionId, message }));
|
|
8110
9411
|
}
|
|
8111
9412
|
function activeVersionId(versions) {
|
|
8112
|
-
const records = versions.filter(
|
|
9413
|
+
const records = versions.filter(isRecord9);
|
|
8113
9414
|
const active = records.find((version) => version.percentage === 100) ?? records[0];
|
|
8114
9415
|
const id2 = active?.version_id ?? active?.versionId;
|
|
8115
9416
|
return typeof id2 === "string" ? id2 : null;
|
|
@@ -8120,7 +9421,7 @@ function releaseDigestFromMessage(message) {
|
|
|
8120
9421
|
function collectAccountObjects(value) {
|
|
8121
9422
|
const accounts = [];
|
|
8122
9423
|
visit(value, (_key, item) => {
|
|
8123
|
-
if (!
|
|
9424
|
+
if (!isRecord9(item)) {
|
|
8124
9425
|
return;
|
|
8125
9426
|
}
|
|
8126
9427
|
const id2 = typeof item.id === "string" ? item.id : typeof item.account_id === "string" ? item.account_id : null;
|
|
@@ -8149,14 +9450,14 @@ function visit(value, visitor, key = "") {
|
|
|
8149
9450
|
for (const item of value) {
|
|
8150
9451
|
visit(item, visitor);
|
|
8151
9452
|
}
|
|
8152
|
-
} else if (
|
|
9453
|
+
} else if (isRecord9(value)) {
|
|
8153
9454
|
for (const [childKey, item] of Object.entries(value)) {
|
|
8154
9455
|
visit(item, visitor, childKey);
|
|
8155
9456
|
}
|
|
8156
9457
|
}
|
|
8157
9458
|
}
|
|
8158
9459
|
function isReceipt(value) {
|
|
8159
|
-
if (!
|
|
9460
|
+
if (!isRecord9(value)) {
|
|
8160
9461
|
return false;
|
|
8161
9462
|
}
|
|
8162
9463
|
return [
|
|
@@ -8170,7 +9471,7 @@ function isReceipt(value) {
|
|
|
8170
9471
|
"releaseDigest"
|
|
8171
9472
|
].every((key) => typeof value[key] === "string") && typeof value.sessionRevision === "number";
|
|
8172
9473
|
}
|
|
8173
|
-
function
|
|
9474
|
+
function isRecord9(value) {
|
|
8174
9475
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
8175
9476
|
}
|
|
8176
9477
|
function isMissing4(error) {
|
|
@@ -8234,13 +9535,23 @@ var READ_ONLY_ANNOTATIONS = {
|
|
|
8234
9535
|
idempotentHint: true,
|
|
8235
9536
|
openWorldHint: true
|
|
8236
9537
|
};
|
|
9538
|
+
var WRITE_ANNOTATIONS = {
|
|
9539
|
+
readOnlyHint: false,
|
|
9540
|
+
destructiveHint: true,
|
|
9541
|
+
idempotentHint: false,
|
|
9542
|
+
openWorldHint: true
|
|
9543
|
+
};
|
|
9544
|
+
var WRITE_TOOLS = /* @__PURE__ */ new Set(["submit"]);
|
|
8237
9545
|
var aliases = { get_overview: "home", list_courses: "units", get_course: "unit", get_activity: "item", get_grades: "grades", get_thread: "thread", get_file: "file" };
|
|
8238
9546
|
var TOOL_CATALOG = Object.entries(intentContracts).map(([name, contract]) => ({
|
|
8239
9547
|
name,
|
|
8240
9548
|
description: intentDescription(name),
|
|
8241
9549
|
inputSchema: compactSchema(z4.toJSONSchema(contract.input, { io: "input" })),
|
|
8242
|
-
annotations: READ_ONLY_ANNOTATIONS
|
|
9550
|
+
annotations: WRITE_TOOLS.has(name) ? WRITE_ANNOTATIONS : READ_ONLY_ANNOTATIONS
|
|
8243
9551
|
}));
|
|
9552
|
+
function toolsFor(gateway) {
|
|
9553
|
+
return gateway.submitAssignment ? TOOL_CATALOG : TOOL_CATALOG.filter((tool) => !WRITE_TOOLS.has(tool.name));
|
|
9554
|
+
}
|
|
8244
9555
|
var TOOL_OUTPUT_SCHEMAS = Object.fromEntries(Object.entries(intentContracts).map(([name, contract]) => [name, compactSchema(z4.toJSONSchema(contract.output))]));
|
|
8245
9556
|
function compactSchema(value) {
|
|
8246
9557
|
if (Array.isArray(value)) return value.map(compactSchema);
|
|
@@ -8278,7 +9589,7 @@ function createMoodleMcpServer(gateway, options = {}) {
|
|
|
8278
9589
|
protocolVersion: protocolVersion2,
|
|
8279
9590
|
capabilities: { tools: { listChanged: false } },
|
|
8280
9591
|
serverInfo,
|
|
8281
|
-
instructions: "Read-only access to the authenticated user's Moodle data."
|
|
9592
|
+
instructions: gateway.submitAssignment ? "Access to the authenticated user's Moodle data. Only submit writes; it defaults to a dry run." : "Read-only access to the authenticated user's Moodle data."
|
|
8282
9593
|
});
|
|
8283
9594
|
}
|
|
8284
9595
|
if (request.method === "ping") {
|
|
@@ -8286,7 +9597,7 @@ function createMoodleMcpServer(gateway, options = {}) {
|
|
|
8286
9597
|
}
|
|
8287
9598
|
if (request.method === "tools/list") {
|
|
8288
9599
|
return jsonRpcSuccess(id2, {
|
|
8289
|
-
tools:
|
|
9600
|
+
tools: toolsFor(gateway),
|
|
8290
9601
|
resultType: "complete",
|
|
8291
9602
|
_meta: RESULT_META
|
|
8292
9603
|
});
|
|
@@ -8342,8 +9653,9 @@ async function callTool(gateway, params) {
|
|
|
8342
9653
|
const requested = typeof params?.name === "string" ? params.name : "";
|
|
8343
9654
|
const name = Object.hasOwn(aliases, requested) ? aliases[requested] : requested;
|
|
8344
9655
|
if (!Object.hasOwn(intentContracts, name) && !["get_user", "list_activities", "list_forums"].includes(name)) throw new McpCallError("TOOL_NOT_FOUND", `Unknown Moodle tool: ${requested || "<missing>"}`);
|
|
9656
|
+
if (WRITE_TOOLS.has(name) && !gateway.submitAssignment) throw new McpCallError("TOOL_NOT_FOUND", `${requested} is only available on a local MCP server with access to the files.`);
|
|
8345
9657
|
const raw = params?.arguments ?? {};
|
|
8346
|
-
if (!
|
|
9658
|
+
if (!isRecord10(raw)) throw new McpCallError("INVALID_TOOL_ARGUMENTS", "Tool arguments must be an object.");
|
|
8347
9659
|
const args = { ...raw };
|
|
8348
9660
|
if (Object.hasOwn(aliases, requested)) {
|
|
8349
9661
|
const renames = { courseId: "unit", activityId: "ref", discussionId: "discussion_id", source: "ref", todoDays: "days", gradedOnly: "graded_only" };
|
|
@@ -8391,7 +9703,7 @@ async function callTool(gateway, params) {
|
|
|
8391
9703
|
const mapped2 = { type: "MOODLE_RESULT_INVALID", message: `Moodle returned ${name} data in an unexpected shape.`, hint: "Retry once; if it persists, run the same command locally with --verbose and report the tool name.", issues: error.issues.slice(0, 5).map((issue) => ({ path: issue.path.join("."), message: issue.message })) };
|
|
8392
9704
|
return { content: [{ type: "text", text: JSON.stringify({ error: mapped2 }) }], structuredContent: { error: mapped2 }, isError: true, resultType: "complete", _meta: RESULT_META };
|
|
8393
9705
|
}
|
|
8394
|
-
const mapped = error instanceof ReferenceError ? { type: error.code, code: error.code, message: error.message, hint: error.hint, candidates: error.candidates } : mapMoodleError(error);
|
|
9706
|
+
const mapped = error instanceof ReferenceError ? { type: error.code, code: error.code, message: error.message, hint: error.hint, candidates: error.candidates } : mapMoodleError(error, WRITE_TOOLS.has(name));
|
|
8395
9707
|
return { content: [{ type: "text", text: JSON.stringify({ error: mapped }) }], structuredContent: { error: mapped }, isError: true, resultType: "complete", _meta: RESULT_META };
|
|
8396
9708
|
}
|
|
8397
9709
|
}
|
|
@@ -8410,9 +9722,11 @@ function toolContent(name, payload, structuredContent) {
|
|
|
8410
9722
|
}
|
|
8411
9723
|
];
|
|
8412
9724
|
}
|
|
8413
|
-
function mapMoodleError(error) {
|
|
8414
|
-
const
|
|
8415
|
-
const code = typeof
|
|
9725
|
+
function mapMoodleError(error, verbatim = false) {
|
|
9726
|
+
const record3 = isRecord10(error) ? error : {};
|
|
9727
|
+
const code = typeof record3.code === "string" ? record3.code : "";
|
|
9728
|
+
const own = verbatim && typeof record3.message === "string" && record3.message.trim() && code !== "auth" ? record3.message : void 0;
|
|
9729
|
+
const ownHint = verbatim && typeof record3.hint === "string" && record3.hint.trim() ? record3.hint : void 0;
|
|
8416
9730
|
const typeByCode = {
|
|
8417
9731
|
auth: "MOODLE_AUTH_REQUIRED",
|
|
8418
9732
|
not_found: "MOODLE_NOT_FOUND",
|
|
@@ -8421,8 +9735,8 @@ function mapMoodleError(error) {
|
|
|
8421
9735
|
};
|
|
8422
9736
|
const type = code.startsWith("MOODLE_") ? code : typeByCode[code] ?? "MOODLE_UPSTREAM_ERROR";
|
|
8423
9737
|
const message = type === "MOODLE_AUTH_REQUIRED" ? "The Moodle session has expired. Sign in again." : type === "MOODLE_NOT_FOUND" || type === "MOODLE_COURSE_NOT_FOUND" ? "The requested Moodle item was not found." : type === "MOODLE_INVALID_REQUEST" ? "The Moodle request is invalid." : "Moodle could not complete the request.";
|
|
8424
|
-
const moodleCode = typeof
|
|
8425
|
-
return { type, message, hint: type === "MOODLE_AUTH_REQUIRED" ? "Run moodle mcp login for a remote server, or moodle auth login locally; then retry." : "Run moodle doctor, or refine the request using units and find.", ...type === "MOODLE_AUTH_REQUIRED" ? { recovery: { action: "moodle mcp login", where: "machine running moodle-cli", then: "retry this tool" } } : {}, ...moodleCode ? { moodleCode } : {} };
|
|
9738
|
+
const moodleCode = typeof record3.moodleErrorCode === "string" && /^[a-z][a-z0-9_]{0,63}$/u.test(record3.moodleErrorCode) ? record3.moodleErrorCode : void 0;
|
|
9739
|
+
return { type, message: own ?? message, hint: ownHint ?? (type === "MOODLE_AUTH_REQUIRED" ? "Run moodle mcp login for a remote server, or moodle auth login locally; then retry." : "Run moodle doctor, or refine the request using units and find."), ...type === "MOODLE_AUTH_REQUIRED" ? { recovery: { action: "moodle mcp login", where: "machine running moodle-cli", then: "retry this tool" } } : {}, ...moodleCode ? { moodleCode } : {} };
|
|
8426
9740
|
}
|
|
8427
9741
|
var McpCallError = class extends Error {
|
|
8428
9742
|
type;
|
|
@@ -8434,11 +9748,11 @@ var McpCallError = class extends Error {
|
|
|
8434
9748
|
this.details = details;
|
|
8435
9749
|
}
|
|
8436
9750
|
};
|
|
8437
|
-
function
|
|
9751
|
+
function isRecord10(value) {
|
|
8438
9752
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
8439
9753
|
}
|
|
8440
9754
|
function isMoodleFile(value) {
|
|
8441
|
-
return
|
|
9755
|
+
return isRecord10(value) && typeof value.name === "string" && typeof value.mimeType === "string" && typeof value.bytes === "number" && typeof value.uri === "string" && typeof value.blob === "string";
|
|
8442
9756
|
}
|
|
8443
9757
|
|
|
8444
9758
|
// src/mcp/stdio.ts
|
|
@@ -8494,12 +9808,12 @@ async function writeResponse2(output, response) {
|
|
|
8494
9808
|
`);
|
|
8495
9809
|
}
|
|
8496
9810
|
function initializeProtocolVersion(input2) {
|
|
8497
|
-
if (!
|
|
9811
|
+
if (!isRecord11(input2) || input2.method !== "initialize" || !isRecord11(input2.params)) {
|
|
8498
9812
|
return void 0;
|
|
8499
9813
|
}
|
|
8500
9814
|
return typeof input2.params.protocolVersion === "string" ? input2.params.protocolVersion : void 0;
|
|
8501
9815
|
}
|
|
8502
|
-
function
|
|
9816
|
+
function isRecord11(value) {
|
|
8503
9817
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
8504
9818
|
}
|
|
8505
9819
|
|
|
@@ -8520,9 +9834,9 @@ function deriveMcpWorkerName(moodleOrigin) {
|
|
|
8520
9834
|
var DefaultMcpCommandService = class {
|
|
8521
9835
|
constructor(options) {
|
|
8522
9836
|
this.options = options;
|
|
8523
|
-
this.homeDirectory = options.homeDir ??
|
|
9837
|
+
this.homeDirectory = options.homeDir ?? homedir14();
|
|
8524
9838
|
this.wranglerInstance = options.wrangler;
|
|
8525
|
-
this.receipts = options.receipts ?? new PrivateDeploymentReceiptStore(
|
|
9839
|
+
this.receipts = options.receipts ?? new PrivateDeploymentReceiptStore(join13(this.homeDirectory, ".config", "moodle-cli", "mcp", "deployments"));
|
|
8526
9840
|
this.credentials = options.credentials ?? createDefaultCredentialStore({ platform: process.platform, homeDirectory: this.homeDirectory });
|
|
8527
9841
|
this.worker = options.worker ?? new FetchManagedWorkerClient(options.fetchImpl);
|
|
8528
9842
|
this.renewal = options.renewal ?? new DefaultRenewalIntegration({
|
|
@@ -8547,6 +9861,7 @@ var DefaultMcpCommandService = class {
|
|
|
8547
9861
|
notifyRenewalSignIn;
|
|
8548
9862
|
progressReporter;
|
|
8549
9863
|
async deploy(input2) {
|
|
9864
|
+
await this.prepareToolchain(input2.yes);
|
|
8550
9865
|
const progress = this.progress();
|
|
8551
9866
|
try {
|
|
8552
9867
|
progress.begin("Reading Cloudflare account and deployment state");
|
|
@@ -8657,6 +9972,7 @@ var DefaultMcpCommandService = class {
|
|
|
8657
9972
|
async status(input2) {
|
|
8658
9973
|
const config = await this.config();
|
|
8659
9974
|
const profile = deriveMcpProfile(config.baseUrl);
|
|
9975
|
+
await this.prepareToolchain();
|
|
8660
9976
|
const progress = this.progress();
|
|
8661
9977
|
let managed;
|
|
8662
9978
|
try {
|
|
@@ -8699,6 +10015,7 @@ var DefaultMcpCommandService = class {
|
|
|
8699
10015
|
}
|
|
8700
10016
|
async login() {
|
|
8701
10017
|
const profile = deriveMcpProfile((await this.config()).baseUrl);
|
|
10018
|
+
await this.prepareToolchain();
|
|
8702
10019
|
const progress = this.progress();
|
|
8703
10020
|
try {
|
|
8704
10021
|
progress.begin("Reading your Moodle session (a browser sign-in may be required)");
|
|
@@ -8932,7 +10249,7 @@ var DefaultMcpCommandService = class {
|
|
|
8932
10249
|
async pushSessionFromStdin() {
|
|
8933
10250
|
const config = await this.config();
|
|
8934
10251
|
const profile = deriveMcpProfile(config.baseUrl);
|
|
8935
|
-
const raw = (await
|
|
10252
|
+
const raw = (await readAll2(this.options.stdin ?? process.stdin)).trim();
|
|
8936
10253
|
const cookieValue = raw.startsWith("MoodleSession=") ? raw.slice("MoodleSession=".length).trim() : raw;
|
|
8937
10254
|
if (!cookieValue || /[\r\n]/u.test(cookieValue)) throw new UsageError("Standard input did not contain one Moodle session cookie.");
|
|
8938
10255
|
const session = await getAuthenticatedSession(config.baseUrl, {
|
|
@@ -9077,7 +10394,7 @@ Selection: `)).trim());
|
|
|
9077
10394
|
if (this.options.prompt) return this.options.prompt(question);
|
|
9078
10395
|
const input2 = this.options.stdin ?? process.stdin;
|
|
9079
10396
|
const output = this.options.stderr ?? process.stderr;
|
|
9080
|
-
const readline =
|
|
10397
|
+
const readline = createInterface({ input: input2, output });
|
|
9081
10398
|
return readline.question(question).finally(() => readline.close());
|
|
9082
10399
|
}
|
|
9083
10400
|
workerBundlePath() {
|
|
@@ -9087,8 +10404,14 @@ Selection: `)).trim());
|
|
|
9087
10404
|
this.wranglerInstance ??= new NodeWranglerDeploymentAdapter();
|
|
9088
10405
|
return this.wranglerInstance;
|
|
9089
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
|
+
}
|
|
9090
10413
|
releaseDigest() {
|
|
9091
|
-
return
|
|
10414
|
+
return readFile9(this.workerBundlePath()).then((content) => sha256(content));
|
|
9092
10415
|
}
|
|
9093
10416
|
// True when a deployment receipt exists but its recorded release digest no
|
|
9094
10417
|
// longer matches the Worker bundle shipped with this CLI, i.e. the remote
|
|
@@ -9212,7 +10535,7 @@ function displayClientName(client) {
|
|
|
9212
10535
|
};
|
|
9213
10536
|
return names[client];
|
|
9214
10537
|
}
|
|
9215
|
-
async function
|
|
10538
|
+
async function readAll2(input2) {
|
|
9216
10539
|
const decoder = new TextDecoder();
|
|
9217
10540
|
let value = "";
|
|
9218
10541
|
for await (const chunk of input2) value += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
|
|
@@ -9233,12 +10556,12 @@ function sha256(value) {
|
|
|
9233
10556
|
// src/cli.ts
|
|
9234
10557
|
var NOUNS = [
|
|
9235
10558
|
{ name: "units", aliases: ["courses"], verbs: ["list", "show"], defaultByArity: { 0: "list", 1: "show" } },
|
|
9236
|
-
{ name: "activities", verbs: ["list", "show"], defaultByArity: { 1: "list" }, valueFlags: ["--limit", "--section"] },
|
|
10559
|
+
{ name: "activities", verbs: ["list", "show"], defaultByArity: { 0: "list", 1: "list" }, valueFlags: ["--limit", "--section"] },
|
|
9237
10560
|
{ name: "grades", verbs: ["list"], defaultByArity: { 0: "list", 1: "list" } },
|
|
9238
10561
|
{
|
|
9239
10562
|
name: "forums",
|
|
9240
10563
|
verbs: ["list", "show", "search"],
|
|
9241
|
-
defaultByArity: { 1: "list" },
|
|
10564
|
+
defaultByArity: { 0: "list", 1: "list" },
|
|
9242
10565
|
valueFlags: ["--limit", "--course", "--forum", "--limit-forums", "--limit-discussions", "--unit"]
|
|
9243
10566
|
},
|
|
9244
10567
|
{ name: "threads", verbs: ["show"], defaultByArity: { 1: "show" }, valueFlags: ["--post", "--limit", "--offset"] }
|
|
@@ -9246,7 +10569,8 @@ var NOUNS = [
|
|
|
9246
10569
|
function buildProgram(io = {}) {
|
|
9247
10570
|
const stdout = io.stdout ?? process.stdout;
|
|
9248
10571
|
const stderr = io.stderr ?? process.stderr;
|
|
9249
|
-
const program = createProgram({ name: "moodle", version: VERSION, description:
|
|
10572
|
+
const program = createProgram({ name: "moodle", version: VERSION, description: MOODLE_TAGLINE });
|
|
10573
|
+
banner(program, MOODLE_WORDMARK);
|
|
9250
10574
|
program.configureOutput({
|
|
9251
10575
|
writeOut: (text2) => stdout.write(text2),
|
|
9252
10576
|
writeErr: (text2) => stderr.write(text2),
|
|
@@ -9271,9 +10595,11 @@ function buildProgram(io = {}) {
|
|
|
9271
10595
|
width: stdout.columns || void 0,
|
|
9272
10596
|
color: !process.env.NO_COLOR && program.opts().color !== false && outputFormat({ ...program.opts(), ...options }, stdout) === "table"
|
|
9273
10597
|
});
|
|
10598
|
+
configureTerminalTables({ color: () => colorEnabled2(stdout, io.env) && program.opts().color !== false });
|
|
9274
10599
|
const count = (key, local, fallback) => program.opts()[key] ?? local ?? fallback;
|
|
9275
10600
|
const runtime = {
|
|
9276
10601
|
client: null,
|
|
10602
|
+
busy: false,
|
|
9277
10603
|
screen,
|
|
9278
10604
|
count,
|
|
9279
10605
|
baseUrl: async () => (await loadConfig({ env: io.env, cwd: io.cwd, homeDir: io.homeDir, stdin: io.stdin, stderr, fetch: io.fetchImpl })).baseUrl,
|
|
@@ -9283,11 +10609,11 @@ function buildProgram(io = {}) {
|
|
|
9283
10609
|
let inflight = 0;
|
|
9284
10610
|
let displayed = false;
|
|
9285
10611
|
let timer;
|
|
9286
|
-
|
|
10612
|
+
const connect = () => createMoodleClient(baseUrl, {
|
|
9287
10613
|
env: io.env,
|
|
9288
10614
|
fetchImpl: async (input2, init) => {
|
|
9289
10615
|
const started2 = Date.now();
|
|
9290
|
-
const tty = Boolean("isTTY" in stderr && stderr.isTTY) && !program.opts().json && !io.rootArgs?.includes("--json");
|
|
10616
|
+
const tty = Boolean("isTTY" in stderr && stderr.isTTY) && !program.opts().json && !io.rootArgs?.includes("--json") && !runtime.busy;
|
|
9291
10617
|
if (tty && inflight++ === 0) timer = setTimeout(() => {
|
|
9292
10618
|
displayed = true;
|
|
9293
10619
|
stderr.write("Loading Moodle\u2026");
|
|
@@ -9313,14 +10639,23 @@ function buildProgram(io = {}) {
|
|
|
9313
10639
|
homeDir: io.homeDir,
|
|
9314
10640
|
noCache: Boolean(program.opts().cache === false)
|
|
9315
10641
|
});
|
|
10642
|
+
try {
|
|
10643
|
+
runtime.client = await connect();
|
|
10644
|
+
} catch (error) {
|
|
10645
|
+
if (!(error instanceof AuthError) || !human()) throw error;
|
|
10646
|
+
await signIn(baseUrl);
|
|
10647
|
+
runtime.client = await connect();
|
|
10648
|
+
}
|
|
9316
10649
|
}
|
|
9317
10650
|
return runtime.client;
|
|
9318
10651
|
},
|
|
9319
10652
|
output: async (data, formatter, options) => {
|
|
9320
10653
|
const merged = { ...program.opts(), ...options };
|
|
9321
10654
|
const format = outputFormat(merged, stdout);
|
|
9322
|
-
const
|
|
9323
|
-
const text2 = format === "table" ? `${
|
|
10655
|
+
const human2 = format === "table" ? formatter() : "";
|
|
10656
|
+
const text2 = format === "table" ? `${human2}${human2.includes("Try ") ? "" : `
|
|
10657
|
+
|
|
10658
|
+
${tryLines(["moodle due", "moodle units", "moodle --help"])}`}
|
|
9324
10659
|
` : format === "json" ? `${JSON.stringify(JSON.parse(render(data, { format, fields: parseFields(data, merged.fields) })), null, merged.pretty ? 2 : void 0)}
|
|
9325
10660
|
` : render(data, { format, fields: parseFields(data, merged.fields) });
|
|
9326
10661
|
if (io.stdout && !merged.output) {
|
|
@@ -9348,23 +10683,43 @@ function buildProgram(io = {}) {
|
|
|
9348
10683
|
const result = await runner.run(name, args);
|
|
9349
10684
|
await runtime.output(result, () => screen(result, options), options);
|
|
9350
10685
|
};
|
|
10686
|
+
const human = () => detectAudience({
|
|
10687
|
+
stdin: io.stdin ?? process.stdin,
|
|
10688
|
+
stdout: { isTTY: Boolean(stdout && "isTTY" in stdout && stdout.isTTY) },
|
|
10689
|
+
env: io.env ?? process.env,
|
|
10690
|
+
format: outputFormat(program.opts(), stdout)
|
|
10691
|
+
}) === "human";
|
|
10692
|
+
const theme = () => createTheme3(colorEnabled2(stderr, io.env) && program.opts().color !== false);
|
|
10693
|
+
const signIn = async (baseUrl) => {
|
|
10694
|
+
const ui = createUi3({ input: io.stdin ?? process.stdin, output: stderr, interactive: true });
|
|
10695
|
+
const auth2 = { env: io.env, fetch: io.fetchImpl, homeDir: io.homeDir, captureMobileToken: true };
|
|
10696
|
+
runtime.busy = true;
|
|
10697
|
+
try {
|
|
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;
|
|
10712
|
+
}
|
|
10713
|
+
};
|
|
9351
10714
|
const choose = async (action, retry) => {
|
|
9352
10715
|
try {
|
|
9353
10716
|
return await action();
|
|
9354
10717
|
} catch (error) {
|
|
9355
|
-
if (!(error instanceof ReferenceError) || error.code !== "ambiguous" || !(
|
|
9356
|
-
|
|
9357
|
-
|
|
9358
|
-
|
|
9359
|
-
|
|
9360
|
-
try {
|
|
9361
|
-
const answer = await reader.question(`Pick [1-${error.candidates.length}]: `);
|
|
9362
|
-
const chosen = error.candidates[Number(answer) - 1];
|
|
9363
|
-
if (!chosen) throw error;
|
|
9364
|
-
return await retry(chosen.id);
|
|
9365
|
-
} finally {
|
|
9366
|
-
reader.close();
|
|
9367
|
-
}
|
|
10718
|
+
if (!(error instanceof ReferenceError) || error.code !== "ambiguous" || !human()) throw error;
|
|
10719
|
+
const ui = createUi3({ input: io.stdin ?? process.stdin, output: stderr, interactive: true });
|
|
10720
|
+
const hint = (c) => c.code ?? c.type;
|
|
10721
|
+
const chosen = await ui.select(error.message, error.candidates.map((c) => ({ value: c.id, label: c.name, ...hint(c) ? { hint: hint(c) } : {} })));
|
|
10722
|
+
return await retry(chosen);
|
|
9368
10723
|
}
|
|
9369
10724
|
};
|
|
9370
10725
|
program.action(async (targets, options) => {
|
|
@@ -9429,15 +10784,42 @@ ${error.candidates.map((c, i) => ` ${i + 1} ${c.name}`).join("\n")}
|
|
|
9429
10784
|
if (name === "due") command.option("--days <number>", "Deadline window in days.", parsePositiveInt);
|
|
9430
10785
|
command.option("--limit <number>", "Maximum returned rows.", parsePositiveInt).action(async (unit, options) => execute(name, { unit, limit: count("limit", options.limit), ...name === "due" ? { days: count("days", options.days) } : {} }, options));
|
|
9431
10786
|
}
|
|
9432
|
-
addOutputOptions(program.command("find").description(humanDescription("find")).argument("<query>").argument("[unit]")).option("--limit <number>", "Maximum returned rows.", parsePositiveInt).option("--types <types>", "Comma-separated activity types.").action(async (query, unit, options) => execute("find", { query, unit, limit: count("limit", options.limit), types: options.types?.split(",") }, options));
|
|
9433
|
-
addOutputOptions(program.command("get").description("Download a resource by id, URL, or UNIT TASK phrase.").argument("<ref>")).option("--to <directory>", "Destination directory.").option("--force", "Replace an existing file atomically.").action(async (ref2, options) => {
|
|
10787
|
+
addOutputOptions(program.command("find").description(humanDescription("find")).argument("<query>", "Words to look for").argument("[unit]", "Unit code, name, id or URL")).option("--limit <number>", "Maximum returned rows.", parsePositiveInt).option("--types <types>", "Comma-separated activity types.").action(async (query, unit, options) => execute("find", { query, unit, limit: count("limit", options.limit), types: options.types?.split(",") }, options));
|
|
10788
|
+
addOutputOptions(program.command("get").description("Download a resource by id, URL, or UNIT TASK phrase.").argument("<ref>", "Resource id, URL, or UNIT TASK phrase")).option("--to <directory>", "Destination directory.").option("--force", "Replace an existing file atomically.").action(async (ref2, options) => {
|
|
9434
10789
|
const client = await runtime.getClient();
|
|
9435
10790
|
const service = createIntentService(createMoodleGateway(client));
|
|
9436
10791
|
const source = await choose(() => service.fileSource(ref2), (id2) => Promise.resolve(id2));
|
|
9437
|
-
const receipt = await downloadMoodleFile(client, { source: String(source), directory: options.to ?
|
|
10792
|
+
const receipt = await downloadMoodleFile(client, { source: String(source), directory: options.to ? path4.resolve(io.cwd ?? process.cwd(), options.to) : void 0, force: options.force });
|
|
9438
10793
|
await runtime.output(receipt, () => formatDownloadReceipt(receipt), options);
|
|
9439
10794
|
});
|
|
9440
|
-
addOutputOptions(program.command("
|
|
10795
|
+
addOutputOptions(mutating(program.command("submit").description(humanDescription("submit")).summary("Upload files into an assignment").argument("<ref>", "Assignment id, URL, or UNIT TASK phrase").argument("[files...]", "Local files to upload"))).option("--final", "Also submit for grading. Moodle does not allow undoing this.").option("--replace", "Remove the files already in the submission first.").option("--accept-statement", "Agree to the site's submission statement when it requires one.").action(async (ref2, files, options) => {
|
|
10796
|
+
const interactive = human();
|
|
10797
|
+
if (!program.opts().dryRun && !program.opts().yes && !interactive) throw new UsageError("Mutation requires --yes when stdin is not interactive.", "Run with --dry-run to see the plan first.");
|
|
10798
|
+
const client = await runtime.getClient();
|
|
10799
|
+
const service = createIntentService(createMoodleGateway(client));
|
|
10800
|
+
const args = { files: files.map((file2) => resolveSubmissionPath(file2, io.cwd ?? process.cwd())), final: Boolean(options.final), replace: Boolean(options.replace), accept_statement: Boolean(options.acceptStatement) };
|
|
10801
|
+
const plan = await choose(() => service.run("submit", { ref: ref2, ...args, dry_run: true }), (id2) => service.run("submit", { ref: id2, ...args, dry_run: true }));
|
|
10802
|
+
const planned = plan.submission;
|
|
10803
|
+
if (program.opts().dryRun) return runtime.output(plan, () => formatSubmissionReceipt(planned), options);
|
|
10804
|
+
if (!await confirm({ summary: submissionSummary(planned, args.final, theme()) }, { yes: Boolean(program.opts().yes), dryRun: false, interactive })) return;
|
|
10805
|
+
const spin = interactive ? createUi3({ input: io.stdin ?? process.stdin, output: stderr, interactive: true }).spinner() : void 0;
|
|
10806
|
+
runtime.busy = true;
|
|
10807
|
+
spin?.start("Preparing the upload");
|
|
10808
|
+
let result;
|
|
10809
|
+
try {
|
|
10810
|
+
const live = createIntentService(createMoodleGateway(client, { onSubmitProgress: (message) => spin?.message(message) }));
|
|
10811
|
+
result = await live.run("submit", { ref: planned.id, ...args, dry_run: false });
|
|
10812
|
+
const receipt = result.submission;
|
|
10813
|
+
spin?.stop(receipt.uploads.length ? `Uploaded ${receipt.uploads.map((file2) => file2.name).join(", ")} to ${receipt.name}` : `Submitted ${receipt.name}`);
|
|
10814
|
+
} catch (error) {
|
|
10815
|
+
spin?.error("The upload did not complete");
|
|
10816
|
+
throw error;
|
|
10817
|
+
} finally {
|
|
10818
|
+
runtime.busy = false;
|
|
10819
|
+
}
|
|
10820
|
+
await runtime.output(result, () => formatSubmissionReceipt(result.submission), options);
|
|
10821
|
+
});
|
|
10822
|
+
addOutputOptions(program.command("open").description("Open a unit or activity reference in the browser.").argument("<ref>", "Unit or activity id, URL, or UNIT TASK phrase")).action(async (ref2, options) => {
|
|
9441
10823
|
const client = await runtime.getClient();
|
|
9442
10824
|
let url;
|
|
9443
10825
|
if (looksLikeUrl(ref2)) {
|
|
@@ -9453,11 +10835,7 @@ ${error.candidates.map((c, i) => ` ${i + 1} ${c.name}`).join("\n")}
|
|
|
9453
10835
|
}
|
|
9454
10836
|
}
|
|
9455
10837
|
if (!url || !/^https?:/u.test(url)) throw new UsageError("This item has no browser URL.");
|
|
9456
|
-
await
|
|
9457
|
-
const child = spawn3(process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer.exe" : "xdg-open", [url], { stdio: "ignore" });
|
|
9458
|
-
child.once("error", reject);
|
|
9459
|
-
child.once("exit", (code) => code === 0 ? resolve() : reject(new Error("Could not open the browser.")));
|
|
9460
|
-
});
|
|
10838
|
+
await openInBrowser(url);
|
|
9461
10839
|
await runtime.output({ opened: url }, () => `Opened ${url}`, options);
|
|
9462
10840
|
});
|
|
9463
10841
|
addOutputOptions(program.command("user").description("Show authenticated user info.")).action(async (options) => {
|
|
@@ -9495,7 +10873,7 @@ ${error.candidates.map((c, i) => ` ${i + 1} ${c.name}`).join("\n")}
|
|
|
9495
10873
|
const result = stripEmpty({ activities: rows.slice(0, count("limit", options.limit)), total: rows.length });
|
|
9496
10874
|
await runtime.output(result, () => runtime.screen(result, options), options);
|
|
9497
10875
|
});
|
|
9498
|
-
addOutputOptions(activities.command("show").description("Show activity details; resource and folder files can be passed to moodle get or download.").argument("<id>", "Course-module ID")).action(
|
|
10876
|
+
addOutputOptions(activities.command("show").description("Show activity details; resource and folder files can be passed to moodle get or download.").summary("Show activity details").argument("<id>", "Course-module ID")).action(
|
|
9499
10877
|
async (id2, options) => {
|
|
9500
10878
|
await execute("item", { ref: parsePositiveInt(id2) }, options);
|
|
9501
10879
|
}
|
|
@@ -9503,7 +10881,7 @@ ${error.candidates.map((c, i) => ` ${i + 1} ${c.name}`).join("\n")}
|
|
|
9503
10881
|
addOutputOptions(
|
|
9504
10882
|
program.command("download").alias("dl").description("Download one authenticated Moodle file.").argument("<source>", "Course-module ID or authenticated Moodle file URL").option("--dest <path>", "Exact downloaded file path").option("--force", "Atomically replace an existing destination")
|
|
9505
10883
|
).action(async (source, options) => {
|
|
9506
|
-
const destination = options.dest ?
|
|
10884
|
+
const destination = options.dest ? path4.resolve(io.cwd ?? process.cwd(), options.dest) : void 0;
|
|
9507
10885
|
const receipt = await downloadMoodleFile(await runtime.getClient(), {
|
|
9508
10886
|
source,
|
|
9509
10887
|
destination,
|
|
@@ -9548,12 +10926,15 @@ ${error.candidates.map((c, i) => ` ${i + 1} ${c.name}`).join("\n")}
|
|
|
9548
10926
|
await runtime.output(stripEmpty({ forums: forums2.map((f) => ({ id: f.id, name: f.name, unit_id: f.course_id })), total }), () => formatForumActivities(forums2), options);
|
|
9549
10927
|
});
|
|
9550
10928
|
addForumSearchCommand(forums.command("search").description("Search forum discussion titles and post text."), runtime, 20);
|
|
9551
|
-
addOutputOptions(program.command("doctor").description("Diagnose runtime, browser access, session, background jobs and MCP setup.")).action(async (options) => {
|
|
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) => {
|
|
9552
10930
|
const result = await doctor(io);
|
|
9553
10931
|
await runtime.output(result, () => result.checks.map((c) => `${c.status.toUpperCase()} ${c.name}: ${c.detail}${c.hint ? `
|
|
9554
|
-
${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;
|
|
9555
10936
|
});
|
|
9556
|
-
program.command("completion").description("Print shell completion for zsh, bash or fish.").
|
|
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) => {
|
|
9557
10938
|
const names = program.commands.filter((c) => c.name() !== "help").flatMap((c) => [c.name(), ...c.aliases()]);
|
|
9558
10939
|
if (shell === "bash") stdout.write(`complete -W '${names.join(" ")}' moodle
|
|
9559
10940
|
`);
|
|
@@ -9563,22 +10944,22 @@ _arguments '1:command:(${names.join(" ")})' '*:reference:'
|
|
|
9563
10944
|
else if (shell === "fish") stdout.write(names.map((n2) => `complete -c moodle -f -a '${n2}'`).join("\n") + "\n");
|
|
9564
10945
|
else throw new UsageError("Choose zsh, bash or fish.");
|
|
9565
10946
|
});
|
|
9566
|
-
addOutputOptions(mutating(program.command("uninstall").description("Remove local background jobs; optionally remove the selected Worker and configuration."))).option("--remote", "Also remove the configured managed MCP deployment.").option("--purge", "Also delete local Moodle CLI configuration, receipts and cache.").action(async (options) => {
|
|
9567
|
-
const home = io.homeDir ??
|
|
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) => {
|
|
10948
|
+
const home = io.homeDir ?? homedir15();
|
|
9568
10949
|
const jobs = await ownedJobs(home);
|
|
9569
|
-
const receipts = await
|
|
9570
|
-
const result = { jobs: jobs.map((j) => j.path), remote: Boolean(options.remote), purge: Boolean(options.purge), config:
|
|
10950
|
+
const receipts = await readdir4(path4.join(home, ".config", "moodle-cli", "mcp", "deployments")).catch(() => []);
|
|
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." };
|
|
9571
10952
|
if (program.opts().dryRun) return runtime.output(result, () => JSON.stringify(result, null, 2), options);
|
|
9572
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.");
|
|
9573
10954
|
if (options.purge && receipts.length > 1) throw new UsageError("Multiple managed deployment receipts exist; remove each Worker before purging configuration.");
|
|
9574
|
-
if (!await confirm({ summary: `Remove Moodle background jobs${options.remote ? ", the configured Worker" : ""}${options.purge ? ", configuration and cache" : ""}.` }, { yes: Boolean(program.opts().yes), dryRun: false, interactive:
|
|
10955
|
+
if (!await confirm({ summary: `Remove Moodle background jobs${options.remote ? ", the configured Worker" : ""}${options.purge ? ", configuration and cache" : ""}.` }, { yes: Boolean(program.opts().yes), dryRun: false, interactive: human() })) return;
|
|
9575
10956
|
if (options.remote) await getMcpService().remove({ yes: true });
|
|
9576
10957
|
if (process.platform === "darwin") await uninstallKeepalive({ homeDir: home });
|
|
9577
10958
|
const renewal2 = new DefaultRenewalIntegration({ homeDirectory: home, executable: process.execPath });
|
|
9578
10959
|
const profiles = new Set([...jobs.map((j) => j.profile), ...receipts.map((n2) => n2.replace(/\.json$/u, ""))].filter((p) => Boolean(p) && /^[a-z0-9_-]+$/u.test(p)));
|
|
9579
10960
|
for (const profile of profiles) {
|
|
9580
10961
|
await renewal2.remove(profile);
|
|
9581
|
-
if (options.purge) await rm7(
|
|
10962
|
+
if (options.purge) await rm7(path4.join(home, "Library", "Logs", `com.moodle-cli.mcp-renewal.${profile}.log`), { force: true });
|
|
9582
10963
|
}
|
|
9583
10964
|
if (options.purge) {
|
|
9584
10965
|
await rm7(result.config, { recursive: true, force: true });
|
|
@@ -9599,25 +10980,38 @@ ${result.package_command}`, options);
|
|
|
9599
10980
|
${note}`, options);
|
|
9600
10981
|
}
|
|
9601
10982
|
);
|
|
9602
|
-
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(
|
|
9603
10986
|
async (options) => {
|
|
9604
10987
|
const baseUrl = await runtime.baseUrl();
|
|
9605
|
-
await invalidateCachedSession(baseUrl, { homeDir: io.homeDir });
|
|
9606
10988
|
const humanOutput = outputFormat(options, stdout) === "table";
|
|
9607
|
-
const session = await getAuthenticatedSessionWithBrowserFallback(baseUrl, {
|
|
10989
|
+
const session = options.paste ? await pasteLogin(baseUrl, humanOutput) : await getAuthenticatedSessionWithBrowserFallback(baseUrl, {
|
|
9608
10990
|
env: io.env,
|
|
9609
10991
|
fetch: io.fetchImpl,
|
|
9610
10992
|
homeDir: io.homeDir,
|
|
9611
|
-
|
|
9612
|
-
|
|
9613
|
-
`) : 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
|
|
9614
10995
|
});
|
|
9615
10996
|
const result = { base_url: baseUrl, userid: session.userid, cookie_source: session.cookie.source ?? "unknown" };
|
|
9616
10997
|
await runtime.output(result, () => `Authenticated as userid ${result.userid} via ${result.cookie_source}`, options);
|
|
9617
10998
|
}
|
|
9618
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
|
+
}
|
|
9619
11013
|
const keepalive = addOutputOptions(
|
|
9620
|
-
auth.command("keepalive").description("Renew the Moodle session once; used by the background keepalive agent.").option("--no-renew", "Only touch the session; skip re-login when it is expired.")
|
|
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.")
|
|
9621
11015
|
).action(async (options) => {
|
|
9622
11016
|
const baseUrl = await runtime.baseUrl();
|
|
9623
11017
|
const result = await keepAliveOnce(baseUrl, { homeDir: io.homeDir, fetchImpl: io.fetchImpl, renewOnExpiry: options.renew });
|
|
@@ -9629,10 +11023,10 @@ ${url}
|
|
|
9629
11023
|
const globals = program.opts();
|
|
9630
11024
|
if (!await confirm(
|
|
9631
11025
|
{ summary: `Install the Moodle session keepalive agent${options.interval ? ` with a ${options.interval}-minute interval` : ""}.` },
|
|
9632
|
-
{ yes: Boolean(globals.yes), dryRun: Boolean(globals.dryRun), interactive:
|
|
11026
|
+
{ yes: Boolean(globals.yes), dryRun: Boolean(globals.dryRun), interactive: human() }
|
|
9633
11027
|
)) return;
|
|
9634
11028
|
const baseUrl = await runtime.baseUrl();
|
|
9635
|
-
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 });
|
|
9636
11030
|
const result = await installKeepalive({ homeDir: io.homeDir, intervalMinutes: options.interval });
|
|
9637
11031
|
await runtime.output(result, () => `Keepalive installed: renews every ${result.interval_minutes} min
|
|
9638
11032
|
Agent: ${result.plist_path}
|
|
@@ -9643,7 +11037,7 @@ Log: ${result.log_path}`, options);
|
|
|
9643
11037
|
const globals = program.opts();
|
|
9644
11038
|
if (!await confirm(
|
|
9645
11039
|
{ summary: "Remove the Moodle session keepalive agent." },
|
|
9646
|
-
{ yes: Boolean(globals.yes), dryRun: Boolean(globals.dryRun), interactive:
|
|
11040
|
+
{ yes: Boolean(globals.yes), dryRun: Boolean(globals.dryRun), interactive: human() }
|
|
9647
11041
|
)) return;
|
|
9648
11042
|
const result = await uninstallKeepalive({ homeDir: io.homeDir });
|
|
9649
11043
|
await runtime.output(result, () => `Keepalive removed (${result.plist_path})`, options);
|
|
@@ -9655,7 +11049,7 @@ Log: ${result.log_path}`, options);
|
|
|
9655
11049
|
await runtime.output(result, () => result.installed ? `Keepalive installed (${result.plist_path})` : "Keepalive not installed", options);
|
|
9656
11050
|
}
|
|
9657
11051
|
);
|
|
9658
|
-
const mcp = program.command("mcp").description("Deploy a private MCP Worker on Cloudflare; encrypted session storage and local renewal. Free-tier limits apply.");
|
|
11052
|
+
const mcp = program.command("mcp").description("Deploy a private MCP Worker on Cloudflare; encrypted session storage and local renewal. Free-tier limits apply.").summary("Private MCP server on Cloudflare");
|
|
9659
11053
|
addOutputOptions(mutating(mcp.command("deploy").description("Deploy or update the managed Moodle MCP server."))).option("--dry-run", "Preview deployment changes without applying them.").option("--repair", "Repair authentication and managed deployment state.").option("--rotate-key", "Rotate the session encryption key and migrate the active session.").option("--rotate-token", "Rotate the MCP access token with an overlap window.").option("--rollback", "Restore the previous healthy Worker release.").action(async (options) => {
|
|
9660
11054
|
const dryRun = Boolean(options.dryRun || program.opts().dryRun);
|
|
9661
11055
|
if (!dryRun && !await confirm(
|
|
@@ -9663,7 +11057,7 @@ Log: ${result.log_path}`, options);
|
|
|
9663
11057
|
{
|
|
9664
11058
|
yes: Boolean(program.opts().yes),
|
|
9665
11059
|
dryRun: false,
|
|
9666
|
-
interactive:
|
|
11060
|
+
interactive: human()
|
|
9667
11061
|
}
|
|
9668
11062
|
)) return;
|
|
9669
11063
|
const result = await getMcpService().deploy({
|
|
@@ -9705,11 +11099,11 @@ Log: ${result.log_path}`, options);
|
|
|
9705
11099
|
addOutputOptions(mcp.command("clients").description("List pending and approved OAuth clients.")).action(async (options) => {
|
|
9706
11100
|
await outputMcpResult(runtime, await getMcpService().manageClients({}), options);
|
|
9707
11101
|
});
|
|
9708
|
-
addOutputOptions(mutating(mcp.command("revoke").description("Revoke an OAuth client or all OAuth access.").argument("[client-id]"))).option("--all", "Revoke every client, token, pending authorization, and pairing window.").action(async (clientId, options) => {
|
|
11102
|
+
addOutputOptions(mutating(mcp.command("revoke").description("Revoke an OAuth client or all OAuth access.").argument("[client-id]", "OAuth client id; omit with --all"))).option("--all", "Revoke every client, token, pending authorization, and pairing window.").action(async (clientId, options) => {
|
|
9709
11103
|
if (Boolean(clientId) === Boolean(options.all)) throw new UsageError("Provide a client ID or --all.");
|
|
9710
11104
|
await outputMcpResult(runtime, await getMcpService().manageClients({ revoke: true, clientId }), options);
|
|
9711
11105
|
});
|
|
9712
|
-
addOutputOptions(mutating(mcp.command("pair").description("Open a pairing window so Claude can connect to the remote MCP server."))).action(
|
|
11106
|
+
addOutputOptions(mutating(mcp.command("pair").description("Open a pairing window so Claude can connect to the remote MCP server.").summary("Open a pairing window for Claude"))).action(
|
|
9713
11107
|
async (options) => {
|
|
9714
11108
|
await outputMcpResult(runtime, await getMcpService().pair(), options);
|
|
9715
11109
|
}
|
|
@@ -9742,7 +11136,7 @@ Log: ${result.log_path}`, options);
|
|
|
9742
11136
|
await runtime.output(description, () => JSON.stringify(description, null, 2), options);
|
|
9743
11137
|
}
|
|
9744
11138
|
);
|
|
9745
|
-
const skills = program.command("skills").description("Show skill metadata or delegate to the shared skills CLI.");
|
|
11139
|
+
const skills = program.command("skills").description("Show skill metadata or delegate to the shared skills CLI.").summary("Agent skill metadata");
|
|
9746
11140
|
skills.action(() => {
|
|
9747
11141
|
stdout.write(`${formatSkillSummary()}
|
|
9748
11142
|
`);
|
|
@@ -9752,24 +11146,46 @@ Log: ${result.log_path}`, options);
|
|
|
9752
11146
|
stdout.write("Generated Moodle skill bundle\n");
|
|
9753
11147
|
});
|
|
9754
11148
|
skills.command("add").description("Install the published skill through npx skills add.").allowUnknownOption(true).action((_options, command) => installSkill(command.args));
|
|
11149
|
+
for (const [title, names] of Object.entries(HELP_SECTIONS)) {
|
|
11150
|
+
for (const name of names) for (const command of program.commands) if (command.name() === name) helpSection(command, title);
|
|
11151
|
+
}
|
|
11152
|
+
examples(program, [
|
|
11153
|
+
"moodle # today: due items, alerts and news",
|
|
11154
|
+
"moodle UNIT grades",
|
|
11155
|
+
'moodle submit UNIT "Assignment 2" report.pdf'
|
|
11156
|
+
]);
|
|
9755
11157
|
return program;
|
|
9756
11158
|
}
|
|
11159
|
+
var HELP_SECTIONS = {
|
|
11160
|
+
"Core commands": ["due", "news", "find", "get", "open", "submit", "units", "activities", "grades", "threads", "forums"],
|
|
11161
|
+
"Additional commands": ["user", "todo", "alerts", "overview", "download", "auth", "doctor", "completion", "uninstall"],
|
|
11162
|
+
"Agent commands": ["mcp", "commands", "skills"]
|
|
11163
|
+
};
|
|
9757
11164
|
async function runCli(argv = process.argv, io = {}) {
|
|
9758
11165
|
const stderr = io.stderr ?? process.stderr;
|
|
9759
11166
|
const stdout = io.stdout ?? process.stdout;
|
|
9760
11167
|
const args = insertDefaultVerb(argv.slice(2), NOUNS);
|
|
9761
|
-
const
|
|
11168
|
+
const ui = createUi3({
|
|
11169
|
+
input: io.stdin ?? process.stdin,
|
|
11170
|
+
output: stderr,
|
|
11171
|
+
interactive: detectAudience({
|
|
11172
|
+
stdin: io.stdin ?? process.stdin,
|
|
11173
|
+
stdout: { isTTY: Boolean(stdout && "isTTY" in stdout && stdout.isTTY) },
|
|
11174
|
+
env: io.env ?? process.env,
|
|
11175
|
+
format: errorOutputFormat(args, stdout)
|
|
11176
|
+
}) === "human"
|
|
11177
|
+
});
|
|
9762
11178
|
try {
|
|
9763
|
-
await
|
|
11179
|
+
await parseWithPrompts(() => buildProgram({ ...io, rootArgs: args }), args, { ui, fillers: { unit: pickUnit(io, ui) } });
|
|
9764
11180
|
return 0;
|
|
9765
11181
|
} catch (error) {
|
|
9766
|
-
if (
|
|
11182
|
+
if (isInformationalExit(error)) {
|
|
9767
11183
|
return 0;
|
|
9768
11184
|
}
|
|
9769
11185
|
const format = errorOutputFormat(args, stdout);
|
|
9770
|
-
const normalized = normalizeError(error);
|
|
11186
|
+
const normalized = normalizeError(asNetworkError(error) ?? error);
|
|
9771
11187
|
const reference = error instanceof ReferenceError ? error : void 0;
|
|
9772
|
-
const reported = reportError(error, "json");
|
|
11188
|
+
const reported = reportError(asNetworkError(error) ?? error, "json");
|
|
9773
11189
|
const envelope = JSON.parse(reported.text);
|
|
9774
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];
|
|
9775
11191
|
envelope.error.hint = hint;
|
|
@@ -9786,6 +11202,23 @@ ${hint}
|
|
|
9786
11202
|
return envelope.exit_code;
|
|
9787
11203
|
}
|
|
9788
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
|
+
}
|
|
9789
11222
|
async function dispatchUrl(runtime, target, options) {
|
|
9790
11223
|
const client = await runtime.getClient();
|
|
9791
11224
|
const resolved = await resolveTopLevelUrl(client.baseUrl, target, (url) => client.resolveCourseIdForUrl(url));
|
|
@@ -9843,11 +11276,26 @@ function addForumSearchCommand(command, runtime, defaultLimit) {
|
|
|
9843
11276
|
});
|
|
9844
11277
|
}
|
|
9845
11278
|
function addOutputOptions(command) {
|
|
9846
|
-
|
|
11279
|
+
for (const [flags, description] of [
|
|
11280
|
+
["--pretty", "Indent JSON output."],
|
|
11281
|
+
["--json", "Output as JSON."],
|
|
11282
|
+
["--yaml", "Output as YAML."],
|
|
11283
|
+
["--table", "Force human output."],
|
|
11284
|
+
["--fields <fields>", "Keep only listed top-level fields in structured output."]
|
|
11285
|
+
]) command.addOption(command.createOption(flags, description).hideHelp());
|
|
11286
|
+
return command;
|
|
9847
11287
|
}
|
|
9848
11288
|
function outputFormat(options, stdout) {
|
|
9849
11289
|
return resolveFormat(options, Boolean(stdout && "isTTY" in stdout && stdout.isTTY));
|
|
9850
11290
|
}
|
|
11291
|
+
function submissionSummary(plan, final, theme) {
|
|
11292
|
+
const destination = `${theme.target(plan.name)}${plan.unit_id ? theme.dim(` unit ${plan.unit_id}`) : ""}`;
|
|
11293
|
+
const lines = plan.uploads.length ? [`${theme.dim("Upload")} ${theme.subject(plan.uploads.map((file2) => file2.name).join(", "))}`, `${theme.dim(" to")} ${destination}`] : [`${theme.dim("Submit")} ${destination}`, `${theme.dim(" ")} ${theme.subject("the files already there")} for grading`];
|
|
11294
|
+
if (plan.removed.length) lines.push(`${theme.dim("Remove")} ${theme.tone("danger", plan.removed.join(", "))} ${theme.dim("first")}`);
|
|
11295
|
+
if (plan.statement) lines.push(`${theme.dim(" Agree")} "${plan.statement}"`);
|
|
11296
|
+
lines.push(final ? theme.tone("warning", "Then submit for grading. Moodle does not allow undoing this.") : theme.dim("Moodle keeps a draft where the assignment allows drafts; otherwise it submits at once."));
|
|
11297
|
+
return lines.join("\n");
|
|
11298
|
+
}
|
|
9851
11299
|
function parsePositiveInt(value) {
|
|
9852
11300
|
const parsed = Number(value);
|
|
9853
11301
|
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
@@ -9888,11 +11336,24 @@ function parseFields(data, value) {
|
|
|
9888
11336
|
}
|
|
9889
11337
|
return fields2;
|
|
9890
11338
|
}
|
|
9891
|
-
function
|
|
9892
|
-
|
|
9893
|
-
|
|
9894
|
-
|
|
9895
|
-
|
|
11339
|
+
function pickUnit(io, ui) {
|
|
11340
|
+
return async () => {
|
|
11341
|
+
const spin = ui.spinner();
|
|
11342
|
+
spin.start("Loading your units");
|
|
11343
|
+
try {
|
|
11344
|
+
return await selectUnit(spin);
|
|
11345
|
+
} catch (error) {
|
|
11346
|
+
spin.stop("Could not load your units");
|
|
11347
|
+
throw error;
|
|
11348
|
+
}
|
|
11349
|
+
};
|
|
11350
|
+
async function selectUnit(spin) {
|
|
11351
|
+
const { baseUrl } = await loadConfig({ env: io.env, cwd: io.cwd, homeDir: io.homeDir, stdin: io.stdin, stderr: io.stderr ?? process.stderr, fetch: io.fetchImpl });
|
|
11352
|
+
const client = await createMoodleClient(baseUrl, { env: io.env, fetchImpl: io.fetchImpl, homeDir: io.homeDir });
|
|
11353
|
+
const courses = await client.getCourses();
|
|
11354
|
+
spin.stop(`${courses.length} units`);
|
|
11355
|
+
return ui.select("Which unit?", courses.map((course) => ({ value: String(course.id), label: course.fullname, ...course.shortname ? { hint: course.shortname } : {} })));
|
|
11356
|
+
}
|
|
9896
11357
|
}
|
|
9897
11358
|
function parseRootOutputOptions(args) {
|
|
9898
11359
|
const fieldsIndex = args.findIndex((arg) => arg === "--fields" || arg.startsWith("--fields="));
|
|
@@ -9925,7 +11386,7 @@ function pathsReferToSameFile(moduleUrl, executable) {
|
|
|
9925
11386
|
var isMain = import.meta.main === true || pathsReferToSameFile(import.meta.url, process.argv[1]);
|
|
9926
11387
|
if (isMain) {
|
|
9927
11388
|
runCli().then((code) => {
|
|
9928
|
-
process.exitCode = code;
|
|
11389
|
+
process.exitCode = code || process.exitCode;
|
|
9929
11390
|
});
|
|
9930
11391
|
}
|
|
9931
11392
|
export {
|