moodle-cli 0.9.1 → 0.9.3
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 +18 -3
- package/SKILL.md +3 -2
- package/dist/moodle.js +1197 -251
- package/dist/worker/recovery.js +622 -103
- package/dist/worker/worker.js +622 -103
- package/package.json +2 -2
- package/references/command-reference.md +7 -0
package/dist/moodle.js
CHANGED
|
@@ -11,7 +11,10 @@ var fields = (keys) => Object.fromEntries(keys.map((key) => [key, s]));
|
|
|
11
11
|
var file = z.object({ name: s, url: s, requires_authentication: z.boolean().optional() });
|
|
12
12
|
var activityFields = { id, name: s, type: s, unit_id: n, section_id: n, hidden: z.boolean().optional(), due: s, due_at: n, files: z.array(file).optional() };
|
|
13
13
|
var activityListSchema = z.object({ ...activityFields, section: s, unit_code: s, description: s });
|
|
14
|
-
var
|
|
14
|
+
var criterion = z.object({ name: s, level: s, score: s, remark: s });
|
|
15
|
+
var attemptSummary = { id, status: s, started: s, completed: s, duration: s, marks: s, grade: s };
|
|
16
|
+
var question = z.object({ number: id, type: s, state: s, mark: s, text: s, response: s, correct: s, feedback: s });
|
|
17
|
+
var activitySchema = z.object({ ...activityFields, ...fields(["url", "target_url", "section", "unit_code", "description", "submission_status", "grading_status", "grade", "graded_on", "graded_by", "feedback_comments", "due_pretty", "opens_pretty", "closes_pretty", "attempts_allowed", "time_limit", "availability", "time_remaining", "content_text"]), criteria: z.array(criterion).optional(), attempts: z.array(z.object({ ...attemptSummary, number: id })).optional(), files: z.array(file).optional() });
|
|
15
18
|
var sectionSchema = z.object({ id, name: s, activity_count: id, hidden: z.boolean().optional(), positional: z.boolean().optional(), activities: z.array(activityListSchema).optional() });
|
|
16
19
|
var current = z.object({ id, name: s, estimated: z.boolean().optional() });
|
|
17
20
|
var unitSchema = z.object({ id, code: s, name: s, start: s, end: s, start_at: n, end_at: n, hidden: z.boolean().optional(), current_section: current.optional() });
|
|
@@ -25,25 +28,30 @@ var receiptSchema = z.object({ id, name: s, unit_id: n, url: s, action: z.enum([
|
|
|
25
28
|
var input = (shape) => z.object(shape).strict();
|
|
26
29
|
var list = (key, value) => z.object({ [key]: z.array(value).optional(), total: id });
|
|
27
30
|
var intentContracts = {
|
|
28
|
-
home: { when: "dashboard", command: "moodle", what: "Today
|
|
31
|
+
home: { when: "dashboard", command: "moodle", what: "Today's date and timezone, items due soon, unread counts and the current section of each unit.", instead: "due for longer deadline lists", refs: "days defaults to 14", then: "unit or item", cost: "small dashboard", input: input({ days: z.number().int().min(1).max(365).default(14) }), output: z.object({ home: z.object({ today: z.string(), timezone: z.string(), timezone_source: s, name: s, siteurl: s, units: z.array(unitSchema).optional(), due: z.array(dueSchema).optional(), total: id, unread: counts.optional(), errors: z.array(z.string()).optional() }) }) },
|
|
29
32
|
due: { when: "deadlines", command: "moodle due [UNIT] --days 14", what: "Items due in a date window.", instead: "item for submission details", refs: "unit code, name, id or URL", then: "item with activity_id", cost: "up to 20 rows by default", input: input({ unit: ref.optional(), days: z.number().int().min(1).max(365).default(14), limit }), output: list("due", dueSchema) },
|
|
30
33
|
units: { when: "unit names", command: "moodle units", what: "Enrolled units with ids, codes and names.", instead: "unit for sections", refs: "none", then: "unit with a name or id", cost: "small list", input: input({ limit: limit.default(200) }), output: list("units", unitSchema) },
|
|
31
34
|
unit: { when: "a unit or section", command: "moodle UNIT [SECTION]", what: "Section index and current section; section argument returns activities and files.", instead: "find for a named item", refs: "unit code, name, id or URL; section number or name", then: "item or file with activity id", cost: "index about 4 KB; section about 0.5 KB", input: input({ unit: ref, section: ref.optional() }), output: z.object({ unit: unitSchema, sections: z.array(sectionSchema).optional(), total: id }) },
|
|
32
35
|
find: { when: "find slides or a task", command: 'moodle find "QUERY" [UNIT]', what: "Ranked sections, activities and discussion subjects.", instead: "search_forums for post text; due for deadlines", refs: "query and optional unit code, name, id or URL", then: "item or file with id", cost: "up to 20 rows by default", input: input({ query: z.string().trim().min(1), unit: ref.optional(), types: z.array(z.string()).optional(), limit }), output: list("results", activityListSchema) },
|
|
33
|
-
item: { when: "submission or item detail", command: 'moodle UNIT "TASK"', what: "
|
|
34
|
-
|
|
36
|
+
item: { when: "submission or item detail", command: 'moodle UNIT "TASK"', what: "One activity in full: due date, status, grade, marker feedback with rubric or marking-guide rows and feedback files, attached files, quiz attempts, or a forum's latest threads.", instead: "file for binary content; attempt for quiz answers", refs: "id, same-site URL, or UNIT TASK phrase", then: "file with a file id, or attempt with an attempt id", cost: "one item", input: input({ ref }), output: z.object({ item: activitySchema, threads: z.array(z.object({ id, name: s })).optional(), total: n }) },
|
|
37
|
+
attempt: { when: "a quiz attempt", command: "moodle attempt ID", what: "Each question with your response, and mark, correct answer and feedback when the site shows them.", instead: "item for the attempt list", refs: "attempt id or review URL", then: "item with quiz_id", cost: "one attempt", input: input({ attempt: ref }), output: z.object({ attempt: z.object({ ...attemptSummary, quiz_id: n, unit_id: n, url: s, questions: z.array(question).optional() }) }) },
|
|
38
|
+
grades: { when: "my grades", command: "moodle grades [UNIT]", what: "Gradebook rows across units or in one unit, with grade, range, percentage and grader feedback.", instead: "item for submission status and rubric detail", refs: "optional unit code, name, id or URL", then: "item for an ungraded task", cost: "graded_only narrows output", input: input({ unit: ref.optional(), graded_only: z.boolean().default(false) }), output: list("grades", gradeSchema) },
|
|
35
39
|
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() })) },
|
|
36
40
|
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 }) }) },
|
|
37
41
|
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
42
|
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 }) },
|
|
39
|
-
file: { when: "download a file", command: 'moodle get "UNIT TASK" --to DIR', what: "One authenticated file as
|
|
43
|
+
file: { when: "download a file", command: 'moodle get "UNIT TASK" --to DIR', what: "One authenticated file: an image as image content, anything else as embedded binary, at most 16 MiB.", instead: "item for file choices", refs: "resource id, same-site URL, or UNIT TASK phrase", then: "read the returned image or 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() }) }) }
|
|
40
44
|
};
|
|
41
45
|
function humanDescription(name) {
|
|
42
46
|
return intentContracts[name].what;
|
|
43
47
|
}
|
|
44
48
|
function intentDescription(name) {
|
|
45
49
|
const c = intentContracts[name];
|
|
46
|
-
|
|
50
|
+
const alternatives = c.instead.split("; ").map((rule) => {
|
|
51
|
+
const [tool, ...purpose] = rule.split(" for ");
|
|
52
|
+
return `for ${purpose.join(" for ")} call ${tool}`;
|
|
53
|
+
}).join("; ");
|
|
54
|
+
return `${c.what} Use it for ${c.when}; ${alternatives}. Input: ${c.refs}. Next: ${c.then}. Cost: ${c.cost}.`;
|
|
47
55
|
}
|
|
48
56
|
|
|
49
57
|
// src/results.ts
|
|
@@ -182,11 +190,17 @@ import { join as join5 } from "path";
|
|
|
182
190
|
// src/constants.ts
|
|
183
191
|
var PACKAGE_NAME = "moodle-cli";
|
|
184
192
|
var NPM_LATEST_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
|
|
193
|
+
var GITHUB_RELEASES_URL = "https://github.com/bunizao/moodle-cli/releases/latest";
|
|
185
194
|
var AJAX_SERVICE_PATH = "/lib/ajax/service.php";
|
|
186
195
|
var DASHBOARD_PATH = "/my/";
|
|
187
196
|
var COURSE_PATH = "/course/view.php";
|
|
188
197
|
var ASSIGN_VIEW_PATH = "/mod/assign/view.php";
|
|
189
198
|
var QUIZ_VIEW_PATH = "/mod/quiz/view.php";
|
|
199
|
+
var QUIZ_REVIEW_PATH = "/mod/quiz/review.php";
|
|
200
|
+
var QUIZ_START_PATH = "/mod/quiz/startattempt.php";
|
|
201
|
+
var QUIZ_ATTEMPT_PATH = "/mod/quiz/attempt.php";
|
|
202
|
+
var QUIZ_SUMMARY_PATH = "/mod/quiz/summary.php";
|
|
203
|
+
var QUIZ_PROCESS_PATH = "/mod/quiz/processattempt.php";
|
|
190
204
|
var RESOURCE_VIEW_PATH = "/mod/resource/view.php";
|
|
191
205
|
var URL_VIEW_PATH = "/mod/url/view.php";
|
|
192
206
|
var PAGE_VIEW_PATH = "/mod/page/view.php";
|
|
@@ -381,16 +395,16 @@ async function findChromiumBrowser(options = {}) {
|
|
|
381
395
|
if (options.browserPath) {
|
|
382
396
|
return { name: "Chromium", path: options.browserPath };
|
|
383
397
|
}
|
|
384
|
-
const
|
|
398
|
+
const platform2 = options.platform ?? process.platform;
|
|
385
399
|
const env = options.env ?? process.env;
|
|
386
|
-
if (
|
|
400
|
+
if (platform2 === "linux") {
|
|
387
401
|
for (const candidate of LINUX_BROWSERS) {
|
|
388
|
-
const resolved = resolveFromPath(candidate.path, env,
|
|
402
|
+
const resolved = resolveFromPath(candidate.path, env, platform2);
|
|
389
403
|
if (resolved) return { name: candidate.name, path: resolved };
|
|
390
404
|
}
|
|
391
405
|
return null;
|
|
392
406
|
}
|
|
393
|
-
const candidates =
|
|
407
|
+
const candidates = platform2 === "win32" ? windowsBrowsers(env) : MAC_BROWSERS;
|
|
394
408
|
for (const candidate of candidates) {
|
|
395
409
|
if (await isExecutable(candidate.path)) return candidate;
|
|
396
410
|
}
|
|
@@ -547,9 +561,9 @@ async function isExecutable(path5) {
|
|
|
547
561
|
return false;
|
|
548
562
|
}
|
|
549
563
|
}
|
|
550
|
-
function resolveFromPath(name, env,
|
|
564
|
+
function resolveFromPath(name, env, platform2) {
|
|
551
565
|
if (name.includes("/")) return null;
|
|
552
|
-
const separator =
|
|
566
|
+
const separator = platform2 === "win32" ? ";" : ":";
|
|
553
567
|
for (const dir of (env.PATH ?? "").split(separator).filter(Boolean)) {
|
|
554
568
|
const candidate = join2(dir, name);
|
|
555
569
|
try {
|
|
@@ -841,6 +855,14 @@ function createDeploymentCredentials(createToken) {
|
|
|
841
855
|
function isUnavailable(error) {
|
|
842
856
|
return error instanceof CredentialBackendUnavailableError;
|
|
843
857
|
}
|
|
858
|
+
async function readCredentialsForReport(read) {
|
|
859
|
+
try {
|
|
860
|
+
return { value: await read(), available: true };
|
|
861
|
+
} catch (error) {
|
|
862
|
+
if (!isUnavailable(error)) throw error;
|
|
863
|
+
return { value: null, available: false };
|
|
864
|
+
}
|
|
865
|
+
}
|
|
844
866
|
|
|
845
867
|
// src/mcp/credentials/node-store.ts
|
|
846
868
|
var SERVICE = "moodle-cli-mcp";
|
|
@@ -1252,13 +1274,13 @@ var PrivateFileCredentialBackend = class {
|
|
|
1252
1274
|
}
|
|
1253
1275
|
};
|
|
1254
1276
|
function createDefaultCredentialStore(options = {}) {
|
|
1255
|
-
const
|
|
1277
|
+
const platform2 = options.platform ?? process.platform;
|
|
1256
1278
|
const runner = options.runner ?? new NodeCredentialCommandRunner();
|
|
1257
|
-
const preferred =
|
|
1279
|
+
const preferred = platform2 === "darwin" ? new MacOSKeychainCredentialBackend(runner) : platform2 === "linux" ? new LinuxSecretServiceCredentialBackend(runner) : platform2 === "win32" ? new WindowsCredentialManagerBackend(runner) : new UnavailableCredentialBackend(`${platform2} credential store`);
|
|
1258
1280
|
const home = options.homeDirectory ?? homedir3();
|
|
1259
|
-
const fallbackDirectory =
|
|
1260
|
-
const fallback =
|
|
1261
|
-
return new SafeCredentialStore(preferred, fallback,
|
|
1281
|
+
const fallbackDirectory = platform2 === "win32" ? windowsPath.join(home, "AppData", "Local", "moodle-cli", "credentials") : join3(home, ".config", "moodle-cli", "mcp", "credentials");
|
|
1282
|
+
const fallback = platform2 === "win32" ? new WindowsDpapiFileCredentialBackend(fallbackDirectory, runner) : new PrivateFileCredentialBackend(fallbackDirectory);
|
|
1283
|
+
return new SafeCredentialStore(preferred, fallback, platform2 !== "win32");
|
|
1262
1284
|
}
|
|
1263
1285
|
function parseCredentials(value) {
|
|
1264
1286
|
const parsed = JSON.parse(value);
|
|
@@ -1764,11 +1786,11 @@ async function defaultBrowserCookieProvider(baseUrl, options = {}) {
|
|
|
1764
1786
|
}
|
|
1765
1787
|
async function braveProfilePaths(options = {}) {
|
|
1766
1788
|
const home = options.homeDir ?? homedir5();
|
|
1767
|
-
const
|
|
1768
|
-
const roots =
|
|
1789
|
+
const platform2 = options.platform ?? process.platform;
|
|
1790
|
+
const roots = platform2 === "linux" ? [
|
|
1769
1791
|
join5(home, ".config/BraveSoftware/Brave-Browser"),
|
|
1770
1792
|
join5(home, ".var/app/com.brave.Browser/config/BraveSoftware/Brave-Browser")
|
|
1771
|
-
] :
|
|
1793
|
+
] : platform2 === "win32" ? [join5(home, "AppData/Local/BraveSoftware/Brave-Browser/User Data")] : platform2 === "darwin" ? [join5(home, "Library/Application Support/BraveSoftware/Brave-Browser")] : [];
|
|
1772
1794
|
const profiles = [];
|
|
1773
1795
|
for (const root of roots) {
|
|
1774
1796
|
try {
|
|
@@ -1802,8 +1824,8 @@ var MINIMUM_NODE_FOR_BROWSER_COOKIES = "22.13.0";
|
|
|
1802
1824
|
function cookieAccessBlocked(warnings) {
|
|
1803
1825
|
return warnings.some((warning) => COOKIE_ACCESS_DENIED.test(warning) || COOKIE_SQLITE_UNAVAILABLE.test(warning));
|
|
1804
1826
|
}
|
|
1805
|
-
function cookieAccessHint(warnings,
|
|
1806
|
-
const grant =
|
|
1827
|
+
function cookieAccessHint(warnings, platform2 = process.platform, unreadable = [], env = process.env) {
|
|
1828
|
+
const grant = platform2 === "darwin" ? [
|
|
1807
1829
|
`Grant Full Disk Access to ${hostApplicationName(env) ?? "the application running this command"}, then restart it:`,
|
|
1808
1830
|
` open "${FULL_DISK_ACCESS_PANE}"`
|
|
1809
1831
|
].join("\n") : "Run this command as the user that owns the browser profile, or grant it read access to the browser cookie store.";
|
|
@@ -1835,9 +1857,9 @@ function cookieDiagnostics(warnings, unreadable) {
|
|
|
1835
1857
|
];
|
|
1836
1858
|
return lines.length ? ["", "Cookie store diagnostics:", ...lines] : [];
|
|
1837
1859
|
}
|
|
1838
|
-
function authFailureHint(baseUrl, cookieWarnings = [],
|
|
1860
|
+
function authFailureHint(baseUrl, cookieWarnings = [], platform2 = process.platform, unreadable = [], env = process.env) {
|
|
1839
1861
|
if (cookieAccessBlocked(cookieWarnings) || unreadable.length) {
|
|
1840
|
-
return cookieAccessHint(cookieWarnings,
|
|
1862
|
+
return cookieAccessHint(cookieWarnings, platform2, unreadable, env);
|
|
1841
1863
|
}
|
|
1842
1864
|
return [
|
|
1843
1865
|
`Log in to ${loginUrl(baseUrl)} in your browser, then rerun the command.`,
|
|
@@ -2444,8 +2466,8 @@ function buildKeepalivePlist(programArguments2, intervalMinutes, logPath) {
|
|
|
2444
2466
|
].join("\n");
|
|
2445
2467
|
}
|
|
2446
2468
|
async function installKeepalive(options = {}) {
|
|
2447
|
-
const
|
|
2448
|
-
if (
|
|
2469
|
+
const platform2 = options.platform ?? process.platform;
|
|
2470
|
+
if (platform2 !== "darwin") {
|
|
2449
2471
|
throw new Error(
|
|
2450
2472
|
"Automatic keepalive install requires macOS launchd. Add a cron entry instead: */30 * * * * moodle auth keepalive --json"
|
|
2451
2473
|
);
|
|
@@ -2556,8 +2578,8 @@ async function doctor(options = {}) {
|
|
|
2556
2578
|
}
|
|
2557
2579
|
|
|
2558
2580
|
// src/cli.ts
|
|
2559
|
-
import { rm as
|
|
2560
|
-
import { homedir as
|
|
2581
|
+
import { readFile as readFile11, rm as rm8, readdir as readdir4 } from "fs/promises";
|
|
2582
|
+
import { homedir as homedir16 } from "os";
|
|
2561
2583
|
|
|
2562
2584
|
// src/mcp/renewal/decision.ts
|
|
2563
2585
|
function decideRenewal(snapshot) {
|
|
@@ -2674,6 +2696,19 @@ var RenewalInstaller = class {
|
|
|
2674
2696
|
}
|
|
2675
2697
|
}
|
|
2676
2698
|
};
|
|
2699
|
+
function describeRenewalJob(platform2, homeDirectory, profile, intervalMinutes = 30) {
|
|
2700
|
+
const schedule = `silent check every ${intervalMinutes} minutes`;
|
|
2701
|
+
if (platform2 === "darwin") {
|
|
2702
|
+
const label2 = `com.moodle-cli.mcp-renewal.${profile}`;
|
|
2703
|
+
return { scheduler: "launchd agent", label: label2, schedule, log: `${trimEnd(homeDirectory, "/")}/Library/Logs/${label2}.log` };
|
|
2704
|
+
}
|
|
2705
|
+
if (platform2 === "linux") {
|
|
2706
|
+
const label2 = `moodle-cli-mcp-renewal-${profile}`;
|
|
2707
|
+
return { scheduler: "systemd user timer", label: label2, schedule, log: `journalctl --user -u ${label2}` };
|
|
2708
|
+
}
|
|
2709
|
+
const label = `Moodle CLI MCP Renewal (${profile})`;
|
|
2710
|
+
return { scheduler: "Task Scheduler task", label, schedule, log: "Task Scheduler history" };
|
|
2711
|
+
}
|
|
2677
2712
|
function buildRenewalInstallPlan(options) {
|
|
2678
2713
|
validateOptions(options);
|
|
2679
2714
|
const intervalMinutes = options.intervalMinutes ?? 30;
|
|
@@ -2883,13 +2918,13 @@ var NodeRenewalInstallerIO = class {
|
|
|
2883
2918
|
}
|
|
2884
2919
|
};
|
|
2885
2920
|
function createDefaultRenewalInstaller(profile, options = {}) {
|
|
2886
|
-
const
|
|
2887
|
-
if (!isSupportedPlatform(
|
|
2888
|
-
throw new Error(`Moodle MCP renewal is not supported on ${
|
|
2921
|
+
const platform2 = options.platform ?? process.platform;
|
|
2922
|
+
if (!isSupportedPlatform(platform2)) {
|
|
2923
|
+
throw new Error(`Moodle MCP renewal is not supported on ${platform2}`);
|
|
2889
2924
|
}
|
|
2890
2925
|
const runtime = runtimeCommand(options.executable, options.executableArgs);
|
|
2891
2926
|
const plan = buildRenewalInstallPlan({
|
|
2892
|
-
platform,
|
|
2927
|
+
platform: platform2,
|
|
2893
2928
|
profile,
|
|
2894
2929
|
executable: runtime.command,
|
|
2895
2930
|
executableArgs: runtime.args,
|
|
@@ -2915,8 +2950,8 @@ var DefaultRenewalIntegration = class {
|
|
|
2915
2950
|
}
|
|
2916
2951
|
};
|
|
2917
2952
|
var NodeRenewalNotificationSender = class {
|
|
2918
|
-
constructor(
|
|
2919
|
-
this.platform =
|
|
2953
|
+
constructor(platform2 = process.platform) {
|
|
2954
|
+
this.platform = platform2;
|
|
2920
2955
|
}
|
|
2921
2956
|
platform;
|
|
2922
2957
|
async send(notification) {
|
|
@@ -2960,11 +2995,11 @@ var NodeRenewalNotificationSender = class {
|
|
|
2960
2995
|
}
|
|
2961
2996
|
}
|
|
2962
2997
|
};
|
|
2963
|
-
function notifyRenewalSignInRequired(
|
|
2964
|
-
return sendRenewalNotification("sign_in_required", new NodeRenewalNotificationSender(
|
|
2998
|
+
function notifyRenewalSignInRequired(platform2 = process.platform) {
|
|
2999
|
+
return sendRenewalNotification("sign_in_required", new NodeRenewalNotificationSender(platform2));
|
|
2965
3000
|
}
|
|
2966
|
-
function isSupportedPlatform(
|
|
2967
|
-
return
|
|
3001
|
+
function isSupportedPlatform(platform2) {
|
|
3002
|
+
return platform2 === "darwin" || platform2 === "linux" || platform2 === "win32";
|
|
2968
3003
|
}
|
|
2969
3004
|
function isMissing2(error) {
|
|
2970
3005
|
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
@@ -3006,6 +3041,7 @@ function createMoodleGateway(client, hooks = {}) {
|
|
|
3006
3041
|
return limit2 === void 0 ? activities : activities.slice(0, limit2);
|
|
3007
3042
|
},
|
|
3008
3043
|
getActivity: ({ activityId }) => client.getActivity(activityId),
|
|
3044
|
+
...client.getQuizAttempt ? { getQuizAttempt: (attemptId) => client.getQuizAttempt(attemptId) } : {},
|
|
3009
3045
|
getGrades: ({ courseId }) => client.getCourseGrades(courseId),
|
|
3010
3046
|
async listForums({ courseId, limit: limit2 }) {
|
|
3011
3047
|
const forums = await client.getForums(courseId);
|
|
@@ -3046,7 +3082,9 @@ function createMoodleGateway(client, hooks = {}) {
|
|
|
3046
3082
|
name,
|
|
3047
3083
|
mimeType: contentType(response),
|
|
3048
3084
|
bytes: content.byteLength,
|
|
3049
|
-
|
|
3085
|
+
// The Moodle-origin URL, never the post-redirect CDN one: stripping query parameters from
|
|
3086
|
+
// a signed CDN link leaves an address that can only ever answer MissingKey.
|
|
3087
|
+
uri: publicFileUrl(target.url),
|
|
3050
3088
|
blob: encodeBase64(content)
|
|
3051
3089
|
};
|
|
3052
3090
|
}
|
|
@@ -3411,6 +3449,15 @@ function createIntentService(gateway, now = () => Date.now()) {
|
|
|
3411
3449
|
result = { item: { ...itemRow(activity), ...due }, threads: threads?.slice(0, 20).map((t) => ({ id: t.id, name: t.subject })), total: threads?.length };
|
|
3412
3450
|
break;
|
|
3413
3451
|
}
|
|
3452
|
+
case "attempt": {
|
|
3453
|
+
if (!gateway.getQuizAttempt) throw new ReferenceError("not_found", "This gateway cannot read quiz attempts.", []);
|
|
3454
|
+
const raw = String(input2.attempt);
|
|
3455
|
+
const id2 = raw.includes("://") ? Number(new URL(raw).searchParams.get("attempt")) : Number(raw);
|
|
3456
|
+
if (!Number.isInteger(id2) || id2 <= 0) throw new ReferenceError("not_found", "Use a quiz attempt id or a review URL with ?attempt=.", []);
|
|
3457
|
+
const { course_id, questions, ...attempt } = await gateway.getQuizAttempt(id2);
|
|
3458
|
+
result = { attempt: { ...attempt, unit_id: course_id || void 0, questions } };
|
|
3459
|
+
break;
|
|
3460
|
+
}
|
|
3414
3461
|
case "home":
|
|
3415
3462
|
case "due": {
|
|
3416
3463
|
const data = name === "due" && gateway.getDue ? { user: await user(), courses: await courses(), todo: await gateway.getDue(Number(input2.days)), errors: [] } : await overview(Number(input2.days));
|
|
@@ -3541,12 +3588,12 @@ function renderTerminalTable(columns, rows, options = {}) {
|
|
|
3541
3588
|
const minimum = widths.reduce((n2, w, i) => n2 + (flexible.includes(i) ? 8 : w), 0) + columns.length * 3 + 1;
|
|
3542
3589
|
const title = options.title ? [theme.subject(sanitizeTerminalText(options.title))] : [];
|
|
3543
3590
|
if (width < 60 || minimum > width) {
|
|
3544
|
-
const
|
|
3591
|
+
const wrap2 = (line2) => Array.from(line2).reduce((lines, char) => {
|
|
3545
3592
|
if (!lines.length || Array.from(lines.at(-1)).length >= width) lines.push("");
|
|
3546
3593
|
lines[lines.length - 1] += char;
|
|
3547
3594
|
return lines;
|
|
3548
3595
|
}, []);
|
|
3549
|
-
return [...title, ...clean.flatMap((row) => [...columns.flatMap((c, i) =>
|
|
3596
|
+
return [...title, ...clean.flatMap((row) => [...columns.flatMap((c, i) => wrap2(`${c.label}: ${row[i]}`)), ""])].join("\n").trimEnd();
|
|
3550
3597
|
}
|
|
3551
3598
|
while (widths.reduce((n2, v) => n2 + v, 0) + columns.length * 3 + 1 > width) {
|
|
3552
3599
|
const index = flexible.reduce((best, i) => widths[i] > (widths[best] ?? 0) ? i : best, -1);
|
|
@@ -3670,9 +3717,20 @@ function renderScreen(data, options = {}) {
|
|
|
3670
3717
|
const i = record(data.item);
|
|
3671
3718
|
lines.push(`${text(i.name)} \xB7 ${text(i.type)} \xB7 #${i.id}`);
|
|
3672
3719
|
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)}`);
|
|
3720
|
+
for (const a of array(i.attempts)) lines.push(`Attempt ${a.number} \xB7 #${a.id} ${[a.status, a.marks, a.grade, a.completed].map(text).filter(Boolean).join(" \xB7 ")}`);
|
|
3721
|
+
for (const c of array(i.criteria)) lines.push(`${text(c.name)} ${[c.score, c.level, c.remark].map(text).filter(Boolean).join(" \xB7 ")}`);
|
|
3673
3722
|
for (const f of array(i.files)) lines.push(`File ${text(f.name)} ${text(f.url)}`);
|
|
3674
3723
|
if (data.threads) rows(array(data.threads), "Threads");
|
|
3675
3724
|
next = [`moodle get ${i.id} --to DIR`];
|
|
3725
|
+
} else if (data.attempt) {
|
|
3726
|
+
const a = record(data.attempt);
|
|
3727
|
+
lines.push(`Attempt #${a.id} \xB7 ${[a.status, a.marks, a.grade].map(text).filter(Boolean).join(" \xB7 ")}`);
|
|
3728
|
+
for (const q of array(a.questions)) {
|
|
3729
|
+
lines.push("", `Q${q.number} \xB7 ${[q.type, q.state, q.mark].map(text).filter(Boolean).join(" \xB7 ")}`, text(q.text), `You: ${text(q.response)}`);
|
|
3730
|
+
if (q.correct) lines.push(`Correct: ${text(q.correct)}`);
|
|
3731
|
+
if (q.feedback) lines.push(`Feedback: ${text(q.feedback)}`);
|
|
3732
|
+
}
|
|
3733
|
+
next = [`moodle item ${a.quiz_id}`];
|
|
3676
3734
|
} else if (data.thread) {
|
|
3677
3735
|
const t = record(data.thread);
|
|
3678
3736
|
lines.push(text(t.name));
|
|
@@ -3713,13 +3771,13 @@ function renderScreen(data, options = {}) {
|
|
|
3713
3771
|
}
|
|
3714
3772
|
|
|
3715
3773
|
// src/cli.ts
|
|
3716
|
-
import { spawn as
|
|
3774
|
+
import { spawn as spawn5 } from "child_process";
|
|
3717
3775
|
import {
|
|
3718
3776
|
banner,
|
|
3719
3777
|
colorEnabled as colorEnabled2,
|
|
3720
3778
|
confirm,
|
|
3721
3779
|
createProgram,
|
|
3722
|
-
createTheme as
|
|
3780
|
+
createTheme as createTheme5,
|
|
3723
3781
|
createUi as createUi3,
|
|
3724
3782
|
detectAudience,
|
|
3725
3783
|
examples,
|
|
@@ -3734,7 +3792,7 @@ import {
|
|
|
3734
3792
|
mutating,
|
|
3735
3793
|
writeOutput
|
|
3736
3794
|
} from "@bunizao/cli-kit";
|
|
3737
|
-
import { realpathSync as
|
|
3795
|
+
import { realpathSync as realpathSync4 } from "fs";
|
|
3738
3796
|
import path4 from "path";
|
|
3739
3797
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
3740
3798
|
|
|
@@ -4038,6 +4096,8 @@ function parseGradeOverviewRows(html, baseUrl) {
|
|
|
4038
4096
|
return rows;
|
|
4039
4097
|
}
|
|
4040
4098
|
function parseAssignmentHtml(html, assignmentId, baseUrl) {
|
|
4099
|
+
const root = parse3(html);
|
|
4100
|
+
const feedback = root.querySelector(".feedback");
|
|
4041
4101
|
return {
|
|
4042
4102
|
id: assignmentId,
|
|
4043
4103
|
name: pageTitle(html),
|
|
@@ -4047,9 +4107,39 @@ function parseAssignmentHtml(html, assignmentId, baseUrl) {
|
|
|
4047
4107
|
grading_status: findTableValue(html, "Grading status"),
|
|
4048
4108
|
time_remaining: findTableValue(html, "Time remaining"),
|
|
4049
4109
|
grade: findTableValue(html, "Grade"),
|
|
4110
|
+
graded_on: feedback ? findTableValue(feedback.toString(), "Graded on") : "",
|
|
4111
|
+
graded_by: feedback ? findTableValue(feedback.toString(), "Graded by") : "",
|
|
4112
|
+
feedback_comments: feedback ? findTableValue(feedback.toString(), "Feedback comments") : "",
|
|
4113
|
+
criteria: feedback ? parseFeedbackCriteria(feedback) : [],
|
|
4114
|
+
file_entries: feedback ? parseFeedbackFiles(feedback, baseUrl) : [],
|
|
4050
4115
|
url: `${baseUrl.replace(/\/$/, "")}/mod/assign/view.php?id=${assignmentId}`
|
|
4051
4116
|
};
|
|
4052
4117
|
}
|
|
4118
|
+
function parseFeedbackCriteria(feedback) {
|
|
4119
|
+
const criteria = [];
|
|
4120
|
+
for (const row of feedback.querySelectorAll("tr.criterion")) {
|
|
4121
|
+
const level = row.querySelector("td.level.checked");
|
|
4122
|
+
const name = cleanNodeText(row.querySelector(".criterionshortname") ?? row.querySelector("td.description"));
|
|
4123
|
+
if (!name) continue;
|
|
4124
|
+
criteria.push({
|
|
4125
|
+
name,
|
|
4126
|
+
level: cleanNodeText(level?.querySelector(".definition")),
|
|
4127
|
+
score: cleanNodeText(row.querySelector("td.score") ?? level?.querySelector(".score")),
|
|
4128
|
+
remark: cleanTableCell(row.querySelector("td.remark"))
|
|
4129
|
+
});
|
|
4130
|
+
}
|
|
4131
|
+
return criteria;
|
|
4132
|
+
}
|
|
4133
|
+
function parseFeedbackFiles(feedback, baseUrl) {
|
|
4134
|
+
const entries = [];
|
|
4135
|
+
for (const link2 of feedback.querySelectorAll('a[href*="pluginfile.php"]')) {
|
|
4136
|
+
const url = resolveUrl(baseUrl, link2.getAttribute("href") ?? "");
|
|
4137
|
+
const label = cleanNodeText(link2);
|
|
4138
|
+
const name = /\.\w{1,5}$/u.test(label) ? label : decodeURIComponent(new URL(url).pathname.split("/").at(-1) || "file");
|
|
4139
|
+
if (!entries.some((entry) => entry.url === url)) entries.push(fileEntry(name, url, baseUrl));
|
|
4140
|
+
}
|
|
4141
|
+
return entries;
|
|
4142
|
+
}
|
|
4053
4143
|
function parseQuizHtml(html, quizId, baseUrl) {
|
|
4054
4144
|
const root = parse3(html);
|
|
4055
4145
|
return {
|
|
@@ -4058,12 +4148,85 @@ function parseQuizHtml(html, quizId, baseUrl) {
|
|
|
4058
4148
|
...activityContext(html),
|
|
4059
4149
|
opens_pretty: extractLabeledText(html, "Opens:"),
|
|
4060
4150
|
closes_pretty: extractLabeledText(html, "Closes:"),
|
|
4061
|
-
attempts_allowed:
|
|
4151
|
+
attempts_allowed: labeledParagraph(root, "Attempts allowed:"),
|
|
4152
|
+
time_limit: labeledParagraph(root, "Time limit:"),
|
|
4062
4153
|
availability: cleanText(root.textContent.match(/This quiz is currently[^\n]+/i)?.[0] ?? ""),
|
|
4063
4154
|
grade: findTableValue(html, "Grade"),
|
|
4155
|
+
attempts: parseQuizAttempts(root, baseUrl),
|
|
4064
4156
|
url: `${baseUrl.replace(/\/$/, "")}/mod/quiz/view.php?id=${quizId}`
|
|
4065
4157
|
};
|
|
4066
4158
|
}
|
|
4159
|
+
function labeledParagraph(root, label) {
|
|
4160
|
+
for (const p of root.querySelectorAll("p")) {
|
|
4161
|
+
const line = cleanNodeText(p);
|
|
4162
|
+
if (line.startsWith(label)) return cleanText(line.slice(label.length));
|
|
4163
|
+
}
|
|
4164
|
+
return "";
|
|
4165
|
+
}
|
|
4166
|
+
function parseQuizAttempts(root, baseUrl) {
|
|
4167
|
+
const attempts = [];
|
|
4168
|
+
for (const table of root.querySelectorAll("table.quizreviewsummary")) {
|
|
4169
|
+
const card = table.closest(".card") ?? table.parentNode;
|
|
4170
|
+
const link2 = card?.querySelector('a[href*="/mod/quiz/review.php"]');
|
|
4171
|
+
const reviewUrl = link2 ? resolveUrl(baseUrl, link2.getAttribute("href") ?? "") : "";
|
|
4172
|
+
const id2 = numberQueryValue(reviewUrl, "attempt");
|
|
4173
|
+
if (!link2 || id2 === null) continue;
|
|
4174
|
+
const summary = tableValues(table);
|
|
4175
|
+
const number = Number(cleanNodeText(card?.querySelector(".card-title")).match(/\d+/)?.[0] ?? attempts.length + 1);
|
|
4176
|
+
attempts.push({ id: id2, number, status: summary.Status ?? "", started: summary.Started ?? "", completed: summary.Completed ?? "", duration: summary.Duration ?? "", marks: summary.Marks ?? "", grade: summary.Grade ?? "", review_url: reviewUrl });
|
|
4177
|
+
}
|
|
4178
|
+
return attempts;
|
|
4179
|
+
}
|
|
4180
|
+
function parseQuizReviewHtml(html, attemptId, baseUrl) {
|
|
4181
|
+
const root = parse3(html);
|
|
4182
|
+
const summary = tableValues(root.querySelector("table.quizreviewsummary"));
|
|
4183
|
+
const form = root.querySelector("form.questionflagsaveform");
|
|
4184
|
+
const url = `${baseUrl.replace(/\/$/, "")}/mod/quiz/review.php?attempt=${attemptId}`;
|
|
4185
|
+
return {
|
|
4186
|
+
id: attemptId,
|
|
4187
|
+
quiz_id: numberQueryValue(form?.getAttribute("action") ?? "", "cmid") ?? 0,
|
|
4188
|
+
course_id: parseCourseIdFromPageHtml(html) ?? 0,
|
|
4189
|
+
status: summary.Status ?? "",
|
|
4190
|
+
started: summary.Started ?? "",
|
|
4191
|
+
completed: summary.Completed ?? "",
|
|
4192
|
+
duration: summary.Duration ?? "",
|
|
4193
|
+
marks: summary.Marks ?? "",
|
|
4194
|
+
grade: summary.Grade ?? "",
|
|
4195
|
+
questions: root.querySelectorAll("div.que").map(parseQuizQuestion),
|
|
4196
|
+
url
|
|
4197
|
+
};
|
|
4198
|
+
}
|
|
4199
|
+
function parseQuizQuestion(que) {
|
|
4200
|
+
const answer = que.querySelector(".answer");
|
|
4201
|
+
const picked = answer?.querySelectorAll("input:checked, input[checked]").map((input2) => {
|
|
4202
|
+
const label = input2.getAttribute("aria-labelledby");
|
|
4203
|
+
return cleanTableCell(label ? que.querySelector(`[id="${label}"]`) : input2.parentNode);
|
|
4204
|
+
}).filter(Boolean) ?? [];
|
|
4205
|
+
const typed = cleanText(que.querySelector('input[type="text"], input[type="number"]')?.getAttribute("value") ?? "");
|
|
4206
|
+
const response = picked.length ? picked.join("; ") : typed || blockText(answer?.querySelector(".qtype_essay_response") ?? answer);
|
|
4207
|
+
return {
|
|
4208
|
+
number: Number(cleanNodeText(que.querySelector(".qno")) || 0),
|
|
4209
|
+
type: que.classList.value[1] ?? "",
|
|
4210
|
+
state: cleanNodeText(que.querySelector(".info .state")),
|
|
4211
|
+
mark: cleanNodeText(que.querySelector(".info .grade")).replace(/^Mark\s+/u, ""),
|
|
4212
|
+
text: blockText(que.querySelector(".qtext")),
|
|
4213
|
+
response: response.replace(/\s*Word count: \d+$/u, ""),
|
|
4214
|
+
correct: blockText(que.querySelector(".rightanswer")).replace(/^The correct answers? (?:is|are):?\s*/iu, "").replace(/^'(.*)'\.?$/u, "$1"),
|
|
4215
|
+
feedback: blockText(que.querySelector(".outcome .feedback"))
|
|
4216
|
+
};
|
|
4217
|
+
}
|
|
4218
|
+
function blockText(node) {
|
|
4219
|
+
if (!node) return "";
|
|
4220
|
+
return cleanTableCell(parse3(node.toString().replace(/<br\s*\/?>|<\/(?:p|div|li|h\d|tr)>/giu, "$& ")));
|
|
4221
|
+
}
|
|
4222
|
+
function tableValues(table) {
|
|
4223
|
+
const values = {};
|
|
4224
|
+
for (const row of table?.querySelectorAll("tr") ?? []) {
|
|
4225
|
+
const cells = row.querySelectorAll("th, td");
|
|
4226
|
+
if (cells.length >= 2) values[cleanNodeText(cells[0])] = cleanTableCell(cells[1]);
|
|
4227
|
+
}
|
|
4228
|
+
return values;
|
|
4229
|
+
}
|
|
4067
4230
|
function parseResourceHtml(html, resourceId, baseUrl) {
|
|
4068
4231
|
const root = parse3(html);
|
|
4069
4232
|
const link2 = root.querySelector(".resourceworkaround a[href], .resourcecontent a[href], a.resourceworkaround[href]");
|
|
@@ -4243,12 +4406,12 @@ function parseForumGroupsHtml(html) {
|
|
|
4243
4406
|
}
|
|
4244
4407
|
const groups = [];
|
|
4245
4408
|
const seen = /* @__PURE__ */ new Set();
|
|
4246
|
-
for (const
|
|
4247
|
-
const groupId = safeInt(
|
|
4409
|
+
for (const option2 of select.querySelectorAll("option")) {
|
|
4410
|
+
const groupId = safeInt(option2.getAttribute("value"));
|
|
4248
4411
|
if (!groupId) {
|
|
4249
4412
|
continue;
|
|
4250
4413
|
}
|
|
4251
|
-
const groupName = cleanNodeText(
|
|
4414
|
+
const groupName = cleanNodeText(option2);
|
|
4252
4415
|
const key = `${groupId}:${groupName}`;
|
|
4253
4416
|
if (seen.has(key)) {
|
|
4254
4417
|
continue;
|
|
@@ -4268,9 +4431,9 @@ function selectedGroupName(root, groupId) {
|
|
|
4268
4431
|
return "";
|
|
4269
4432
|
}
|
|
4270
4433
|
for (const selector of ["select[name='groupinfo']", "select[name='group']"]) {
|
|
4271
|
-
const
|
|
4272
|
-
if (
|
|
4273
|
-
return cleanNodeText(
|
|
4434
|
+
const option2 = root.querySelector(selector)?.querySelector(`option[value='${groupId}']`) ?? null;
|
|
4435
|
+
if (option2) {
|
|
4436
|
+
return cleanNodeText(option2);
|
|
4274
4437
|
}
|
|
4275
4438
|
}
|
|
4276
4439
|
return "";
|
|
@@ -4386,7 +4549,7 @@ function cleanTableCell(node) {
|
|
|
4386
4549
|
return "";
|
|
4387
4550
|
}
|
|
4388
4551
|
const clone = parse3(node.toString());
|
|
4389
|
-
for (const unwanted of clone.querySelectorAll(".action-menu, .dropdown, script, style")) {
|
|
4552
|
+
for (const unwanted of clone.querySelectorAll(".action-menu, .dropdown, .hidden, .accesshide, script, style")) {
|
|
4390
4553
|
unwanted.remove();
|
|
4391
4554
|
}
|
|
4392
4555
|
return cleanText(clone.textContent.replace("( Empty )", "(Empty)"));
|
|
@@ -4604,7 +4767,7 @@ function formFields(form) {
|
|
|
4604
4767
|
}
|
|
4605
4768
|
if (tag === "select") {
|
|
4606
4769
|
const options = element.querySelectorAll("option");
|
|
4607
|
-
const chosen = options.find((
|
|
4770
|
+
const chosen = options.find((option2) => option2.hasAttribute("selected")) ?? options[0];
|
|
4608
4771
|
if (chosen) fields2.push([name, chosen.getAttribute("value") ?? cleanText(chosen.textContent)]);
|
|
4609
4772
|
continue;
|
|
4610
4773
|
}
|
|
@@ -4778,6 +4941,292 @@ function record2(value) {
|
|
|
4778
4941
|
return isRecord4(value) ? value : {};
|
|
4779
4942
|
}
|
|
4780
4943
|
|
|
4944
|
+
// src/moodle-quiz-core.ts
|
|
4945
|
+
import { parse as parse5 } from "node-html-parser";
|
|
4946
|
+
async function startQuizAttempt(deps, quizId, options = {}) {
|
|
4947
|
+
if (!Number.isSafeInteger(quizId) || quizId <= 0) throw deps.usage("The quiz id must be a positive integer.");
|
|
4948
|
+
const viewUrl = `${deps.baseUrl}${QUIZ_VIEW_PATH}?id=${quizId}`;
|
|
4949
|
+
const viewHtml = await pageText2(deps, viewUrl);
|
|
4950
|
+
const root = parse5(viewHtml);
|
|
4951
|
+
if (/safeexambrowser|Safe Exam Browser/iu.test(viewHtml)) throw deps.usage("This quiz requires the Safe Exam Browser, which the CLI cannot provide.", "Open it in the browser Moodle asks for.");
|
|
4952
|
+
const resume = formWithAction2(root, QUIZ_ATTEMPT_PATH) ?? root.querySelector(`a[href*="${QUIZ_ATTEMPT_PATH}?"]`);
|
|
4953
|
+
if (resume) {
|
|
4954
|
+
const target = resume.tagName.toLowerCase() === "form" ? `${resolveUrl(deps.baseUrl, resume.getAttribute("action") ?? "")}?${new URLSearchParams(formFields2(resume)).toString()}` : resolveUrl(deps.baseUrl, resume.getAttribute("href") ?? "");
|
|
4955
|
+
const attempt = numberParam(target, "attempt");
|
|
4956
|
+
if (attempt) return getAttemptPage(deps, attempt, quizId, 0);
|
|
4957
|
+
}
|
|
4958
|
+
const start = formWithAction2(root, QUIZ_START_PATH);
|
|
4959
|
+
if (!start) {
|
|
4960
|
+
const reason = cleanText(root.querySelector(".quizattempt, .quizinfo")?.textContent) || "the quiz page shows no attempt button";
|
|
4961
|
+
throw deps.usage(`Moodle offers no new attempt: ${reason}`, `See ${viewUrl}`);
|
|
4962
|
+
}
|
|
4963
|
+
let response = await deps.request(resolveUrl(deps.baseUrl, start.getAttribute("action") ?? ""), postInit(formFields2(start)));
|
|
4964
|
+
let html = await response.text();
|
|
4965
|
+
if (!onPath(response.url, QUIZ_ATTEMPT_PATH)) {
|
|
4966
|
+
const preflight = formWithAction2(parse5(html), QUIZ_START_PATH);
|
|
4967
|
+
if (!preflight) throw deps.fail(`Moodle did not start the attempt: ${noticesOf2(html) || "it returned an unexpected page"}`);
|
|
4968
|
+
const fields2 = [...formFields2(preflight).filter(([name]) => name !== "quizpassword"), ["submitbutton", "Start attempt"]];
|
|
4969
|
+
if (preflight.querySelector("input[name=quizpassword]")) {
|
|
4970
|
+
const password = options.password ? await options.password() : null;
|
|
4971
|
+
if (!password) throw deps.usage("This quiz needs its access password to start.", "Run moodle quiz start again at a terminal to be asked for it, or pass --password.");
|
|
4972
|
+
fields2.push(["quizpassword", password]);
|
|
4973
|
+
}
|
|
4974
|
+
response = await deps.request(resolveUrl(deps.baseUrl, preflight.getAttribute("action") ?? ""), postInit(fields2));
|
|
4975
|
+
html = await response.text();
|
|
4976
|
+
if (!onPath(response.url, QUIZ_ATTEMPT_PATH)) throw deps.fail(`Moodle did not start the attempt: ${noticesOf2(html) || "it returned the pre-flight form again"}`);
|
|
4977
|
+
}
|
|
4978
|
+
return withoutForm(parseAttemptPage(html, response.url, deps));
|
|
4979
|
+
}
|
|
4980
|
+
async function getAttemptPage(deps, attemptId, quizId, page) {
|
|
4981
|
+
return withoutForm(await loadAttemptPage(deps, attemptId, quizId, page));
|
|
4982
|
+
}
|
|
4983
|
+
async function loadAttemptPage(deps, attemptId, quizId, page) {
|
|
4984
|
+
const url = attemptUrl(deps.baseUrl, attemptId, quizId, page);
|
|
4985
|
+
const response = await deps.request(url);
|
|
4986
|
+
const html = await response.text();
|
|
4987
|
+
if (onPath(response.url, QUIZ_REVIEW_PATH)) throw deps.usage(`Attempt ${attemptId} is already finished.`, `Its review is at ${response.url}`);
|
|
4988
|
+
if (!onPath(response.url, QUIZ_ATTEMPT_PATH)) throw deps.fail(`Moodle did not show attempt ${attemptId}: ${noticesOf2(html) || "it redirected elsewhere"}`);
|
|
4989
|
+
return parseAttemptPage(html, response.url, deps);
|
|
4990
|
+
}
|
|
4991
|
+
function withoutForm({ form: _form, ...page }) {
|
|
4992
|
+
return page;
|
|
4993
|
+
}
|
|
4994
|
+
async function answerQuizQuestion(deps, request) {
|
|
4995
|
+
const first2 = await loadAttemptPage(deps, request.attemptId, request.quizId, 0);
|
|
4996
|
+
const entry = first2.navigation.find((item) => item.number === request.question.trim());
|
|
4997
|
+
if (!entry) throw deps.usage(`Attempt ${request.attemptId} has no question ${request.question}.`, `Questions: ${first2.navigation.map((item) => item.number).join(", ")}`);
|
|
4998
|
+
const page = entry.page === first2.page ? first2 : await loadAttemptPage(deps, request.attemptId, request.quizId, entry.page);
|
|
4999
|
+
const question2 = page.questions.find((item) => item.slot === entry.slot);
|
|
5000
|
+
if (!question2) throw deps.fail(`Page ${entry.page + 1} does not contain question ${request.question}.`);
|
|
5001
|
+
const fields2 = encodeAnswer(deps, page.form, question2, request.value);
|
|
5002
|
+
const replay = fields2.map(([name, value]) => name === "nextpage" ? [name, String(page.page)] : [name, value]);
|
|
5003
|
+
const response = await deps.request(page.form.action, postInit(replay));
|
|
5004
|
+
const html = await response.text();
|
|
5005
|
+
if (!onPath(response.url, QUIZ_ATTEMPT_PATH)) throw deps.fail(`Moodle did not save the answer: ${noticesOf2(html) || "it left the attempt page"}`);
|
|
5006
|
+
const after = parseAttemptPage(html, response.url, deps);
|
|
5007
|
+
const saved = after.questions.find((item) => item.slot === question2.slot);
|
|
5008
|
+
if (!saved || /not yet answered|not answered/iu.test(saved.state)) throw deps.fail(`Moodle accepted the post but still reports question ${request.question} as "${saved?.state || "missing"}".`);
|
|
5009
|
+
return withoutForm(after);
|
|
5010
|
+
}
|
|
5011
|
+
async function getAttemptSummary(deps, attemptId, quizId) {
|
|
5012
|
+
const { form: _form, ...summary } = await loadAttemptSummary(deps, attemptId, quizId);
|
|
5013
|
+
return summary;
|
|
5014
|
+
}
|
|
5015
|
+
async function loadAttemptSummary(deps, attemptId, quizId) {
|
|
5016
|
+
const url = `${deps.baseUrl}${QUIZ_SUMMARY_PATH}?attempt=${attemptId}&cmid=${quizId}`;
|
|
5017
|
+
const response = await deps.request(url);
|
|
5018
|
+
const html = await response.text();
|
|
5019
|
+
if (onPath(response.url, QUIZ_REVIEW_PATH)) throw deps.usage(`Attempt ${attemptId} is already finished.`, `Its review is at ${response.url}`);
|
|
5020
|
+
const root = parse5(html);
|
|
5021
|
+
const rows = root.querySelectorAll("table.quizsummaryofattempt tbody tr").flatMap((row) => {
|
|
5022
|
+
const cells = row.querySelectorAll("td");
|
|
5023
|
+
if (cells.length < 2) return [];
|
|
5024
|
+
const link2 = cells[0].querySelector("a")?.getAttribute("href") ?? "";
|
|
5025
|
+
return [{ number: cleanText(cells[0].textContent), state: cleanText(cells[1].textContent), page: numberParam(link2, "page") ?? 0 }];
|
|
5026
|
+
});
|
|
5027
|
+
const finish = root.querySelector("form#frm-finishattempt") ?? formWithAction2(root, QUIZ_PROCESS_PATH);
|
|
5028
|
+
if (!finish || !rows.length) throw deps.fail(`Moodle did not show the summary of attempt ${attemptId}: ${noticesOf2(html) || "the page has no finish button"}`);
|
|
5029
|
+
const form = { action: resolveUrl(deps.baseUrl, finish.getAttribute("action") ?? ""), fields: formFields2(finish) };
|
|
5030
|
+
return { attempt: attemptId, quiz_id: quizId, name: pageHeading(root), rows, url, form };
|
|
5031
|
+
}
|
|
5032
|
+
async function finishQuizAttempt(deps, attemptId, quizId) {
|
|
5033
|
+
const summary = await loadAttemptSummary(deps, attemptId, quizId);
|
|
5034
|
+
const response = await deps.request(summary.form.action, postInit(summary.form.fields));
|
|
5035
|
+
const html = await response.text();
|
|
5036
|
+
const receipt = { attempt: attemptId, quiz_id: quizId, name: summary.name, summary: summary.rows, url: response.url };
|
|
5037
|
+
if (onPath(response.url, QUIZ_REVIEW_PATH)) {
|
|
5038
|
+
receipt.review = parseQuizReviewHtml(html, attemptId, deps.baseUrl);
|
|
5039
|
+
return receipt;
|
|
5040
|
+
}
|
|
5041
|
+
const quiz = parseQuizHtml(onPath(response.url, QUIZ_VIEW_PATH) ? html : await pageText2(deps, `${deps.baseUrl}${QUIZ_VIEW_PATH}?id=${quizId}`), quizId, deps.baseUrl);
|
|
5042
|
+
const row = quiz.attempts.find((attempt) => attempt.id === attemptId);
|
|
5043
|
+
if (row && /in progress/iu.test(row.status)) throw deps.fail(`Moodle accepted the finish request but still lists attempt ${attemptId} as ${row.status}; check the quiz in a browser.`);
|
|
5044
|
+
receipt.url = quiz.url;
|
|
5045
|
+
if (row) {
|
|
5046
|
+
receipt.result = { status: row.status, marks: row.marks, grade: row.grade, completed: row.completed };
|
|
5047
|
+
return receipt;
|
|
5048
|
+
}
|
|
5049
|
+
const probe2 = await deps.request(attemptUrl(deps.baseUrl, attemptId, quizId, 0));
|
|
5050
|
+
if (onPath(probe2.url, QUIZ_ATTEMPT_PATH) && parse5(await probe2.text()).querySelector("form#responseform")) throw deps.fail(`Moodle accepted the finish request but attempt ${attemptId} is still open; check the quiz in a browser.`);
|
|
5051
|
+
return receipt;
|
|
5052
|
+
}
|
|
5053
|
+
function parseAttemptPage(html, url, deps) {
|
|
5054
|
+
const root = parse5(html);
|
|
5055
|
+
const form = root.querySelector("form#responseform");
|
|
5056
|
+
if (!form) throw deps.fail(`Moodle did not render an attempt page: ${noticesOf2(html) || "no response form found"}`);
|
|
5057
|
+
const attempt = numberParam(url, "attempt") ?? Number(form.querySelector("input[name=attempt]")?.getAttribute("value"));
|
|
5058
|
+
const quizId = numberParam(form.getAttribute("action") ?? "", "cmid") ?? numberParam(url, "cmid") ?? 0;
|
|
5059
|
+
const page = Number(form.querySelector("input[name=thispage]")?.getAttribute("value") ?? numberParam(url, "page") ?? 0);
|
|
5060
|
+
const navigation = root.querySelectorAll("a.qnbutton").map((button) => {
|
|
5061
|
+
const title = button.getAttribute("title") ?? "";
|
|
5062
|
+
const match = title.match(/^(?:Question|Information)?\s*(\S+)\s*-\s*(.+)$/u);
|
|
5063
|
+
return {
|
|
5064
|
+
slot: Number(button.getAttribute("id")?.replace(/^quiznavbutton/u, "") ?? 0),
|
|
5065
|
+
number: match?.[1] ?? cleanText(button.textContent),
|
|
5066
|
+
page: Number(button.getAttribute("data-quiz-page") ?? 0),
|
|
5067
|
+
state: match?.[2] ?? ""
|
|
5068
|
+
};
|
|
5069
|
+
});
|
|
5070
|
+
const questions = form.querySelectorAll("div.que").map(parseAttemptQuestion);
|
|
5071
|
+
return {
|
|
5072
|
+
attempt,
|
|
5073
|
+
quiz_id: quizId,
|
|
5074
|
+
name: pageHeading(root),
|
|
5075
|
+
page,
|
|
5076
|
+
pages: Math.max(page + 1, ...navigation.map((entry) => entry.page + 1)),
|
|
5077
|
+
questions,
|
|
5078
|
+
navigation,
|
|
5079
|
+
url: attemptUrl(deps.baseUrl, attempt, quizId, page),
|
|
5080
|
+
form: { action: resolveUrl(deps.baseUrl, form.getAttribute("action") ?? ""), fields: formFields2(form) }
|
|
5081
|
+
};
|
|
5082
|
+
}
|
|
5083
|
+
function parseAttemptQuestion(que) {
|
|
5084
|
+
const slot = Number(que.getAttribute("id")?.split("-").at(-1) ?? 0);
|
|
5085
|
+
const base = {
|
|
5086
|
+
slot,
|
|
5087
|
+
number: cleanText(que.querySelector(".info .qno, .info .no")?.textContent).replace(/^Question\s*/iu, "") || "i",
|
|
5088
|
+
type: que.classList.value[1] ?? "",
|
|
5089
|
+
kind: "unsupported",
|
|
5090
|
+
state: cleanText(que.querySelector(".info .state")?.textContent),
|
|
5091
|
+
text: blockText(que.querySelector(".qtext"))
|
|
5092
|
+
};
|
|
5093
|
+
if (que.classList.contains("description")) return { ...base, number: "i", kind: "info" };
|
|
5094
|
+
const inputs = que.querySelectorAll("input, textarea, select").filter((input2) => {
|
|
5095
|
+
const name = input2.getAttribute("name") ?? "";
|
|
5096
|
+
return name.startsWith("q") && !/_:(?:flagged|sequencecheck)$|_-seen$|_answerformat$/u.test(name) && !/^(?:hidden|submit)$/u.test(input2.getAttribute("type") ?? "");
|
|
5097
|
+
});
|
|
5098
|
+
const tag = (input2) => input2.tagName.toLowerCase();
|
|
5099
|
+
const type = (input2) => tag(input2) === "input" ? (input2.getAttribute("type") ?? "text").toLowerCase() : tag(input2);
|
|
5100
|
+
const isClearChoice = (input2) => type(input2) === "radio" && (input2.closest(".qtype_multichoice_clearchoice") !== null || input2.getAttribute("aria-hidden") === "true");
|
|
5101
|
+
const radios = inputs.filter((input2) => type(input2) === "radio" && !isClearChoice(input2));
|
|
5102
|
+
const boxes = inputs.filter((input2) => type(input2) === "checkbox");
|
|
5103
|
+
const texts = inputs.filter((input2) => ["textarea", "text", "number"].includes(type(input2)));
|
|
5104
|
+
const selects = inputs.filter((input2) => type(input2) === "select");
|
|
5105
|
+
const others = inputs.length - radios.length - boxes.length - texts.length - selects.length - inputs.filter(isClearChoice).length;
|
|
5106
|
+
const radioNames = new Set(radios.map((input2) => input2.getAttribute("name")));
|
|
5107
|
+
const only = (group) => group.length === inputs.length - inputs.filter(isClearChoice).length && others === 0;
|
|
5108
|
+
if (radios.length && radioNames.size === 1 && only(radios)) {
|
|
5109
|
+
return { ...base, kind: "choice", field: radios[0].getAttribute("name"), options: radios.map((input2, index) => option(que, input2, index)) };
|
|
5110
|
+
}
|
|
5111
|
+
if (boxes.length && only(boxes)) return { ...base, kind: "multi", options: boxes.map((input2, index) => option(que, input2, index)) };
|
|
5112
|
+
if (selects.length === 1 && only(selects)) {
|
|
5113
|
+
const select = selects[0];
|
|
5114
|
+
const name = select.getAttribute("name");
|
|
5115
|
+
const choices = select.querySelectorAll("option").filter((item) => (item.getAttribute("value") ?? "") !== "");
|
|
5116
|
+
return { ...base, kind: "choice", field: name, options: choices.map((item, index) => ({ key: String.fromCharCode(97 + index), field: name, value: item.getAttribute("value") ?? "", text: cleanText(item.textContent), chosen: item.hasAttribute("selected") })) };
|
|
5117
|
+
}
|
|
5118
|
+
if (texts.length === 1 && only(texts)) {
|
|
5119
|
+
const text2 = texts[0];
|
|
5120
|
+
const html = tag(text2) === "textarea";
|
|
5121
|
+
return { ...base, kind: "text", field: text2.getAttribute("name"), answer: html ? blockText(parse5(text2.textContent)) : cleanText(text2.getAttribute("value") ?? "") };
|
|
5122
|
+
}
|
|
5123
|
+
return base;
|
|
5124
|
+
}
|
|
5125
|
+
function option(que, input2, index) {
|
|
5126
|
+
const labelId = input2.getAttribute("aria-labelledby");
|
|
5127
|
+
const id2 = input2.getAttribute("id");
|
|
5128
|
+
const label = (labelId ? que.querySelector(`[id="${labelId}"]`) : null) ?? (id2 ? que.querySelector(`label[for="${id2}"]`) : null) ?? input2.parentNode;
|
|
5129
|
+
const text2 = blockText(label) || label?.querySelectorAll("img").map((img) => cleanText(img.getAttribute("alt"))).filter(Boolean).join(" ") || "(image; see the quiz in a browser)";
|
|
5130
|
+
return { key: String.fromCharCode(97 + index), field: input2.getAttribute("name") ?? "", value: input2.getAttribute("value") ?? "", text: text2, chosen: input2.hasAttribute("checked") };
|
|
5131
|
+
}
|
|
5132
|
+
function encodeAnswer(deps, form, question2, value) {
|
|
5133
|
+
const raw = value.trim();
|
|
5134
|
+
if (question2.kind === "info") throw deps.usage(`Question ${question2.number} is an information block; it takes no answer.`);
|
|
5135
|
+
if (question2.kind === "unsupported" || !question2.field && question2.kind !== "multi") throw deps.usage(`Question ${question2.number} is a ${question2.type || "question"} type the CLI cannot answer.`, "Answer it in a browser; other questions can still be answered here.");
|
|
5136
|
+
if (question2.kind === "text") {
|
|
5137
|
+
if (!raw) throw deps.usage(`Question ${question2.number} needs a written answer.`);
|
|
5138
|
+
const format = form.fields.find(([name]) => name === `${question2.field}format`)?.[1];
|
|
5139
|
+
return [...form.fields.filter(([name]) => name !== question2.field), [question2.field, format === "1" ? paragraphs(value) : raw]];
|
|
5140
|
+
}
|
|
5141
|
+
const options = question2.options ?? [];
|
|
5142
|
+
const picks = raw.split(",").map((part) => part.trim()).filter(Boolean).map((part) => {
|
|
5143
|
+
const match = options.find((item) => item.key === part.toLowerCase()) ?? options.find((item) => cleanText(item.text).toLowerCase() === part.toLowerCase());
|
|
5144
|
+
if (!match) throw deps.usage(`Question ${question2.number} has no option '${part}'.`, `Choose from ${options.map((item) => item.key).join(", ")}.`);
|
|
5145
|
+
return match;
|
|
5146
|
+
});
|
|
5147
|
+
if (!picks.length) throw deps.usage(`Question ${question2.number} needs an option letter.`, `Choose from ${options.map((item) => item.key).join(", ")}.`);
|
|
5148
|
+
if (question2.kind === "choice") {
|
|
5149
|
+
if (picks.length > 1) throw deps.usage(`Question ${question2.number} takes one option, not ${picks.length}.`);
|
|
5150
|
+
return [...form.fields.filter(([name]) => name !== question2.field), [question2.field, picks[0].value]];
|
|
5151
|
+
}
|
|
5152
|
+
const chosen = new Set(picks.map((pick) => pick.key));
|
|
5153
|
+
const boxNames = new Set(options.map((item) => item.field));
|
|
5154
|
+
return [
|
|
5155
|
+
...form.fields.filter(([name]) => !boxNames.has(name)),
|
|
5156
|
+
...options.map((item) => [item.field, chosen.has(item.key) ? "1" : "0"])
|
|
5157
|
+
];
|
|
5158
|
+
}
|
|
5159
|
+
function noticesOf2(html) {
|
|
5160
|
+
const root = parse5(html);
|
|
5161
|
+
const texts = [];
|
|
5162
|
+
for (const node of root.querySelectorAll(".alert, .errorbox, .error, #notice")) {
|
|
5163
|
+
for (const junk of node.querySelectorAll("button, .close")) junk.remove();
|
|
5164
|
+
const text2 = cleanText(node.textContent);
|
|
5165
|
+
if (text2 && !texts.includes(text2)) texts.push(text2);
|
|
5166
|
+
}
|
|
5167
|
+
return texts.join(" ");
|
|
5168
|
+
}
|
|
5169
|
+
function formWithAction2(root, path5) {
|
|
5170
|
+
return root.querySelectorAll("form").find((form) => onPath(form.getAttribute("action") ?? "", path5)) ?? null;
|
|
5171
|
+
}
|
|
5172
|
+
function formFields2(form) {
|
|
5173
|
+
const fields2 = [];
|
|
5174
|
+
for (const element of form.querySelectorAll("input, textarea, select")) {
|
|
5175
|
+
const name = element.getAttribute("name");
|
|
5176
|
+
if (!name) continue;
|
|
5177
|
+
const tag = element.tagName.toLowerCase();
|
|
5178
|
+
if (tag === "textarea") {
|
|
5179
|
+
fields2.push([name, element.textContent]);
|
|
5180
|
+
continue;
|
|
5181
|
+
}
|
|
5182
|
+
if (tag === "select") {
|
|
5183
|
+
const options = element.querySelectorAll("option");
|
|
5184
|
+
const chosen = options.find((item) => item.hasAttribute("selected")) ?? options[0];
|
|
5185
|
+
if (chosen) fields2.push([name, chosen.getAttribute("value") ?? cleanText(chosen.textContent)]);
|
|
5186
|
+
continue;
|
|
5187
|
+
}
|
|
5188
|
+
const type = (element.getAttribute("type") ?? "text").toLowerCase();
|
|
5189
|
+
if (["submit", "button", "image", "file", "reset"].includes(type)) continue;
|
|
5190
|
+
if ((type === "checkbox" || type === "radio") && !element.hasAttribute("checked")) continue;
|
|
5191
|
+
fields2.push([name, element.getAttribute("value") ?? (type === "checkbox" ? "on" : "")]);
|
|
5192
|
+
}
|
|
5193
|
+
return fields2;
|
|
5194
|
+
}
|
|
5195
|
+
function postInit(fields2) {
|
|
5196
|
+
return { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(fields2).toString() };
|
|
5197
|
+
}
|
|
5198
|
+
async function pageText2(deps, url) {
|
|
5199
|
+
return (await deps.request(url)).text();
|
|
5200
|
+
}
|
|
5201
|
+
function attemptUrl(baseUrl, attempt, quizId, page) {
|
|
5202
|
+
return `${baseUrl}${QUIZ_ATTEMPT_PATH}?attempt=${attempt}&cmid=${quizId}${page ? `&page=${page}` : ""}`;
|
|
5203
|
+
}
|
|
5204
|
+
function pageHeading(root) {
|
|
5205
|
+
const heading = cleanText(root.querySelector(".page-header-headings h1, #page-header h1")?.textContent);
|
|
5206
|
+
if (heading) return heading;
|
|
5207
|
+
const title = cleanText(root.querySelector("title")?.textContent).replace(/\s*\(page \d+ of \d+\)/iu, "").split(" | ")[0].trim();
|
|
5208
|
+
return title || cleanText(root.querySelector("h2")?.textContent);
|
|
5209
|
+
}
|
|
5210
|
+
function onPath(url, path5) {
|
|
5211
|
+
try {
|
|
5212
|
+
return new URL(url, "https://moodle.invalid").pathname.endsWith(path5);
|
|
5213
|
+
} catch {
|
|
5214
|
+
return false;
|
|
5215
|
+
}
|
|
5216
|
+
}
|
|
5217
|
+
function numberParam(url, key) {
|
|
5218
|
+
try {
|
|
5219
|
+
const value = Number(new URL(url, "https://moodle.invalid").searchParams.get(key));
|
|
5220
|
+
return Number.isSafeInteger(value) && value > 0 ? value : key === "page" && value === 0 ? 0 : null;
|
|
5221
|
+
} catch {
|
|
5222
|
+
return null;
|
|
5223
|
+
}
|
|
5224
|
+
}
|
|
5225
|
+
function paragraphs(text2) {
|
|
5226
|
+
const escaped = text2.trim().replace(/&/gu, "&").replace(/</gu, "<").replace(/>/gu, ">");
|
|
5227
|
+
return escaped.split(/\n\s*\n/u).map((block) => `<p>${block.trim().replace(/\n/gu, "<br>")}</p>`).join("");
|
|
5228
|
+
}
|
|
5229
|
+
|
|
4781
5230
|
// src/parsers.ts
|
|
4782
5231
|
function schema(parser) {
|
|
4783
5232
|
return { parse: parser };
|
|
@@ -5778,6 +6227,10 @@ var MoodleClientCore = class {
|
|
|
5778
6227
|
async getQuiz(id2) {
|
|
5779
6228
|
return parseQuizHtml(await this.get(QUIZ_VIEW_PATH, { id: id2 }), id2, this.baseUrl);
|
|
5780
6229
|
}
|
|
6230
|
+
async getQuizAttempt(attemptId) {
|
|
6231
|
+
await this.ensureSession();
|
|
6232
|
+
return parseQuizReviewHtml(await this.get(QUIZ_REVIEW_PATH, { attempt: attemptId, showall: 1 }), attemptId, this.baseUrl);
|
|
6233
|
+
}
|
|
5781
6234
|
async getResource(id2) {
|
|
5782
6235
|
const url = `${this.baseUrl}${RESOURCE_VIEW_PATH}?id=${id2}`;
|
|
5783
6236
|
const response = await this.requestAbsolute(url);
|
|
@@ -5826,6 +6279,32 @@ var MoodleClientCore = class {
|
|
|
5826
6279
|
usage: (message, hint) => this.errors.usage ? this.errors.usage(message, hint) : new MoodleClientCoreError("usage", message, hint)
|
|
5827
6280
|
}, request);
|
|
5828
6281
|
}
|
|
6282
|
+
/** Starts a new attempt, or resumes the one already in progress, and returns its first page. */
|
|
6283
|
+
async startQuizAttempt(quizId, options = {}) {
|
|
6284
|
+
return startQuizAttempt(await this.quizDeps(), quizId, options);
|
|
6285
|
+
}
|
|
6286
|
+
async getQuizAttemptPage(attemptId, quizId, page = 0) {
|
|
6287
|
+
return getAttemptPage(await this.quizDeps(), attemptId, quizId, page);
|
|
6288
|
+
}
|
|
6289
|
+
async getQuizAttemptSummary(attemptId, quizId) {
|
|
6290
|
+
return getAttemptSummary(await this.quizDeps(), attemptId, quizId);
|
|
6291
|
+
}
|
|
6292
|
+
async answerQuizQuestion(request) {
|
|
6293
|
+
return answerQuizQuestion(await this.quizDeps(), request);
|
|
6294
|
+
}
|
|
6295
|
+
/** Submits the attempt for grading. Moodle treats this as final. */
|
|
6296
|
+
async finishQuizAttempt(attemptId, quizId) {
|
|
6297
|
+
return finishQuizAttempt(await this.quizDeps(), attemptId, quizId);
|
|
6298
|
+
}
|
|
6299
|
+
async quizDeps() {
|
|
6300
|
+
await this.ensureSession();
|
|
6301
|
+
return {
|
|
6302
|
+
baseUrl: this.baseUrl,
|
|
6303
|
+
request: (url, init, options) => this.requestAbsolute(url, init, options),
|
|
6304
|
+
fail: (message, moodleErrorCode) => this.errors.api(message, moodleErrorCode),
|
|
6305
|
+
usage: (message, hint) => this.errors.usage ? this.errors.usage(message, hint) : new MoodleClientCoreError("usage", message, hint)
|
|
6306
|
+
};
|
|
6307
|
+
}
|
|
5829
6308
|
async getNewsForums(courseId) {
|
|
5830
6309
|
const units = courseId === void 0 ? await this.getCourses() : (await this.getCourses()).filter((c) => c.id === courseId);
|
|
5831
6310
|
const forums = [];
|
|
@@ -6294,6 +6773,202 @@ function authToClientSession(auth) {
|
|
|
6294
6773
|
};
|
|
6295
6774
|
}
|
|
6296
6775
|
|
|
6776
|
+
// src/update-check.ts
|
|
6777
|
+
import { spawn as spawn3, spawnSync as spawnSync3 } from "child_process";
|
|
6778
|
+
import { chmod as chmod4, mkdir as mkdir7, readFile as readFile7, rename as rename2, rm as rm5, writeFile as writeFile6 } from "fs/promises";
|
|
6779
|
+
import { realpathSync as realpathSync3 } from "fs";
|
|
6780
|
+
import { arch, homedir as homedir11, platform } from "os";
|
|
6781
|
+
import { join as join10 } from "path";
|
|
6782
|
+
|
|
6783
|
+
// src/update-core.ts
|
|
6784
|
+
var PACKAGE_NAME2 = "moodle-cli";
|
|
6785
|
+
var LATEST_VERSION_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME2}/dist-tags`;
|
|
6786
|
+
var UPDATE_CHECK_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
6787
|
+
var UPDATE_RETRY_MS = 60 * 60 * 1e3;
|
|
6788
|
+
var GITHUB_REPOSITORY = "bunizao/moodle-cli";
|
|
6789
|
+
function compareVersions(a, b) {
|
|
6790
|
+
const [aMain, aPre] = a.split("-", 2);
|
|
6791
|
+
const [bMain, bPre] = b.split("-", 2);
|
|
6792
|
+
const left = aMain.split(".").map(Number);
|
|
6793
|
+
const right = bMain.split(".").map(Number);
|
|
6794
|
+
for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
|
|
6795
|
+
const diff = (left[index] ?? 0) - (right[index] ?? 0);
|
|
6796
|
+
if (diff !== 0) return Math.sign(diff);
|
|
6797
|
+
}
|
|
6798
|
+
if (Boolean(aPre) === Boolean(bPre)) return (aPre ?? "").localeCompare(bPre ?? "");
|
|
6799
|
+
return aPre ? -1 : 1;
|
|
6800
|
+
}
|
|
6801
|
+
function isNewerVersion(candidate, current2) {
|
|
6802
|
+
return Boolean(candidate) && /^\d+\.\d+\.\d+/u.test(candidate) && compareVersions(candidate, current2) > 0;
|
|
6803
|
+
}
|
|
6804
|
+
async function fetchLatestVersion(fetchImpl = fetch, timeoutMs = 5e3) {
|
|
6805
|
+
try {
|
|
6806
|
+
const response = await fetchImpl(LATEST_VERSION_URL, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(timeoutMs) });
|
|
6807
|
+
if (!response.ok) return null;
|
|
6808
|
+
const tags = await response.json();
|
|
6809
|
+
return typeof tags.latest === "string" ? tags.latest : null;
|
|
6810
|
+
} catch {
|
|
6811
|
+
return null;
|
|
6812
|
+
}
|
|
6813
|
+
}
|
|
6814
|
+
function updateHint(current2, latest) {
|
|
6815
|
+
return `moodle-cli ${latest} is available (running ${current2}). Run: moodle update`;
|
|
6816
|
+
}
|
|
6817
|
+
function standaloneUpdateHint(current2, latest) {
|
|
6818
|
+
return `moodle-cli ${latest} is available (running ${current2}). Run: moodle update`;
|
|
6819
|
+
}
|
|
6820
|
+
var STANDALONE_TARGETS = /* @__PURE__ */ new Set(["darwin-arm64", "linux-x64"]);
|
|
6821
|
+
function standaloneAssetUrl(version, platform2, arch2) {
|
|
6822
|
+
const target = `${platform2}-${arch2}`;
|
|
6823
|
+
if (!STANDALONE_TARGETS.has(target)) return null;
|
|
6824
|
+
return `https://github.com/${GITHUB_REPOSITORY}/releases/download/v${version}/moodle-${target}`;
|
|
6825
|
+
}
|
|
6826
|
+
|
|
6827
|
+
// src/version.ts
|
|
6828
|
+
var VERSION = "0.9.3";
|
|
6829
|
+
|
|
6830
|
+
// src/update-check.ts
|
|
6831
|
+
var UPDATE_CACHE_FILENAME = "update-check.json";
|
|
6832
|
+
var ENV_NO_UPDATE_CHECK = "MOODLE_NO_UPDATE_CHECK";
|
|
6833
|
+
function updateCachePath(homeDir = homedir11()) {
|
|
6834
|
+
return join10(homeDir, CONFIG_DIR_NAME, UPDATE_CACHE_FILENAME);
|
|
6835
|
+
}
|
|
6836
|
+
async function readUpdateCache(homeDir) {
|
|
6837
|
+
try {
|
|
6838
|
+
const parsed = JSON.parse(await readFile7(updateCachePath(homeDir), "utf8"));
|
|
6839
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
6840
|
+
} catch {
|
|
6841
|
+
return {};
|
|
6842
|
+
}
|
|
6843
|
+
}
|
|
6844
|
+
async function writeUpdateCache(cache, homeDir) {
|
|
6845
|
+
const file2 = updateCachePath(homeDir);
|
|
6846
|
+
await mkdir7(join10(file2, ".."), { recursive: true });
|
|
6847
|
+
await writeFile6(file2, `${JSON.stringify(cache)}
|
|
6848
|
+
`, { mode: 384 });
|
|
6849
|
+
}
|
|
6850
|
+
async function refreshLatestVersion(options = {}) {
|
|
6851
|
+
const latest = await fetchLatestVersion(options.fetchImpl);
|
|
6852
|
+
const now = (options.now ?? Date.now)();
|
|
6853
|
+
const cache = await readUpdateCache(options.homeDir);
|
|
6854
|
+
await writeUpdateCache(latest ? { ...cache, latest, checked_at: now, failed_at: void 0 } : { ...cache, failed_at: now }, options.homeDir);
|
|
6855
|
+
return latest;
|
|
6856
|
+
}
|
|
6857
|
+
function refreshDue(cache, now) {
|
|
6858
|
+
if (now - (cache.checked_at ?? 0) < UPDATE_CHECK_TTL_MS) return false;
|
|
6859
|
+
return now - (cache.failed_at ?? 0) >= UPDATE_RETRY_MS;
|
|
6860
|
+
}
|
|
6861
|
+
var QUIET_COMMANDS = /* @__PURE__ */ new Set(["update", "dev", "completion", "commands", "skills", "mcp", "doctor"]);
|
|
6862
|
+
function startupCheckApplies(args, env = process.env) {
|
|
6863
|
+
if (env[ENV_NO_UPDATE_CHECK] || env.CI) return false;
|
|
6864
|
+
const first2 = args.find((arg) => !arg.startsWith("-"));
|
|
6865
|
+
return first2 === void 0 || !QUIET_COMMANDS.has(first2);
|
|
6866
|
+
}
|
|
6867
|
+
async function startupUpdateNotice(args, stderr, options = {}) {
|
|
6868
|
+
const env = options.env ?? process.env;
|
|
6869
|
+
if (!startupCheckApplies(args, env)) return;
|
|
6870
|
+
const now = (options.now ?? Date.now)();
|
|
6871
|
+
const cache = await readUpdateCache(options.homeDir);
|
|
6872
|
+
if (isNewerVersion(cache.latest, VERSION) && now - (cache.notified_at ?? 0) >= UPDATE_CHECK_TTL_MS) {
|
|
6873
|
+
stderr.write(`${selfCommand().args.length ? updateHint(VERSION, cache.latest) : standaloneUpdateHint(VERSION, cache.latest)}
|
|
6874
|
+
`);
|
|
6875
|
+
await writeUpdateCache({ ...cache, notified_at: now }, options.homeDir);
|
|
6876
|
+
}
|
|
6877
|
+
if (refreshDue(cache, now)) spawnRefresh(env);
|
|
6878
|
+
}
|
|
6879
|
+
function spawnRefresh(env) {
|
|
6880
|
+
const self = selfCommand();
|
|
6881
|
+
try {
|
|
6882
|
+
const child = spawn3(self.command, [...self.args, "update", "--check", "--quiet"], { detached: true, stdio: "ignore", env: { ...env, [ENV_NO_UPDATE_CHECK]: "1" } });
|
|
6883
|
+
child.unref();
|
|
6884
|
+
} catch {
|
|
6885
|
+
}
|
|
6886
|
+
}
|
|
6887
|
+
function detectInstallKind(argv = process.argv, execPath = process.execPath) {
|
|
6888
|
+
const self = selfCommand(argv, execPath);
|
|
6889
|
+
if (!self.args.length) return "standalone";
|
|
6890
|
+
let script = self.args[0];
|
|
6891
|
+
try {
|
|
6892
|
+
script = realpathSync3(script);
|
|
6893
|
+
} catch {
|
|
6894
|
+
}
|
|
6895
|
+
return /[\\/]\.bun[\\/]/u.test(script) || /[\\/]bun[\\/]install[\\/]global[\\/]/u.test(script) ? "bun" : "npm";
|
|
6896
|
+
}
|
|
6897
|
+
function installCommand(kind) {
|
|
6898
|
+
if (kind === "bun") return { command: findExecutable("bun") ?? "bun", args: ["add", "--global", "moodle-cli@latest"] };
|
|
6899
|
+
if (kind === "npm") return { command: findExecutable("npm") ?? "npm", args: ["install", "-g", "moodle-cli@latest"] };
|
|
6900
|
+
return null;
|
|
6901
|
+
}
|
|
6902
|
+
async function replaceStandalone(execPath, version, fetchImpl = fetch, host = { platform: platform(), arch: arch() }) {
|
|
6903
|
+
const url = standaloneAssetUrl(version, host.platform, host.arch);
|
|
6904
|
+
if (!url) return `No standalone build is published for ${host.platform}-${host.arch}. See ${GITHUB_RELEASES_URL}`;
|
|
6905
|
+
const staging = `${execPath}.${process.pid}.download`;
|
|
6906
|
+
try {
|
|
6907
|
+
const response = await fetchImpl(url, { redirect: "follow" });
|
|
6908
|
+
if (!response.ok) return `Download failed with HTTP ${response.status} for ${url}`;
|
|
6909
|
+
await writeFile6(staging, new Uint8Array(await response.arrayBuffer()), { mode: 493 });
|
|
6910
|
+
await chmod4(staging, 493);
|
|
6911
|
+
await rename2(staging, execPath);
|
|
6912
|
+
return null;
|
|
6913
|
+
} catch (error) {
|
|
6914
|
+
await rm5(staging, { force: true });
|
|
6915
|
+
return `Could not replace ${execPath}: ${error instanceof Error ? error.message : String(error)}`;
|
|
6916
|
+
}
|
|
6917
|
+
}
|
|
6918
|
+
function readOutput(command, args) {
|
|
6919
|
+
const result = spawnSync3(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
6920
|
+
return result.status === 0 ? result.stdout : null;
|
|
6921
|
+
}
|
|
6922
|
+
async function runUpdate(options) {
|
|
6923
|
+
const run = options.runCommand ?? ((command, args) => spawnSync3(command, args, { stdio: ["inherit", 2, "inherit"], env: options.env }));
|
|
6924
|
+
const install = detectInstallKind(options.argv, options.execPath);
|
|
6925
|
+
const latest = await refreshLatestVersion(options);
|
|
6926
|
+
const report = { current: VERSION, latest, install, updated: false, deployed: false, ok: true, note: "" };
|
|
6927
|
+
const newer = isNewerVersion(latest ?? void 0, VERSION);
|
|
6928
|
+
const self = selfCommand(options.argv, options.execPath);
|
|
6929
|
+
if (newer) {
|
|
6930
|
+
const command = installCommand(install);
|
|
6931
|
+
if (command) {
|
|
6932
|
+
const result2 = run(command.command, command.args);
|
|
6933
|
+
if (result2.status !== 0) {
|
|
6934
|
+
report.ok = false;
|
|
6935
|
+
report.note = `${command.command} exited with ${result2.status ?? "a signal"}; the package was not updated.`;
|
|
6936
|
+
return report;
|
|
6937
|
+
}
|
|
6938
|
+
} else {
|
|
6939
|
+
const failure = await replaceStandalone(options.execPath ?? process.execPath, latest, options.fetchImpl);
|
|
6940
|
+
if (failure) {
|
|
6941
|
+
report.ok = false;
|
|
6942
|
+
report.note = `${standaloneUpdateHint(VERSION, latest)} ${failure}`;
|
|
6943
|
+
return report;
|
|
6944
|
+
}
|
|
6945
|
+
}
|
|
6946
|
+
const installed = (options.readOutput ?? readOutput)(self.command, [...self.args, "--version"])?.trim();
|
|
6947
|
+
if (installed !== latest) {
|
|
6948
|
+
report.ok = false;
|
|
6949
|
+
report.note = `The installer finished but ${self.args[0] ?? self.command} reports ${installed || "no version"} instead of ${latest}; another moodle install may be on PATH.`;
|
|
6950
|
+
return report;
|
|
6951
|
+
}
|
|
6952
|
+
report.updated = true;
|
|
6953
|
+
}
|
|
6954
|
+
const unreachable = latest === null ? "The npm registry could not be reached, so the installed version was not checked." : "";
|
|
6955
|
+
if (options.workerBehind === void 0) {
|
|
6956
|
+
report.ok = !unreachable;
|
|
6957
|
+
report.note = unreachable || (newer ? `Updated to ${latest}.` : "Already up to date.");
|
|
6958
|
+
return report;
|
|
6959
|
+
}
|
|
6960
|
+
if (!newer && !options.workerBehind) {
|
|
6961
|
+
report.ok = !unreachable;
|
|
6962
|
+
report.note = unreachable ? `${unreachable} The Worker is current.` : "Package and Worker are up to date.";
|
|
6963
|
+
return report;
|
|
6964
|
+
}
|
|
6965
|
+
const result = run(self.command, [...self.args, "mcp", "deploy", "--yes"]);
|
|
6966
|
+
report.deployed = result.status === 0;
|
|
6967
|
+
report.ok = report.deployed && !unreachable;
|
|
6968
|
+
report.note = [unreachable, report.deployed ? `Worker redeployed from ${newer ? latest : VERSION}.` : "Worker deploy failed; run moodle mcp deploy to retry."].filter(Boolean).join(" ");
|
|
6969
|
+
return report;
|
|
6970
|
+
}
|
|
6971
|
+
|
|
6297
6972
|
// src/formatters.ts
|
|
6298
6973
|
function formatUser(user) {
|
|
6299
6974
|
return renderKeyValueTable([
|
|
@@ -6479,15 +7154,58 @@ function formatTimestamp(value) {
|
|
|
6479
7154
|
const pad = (part) => String(part).padStart(2, "0");
|
|
6480
7155
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
6481
7156
|
}
|
|
7157
|
+
function formatAttemptPage(page) {
|
|
7158
|
+
const lines = [`${page.name} attempt ${page.attempt} page ${page.page + 1} of ${page.pages}`, ""];
|
|
7159
|
+
for (const question2 of page.questions) lines.push(...attemptQuestionLines(question2), "");
|
|
7160
|
+
const elsewhere = page.navigation.filter((entry) => entry.page !== page.page && entry.number !== "i");
|
|
7161
|
+
if (elsewhere.length) lines.push(`Other pages: ${elsewhere.map((entry) => `Q${entry.number} p${entry.page + 1} (${entry.state.toLowerCase()})`).join(", ")}`);
|
|
7162
|
+
return lines.join("\n").trimEnd();
|
|
7163
|
+
}
|
|
7164
|
+
function attemptQuestionLines(question2) {
|
|
7165
|
+
const head = question2.kind === "info" ? "Information" : `Question ${question2.number} ${question2.state}`;
|
|
7166
|
+
const lines = [head, ...wrap(question2.text)];
|
|
7167
|
+
for (const option2 of question2.options ?? []) {
|
|
7168
|
+
const [first2, ...rest] = wrap(option2.text, 88);
|
|
7169
|
+
lines.push(` ${option2.chosen ? "[x]" : "[ ]"} ${option2.key})${first2.slice(1)}`, ...rest.map((line) => ` ${line}`));
|
|
7170
|
+
}
|
|
7171
|
+
if (question2.kind === "text") lines.push(` Answer: ${question2.answer || "(empty)"}`);
|
|
7172
|
+
if (question2.kind === "unsupported") lines.push(` (${question2.type} questions can only be answered in a browser)`);
|
|
7173
|
+
return lines;
|
|
7174
|
+
}
|
|
7175
|
+
function wrap(text2, width = 96) {
|
|
7176
|
+
const lines = [];
|
|
7177
|
+
let line = "";
|
|
7178
|
+
for (const word of text2.split(/\s+/u).filter(Boolean)) {
|
|
7179
|
+
if (line && line.length + word.length + 1 > width) {
|
|
7180
|
+
lines.push(` ${line}`);
|
|
7181
|
+
line = word;
|
|
7182
|
+
} else line = line ? `${line} ${word}` : word;
|
|
7183
|
+
}
|
|
7184
|
+
if (line || !lines.length) lines.push(` ${line}`);
|
|
7185
|
+
return lines;
|
|
7186
|
+
}
|
|
7187
|
+
function formatAttemptSummary(summary) {
|
|
7188
|
+
return renderTerminalTable([{ label: "Question" }, { label: "Status" }], summary.rows.map((row) => [row.number, row.state]), { title: `${summary.name} attempt ${summary.attempt}` });
|
|
7189
|
+
}
|
|
7190
|
+
function formatAttemptFinish(receipt) {
|
|
7191
|
+
const result = receipt.review ? [["Status", receipt.review.status], ["Marks", receipt.review.marks], ["Grade", receipt.review.grade], ["Completed", receipt.review.completed]] : receipt.result ? [["Status", receipt.result.status], ["Marks", receipt.result.marks], ["Grade", receipt.result.grade], ["Completed", receipt.result.completed]] : [];
|
|
7192
|
+
return renderKeyValueTable([
|
|
7193
|
+
["Quiz", receipt.name],
|
|
7194
|
+
["Attempt", String(receipt.attempt)],
|
|
7195
|
+
...result,
|
|
7196
|
+
["Answered", `${receipt.summary.filter((row) => !/not yet answered/iu.test(row.state)).length} of ${receipt.summary.length}`],
|
|
7197
|
+
["URL", receipt.url]
|
|
7198
|
+
], { title: "Attempt submitted" });
|
|
7199
|
+
}
|
|
6482
7200
|
|
|
6483
7201
|
// src/download.ts
|
|
6484
7202
|
import { createWriteStream } from "fs";
|
|
6485
|
-
import { link, lstat, rename as
|
|
7203
|
+
import { link, lstat, rename as rename3, unlink } from "fs/promises";
|
|
6486
7204
|
import { randomUUID } from "crypto";
|
|
6487
7205
|
import path2 from "path";
|
|
6488
7206
|
import { Readable, Transform } from "stream";
|
|
6489
7207
|
import { pipeline } from "stream/promises";
|
|
6490
|
-
import { parse as
|
|
7208
|
+
import { parse as parse6 } from "node-html-parser";
|
|
6491
7209
|
var ACCEPTED_SOURCE_HINT = "Use a positive resource activity ID, a same-site resource URL, or a same-site pluginfile URL.";
|
|
6492
7210
|
var FILE_SYSTEM_ERROR_CODES = /* @__PURE__ */ new Set([
|
|
6493
7211
|
"EACCES",
|
|
@@ -6609,7 +7327,7 @@ async function responseOrWrapper(client, requestUrl, sourceUrl, targetName, sign
|
|
|
6609
7327
|
};
|
|
6610
7328
|
}
|
|
6611
7329
|
function resourceLinks2(html, baseUrl) {
|
|
6612
|
-
const root =
|
|
7330
|
+
const root = parse6(html);
|
|
6613
7331
|
const entries = root.querySelectorAll(".resourceworkaround a[href], .resourcecontent a[href], a.resourceworkaround[href]").map((linkNode) => ({
|
|
6614
7332
|
name: linkNode.textContent.trim(),
|
|
6615
7333
|
url: new URL(linkNode.getAttribute("href") ?? "", baseUrl).toString()
|
|
@@ -6625,7 +7343,7 @@ function isHtmlWrapper(response) {
|
|
|
6625
7343
|
return type.includes("text/html") || type.includes("application/xhtml+xml");
|
|
6626
7344
|
}
|
|
6627
7345
|
function looksLikeLoginPage3(html) {
|
|
6628
|
-
const root =
|
|
7346
|
+
const root = parse6(html);
|
|
6629
7347
|
return root.querySelector('form[action*="/login/"], input[name="password"], #page-login-index') !== null || /<title>\s*(?:log in|login)/iu.test(html);
|
|
6630
7348
|
}
|
|
6631
7349
|
function chooseUpstreamFilename(resolved) {
|
|
@@ -6699,7 +7417,7 @@ async function writeResponse(response, destination, force, signal) {
|
|
|
6699
7417
|
const input2 = response.body ? Readable.fromWeb(response.body) : Readable.from([]);
|
|
6700
7418
|
await pipeline(input2, counter, createWriteStream(temporaryPath, { flags: "wx" }), { signal });
|
|
6701
7419
|
if (force) {
|
|
6702
|
-
await
|
|
7420
|
+
await rename3(temporaryPath, destination);
|
|
6703
7421
|
} else {
|
|
6704
7422
|
try {
|
|
6705
7423
|
await link(temporaryPath, destination);
|
|
@@ -6756,7 +7474,7 @@ function isFileSystemError(error) {
|
|
|
6756
7474
|
}
|
|
6757
7475
|
|
|
6758
7476
|
// src/skills.ts
|
|
6759
|
-
import { spawnSync as
|
|
7477
|
+
import { spawnSync as spawnSync4 } from "child_process";
|
|
6760
7478
|
import { mkdirSync, readFileSync, writeFileSync, rmSync } from "fs";
|
|
6761
7479
|
import path3 from "path";
|
|
6762
7480
|
|
|
@@ -6768,7 +7486,7 @@ function describeProgram(program) {
|
|
|
6768
7486
|
name: program.name(),
|
|
6769
7487
|
version: program.version() ?? "",
|
|
6770
7488
|
description: program.description(),
|
|
6771
|
-
commands: program.commands.filter((command) => command.name() !== "help").map((command) => describeCommand(command))
|
|
7489
|
+
commands: program.commands.filter((command) => command.name() !== "help" && command.name() !== "dev").map((command) => describeCommand(command))
|
|
6772
7490
|
};
|
|
6773
7491
|
}
|
|
6774
7492
|
function describeCommand(command, noun) {
|
|
@@ -6795,20 +7513,20 @@ function describeArgument(argument) {
|
|
|
6795
7513
|
...argument.argChoices ? { enumValues: argument.argChoices } : {}
|
|
6796
7514
|
};
|
|
6797
7515
|
}
|
|
6798
|
-
function describeOption(
|
|
7516
|
+
function describeOption(option2) {
|
|
6799
7517
|
return {
|
|
6800
|
-
flags:
|
|
6801
|
-
description:
|
|
6802
|
-
required:
|
|
6803
|
-
variadic:
|
|
6804
|
-
...
|
|
7518
|
+
flags: option2.flags,
|
|
7519
|
+
description: option2.description,
|
|
7520
|
+
required: option2.required,
|
|
7521
|
+
variadic: option2.variadic,
|
|
7522
|
+
...option2.argChoices ? { enumValues: option2.argChoices } : {}
|
|
6805
7523
|
};
|
|
6806
7524
|
}
|
|
6807
7525
|
function isMutating(command) {
|
|
6808
7526
|
if (VERB_SET.has(command.name())) {
|
|
6809
7527
|
return ["send", "submit", "set", "mark-read"].includes(command.name());
|
|
6810
7528
|
}
|
|
6811
|
-
return ["install", "uninstall", "login", "deploy", "connect", "pair", "revoke", "remove", "push"].includes(command.name());
|
|
7529
|
+
return ["install", "uninstall", "login", "deploy", "connect", "pair", "revoke", "remove", "push", "update", "start", "answer", "finish"].includes(command.name());
|
|
6812
7530
|
}
|
|
6813
7531
|
|
|
6814
7532
|
// src/skills.ts
|
|
@@ -6839,7 +7557,7 @@ function buildSkillsAddCommand(extraArgs = [], launcher = "npx") {
|
|
|
6839
7557
|
return ["npm", "exec", "--yes", "--", "skills", "add", SKILL_SOURCE, ...extraArgs];
|
|
6840
7558
|
}
|
|
6841
7559
|
function addSkill(extraArgs = [], options = {}) {
|
|
6842
|
-
const runCommand = options.runCommand ??
|
|
7560
|
+
const runCommand = options.runCommand ?? spawnSync4;
|
|
6843
7561
|
const commandExists = options.commandExists ?? ((name) => isCommandAvailable(name, runCommand));
|
|
6844
7562
|
const command = commandExists("npx") ? buildSkillsAddCommand(extraArgs, "npx") : commandExists("npm") ? buildSkillsAddCommand(extraArgs, "npm") : void 0;
|
|
6845
7563
|
if (!command) {
|
|
@@ -6872,11 +7590,11 @@ function commandDescriptionRows(command, parentPath = []) {
|
|
|
6872
7590
|
required: argument.required,
|
|
6873
7591
|
variadic: argument.variadic
|
|
6874
7592
|
})),
|
|
6875
|
-
flags: command.options.map((
|
|
6876
|
-
const names =
|
|
6877
|
-
const name = names.find((value) => value.startsWith("--")) ?? names[0] ??
|
|
7593
|
+
flags: command.options.map((option2) => {
|
|
7594
|
+
const names = option2.flags.match(/-{1,2}[\w-]+/g) ?? [];
|
|
7595
|
+
const name = names.find((value) => value.startsWith("--")) ?? names[0] ?? option2.flags;
|
|
6878
7596
|
const alias = names.find((value) => value !== name);
|
|
6879
|
-
return { name, alias, description:
|
|
7597
|
+
return { name, alias, description: option2.description, required: option2.required };
|
|
6880
7598
|
})
|
|
6881
7599
|
};
|
|
6882
7600
|
return [row, ...command.commands.flatMap((child) => commandDescriptionRows(child, path5))];
|
|
@@ -7155,9 +7873,6 @@ async function readAll(input2) {
|
|
|
7155
7873
|
return chunks2.join("");
|
|
7156
7874
|
}
|
|
7157
7875
|
|
|
7158
|
-
// src/version.ts
|
|
7159
|
-
var VERSION = "0.9.1";
|
|
7160
|
-
|
|
7161
7876
|
// src/forum.ts
|
|
7162
7877
|
function parseDiscussionReference(value) {
|
|
7163
7878
|
const raw = value.trim();
|
|
@@ -7310,10 +8025,11 @@ function resolveTopLevelUrl(baseUrlOrOptions, targetValue, resolveCourseIdForUrl
|
|
|
7310
8025
|
}
|
|
7311
8026
|
|
|
7312
8027
|
// src/mcp/cli.ts
|
|
8028
|
+
import { createTheme as createTheme4 } from "@bunizao/cli-kit";
|
|
7313
8029
|
import { createHash as createHash3 } from "crypto";
|
|
7314
|
-
import { readFile as
|
|
7315
|
-
import { homedir as
|
|
7316
|
-
import { join as
|
|
8030
|
+
import { readFile as readFile10 } from "fs/promises";
|
|
8031
|
+
import { homedir as homedir15 } from "os";
|
|
8032
|
+
import { join as join14 } from "path";
|
|
7317
8033
|
import { createInterface } from "readline/promises";
|
|
7318
8034
|
import { fileURLToPath } from "url";
|
|
7319
8035
|
|
|
@@ -7847,9 +8563,9 @@ function resolveConnection(options) {
|
|
|
7847
8563
|
}
|
|
7848
8564
|
|
|
7849
8565
|
// src/mcp/connectors/node-connectors.ts
|
|
7850
|
-
import { chmod as
|
|
7851
|
-
import { homedir as
|
|
7852
|
-
import { dirname as dirname6, join as
|
|
8566
|
+
import { chmod as chmod5, mkdir as mkdir8, readFile as readFile8, rm as rm6, stat as stat3, writeFile as writeFile7 } from "fs/promises";
|
|
8567
|
+
import { homedir as homedir12 } from "os";
|
|
8568
|
+
import { dirname as dirname6, join as join11 } from "path";
|
|
7853
8569
|
var NodeConnectorFileSystem = class {
|
|
7854
8570
|
async exists(path5) {
|
|
7855
8571
|
try {
|
|
@@ -7863,20 +8579,20 @@ var NodeConnectorFileSystem = class {
|
|
|
7863
8579
|
}
|
|
7864
8580
|
}
|
|
7865
8581
|
async readText(path5) {
|
|
7866
|
-
return
|
|
8582
|
+
return readFile8(path5, "utf8");
|
|
7867
8583
|
}
|
|
7868
8584
|
async writePrivate(path5, content) {
|
|
7869
|
-
await
|
|
7870
|
-
await
|
|
7871
|
-
await
|
|
8585
|
+
await mkdir8(dirname6(path5), { recursive: true, mode: 448 });
|
|
8586
|
+
await writeFile7(path5, content, { encoding: "utf8", mode: 384 });
|
|
8587
|
+
await chmod5(path5, 384);
|
|
7872
8588
|
}
|
|
7873
8589
|
async remove(path5) {
|
|
7874
|
-
await
|
|
8590
|
+
await rm6(path5, { force: true });
|
|
7875
8591
|
}
|
|
7876
8592
|
};
|
|
7877
8593
|
function createDefaultClientConnectors(profile, options = {}) {
|
|
7878
|
-
const home = options.homeDirectory ??
|
|
7879
|
-
const
|
|
8594
|
+
const home = options.homeDirectory ?? homedir12();
|
|
8595
|
+
const platform2 = options.platform ?? process.platform;
|
|
7880
8596
|
const fileSystem = options.fileSystem ?? new NodeConnectorFileSystem();
|
|
7881
8597
|
const runtime = runtimeCommand(options.command, options.commandArgs);
|
|
7882
8598
|
const shared = {
|
|
@@ -7887,14 +8603,14 @@ function createDefaultClientConnectors(profile, options = {}) {
|
|
|
7887
8603
|
endpoint: options.endpoint,
|
|
7888
8604
|
accessToken: options.accessToken
|
|
7889
8605
|
};
|
|
7890
|
-
const claudeDesktop =
|
|
7891
|
-
const vscodeUser =
|
|
8606
|
+
const claudeDesktop = platform2 === "darwin" ? join11(home, "Library", "Application Support", "Claude", "claude_desktop_config.json") : platform2 === "win32" ? join11(home, "AppData", "Roaming", "Claude", "claude_desktop_config.json") : join11(home, ".config", "Claude", "claude_desktop_config.json");
|
|
8607
|
+
const vscodeUser = platform2 === "darwin" ? join11(home, "Library", "Application Support", "Code", "User", "mcp.json") : platform2 === "win32" ? join11(home, "AppData", "Roaming", "Code", "User", "mcp.json") : join11(home, ".config", "Code", "User", "mcp.json");
|
|
7892
8608
|
return [
|
|
7893
|
-
createCodexConnector({ ...shared, configPath:
|
|
8609
|
+
createCodexConnector({ ...shared, configPath: join11(home, ".codex", "config.toml"), detectionPath: join11(home, ".codex") }, fileSystem),
|
|
7894
8610
|
createClaudeDesktopConnector({ ...shared, configPath: claudeDesktop, detectionPath: dirname6(claudeDesktop) }, fileSystem),
|
|
7895
|
-
createClaudeCodeConnector({ ...shared, configPath:
|
|
8611
|
+
createClaudeCodeConnector({ ...shared, configPath: join11(home, ".claude.json"), detectionPath: join11(home, ".claude") }, fileSystem),
|
|
7896
8612
|
createVsCodeConnector({ ...shared, configPath: vscodeUser, detectionPath: dirname6(vscodeUser) }, fileSystem),
|
|
7897
|
-
createCursorConnector({ ...shared, configPath:
|
|
8613
|
+
createCursorConnector({ ...shared, configPath: join11(home, ".cursor", "mcp.json"), detectionPath: join11(home, ".cursor") }, fileSystem)
|
|
7898
8614
|
];
|
|
7899
8615
|
}
|
|
7900
8616
|
var DefaultClientIntegration = class {
|
|
@@ -7934,6 +8650,8 @@ function isMissing3(error) {
|
|
|
7934
8650
|
}
|
|
7935
8651
|
|
|
7936
8652
|
// src/mcp/deployment/onboarding.ts
|
|
8653
|
+
import { createTheme as createTheme3 } from "@bunizao/cli-kit";
|
|
8654
|
+
var PLAIN = createTheme3(false);
|
|
7937
8655
|
var ONBOARDING_STAGES = [
|
|
7938
8656
|
{ id: "validate_moodle_session", index: 1, label: "Validating Moodle session" },
|
|
7939
8657
|
{ id: "check_cloudflare_access", index: 2, label: "Checking Cloudflare access" },
|
|
@@ -8025,39 +8743,42 @@ var ONBOARDING_COPY = {
|
|
|
8025
8743
|
"Wrangler will request Cloudflare authorization again."
|
|
8026
8744
|
].join("\n")
|
|
8027
8745
|
};
|
|
8028
|
-
function formatOnboardingStage(stageId, status) {
|
|
8746
|
+
function formatOnboardingStage(stageId, status, theme = PLAIN) {
|
|
8029
8747
|
const stage = ONBOARDING_STAGES.find((item) => item.id === stageId);
|
|
8030
8748
|
if (!stage) {
|
|
8031
8749
|
throw new Error(`Unknown onboarding stage: ${stageId}`);
|
|
8032
8750
|
}
|
|
8033
8751
|
const line = `[${stage.index}/8] ${stage.label}`;
|
|
8034
|
-
return status === "completed" ?
|
|
8752
|
+
return status === "completed" ? `${theme.tone("success", "\u2713")} ${line}` : line;
|
|
8035
8753
|
}
|
|
8036
|
-
function successfulDeploymentCopy(input2) {
|
|
8037
|
-
const connectedClients = input2.clients.length ? input2.clients.map((client) => ` \u2713 ${client}`).join("\n") : "
|
|
8754
|
+
function successfulDeploymentCopy(input2, theme = PLAIN) {
|
|
8755
|
+
const connectedClients = input2.clients.length ? input2.clients.map((client) => ` ${theme.tone("success", "\u2713")} ${client}`).join("\n") : ` ${theme.dim("No supported clients detected")}`;
|
|
8756
|
+
const field = (label, value) => ` ${theme.dim(`${label}:`)} ${value}`;
|
|
8038
8757
|
return [
|
|
8039
|
-
"Moodle MCP is ready.",
|
|
8758
|
+
theme.tone("success", "Moodle MCP is ready."),
|
|
8040
8759
|
"",
|
|
8041
|
-
"Endpoint",
|
|
8042
|
-
|
|
8760
|
+
theme.subject("Endpoint"),
|
|
8761
|
+
// The one line a person copies into a client, so it reads as an identifier, not a footnote.
|
|
8762
|
+
` ${theme.key(input2.endpoint)}`,
|
|
8043
8763
|
"",
|
|
8044
|
-
"Protocol",
|
|
8045
|
-
"
|
|
8046
|
-
|
|
8764
|
+
theme.subject("Protocol"),
|
|
8765
|
+
` ${theme.dim("MCP")} ${MODERN_PROTOCOL_VERSION}`,
|
|
8766
|
+
` ${theme.dim(`Stateless legacy compatibility: ${LEGACY_PROTOCOL_VERSION}`)}`,
|
|
8047
8767
|
"",
|
|
8048
|
-
"Moodle",
|
|
8049
|
-
|
|
8050
|
-
|
|
8051
|
-
"
|
|
8768
|
+
theme.subject("Moodle"),
|
|
8769
|
+
field("Site", theme.target(input2.moodleSite)),
|
|
8770
|
+
field("User", input2.moodleUser),
|
|
8771
|
+
field("Session", theme.status("ready", { ready: "success" })),
|
|
8052
8772
|
"",
|
|
8053
|
-
"Renewal",
|
|
8054
|
-
|
|
8055
|
-
|
|
8773
|
+
theme.subject("Renewal"),
|
|
8774
|
+
// People did not ask for a scheduler, so the first thing to say is what they will
|
|
8775
|
+
// notice: nothing, unless Moodle signs them out. The mechanics come last and dim.
|
|
8776
|
+
" A silent background check every 30 minutes; you never see it.",
|
|
8777
|
+
` If Moodle signs you out it re-uploads your browser cookie, or sends one notification to run ${theme.key("moodle mcp login")}.`,
|
|
8778
|
+
...input2.renewal ? [` ${theme.dim(`${input2.renewal.scheduler} ${input2.renewal.label} \xB7 log ${input2.renewal.log} \xB7 remove with moodle mcp remove`)}`] : [],
|
|
8056
8779
|
"",
|
|
8057
|
-
"Connected clients",
|
|
8058
|
-
connectedClients
|
|
8059
|
-
"",
|
|
8060
|
-
"Run `moodle mcp status` at any time."
|
|
8780
|
+
theme.subject("Connected clients"),
|
|
8781
|
+
connectedClients
|
|
8061
8782
|
].join("\n");
|
|
8062
8783
|
}
|
|
8063
8784
|
|
|
@@ -8363,24 +9084,27 @@ var ManagedMcpDeployment = class {
|
|
|
8363
9084
|
profile,
|
|
8364
9085
|
worker: null,
|
|
8365
9086
|
credentialsStored: false,
|
|
9087
|
+
credentialsAvailable: true,
|
|
8366
9088
|
renewalInstalled: await this.dependencies.renewal.inspect(profile),
|
|
8367
9089
|
clientsConnected: await this.dependencies.clients.inspect(profile),
|
|
8368
9090
|
readiness: "unknown",
|
|
8369
9091
|
readinessReasonCode: "NOT_DEPLOYED",
|
|
8370
|
-
sessionRevision: null
|
|
9092
|
+
sessionRevision: null,
|
|
9093
|
+
recoveryActive: false
|
|
8371
9094
|
};
|
|
8372
9095
|
}
|
|
8373
9096
|
const [worker, credentials, renewalInstalled, clientsConnected] = await Promise.all([
|
|
8374
9097
|
this.dependencies.wrangler.inspect(receipt.accountId, receipt.workerName),
|
|
8375
|
-
|
|
9098
|
+
// Reporting is not worth failing over: an unopenable keychain is reported, not thrown.
|
|
9099
|
+
readCredentialsForReport(() => this.dependencies.credentials.read(profile)),
|
|
8376
9100
|
this.dependencies.renewal.inspect(profile),
|
|
8377
9101
|
this.dependencies.clients.inspect(profile)
|
|
8378
9102
|
]);
|
|
8379
9103
|
let readiness = "unknown";
|
|
8380
9104
|
let readinessReasonCode = null;
|
|
8381
9105
|
let sessionRevision = null;
|
|
8382
|
-
if (worker && credentials) {
|
|
8383
|
-
const target = { endpoint: receipt.productionEndpoint, sessionSyncToken: credentials.sessionSyncToken };
|
|
9106
|
+
if (worker && credentials.value) {
|
|
9107
|
+
const target = { endpoint: receipt.productionEndpoint, sessionSyncToken: credentials.value.sessionSyncToken };
|
|
8384
9108
|
await this.dependencies.worker.touchSession(target);
|
|
8385
9109
|
const remoteReadiness = await this.dependencies.worker.getReadiness(target);
|
|
8386
9110
|
readiness = remoteReadiness.status;
|
|
@@ -8391,12 +9115,14 @@ var ManagedMcpDeployment = class {
|
|
|
8391
9115
|
return {
|
|
8392
9116
|
profile,
|
|
8393
9117
|
worker: resolvedWorker,
|
|
8394
|
-
credentialsStored: credentials !== null,
|
|
9118
|
+
credentialsStored: credentials.value !== null,
|
|
9119
|
+
credentialsAvailable: credentials.available,
|
|
8395
9120
|
renewalInstalled,
|
|
8396
9121
|
clientsConnected,
|
|
8397
9122
|
readiness,
|
|
8398
9123
|
readinessReasonCode,
|
|
8399
|
-
sessionRevision
|
|
9124
|
+
sessionRevision,
|
|
9125
|
+
recoveryActive: receipt.releaseDigest.endsWith("-recovery")
|
|
8400
9126
|
};
|
|
8401
9127
|
}
|
|
8402
9128
|
async recover(profile) {
|
|
@@ -8664,10 +9390,10 @@ function asDeploymentError(error) {
|
|
|
8664
9390
|
|
|
8665
9391
|
// src/mcp/wrangler.ts
|
|
8666
9392
|
import { createUi as createUi2 } from "@bunizao/cli-kit";
|
|
8667
|
-
import { mkdir as
|
|
9393
|
+
import { mkdir as mkdir9, writeFile as writeFile8 } from "fs/promises";
|
|
8668
9394
|
import { existsSync } from "fs";
|
|
8669
|
-
import { homedir as
|
|
8670
|
-
import { join as
|
|
9395
|
+
import { homedir as homedir13 } from "os";
|
|
9396
|
+
import { join as join12 } from "path";
|
|
8671
9397
|
async function resolveWrangler(runner, options = {}) {
|
|
8672
9398
|
const env = options.env ?? process.env;
|
|
8673
9399
|
const notice = options.notice ?? ((text2) => process.stderr.write(`${text2}
|
|
@@ -8678,8 +9404,8 @@ async function resolveWrangler(runner, options = {}) {
|
|
|
8678
9404
|
if (version && sameMajorAtLeast(version, WRANGLER_VERSION)) return { command: existing, args: [] };
|
|
8679
9405
|
notice(`Ignoring ${existing} (${version ?? "unknown version"}); Cloudflare management needs Wrangler ${WRANGLER_VERSION.split(".")[0]}.x.`);
|
|
8680
9406
|
}
|
|
8681
|
-
const root =
|
|
8682
|
-
const script =
|
|
9407
|
+
const root = join12(options.homeDir ?? homedir13(), ".config", "moodle-cli", "tools", `wrangler@${WRANGLER_VERSION}`);
|
|
9408
|
+
const script = join12(root, "node_modules", "wrangler", "bin", "wrangler.js");
|
|
8683
9409
|
const bun = findExecutable("bun", env);
|
|
8684
9410
|
const node = findExecutable("node", env);
|
|
8685
9411
|
if (!bun && !node) throw new Error("Cloudflare management needs Bun or Node 22.13+. Install either, then retry moodle mcp deploy.");
|
|
@@ -8695,8 +9421,8 @@ async function resolveWrangler(runner, options = {}) {
|
|
|
8695
9421
|
}
|
|
8696
9422
|
}
|
|
8697
9423
|
notice(`Cloudflare management needs Wrangler ${WRANGLER_VERSION}; downloading once to ${root}.`);
|
|
8698
|
-
await
|
|
8699
|
-
await
|
|
9424
|
+
await mkdir9(root, { recursive: true, mode: 448 });
|
|
9425
|
+
await writeFile8(join12(root, "package.json"), '{ "private": true }\n');
|
|
8700
9426
|
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
9427
|
if (!existsSync(script)) {
|
|
8702
9428
|
const output = `${result.stderr}
|
|
@@ -8716,18 +9442,18 @@ function sameMajorAtLeast(actual, pinned) {
|
|
|
8716
9442
|
|
|
8717
9443
|
// src/mcp/deployment/node-adapters.ts
|
|
8718
9444
|
import { isDeepStrictEqual } from "util";
|
|
8719
|
-
import { spawn as
|
|
9445
|
+
import { spawn as spawn4 } from "child_process";
|
|
8720
9446
|
import { createHash as createHash2, randomBytes as randomBytes2 } from "crypto";
|
|
8721
|
-
import { chmod as
|
|
8722
|
-
import { homedir as
|
|
8723
|
-
import { basename, dirname as dirname7, join as
|
|
9447
|
+
import { chmod as chmod6, mkdir as mkdir10, mkdtemp, readFile as readFile9, rm as rm7, writeFile as writeFile9 } from "fs/promises";
|
|
9448
|
+
import { homedir as homedir14, tmpdir } from "os";
|
|
9449
|
+
import { basename, dirname as dirname7, join as join13 } from "path";
|
|
8724
9450
|
var MODERN_MCP_VERSION = "2026-07-28";
|
|
8725
9451
|
var WORKER_PROPAGATION_ATTEMPTS = 10;
|
|
8726
9452
|
var WORKER_PROPAGATION_MAX_DELAY_MS = 4e3;
|
|
8727
9453
|
var NodeDeploymentCommandRunner = class {
|
|
8728
9454
|
async run(command, args, environment = {}) {
|
|
8729
9455
|
return new Promise((resolve, reject) => {
|
|
8730
|
-
const child =
|
|
9456
|
+
const child = spawn4(command, args, {
|
|
8731
9457
|
env: { ...process.env, ...environment },
|
|
8732
9458
|
stdio: ["ignore", "pipe", "pipe"],
|
|
8733
9459
|
windowsHide: true
|
|
@@ -8961,7 +9687,7 @@ ${error.stderr}`)) {
|
|
|
8961
9687
|
}
|
|
8962
9688
|
};
|
|
8963
9689
|
async function copyReleaseBundle(source, destination) {
|
|
8964
|
-
await
|
|
9690
|
+
await writeFile9(destination, await readFile9(source));
|
|
8965
9691
|
}
|
|
8966
9692
|
var NodeReleaseMaterializer = class {
|
|
8967
9693
|
constructor(options) {
|
|
@@ -8970,13 +9696,13 @@ var NodeReleaseMaterializer = class {
|
|
|
8970
9696
|
options;
|
|
8971
9697
|
async prepare(plan, credentials) {
|
|
8972
9698
|
const temporaryRoot = this.options.temporaryRoot ?? tmpdir();
|
|
8973
|
-
await
|
|
8974
|
-
const artifactDirectory = await mkdtemp(
|
|
8975
|
-
await
|
|
8976
|
-
const workerFile =
|
|
9699
|
+
await mkdir10(temporaryRoot, { recursive: true });
|
|
9700
|
+
const artifactDirectory = await mkdtemp(join13(temporaryRoot, "moodle-mcp-"));
|
|
9701
|
+
await chmod6(artifactDirectory, 448);
|
|
9702
|
+
const workerFile = join13(artifactDirectory, basename(this.options.workerBundlePath));
|
|
8977
9703
|
await copyReleaseBundle(this.options.workerBundlePath, workerFile);
|
|
8978
|
-
const wranglerConfigPath =
|
|
8979
|
-
const secretsFilePath =
|
|
9704
|
+
const wranglerConfigPath = join13(artifactDirectory, "wrangler.json");
|
|
9705
|
+
const secretsFilePath = join13(artifactDirectory, "secrets.json");
|
|
8980
9706
|
const expectedHosts = endpointHosts(plan.intent.workerName, plan.existing?.productionEndpoint);
|
|
8981
9707
|
const config = {
|
|
8982
9708
|
$schema: "node_modules/wrangler/config-schema.json",
|
|
@@ -9016,18 +9742,18 @@ var NodeReleaseMaterializer = class {
|
|
|
9016
9742
|
if (credentials.previousTokensExpireAt !== void 0 && Number.isFinite(credentials.previousTokensExpireAt)) {
|
|
9017
9743
|
secrets.TOKEN_OVERLAP_EXPIRES_AT = String(credentials.previousTokensExpireAt);
|
|
9018
9744
|
}
|
|
9019
|
-
await
|
|
9745
|
+
await writeFile9(wranglerConfigPath, `${JSON.stringify(config, null, 2)}
|
|
9020
9746
|
`, { mode: 384 });
|
|
9021
|
-
await
|
|
9747
|
+
await writeFile9(secretsFilePath, `${JSON.stringify(secrets)}
|
|
9022
9748
|
`, { mode: 384 });
|
|
9023
|
-
await
|
|
9024
|
-
await
|
|
9749
|
+
await chmod6(wranglerConfigPath, 384);
|
|
9750
|
+
await chmod6(secretsFilePath, 384);
|
|
9025
9751
|
let recoveryConfigPath;
|
|
9026
9752
|
try {
|
|
9027
|
-
const recoveryBundle = process.env.MOODLE_BUNDLED_RECOVERY ??
|
|
9028
|
-
await copyReleaseBundle(recoveryBundle,
|
|
9029
|
-
recoveryConfigPath =
|
|
9030
|
-
await
|
|
9753
|
+
const recoveryBundle = process.env.MOODLE_BUNDLED_RECOVERY ?? join13(dirname7(this.options.workerBundlePath), "recovery.js");
|
|
9754
|
+
await copyReleaseBundle(recoveryBundle, join13(artifactDirectory, "recovery.js"));
|
|
9755
|
+
recoveryConfigPath = join13(artifactDirectory, "wrangler-recovery.json");
|
|
9756
|
+
await writeFile9(recoveryConfigPath, `${JSON.stringify({ ...config, main: "./recovery.js" })}
|
|
9031
9757
|
`, { mode: 384 });
|
|
9032
9758
|
} catch (error) {
|
|
9033
9759
|
if (!isMissing4(error) || plan.existing) throw error;
|
|
@@ -9035,7 +9761,7 @@ var NodeReleaseMaterializer = class {
|
|
|
9035
9761
|
return { artifactDirectory, wranglerConfigPath, secretsFilePath, recoveryConfigPath, encryptionKeyId: config.vars.SESSION_KEY_ID, credentialId: config.vars.SESSION_CREDENTIAL_ID };
|
|
9036
9762
|
}
|
|
9037
9763
|
async cleanup(release) {
|
|
9038
|
-
await
|
|
9764
|
+
await rm7(release.artifactDirectory, { recursive: true, force: true });
|
|
9039
9765
|
}
|
|
9040
9766
|
};
|
|
9041
9767
|
var DefaultMoodleSessionSource = class {
|
|
@@ -9136,28 +9862,25 @@ var FetchManagedWorkerClient = class {
|
|
|
9136
9862
|
}
|
|
9137
9863
|
await this.mcpCall(input2.endpoint, input2.mcpAccessToken, "server/discover", {}, 1);
|
|
9138
9864
|
await this.mcpCall(input2.endpoint, input2.mcpAccessToken, "tools/list", {}, 2);
|
|
9139
|
-
const
|
|
9140
|
-
|
|
9141
|
-
input2.mcpAccessToken,
|
|
9142
|
-
"tools/call",
|
|
9143
|
-
{ name: "get_user", arguments: {} },
|
|
9144
|
-
3
|
|
9145
|
-
);
|
|
9146
|
-
const moodleUser = mcpUserFullname(userResult);
|
|
9865
|
+
const homeResult = await this.mcpCall(input2.endpoint, input2.mcpAccessToken, "tools/call", { name: "home", arguments: {} }, 3);
|
|
9866
|
+
const moodleUser = smokeMoodleUser(homeResult);
|
|
9147
9867
|
if (!moodleUser) {
|
|
9148
|
-
throw new DeploymentApplyError("MCP_SMOKE_FAILED", "MCP
|
|
9868
|
+
throw new DeploymentApplyError("MCP_SMOKE_FAILED", "MCP home check returned no Moodle user");
|
|
9149
9869
|
}
|
|
9150
|
-
const
|
|
9151
|
-
const
|
|
9152
|
-
if (!Array.isArray(
|
|
9153
|
-
if (
|
|
9154
|
-
const first2 =
|
|
9870
|
+
const unitsResult = await this.mcpCall(input2.endpoint, input2.mcpAccessToken, "tools/call", { name: "units", arguments: { limit: 1 } }, 4);
|
|
9871
|
+
const units = readableToolResult(unitsResult).units;
|
|
9872
|
+
if (!Array.isArray(units)) throw new DeploymentApplyError("MCP_SMOKE_FAILED", "MCP units returned no unit list");
|
|
9873
|
+
if (units.length) {
|
|
9874
|
+
const first2 = units[0];
|
|
9155
9875
|
if (!isRecord9(first2) || !Number.isSafeInteger(first2.id) || Number(first2.id) <= 0) {
|
|
9156
|
-
throw new DeploymentApplyError("MCP_SMOKE_FAILED", "MCP
|
|
9876
|
+
throw new DeploymentApplyError("MCP_SMOKE_FAILED", "MCP units returned no usable unit ID");
|
|
9877
|
+
}
|
|
9878
|
+
const detail = readableToolResult(await this.mcpCall(input2.endpoint, input2.mcpAccessToken, "tools/call", { name: "unit", arguments: { unit: first2.id } }, 5));
|
|
9879
|
+
if (!isRecord9(detail.unit) || detail.unit.id !== first2.id) {
|
|
9880
|
+
throw new DeploymentApplyError("MCP_SMOKE_FAILED", "MCP unit lookup did not match the listed unit");
|
|
9157
9881
|
}
|
|
9158
|
-
|
|
9159
|
-
|
|
9160
|
-
throw new DeploymentApplyError("MCP_SMOKE_FAILED", "MCP course lookup did not match the listed course");
|
|
9882
|
+
if (detail.sections !== void 0 && !Array.isArray(detail.sections)) {
|
|
9883
|
+
throw new DeploymentApplyError("MCP_SMOKE_FAILED", "MCP unit lookup returned an unusable section list");
|
|
9161
9884
|
}
|
|
9162
9885
|
}
|
|
9163
9886
|
return { moodleUser };
|
|
@@ -9235,13 +9958,13 @@ var FetchManagedWorkerClient = class {
|
|
|
9235
9958
|
}
|
|
9236
9959
|
};
|
|
9237
9960
|
var PrivateDeploymentReceiptStore = class {
|
|
9238
|
-
constructor(baseDirectory =
|
|
9961
|
+
constructor(baseDirectory = join13(homedir14(), ".config", "moodle-cli", "mcp", "deployments")) {
|
|
9239
9962
|
this.baseDirectory = baseDirectory;
|
|
9240
9963
|
}
|
|
9241
9964
|
baseDirectory;
|
|
9242
9965
|
async read(profile) {
|
|
9243
9966
|
try {
|
|
9244
|
-
const parsed = JSON.parse(await
|
|
9967
|
+
const parsed = JSON.parse(await readFile9(this.path(profile), "utf8"));
|
|
9245
9968
|
return isReceipt(parsed) ? parsed : null;
|
|
9246
9969
|
} catch (error) {
|
|
9247
9970
|
if (isMissing4(error)) {
|
|
@@ -9252,24 +9975,24 @@ var PrivateDeploymentReceiptStore = class {
|
|
|
9252
9975
|
}
|
|
9253
9976
|
async write(receipt) {
|
|
9254
9977
|
const path5 = this.path(receipt.profile);
|
|
9255
|
-
await
|
|
9256
|
-
await
|
|
9978
|
+
await mkdir10(dirname7(path5), { recursive: true, mode: 448 });
|
|
9979
|
+
await writeFile9(path5, `${JSON.stringify(receipt, null, 2)}
|
|
9257
9980
|
`, { mode: 384 });
|
|
9258
|
-
await
|
|
9981
|
+
await chmod6(path5, 384);
|
|
9259
9982
|
}
|
|
9260
9983
|
async delete(profile) {
|
|
9261
|
-
await
|
|
9984
|
+
await rm7(this.path(profile), { force: true });
|
|
9262
9985
|
}
|
|
9263
9986
|
path(profile) {
|
|
9264
9987
|
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(profile)) {
|
|
9265
9988
|
throw new Error("Invalid Moodle MCP profile name");
|
|
9266
9989
|
}
|
|
9267
|
-
return
|
|
9990
|
+
return join13(this.baseDirectory, `${profile}.json`);
|
|
9268
9991
|
}
|
|
9269
9992
|
};
|
|
9270
9993
|
function createDefaultManagedDeployment(options) {
|
|
9271
|
-
const homeDirectory = options.homeDirectory ??
|
|
9272
|
-
const
|
|
9994
|
+
const homeDirectory = options.homeDirectory ?? homedir14();
|
|
9995
|
+
const platform2 = options.platform ?? process.platform;
|
|
9273
9996
|
const runtime = runtimeCommand(options.executable, options.executableArgs);
|
|
9274
9997
|
const defaults = {
|
|
9275
9998
|
wrangler: new NodeWranglerDeploymentAdapter({ wranglerBinPath: options.wranglerBinPath }),
|
|
@@ -9277,12 +10000,12 @@ function createDefaultManagedDeployment(options) {
|
|
|
9277
10000
|
workerBundlePath: options.workerBundlePath,
|
|
9278
10001
|
compatibilityDate: options.compatibilityDate
|
|
9279
10002
|
}),
|
|
9280
|
-
credentials: createDefaultCredentialStore({ platform, homeDirectory }),
|
|
10003
|
+
credentials: createDefaultCredentialStore({ platform: platform2, homeDirectory }),
|
|
9281
10004
|
sessions: createInteractiveMoodleSessionSource(options.auth),
|
|
9282
10005
|
worker: new FetchManagedWorkerClient(options.fetch),
|
|
9283
10006
|
renewal: new DefaultRenewalIntegration({
|
|
9284
10007
|
...options.renewal,
|
|
9285
|
-
platform,
|
|
10008
|
+
platform: platform2,
|
|
9286
10009
|
homeDirectory,
|
|
9287
10010
|
executable: runtime.command,
|
|
9288
10011
|
executableArgs: runtime.args,
|
|
@@ -9290,12 +10013,12 @@ function createDefaultManagedDeployment(options) {
|
|
|
9290
10013
|
}),
|
|
9291
10014
|
clients: new DefaultClientIntegration({
|
|
9292
10015
|
...options.connector,
|
|
9293
|
-
platform,
|
|
10016
|
+
platform: platform2,
|
|
9294
10017
|
homeDirectory,
|
|
9295
10018
|
command: runtime.command,
|
|
9296
10019
|
commandArgs: runtime.args
|
|
9297
10020
|
}),
|
|
9298
|
-
receipts: new PrivateDeploymentReceiptStore(
|
|
10021
|
+
receipts: new PrivateDeploymentReceiptStore(join13(homeDirectory, ".config", "moodle-cli", "mcp", "deployments")),
|
|
9299
10022
|
createToken: () => randomBytes2(32).toString("base64url")
|
|
9300
10023
|
};
|
|
9301
10024
|
return new ManagedMcpDeployment({ ...defaults, ...options.dependencies });
|
|
@@ -9312,7 +10035,7 @@ function endpointUrl(endpoint, path5) {
|
|
|
9312
10035
|
async function pinExpectedHosts(configPath, workerName, productionEndpoint) {
|
|
9313
10036
|
let config;
|
|
9314
10037
|
try {
|
|
9315
|
-
config = JSON.parse(await
|
|
10038
|
+
config = JSON.parse(await readFile9(configPath, "utf8"));
|
|
9316
10039
|
} catch {
|
|
9317
10040
|
throw new DeploymentApplyError("RELEASE_CONFIG_INVALID", "The generated Wrangler configuration is invalid");
|
|
9318
10041
|
}
|
|
@@ -9324,7 +10047,7 @@ async function pinExpectedHosts(configPath, workerName, productionEndpoint) {
|
|
|
9324
10047
|
throw new DeploymentApplyError("MISSING_ENDPOINT", "The Worker production endpoint is invalid");
|
|
9325
10048
|
}
|
|
9326
10049
|
config.vars.EXPECTED_HOSTS = hosts.join(",");
|
|
9327
|
-
await
|
|
10050
|
+
await writeFile9(configPath, `${JSON.stringify(config, null, 2)}
|
|
9328
10051
|
`, { mode: 384 });
|
|
9329
10052
|
}
|
|
9330
10053
|
function endpointHosts(workerName, endpoint) {
|
|
@@ -9373,9 +10096,9 @@ function readableToolResult(result) {
|
|
|
9373
10096
|
}
|
|
9374
10097
|
throw new DeploymentApplyError("MCP_CONTENT_INCOMPLETE", "MCP text content is missing the complete structured result");
|
|
9375
10098
|
}
|
|
9376
|
-
function
|
|
9377
|
-
const
|
|
9378
|
-
return isRecord9(
|
|
10099
|
+
function smokeMoodleUser(result) {
|
|
10100
|
+
const home = readableToolResult(result).home;
|
|
10101
|
+
return isRecord9(home) && typeof home.name === "string" && home.name.trim() ? home.name.trim() : null;
|
|
9379
10102
|
}
|
|
9380
10103
|
function parseJsonOutput(output) {
|
|
9381
10104
|
const candidates = [output.indexOf("{"), output.indexOf("[")].filter((index) => index >= 0).sort((a, b) => a - b);
|
|
@@ -9589,7 +10312,10 @@ function createMoodleMcpServer(gateway, options = {}) {
|
|
|
9589
10312
|
protocolVersion: protocolVersion2,
|
|
9590
10313
|
capabilities: { tools: { listChanged: false } },
|
|
9591
10314
|
serverInfo,
|
|
9592
|
-
instructions:
|
|
10315
|
+
instructions: [
|
|
10316
|
+
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.",
|
|
10317
|
+
...options.instructions ?? []
|
|
10318
|
+
].join(" ")
|
|
9593
10319
|
});
|
|
9594
10320
|
}
|
|
9595
10321
|
if (request.method === "ping") {
|
|
@@ -9710,6 +10436,9 @@ async function callTool(gateway, params) {
|
|
|
9710
10436
|
function toolContent(name, payload, structuredContent) {
|
|
9711
10437
|
const text2 = { type: "text", text: JSON.stringify(structuredContent) };
|
|
9712
10438
|
if (name !== "get_file" || !isMoodleFile(payload)) return [text2];
|
|
10439
|
+
if (payload.mimeType.startsWith("image/")) {
|
|
10440
|
+
return [text2, { type: "image", data: payload.blob, mimeType: payload.mimeType }];
|
|
10441
|
+
}
|
|
9713
10442
|
return [
|
|
9714
10443
|
text2,
|
|
9715
10444
|
{
|
|
@@ -9834,9 +10563,9 @@ function deriveMcpWorkerName(moodleOrigin) {
|
|
|
9834
10563
|
var DefaultMcpCommandService = class {
|
|
9835
10564
|
constructor(options) {
|
|
9836
10565
|
this.options = options;
|
|
9837
|
-
this.homeDirectory = options.homeDir ??
|
|
10566
|
+
this.homeDirectory = options.homeDir ?? homedir15();
|
|
9838
10567
|
this.wranglerInstance = options.wrangler;
|
|
9839
|
-
this.receipts = options.receipts ?? new PrivateDeploymentReceiptStore(
|
|
10568
|
+
this.receipts = options.receipts ?? new PrivateDeploymentReceiptStore(join14(this.homeDirectory, ".config", "moodle-cli", "mcp", "deployments"));
|
|
9840
10569
|
this.credentials = options.credentials ?? createDefaultCredentialStore({ platform: process.platform, homeDirectory: this.homeDirectory });
|
|
9841
10570
|
this.worker = options.worker ?? new FetchManagedWorkerClient(options.fetchImpl);
|
|
9842
10571
|
this.renewal = options.renewal ?? new DefaultRenewalIntegration({
|
|
@@ -9862,6 +10591,7 @@ var DefaultMcpCommandService = class {
|
|
|
9862
10591
|
progressReporter;
|
|
9863
10592
|
async deploy(input2) {
|
|
9864
10593
|
await this.prepareToolchain(input2.yes);
|
|
10594
|
+
const theme = this.theme();
|
|
9865
10595
|
const progress = this.progress();
|
|
9866
10596
|
try {
|
|
9867
10597
|
progress.begin("Reading Cloudflare account and deployment state");
|
|
@@ -9872,7 +10602,8 @@ var DefaultMcpCommandService = class {
|
|
|
9872
10602
|
const recovery = await deployment.rollback(identity.profile);
|
|
9873
10603
|
return {
|
|
9874
10604
|
data: recovery,
|
|
9875
|
-
text:
|
|
10605
|
+
text: `${theme.tone("success", "Moodle MCP restored release")} ${theme.key(recovery.versionId)}.`,
|
|
10606
|
+
next: ["moodle mcp status"]
|
|
9876
10607
|
};
|
|
9877
10608
|
}
|
|
9878
10609
|
progress.begin("Planning the deployment");
|
|
@@ -9894,18 +10625,19 @@ var DefaultMcpCommandService = class {
|
|
|
9894
10625
|
uploadCandidate: plan.uploadCandidate
|
|
9895
10626
|
},
|
|
9896
10627
|
text: [
|
|
9897
|
-
"Moodle MCP deployment plan",
|
|
9898
|
-
`Operation: ${plan.operation}`,
|
|
9899
|
-
`Worker: ${plan.intent.workerName}`,
|
|
9900
|
-
`Candidate upload: ${plan.uploadCandidate ? "yes" : "no"}`
|
|
9901
|
-
].join("\n")
|
|
10628
|
+
theme.subject("Moodle MCP deployment plan"),
|
|
10629
|
+
` ${theme.dim("Operation:")} ${plan.operation}`,
|
|
10630
|
+
` ${theme.dim("Worker:")} ${theme.key(plan.intent.workerName)}`,
|
|
10631
|
+
` ${theme.dim("Candidate upload:")} ${plan.uploadCandidate ? "yes" : "no"}`
|
|
10632
|
+
].join("\n"),
|
|
10633
|
+
next: ["moodle mcp deploy"]
|
|
9902
10634
|
};
|
|
9903
10635
|
}
|
|
9904
10636
|
const events = [];
|
|
9905
10637
|
for await (const event2 of deployment.apply(plan)) {
|
|
9906
10638
|
events.push(event2);
|
|
9907
|
-
if (event2.status === "started") progress.begin(formatOnboardingStage(event2.stageId, "pending"));
|
|
9908
|
-
else if (event2.status === "completed") progress.end(formatOnboardingStage(event2.stageId, "completed"));
|
|
10639
|
+
if (event2.status === "started") progress.begin(formatOnboardingStage(event2.stageId, "pending", theme));
|
|
10640
|
+
else if (event2.status === "completed") progress.end(formatOnboardingStage(event2.stageId, "completed", theme));
|
|
9909
10641
|
else progress.clear();
|
|
9910
10642
|
}
|
|
9911
10643
|
progress.begin("Reading deployment status");
|
|
@@ -9927,8 +10659,10 @@ var DefaultMcpCommandService = class {
|
|
|
9927
10659
|
endpoint: `${endpoint.replace(/\/$/u, "")}/mcp`,
|
|
9928
10660
|
moodleSite: identity.moodleOrigin,
|
|
9929
10661
|
moodleUser: events.find((event2) => event2.moodleUser)?.moodleUser ?? "Unknown Moodle user",
|
|
9930
|
-
clients: await this.connectedClientNames(identity.profile)
|
|
9931
|
-
|
|
10662
|
+
clients: await this.connectedClientNames(identity.profile),
|
|
10663
|
+
renewal: this.renewalJob(identity.profile)
|
|
10664
|
+
}, this.theme()),
|
|
10665
|
+
next: ["moodle mcp status", "moodle mcp pair"]
|
|
9932
10666
|
};
|
|
9933
10667
|
}
|
|
9934
10668
|
async planDeployment(deployment, initialIntent) {
|
|
@@ -9991,26 +10725,54 @@ var DefaultMcpCommandService = class {
|
|
|
9991
10725
|
localAuthentication = { status: "unknown" };
|
|
9992
10726
|
}
|
|
9993
10727
|
const updateAvailable = await this.remoteWorkerBehindLocal(profile);
|
|
10728
|
+
const [receipt, credentials] = await Promise.all([
|
|
10729
|
+
this.receipts.read(profile),
|
|
10730
|
+
readCredentialsForReport(() => this.credentials.read(profile))
|
|
10731
|
+
]);
|
|
10732
|
+
const job = this.renewalJob(profile);
|
|
10733
|
+
const hosted = receipt && credentials.value ? await this.worker.manageClients({ endpoint: receipt.productionEndpoint, sessionSyncToken: credentials.value.sessionSyncToken }).then((data2) => isClientList(data2) ? data2.clients.filter((client) => client.approved).length : null).catch(() => null) : null;
|
|
10734
|
+
const credentialsState = !credentials.available || !managed.credentialsAvailable ? "unavailable" : managed.credentialsStored ? "stored" : "missing";
|
|
10735
|
+
const renewal = {
|
|
10736
|
+
installed: managed.renewalInstalled,
|
|
10737
|
+
...job ? { scheduler: job.scheduler, label: job.label, schedule: job.schedule, log: job.log } : {},
|
|
10738
|
+
lastRun: receipt?.lastRenewal ?? null
|
|
10739
|
+
};
|
|
9994
10740
|
const data = {
|
|
9995
10741
|
profile,
|
|
9996
10742
|
localAuthentication,
|
|
9997
10743
|
managed,
|
|
10744
|
+
credentials: { state: credentialsState },
|
|
10745
|
+
renewal,
|
|
10746
|
+
hostedClients: hosted,
|
|
9998
10747
|
protocols: [...SUPPORTED_PROTOCOL_VERSIONS],
|
|
9999
10748
|
updateAvailable,
|
|
10000
10749
|
...input2.verbose ? { serviceVersion: VERSION } : {},
|
|
10001
10750
|
...input2.logs ? { logs: { available: false, reason: "live_tail_required" } } : {}
|
|
10002
10751
|
};
|
|
10752
|
+
const theme = this.theme();
|
|
10753
|
+
const row = (label, value) => `${theme.dim(`${label}:`)} ${theme.status(value, { pass: "success", warn: "warning", fail: "danger", unknown: "muted", "not deployed": "warning", missing: "warning", "not connected": "warning", unavailable: "warning", stored: "success", installed: "success", connected: "success" })}`;
|
|
10003
10754
|
return {
|
|
10004
10755
|
data,
|
|
10005
10756
|
text: [
|
|
10006
|
-
|
|
10007
|
-
|
|
10008
|
-
|
|
10009
|
-
|
|
10010
|
-
`
|
|
10011
|
-
|
|
10012
|
-
...input2.
|
|
10013
|
-
|
|
10757
|
+
row("Moodle MCP", managed.readiness),
|
|
10758
|
+
row("Worker", managed.worker?.workerName ?? "not deployed"),
|
|
10759
|
+
row("Credentials", credentialsState),
|
|
10760
|
+
row("Renewal", managed.renewalInstalled ? "installed" : "missing"),
|
|
10761
|
+
...managed.renewalInstalled && job ? [` ${theme.dim(`${job.schedule}; you only hear from it when Moodle signs you out`)}`] : [],
|
|
10762
|
+
` ${theme.dim("Last run:")} ${renewalRunText(renewal.lastRun, theme)}`,
|
|
10763
|
+
...managed.renewalInstalled && job && input2.verbose ? [` ${theme.dim("Log:")} ${theme.dim(job.log)}`] : [],
|
|
10764
|
+
row("Local clients", managed.clientsConnected ? "connected" : "not connected"),
|
|
10765
|
+
`${theme.dim("Hosted clients:")} ${hosted === null ? theme.dim("unknown") : hosted ? `${hosted} approved` : theme.dim("none")}`,
|
|
10766
|
+
...managed.recoveryActive ? [`${theme.dim("Release:")} ${theme.tone("warning", "the recovery Worker is live; OAuth sign-in is disabled until")} ${theme.key("moodle mcp deploy")} ${theme.tone("warning", "succeeds.")}`] : [],
|
|
10767
|
+
...updateAvailable ? [`${theme.dim("Update:")} remote Worker is behind this CLI. Run ${theme.key("moodle update")} to update it.`] : [],
|
|
10768
|
+
...input2.logs ? [`${theme.dim("Logs:")} use a live sanitized tail from an interactive terminal`] : []
|
|
10769
|
+
].join("\n"),
|
|
10770
|
+
next: [
|
|
10771
|
+
...updateAvailable ? ["moodle update"] : [],
|
|
10772
|
+
...managed.readiness === "fail" ? ["moodle mcp login"] : [],
|
|
10773
|
+
...!managed.clientsConnected ? ["moodle mcp connect"] : [],
|
|
10774
|
+
...hosted ? [] : ["moodle mcp pair"]
|
|
10775
|
+
]
|
|
10014
10776
|
};
|
|
10015
10777
|
}
|
|
10016
10778
|
async login() {
|
|
@@ -10020,9 +10782,12 @@ var DefaultMcpCommandService = class {
|
|
|
10020
10782
|
try {
|
|
10021
10783
|
progress.begin("Reading your Moodle session (a browser sign-in may be required)");
|
|
10022
10784
|
const recovery = await this.deployment(false).recover(profile);
|
|
10785
|
+
const theme = this.theme();
|
|
10786
|
+
const done = (line) => `${theme.tone("success", "\u2713")} ${line}`;
|
|
10023
10787
|
return {
|
|
10024
10788
|
data: recovery,
|
|
10025
|
-
text: "
|
|
10789
|
+
text: [done("New Moodle session acquired."), done("Remote session updated."), done("MCP readiness restored.")].join("\n"),
|
|
10790
|
+
next: ["moodle mcp status"]
|
|
10026
10791
|
};
|
|
10027
10792
|
} finally {
|
|
10028
10793
|
progress.clear();
|
|
@@ -10051,13 +10816,15 @@ var DefaultMcpCommandService = class {
|
|
|
10051
10816
|
}
|
|
10052
10817
|
const connected = [];
|
|
10053
10818
|
for (const connector of selected) connected.push(await connectClient(connector));
|
|
10819
|
+
const theme = this.theme();
|
|
10054
10820
|
const text2 = [
|
|
10055
|
-
...connected.map((item) =>
|
|
10056
|
-
...input2.showToken ? ["", "MCP access token", credentials.mcpAccessToken] : []
|
|
10821
|
+
...connected.map((item) => `${theme.tone("success", "\u2713")} ${item.client} ${theme.dim(item.changed ? `\xB7 ${item.configPath}` : "\xB7 already connected")}`),
|
|
10822
|
+
...input2.showToken ? ["", theme.subject("MCP access token"), ` ${credentials.mcpAccessToken}`] : []
|
|
10057
10823
|
].join("\n");
|
|
10058
10824
|
return {
|
|
10059
10825
|
data: { profile, mode: input2.mode, connected: connected.map(({ client, configPath, changed }) => ({ client, configPath, changed })) },
|
|
10060
|
-
text: text2
|
|
10826
|
+
text: text2,
|
|
10827
|
+
next: ["moodle mcp status"]
|
|
10061
10828
|
};
|
|
10062
10829
|
}
|
|
10063
10830
|
async manageClients(input2) {
|
|
@@ -10067,7 +10834,19 @@ var DefaultMcpCommandService = class {
|
|
|
10067
10834
|
const credentials = await this.credentials.read(profile);
|
|
10068
10835
|
if (!receipt || !credentials) throw new UsageError(`No managed Moodle MCP deployment exists for profile ${profile}.`);
|
|
10069
10836
|
const data = await this.worker.manageClients({ endpoint: receipt.productionEndpoint, sessionSyncToken: credentials.sessionSyncToken, ...input2 });
|
|
10070
|
-
|
|
10837
|
+
const theme = this.theme();
|
|
10838
|
+
if (input2.revoke) {
|
|
10839
|
+
return { data, text: theme.tone("success", input2.clientId ? "OAuth client revoked." : "All OAuth access revoked."), next: ["moodle mcp pair"] };
|
|
10840
|
+
}
|
|
10841
|
+
const clients = isClientList(data) ? data.clients : [];
|
|
10842
|
+
return {
|
|
10843
|
+
data,
|
|
10844
|
+
text: clients.length ? [
|
|
10845
|
+
theme.subject("OAuth clients"),
|
|
10846
|
+
...clients.map((client) => ` ${theme.status(client.approved ? "approved" : "pending", { approved: "success", pending: "warning" }).padEnd(theme.enabled ? 18 : 9)} ${client.clientName} ${theme.dim(client.clientId)}`)
|
|
10847
|
+
].join("\n") : theme.dim("No OAuth clients."),
|
|
10848
|
+
next: clients.length ? [`moodle mcp revoke ${clients[0].clientId}`, "moodle mcp pair"] : ["moodle mcp pair"]
|
|
10849
|
+
};
|
|
10071
10850
|
}
|
|
10072
10851
|
async pair() {
|
|
10073
10852
|
const profile = deriveMcpProfile((await this.config()).baseUrl);
|
|
@@ -10089,17 +10868,8 @@ var DefaultMcpCommandService = class {
|
|
|
10089
10868
|
expiresAt: pairing.expiresAt,
|
|
10090
10869
|
authorizationServer: pairing.authorizationServer
|
|
10091
10870
|
},
|
|
10092
|
-
text:
|
|
10093
|
-
|
|
10094
|
-
"",
|
|
10095
|
-
"Connector URL",
|
|
10096
|
-
` ${endpoint}`,
|
|
10097
|
-
"",
|
|
10098
|
-
"Pairing code",
|
|
10099
|
-
` ${formatPairingCode(pairing.code)}`,
|
|
10100
|
-
"",
|
|
10101
|
-
`The code expires at ${pairing.expiresAt} and works for one approval.`
|
|
10102
|
-
].join("\n")
|
|
10871
|
+
text: pairingCopy({ endpoint, code: pairing.code, expiresAt: pairing.expiresAt }, this.theme()),
|
|
10872
|
+
next: ["moodle mcp clients"]
|
|
10103
10873
|
};
|
|
10104
10874
|
}
|
|
10105
10875
|
async remove(input2) {
|
|
@@ -10115,11 +10885,12 @@ var DefaultMcpCommandService = class {
|
|
|
10115
10885
|
return {
|
|
10116
10886
|
data: result,
|
|
10117
10887
|
text: [
|
|
10118
|
-
"Moodle MCP has been removed.",
|
|
10119
|
-
`Worker: ${result.workerRemoved ? "deleted" : "not present"}`,
|
|
10120
|
-
"
|
|
10121
|
-
"Local Moodle configuration: kept
|
|
10122
|
-
].join("\n")
|
|
10888
|
+
this.theme().tone("success", "Moodle MCP has been removed."),
|
|
10889
|
+
` ${this.theme().dim("Worker:")} ${result.workerRemoved ? "deleted" : "not present"}`,
|
|
10890
|
+
` ${this.theme().dim("Renewal job, client registrations, deployment credentials:")} deleted`,
|
|
10891
|
+
` ${this.theme().dim("Local Moodle configuration:")} kept ${this.theme().dim("(its authentication cache was removed)")}`
|
|
10892
|
+
].join("\n"),
|
|
10893
|
+
next: ["moodle mcp deploy"]
|
|
10123
10894
|
};
|
|
10124
10895
|
}
|
|
10125
10896
|
async serveStdio() {
|
|
@@ -10229,16 +11000,22 @@ var DefaultMcpCommandService = class {
|
|
|
10229
11000
|
notifySignIn: this.notifyRenewalSignIn
|
|
10230
11001
|
};
|
|
10231
11002
|
const decision = await executeRenewalWithRecovery(decideRenewal(snapshot), snapshot, executor);
|
|
11003
|
+
const outcome = uploaded ? { state: "healthy", reasonCode: "SESSION_VALID" } : { state: decision.state, reasonCode: decision.reasonCode };
|
|
11004
|
+
receipt = { ...receipt, lastRenewal: { at: (/* @__PURE__ */ new Date()).toISOString(), ...outcome } };
|
|
11005
|
+
await this.receipts.write(receipt);
|
|
11006
|
+
const theme = this.theme();
|
|
10232
11007
|
if (uploaded) {
|
|
10233
11008
|
return {
|
|
10234
|
-
data: { profile,
|
|
10235
|
-
text: "Moodle MCP session renewed."
|
|
11009
|
+
data: { profile, ...outcome, revision: receipt.sessionRevision },
|
|
11010
|
+
text: theme.tone("success", "Moodle MCP session renewed."),
|
|
11011
|
+
next: ["moodle mcp status"]
|
|
10236
11012
|
};
|
|
10237
11013
|
}
|
|
10238
11014
|
const detail = decision.state === "needs_sign_in" && signInDetail ? { detail: signInDetail } : {};
|
|
10239
11015
|
return {
|
|
10240
|
-
data: { profile,
|
|
10241
|
-
text: [renewalResultText(decision), signInDetail].filter(Boolean).join("\n")
|
|
11016
|
+
data: { profile, ...outcome, revision: receipt.sessionRevision, ...detail },
|
|
11017
|
+
text: [renewalResultText(decision, theme), signInDetail].filter(Boolean).join("\n"),
|
|
11018
|
+
next: [decision.state === "needs_sign_in" ? "moodle mcp login" : "moodle mcp status"]
|
|
10242
11019
|
};
|
|
10243
11020
|
}
|
|
10244
11021
|
async writeRenewalRevision(receipt, revision) {
|
|
@@ -10280,7 +11057,7 @@ var DefaultMcpCommandService = class {
|
|
|
10280
11057
|
}
|
|
10281
11058
|
});
|
|
10282
11059
|
await this.receipts.write({ ...receipt, sessionRevision: uploaded.revision });
|
|
10283
|
-
return { data: { profile, revision: uploaded.revision }, text: "Moodle MCP session updated." };
|
|
11060
|
+
return { data: { profile, revision: uploaded.revision }, text: this.theme().tone("success", "Moodle MCP session updated."), next: ["moodle mcp status"] };
|
|
10284
11061
|
}
|
|
10285
11062
|
deployment(background) {
|
|
10286
11063
|
if (this.options.createDeployment) return this.options.createDeployment(background);
|
|
@@ -10389,13 +11166,13 @@ Selection: `)).trim());
|
|
|
10389
11166
|
fetch: this.options.fetchImpl
|
|
10390
11167
|
});
|
|
10391
11168
|
}
|
|
10392
|
-
prompt(
|
|
11169
|
+
prompt(question2) {
|
|
10393
11170
|
this.progress().clear();
|
|
10394
|
-
if (this.options.prompt) return this.options.prompt(
|
|
11171
|
+
if (this.options.prompt) return this.options.prompt(question2);
|
|
10395
11172
|
const input2 = this.options.stdin ?? process.stdin;
|
|
10396
11173
|
const output = this.options.stderr ?? process.stderr;
|
|
10397
11174
|
const readline = createInterface({ input: input2, output });
|
|
10398
|
-
return readline.question(
|
|
11175
|
+
return readline.question(question2).finally(() => readline.close());
|
|
10399
11176
|
}
|
|
10400
11177
|
workerBundlePath() {
|
|
10401
11178
|
return this.options.workerBundlePath ?? process.env.MOODLE_BUNDLED_WORKER ?? fileURLToPath(new URL("./worker/worker.js", import.meta.url));
|
|
@@ -10404,6 +11181,21 @@ Selection: `)).trim());
|
|
|
10404
11181
|
this.wranglerInstance ??= new NodeWranglerDeploymentAdapter();
|
|
10405
11182
|
return this.wranglerInstance;
|
|
10406
11183
|
}
|
|
11184
|
+
async workerState() {
|
|
11185
|
+
try {
|
|
11186
|
+
const profile = deriveMcpProfile((await this.config()).baseUrl);
|
|
11187
|
+
const [receipt, credentials] = await Promise.all([
|
|
11188
|
+
this.receipts.read(profile),
|
|
11189
|
+
readCredentialsForReport(() => this.credentials.read(profile))
|
|
11190
|
+
]);
|
|
11191
|
+
if (!receipt || !credentials.value) return null;
|
|
11192
|
+
const behind = await this.remoteWorkerBehindLocal(profile);
|
|
11193
|
+
const readiness = await this.worker.getReadiness({ endpoint: receipt.productionEndpoint, sessionSyncToken: credentials.value.sessionSyncToken }).catch(() => null);
|
|
11194
|
+
return { behind, ready: readiness?.status === "pass" };
|
|
11195
|
+
} catch {
|
|
11196
|
+
return null;
|
|
11197
|
+
}
|
|
11198
|
+
}
|
|
10407
11199
|
// The first-use Wrangler download asks a question, so it runs before a spinner
|
|
10408
11200
|
// owns the terminal rather than drawing its prompt underneath one.
|
|
10409
11201
|
async prepareToolchain(yes = false) {
|
|
@@ -10411,7 +11203,7 @@ Selection: `)).trim());
|
|
|
10411
11203
|
await this.wrangler().prepare({ yes });
|
|
10412
11204
|
}
|
|
10413
11205
|
releaseDigest() {
|
|
10414
|
-
return
|
|
11206
|
+
return readFile10(this.workerBundlePath()).then((content) => sha256(content));
|
|
10415
11207
|
}
|
|
10416
11208
|
// True when a deployment receipt exists but its recorded release digest no
|
|
10417
11209
|
// longer matches the Worker bundle shipped with this CLI, i.e. the remote
|
|
@@ -10431,6 +11223,13 @@ Selection: `)).trim());
|
|
|
10431
11223
|
}
|
|
10432
11224
|
// Progress belongs on stderr so that --json and --yaml keep stdout to themselves.
|
|
10433
11225
|
// One shared reporter, so anything else that writes can stop the animation first.
|
|
11226
|
+
theme() {
|
|
11227
|
+
return createTheme4(this.options.color?.() ?? false);
|
|
11228
|
+
}
|
|
11229
|
+
renewalJob(profile) {
|
|
11230
|
+
const platform2 = process.platform;
|
|
11231
|
+
return platform2 === "darwin" || platform2 === "linux" || platform2 === "win32" ? describeRenewalJob(platform2, this.homeDirectory, profile) : void 0;
|
|
11232
|
+
}
|
|
10434
11233
|
progress() {
|
|
10435
11234
|
this.progressReporter ??= createProgressReporter({ stream: this.options.stderr ?? process.stderr });
|
|
10436
11235
|
return this.progressReporter;
|
|
@@ -10495,12 +11294,36 @@ function isWranglerAuthRequired(error) {
|
|
|
10495
11294
|
return error instanceof WranglerCommandError && /not authenticated|not logged in|wrangler login/iu.test(`${error.stdout}
|
|
10496
11295
|
${error.stderr}`);
|
|
10497
11296
|
}
|
|
10498
|
-
function renewalResultText(decision) {
|
|
10499
|
-
if (decision.state === "offline") return "Moodle is unreachable. The remote session was preserved
|
|
10500
|
-
if (decision.state === "needs_sign_in") return "Moodle MCP needs sign-in. Run
|
|
11297
|
+
function renewalResultText(decision, theme) {
|
|
11298
|
+
if (decision.state === "offline") return `${theme.tone("warning", "Moodle is unreachable.")} The remote session was preserved.`;
|
|
11299
|
+
if (decision.state === "needs_sign_in") return `${theme.tone("warning", "Moodle MCP needs sign-in.")} Run ${theme.key("moodle mcp login")}.`;
|
|
10501
11300
|
if (decision.state === "conflict") return "Moodle MCP refreshed the remote session revision without overwriting it.";
|
|
10502
|
-
if (decision.reasonCode === "RENEWAL_AGENT_MISSING") return "Moodle MCP renewal agent installed.";
|
|
10503
|
-
return "Moodle MCP session is ready.";
|
|
11301
|
+
if (decision.reasonCode === "RENEWAL_AGENT_MISSING") return theme.tone("success", "Moodle MCP renewal agent installed.");
|
|
11302
|
+
return theme.tone("success", "Moodle MCP session is ready.");
|
|
11303
|
+
}
|
|
11304
|
+
function isClientList(value) {
|
|
11305
|
+
return typeof value === "object" && value !== null && Array.isArray(value.clients);
|
|
11306
|
+
}
|
|
11307
|
+
function renewalRunText(lastRun, theme) {
|
|
11308
|
+
if (!lastRun) return theme.dim("never (the job has not reported yet)");
|
|
11309
|
+
const ago = Math.max(0, Math.round((Date.now() - Date.parse(lastRun.at)) / 6e4));
|
|
11310
|
+
const when = ago < 1 ? "just now" : ago < 120 ? `${ago} min ago` : ago < 48 * 60 ? `${Math.round(ago / 60)} h ago` : `${Math.round(ago / 1440)} days ago`;
|
|
11311
|
+
return `${when} ${theme.status(lastRun.state.replaceAll("_", " "), { healthy: "success", "needs sign in": "warning", offline: "warning", conflict: "warning" })}`;
|
|
11312
|
+
}
|
|
11313
|
+
function pairingCopy(input2, theme) {
|
|
11314
|
+
const expires = new Date(input2.expiresAt);
|
|
11315
|
+
const minutes = Math.max(0, Math.round((expires.getTime() - Date.now()) / 6e4));
|
|
11316
|
+
return [
|
|
11317
|
+
"Add this custom connector in Claude, then approve it with the pairing code.",
|
|
11318
|
+
"",
|
|
11319
|
+
theme.subject("Connector URL"),
|
|
11320
|
+
` ${theme.key(input2.endpoint)}`,
|
|
11321
|
+
"",
|
|
11322
|
+
theme.subject("Pairing code"),
|
|
11323
|
+
` ${theme.key(formatPairingCode(input2.code))}`,
|
|
11324
|
+
"",
|
|
11325
|
+
theme.dim(`One approval, valid for ${minutes} minutes (until ${expires.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}).`)
|
|
11326
|
+
].join("\n");
|
|
10504
11327
|
}
|
|
10505
11328
|
async function selectConnectors(connectors, requested) {
|
|
10506
11329
|
const normalized = normalizeClientName(requested);
|
|
@@ -10584,7 +11407,7 @@ function buildProgram(io = {}) {
|
|
|
10584
11407
|
program.option("--pretty", "Indent JSON output.");
|
|
10585
11408
|
program.option("--limit <number>", "Maximum returned rows.", parsePositiveInt);
|
|
10586
11409
|
program.option("--days <number>", "Deadline window in days.", parsePositiveInt);
|
|
10587
|
-
const verbose = program.options.find((
|
|
11410
|
+
const verbose = program.options.find((option2) => option2.long === "--verbose");
|
|
10588
11411
|
if (verbose) {
|
|
10589
11412
|
verbose.short = "-v";
|
|
10590
11413
|
verbose.flags = "-v, --verbose";
|
|
@@ -10674,7 +11497,9 @@ ${tryLines(["moodle due", "moodle units", "moodle --help"])}`}
|
|
|
10674
11497
|
stdin: io.stdin,
|
|
10675
11498
|
stdout,
|
|
10676
11499
|
stderr,
|
|
10677
|
-
fetchImpl: io.fetchImpl
|
|
11500
|
+
fetchImpl: io.fetchImpl,
|
|
11501
|
+
// Progress goes to stderr and the summary to stdout, so both streams must want colour.
|
|
11502
|
+
color: () => colorEnabled2(stdout, io.env) && colorEnabled2(stderr, io.env) && program.opts().color !== false
|
|
10678
11503
|
});
|
|
10679
11504
|
return mcpService;
|
|
10680
11505
|
};
|
|
@@ -10689,7 +11514,7 @@ ${tryLines(["moodle due", "moodle units", "moodle --help"])}`}
|
|
|
10689
11514
|
env: io.env ?? process.env,
|
|
10690
11515
|
format: outputFormat(program.opts(), stdout)
|
|
10691
11516
|
}) === "human";
|
|
10692
|
-
const theme = () =>
|
|
11517
|
+
const theme = () => createTheme5(colorEnabled2(stderr, io.env) && program.opts().color !== false);
|
|
10693
11518
|
const signIn = async (baseUrl) => {
|
|
10694
11519
|
const ui = createUi3({ input: io.stdin ?? process.stdin, output: stderr, interactive: true });
|
|
10695
11520
|
const auth2 = { env: io.env, fetch: io.fetchImpl, homeDir: io.homeDir, captureMobileToken: true };
|
|
@@ -10785,6 +11610,46 @@ ${tryLines(["moodle due", "moodle units", "moodle --help"])}`}
|
|
|
10785
11610
|
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));
|
|
10786
11611
|
}
|
|
10787
11612
|
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));
|
|
11613
|
+
addOutputOptions(program.command("attempt").description(humanDescription("attempt")).argument("<ref>", "Quiz attempt id or review URL")).action(async (ref2, options) => execute("attempt", { attempt: ref2 }, options));
|
|
11614
|
+
const dev = program.command("dev", { hidden: true }).description("Maintainer utilities.");
|
|
11615
|
+
dev.command("fetch").description("Print a same-site page as HTML with the session; sesskey values are redacted.").argument("<url>").action(async (url) => {
|
|
11616
|
+
const client = await runtime.getClient();
|
|
11617
|
+
if (new URL(url).origin !== new URL(client.baseUrl).origin) throw new UsageError("The URL must belong to the configured Moodle site.");
|
|
11618
|
+
const html = await (await client.requestAbsolute(url)).text();
|
|
11619
|
+
stdout.write(`${redactSesskey(html)}
|
|
11620
|
+
`);
|
|
11621
|
+
});
|
|
11622
|
+
addOutputOptions(mutating(program.command("update").description("Update the package and redeploy the managed MCP Worker when either is behind."))).option("--check", "Report versions without installing or deploying.").option("--quiet", "Print nothing; refresh the cached version only.").action(async (options) => {
|
|
11623
|
+
const updateOptions = { homeDir: io.homeDir, env: io.env, fetchImpl: io.fetchImpl };
|
|
11624
|
+
if (options.quiet) {
|
|
11625
|
+
await refreshLatestVersion(updateOptions);
|
|
11626
|
+
return;
|
|
11627
|
+
}
|
|
11628
|
+
const worker = await getMcpService().workerState();
|
|
11629
|
+
const palette = theme();
|
|
11630
|
+
const workerLine = worker === null ? [] : [`${palette.dim("Worker:")} ${palette.status(worker.behind ? "behind this package" : worker.ready ? "current" : "not ready", { current: "success", "behind this package": "warning", "not ready": "danger" })}`];
|
|
11631
|
+
if (options.check) {
|
|
11632
|
+
const latest = await refreshLatestVersion(updateOptions);
|
|
11633
|
+
const available = isNewerVersion(latest ?? void 0, VERSION);
|
|
11634
|
+
const report2 = { current: VERSION, latest, update_available: available, ...worker ? { worker_behind: worker.behind, worker_ready: worker.ready } : {} };
|
|
11635
|
+
await runtime.output(report2, () => [
|
|
11636
|
+
`${palette.dim("moodle-cli:")} ${palette.key(VERSION)} ${available ? palette.tone("warning", `\u2192 ${latest} available`) : latest ? palette.dim("(latest)") : palette.tone("warning", "(npm unreachable)")}`,
|
|
11637
|
+
...workerLine,
|
|
11638
|
+
...available || worker?.behind ? ["", tryLines(["moodle update"])] : []
|
|
11639
|
+
].join("\n"), options);
|
|
11640
|
+
return;
|
|
11641
|
+
}
|
|
11642
|
+
if (program.opts().dryRun) {
|
|
11643
|
+
const latest = await refreshLatestVersion(updateOptions);
|
|
11644
|
+
const steps = [...isNewerVersion(latest ?? void 0, VERSION) ? [`install moodle-cli ${latest}`] : [], ...worker?.behind ? ["redeploy the Worker"] : []];
|
|
11645
|
+
await runtime.output({ planned: steps, current: VERSION, latest }, () => steps.length ? `Would ${steps.join(", then ")}.` : "Nothing to do; everything is current.", options);
|
|
11646
|
+
return;
|
|
11647
|
+
}
|
|
11648
|
+
const report = await runUpdate({ ...updateOptions, workerBehind: worker?.behind });
|
|
11649
|
+
const stale = worker && !worker.ready && !report.deployed ? [`${palette.tone("warning", "The Worker is deployed but not answering.")} ${tryLines(["moodle mcp status"])}`] : [];
|
|
11650
|
+
await runtime.output({ ...report, ...worker ? { worker_ready: worker.ready } : {} }, () => [palette.tone(report.ok ? report.updated || report.deployed ? "success" : "muted" : "danger", report.note), ...stale].join("\n"), options);
|
|
11651
|
+
if (!report.ok) throw new CliError2("upstream", report.note);
|
|
11652
|
+
});
|
|
10788
11653
|
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) => {
|
|
10789
11654
|
const client = await runtime.getClient();
|
|
10790
11655
|
const service = createIntentService(createMoodleGateway(client));
|
|
@@ -10819,6 +11684,75 @@ ${tryLines(["moodle due", "moodle units", "moodle --help"])}`}
|
|
|
10819
11684
|
}
|
|
10820
11685
|
await runtime.output(result, () => formatSubmissionReceipt(result.submission), options);
|
|
10821
11686
|
});
|
|
11687
|
+
const quiz = program.command("quiz").description("Take a quiz: start an attempt, answer questions, finish it. Beta.").summary("Take a quiz (beta)");
|
|
11688
|
+
quiz.addHelpText("after", `
|
|
11689
|
+
${QUIZ_NOTICE.join("\n")}
|
|
11690
|
+
`);
|
|
11691
|
+
const quizConsent = async (summary) => {
|
|
11692
|
+
const palette = theme();
|
|
11693
|
+
if (!program.opts().yes && !human()) throw new UsageError("Quiz actions need --yes when stdin is not interactive.", QUIZ_NOTICE.join(" "));
|
|
11694
|
+
const notice = [palette.tone("warning", "BETA"), ...QUIZ_NOTICE.map((line) => palette.dim(line))].join("\n");
|
|
11695
|
+
return confirm({ summary: `${notice}
|
|
11696
|
+
|
|
11697
|
+
${summary}` }, { yes: Boolean(program.opts().yes), dryRun: false, interactive: human() });
|
|
11698
|
+
};
|
|
11699
|
+
const attemptNext = (page) => {
|
|
11700
|
+
const open = page.navigation.find((entry) => entry.number !== "i" && /not yet|not answered/iu.test(entry.state));
|
|
11701
|
+
if (!open) return [`moodle quiz finish ${page.attempt} ${page.quiz_id}`];
|
|
11702
|
+
return [
|
|
11703
|
+
`moodle quiz answer ${page.attempt} ${page.quiz_id} ${open.number} <answer>`,
|
|
11704
|
+
...open.page === page.page ? [] : [`moodle quiz show ${page.attempt} ${page.quiz_id} --page ${open.page + 1}`]
|
|
11705
|
+
];
|
|
11706
|
+
};
|
|
11707
|
+
const showPage = (page, palette) => `${palette.tone("warning", "BETA")} ${formatAttemptPage(page)}
|
|
11708
|
+
|
|
11709
|
+
${tryLines(attemptNext(page))}`;
|
|
11710
|
+
addOutputOptions(mutating(quiz.command("start").description("Start a new attempt, or continue the one in progress, and show its first page.").argument("<ref>", "Quiz id, URL, or UNIT TASK phrase"))).option("--password <password>", "Quiz access password for scripts; at a terminal you are asked for it instead.").action(async (ref2, options) => {
|
|
11711
|
+
const client = await runtime.getClient();
|
|
11712
|
+
const service = createIntentService(createMoodleGateway(client));
|
|
11713
|
+
const id2 = await choose(() => service.resolveItem(ref2), (id3) => Promise.resolve(id3));
|
|
11714
|
+
if (!program.opts().dryRun && !await quizConsent(`Start or continue an attempt on quiz ${theme().target(String(id2))}. Moodle records the attempt and its start time.`)) return;
|
|
11715
|
+
if (program.opts().dryRun) return runtime.output({ planned: "start", quiz_id: id2 }, () => `Would start an attempt on quiz ${id2}.`, options);
|
|
11716
|
+
const password = async () => {
|
|
11717
|
+
if (options.password) return options.password;
|
|
11718
|
+
const input2 = io.stdin ?? process.stdin;
|
|
11719
|
+
if (!human() || !input2.isTTY) return null;
|
|
11720
|
+
return readSecretLine(input2, stderr, "Quiz password (not echoed): ");
|
|
11721
|
+
};
|
|
11722
|
+
const page = await client.startQuizAttempt(id2, { password });
|
|
11723
|
+
await runtime.output({ attempt: page }, () => showPage(page, theme()), options);
|
|
11724
|
+
});
|
|
11725
|
+
addOutputOptions(quiz.command("show").description("Show one page of an attempt in progress: questions, options and saved answers.").argument("<attempt>", "Attempt id").argument("<quiz>", "Quiz id")).option("--page <n>", "Page number, starting at 1.", parsePositiveInt).action(async (attempt, quizId, options) => {
|
|
11726
|
+
const client = await runtime.getClient();
|
|
11727
|
+
const page = await client.getQuizAttemptPage(parsePositiveInt(attempt), parsePositiveInt(quizId), (options.page ?? 1) - 1);
|
|
11728
|
+
await runtime.output({ attempt: page }, () => showPage(page, theme()), options);
|
|
11729
|
+
});
|
|
11730
|
+
addOutputOptions(mutating(quiz.command("answer").description("Save one answer: option letters for a choice question (b, or a,c), the text otherwise.").argument("<attempt>", "Attempt id").argument("<quiz>", "Quiz id").argument("<question>", "Question number as shown").argument("[answer]", "Option letters or answer text"))).option("--from <file>", "Read the answer text from a file.").action(async (attempt, quizId, question2, answer, options) => {
|
|
11731
|
+
const value = options.from ? await readFile11(path4.resolve(io.cwd ?? process.cwd(), options.from), "utf8") : answer;
|
|
11732
|
+
if (!value?.trim()) throw new UsageError("Give the answer as an argument or with --from <file>.");
|
|
11733
|
+
const request = { attemptId: parsePositiveInt(attempt), quizId: parsePositiveInt(quizId), question: question2, value };
|
|
11734
|
+
const palette = theme();
|
|
11735
|
+
const preview2 = value.trim().length > 80 ? `${value.trim().slice(0, 77)}...` : value.trim();
|
|
11736
|
+
if (program.opts().dryRun) return runtime.output({ planned: "answer", ...request }, () => `Would answer question ${question2} of attempt ${attempt} with: ${preview2}`, options);
|
|
11737
|
+
if (!await quizConsent(`Save ${palette.subject(preview2)} as the answer to question ${palette.target(question2)} of attempt ${attempt}.`)) return;
|
|
11738
|
+
const client = await runtime.getClient();
|
|
11739
|
+
const page = await client.answerQuizQuestion(request);
|
|
11740
|
+
await runtime.output({ attempt: page }, () => showPage(page, palette), options);
|
|
11741
|
+
});
|
|
11742
|
+
addOutputOptions(mutating(quiz.command("finish").description("Submit the attempt for grading. Moodle does not allow undoing this.").argument("<attempt>", "Attempt id").argument("<quiz>", "Quiz id"))).action(async (attempt, quizId, options) => {
|
|
11743
|
+
const client = await runtime.getClient();
|
|
11744
|
+
const ids = [parsePositiveInt(attempt), parsePositiveInt(quizId)];
|
|
11745
|
+
const summary = await client.getQuizAttemptSummary(...ids);
|
|
11746
|
+
if (program.opts().dryRun) return runtime.output({ planned: "finish", summary }, () => formatAttemptSummary(summary), options);
|
|
11747
|
+
const palette = theme();
|
|
11748
|
+
const open = summary.rows.filter((row) => /not yet answered/iu.test(row.state));
|
|
11749
|
+
const warning = open.length ? `
|
|
11750
|
+
${palette.tone("danger", `${open.length} question${open.length === 1 ? "" : "s"} not yet answered: ${open.map((row) => row.number).join(", ")}`)}` : "";
|
|
11751
|
+
if (!await quizConsent(`${formatAttemptSummary(summary)}${warning}
|
|
11752
|
+
${palette.tone("warning", "Submit all and finish. Moodle does not allow undoing this.")}`)) return;
|
|
11753
|
+
const receipt = await client.finishQuizAttempt(...ids);
|
|
11754
|
+
await runtime.output({ finished: receipt }, () => formatAttemptFinish(receipt), options);
|
|
11755
|
+
});
|
|
10822
11756
|
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) => {
|
|
10823
11757
|
const client = await runtime.getClient();
|
|
10824
11758
|
let url;
|
|
@@ -10945,7 +11879,7 @@ _arguments '1:command:(${names.join(" ")})' '*:reference:'
|
|
|
10945
11879
|
else throw new UsageError("Choose zsh, bash or fish.");
|
|
10946
11880
|
});
|
|
10947
11881
|
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 ??
|
|
11882
|
+
const home = io.homeDir ?? homedir16();
|
|
10949
11883
|
const jobs = await ownedJobs(home);
|
|
10950
11884
|
const receipts = await readdir4(path4.join(home, ".config", "moodle-cli", "mcp", "deployments")).catch(() => []);
|
|
10951
11885
|
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." };
|
|
@@ -10959,11 +11893,11 @@ _arguments '1:command:(${names.join(" ")})' '*:reference:'
|
|
|
10959
11893
|
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)));
|
|
10960
11894
|
for (const profile of profiles) {
|
|
10961
11895
|
await renewal2.remove(profile);
|
|
10962
|
-
if (options.purge) await
|
|
11896
|
+
if (options.purge) await rm8(path4.join(home, "Library", "Logs", `com.moodle-cli.mcp-renewal.${profile}.log`), { force: true });
|
|
10963
11897
|
}
|
|
10964
11898
|
if (options.purge) {
|
|
10965
|
-
await
|
|
10966
|
-
await
|
|
11899
|
+
await rm8(result.config, { recursive: true, force: true });
|
|
11900
|
+
await rm8(result.cache, { recursive: true, force: true });
|
|
10967
11901
|
}
|
|
10968
11902
|
await runtime.output(result, () => `Moodle background jobs removed.
|
|
10969
11903
|
${result.remaining}
|
|
@@ -11157,7 +12091,7 @@ Log: ${result.log_path}`, options);
|
|
|
11157
12091
|
return program;
|
|
11158
12092
|
}
|
|
11159
12093
|
var HELP_SECTIONS = {
|
|
11160
|
-
"Core commands": ["due", "news", "find", "get", "open", "submit", "units", "activities", "grades", "threads", "forums"],
|
|
12094
|
+
"Core commands": ["due", "news", "find", "get", "open", "submit", "quiz", "units", "activities", "grades", "threads", "forums"],
|
|
11161
12095
|
"Additional commands": ["user", "todo", "alerts", "overview", "download", "auth", "doctor", "completion", "uninstall"],
|
|
11162
12096
|
"Agent commands": ["mcp", "commands", "skills"]
|
|
11163
12097
|
};
|
|
@@ -11176,6 +12110,7 @@ async function runCli(argv = process.argv, io = {}) {
|
|
|
11176
12110
|
}) === "human"
|
|
11177
12111
|
});
|
|
11178
12112
|
try {
|
|
12113
|
+
await startupUpdateNotice(args, stderr, { homeDir: io.homeDir, env: io.env });
|
|
11179
12114
|
await parseWithPrompts(() => buildProgram({ ...io, rootArgs: args }), args, { ui, fillers: { unit: pickUnit(io, ui) } });
|
|
11180
12115
|
return 0;
|
|
11181
12116
|
} catch (error) {
|
|
@@ -11204,7 +12139,7 @@ ${hint}
|
|
|
11204
12139
|
}
|
|
11205
12140
|
function openInBrowser(url) {
|
|
11206
12141
|
return new Promise((resolve, reject) => {
|
|
11207
|
-
const child =
|
|
12142
|
+
const child = spawn5(process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer.exe" : "xdg-open", [url], { stdio: "ignore" });
|
|
11208
12143
|
child.once("error", reject);
|
|
11209
12144
|
child.once("exit", (code) => code === 0 ? resolve() : reject(new Error("Could not open the browser.")));
|
|
11210
12145
|
});
|
|
@@ -11288,6 +12223,14 @@ function addOutputOptions(command) {
|
|
|
11288
12223
|
function outputFormat(options, stdout) {
|
|
11289
12224
|
return resolveFormat(options, Boolean(stdout && "isTTY" in stdout && stdout.isTTY));
|
|
11290
12225
|
}
|
|
12226
|
+
var QUIZ_NOTICE = [
|
|
12227
|
+
"moodle quiz is beta: it replays the browser's quiz forms, and a Moodle update can break it without warning.",
|
|
12228
|
+
"Academic integrity: answers you send are your own submission under your institution's rules. Only use this",
|
|
12229
|
+
"where the quiz allows it, and check the attempt in a browser before you finish."
|
|
12230
|
+
];
|
|
12231
|
+
function redactSesskey(html) {
|
|
12232
|
+
return html.replace(/<input\b[^>]*>/giu, (tag) => /\bname\s*=\s*["']?sesskey["']?/iu.test(tag) ? tag.replace(/(\bvalue\s*=\s*)(?:"[^"]*"|'[^']*'|[^\s>]+)/iu, '$1"REDACTED"') : tag).replace(/(["']?sesskey["']?\s*[:=]\s*["']?)[A-Za-z0-9]{8,}/gu, "$1REDACTED");
|
|
12233
|
+
}
|
|
11291
12234
|
function submissionSummary(plan, final, theme) {
|
|
11292
12235
|
const destination = `${theme.target(plan.name)}${plan.unit_id ? theme.dim(` unit ${plan.unit_id}`) : ""}`;
|
|
11293
12236
|
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`];
|
|
@@ -11308,7 +12251,9 @@ function parseMcpConnectionMode(value) {
|
|
|
11308
12251
|
throw new UsageError("MCP connection mode must be 'bridge' or 'remote'.");
|
|
11309
12252
|
}
|
|
11310
12253
|
async function outputMcpResult(runtime, result, options) {
|
|
11311
|
-
await runtime.output(result.data, () => result.text
|
|
12254
|
+
await runtime.output(result.data, () => result.next?.length ? `${result.text}
|
|
12255
|
+
|
|
12256
|
+
${tryLines(result.next)}` : result.text, options);
|
|
11312
12257
|
}
|
|
11313
12258
|
function errorOutputFormat(args, stdout) {
|
|
11314
12259
|
try {
|
|
@@ -11378,7 +12323,7 @@ function queryMatches(text2, query) {
|
|
|
11378
12323
|
function pathsReferToSameFile(moduleUrl, executable) {
|
|
11379
12324
|
if (!executable) return false;
|
|
11380
12325
|
try {
|
|
11381
|
-
return
|
|
12326
|
+
return realpathSync4(fileURLToPath2(moduleUrl)) === realpathSync4(executable);
|
|
11382
12327
|
} catch {
|
|
11383
12328
|
return false;
|
|
11384
12329
|
}
|
|
@@ -11391,5 +12336,6 @@ if (isMain) {
|
|
|
11391
12336
|
}
|
|
11392
12337
|
export {
|
|
11393
12338
|
buildProgram,
|
|
12339
|
+
redactSesskey,
|
|
11394
12340
|
runCli
|
|
11395
12341
|
};
|