moodle-cli 0.8.0 → 0.9.0
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 +24 -3
- package/SKILL.md +4 -3
- package/agents/openai.yaml +1 -1
- package/dist/moodle.js +1224 -635
- package/dist/worker/recovery.js +777 -384
- package/dist/worker/worker.js +777 -384
- package/package.json +2 -2
- package/references/command-reference.md +6 -1
package/dist/moodle.js
CHANGED
|
@@ -20,6 +20,8 @@ var counts = z.object(Object.fromEntries(["notification_count", "unread_notifica
|
|
|
20
20
|
var postSchema = z.object({ id, parent_id: n, name: s, author: z.object({ id, name: s }), time_created: id, created: s, message_text: s, links: z.array(z.object({ text: s, url: s })).optional() });
|
|
21
21
|
var gradeSchema = z.object({ unit_id: id, code: s, graded: id, total: id, total_grade: s, total_range: s, total_percentage: s, items: z.array(z.object({ name: s, type: s, grade: s, range: s, percentage: s, weight: s, contribution: s, feedback: s, status: s, due: s, due_at: n })).optional() });
|
|
22
22
|
var searchSchema = z.object({ unit_id: id, forum_id: id, discussion_id: id, name: s, post_id: id, snippet: s, time_created: n, created: s, url: s });
|
|
23
|
+
var submissionFile = z.object({ name: z.string(), bytes: n, url: s });
|
|
24
|
+
var receiptSchema = z.object({ id, name: s, unit_id: n, url: s, action: z.enum(["planned", "saved", "submitted"]), submission_status: s, grading_status: s, due: s, time_remaining: s, last_modified: s, statement: s, statement_accepted: z.boolean().optional(), files: z.array(submissionFile).optional(), uploads: z.array(z.object({ name: z.string(), bytes: id, path: s })).optional(), removed: z.array(z.string()).optional(), limits: z.object({ max_bytes: n, max_files: n, area_max_bytes: n, accepted_types: z.array(z.string()).optional() }).optional(), checked_at: z.string() });
|
|
23
25
|
var input = (shape) => z.object(shape).strict();
|
|
24
26
|
var list = (key, value) => z.object({ [key]: z.array(value).optional(), total: id });
|
|
25
27
|
var intentContracts = {
|
|
@@ -33,6 +35,7 @@ var intentContracts = {
|
|
|
33
35
|
news: { when: "announcements", command: "moodle news [UNIT]", what: "Latest announcement threads with first-post text.", instead: "search_forums for other discussions", refs: "optional unit code, name, id or URL", then: "thread with discussion id", cost: "up to 5 announcements by default", input: input({ unit: ref.optional(), limit: limit.default(5) }), output: list("news", z.object({ id, name: s, unit_id: id, unit_code: s, forum_id: id, post: postSchema.optional() })) },
|
|
34
36
|
thread: { when: "discussion posts", command: "moodle threads show ID", what: "A discussion and a page of compact posts, with attachment links.", instead: "news for announcements", refs: "discussion_id; offset and limit", then: "increase offset while posts_total exceeds returned", cost: "up to 20 posts by default", input: input({ discussion_id: z.number().int().positive(), limit, offset: z.number().int().nonnegative().default(0) }), output: z.object({ thread: z.object({ id, name: s, unit_id: id, forum_id: id, url: s, posts: z.array(postSchema).optional(), posts_total: id, offset: id }) }) },
|
|
35
37
|
search_forums: { when: "forum post text", command: 'moodle forums search "QUERY" --unit UNIT', what: "Matching posts with unit and forum name maps.", instead: "find for activity names", refs: "query, optional unit or courseId and forumId", then: "thread with discussion_id", cost: "bounded forum scan; total covers scanned scope", input: input({ query: z.string().trim().min(1), unit: ref.optional(), courseId: z.number().int().positive().optional(), forumId: z.number().int().positive().optional(), limit, includePostText: z.boolean().default(true), titlesOnly: z.boolean().default(false), unreadOnly: z.boolean().default(false), sortBy: z.enum(["relevance", "recent"]).default("relevance"), maxForums: limit.default(20), maxDiscussionsPerForum: limit.default(50) }), output: z.object({ results: z.array(searchSchema).optional(), total: id, forums: z.record(z.string(), z.string()).optional(), units: z.record(z.string(), z.string()).optional(), scope: z.object({ max_forums: id, max_discussions_per_forum: id }) }) },
|
|
38
|
+
submit: { when: "upload assignment files", command: 'moodle submit "UNIT TASK" FILE... [--final]', what: "Upload local files into an assignment; returns the receipt Moodle shows afterwards.", instead: "item for status only", refs: "assignment id, same-site URL or UNIT TASK phrase; local file paths", then: "dry_run (default) only plans; show the plan to the person, then rerun with dry_run false; final submits for grading and cannot be undone", cost: "writes to Moodle", input: input({ ref, files: z.array(z.string().trim().min(1)).max(20).default([]), final: z.boolean().default(false), replace: z.boolean().default(false), accept_statement: z.boolean().default(false), dry_run: z.boolean().default(true) }), output: z.object({ submission: receiptSchema }) },
|
|
36
39
|
file: { when: "download a file", command: 'moodle get "UNIT TASK" --to DIR', what: "One authenticated file as embedded content, at most 16 MiB.", instead: "item for file choices", refs: "resource id, same-site URL, or UNIT TASK phrase", then: "read the returned resource", cost: "binary content up to 16 MiB", input: input({ ref }), output: z.object({ file: z.object({ name: z.string(), mime_type: z.string(), bytes: id, uri: z.string() }) }) }
|
|
37
40
|
};
|
|
38
41
|
function humanDescription(name) {
|
|
@@ -723,9 +726,9 @@ var PrivateFileCredentialBackend = class {
|
|
|
723
726
|
baseDirectory;
|
|
724
727
|
name = "private credential file";
|
|
725
728
|
async read(profile) {
|
|
726
|
-
const
|
|
729
|
+
const path5 = this.path(profile);
|
|
727
730
|
try {
|
|
728
|
-
return parseCredentials(await readFile(
|
|
731
|
+
return parseCredentials(await readFile(path5, "utf8"));
|
|
729
732
|
} catch (error) {
|
|
730
733
|
if (isMissing(error)) {
|
|
731
734
|
return null;
|
|
@@ -734,15 +737,15 @@ var PrivateFileCredentialBackend = class {
|
|
|
734
737
|
}
|
|
735
738
|
}
|
|
736
739
|
async write(profile, credentials) {
|
|
737
|
-
const
|
|
738
|
-
const temporary = `${
|
|
739
|
-
await mkdir(dirname(
|
|
740
|
-
await chmod(dirname(
|
|
740
|
+
const path5 = this.path(profile);
|
|
741
|
+
const temporary = `${path5}.${process.pid}.tmp`;
|
|
742
|
+
await mkdir(dirname(path5), { recursive: true, mode: 448 });
|
|
743
|
+
await chmod(dirname(path5), 448);
|
|
741
744
|
await writeFile(temporary, `${JSON.stringify(credentials)}
|
|
742
745
|
`, { encoding: "utf8", mode: 384 });
|
|
743
746
|
await chmod(temporary, 384);
|
|
744
|
-
await rename(temporary,
|
|
745
|
-
await chmod(
|
|
747
|
+
await rename(temporary, path5);
|
|
748
|
+
await chmod(path5, 384);
|
|
746
749
|
}
|
|
747
750
|
async delete(profile) {
|
|
748
751
|
await rm(this.path(profile), { force: true });
|
|
@@ -798,9 +801,9 @@ function windowsCredentialInput(profile, credentials) {
|
|
|
798
801
|
...credentials ? { credentials: JSON.stringify(credentials) } : {}
|
|
799
802
|
});
|
|
800
803
|
}
|
|
801
|
-
function windowsDpapiInput(
|
|
804
|
+
function windowsDpapiInput(path5, credentials) {
|
|
802
805
|
return JSON.stringify({
|
|
803
|
-
path:
|
|
806
|
+
path: path5,
|
|
804
807
|
...credentials ? { credentials: JSON.stringify(credentials) } : {}
|
|
805
808
|
});
|
|
806
809
|
}
|
|
@@ -910,10 +913,10 @@ async function readCachedSession(baseUrl, options = {}) {
|
|
|
910
913
|
return null;
|
|
911
914
|
}
|
|
912
915
|
const fs = options.fs ?? nodeFs;
|
|
913
|
-
const
|
|
916
|
+
const path5 = sessionCachePath(options.homeDir);
|
|
914
917
|
let raw;
|
|
915
918
|
try {
|
|
916
|
-
raw = await fs.readFile(
|
|
919
|
+
raw = await fs.readFile(path5, "utf8");
|
|
917
920
|
} catch (error) {
|
|
918
921
|
if (isMissingFileError(error)) {
|
|
919
922
|
return null;
|
|
@@ -931,7 +934,7 @@ async function readCachedSession(baseUrl, options = {}) {
|
|
|
931
934
|
if (session) await writeCachedSession(session, { ...options, noCache: false });
|
|
932
935
|
}
|
|
933
936
|
} catch {
|
|
934
|
-
if (parseCachedSession(raw)) await fs.rm(
|
|
937
|
+
if (parseCachedSession(raw)) await fs.rm(path5, { force: true });
|
|
935
938
|
return null;
|
|
936
939
|
}
|
|
937
940
|
if (!session || !sameBaseUrl(session.baseUrl, baseUrl)) {
|
|
@@ -943,13 +946,13 @@ async function readCachedSession(baseUrl, options = {}) {
|
|
|
943
946
|
async function writeCachedSession(session, options = {}) {
|
|
944
947
|
if (options.noCache) return;
|
|
945
948
|
const fs = options.fs ?? nodeFs;
|
|
946
|
-
const
|
|
949
|
+
const path5 = sessionCachePath(options.homeDir);
|
|
947
950
|
const keyring = await createEncryptionKeyring(await cacheEncryptionKey(options));
|
|
948
951
|
const encrypted = { version: 2, encrypted_session: await encryptValue(JSON.stringify(session), keyring) };
|
|
949
|
-
await fs.mkdir(dirname2(
|
|
950
|
-
await fs.writeFile(
|
|
952
|
+
await fs.mkdir(dirname2(path5), { recursive: true, mode: 448 });
|
|
953
|
+
await fs.writeFile(path5, `${JSON.stringify(encrypted)}
|
|
951
954
|
`, { encoding: "utf8", mode: 384 });
|
|
952
|
-
await fs.chmod(
|
|
955
|
+
await fs.chmod(path5, 384);
|
|
953
956
|
}
|
|
954
957
|
async function deleteCachedSession(baseUrl, options = {}) {
|
|
955
958
|
const current2 = await readCachedSession(baseUrl, { ...options, noCache: false, ttlMs: Number.MAX_SAFE_INTEGER });
|
|
@@ -1347,8 +1350,8 @@ function isLoginRedirect(responseUrl, baseUrl) {
|
|
|
1347
1350
|
if (!responseUrl) {
|
|
1348
1351
|
return false;
|
|
1349
1352
|
}
|
|
1350
|
-
const
|
|
1351
|
-
return
|
|
1353
|
+
const path5 = new URL(responseUrl, baseUrl).pathname;
|
|
1354
|
+
return path5 === LOGIN_PATH || path5.startsWith("/login/");
|
|
1352
1355
|
}
|
|
1353
1356
|
function looksLikeLoginPage(html) {
|
|
1354
1357
|
return /name=["']username["']/i.test(html) && /name=["']password["']/i.test(html);
|
|
@@ -1391,7 +1394,24 @@ var defaultExecFile = (file2, args) => new Promise((resolve) => {
|
|
|
1391
1394
|
import { mkdir as mkdir3, readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
|
|
1392
1395
|
import { homedir as homedir4 } from "os";
|
|
1393
1396
|
import { dirname as dirname3, join as join4 } from "path";
|
|
1394
|
-
import {
|
|
1397
|
+
import { createUi, isAgentEnvironment } from "@bunizao/cli-kit";
|
|
1398
|
+
|
|
1399
|
+
// src/wordmark.ts
|
|
1400
|
+
var MOODLE_TAGLINE = "Read Moodle and submit work from the command line.";
|
|
1401
|
+
var MOODLE_WORDMARK = [
|
|
1402
|
+
" _ _",
|
|
1403
|
+
" _ __ ___ ___ __| | |___",
|
|
1404
|
+
" | ' \\/ _ \\/ _ \\/ _` | / -_)",
|
|
1405
|
+
" |_|_|_\\___/\\___/\\__,_|_\\___|"
|
|
1406
|
+
].join("\n");
|
|
1407
|
+
var shown = false;
|
|
1408
|
+
function showWordmark(ui) {
|
|
1409
|
+
if (shown) return;
|
|
1410
|
+
shown = true;
|
|
1411
|
+
ui.banner(MOODLE_WORDMARK, MOODLE_TAGLINE);
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
// src/config.ts
|
|
1395
1415
|
import YAML from "yaml";
|
|
1396
1416
|
var nodeFs2 = { readFile: readFile3, writeFile: writeFile3, mkdir: mkdir3 };
|
|
1397
1417
|
function cwdConfigPath(cwd = process.cwd()) {
|
|
@@ -1453,29 +1473,32 @@ async function loadConfig(options = {}) {
|
|
|
1453
1473
|
return toMoodleConfig(loaded.config, baseUrl);
|
|
1454
1474
|
}
|
|
1455
1475
|
async function promptForBaseUrl(options = {}) {
|
|
1456
|
-
const
|
|
1457
|
-
const
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1476
|
+
const ui = createUi({ input: process.stdin, output: options.stderr ?? process.stderr, ...options.prompt ? { interactive: false } : {} });
|
|
1477
|
+
const prompt = options.prompt ?? defaultPrompt(ui);
|
|
1478
|
+
showWordmark(ui);
|
|
1479
|
+
ui.intro("Moodle setup");
|
|
1480
|
+
ui.note([
|
|
1481
|
+
"Moodle base URL is not configured yet.",
|
|
1482
|
+
`Runtime: ${process.versions.bun ? `bun ${process.versions.bun}` : `node ${process.versions.node}`}. Browser SQLite needs Bun or Node 22.13+. Run moodle doctor for diagnostics.`,
|
|
1483
|
+
"Use the site root only, for example https://school.example.edu.",
|
|
1484
|
+
"Do not include paths like /login/index.php or /my/."
|
|
1485
|
+
].join("\n"), "Configuration required");
|
|
1464
1486
|
while (true) {
|
|
1465
1487
|
let baseUrl;
|
|
1466
1488
|
try {
|
|
1467
1489
|
baseUrl = normalizeBaseUrl(await prompt("Moodle base URL"));
|
|
1468
1490
|
} catch (error) {
|
|
1469
|
-
|
|
1470
|
-
`);
|
|
1491
|
+
ui.warn(`Invalid URL: ${error instanceof Error ? error.message : String(error)}`);
|
|
1471
1492
|
continue;
|
|
1472
1493
|
}
|
|
1494
|
+
const spin = ui.spinner();
|
|
1495
|
+
spin.start(`Checking ${baseUrl}`);
|
|
1473
1496
|
const probe = await probeBaseUrl(baseUrl, options);
|
|
1474
1497
|
if (probe.ok) {
|
|
1498
|
+
spin.stop(`${baseUrl} looks like Moodle`);
|
|
1475
1499
|
return baseUrl;
|
|
1476
1500
|
}
|
|
1477
|
-
|
|
1478
|
-
`);
|
|
1501
|
+
spin.error(`Validation failed: ${probe.message ?? "site did not look like Moodle"}`);
|
|
1479
1502
|
}
|
|
1480
1503
|
}
|
|
1481
1504
|
async function probeBaseUrl(baseUrl, options = {}) {
|
|
@@ -1523,19 +1546,19 @@ async function loadExistingConfig(options) {
|
|
|
1523
1546
|
const paths = [explicitPath, cwdConfigPath(options.cwd), userConfigPath(options.homeDir)].filter(
|
|
1524
1547
|
(value) => Boolean(value)
|
|
1525
1548
|
);
|
|
1526
|
-
for (const
|
|
1527
|
-
const config = await readConfigFile(
|
|
1549
|
+
for (const path5 of paths) {
|
|
1550
|
+
const config = await readConfigFile(path5, options);
|
|
1528
1551
|
if (config) {
|
|
1529
|
-
return { config, path:
|
|
1552
|
+
return { config, path: path5 };
|
|
1530
1553
|
}
|
|
1531
1554
|
}
|
|
1532
1555
|
return { config: {}, path: explicitPath ?? null };
|
|
1533
1556
|
}
|
|
1534
|
-
async function readConfigFile(
|
|
1557
|
+
async function readConfigFile(path5, options) {
|
|
1535
1558
|
const fs = options.fs ?? nodeFs2;
|
|
1536
1559
|
let raw;
|
|
1537
1560
|
try {
|
|
1538
|
-
raw = await fs.readFile(
|
|
1561
|
+
raw = await fs.readFile(path5, "utf8");
|
|
1539
1562
|
} catch (error) {
|
|
1540
1563
|
if (isMissingFileError2(error)) {
|
|
1541
1564
|
return null;
|
|
@@ -1544,34 +1567,24 @@ async function readConfigFile(path4, options) {
|
|
|
1544
1567
|
}
|
|
1545
1568
|
const parsed = YAML.parse(raw) ?? {};
|
|
1546
1569
|
if (!isRecord3(parsed)) {
|
|
1547
|
-
throw new ConfigError(`${
|
|
1570
|
+
throw new ConfigError(`${path5} must contain a YAML object.`);
|
|
1548
1571
|
}
|
|
1549
1572
|
return parsed;
|
|
1550
1573
|
}
|
|
1551
|
-
async function saveConfigFile(
|
|
1574
|
+
async function saveConfigFile(path5, config, options) {
|
|
1552
1575
|
const fs = options.fs ?? nodeFs2;
|
|
1553
|
-
await fs.mkdir(dirname3(
|
|
1554
|
-
await fs.writeFile(
|
|
1576
|
+
await fs.mkdir(dirname3(path5), { recursive: true });
|
|
1577
|
+
await fs.writeFile(path5, YAML.stringify(config, { sortMapEntries: true }), "utf8");
|
|
1555
1578
|
}
|
|
1556
1579
|
function toMoodleConfig(config, baseUrl) {
|
|
1557
1580
|
const { base_url: _baseUrl, ...rest } = config;
|
|
1558
1581
|
return { ...rest, baseUrl };
|
|
1559
1582
|
}
|
|
1560
|
-
function defaultPrompt(
|
|
1561
|
-
return
|
|
1562
|
-
const rl = createInterface({
|
|
1563
|
-
input: process.stdin,
|
|
1564
|
-
output: options.stdout ?? process.stdout
|
|
1565
|
-
});
|
|
1566
|
-
try {
|
|
1567
|
-
return await rl.question(`${label} > `);
|
|
1568
|
-
} finally {
|
|
1569
|
-
rl.close();
|
|
1570
|
-
}
|
|
1571
|
-
};
|
|
1583
|
+
function defaultPrompt(ui) {
|
|
1584
|
+
return (label) => ui.text(label, { placeholder: "https://school.example.edu" });
|
|
1572
1585
|
}
|
|
1573
1586
|
function isInteractive(options) {
|
|
1574
|
-
return Boolean((options.stdin ?? process.stdin).isTTY);
|
|
1587
|
+
return Boolean((options.stdin ?? process.stdin).isTTY) && !isAgentEnvironment(options.env ?? process.env);
|
|
1575
1588
|
}
|
|
1576
1589
|
function isRecord3(value) {
|
|
1577
1590
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
@@ -1834,9 +1847,9 @@ async function ownedJobs(homeDir = homedir6()) {
|
|
|
1834
1847
|
const files = await readdir2(root).catch(() => []);
|
|
1835
1848
|
const jobs = [];
|
|
1836
1849
|
for (const name of files.filter((n2) => n2 === "com.moodle-cli.keepalive.plist" || /^com\.moodle-cli\.mcp-renewal\.[a-z0-9_-]+\.plist$/u.test(n2))) {
|
|
1837
|
-
const
|
|
1838
|
-
const content = await readFile4(
|
|
1839
|
-
jobs.push({ path:
|
|
1850
|
+
const path5 = join7(root, name);
|
|
1851
|
+
const content = await readFile4(path5, "utf8");
|
|
1852
|
+
jobs.push({ path: path5, profile: name.match(/mcp-renewal\.(.+)\.plist$/u)?.[1], interpreter: content.match(/<key>ProgramArguments<\/key>\s*<array>\s*<string>([^<]+)<\/string>/u)?.[1] });
|
|
1840
1853
|
}
|
|
1841
1854
|
return jobs;
|
|
1842
1855
|
}
|
|
@@ -1910,7 +1923,7 @@ async function doctor(options = {}) {
|
|
|
1910
1923
|
|
|
1911
1924
|
// src/cli.ts
|
|
1912
1925
|
import { rm as rm7, readdir as readdir3 } from "fs/promises";
|
|
1913
|
-
import { homedir as
|
|
1926
|
+
import { homedir as homedir13 } from "os";
|
|
1914
1927
|
|
|
1915
1928
|
// src/mcp/renewal/decision.ts
|
|
1916
1929
|
function decideRenewal(snapshot) {
|
|
@@ -2047,7 +2060,7 @@ function macOSPlan(options, intervalMinutes) {
|
|
|
2047
2060
|
throw new Error("macOS renewal installation requires the current user ID");
|
|
2048
2061
|
}
|
|
2049
2062
|
const label = `com.moodle-cli.mcp-renewal.${options.profile}`;
|
|
2050
|
-
const
|
|
2063
|
+
const path5 = `${trimEnd(options.homeDirectory, "/")}/Library/LaunchAgents/${label}.plist`;
|
|
2051
2064
|
const target = `gui/${options.uid}`;
|
|
2052
2065
|
const logPath = `${trimEnd(options.homeDirectory, "/")}/Library/Logs/${label}.log`;
|
|
2053
2066
|
const plist = [
|
|
@@ -2069,12 +2082,12 @@ function macOSPlan(options, intervalMinutes) {
|
|
|
2069
2082
|
platform: "darwin",
|
|
2070
2083
|
profile: options.profile,
|
|
2071
2084
|
label,
|
|
2072
|
-
files: [{ path:
|
|
2085
|
+
files: [{ path: path5, content: plist, mode: 384 }],
|
|
2073
2086
|
installCommands: [
|
|
2074
|
-
{ command: "launchctl", args: ["bootout", target,
|
|
2075
|
-
{ command: "launchctl", args: ["bootstrap", target,
|
|
2087
|
+
{ command: "launchctl", args: ["bootout", target, path5], ignoreFailure: true },
|
|
2088
|
+
{ command: "launchctl", args: ["bootstrap", target, path5] }
|
|
2076
2089
|
],
|
|
2077
|
-
removeCommands: [{ command: "launchctl", args: ["bootout", target,
|
|
2090
|
+
removeCommands: [{ command: "launchctl", args: ["bootout", target, path5], ignoreFailure: true }]
|
|
2078
2091
|
};
|
|
2079
2092
|
}
|
|
2080
2093
|
function linuxPlan(options, intervalMinutes) {
|
|
@@ -2125,7 +2138,7 @@ function linuxPlan(options, intervalMinutes) {
|
|
|
2125
2138
|
}
|
|
2126
2139
|
function windowsPlan(options, intervalMinutes) {
|
|
2127
2140
|
const label = `Moodle CLI MCP Renewal (${options.profile})`;
|
|
2128
|
-
const
|
|
2141
|
+
const path5 = `${trimEnd(options.homeDirectory, "\\/")}\\AppData\\Local\\moodle-cli\\renewal\\${options.profile}.xml`;
|
|
2129
2142
|
const argumentsText = [...options.executableArgs ?? [], ...renewalArgs(options.profile)].map(windowsArgument).join(" ");
|
|
2130
2143
|
const task = [
|
|
2131
2144
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
@@ -2145,8 +2158,8 @@ function windowsPlan(options, intervalMinutes) {
|
|
|
2145
2158
|
platform: "win32",
|
|
2146
2159
|
profile: options.profile,
|
|
2147
2160
|
label,
|
|
2148
|
-
files: [{ path:
|
|
2149
|
-
installCommands: [{ command: "schtasks.exe", args: ["/Create", "/TN", label, "/XML",
|
|
2161
|
+
files: [{ path: path5, content: task, mode: 384 }],
|
|
2162
|
+
installCommands: [{ command: "schtasks.exe", args: ["/Create", "/TN", label, "/XML", path5, "/F"] }],
|
|
2150
2163
|
removeCommands: [{ command: "schtasks.exe", args: ["/Delete", "/TN", label, "/F"], ignoreFailure: true }]
|
|
2151
2164
|
};
|
|
2152
2165
|
}
|
|
@@ -2206,17 +2219,17 @@ import { dirname as dirname5 } from "path";
|
|
|
2206
2219
|
import { promisify } from "util";
|
|
2207
2220
|
var execFile = promisify(execFileCallback2);
|
|
2208
2221
|
var NodeRenewalInstallerIO = class {
|
|
2209
|
-
async writePrivate(
|
|
2210
|
-
await mkdir5(dirname5(
|
|
2211
|
-
await writeFile5(
|
|
2212
|
-
await chmod3(
|
|
2222
|
+
async writePrivate(path5, content, mode) {
|
|
2223
|
+
await mkdir5(dirname5(path5), { recursive: true, mode: 448 });
|
|
2224
|
+
await writeFile5(path5, content, { encoding: "utf8", mode });
|
|
2225
|
+
await chmod3(path5, mode);
|
|
2213
2226
|
}
|
|
2214
|
-
async removeFile(
|
|
2215
|
-
await rm4(
|
|
2227
|
+
async removeFile(path5) {
|
|
2228
|
+
await rm4(path5, { force: true });
|
|
2216
2229
|
}
|
|
2217
|
-
async exists(
|
|
2230
|
+
async exists(path5) {
|
|
2218
2231
|
try {
|
|
2219
|
-
await readFile5(
|
|
2232
|
+
await readFile5(path5);
|
|
2220
2233
|
return true;
|
|
2221
2234
|
} catch (error) {
|
|
2222
2235
|
if (isMissing2(error)) {
|
|
@@ -2334,13 +2347,14 @@ var MoodleGatewayError = class extends Error {
|
|
|
2334
2347
|
this.code = code;
|
|
2335
2348
|
}
|
|
2336
2349
|
};
|
|
2337
|
-
function createMoodleGateway(client) {
|
|
2350
|
+
function createMoodleGateway(client, hooks = {}) {
|
|
2338
2351
|
return {
|
|
2339
2352
|
getUser: () => client.getSiteInfo(),
|
|
2340
2353
|
listThreads: (id2) => client.getForumDiscussionRefs ? client.getForumDiscussionRefs(id2) : Promise.resolve([]),
|
|
2341
2354
|
listNewsForums: (id2) => client.getNewsForums ? client.getNewsForums(id2) : Promise.resolve([]),
|
|
2342
2355
|
getOverview: (input2) => client.getOverview(input2.todoLimit, input2.todoDays, input2.alertsLimit),
|
|
2343
2356
|
...client.getTodo ? { getDue: (days, courseId) => client.getTodo(Number.MAX_SAFE_INTEGER, days, courseId) } : {},
|
|
2357
|
+
...client.submitAssignmentFiles ? { submitAssignment: (input2) => client.submitAssignmentFiles({ ...input2, ...hooks.onSubmitProgress ? { onProgress: hooks.onSubmitProgress } : {} }) } : {},
|
|
2344
2358
|
listCourses: () => client.getCourses(),
|
|
2345
2359
|
async getCourse({ courseId }) {
|
|
2346
2360
|
const [courses, sections] = await Promise.all([
|
|
@@ -2658,9 +2672,9 @@ function splitUnitPhrase(phrase, courses) {
|
|
|
2658
2672
|
}
|
|
2659
2673
|
|
|
2660
2674
|
// src/intents.ts
|
|
2661
|
-
async function inParallel(items,
|
|
2675
|
+
async function inParallel(items, size2, run) {
|
|
2662
2676
|
const results = [];
|
|
2663
|
-
for (let index = 0; index < items.length; index +=
|
|
2677
|
+
for (let index = 0; index < items.length; index += size2) results.push(...await Promise.all(items.slice(index, index + size2).map(run)));
|
|
2664
2678
|
return results;
|
|
2665
2679
|
}
|
|
2666
2680
|
function createIntentService(gateway, now = () => Date.now()) {
|
|
@@ -2851,6 +2865,12 @@ function createIntentService(gateway, now = () => Date.now()) {
|
|
|
2851
2865
|
result = { file: { name: file2.name, mime_type: file2.mimeType, bytes: file2.bytes, uri: file2.uri } };
|
|
2852
2866
|
break;
|
|
2853
2867
|
}
|
|
2868
|
+
case "submit": {
|
|
2869
|
+
if (!gateway.submitAssignment) throw new MoodleGatewayError("MOODLE_TOOL_UNAVAILABLE", "Submitting needs local files; run moodle submit or the local MCP server on the machine that holds them.");
|
|
2870
|
+
const activityId = await resolveItem(input2.ref);
|
|
2871
|
+
result = { submission: await gateway.submitAssignment({ activityId, files: input2.files, final: Boolean(input2.final), replace: Boolean(input2.replace), acceptStatement: Boolean(input2.accept_statement), dryRun: Boolean(input2.dry_run) }) };
|
|
2872
|
+
break;
|
|
2873
|
+
}
|
|
2854
2874
|
}
|
|
2855
2875
|
return intentContracts[name].output.parse(stripEmpty(result));
|
|
2856
2876
|
}
|
|
@@ -2861,16 +2881,26 @@ function createIntentService(gateway, now = () => Date.now()) {
|
|
|
2861
2881
|
return { run, resolveItem, fileSource, find, sections };
|
|
2862
2882
|
}
|
|
2863
2883
|
|
|
2884
|
+
// src/screens.ts
|
|
2885
|
+
import { createTheme as createTheme2 } from "@bunizao/cli-kit";
|
|
2886
|
+
|
|
2864
2887
|
// src/terminal-table.ts
|
|
2888
|
+
import { createTheme } from "@bunizao/cli-kit";
|
|
2865
2889
|
var DEFAULT_WIDTH = 120;
|
|
2890
|
+
var TONED_LABEL = /status|state|type|grade|due|ok|action|grading/iu;
|
|
2891
|
+
var colorEnabled = () => false;
|
|
2892
|
+
function configureTerminalTables(options) {
|
|
2893
|
+
colorEnabled = options.color;
|
|
2894
|
+
}
|
|
2866
2895
|
function renderTerminalTable(columns, rows, options = {}) {
|
|
2896
|
+
const theme = createTheme(colorEnabled());
|
|
2867
2897
|
const width = Math.max(20, options.width ?? process.stdout.columns ?? DEFAULT_WIDTH);
|
|
2868
2898
|
const clean = rows.map((row) => columns.map((_, i) => sanitizeTerminalText(row[i] ?? "").replace(/\s+/gu, " ")));
|
|
2869
2899
|
const lengths = columns.map((c, i) => Math.max(c.label.length, ...clean.map((r) => Array.from(r[i]).length)));
|
|
2870
2900
|
const flexible = columns.map((c, i) => c.flex || /name|title|subject|feedback|description|value|course|unit/iu.test(c.label) ? i : -1).filter((i) => i >= 0);
|
|
2871
2901
|
const widths = columns.map((c, i) => c.width ?? (flexible.includes(i) ? Math.min(lengths[i], 36) : lengths[i]));
|
|
2872
2902
|
const minimum = widths.reduce((n2, w, i) => n2 + (flexible.includes(i) ? 8 : w), 0) + columns.length * 3 + 1;
|
|
2873
|
-
const title = options.title ? [sanitizeTerminalText(options.title)] : [];
|
|
2903
|
+
const title = options.title ? [theme.subject(sanitizeTerminalText(options.title))] : [];
|
|
2874
2904
|
if (width < 60 || minimum > width) {
|
|
2875
2905
|
const wrap = (line2) => Array.from(line2).reduce((lines, char) => {
|
|
2876
2906
|
if (!lines.length || Array.from(lines.at(-1)).length >= width) lines.push("");
|
|
@@ -2889,9 +2919,21 @@ function renderTerminalTable(columns, rows, options = {}) {
|
|
|
2889
2919
|
const trimmed = chars.length > w ? `${chars.slice(0, w - 1).join("")}\u2026` : v;
|
|
2890
2920
|
return trimmed + " ".repeat(Math.max(0, w - Array.from(trimmed).length));
|
|
2891
2921
|
};
|
|
2892
|
-
const
|
|
2893
|
-
|
|
2894
|
-
|
|
2922
|
+
const paint = (cell, i, row) => {
|
|
2923
|
+
if (options.keyValue) return i === 0 ? theme.dim(cell) : /status|grading|action/iu.test(row[0] ?? "") ? theme.status(cell) : cell;
|
|
2924
|
+
return i === 0 ? theme.key(cell) : TONED_LABEL.test(columns[i]?.label ?? "") ? theme.status(cell) : cell;
|
|
2925
|
+
};
|
|
2926
|
+
const border = (l, m, r) => theme.dim(l + widths.map((w) => "\u2500".repeat(w + 2)).join(m) + r);
|
|
2927
|
+
const bar = theme.dim("\u2502");
|
|
2928
|
+
const line = (row, paintCell) => bar + widths.map((w, i) => ` ${paintCell(fit(row[i] ?? "", w), i)} `).join(bar) + bar;
|
|
2929
|
+
return [
|
|
2930
|
+
...title,
|
|
2931
|
+
border("\u250C", "\u252C", "\u2510"),
|
|
2932
|
+
line(columns.map((c) => c.label), (cell) => theme.dim(cell)),
|
|
2933
|
+
border("\u251C", "\u253C", "\u2524"),
|
|
2934
|
+
...clean.map((row) => line(row, (cell, i) => paint(cell, i, row))),
|
|
2935
|
+
border("\u2514", "\u2534", "\u2518")
|
|
2936
|
+
].join("\n");
|
|
2895
2937
|
}
|
|
2896
2938
|
function renderKeyValueTable(rows, options = {}) {
|
|
2897
2939
|
const present = rows.filter(([, value]) => value !== "");
|
|
@@ -2903,10 +2945,10 @@ No details` : "No details";
|
|
|
2903
2945
|
{ label: "Field" },
|
|
2904
2946
|
{ label: "Value" }
|
|
2905
2947
|
];
|
|
2906
|
-
return renderTerminalTable(columns, present, options);
|
|
2948
|
+
return renderTerminalTable(columns, present, { ...options, keyValue: true });
|
|
2907
2949
|
}
|
|
2908
2950
|
function sanitizeTerminalText(value) {
|
|
2909
|
-
return value.replace(/\r\n?/gu, "\n").replace(/\t/gu, " ").replace(/[
|
|
2951
|
+
return value.replace(/\r\n?/gu, "\n").replace(/\t/gu, " ").replace(/[---]/gu, "");
|
|
2910
2952
|
}
|
|
2911
2953
|
|
|
2912
2954
|
// src/screens.ts
|
|
@@ -2927,16 +2969,17 @@ function moment(value, now) {
|
|
|
2927
2969
|
function renderScreen(data, options = {}) {
|
|
2928
2970
|
const lines = [];
|
|
2929
2971
|
const now = options.now ?? Date.now();
|
|
2972
|
+
const theme = createTheme2(Boolean(options.color));
|
|
2930
2973
|
const dueText = (row) => {
|
|
2931
|
-
if (!row.due_at) return text(row.status || row.submission_status);
|
|
2974
|
+
if (!row.due_at) return theme.status(text(row.status || row.submission_status));
|
|
2932
2975
|
const days = Math.ceil((Number(row.due_at) * 1e3 - now) / 864e5);
|
|
2933
2976
|
const value = `${days < 0 ? `${-days} days overdue` : days === 0 ? "today" : days === 1 ? "tomorrow" : `in ${days} days`} \xB7 ${moment(row.due, now)}`;
|
|
2934
|
-
return
|
|
2977
|
+
return days < 0 ? theme.tone("danger", value) : days <= 2 ? theme.tone("warning", value) : theme.dim(value);
|
|
2935
2978
|
};
|
|
2936
2979
|
const rows = (items, title) => {
|
|
2937
|
-
lines.push(title);
|
|
2938
|
-
if (!items.length) lines.push(" None");
|
|
2939
|
-
for (const r of items) lines.push(` ${text(r.unit_code || r.type)} ${text(r.name)}${r.due_at ? ` ${dueText(r)}` : ""}${r.id ? `
|
|
2980
|
+
lines.push(theme.subject(title));
|
|
2981
|
+
if (!items.length) lines.push(theme.dim(" None"));
|
|
2982
|
+
for (const r of items) lines.push(` ${theme.key(text(r.unit_code || r.type))} ${text(r.name)}${r.due_at ? ` ${dueText(r)}` : ""}${r.id ? ` ${theme.dim(`#${r.id}`)}` : ""}`);
|
|
2940
2983
|
};
|
|
2941
2984
|
let next = "moodle due --days 30 \xB7 moodle grades";
|
|
2942
2985
|
if (data.home) {
|
|
@@ -3001,7 +3044,7 @@ function renderScreen(data, options = {}) {
|
|
|
3001
3044
|
rows(array(key ? data[key] : []), key === "due" ? "Due" : "Matches");
|
|
3002
3045
|
if (data.total !== void 0) lines.push(`${data.total} total`);
|
|
3003
3046
|
}
|
|
3004
|
-
lines.push("", `Try ${next}`);
|
|
3047
|
+
lines.push("", theme.dim(`Try ${next}`));
|
|
3005
3048
|
const width = Math.max(40, options.width || 80);
|
|
3006
3049
|
return lines.flatMap((line) => {
|
|
3007
3050
|
if (line.includes("\x1B[") || line.startsWith("\u2502") || /^[┌└├]/u.test(line)) return [line];
|
|
@@ -3022,12 +3065,20 @@ function renderScreen(data, options = {}) {
|
|
|
3022
3065
|
}
|
|
3023
3066
|
|
|
3024
3067
|
// src/cli.ts
|
|
3025
|
-
import { createInterface as createInterface4 } from "readline/promises";
|
|
3026
3068
|
import { spawn as spawn3 } from "child_process";
|
|
3027
3069
|
import {
|
|
3070
|
+
banner,
|
|
3071
|
+
colorEnabled as colorEnabled2,
|
|
3028
3072
|
confirm,
|
|
3029
3073
|
createProgram,
|
|
3074
|
+
createTheme as createTheme3,
|
|
3075
|
+
createUi as createUi3,
|
|
3076
|
+
detectAudience,
|
|
3077
|
+
examples,
|
|
3078
|
+
helpSection,
|
|
3030
3079
|
insertDefaultVerb,
|
|
3080
|
+
isInformationalExit,
|
|
3081
|
+
parseWithPrompts,
|
|
3031
3082
|
render,
|
|
3032
3083
|
reportError,
|
|
3033
3084
|
normalizeError,
|
|
@@ -3036,12 +3087,15 @@ import {
|
|
|
3036
3087
|
writeOutput
|
|
3037
3088
|
} from "@bunizao/cli-kit";
|
|
3038
3089
|
import { realpathSync as realpathSync3 } from "fs";
|
|
3039
|
-
import
|
|
3090
|
+
import path4 from "path";
|
|
3040
3091
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
3041
3092
|
|
|
3042
3093
|
// src/moodle-client-core.ts
|
|
3043
3094
|
import { z as z2 } from "zod";
|
|
3044
3095
|
|
|
3096
|
+
// src/moodle-assign-core.ts
|
|
3097
|
+
import { parse as parse4 } from "node-html-parser";
|
|
3098
|
+
|
|
3045
3099
|
// src/html-utils.ts
|
|
3046
3100
|
import { parse as parse2 } from "node-html-parser";
|
|
3047
3101
|
function htmlToStructuredContent(html, baseUrl) {
|
|
@@ -3111,262 +3165,6 @@ function decodeHtml2(value) {
|
|
|
3111
3165
|
return value.replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'");
|
|
3112
3166
|
}
|
|
3113
3167
|
|
|
3114
|
-
// src/parsers.ts
|
|
3115
|
-
function schema(parser) {
|
|
3116
|
-
return { parse: parser };
|
|
3117
|
-
}
|
|
3118
|
-
var UserInfoSchema = schema(parseUserInfo);
|
|
3119
|
-
var CourseSchema = schema(parseCourse);
|
|
3120
|
-
var CoursesSchema = schema(parseCourses);
|
|
3121
|
-
var ActivitySchema = schema(parseActivity);
|
|
3122
|
-
var SectionSchema = schema(parseSection);
|
|
3123
|
-
var CourseContentsSchema = schema(parseCourseContents);
|
|
3124
|
-
var TodoItemSchema = schema(parseTodoItem);
|
|
3125
|
-
function parseUserInfo(value) {
|
|
3126
|
-
const data = asRecord(value);
|
|
3127
|
-
return {
|
|
3128
|
-
userid: numberValue(data.userid),
|
|
3129
|
-
username: stringValue(data.username),
|
|
3130
|
-
fullname: stringValue(data.fullname),
|
|
3131
|
-
sitename: stringValue(data.sitename),
|
|
3132
|
-
siteurl: stringValue(data.siteurl),
|
|
3133
|
-
lang: stringValue(data.lang),
|
|
3134
|
-
...data.timezone ? { timezone: String(data.timezone) } : {}
|
|
3135
|
-
};
|
|
3136
|
-
}
|
|
3137
|
-
function parseCourse(value, nowSeconds = Math.floor(Date.now() / 1e3)) {
|
|
3138
|
-
const data = asRecord(value);
|
|
3139
|
-
const course = {
|
|
3140
|
-
id: numberValue(data.id),
|
|
3141
|
-
shortname: stringValue(data.shortname),
|
|
3142
|
-
fullname: stringValue(data.fullname),
|
|
3143
|
-
category: numberValue(data.category),
|
|
3144
|
-
visible: booleanValue(data.visible, true),
|
|
3145
|
-
startdate: numberValue(data.startdate)
|
|
3146
|
-
};
|
|
3147
|
-
const enddate = numberValue(data.enddate);
|
|
3148
|
-
if (enddate > 0) {
|
|
3149
|
-
course.enddate = enddate;
|
|
3150
|
-
}
|
|
3151
|
-
return course;
|
|
3152
|
-
}
|
|
3153
|
-
function parseCourses(value) {
|
|
3154
|
-
return asArray(value).map((item) => parseCourse(item));
|
|
3155
|
-
}
|
|
3156
|
-
function parseActivity(value) {
|
|
3157
|
-
const data = asRecord(value);
|
|
3158
|
-
return {
|
|
3159
|
-
id: numberValue(data.id),
|
|
3160
|
-
name: stringValue(data.name),
|
|
3161
|
-
modname: stringValue(data.modname),
|
|
3162
|
-
url: stringValue(data.url),
|
|
3163
|
-
visible: booleanValue(data.visible, true),
|
|
3164
|
-
description: stringValue(data.description),
|
|
3165
|
-
...data.completiondata && typeof data.completiondata === "object" ? { completion: numberValue(asRecord(data.completiondata).state) } : {},
|
|
3166
|
-
...Array.isArray(data.contents) ? { file_entries: data.contents.filter((f) => asRecord(f).fileurl).map((f) => ({ name: stringValue(asRecord(f).filename), url: stringValue(asRecord(f).fileurl), requires_authentication: true })) } : {}
|
|
3167
|
-
};
|
|
3168
|
-
}
|
|
3169
|
-
function parseSection(value) {
|
|
3170
|
-
const data = asRecord(value);
|
|
3171
|
-
return {
|
|
3172
|
-
id: numberValue(data.id),
|
|
3173
|
-
name: stringValue(data.name),
|
|
3174
|
-
section: numberValue(data.section),
|
|
3175
|
-
visible: booleanValue(data.visible, true),
|
|
3176
|
-
summary: stringValue(data.summary),
|
|
3177
|
-
...data.current !== void 0 ? { current: booleanValue(data.current) } : {},
|
|
3178
|
-
activities: asArray(data.modules).map((item) => parseActivity(item))
|
|
3179
|
-
};
|
|
3180
|
-
}
|
|
3181
|
-
function parseCourseContents(value) {
|
|
3182
|
-
return asArray(value).map((item) => parseSection(item));
|
|
3183
|
-
}
|
|
3184
|
-
function parseCourseFormatState(value, baseUrl) {
|
|
3185
|
-
const state = asRecord(parseJsonValue(value));
|
|
3186
|
-
const activities = /* @__PURE__ */ new Map();
|
|
3187
|
-
const activitiesBySection = /* @__PURE__ */ new Map();
|
|
3188
|
-
for (const item of asArray(state.cm)) {
|
|
3189
|
-
const data = asRecord(item);
|
|
3190
|
-
const id2 = numberValue(data.id);
|
|
3191
|
-
const sectionId = stringValue(data.sectionid);
|
|
3192
|
-
const module = stringValue(data.module) || stringValue(data.plugin).replace(/^mod_/u, "") || stringValue(data.modname).toLowerCase();
|
|
3193
|
-
const activity = {
|
|
3194
|
-
id: id2,
|
|
3195
|
-
name: htmlText(data.name, baseUrl),
|
|
3196
|
-
modname: module.toLowerCase(),
|
|
3197
|
-
url: stringValue(data.url) ? resolveUrl(baseUrl, stringValue(data.url)) : "",
|
|
3198
|
-
visible: booleanValue(data.visible, true) && booleanValue(data.uservisible, true) && !booleanValue(data.stealth),
|
|
3199
|
-
description: htmlText(data.content ?? data.description, baseUrl),
|
|
3200
|
-
...data.completionstate !== void 0 && data.completionstate !== null ? { completion: numberValue(data.completionstate) } : {}
|
|
3201
|
-
};
|
|
3202
|
-
activities.set(String(id2), activity);
|
|
3203
|
-
const sectionActivities = activitiesBySection.get(sectionId) ?? [];
|
|
3204
|
-
sectionActivities.push(activity);
|
|
3205
|
-
activitiesBySection.set(sectionId, sectionActivities);
|
|
3206
|
-
}
|
|
3207
|
-
return asArray(state.section).map((item) => {
|
|
3208
|
-
const data = asRecord(item);
|
|
3209
|
-
const id2 = numberValue(data.id);
|
|
3210
|
-
const hasActivityList = Array.isArray(data.cmlist);
|
|
3211
|
-
const listedActivities = asArray(data.cmlist).map((activityId) => activities.get(stringValue(activityId))).filter((activity) => activity !== void 0);
|
|
3212
|
-
return {
|
|
3213
|
-
id: id2,
|
|
3214
|
-
name: htmlText(data.title || data.rawtitle, baseUrl),
|
|
3215
|
-
section: numberValue(data.section ?? data.number),
|
|
3216
|
-
visible: booleanValue(data.visible, true),
|
|
3217
|
-
summary: htmlText(data.summary, baseUrl),
|
|
3218
|
-
...data.current !== void 0 ? { current: booleanValue(data.current) } : {},
|
|
3219
|
-
activities: hasActivityList ? listedActivities : activitiesBySection.get(String(id2)) ?? []
|
|
3220
|
-
};
|
|
3221
|
-
});
|
|
3222
|
-
}
|
|
3223
|
-
function parseTodoItem(value) {
|
|
3224
|
-
const data = asRecord(value);
|
|
3225
|
-
const course = asRecord(data.course);
|
|
3226
|
-
const action = asRecord(data.action);
|
|
3227
|
-
const progress = course.progress;
|
|
3228
|
-
return {
|
|
3229
|
-
id: numberValue(data.id),
|
|
3230
|
-
name: stringValue(data.name),
|
|
3231
|
-
activity_name: stringValue(data.activityname),
|
|
3232
|
-
modname: stringValue(data.modulename),
|
|
3233
|
-
course_id: numberValue(course.id),
|
|
3234
|
-
course_name: stringValue(course.fullname),
|
|
3235
|
-
due_at: numberValue(data.timesort) || numberValue(data.timestart),
|
|
3236
|
-
overdue: booleanValue(data.overdue),
|
|
3237
|
-
actionable: booleanValue(action.actionable),
|
|
3238
|
-
action_name: stringValue(action.name),
|
|
3239
|
-
action_url: stringValue(action.url),
|
|
3240
|
-
url: stringValue(data.url),
|
|
3241
|
-
event_type: stringValue(data.eventtype),
|
|
3242
|
-
course_progress: typeof progress === "number" ? progress : void 0
|
|
3243
|
-
};
|
|
3244
|
-
}
|
|
3245
|
-
function parseTodoItems(value) {
|
|
3246
|
-
return asArray(value).map((item) => parseTodoItem(item));
|
|
3247
|
-
}
|
|
3248
|
-
function parseAlertNotification(value) {
|
|
3249
|
-
const data = asRecord(value);
|
|
3250
|
-
return {
|
|
3251
|
-
id: numberValue(data.id),
|
|
3252
|
-
subject: stringValue(data.subject),
|
|
3253
|
-
short_subject: stringValue(data.shortenedsubject),
|
|
3254
|
-
event_type: stringValue(data.eventtype),
|
|
3255
|
-
component: stringValue(data.component),
|
|
3256
|
-
created_at: numberValue(data.timecreated),
|
|
3257
|
-
created_pretty: stringValue(data.timecreatedpretty),
|
|
3258
|
-
read: booleanValue(data.read),
|
|
3259
|
-
context_url: stringValue(data.contexturl),
|
|
3260
|
-
context_name: stringValue(data.contexturlname)
|
|
3261
|
-
};
|
|
3262
|
-
}
|
|
3263
|
-
function parseAlertSummary(notificationsData, countsData, unreadCountsData) {
|
|
3264
|
-
const notificationsRecord = asRecord(notificationsData);
|
|
3265
|
-
const counts2 = asRecord(countsData);
|
|
3266
|
-
const unreadCounts = asRecord(unreadCountsData);
|
|
3267
|
-
const types = asRecord(counts2.types);
|
|
3268
|
-
const unreadTypes = asRecord(unreadCounts.types);
|
|
3269
|
-
const notifications = asArray(notificationsRecord.notifications).map((item) => parseAlertNotification(item));
|
|
3270
|
-
return {
|
|
3271
|
-
notifications,
|
|
3272
|
-
notification_count: notifications.length,
|
|
3273
|
-
unread_notification_count: notifications.filter((notification) => !notification.read).length,
|
|
3274
|
-
starred_message_count: numberValue(counts2.favourites),
|
|
3275
|
-
direct_message_count: numberValue(types["1"]),
|
|
3276
|
-
group_message_count: numberValue(types["2"]),
|
|
3277
|
-
self_message_count: numberValue(types["3"]),
|
|
3278
|
-
unread_starred_message_count: numberValue(unreadCounts.favourites),
|
|
3279
|
-
unread_direct_message_count: numberValue(unreadTypes["1"]),
|
|
3280
|
-
unread_group_message_count: numberValue(unreadTypes["2"]),
|
|
3281
|
-
unread_self_message_count: numberValue(unreadTypes["3"])
|
|
3282
|
-
};
|
|
3283
|
-
}
|
|
3284
|
-
function parseForumPostAuthor(value) {
|
|
3285
|
-
const data = asRecord(value);
|
|
3286
|
-
const urls = asRecord(data.urls);
|
|
3287
|
-
return {
|
|
3288
|
-
id: numberValue(data.id),
|
|
3289
|
-
fullname: stringValue(data.fullname),
|
|
3290
|
-
profile_url: stringValue(urls.profile),
|
|
3291
|
-
profile_image_url: stringValue(urls.profileimage)
|
|
3292
|
-
};
|
|
3293
|
-
}
|
|
3294
|
-
function parseForumPost(value, baseUrl = "") {
|
|
3295
|
-
const data = asRecord(value);
|
|
3296
|
-
const urls = asRecord(data.urls);
|
|
3297
|
-
const messageHtml = stringValue(data.message);
|
|
3298
|
-
const structured = htmlToStructuredContent(messageHtml, stringValue(urls.view || urls.discuss) || baseUrl);
|
|
3299
|
-
return {
|
|
3300
|
-
id: numberValue(data.id),
|
|
3301
|
-
discussion_id: numberValue(data.discussionid),
|
|
3302
|
-
subject: stringValue(data.subject),
|
|
3303
|
-
message_html: messageHtml,
|
|
3304
|
-
message_text: structured.text,
|
|
3305
|
-
image_urls: structured.image_urls,
|
|
3306
|
-
links: structured.links,
|
|
3307
|
-
tables: structured.tables,
|
|
3308
|
-
author: parseForumPostAuthor(data.author),
|
|
3309
|
-
parent_id: numberValue(data.parentid),
|
|
3310
|
-
time_created: numberValue(data.timecreated),
|
|
3311
|
-
time_modified: numberValue(data.timemodified),
|
|
3312
|
-
created_pretty: "",
|
|
3313
|
-
unread: booleanValue(data.unread),
|
|
3314
|
-
is_deleted: booleanValue(data.isdeleted),
|
|
3315
|
-
is_private_reply: booleanValue(data.isprivatereply),
|
|
3316
|
-
url: stringValue(urls.view || urls.viewisolated),
|
|
3317
|
-
reply_url: stringValue(urls.reply)
|
|
3318
|
-
};
|
|
3319
|
-
}
|
|
3320
|
-
function parseForumDiscussion(value, discussionId, baseUrl = "") {
|
|
3321
|
-
const data = asRecord(value);
|
|
3322
|
-
const posts = asArray(data.posts).map((item) => parseForumPost(item, baseUrl));
|
|
3323
|
-
return {
|
|
3324
|
-
id: discussionId,
|
|
3325
|
-
subject: posts[0]?.subject ?? "",
|
|
3326
|
-
course_id: numberValue(data.courseid),
|
|
3327
|
-
forum_id: numberValue(data.forumid),
|
|
3328
|
-
group_id: numberValue(data.groupid),
|
|
3329
|
-
group_name: stringValue(data.groupname),
|
|
3330
|
-
url: posts[0]?.url ? posts[0].url.split("#", 1)[0] : "",
|
|
3331
|
-
posts
|
|
3332
|
-
};
|
|
3333
|
-
}
|
|
3334
|
-
function asRecord(value) {
|
|
3335
|
-
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
3336
|
-
}
|
|
3337
|
-
function asArray(value) {
|
|
3338
|
-
return Array.isArray(value) ? value : [];
|
|
3339
|
-
}
|
|
3340
|
-
function stringValue(value) {
|
|
3341
|
-
return typeof value === "string" ? value : value == null ? "" : String(value);
|
|
3342
|
-
}
|
|
3343
|
-
function numberValue(value) {
|
|
3344
|
-
if (typeof value === "number" && Number.isFinite(value)) {
|
|
3345
|
-
return value;
|
|
3346
|
-
}
|
|
3347
|
-
if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))) {
|
|
3348
|
-
return Number(value);
|
|
3349
|
-
}
|
|
3350
|
-
return 0;
|
|
3351
|
-
}
|
|
3352
|
-
function booleanValue(value, defaultValue = false) {
|
|
3353
|
-
if (value === void 0 || value === null) {
|
|
3354
|
-
return defaultValue;
|
|
3355
|
-
}
|
|
3356
|
-
return Boolean(value);
|
|
3357
|
-
}
|
|
3358
|
-
function parseJsonValue(value) {
|
|
3359
|
-
if (typeof value !== "string") return value;
|
|
3360
|
-
try {
|
|
3361
|
-
return JSON.parse(value);
|
|
3362
|
-
} catch {
|
|
3363
|
-
return {};
|
|
3364
|
-
}
|
|
3365
|
-
}
|
|
3366
|
-
function htmlText(value, baseUrl) {
|
|
3367
|
-
return htmlToStructuredContent(stringValue(value), baseUrl).text;
|
|
3368
|
-
}
|
|
3369
|
-
|
|
3370
3168
|
// src/scraper.ts
|
|
3371
3169
|
import { parse as parse3 } from "node-html-parser";
|
|
3372
3170
|
function parseMoodleErrorHtml(html) {
|
|
@@ -3398,8 +3196,8 @@ function parseMoodleErrorHtml(html) {
|
|
|
3398
3196
|
function parsePageContext(html, baseUrl) {
|
|
3399
3197
|
const root = parse3(html);
|
|
3400
3198
|
const config = parseMoodleConfig(html);
|
|
3401
|
-
const sesskey =
|
|
3402
|
-
const userid =
|
|
3199
|
+
const sesskey = stringValue(config.sesskey).trim();
|
|
3200
|
+
const userid = numberValue(config.userId) || numberValue(root.querySelector("[data-user-id]")?.getAttribute("data-user-id"));
|
|
3403
3201
|
if (!sesskey || !userid) {
|
|
3404
3202
|
throw new Error("Session appears invalid: could not load authenticated Moodle context");
|
|
3405
3203
|
}
|
|
@@ -3411,8 +3209,8 @@ function parsePageContext(html, baseUrl) {
|
|
|
3411
3209
|
fullname: cleanNodeText(root.querySelector(".userfullname")),
|
|
3412
3210
|
sitename: extractSitename(root),
|
|
3413
3211
|
siteurl: baseUrl,
|
|
3414
|
-
...config.timezone ? { timezone:
|
|
3415
|
-
lang:
|
|
3212
|
+
...config.timezone ? { timezone: stringValue(config.timezone) } : {},
|
|
3213
|
+
lang: stringValue(config.language) || root.querySelector("html")?.getAttribute("lang") || ""
|
|
3416
3214
|
}
|
|
3417
3215
|
};
|
|
3418
3216
|
}
|
|
@@ -3861,96 +3659,705 @@ function safeInt(value) {
|
|
|
3861
3659
|
if (typeof value === "string" && /^\d+$/.test(value.trim())) {
|
|
3862
3660
|
return Number(value.trim());
|
|
3863
3661
|
}
|
|
3864
|
-
return 0;
|
|
3662
|
+
return 0;
|
|
3663
|
+
}
|
|
3664
|
+
function parseMaybeUrl(href, baseUrl) {
|
|
3665
|
+
try {
|
|
3666
|
+
return new URL(href, baseUrl);
|
|
3667
|
+
} catch {
|
|
3668
|
+
return null;
|
|
3669
|
+
}
|
|
3670
|
+
}
|
|
3671
|
+
function numericQueryValue(url, key) {
|
|
3672
|
+
const value = url.searchParams.get(key);
|
|
3673
|
+
if (!value || !/^\d+$/.test(value)) {
|
|
3674
|
+
return null;
|
|
3675
|
+
}
|
|
3676
|
+
return Number(value);
|
|
3677
|
+
}
|
|
3678
|
+
function parseMoodleConfig(html) {
|
|
3679
|
+
const match = html.match(/M\.cfg\s*=\s*({[\s\S]*?});/);
|
|
3680
|
+
if (!match) {
|
|
3681
|
+
return {};
|
|
3682
|
+
}
|
|
3683
|
+
try {
|
|
3684
|
+
return JSON.parse(match[1]);
|
|
3685
|
+
} catch {
|
|
3686
|
+
return {};
|
|
3687
|
+
}
|
|
3688
|
+
}
|
|
3689
|
+
function extractSitename(root) {
|
|
3690
|
+
const title = cleanNodeText(root.querySelector("title"));
|
|
3691
|
+
return title.includes("|") ? title.split("|").at(-1)?.trim() ?? title : title;
|
|
3692
|
+
}
|
|
3693
|
+
function pageTitle(html) {
|
|
3694
|
+
return cleanNodeText(parse3(html).querySelector("h1"));
|
|
3695
|
+
}
|
|
3696
|
+
function activityContext(html) {
|
|
3697
|
+
const root = parse3(html);
|
|
3698
|
+
const context = { course_id: parseCourseIdFromPageHtml(html) ?? 0, course_name: "", section_name: "" };
|
|
3699
|
+
const breadcrumbs = root.querySelectorAll('nav[aria-label="Breadcrumb"] a[href], #page-navbar .breadcrumb a[href]');
|
|
3700
|
+
const links = breadcrumbs.length ? breadcrumbs : root.querySelectorAll('a[href*="/course/view.php?id="]');
|
|
3701
|
+
for (const link2 of links) {
|
|
3702
|
+
const href = link2.getAttribute("href") ?? "";
|
|
3703
|
+
const courseId = numberQueryValue(href, "id");
|
|
3704
|
+
if (courseId !== null) {
|
|
3705
|
+
context.course_id = courseId;
|
|
3706
|
+
}
|
|
3707
|
+
if (numberQueryValue(href, "section") === null) {
|
|
3708
|
+
context.course_name ||= cleanNodeText(link2);
|
|
3709
|
+
} else {
|
|
3710
|
+
context.section_name = cleanNodeText(link2);
|
|
3711
|
+
}
|
|
3712
|
+
}
|
|
3713
|
+
return context;
|
|
3714
|
+
}
|
|
3715
|
+
function extractLabeledText(html, label) {
|
|
3716
|
+
const root = parse3(html);
|
|
3717
|
+
for (const node of root.querySelectorAll("strong, b")) {
|
|
3718
|
+
if (cleanNodeText(node) !== label) {
|
|
3719
|
+
continue;
|
|
3720
|
+
}
|
|
3721
|
+
const parent = node.parentNode;
|
|
3722
|
+
return cleanText(parent?.textContent.replace(label, "") ?? "");
|
|
3723
|
+
}
|
|
3724
|
+
return "";
|
|
3725
|
+
}
|
|
3726
|
+
function findTableValue(html, label) {
|
|
3727
|
+
const root = parse3(html);
|
|
3728
|
+
for (const row of root.querySelectorAll("tr")) {
|
|
3729
|
+
const cells = row.querySelectorAll("th, td");
|
|
3730
|
+
if (cleanNodeText(cells[0]) === label) {
|
|
3731
|
+
return cleanTableCell(cells[1]);
|
|
3732
|
+
}
|
|
3733
|
+
}
|
|
3734
|
+
return "";
|
|
3735
|
+
}
|
|
3736
|
+
function cleanTableCell(node) {
|
|
3737
|
+
if (!node) {
|
|
3738
|
+
return "";
|
|
3739
|
+
}
|
|
3740
|
+
const clone = parse3(node.toString());
|
|
3741
|
+
for (const unwanted of clone.querySelectorAll(".action-menu, .dropdown, script, style")) {
|
|
3742
|
+
unwanted.remove();
|
|
3743
|
+
}
|
|
3744
|
+
return cleanText(clone.textContent.replace("( Empty )", "(Empty)"));
|
|
3745
|
+
}
|
|
3746
|
+
function numberQueryValue(href, key) {
|
|
3747
|
+
try {
|
|
3748
|
+
return numericQueryValue(new URL(href, "https://moodle.invalid"), key);
|
|
3749
|
+
} catch {
|
|
3750
|
+
return null;
|
|
3751
|
+
}
|
|
3752
|
+
}
|
|
3753
|
+
function numberValue(value) {
|
|
3754
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
3755
|
+
return value;
|
|
3756
|
+
}
|
|
3757
|
+
if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))) {
|
|
3758
|
+
return Number(value);
|
|
3759
|
+
}
|
|
3760
|
+
return 0;
|
|
3761
|
+
}
|
|
3762
|
+
function stringValue(value) {
|
|
3763
|
+
return typeof value === "string" ? value : value == null ? "" : String(value);
|
|
3764
|
+
}
|
|
3765
|
+
function fileEntry(name, url, baseUrl) {
|
|
3766
|
+
return {
|
|
3767
|
+
name,
|
|
3768
|
+
url,
|
|
3769
|
+
requires_authentication: new URL(url).origin === new URL(baseUrl).origin
|
|
3770
|
+
};
|
|
3771
|
+
}
|
|
3772
|
+
function unique(items) {
|
|
3773
|
+
return [...new Set(items)];
|
|
3774
|
+
}
|
|
3775
|
+
|
|
3776
|
+
// src/moodle-assign-core.ts
|
|
3777
|
+
async function submitAssignmentFiles(deps, request) {
|
|
3778
|
+
const id2 = request.activityId;
|
|
3779
|
+
if (!Number.isSafeInteger(id2) || id2 <= 0) throw deps.usage("The assignment id must be a positive integer.");
|
|
3780
|
+
if (!request.files.length && !request.final) throw deps.usage("Give at least one file to upload, or use --final to submit the existing draft.");
|
|
3781
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3782
|
+
for (const file2 of request.files) {
|
|
3783
|
+
if (!file2.name || /[\\/]/u.test(file2.name)) throw deps.usage(`'${file2.name}' is not a plain file name.`);
|
|
3784
|
+
if (seen.has(file2.name.toLowerCase())) throw deps.usage(`'${file2.name}' is given twice; Moodle keeps one file per name.`);
|
|
3785
|
+
seen.add(file2.name.toLowerCase());
|
|
3786
|
+
}
|
|
3787
|
+
const viewUrl = `${deps.baseUrl}${ASSIGN_VIEW_PATH}?id=${id2}`;
|
|
3788
|
+
request.onProgress?.("Reading the assignment");
|
|
3789
|
+
const before = parseReceiptPage(await pageText(deps, viewUrl), id2, deps.baseUrl);
|
|
3790
|
+
const form = parseSubmissionForm(await pageText(deps, `${viewUrl}&action=editsubmission`), deps);
|
|
3791
|
+
const draft = await listDraftFiles(deps, form);
|
|
3792
|
+
const removed = request.replace ? draft.map((file2) => file2.name) : [];
|
|
3793
|
+
const kept = request.replace ? [] : draft.filter((file2) => !seen.has(file2.name.toLowerCase()));
|
|
3794
|
+
checkLimits(deps, form, kept, request.files);
|
|
3795
|
+
let statement = form.statement;
|
|
3796
|
+
let confirm2;
|
|
3797
|
+
if (request.final && !statement) {
|
|
3798
|
+
confirm2 = parseConfirmForm(await pageText(deps, `${viewUrl}&action=submit`), deps);
|
|
3799
|
+
statement = confirm2.statement;
|
|
3800
|
+
}
|
|
3801
|
+
if (statement && !request.acceptStatement) {
|
|
3802
|
+
throw deps.usage(`Moodle requires you to accept this statement: "${statement}"`, "Re-run with --accept-statement once you agree.");
|
|
3803
|
+
}
|
|
3804
|
+
const limits = describeLimits(form);
|
|
3805
|
+
const uploads = request.files.map((file2) => ({ name: file2.name, bytes: file2.bytes.byteLength, ...file2.path ? { path: file2.path } : {} }));
|
|
3806
|
+
if (request.dryRun) {
|
|
3807
|
+
return {
|
|
3808
|
+
...before,
|
|
3809
|
+
action: "planned",
|
|
3810
|
+
files: draft.map((file2) => ({ name: file2.name, bytes: file2.bytes })),
|
|
3811
|
+
uploads,
|
|
3812
|
+
removed,
|
|
3813
|
+
limits,
|
|
3814
|
+
...statement ? { statement, statement_accepted: true } : {},
|
|
3815
|
+
checked_at: timestamp(deps)
|
|
3816
|
+
};
|
|
3817
|
+
}
|
|
3818
|
+
if (removed.length) {
|
|
3819
|
+
request.onProgress?.(`Removing ${removed.join(", ")}`);
|
|
3820
|
+
await deleteDraftFiles(deps, form, draft);
|
|
3821
|
+
}
|
|
3822
|
+
const storedNames = [];
|
|
3823
|
+
for (const [index, file2] of request.files.entries()) {
|
|
3824
|
+
request.onProgress?.(`Uploading ${file2.name} (${index + 1}/${request.files.length})`);
|
|
3825
|
+
storedNames.push(await uploadDraftFile(deps, form, file2));
|
|
3826
|
+
}
|
|
3827
|
+
if (storedNames.length || removed.length) {
|
|
3828
|
+
request.onProgress?.("Saving the submission");
|
|
3829
|
+
const savedHtml = await postForm(deps, form.action, [...form.fields, ...form.statement ? [["submissionstatement", "1"]] : [], ["submitbutton", "Save changes"]]);
|
|
3830
|
+
if (savedHtml !== null) throw deps.fail(`Moodle did not save the submission: ${noticesOf(savedHtml) || "it returned the edit form again without a reason"}`);
|
|
3831
|
+
}
|
|
3832
|
+
request.onProgress?.("Reading the receipt");
|
|
3833
|
+
let receipt = parseReceiptPage(await pageText(deps, viewUrl), id2, deps.baseUrl);
|
|
3834
|
+
const listed = new Set(receipt.files.map((file2) => file2.name.toLowerCase()));
|
|
3835
|
+
const missing = storedNames.filter((name) => !listed.has(name.toLowerCase()));
|
|
3836
|
+
if (missing.length) throw deps.fail(`Moodle saved the submission but its page does not list ${missing.join(", ")}; check the assignment in a browser before submitting.`);
|
|
3837
|
+
let action = "saved";
|
|
3838
|
+
if (isSubmitted(receipt.submission_status)) action = "submitted";
|
|
3839
|
+
else if (request.final) {
|
|
3840
|
+
request.onProgress?.("Submitting for grading");
|
|
3841
|
+
confirm2 ??= parseConfirmForm(await pageText(deps, `${viewUrl}&action=submit`), deps);
|
|
3842
|
+
const errorHtml = await postForm(deps, confirm2.action, [...confirm2.fields, ...confirm2.statement ? [["submissionstatement", "1"]] : [], ["submitbutton", "Continue"]]);
|
|
3843
|
+
if (errorHtml !== null) throw deps.fail(`Moodle did not submit the assignment for grading: ${noticesOf(errorHtml) || "it returned the confirmation page again without a reason"}`);
|
|
3844
|
+
receipt = parseReceiptPage(await pageText(deps, viewUrl), id2, deps.baseUrl);
|
|
3845
|
+
if (!isSubmitted(receipt.submission_status)) throw deps.fail(`Moodle accepted the confirmation but still reports "${receipt.submission_status || "no status"}"; check the assignment in a browser.`);
|
|
3846
|
+
action = "submitted";
|
|
3847
|
+
}
|
|
3848
|
+
return {
|
|
3849
|
+
...receipt,
|
|
3850
|
+
action,
|
|
3851
|
+
uploads,
|
|
3852
|
+
removed,
|
|
3853
|
+
limits,
|
|
3854
|
+
...statement ? { statement, statement_accepted: true } : {},
|
|
3855
|
+
checked_at: timestamp(deps)
|
|
3856
|
+
};
|
|
3857
|
+
}
|
|
3858
|
+
function parseSubmissionForm(html, deps) {
|
|
3859
|
+
const root = parse4(html);
|
|
3860
|
+
const form = formWithAction(root, "savesubmission");
|
|
3861
|
+
if (!form) {
|
|
3862
|
+
const notice = noticesOf(html);
|
|
3863
|
+
if (root.querySelectorAll("input[type=submit], button").some((button) => /begin assignment/iu.test(cleanText(button.getAttribute("value") ?? button.textContent)))) {
|
|
3864
|
+
throw deps.fail("This is a timed assignment; start it in a browser before uploading files.");
|
|
3865
|
+
}
|
|
3866
|
+
throw deps.fail(notice ? `Moodle is not accepting a submission: ${notice}` : "Moodle did not show a submission form for this assignment.");
|
|
3867
|
+
}
|
|
3868
|
+
const itemid = form.querySelector("input[name=files_filemanager]")?.getAttribute("value")?.trim() ?? "";
|
|
3869
|
+
if (!itemid) throw deps.fail("This assignment does not accept file uploads.");
|
|
3870
|
+
const options = filemanagerOptions(html, itemid);
|
|
3871
|
+
if (!options) throw deps.fail("Moodle did not describe the file upload area for this assignment.");
|
|
3872
|
+
const fields2 = formFields(form);
|
|
3873
|
+
const sesskey = fields2.find(([name]) => name === "sesskey")?.[1] ?? "";
|
|
3874
|
+
if (!sesskey) throw deps.fail("The submission form has no session key.");
|
|
3875
|
+
const picker = record2(options.filepicker);
|
|
3876
|
+
const repositories = (Array.isArray(picker.repositories) ? picker.repositories : Object.values(record2(picker.repositories))).map(record2);
|
|
3877
|
+
const upload = repositories.find((repo) => repo.type === "upload");
|
|
3878
|
+
if (!upload || upload.id === void 0) throw deps.fail("The site does not allow direct file uploads for this assignment.");
|
|
3879
|
+
const accepted = options.accepted_types;
|
|
3880
|
+
const acceptedTypes = Array.isArray(accepted) ? accepted.map(String).filter(Boolean) : accepted === void 0 || accepted === "*" ? "*" : [String(accepted)];
|
|
3881
|
+
return {
|
|
3882
|
+
action: resolveUrl(deps.baseUrl, form.getAttribute("action") || `${deps.baseUrl}${ASSIGN_VIEW_PATH}`),
|
|
3883
|
+
fields: fields2,
|
|
3884
|
+
sesskey,
|
|
3885
|
+
itemid,
|
|
3886
|
+
clientId: String(options.client_id ?? ""),
|
|
3887
|
+
contextId: String(record2(options.context).id ?? ""),
|
|
3888
|
+
repoId: String(upload.id),
|
|
3889
|
+
author: String(picker.author ?? ""),
|
|
3890
|
+
license: String(picker.defaultlicense ?? ""),
|
|
3891
|
+
maxBytes: integer(options.maxbytes),
|
|
3892
|
+
areaMaxBytes: integer(options.areamaxbytes),
|
|
3893
|
+
maxFiles: integer(options.maxfiles),
|
|
3894
|
+
acceptedTypes: acceptedTypes.length === 1 && acceptedTypes[0] === "*" ? "*" : acceptedTypes,
|
|
3895
|
+
...statementOf(form)
|
|
3896
|
+
};
|
|
3897
|
+
}
|
|
3898
|
+
function parseConfirmForm(html, deps) {
|
|
3899
|
+
const root = parse4(html);
|
|
3900
|
+
const form = formWithAction(root, "confirmsubmit");
|
|
3901
|
+
if (!form) {
|
|
3902
|
+
const notice = noticesOf(html);
|
|
3903
|
+
throw deps.fail(notice ? `Moodle is not accepting a submission for grading: ${notice}` : "Moodle did not show the submit-for-grading confirmation.");
|
|
3904
|
+
}
|
|
3905
|
+
return { action: resolveUrl(deps.baseUrl, form.getAttribute("action") || `${deps.baseUrl}${ASSIGN_VIEW_PATH}`), fields: formFields(form), ...statementOf(form) };
|
|
3906
|
+
}
|
|
3907
|
+
function parseReceiptPage(html, activityId, baseUrl) {
|
|
3908
|
+
const page = parseAssignmentHtml(html, activityId, baseUrl);
|
|
3909
|
+
const root = parse4(html);
|
|
3910
|
+
const files = /* @__PURE__ */ new Map();
|
|
3911
|
+
const add = (link2) => {
|
|
3912
|
+
const name = cleanText(link2.textContent);
|
|
3913
|
+
const href = link2.getAttribute("href") ?? "";
|
|
3914
|
+
if (name && !files.has(name.toLowerCase())) files.set(name.toLowerCase(), { name, ...href ? { url: resolveUrl(baseUrl, href) } : {} });
|
|
3915
|
+
};
|
|
3916
|
+
for (const link2 of root.querySelectorAll(".fileuploadsubmission a[href]")) add(link2);
|
|
3917
|
+
for (const link2 of tableCell(root, "File submissions")?.querySelectorAll("a[href]") ?? []) add(link2);
|
|
3918
|
+
return {
|
|
3919
|
+
id: activityId,
|
|
3920
|
+
name: page.name,
|
|
3921
|
+
...page.course_id ? { unit_id: page.course_id } : {},
|
|
3922
|
+
url: page.url,
|
|
3923
|
+
submission_status: page.submission_status,
|
|
3924
|
+
grading_status: page.grading_status,
|
|
3925
|
+
due: page.due_pretty || cleanText(tableCell(root, "Due date")?.textContent),
|
|
3926
|
+
time_remaining: page.time_remaining,
|
|
3927
|
+
last_modified: cleanText(tableCell(root, "Last modified")?.textContent),
|
|
3928
|
+
files: [...files.values()]
|
|
3929
|
+
};
|
|
3930
|
+
}
|
|
3931
|
+
function noticesOf(html) {
|
|
3932
|
+
const root = parse4(html);
|
|
3933
|
+
const texts = [];
|
|
3934
|
+
for (const node of root.querySelectorAll(".alert, .invalid-feedback, .form-control-feedback, .error, [data-fieldtype] .text-danger")) {
|
|
3935
|
+
for (const junk of node.querySelectorAll("button, .close")) junk.remove();
|
|
3936
|
+
const text2 = cleanText(node.textContent);
|
|
3937
|
+
if (text2 && !texts.includes(text2)) texts.push(text2);
|
|
3938
|
+
}
|
|
3939
|
+
return texts.join(" ");
|
|
3940
|
+
}
|
|
3941
|
+
function formWithAction(root, action) {
|
|
3942
|
+
for (const form of root.querySelectorAll("form")) {
|
|
3943
|
+
if (form.querySelectorAll("input[name=action]").some((input2) => input2.getAttribute("value") === action)) return form;
|
|
3944
|
+
}
|
|
3945
|
+
return null;
|
|
3946
|
+
}
|
|
3947
|
+
function formFields(form) {
|
|
3948
|
+
const fields2 = [];
|
|
3949
|
+
for (const element of form.querySelectorAll("input, textarea, select")) {
|
|
3950
|
+
const name = element.getAttribute("name");
|
|
3951
|
+
if (!name) continue;
|
|
3952
|
+
const tag = element.tagName.toLowerCase();
|
|
3953
|
+
if (tag === "textarea") {
|
|
3954
|
+
fields2.push([name, element.textContent]);
|
|
3955
|
+
continue;
|
|
3956
|
+
}
|
|
3957
|
+
if (tag === "select") {
|
|
3958
|
+
const options = element.querySelectorAll("option");
|
|
3959
|
+
const chosen = options.find((option) => option.hasAttribute("selected")) ?? options[0];
|
|
3960
|
+
if (chosen) fields2.push([name, chosen.getAttribute("value") ?? cleanText(chosen.textContent)]);
|
|
3961
|
+
continue;
|
|
3962
|
+
}
|
|
3963
|
+
const type = (element.getAttribute("type") ?? "text").toLowerCase();
|
|
3964
|
+
if (["submit", "button", "image", "file", "reset"].includes(type)) continue;
|
|
3965
|
+
if ((type === "checkbox" || type === "radio") && !element.hasAttribute("checked")) continue;
|
|
3966
|
+
fields2.push([name, element.getAttribute("value") ?? (type === "checkbox" ? "on" : "")]);
|
|
3967
|
+
}
|
|
3968
|
+
return fields2;
|
|
3969
|
+
}
|
|
3970
|
+
function statementOf(form) {
|
|
3971
|
+
const box = form.querySelector("input[name=submissionstatement]");
|
|
3972
|
+
if (!box) return {};
|
|
3973
|
+
const id2 = box.getAttribute("id");
|
|
3974
|
+
const label = (id2 ? form.querySelector(`label[for="${id2}"]`) : null) ?? box.closest("label") ?? box.parentNode?.querySelector("label") ?? null;
|
|
3975
|
+
const text2 = cleanText(label?.textContent).replace(/\s*Required\s*$/u, "").trim();
|
|
3976
|
+
return { statement: text2 || "Submission statement" };
|
|
3977
|
+
}
|
|
3978
|
+
function filemanagerOptions(html, itemid) {
|
|
3979
|
+
const pattern = /M\.form_filemanager\.init\(\s*Y\s*,\s*/gu;
|
|
3980
|
+
let match;
|
|
3981
|
+
while (match = pattern.exec(html)) {
|
|
3982
|
+
const json = balancedObject(html, match.index + match[0].length);
|
|
3983
|
+
if (!json) continue;
|
|
3984
|
+
try {
|
|
3985
|
+
const options = JSON.parse(json);
|
|
3986
|
+
if (isRecord4(options) && String(options.itemid) === itemid) return options;
|
|
3987
|
+
} catch {
|
|
3988
|
+
}
|
|
3989
|
+
}
|
|
3990
|
+
return null;
|
|
3991
|
+
}
|
|
3992
|
+
function balancedObject(text2, start) {
|
|
3993
|
+
if (text2[start] !== "{") return null;
|
|
3994
|
+
let depth = 0;
|
|
3995
|
+
let quoted = false;
|
|
3996
|
+
for (let index = start; index < text2.length; index += 1) {
|
|
3997
|
+
const char = text2[index];
|
|
3998
|
+
if (quoted) {
|
|
3999
|
+
if (char === "\\") index += 1;
|
|
4000
|
+
else if (char === '"') quoted = false;
|
|
4001
|
+
} else if (char === '"') quoted = true;
|
|
4002
|
+
else if (char === "{") depth += 1;
|
|
4003
|
+
else if (char === "}") {
|
|
4004
|
+
depth -= 1;
|
|
4005
|
+
if (depth === 0) return text2.slice(start, index + 1);
|
|
4006
|
+
}
|
|
4007
|
+
}
|
|
4008
|
+
return null;
|
|
4009
|
+
}
|
|
4010
|
+
function tableCell(root, label) {
|
|
4011
|
+
for (const row of root.querySelectorAll("tr")) {
|
|
4012
|
+
const cells = row.querySelectorAll("th, td");
|
|
4013
|
+
if (cells.length > 1 && cleanText(cells[0].textContent) === label) return cells[1];
|
|
4014
|
+
}
|
|
4015
|
+
return null;
|
|
4016
|
+
}
|
|
4017
|
+
async function pageText(deps, url) {
|
|
4018
|
+
return (await deps.request(url)).text();
|
|
4019
|
+
}
|
|
4020
|
+
async function postForm(deps, action, fields2) {
|
|
4021
|
+
const response = await deps.request(action, {
|
|
4022
|
+
method: "POST",
|
|
4023
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
4024
|
+
body: new URLSearchParams(fields2).toString()
|
|
4025
|
+
});
|
|
4026
|
+
const html = await response.text();
|
|
4027
|
+
return landedOnView(response.url) ? null : html;
|
|
4028
|
+
}
|
|
4029
|
+
function landedOnView(url) {
|
|
4030
|
+
try {
|
|
4031
|
+
const parsed = new URL(url);
|
|
4032
|
+
return parsed.pathname.endsWith(ASSIGN_VIEW_PATH) && (parsed.searchParams.get("action") ?? "view") === "view";
|
|
4033
|
+
} catch {
|
|
4034
|
+
return false;
|
|
4035
|
+
}
|
|
4036
|
+
}
|
|
4037
|
+
async function draftAjax(deps, form, action, params) {
|
|
4038
|
+
const body = new URLSearchParams({ sesskey: form.sesskey, client_id: form.clientId, itemid: form.itemid, ...params });
|
|
4039
|
+
const response = await deps.request(`${deps.baseUrl}/repository/draftfiles_ajax.php?action=${action}`, {
|
|
4040
|
+
method: "POST",
|
|
4041
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
4042
|
+
body: body.toString()
|
|
4043
|
+
}, { allowErrorStatus: true });
|
|
4044
|
+
return jsonOf(deps, response, `draft file ${action}`);
|
|
4045
|
+
}
|
|
4046
|
+
async function listDraftFiles(deps, form) {
|
|
4047
|
+
const data = record2(await draftAjax(deps, form, "list", { filepath: "/" }));
|
|
4048
|
+
const list2 = Array.isArray(data.list) ? data.list.map(record2) : [];
|
|
4049
|
+
return list2.filter((item) => item.type !== "folder").map((item) => ({ name: String(item.filename ?? item.fullname ?? ""), path: String(item.filepath ?? "/"), bytes: integer(item.size) })).filter((item) => item.name);
|
|
4050
|
+
}
|
|
4051
|
+
async function deleteDraftFiles(deps, form, files) {
|
|
4052
|
+
const selected = JSON.stringify(files.map((file2) => ({ filename: file2.name, filepath: file2.path })));
|
|
4053
|
+
const result = await draftAjax(deps, form, "deleteselected", { selected });
|
|
4054
|
+
if (result === false) throw deps.fail("Moodle did not remove the existing submission files.");
|
|
4055
|
+
}
|
|
4056
|
+
async function uploadDraftFile(deps, form, file2) {
|
|
4057
|
+
const body = new FormData();
|
|
4058
|
+
body.set("sesskey", form.sesskey);
|
|
4059
|
+
body.set("client_id", form.clientId);
|
|
4060
|
+
body.set("repo_id", form.repoId);
|
|
4061
|
+
body.set("itemid", form.itemid);
|
|
4062
|
+
body.set("env", "filemanager");
|
|
4063
|
+
body.set("ctx_id", form.contextId);
|
|
4064
|
+
body.set("title", file2.name);
|
|
4065
|
+
body.set("author", form.author);
|
|
4066
|
+
body.set("license", form.license);
|
|
4067
|
+
body.set("savepath", "/");
|
|
4068
|
+
body.set("maxbytes", String(form.maxBytes));
|
|
4069
|
+
body.set("areamaxbytes", String(form.areaMaxBytes));
|
|
4070
|
+
for (const type of form.acceptedTypes === "*" ? ["*"] : form.acceptedTypes) body.append("accepted_types[]", type);
|
|
4071
|
+
body.set("overwrite", "1");
|
|
4072
|
+
body.set("repo_upload_file", new Blob([Uint8Array.from(file2.bytes)]), file2.name);
|
|
4073
|
+
const response = await deps.request(`${deps.baseUrl}/repository/repository_ajax.php?action=upload`, { method: "POST", body }, { allowErrorStatus: true });
|
|
4074
|
+
const data = record2(await jsonOf(deps, response, `upload of ${file2.name}`));
|
|
4075
|
+
if (typeof data.error === "string" && data.error) throw deps.fail(`Moodle refused ${file2.name}: ${data.error}`, typeof data.errorcode === "string" ? data.errorcode : void 0);
|
|
4076
|
+
if (data.event === "fileexists") throw deps.fail(`Moodle reports ${file2.name} already exists and did not overwrite it.`);
|
|
4077
|
+
const stored = typeof data.file === "string" && data.file ? data.file : file2.name;
|
|
4078
|
+
if (!data.url && !data.id && !data.file) throw deps.fail(`Moodle did not confirm the upload of ${file2.name}.`);
|
|
4079
|
+
return stored;
|
|
4080
|
+
}
|
|
4081
|
+
async function jsonOf(deps, response, step) {
|
|
4082
|
+
const text2 = await response.text();
|
|
4083
|
+
try {
|
|
4084
|
+
return JSON.parse(text2);
|
|
4085
|
+
} catch {
|
|
4086
|
+
const notice = noticesOf(text2);
|
|
4087
|
+
throw deps.fail(`Moodle did not answer the ${step} with JSON (HTTP ${response.status})${notice ? `: ${notice}` : ""}`);
|
|
4088
|
+
}
|
|
4089
|
+
}
|
|
4090
|
+
function checkLimits(deps, form, kept, files) {
|
|
4091
|
+
if (form.maxFiles > 0 && kept.length + files.length > form.maxFiles) {
|
|
4092
|
+
throw deps.usage(`This assignment allows ${form.maxFiles} file${form.maxFiles === 1 ? "" : "s"}; the submission would hold ${kept.length + files.length}.`, kept.length ? "Use --replace to drop the existing files first." : void 0);
|
|
4093
|
+
}
|
|
4094
|
+
for (const file2 of files) {
|
|
4095
|
+
if (form.maxBytes > 0 && file2.bytes.byteLength > form.maxBytes) throw deps.usage(`${file2.name} is ${size(file2.bytes.byteLength)}; the limit is ${size(form.maxBytes)}.`);
|
|
4096
|
+
if (form.acceptedTypes !== "*" && form.acceptedTypes.every((type) => type.startsWith(".")) && !form.acceptedTypes.some((type) => file2.name.toLowerCase().endsWith(type.toLowerCase()))) {
|
|
4097
|
+
throw deps.usage(`${file2.name} is not an accepted type; allowed: ${form.acceptedTypes.join(", ")}.`);
|
|
4098
|
+
}
|
|
4099
|
+
}
|
|
4100
|
+
const total = kept.reduce((sum, file2) => sum + file2.bytes, 0) + files.reduce((sum, file2) => sum + file2.bytes.byteLength, 0);
|
|
4101
|
+
if (form.areaMaxBytes > 0 && total > form.areaMaxBytes) throw deps.usage(`The submission would total ${size(total)}; the limit is ${size(form.areaMaxBytes)}.`, kept.length ? "Use --replace to drop the existing files first." : void 0);
|
|
4102
|
+
}
|
|
4103
|
+
function describeLimits(form) {
|
|
4104
|
+
return {
|
|
4105
|
+
...form.maxBytes > 0 ? { max_bytes: form.maxBytes } : {},
|
|
4106
|
+
...form.maxFiles > 0 ? { max_files: form.maxFiles } : {},
|
|
4107
|
+
...form.areaMaxBytes > 0 ? { area_max_bytes: form.areaMaxBytes } : {},
|
|
4108
|
+
...form.acceptedTypes !== "*" ? { accepted_types: form.acceptedTypes } : {}
|
|
4109
|
+
};
|
|
4110
|
+
}
|
|
4111
|
+
function isSubmitted(status) {
|
|
4112
|
+
return /\bsubmitted\b/iu.test(status) && !/\bnot submitted\b/iu.test(status);
|
|
4113
|
+
}
|
|
4114
|
+
function size(bytes) {
|
|
4115
|
+
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
|
|
4116
|
+
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
|
|
4117
|
+
return `${bytes} B`;
|
|
4118
|
+
}
|
|
4119
|
+
function timestamp(deps) {
|
|
4120
|
+
return (deps.now?.() ?? /* @__PURE__ */ new Date()).toISOString();
|
|
4121
|
+
}
|
|
4122
|
+
function integer(value) {
|
|
4123
|
+
const number = Number(value);
|
|
4124
|
+
return Number.isSafeInteger(number) ? number : 0;
|
|
4125
|
+
}
|
|
4126
|
+
function isRecord4(value) {
|
|
4127
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4128
|
+
}
|
|
4129
|
+
function record2(value) {
|
|
4130
|
+
return isRecord4(value) ? value : {};
|
|
4131
|
+
}
|
|
4132
|
+
|
|
4133
|
+
// src/parsers.ts
|
|
4134
|
+
function schema(parser) {
|
|
4135
|
+
return { parse: parser };
|
|
4136
|
+
}
|
|
4137
|
+
var UserInfoSchema = schema(parseUserInfo);
|
|
4138
|
+
var CourseSchema = schema(parseCourse);
|
|
4139
|
+
var CoursesSchema = schema(parseCourses);
|
|
4140
|
+
var ActivitySchema = schema(parseActivity);
|
|
4141
|
+
var SectionSchema = schema(parseSection);
|
|
4142
|
+
var CourseContentsSchema = schema(parseCourseContents);
|
|
4143
|
+
var TodoItemSchema = schema(parseTodoItem);
|
|
4144
|
+
function parseUserInfo(value) {
|
|
4145
|
+
const data = asRecord(value);
|
|
4146
|
+
return {
|
|
4147
|
+
userid: numberValue2(data.userid),
|
|
4148
|
+
username: stringValue2(data.username),
|
|
4149
|
+
fullname: stringValue2(data.fullname),
|
|
4150
|
+
sitename: stringValue2(data.sitename),
|
|
4151
|
+
siteurl: stringValue2(data.siteurl),
|
|
4152
|
+
lang: stringValue2(data.lang),
|
|
4153
|
+
...data.timezone ? { timezone: String(data.timezone) } : {}
|
|
4154
|
+
};
|
|
4155
|
+
}
|
|
4156
|
+
function parseCourse(value, nowSeconds = Math.floor(Date.now() / 1e3)) {
|
|
4157
|
+
const data = asRecord(value);
|
|
4158
|
+
const course = {
|
|
4159
|
+
id: numberValue2(data.id),
|
|
4160
|
+
shortname: stringValue2(data.shortname),
|
|
4161
|
+
fullname: stringValue2(data.fullname),
|
|
4162
|
+
category: numberValue2(data.category),
|
|
4163
|
+
visible: booleanValue(data.visible, true),
|
|
4164
|
+
startdate: numberValue2(data.startdate)
|
|
4165
|
+
};
|
|
4166
|
+
const enddate = numberValue2(data.enddate);
|
|
4167
|
+
if (enddate > 0) {
|
|
4168
|
+
course.enddate = enddate;
|
|
4169
|
+
}
|
|
4170
|
+
return course;
|
|
4171
|
+
}
|
|
4172
|
+
function parseCourses(value) {
|
|
4173
|
+
return asArray(value).map((item) => parseCourse(item));
|
|
4174
|
+
}
|
|
4175
|
+
function parseActivity(value) {
|
|
4176
|
+
const data = asRecord(value);
|
|
4177
|
+
return {
|
|
4178
|
+
id: numberValue2(data.id),
|
|
4179
|
+
name: stringValue2(data.name),
|
|
4180
|
+
modname: stringValue2(data.modname),
|
|
4181
|
+
url: stringValue2(data.url),
|
|
4182
|
+
visible: booleanValue(data.visible, true),
|
|
4183
|
+
description: stringValue2(data.description),
|
|
4184
|
+
...data.completiondata && typeof data.completiondata === "object" ? { completion: numberValue2(asRecord(data.completiondata).state) } : {},
|
|
4185
|
+
...Array.isArray(data.contents) ? { file_entries: data.contents.filter((f) => asRecord(f).fileurl).map((f) => ({ name: stringValue2(asRecord(f).filename), url: stringValue2(asRecord(f).fileurl), requires_authentication: true })) } : {}
|
|
4186
|
+
};
|
|
4187
|
+
}
|
|
4188
|
+
function parseSection(value) {
|
|
4189
|
+
const data = asRecord(value);
|
|
4190
|
+
return {
|
|
4191
|
+
id: numberValue2(data.id),
|
|
4192
|
+
name: stringValue2(data.name),
|
|
4193
|
+
section: numberValue2(data.section),
|
|
4194
|
+
visible: booleanValue(data.visible, true),
|
|
4195
|
+
summary: stringValue2(data.summary),
|
|
4196
|
+
...data.current !== void 0 ? { current: booleanValue(data.current) } : {},
|
|
4197
|
+
activities: asArray(data.modules).map((item) => parseActivity(item))
|
|
4198
|
+
};
|
|
4199
|
+
}
|
|
4200
|
+
function parseCourseContents(value) {
|
|
4201
|
+
return asArray(value).map((item) => parseSection(item));
|
|
4202
|
+
}
|
|
4203
|
+
function parseCourseFormatState(value, baseUrl) {
|
|
4204
|
+
const state = asRecord(parseJsonValue(value));
|
|
4205
|
+
const activities = /* @__PURE__ */ new Map();
|
|
4206
|
+
const activitiesBySection = /* @__PURE__ */ new Map();
|
|
4207
|
+
for (const item of asArray(state.cm)) {
|
|
4208
|
+
const data = asRecord(item);
|
|
4209
|
+
const id2 = numberValue2(data.id);
|
|
4210
|
+
const sectionId = stringValue2(data.sectionid);
|
|
4211
|
+
const module = stringValue2(data.module) || stringValue2(data.plugin).replace(/^mod_/u, "") || stringValue2(data.modname).toLowerCase();
|
|
4212
|
+
const activity = {
|
|
4213
|
+
id: id2,
|
|
4214
|
+
name: htmlText(data.name, baseUrl),
|
|
4215
|
+
modname: module.toLowerCase(),
|
|
4216
|
+
url: stringValue2(data.url) ? resolveUrl(baseUrl, stringValue2(data.url)) : "",
|
|
4217
|
+
visible: booleanValue(data.visible, true) && booleanValue(data.uservisible, true) && !booleanValue(data.stealth),
|
|
4218
|
+
description: htmlText(data.content ?? data.description, baseUrl),
|
|
4219
|
+
...data.completionstate !== void 0 && data.completionstate !== null ? { completion: numberValue2(data.completionstate) } : {}
|
|
4220
|
+
};
|
|
4221
|
+
activities.set(String(id2), activity);
|
|
4222
|
+
const sectionActivities = activitiesBySection.get(sectionId) ?? [];
|
|
4223
|
+
sectionActivities.push(activity);
|
|
4224
|
+
activitiesBySection.set(sectionId, sectionActivities);
|
|
4225
|
+
}
|
|
4226
|
+
return asArray(state.section).map((item) => {
|
|
4227
|
+
const data = asRecord(item);
|
|
4228
|
+
const id2 = numberValue2(data.id);
|
|
4229
|
+
const hasActivityList = Array.isArray(data.cmlist);
|
|
4230
|
+
const listedActivities = asArray(data.cmlist).map((activityId) => activities.get(stringValue2(activityId))).filter((activity) => activity !== void 0);
|
|
4231
|
+
return {
|
|
4232
|
+
id: id2,
|
|
4233
|
+
name: htmlText(data.title || data.rawtitle, baseUrl),
|
|
4234
|
+
section: numberValue2(data.section ?? data.number),
|
|
4235
|
+
visible: booleanValue(data.visible, true),
|
|
4236
|
+
summary: htmlText(data.summary, baseUrl),
|
|
4237
|
+
...data.current !== void 0 ? { current: booleanValue(data.current) } : {},
|
|
4238
|
+
activities: hasActivityList ? listedActivities : activitiesBySection.get(String(id2)) ?? []
|
|
4239
|
+
};
|
|
4240
|
+
});
|
|
3865
4241
|
}
|
|
3866
|
-
function
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
4242
|
+
function parseTodoItem(value) {
|
|
4243
|
+
const data = asRecord(value);
|
|
4244
|
+
const course = asRecord(data.course);
|
|
4245
|
+
const action = asRecord(data.action);
|
|
4246
|
+
const progress = course.progress;
|
|
4247
|
+
return {
|
|
4248
|
+
id: numberValue2(data.id),
|
|
4249
|
+
name: stringValue2(data.name),
|
|
4250
|
+
activity_name: stringValue2(data.activityname),
|
|
4251
|
+
modname: stringValue2(data.modulename),
|
|
4252
|
+
course_id: numberValue2(course.id),
|
|
4253
|
+
course_name: stringValue2(course.fullname),
|
|
4254
|
+
due_at: numberValue2(data.timesort) || numberValue2(data.timestart),
|
|
4255
|
+
overdue: booleanValue(data.overdue),
|
|
4256
|
+
actionable: booleanValue(action.actionable),
|
|
4257
|
+
action_name: stringValue2(action.name),
|
|
4258
|
+
action_url: stringValue2(action.url),
|
|
4259
|
+
url: stringValue2(data.url),
|
|
4260
|
+
event_type: stringValue2(data.eventtype),
|
|
4261
|
+
course_progress: typeof progress === "number" ? progress : void 0
|
|
4262
|
+
};
|
|
3872
4263
|
}
|
|
3873
|
-
function
|
|
3874
|
-
|
|
3875
|
-
if (!value || !/^\d+$/.test(value)) {
|
|
3876
|
-
return null;
|
|
3877
|
-
}
|
|
3878
|
-
return Number(value);
|
|
4264
|
+
function parseTodoItems(value) {
|
|
4265
|
+
return asArray(value).map((item) => parseTodoItem(item));
|
|
3879
4266
|
}
|
|
3880
|
-
function
|
|
3881
|
-
const
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
4267
|
+
function parseAlertNotification(value) {
|
|
4268
|
+
const data = asRecord(value);
|
|
4269
|
+
return {
|
|
4270
|
+
id: numberValue2(data.id),
|
|
4271
|
+
subject: stringValue2(data.subject),
|
|
4272
|
+
short_subject: stringValue2(data.shortenedsubject),
|
|
4273
|
+
event_type: stringValue2(data.eventtype),
|
|
4274
|
+
component: stringValue2(data.component),
|
|
4275
|
+
created_at: numberValue2(data.timecreated),
|
|
4276
|
+
created_pretty: stringValue2(data.timecreatedpretty),
|
|
4277
|
+
read: booleanValue(data.read),
|
|
4278
|
+
context_url: stringValue2(data.contexturl),
|
|
4279
|
+
context_name: stringValue2(data.contexturlname)
|
|
4280
|
+
};
|
|
3890
4281
|
}
|
|
3891
|
-
function
|
|
3892
|
-
const
|
|
3893
|
-
|
|
4282
|
+
function parseAlertSummary(notificationsData, countsData, unreadCountsData) {
|
|
4283
|
+
const notificationsRecord = asRecord(notificationsData);
|
|
4284
|
+
const counts2 = asRecord(countsData);
|
|
4285
|
+
const unreadCounts = asRecord(unreadCountsData);
|
|
4286
|
+
const types = asRecord(counts2.types);
|
|
4287
|
+
const unreadTypes = asRecord(unreadCounts.types);
|
|
4288
|
+
const notifications = asArray(notificationsRecord.notifications).map((item) => parseAlertNotification(item));
|
|
4289
|
+
return {
|
|
4290
|
+
notifications,
|
|
4291
|
+
notification_count: notifications.length,
|
|
4292
|
+
unread_notification_count: notifications.filter((notification) => !notification.read).length,
|
|
4293
|
+
starred_message_count: numberValue2(counts2.favourites),
|
|
4294
|
+
direct_message_count: numberValue2(types["1"]),
|
|
4295
|
+
group_message_count: numberValue2(types["2"]),
|
|
4296
|
+
self_message_count: numberValue2(types["3"]),
|
|
4297
|
+
unread_starred_message_count: numberValue2(unreadCounts.favourites),
|
|
4298
|
+
unread_direct_message_count: numberValue2(unreadTypes["1"]),
|
|
4299
|
+
unread_group_message_count: numberValue2(unreadTypes["2"]),
|
|
4300
|
+
unread_self_message_count: numberValue2(unreadTypes["3"])
|
|
4301
|
+
};
|
|
3894
4302
|
}
|
|
3895
|
-
function
|
|
3896
|
-
|
|
4303
|
+
function parseForumPostAuthor(value) {
|
|
4304
|
+
const data = asRecord(value);
|
|
4305
|
+
const urls = asRecord(data.urls);
|
|
4306
|
+
return {
|
|
4307
|
+
id: numberValue2(data.id),
|
|
4308
|
+
fullname: stringValue2(data.fullname),
|
|
4309
|
+
profile_url: stringValue2(urls.profile),
|
|
4310
|
+
profile_image_url: stringValue2(urls.profileimage)
|
|
4311
|
+
};
|
|
3897
4312
|
}
|
|
3898
|
-
function
|
|
3899
|
-
const
|
|
3900
|
-
const
|
|
3901
|
-
const
|
|
3902
|
-
const
|
|
3903
|
-
|
|
3904
|
-
|
|
3905
|
-
|
|
3906
|
-
|
|
3907
|
-
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
|
|
3914
|
-
|
|
3915
|
-
|
|
4313
|
+
function parseForumPost(value, baseUrl = "") {
|
|
4314
|
+
const data = asRecord(value);
|
|
4315
|
+
const urls = asRecord(data.urls);
|
|
4316
|
+
const messageHtml = stringValue2(data.message);
|
|
4317
|
+
const structured = htmlToStructuredContent(messageHtml, stringValue2(urls.view || urls.discuss) || baseUrl);
|
|
4318
|
+
return {
|
|
4319
|
+
id: numberValue2(data.id),
|
|
4320
|
+
discussion_id: numberValue2(data.discussionid),
|
|
4321
|
+
subject: stringValue2(data.subject),
|
|
4322
|
+
message_html: messageHtml,
|
|
4323
|
+
message_text: structured.text,
|
|
4324
|
+
image_urls: structured.image_urls,
|
|
4325
|
+
links: structured.links,
|
|
4326
|
+
tables: structured.tables,
|
|
4327
|
+
author: parseForumPostAuthor(data.author),
|
|
4328
|
+
parent_id: numberValue2(data.parentid),
|
|
4329
|
+
time_created: numberValue2(data.timecreated),
|
|
4330
|
+
time_modified: numberValue2(data.timemodified),
|
|
4331
|
+
created_pretty: "",
|
|
4332
|
+
unread: booleanValue(data.unread),
|
|
4333
|
+
is_deleted: booleanValue(data.isdeleted),
|
|
4334
|
+
is_private_reply: booleanValue(data.isprivatereply),
|
|
4335
|
+
url: stringValue2(urls.view || urls.viewisolated),
|
|
4336
|
+
reply_url: stringValue2(urls.reply)
|
|
4337
|
+
};
|
|
3916
4338
|
}
|
|
3917
|
-
function
|
|
3918
|
-
const
|
|
3919
|
-
|
|
3920
|
-
|
|
3921
|
-
|
|
3922
|
-
|
|
3923
|
-
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
|
|
4339
|
+
function parseForumDiscussion(value, discussionId, baseUrl = "") {
|
|
4340
|
+
const data = asRecord(value);
|
|
4341
|
+
const posts = asArray(data.posts).map((item) => parseForumPost(item, baseUrl));
|
|
4342
|
+
return {
|
|
4343
|
+
id: discussionId,
|
|
4344
|
+
subject: posts[0]?.subject ?? "",
|
|
4345
|
+
course_id: numberValue2(data.courseid),
|
|
4346
|
+
forum_id: numberValue2(data.forumid),
|
|
4347
|
+
group_id: numberValue2(data.groupid),
|
|
4348
|
+
group_name: stringValue2(data.groupname),
|
|
4349
|
+
url: posts[0]?.url ? posts[0].url.split("#", 1)[0] : "",
|
|
4350
|
+
posts
|
|
4351
|
+
};
|
|
3927
4352
|
}
|
|
3928
|
-
function
|
|
3929
|
-
|
|
3930
|
-
for (const row of root.querySelectorAll("tr")) {
|
|
3931
|
-
const cells = row.querySelectorAll("th, td");
|
|
3932
|
-
if (cleanNodeText(cells[0]) === label) {
|
|
3933
|
-
return cleanTableCell(cells[1]);
|
|
3934
|
-
}
|
|
3935
|
-
}
|
|
3936
|
-
return "";
|
|
4353
|
+
function asRecord(value) {
|
|
4354
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
3937
4355
|
}
|
|
3938
|
-
function
|
|
3939
|
-
|
|
3940
|
-
return "";
|
|
3941
|
-
}
|
|
3942
|
-
const clone = parse3(node.toString());
|
|
3943
|
-
for (const unwanted of clone.querySelectorAll(".action-menu, .dropdown, script, style")) {
|
|
3944
|
-
unwanted.remove();
|
|
3945
|
-
}
|
|
3946
|
-
return cleanText(clone.textContent.replace("( Empty )", "(Empty)"));
|
|
4356
|
+
function asArray(value) {
|
|
4357
|
+
return Array.isArray(value) ? value : [];
|
|
3947
4358
|
}
|
|
3948
|
-
function
|
|
3949
|
-
|
|
3950
|
-
return numericQueryValue(new URL(href, "https://moodle.invalid"), key);
|
|
3951
|
-
} catch {
|
|
3952
|
-
return null;
|
|
3953
|
-
}
|
|
4359
|
+
function stringValue2(value) {
|
|
4360
|
+
return typeof value === "string" ? value : value == null ? "" : String(value);
|
|
3954
4361
|
}
|
|
3955
4362
|
function numberValue2(value) {
|
|
3956
4363
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
@@ -3961,18 +4368,22 @@ function numberValue2(value) {
|
|
|
3961
4368
|
}
|
|
3962
4369
|
return 0;
|
|
3963
4370
|
}
|
|
3964
|
-
function
|
|
3965
|
-
|
|
4371
|
+
function booleanValue(value, defaultValue = false) {
|
|
4372
|
+
if (value === void 0 || value === null) {
|
|
4373
|
+
return defaultValue;
|
|
4374
|
+
}
|
|
4375
|
+
return Boolean(value);
|
|
3966
4376
|
}
|
|
3967
|
-
function
|
|
3968
|
-
return
|
|
3969
|
-
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
|
|
4377
|
+
function parseJsonValue(value) {
|
|
4378
|
+
if (typeof value !== "string") return value;
|
|
4379
|
+
try {
|
|
4380
|
+
return JSON.parse(value);
|
|
4381
|
+
} catch {
|
|
4382
|
+
return {};
|
|
4383
|
+
}
|
|
3973
4384
|
}
|
|
3974
|
-
function
|
|
3975
|
-
return
|
|
4385
|
+
function htmlText(value, baseUrl) {
|
|
4386
|
+
return htmlToStructuredContent(stringValue2(value), baseUrl).text;
|
|
3976
4387
|
}
|
|
3977
4388
|
|
|
3978
4389
|
// src/moodle-forum-core.ts
|
|
@@ -4109,13 +4520,13 @@ var ForumModule = class {
|
|
|
4109
4520
|
}
|
|
4110
4521
|
};
|
|
4111
4522
|
function shouldFallbackForumAjax(error) {
|
|
4112
|
-
if (!
|
|
4523
|
+
if (!isRecord5(error)) {
|
|
4113
4524
|
return false;
|
|
4114
4525
|
}
|
|
4115
4526
|
const code = typeof error.moodleErrorCode === "string" ? error.moodleErrorCode : "";
|
|
4116
4527
|
return code === "servicenotavailable" || code === "accessexception" || error instanceof Error && error.message.includes("Web service is not available");
|
|
4117
4528
|
}
|
|
4118
|
-
function
|
|
4529
|
+
function isRecord5(value) {
|
|
4119
4530
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4120
4531
|
}
|
|
4121
4532
|
|
|
@@ -4247,9 +4658,9 @@ async function searchForumContent(source, query, options = {}) {
|
|
|
4247
4658
|
hits.sort(sortBy === "recent" ? sortRecent : sortRelevant);
|
|
4248
4659
|
return hits.slice(0, options.limit ?? 20).map(([, hit]) => includePostText ? hit : { ...hit, snippet: "" });
|
|
4249
4660
|
}
|
|
4250
|
-
async function inParallel2(items,
|
|
4661
|
+
async function inParallel2(items, size2, run) {
|
|
4251
4662
|
const results = [];
|
|
4252
|
-
for (let index = 0; index < items.length; index +=
|
|
4663
|
+
for (let index = 0; index < items.length; index += size2) results.push(...await Promise.all(items.slice(index, index + size2).map(run)));
|
|
4253
4664
|
return results;
|
|
4254
4665
|
}
|
|
4255
4666
|
function normalizeQuery(value) {
|
|
@@ -4425,7 +4836,7 @@ var MoodleClientCore = class {
|
|
|
4425
4836
|
await this.ensureSession();
|
|
4426
4837
|
return this.call(functionName, args);
|
|
4427
4838
|
},
|
|
4428
|
-
getPage: (
|
|
4839
|
+
getPage: (path5, params) => this.get(path5, params),
|
|
4429
4840
|
getCourses: () => this.getCourses(),
|
|
4430
4841
|
getCourseContents: (courseId) => this.getCourseContents(courseId)
|
|
4431
4842
|
});
|
|
@@ -4435,7 +4846,7 @@ var MoodleClientCore = class {
|
|
|
4435
4846
|
try {
|
|
4436
4847
|
if (this.unavailable.has(FUNC_GET_SITE_INFO) && this.userInfo?.fullname) return this.userInfo;
|
|
4437
4848
|
const data = await this.call(FUNC_GET_SITE_INFO);
|
|
4438
|
-
if (
|
|
4849
|
+
if (isRecord6(data) && "userid" in data) {
|
|
4439
4850
|
const info = parseUserInfo(data);
|
|
4440
4851
|
this.sesskey = typeof data.sesskey === "string" ? data.sesskey : this.sesskey;
|
|
4441
4852
|
this.userid = info.userid;
|
|
@@ -4534,9 +4945,9 @@ var MoodleClientCore = class {
|
|
|
4534
4945
|
let type = "";
|
|
4535
4946
|
try {
|
|
4536
4947
|
const data = await this.call(FUNC_GET_COURSE_MODULE, { cmid: id2 });
|
|
4537
|
-
const module =
|
|
4538
|
-
type =
|
|
4539
|
-
courseId =
|
|
4948
|
+
const module = isRecord6(data) && isRecord6(data.cm) ? data.cm : data;
|
|
4949
|
+
type = isRecord6(module) && typeof module.modname === "string" ? module.modname : "";
|
|
4950
|
+
courseId = isRecord6(module) && typeof module.course === "number" ? module.course : void 0;
|
|
4540
4951
|
} catch (error) {
|
|
4541
4952
|
if (!this.errors.isApi(error) || error.moodleErrorCode !== "servicenotavailable") {
|
|
4542
4953
|
throw error;
|
|
@@ -4580,14 +4991,14 @@ var MoodleClientCore = class {
|
|
|
4580
4991
|
} else {
|
|
4581
4992
|
data = await this.call(FUNC_GET_ACTION_EVENTS, { ...window, limittononsuspendedevents: true });
|
|
4582
4993
|
}
|
|
4583
|
-
const events =
|
|
4994
|
+
const events = isRecord6(data) && Array.isArray(data.events) ? data.events : [];
|
|
4584
4995
|
for (const item of parseTodoItems(events)) if (!seen.has(item.id)) {
|
|
4585
4996
|
seen.add(item.id);
|
|
4586
4997
|
items.push(item);
|
|
4587
4998
|
}
|
|
4588
4999
|
if (events.length < batchSize) break;
|
|
4589
5000
|
const last = events.at(-1);
|
|
4590
|
-
const next =
|
|
5001
|
+
const next = isRecord6(last) && typeof last.id === "number" ? last.id : void 0;
|
|
4591
5002
|
if (!next || next === aftereventid) throw this.errors.api("Moodle repeated a calendar page; refine the date window.");
|
|
4592
5003
|
aftereventid = next;
|
|
4593
5004
|
}
|
|
@@ -4754,8 +5165,18 @@ var MoodleClientCore = class {
|
|
|
4754
5165
|
async getFolder(id2) {
|
|
4755
5166
|
return parseFolderHtml(await this.get(FOLDER_VIEW_PATH, { id: id2 }), id2, this.baseUrl);
|
|
4756
5167
|
}
|
|
4757
|
-
async requestAbsolute(url, init = {}) {
|
|
4758
|
-
return this.requestAbsoluteInternal(url, init, true);
|
|
5168
|
+
async requestAbsolute(url, init = {}, options = {}) {
|
|
5169
|
+
return this.requestAbsoluteInternal(url, init, true, Boolean(options.allowErrorStatus));
|
|
5170
|
+
}
|
|
5171
|
+
/** Uploads files into an assignment and reads the receipt back from the site. */
|
|
5172
|
+
async submitAssignment(request) {
|
|
5173
|
+
await this.ensureSession();
|
|
5174
|
+
return submitAssignmentFiles({
|
|
5175
|
+
baseUrl: this.baseUrl,
|
|
5176
|
+
request: (url, init, options) => this.requestAbsolute(url, init, options),
|
|
5177
|
+
fail: (message, moodleErrorCode) => this.errors.api(message, moodleErrorCode),
|
|
5178
|
+
usage: (message, hint) => this.errors.usage ? this.errors.usage(message, hint) : new MoodleClientCoreError("usage", message, hint)
|
|
5179
|
+
}, request);
|
|
4759
5180
|
}
|
|
4760
5181
|
async getNewsForums(courseId) {
|
|
4761
5182
|
const units = courseId === void 0 ? await this.getCourses() : (await this.getCourses()).filter((c) => c.id === courseId);
|
|
@@ -4765,7 +5186,7 @@ var MoodleClientCore = class {
|
|
|
4765
5186
|
await this.ensureSession();
|
|
4766
5187
|
const data = await this.call("mod_forum_get_forums_by_courses", { courseids: units.map((c) => c.id) });
|
|
4767
5188
|
for (const f of Array.isArray(data) ? data : []) {
|
|
4768
|
-
if (!
|
|
5189
|
+
if (!isRecord6(f) || f.type !== "news" || typeof f.cmid !== "number") continue;
|
|
4769
5190
|
const c = units.find((c2) => c2.id === f.course);
|
|
4770
5191
|
forums.push({ id: f.cmid, name: String(f.name || ""), course_id: Number(f.course), course_name: c?.fullname || "", url: `${this.baseUrl}/mod/forum/view.php?id=${f.cmid}` });
|
|
4771
5192
|
}
|
|
@@ -4893,16 +5314,16 @@ var MoodleClientCore = class {
|
|
|
4893
5314
|
async getAbsolute(url) {
|
|
4894
5315
|
return (await this.requestAbsolute(url)).text();
|
|
4895
5316
|
}
|
|
4896
|
-
async requestAbsoluteInternal(url, init, allowRetry) {
|
|
5317
|
+
async requestAbsoluteInternal(url, init, allowRetry, allowErrorStatus = false) {
|
|
4897
5318
|
const response = await fetchWithSession(url, init, this.baseUrl, this.cookie, this.fetchImpl);
|
|
4898
5319
|
if (response.url.includes("/login/")) {
|
|
4899
5320
|
if (this.onLoginRequired && allowRetry && !this.retryingLogin) {
|
|
4900
5321
|
await this.reauthenticate();
|
|
4901
|
-
return this.requestAbsoluteInternal(url, init, false);
|
|
5322
|
+
return this.requestAbsoluteInternal(url, init, false, allowErrorStatus);
|
|
4902
5323
|
}
|
|
4903
5324
|
throw this.errors.api("Session expired", "servicerequireslogin");
|
|
4904
5325
|
}
|
|
4905
|
-
if (!response.ok) {
|
|
5326
|
+
if (!response.ok && !allowErrorStatus) {
|
|
4906
5327
|
const context = `HTTP ${response.status} loading ${safeUrl(url)}`;
|
|
4907
5328
|
const contentType3 = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
4908
5329
|
if (contentType3.includes("text/html") || contentType3.includes("application/xhtml+xml")) {
|
|
@@ -4920,7 +5341,7 @@ var MoodleClientCore = class {
|
|
|
4920
5341
|
let offset = 0;
|
|
4921
5342
|
while (true) {
|
|
4922
5343
|
const data = await this.call(FUNC_GET_COURSES_BY_TIMELINE, { classification: "all", limit: 100, offset });
|
|
4923
|
-
if (!
|
|
5344
|
+
if (!isRecord6(data) || !Array.isArray(data.courses) || !data.courses.length) {
|
|
4924
5345
|
break;
|
|
4925
5346
|
}
|
|
4926
5347
|
courses.push(...data.courses);
|
|
@@ -5080,19 +5501,19 @@ function placeholderUserInfo(baseUrl, userid) {
|
|
|
5080
5501
|
};
|
|
5081
5502
|
}
|
|
5082
5503
|
function parseTodoPayload(value) {
|
|
5083
|
-
return parseTodoItems(
|
|
5504
|
+
return parseTodoItems(isRecord6(value) && Array.isArray(value.events) ? value.events : []);
|
|
5084
5505
|
}
|
|
5085
5506
|
function errorMessage(value) {
|
|
5086
5507
|
return value instanceof Error ? value.message : "Unknown Moodle error";
|
|
5087
5508
|
}
|
|
5088
|
-
function chunks(values,
|
|
5509
|
+
function chunks(values, size2) {
|
|
5089
5510
|
const result = [];
|
|
5090
|
-
for (let index = 0; index < values.length; index +=
|
|
5091
|
-
result.push(values.slice(index, index +
|
|
5511
|
+
for (let index = 0; index < values.length; index += size2) {
|
|
5512
|
+
result.push(values.slice(index, index + size2));
|
|
5092
5513
|
}
|
|
5093
5514
|
return result;
|
|
5094
5515
|
}
|
|
5095
|
-
function
|
|
5516
|
+
function isRecord6(value) {
|
|
5096
5517
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
5097
5518
|
}
|
|
5098
5519
|
function isLoginErrorCode2(code) {
|
|
@@ -5114,10 +5535,30 @@ function safeUrl(value) {
|
|
|
5114
5535
|
}
|
|
5115
5536
|
}
|
|
5116
5537
|
|
|
5538
|
+
// src/submit.ts
|
|
5539
|
+
import { readFile as readFile6, stat as stat2 } from "fs/promises";
|
|
5540
|
+
import { homedir as homedir8 } from "os";
|
|
5541
|
+
import path from "path";
|
|
5542
|
+
function resolveSubmissionPath(given, cwd = process.cwd()) {
|
|
5543
|
+
return path.resolve(cwd, given.startsWith("~/") ? path.join(homedir8(), given.slice(2)) : given);
|
|
5544
|
+
}
|
|
5545
|
+
async function readSubmissionFiles(paths, cwd = process.cwd()) {
|
|
5546
|
+
const files = [];
|
|
5547
|
+
for (const given of paths) {
|
|
5548
|
+
const resolved = resolveSubmissionPath(given, cwd);
|
|
5549
|
+
const info = await stat2(resolved).catch(() => null);
|
|
5550
|
+
if (!info) throw new UsageError(`File not found: ${given}`);
|
|
5551
|
+
if (!info.isFile()) throw new UsageError(`Not a file: ${given}`, "Zip a folder before uploading it.");
|
|
5552
|
+
files.push({ name: path.basename(resolved), bytes: await readFile6(resolved), path: resolved });
|
|
5553
|
+
}
|
|
5554
|
+
return files;
|
|
5555
|
+
}
|
|
5556
|
+
|
|
5117
5557
|
// src/client.ts
|
|
5118
5558
|
var NODE_ERROR_ADAPTER = {
|
|
5119
5559
|
api: (message, moodleErrorCode) => new MoodleAPIError(message, moodleErrorCode),
|
|
5120
5560
|
notFound: (message) => new NotFoundError(message),
|
|
5561
|
+
usage: (message, hint) => new UsageError(message, hint),
|
|
5121
5562
|
isApi: (error) => error instanceof MoodleAPIError,
|
|
5122
5563
|
isLoginRequired: isLoginRequiredError
|
|
5123
5564
|
};
|
|
@@ -5126,6 +5567,11 @@ var MoodleClient = class extends MoodleClientCore {
|
|
|
5126
5567
|
const resolvedOptions = typeof options === "string" ? { cookie: { name: "MoodleSession", value: options } } : options;
|
|
5127
5568
|
super(baseUrl, { ...resolvedOptions, errorAdapter: NODE_ERROR_ADAPTER });
|
|
5128
5569
|
}
|
|
5570
|
+
/** Reads local files, then uploads them into the assignment. Only the Node client has a filesystem. */
|
|
5571
|
+
async submitAssignmentFiles(request) {
|
|
5572
|
+
const { files, cwd, ...rest } = request;
|
|
5573
|
+
return this.submitAssignment({ ...rest, files: await readSubmissionFiles(files, cwd) });
|
|
5574
|
+
}
|
|
5129
5575
|
};
|
|
5130
5576
|
async function createMoodleClient(baseUrl, options = {}) {
|
|
5131
5577
|
const cacheOptions2 = {
|
|
@@ -5234,6 +5680,35 @@ function formatDownloadReceipt(receipt) {
|
|
|
5234
5680
|
["Final URL", receipt.final_url]
|
|
5235
5681
|
], { title: "Download" });
|
|
5236
5682
|
}
|
|
5683
|
+
function formatSubmissionReceipt(receipt) {
|
|
5684
|
+
const size2 = (bytes) => bytes >= 1024 * 1024 ? `${(bytes / (1024 * 1024)).toFixed(1)} MiB` : bytes >= 1024 ? `${(bytes / 1024).toFixed(1)} KiB` : `${bytes} B`;
|
|
5685
|
+
const limits = [
|
|
5686
|
+
receipt.limits.max_files ? `${receipt.limits.max_files} files` : "",
|
|
5687
|
+
receipt.limits.max_bytes ? `${size2(receipt.limits.max_bytes)} each` : "",
|
|
5688
|
+
receipt.limits.area_max_bytes ? `${size2(receipt.limits.area_max_bytes)} total` : "",
|
|
5689
|
+
receipt.limits.accepted_types?.length ? receipt.limits.accepted_types.join(" ") : ""
|
|
5690
|
+
].filter(Boolean).join(", ");
|
|
5691
|
+
const table = renderKeyValueTable([
|
|
5692
|
+
["Assignment", receipt.name],
|
|
5693
|
+
["Unit id", receipt.unit_id ? String(receipt.unit_id) : ""],
|
|
5694
|
+
["URL", receipt.url],
|
|
5695
|
+
["Action", receipt.action],
|
|
5696
|
+
["Status", receipt.submission_status],
|
|
5697
|
+
["Grading", receipt.grading_status],
|
|
5698
|
+
["Due", receipt.due],
|
|
5699
|
+
["Time remaining", receipt.time_remaining],
|
|
5700
|
+
["Last modified", receipt.last_modified],
|
|
5701
|
+
[receipt.action === "planned" ? "Files now" : "Files", receipt.files.map((file2) => file2.bytes ? `${file2.name} (${size2(file2.bytes)})` : file2.name).join(", ")],
|
|
5702
|
+
["Uploads", receipt.uploads.map((file2) => `${file2.name} (${size2(file2.bytes)})`).join(", ")],
|
|
5703
|
+
["Removed", receipt.removed.join(", ")],
|
|
5704
|
+
["Statement", receipt.statement ? `${receipt.statement_accepted ? "accepted" : "not accepted"}: ${receipt.statement}` : ""],
|
|
5705
|
+
["Limits", limits],
|
|
5706
|
+
["Checked", receipt.checked_at]
|
|
5707
|
+
], { title: receipt.action === "planned" ? "Submission plan" : "Submission" });
|
|
5708
|
+
const note = receipt.action === "planned" ? "Plan only; nothing was uploaded. Re-run without --dry-run to upload." : receipt.action === "saved" && /draft|not submitted/iu.test(receipt.submission_status) ? "Saved as a draft. Re-run with --final to submit it for grading." : "";
|
|
5709
|
+
return note ? `${table}
|
|
5710
|
+
${note}` : table;
|
|
5711
|
+
}
|
|
5237
5712
|
function formatForumDiscussion(discussion, options = {}) {
|
|
5238
5713
|
const lines = [`Discussion: ${discussion.id}`];
|
|
5239
5714
|
if (discussion.subject) {
|
|
@@ -5351,10 +5826,10 @@ function formatTimestamp(value) {
|
|
|
5351
5826
|
import { createWriteStream } from "fs";
|
|
5352
5827
|
import { link, lstat, rename as rename2, unlink } from "fs/promises";
|
|
5353
5828
|
import { randomUUID } from "crypto";
|
|
5354
|
-
import
|
|
5829
|
+
import path2 from "path";
|
|
5355
5830
|
import { Readable, Transform } from "stream";
|
|
5356
5831
|
import { pipeline } from "stream/promises";
|
|
5357
|
-
import { parse as
|
|
5832
|
+
import { parse as parse5 } from "node-html-parser";
|
|
5358
5833
|
var ACCEPTED_SOURCE_HINT = "Use a positive resource activity ID, a same-site resource URL, or a same-site pluginfile URL.";
|
|
5359
5834
|
var FILE_SYSTEM_ERROR_CODES = /* @__PURE__ */ new Set([
|
|
5360
5835
|
"EACCES",
|
|
@@ -5374,14 +5849,14 @@ var FILE_SYSTEM_ERROR_CODES = /* @__PURE__ */ new Set([
|
|
|
5374
5849
|
]);
|
|
5375
5850
|
async function downloadMoodleFile(client, request, signal) {
|
|
5376
5851
|
throwIfCancelled(signal);
|
|
5377
|
-
const explicitDestination = request.destination ?
|
|
5852
|
+
const explicitDestination = request.destination ? path2.resolve(request.destination) : void 0;
|
|
5378
5853
|
if (explicitDestination && !request.force) {
|
|
5379
5854
|
await ensureDestinationAvailable(explicitDestination);
|
|
5380
5855
|
}
|
|
5381
5856
|
const resolved = await resolveDownload(client, request.source, signal);
|
|
5382
5857
|
throwIfCancelled(signal);
|
|
5383
|
-
const filename = explicitDestination ?
|
|
5384
|
-
const destination = explicitDestination ??
|
|
5858
|
+
const filename = explicitDestination ? path2.basename(explicitDestination) : chooseUpstreamFilename(resolved);
|
|
5859
|
+
const destination = explicitDestination ?? path2.resolve(request.directory ?? process.cwd(), filename);
|
|
5385
5860
|
if (!explicitDestination && !request.force) {
|
|
5386
5861
|
await ensureDestinationAvailable(destination);
|
|
5387
5862
|
}
|
|
@@ -5476,7 +5951,7 @@ async function responseOrWrapper(client, requestUrl, sourceUrl, targetName, sign
|
|
|
5476
5951
|
};
|
|
5477
5952
|
}
|
|
5478
5953
|
function resourceLinks2(html, baseUrl) {
|
|
5479
|
-
const root =
|
|
5954
|
+
const root = parse5(html);
|
|
5480
5955
|
const entries = root.querySelectorAll(".resourceworkaround a[href], .resourcecontent a[href], a.resourceworkaround[href]").map((linkNode) => ({
|
|
5481
5956
|
name: linkNode.textContent.trim(),
|
|
5482
5957
|
url: new URL(linkNode.getAttribute("href") ?? "", baseUrl).toString()
|
|
@@ -5492,7 +5967,7 @@ function isHtmlWrapper(response) {
|
|
|
5492
5967
|
return type.includes("text/html") || type.includes("application/xhtml+xml");
|
|
5493
5968
|
}
|
|
5494
5969
|
function looksLikeLoginPage3(html) {
|
|
5495
|
-
const root =
|
|
5970
|
+
const root = parse5(html);
|
|
5496
5971
|
return root.querySelector('form[action*="/login/"], input[name="password"], #page-login-index') !== null || /<title>\s*(?:log in|login)/iu.test(html);
|
|
5497
5972
|
}
|
|
5498
5973
|
function chooseUpstreamFilename(resolved) {
|
|
@@ -5553,7 +6028,7 @@ async function ensureDestinationAvailable(destination) {
|
|
|
5553
6028
|
throw new UsageError(`Destination already exists: ${destination}`, "Choose another --dest path or pass --force to replace this exact file.");
|
|
5554
6029
|
}
|
|
5555
6030
|
async function writeResponse(response, destination, force, signal) {
|
|
5556
|
-
const temporaryPath =
|
|
6031
|
+
const temporaryPath = path2.join(path2.dirname(destination), `.${path2.basename(destination)}.moodle-${randomUUID()}.tmp`);
|
|
5557
6032
|
let bytesWritten = 0;
|
|
5558
6033
|
const counter = new Transform({
|
|
5559
6034
|
transform(chunk, _encoding, callback) {
|
|
@@ -5625,7 +6100,7 @@ function isFileSystemError(error) {
|
|
|
5625
6100
|
// src/skills.ts
|
|
5626
6101
|
import { spawnSync as spawnSync3 } from "child_process";
|
|
5627
6102
|
import { mkdirSync, readFileSync, writeFileSync, rmSync } from "fs";
|
|
5628
|
-
import
|
|
6103
|
+
import path3 from "path";
|
|
5629
6104
|
|
|
5630
6105
|
// src/command-contract.ts
|
|
5631
6106
|
import { VERBS } from "@bunizao/cli-kit";
|
|
@@ -5682,7 +6157,7 @@ function isMutating(command) {
|
|
|
5682
6157
|
var SKILL_NAME = "moodle-cli";
|
|
5683
6158
|
var SKILL_SOURCE = "https://github.com/bunizao/moodle-cli";
|
|
5684
6159
|
var SKILLS_SPEC_URL = "https://github.com/vercel-labs/skills";
|
|
5685
|
-
var SKILL_DESCRIPTION = "Read Moodle units, deadlines, grades, announcements and files; diagnose sign-in and manage a private MCP server.";
|
|
6160
|
+
var SKILL_DESCRIPTION = "Read Moodle units, deadlines, grades, announcements and files; submit assignment files; diagnose sign-in and manage a private MCP server.";
|
|
5686
6161
|
var SKILL_BUNDLE_TEMPLATES = [
|
|
5687
6162
|
["SKILL.md", "skill.template.md"],
|
|
5688
6163
|
["references/setup-and-auth.md", "skill-references/setup-and-auth.md"],
|
|
@@ -5729,10 +6204,10 @@ function extractCommanderCommands(program) {
|
|
|
5729
6204
|
return describeProgram(program).commands.flatMap((command) => commandDescriptionRows(command));
|
|
5730
6205
|
}
|
|
5731
6206
|
function commandDescriptionRows(command, parentPath = []) {
|
|
5732
|
-
const
|
|
6207
|
+
const path5 = [...parentPath, command.name];
|
|
5733
6208
|
const row = {
|
|
5734
6209
|
name: command.name,
|
|
5735
|
-
path:
|
|
6210
|
+
path: path5,
|
|
5736
6211
|
description: command.description,
|
|
5737
6212
|
arguments: command.positionals.map((argument) => ({
|
|
5738
6213
|
name: argument.name,
|
|
@@ -5746,15 +6221,15 @@ function commandDescriptionRows(command, parentPath = []) {
|
|
|
5746
6221
|
return { name, alias, description: option.description, required: option.required };
|
|
5747
6222
|
})
|
|
5748
6223
|
};
|
|
5749
|
-
return [row, ...command.commands.flatMap((child) => commandDescriptionRows(child,
|
|
6224
|
+
return [row, ...command.commands.flatMap((child) => commandDescriptionRows(child, path5))];
|
|
5750
6225
|
}
|
|
5751
6226
|
function writeGeneratedSkill(program, target = "SKILL.md") {
|
|
5752
6227
|
const commands = extractCommanderCommands(program);
|
|
5753
|
-
const targetDir =
|
|
5754
|
-
for (const obsolete of ["profile-and-courses", "deadlines-and-alerts", "coursework-and-grades", "downloads", "forums", "output-and-errors", "maintenance"]) rmSync(
|
|
6228
|
+
const targetDir = path3.dirname(target);
|
|
6229
|
+
for (const obsolete of ["profile-and-courses", "deadlines-and-alerts", "coursework-and-grades", "downloads", "forums", "output-and-errors", "maintenance"]) rmSync(path3.join(targetDir, "references", `${obsolete}.md`), { force: true });
|
|
5755
6230
|
for (const [relativeTarget, relativeTemplate] of SKILL_BUNDLE_TEMPLATES) {
|
|
5756
|
-
const outputPath = relativeTarget === "SKILL.md" ? target :
|
|
5757
|
-
mkdirSync(
|
|
6231
|
+
const outputPath = relativeTarget === "SKILL.md" ? target : path3.join(targetDir, relativeTarget);
|
|
6232
|
+
mkdirSync(path3.dirname(outputPath), { recursive: true });
|
|
5758
6233
|
const template = readSkillTemplate(relativeTemplate);
|
|
5759
6234
|
writeFileSync(outputPath, renderSkillMarkdown(commands, template), "utf8");
|
|
5760
6235
|
}
|
|
@@ -5862,11 +6337,11 @@ function isCommandAvailable(name, runCommand) {
|
|
|
5862
6337
|
return !result.error && result.status === 0;
|
|
5863
6338
|
}
|
|
5864
6339
|
function readSkillTemplate(relativePath = "skill.template.md") {
|
|
5865
|
-
return readFileSync(
|
|
6340
|
+
return readFileSync(path3.join(process.cwd(), "src", relativePath), "utf8");
|
|
5866
6341
|
}
|
|
5867
6342
|
|
|
5868
6343
|
// src/version.ts
|
|
5869
|
-
var VERSION = "0.
|
|
6344
|
+
var VERSION = "0.9.0";
|
|
5870
6345
|
|
|
5871
6346
|
// src/forum.ts
|
|
5872
6347
|
function parseDiscussionReference(value) {
|
|
@@ -5957,7 +6432,7 @@ function resolveTopLevelUrl(baseUrlOrOptions, targetValue, resolveCourseIdForUrl
|
|
|
5957
6432
|
if (parsed.host.toLowerCase() !== configuredHost) {
|
|
5958
6433
|
throw new UsageError(`URL host '${parsed.host.toLowerCase()}' does not match configured Moodle site '${configuredHost}'.`);
|
|
5959
6434
|
}
|
|
5960
|
-
const
|
|
6435
|
+
const path5 = parsed.pathname.replace(/\/$/, "");
|
|
5961
6436
|
const intParam = (key, label) => {
|
|
5962
6437
|
const value = parsed.searchParams.get(key);
|
|
5963
6438
|
if (!value || !/^\d+$/.test(value)) {
|
|
@@ -5965,45 +6440,45 @@ function resolveTopLevelUrl(baseUrlOrOptions, targetValue, resolveCourseIdForUrl
|
|
|
5965
6440
|
}
|
|
5966
6441
|
return value;
|
|
5967
6442
|
};
|
|
5968
|
-
if (
|
|
6443
|
+
if (path5.endsWith("/mod/forum/discuss.php")) {
|
|
5969
6444
|
return objectMode ? { commandName: "forum_discussion", kwargs: { discussion: intParam("d", "discussion ID"), postId: parsed.hash, asJson: false, asYaml: false } } : { commandName: "forum:discussion", args: [intParam("d", "discussion ID"), parsed.hash] };
|
|
5970
6445
|
}
|
|
5971
|
-
if (
|
|
6446
|
+
if (path5.endsWith("/mod/forum/view.php")) {
|
|
5972
6447
|
return objectMode ? { commandName: "forum_discussions", kwargs: { forum: intParam("id", "forum module ID"), asJson: false, asYaml: false } } : { commandName: "forum:discussions", args: [intParam("id", "forum module ID")] };
|
|
5973
6448
|
}
|
|
5974
|
-
if (
|
|
6449
|
+
if (path5.endsWith("/mod/assign/view.php")) {
|
|
5975
6450
|
const id2 = intParam("id", "assignment module ID");
|
|
5976
6451
|
return objectMode ? { commandName: "assign", kwargs: { assign: id2, asJson: false, asYaml: false } } : { commandName: "assign", args: [id2] };
|
|
5977
6452
|
}
|
|
5978
|
-
if (
|
|
6453
|
+
if (path5.endsWith("/mod/quiz/view.php")) {
|
|
5979
6454
|
const id2 = intParam("id", "quiz module ID");
|
|
5980
6455
|
return objectMode ? { commandName: "quiz", kwargs: { quiz: id2, asJson: false, asYaml: false } } : { commandName: "quiz", args: [id2] };
|
|
5981
6456
|
}
|
|
5982
|
-
if (
|
|
6457
|
+
if (path5.endsWith("/mod/resource/view.php")) {
|
|
5983
6458
|
const id2 = intParam("id", "resource module ID");
|
|
5984
6459
|
return objectMode ? { commandName: "resource", kwargs: { resource: id2, asJson: false, asYaml: false } } : { commandName: "resource", args: [id2] };
|
|
5985
6460
|
}
|
|
5986
|
-
if (
|
|
6461
|
+
if (path5.endsWith("/mod/url/view.php")) {
|
|
5987
6462
|
const id2 = intParam("id", "link module ID");
|
|
5988
6463
|
return objectMode ? { commandName: "link", kwargs: { link: id2, asJson: false, asYaml: false } } : { commandName: "link", args: [id2] };
|
|
5989
6464
|
}
|
|
5990
|
-
if (
|
|
6465
|
+
if (path5.endsWith("/mod/page/view.php")) {
|
|
5991
6466
|
const id2 = intParam("id", "page module ID");
|
|
5992
6467
|
return objectMode ? { commandName: "page", kwargs: { page: id2, asJson: false, asYaml: false } } : { commandName: "page", args: [id2] };
|
|
5993
6468
|
}
|
|
5994
|
-
if (
|
|
6469
|
+
if (path5.endsWith("/mod/folder/view.php")) {
|
|
5995
6470
|
const id2 = intParam("id", "folder module ID");
|
|
5996
6471
|
return objectMode ? { commandName: "folder", kwargs: { folder: id2, asJson: false, asYaml: false } } : { commandName: "folder", args: [id2] };
|
|
5997
6472
|
}
|
|
5998
|
-
if (
|
|
6473
|
+
if (path5.endsWith("/course/view.php")) {
|
|
5999
6474
|
const id2 = intParam("id", "course ID");
|
|
6000
6475
|
return objectMode ? { commandName: "course", kwargs: { course: id2, asJson: false, asYaml: false } } : { commandName: "course", args: [id2] };
|
|
6001
6476
|
}
|
|
6002
|
-
if (
|
|
6477
|
+
if (path5.endsWith("/course/user.php") && parsed.searchParams.get("mode") === "grade" || path5.includes("/grade/report/")) {
|
|
6003
6478
|
const id2 = intParam("id", "course ID");
|
|
6004
6479
|
return objectMode ? { commandName: "grades", kwargs: { course: id2, asJson: false, asYaml: false } } : { commandName: "grades", args: [id2] };
|
|
6005
6480
|
}
|
|
6006
|
-
if (
|
|
6481
|
+
if (path5.includes("/mod/") && path5.endsWith("/view.php")) {
|
|
6007
6482
|
if (!resolveCourseIdForUrl) {
|
|
6008
6483
|
throw new UsageError("Could not resolve course ID from the activity page.");
|
|
6009
6484
|
}
|
|
@@ -6021,10 +6496,10 @@ function resolveTopLevelUrl(baseUrlOrOptions, targetValue, resolveCourseIdForUrl
|
|
|
6021
6496
|
|
|
6022
6497
|
// src/mcp/cli.ts
|
|
6023
6498
|
import { createHash as createHash3 } from "crypto";
|
|
6024
|
-
import { readFile as
|
|
6025
|
-
import { homedir as
|
|
6499
|
+
import { readFile as readFile9 } from "fs/promises";
|
|
6500
|
+
import { homedir as homedir12 } from "os";
|
|
6026
6501
|
import { join as join11 } from "path";
|
|
6027
|
-
import { createInterface
|
|
6502
|
+
import { createInterface } from "readline/promises";
|
|
6028
6503
|
import { fileURLToPath } from "url";
|
|
6029
6504
|
|
|
6030
6505
|
// src/mcp/protocol.ts
|
|
@@ -6076,7 +6551,7 @@ function parseJsonRpcRequest(input2) {
|
|
|
6076
6551
|
return JsonRpcRequestSchema.parse(input2);
|
|
6077
6552
|
}
|
|
6078
6553
|
function resolveProtocolVersion(request, context = {}) {
|
|
6079
|
-
const meta =
|
|
6554
|
+
const meta = isRecord7(request.params?._meta) ? request.params._meta : void 0;
|
|
6080
6555
|
const initializeVersion = request.method === "initialize" && typeof request.params?.protocolVersion === "string" ? request.params.protocolVersion : void 0;
|
|
6081
6556
|
const requested = context.protocolVersion ?? stringValue3(meta?.["io.modelcontextprotocol/protocolVersion"]) ?? initializeVersion ?? MODERN_PROTOCOL_VERSION;
|
|
6082
6557
|
if (!isSupportedProtocolVersion(requested)) {
|
|
@@ -6085,7 +6560,7 @@ function resolveProtocolVersion(request, context = {}) {
|
|
|
6085
6560
|
return requested;
|
|
6086
6561
|
}
|
|
6087
6562
|
function assertRequestMetadata(request, protocolVersion2, context = {}) {
|
|
6088
|
-
const meta =
|
|
6563
|
+
const meta = isRecord7(request.params?._meta) ? request.params._meta : void 0;
|
|
6089
6564
|
const metaVersion = stringValue3(meta?.["io.modelcontextprotocol/protocolVersion"]);
|
|
6090
6565
|
if (protocolVersion2 === MODERN_PROTOCOL_VERSION) {
|
|
6091
6566
|
const parsed = ModernClientMetadataSchema.safeParse(meta);
|
|
@@ -6116,7 +6591,7 @@ function jsonRpcSuccess(id2, result) {
|
|
|
6116
6591
|
function jsonRpcFailure(id2, error) {
|
|
6117
6592
|
return { jsonrpc: "2.0", id: id2, error };
|
|
6118
6593
|
}
|
|
6119
|
-
function
|
|
6594
|
+
function isRecord7(value) {
|
|
6120
6595
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6121
6596
|
}
|
|
6122
6597
|
function stringValue3(value) {
|
|
@@ -6191,7 +6666,7 @@ async function bridgeRemoteMcp(options) {
|
|
|
6191
6666
|
await writeRemoteError(options.output, request.id, response.status);
|
|
6192
6667
|
return;
|
|
6193
6668
|
}
|
|
6194
|
-
if (request.method === "initialize" &&
|
|
6669
|
+
if (request.method === "initialize" && isRecord8(payload) && "result" in payload) {
|
|
6195
6670
|
negotiatedProtocolVersion = responseProtocolVersion(payload) ?? initializedProtocolVersion(request) ?? negotiatedProtocolVersion;
|
|
6196
6671
|
}
|
|
6197
6672
|
await writeJson(options.output, payload);
|
|
@@ -6216,7 +6691,7 @@ function normalizeMcpEndpoint(value) {
|
|
|
6216
6691
|
return url.toString();
|
|
6217
6692
|
}
|
|
6218
6693
|
function protocolVersion(request, negotiatedVersion) {
|
|
6219
|
-
const meta =
|
|
6694
|
+
const meta = isRecord8(request.params?._meta) ? request.params?._meta : void 0;
|
|
6220
6695
|
const metadataVersion = meta?.["io.modelcontextprotocol/protocolVersion"];
|
|
6221
6696
|
if (typeof metadataVersion === "string") return metadataVersion;
|
|
6222
6697
|
return initializedProtocolVersion(request) ?? negotiatedVersion;
|
|
@@ -6226,7 +6701,7 @@ function initializedProtocolVersion(request) {
|
|
|
6226
6701
|
return typeof initialized === "string" ? initialized : void 0;
|
|
6227
6702
|
}
|
|
6228
6703
|
function responseProtocolVersion(payload) {
|
|
6229
|
-
const result =
|
|
6704
|
+
const result = isRecord8(payload.result) ? payload.result : void 0;
|
|
6230
6705
|
return typeof result?.protocolVersion === "string" ? result.protocolVersion : void 0;
|
|
6231
6706
|
}
|
|
6232
6707
|
async function writeSseMessages(output, body, id2) {
|
|
@@ -6260,10 +6735,10 @@ async function forwardProtocolNegotiationError(output, response, id2) {
|
|
|
6260
6735
|
}
|
|
6261
6736
|
try {
|
|
6262
6737
|
const payload = await response.json();
|
|
6263
|
-
if (!
|
|
6738
|
+
if (!isRecord8(payload) || payload.jsonrpc !== "2.0" || payload.id !== id2 || !isRecord8(payload.error)) {
|
|
6264
6739
|
return false;
|
|
6265
6740
|
}
|
|
6266
|
-
const data =
|
|
6741
|
+
const data = isRecord8(payload.error.data) ? payload.error.data : void 0;
|
|
6267
6742
|
const supported = Array.isArray(data?.supported) ? data.supported.filter((version) => typeof version === "string") : [];
|
|
6268
6743
|
if (payload.error.code !== -32022 || typeof data?.requested !== "string" || supported.length === 0) {
|
|
6269
6744
|
return false;
|
|
@@ -6283,9 +6758,9 @@ async function writeJson(output, value) {
|
|
|
6283
6758
|
`);
|
|
6284
6759
|
}
|
|
6285
6760
|
function isBridgeRequest(value) {
|
|
6286
|
-
return
|
|
6761
|
+
return isRecord8(value) && value.jsonrpc === "2.0" && typeof value.method === "string";
|
|
6287
6762
|
}
|
|
6288
|
-
function
|
|
6763
|
+
function isRecord8(value) {
|
|
6289
6764
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6290
6765
|
}
|
|
6291
6766
|
|
|
@@ -6557,13 +7032,13 @@ function resolveConnection(options) {
|
|
|
6557
7032
|
}
|
|
6558
7033
|
|
|
6559
7034
|
// src/mcp/connectors/node-connectors.ts
|
|
6560
|
-
import { chmod as chmod4, mkdir as mkdir6, readFile as
|
|
6561
|
-
import { homedir as
|
|
7035
|
+
import { chmod as chmod4, mkdir as mkdir6, readFile as readFile7, rm as rm5, stat as stat3, writeFile as writeFile6 } from "fs/promises";
|
|
7036
|
+
import { homedir as homedir9 } from "os";
|
|
6562
7037
|
import { dirname as dirname6, join as join8 } from "path";
|
|
6563
7038
|
var NodeConnectorFileSystem = class {
|
|
6564
|
-
async exists(
|
|
7039
|
+
async exists(path5) {
|
|
6565
7040
|
try {
|
|
6566
|
-
await
|
|
7041
|
+
await stat3(path5);
|
|
6567
7042
|
return true;
|
|
6568
7043
|
} catch (error) {
|
|
6569
7044
|
if (isMissing3(error)) {
|
|
@@ -6572,20 +7047,20 @@ var NodeConnectorFileSystem = class {
|
|
|
6572
7047
|
throw error;
|
|
6573
7048
|
}
|
|
6574
7049
|
}
|
|
6575
|
-
async readText(
|
|
6576
|
-
return
|
|
7050
|
+
async readText(path5) {
|
|
7051
|
+
return readFile7(path5, "utf8");
|
|
6577
7052
|
}
|
|
6578
|
-
async writePrivate(
|
|
6579
|
-
await mkdir6(dirname6(
|
|
6580
|
-
await writeFile6(
|
|
6581
|
-
await chmod4(
|
|
7053
|
+
async writePrivate(path5, content) {
|
|
7054
|
+
await mkdir6(dirname6(path5), { recursive: true, mode: 448 });
|
|
7055
|
+
await writeFile6(path5, content, { encoding: "utf8", mode: 384 });
|
|
7056
|
+
await chmod4(path5, 384);
|
|
6582
7057
|
}
|
|
6583
|
-
async remove(
|
|
6584
|
-
await rm5(
|
|
7058
|
+
async remove(path5) {
|
|
7059
|
+
await rm5(path5, { force: true });
|
|
6585
7060
|
}
|
|
6586
7061
|
};
|
|
6587
7062
|
function createDefaultClientConnectors(profile, options = {}) {
|
|
6588
|
-
const home = options.homeDirectory ??
|
|
7063
|
+
const home = options.homeDirectory ?? homedir9();
|
|
6589
7064
|
const platform = options.platform ?? process.platform;
|
|
6590
7065
|
const fileSystem = options.fileSystem ?? new NodeConnectorFileSystem();
|
|
6591
7066
|
const runtime = runtimeCommand(options.command, options.commandArgs);
|
|
@@ -7370,10 +7845,10 @@ function asDeploymentError(error) {
|
|
|
7370
7845
|
}
|
|
7371
7846
|
|
|
7372
7847
|
// src/mcp/wrangler.ts
|
|
7373
|
-
import {
|
|
7848
|
+
import { createUi as createUi2 } from "@bunizao/cli-kit";
|
|
7374
7849
|
import { mkdir as mkdir7 } from "fs/promises";
|
|
7375
7850
|
import { existsSync } from "fs";
|
|
7376
|
-
import { homedir as
|
|
7851
|
+
import { homedir as homedir10 } from "os";
|
|
7377
7852
|
import { join as join9 } from "path";
|
|
7378
7853
|
async function resolveWrangler(runner, options = {}) {
|
|
7379
7854
|
const env = options.env ?? process.env;
|
|
@@ -7385,7 +7860,7 @@ async function resolveWrangler(runner, options = {}) {
|
|
|
7385
7860
|
if (version && sameMajorAtLeast(version, WRANGLER_VERSION)) return { command: existing, args: [] };
|
|
7386
7861
|
notice(`Ignoring ${existing} (${version ?? "unknown version"}); Cloudflare management needs Wrangler ${WRANGLER_VERSION.split(".")[0]}.x.`);
|
|
7387
7862
|
}
|
|
7388
|
-
const root = join9(options.homeDir ??
|
|
7863
|
+
const root = join9(options.homeDir ?? homedir10(), ".config", "moodle-cli", "tools", `wrangler@${WRANGLER_VERSION}`);
|
|
7389
7864
|
const script = join9(root, "node_modules", "wrangler", "bin", "wrangler.js");
|
|
7390
7865
|
const bun = findExecutable("bun", env);
|
|
7391
7866
|
const node = findExecutable("node", env);
|
|
@@ -7395,13 +7870,10 @@ async function resolveWrangler(runner, options = {}) {
|
|
|
7395
7870
|
if (!bun && !npm) throw new Error("Install Bun or npm to download the pinned Cloudflare toolchain.");
|
|
7396
7871
|
const yes = options.yes ?? (process.argv.includes("--yes") || process.argv.includes("-y"));
|
|
7397
7872
|
if (!yes) {
|
|
7398
|
-
|
|
7399
|
-
|
|
7400
|
-
|
|
7401
|
-
|
|
7402
|
-
if (answer.trim() && !/^y(?:es)?$/iu.test(answer.trim())) throw new UsageError("Wrangler download cancelled.", "Retry when ready to install Cloudflare's toolchain.");
|
|
7403
|
-
} finally {
|
|
7404
|
-
reader.close();
|
|
7873
|
+
const ui = createUi2({ input: process.stdin, output: process.stderr });
|
|
7874
|
+
if (!ui.interactive) throw new UsageError("Cloudflare management needs a first-use Wrangler download.", "Rerun with --yes to download and cache the pinned toolchain.");
|
|
7875
|
+
if (!await ui.confirm(`Download Cloudflare Wrangler ${WRANGLER_VERSION} (cached for next time)?`, { initial: true })) {
|
|
7876
|
+
throw new UsageError("Wrangler download cancelled.", "Retry when ready to install Cloudflare's toolchain.");
|
|
7405
7877
|
}
|
|
7406
7878
|
}
|
|
7407
7879
|
notice(`Cloudflare management needs Wrangler ${WRANGLER_VERSION}; downloading once to ${root}.`);
|
|
@@ -7421,8 +7893,8 @@ function sameMajorAtLeast(actual, pinned) {
|
|
|
7421
7893
|
import { isDeepStrictEqual } from "util";
|
|
7422
7894
|
import { spawn as spawn2 } from "child_process";
|
|
7423
7895
|
import { createHash as createHash2, randomBytes as randomBytes2 } from "crypto";
|
|
7424
|
-
import { chmod as chmod5, mkdir as mkdir8, mkdtemp, readFile as
|
|
7425
|
-
import { homedir as
|
|
7896
|
+
import { chmod as chmod5, mkdir as mkdir8, mkdtemp, readFile as readFile8, rm as rm6, writeFile as writeFile7 } from "fs/promises";
|
|
7897
|
+
import { homedir as homedir11, tmpdir } from "os";
|
|
7426
7898
|
import { basename, dirname as dirname7, join as join10 } from "path";
|
|
7427
7899
|
var MODERN_MCP_VERSION = "2026-07-28";
|
|
7428
7900
|
var WORKER_PROPAGATION_ATTEMPTS = 10;
|
|
@@ -7614,7 +8086,7 @@ ${error.stderr}`)) {
|
|
|
7614
8086
|
const binding = (value, name) => {
|
|
7615
8087
|
let result2 = null;
|
|
7616
8088
|
visit(value, (_key, item) => {
|
|
7617
|
-
if (
|
|
8089
|
+
if (isRecord9(item) && item.name === name && typeof item.text === "string") result2 = item.text;
|
|
7618
8090
|
});
|
|
7619
8091
|
return result2;
|
|
7620
8092
|
};
|
|
@@ -7660,7 +8132,7 @@ ${error.stderr}`)) {
|
|
|
7660
8132
|
}
|
|
7661
8133
|
};
|
|
7662
8134
|
async function copyReleaseBundle(source, destination) {
|
|
7663
|
-
await writeFile7(destination, await
|
|
8135
|
+
await writeFile7(destination, await readFile8(source));
|
|
7664
8136
|
}
|
|
7665
8137
|
var NodeReleaseMaterializer = class {
|
|
7666
8138
|
constructor(options) {
|
|
@@ -7782,10 +8254,10 @@ var FetchManagedWorkerClient = class {
|
|
|
7782
8254
|
})
|
|
7783
8255
|
}, isRetryableSessionUpload);
|
|
7784
8256
|
const body = await safeJson(response);
|
|
7785
|
-
if (response.ok &&
|
|
8257
|
+
if (response.ok && isRecord9(body) && typeof body.revision === "number") {
|
|
7786
8258
|
return { revision: body.revision };
|
|
7787
8259
|
}
|
|
7788
|
-
const code =
|
|
8260
|
+
const code = isRecord9(body) && typeof body.code === "string" ? body.code : "SESSION_UPLOAD_FAILED";
|
|
7789
8261
|
throw new DeploymentApplyError(code, `The Worker rejected the Moodle session update (${code})`);
|
|
7790
8262
|
}
|
|
7791
8263
|
async getReadiness(input2) {
|
|
@@ -7793,7 +8265,7 @@ var FetchManagedWorkerClient = class {
|
|
|
7793
8265
|
headers: { authorization: `Bearer ${input2.sessionSyncToken}` }
|
|
7794
8266
|
}, isRetryableSessionUpload);
|
|
7795
8267
|
const body = await safeJson(response);
|
|
7796
|
-
if (
|
|
8268
|
+
if (isRecord9(body) && (body.status === "pass" || body.status === "warn" || body.status === "fail")) {
|
|
7797
8269
|
const session = firstHealthCheck(body, "moodle:session");
|
|
7798
8270
|
const upstream = firstHealthCheck(body, "moodle:upstream");
|
|
7799
8271
|
return {
|
|
@@ -7820,7 +8292,7 @@ var FetchManagedWorkerClient = class {
|
|
|
7820
8292
|
isRetryableWorkerPropagation
|
|
7821
8293
|
);
|
|
7822
8294
|
const healthBody = await safeJson(health);
|
|
7823
|
-
if (!health.ok || !
|
|
8295
|
+
if (!health.ok || !isRecord9(healthBody) || healthBody.status !== "pass") {
|
|
7824
8296
|
throw new DeploymentApplyError("HEALTH_CHECK_FAILED", "Worker liveness check failed");
|
|
7825
8297
|
}
|
|
7826
8298
|
let readiness = await this.getReadiness(input2);
|
|
@@ -7851,11 +8323,11 @@ var FetchManagedWorkerClient = class {
|
|
|
7851
8323
|
if (!Array.isArray(courses)) throw new DeploymentApplyError("MCP_SMOKE_FAILED", "MCP list_courses returned no course list");
|
|
7852
8324
|
if (courses.length) {
|
|
7853
8325
|
const first2 = courses[0];
|
|
7854
|
-
if (!
|
|
8326
|
+
if (!isRecord9(first2) || !Number.isSafeInteger(first2.id) || Number(first2.id) <= 0) {
|
|
7855
8327
|
throw new DeploymentApplyError("MCP_SMOKE_FAILED", "MCP list_courses returned no usable course ID");
|
|
7856
8328
|
}
|
|
7857
8329
|
const detail = readableToolResult(await this.mcpCall(input2.endpoint, input2.mcpAccessToken, "tools/call", { name: "get_course", arguments: { courseId: first2.id } }, 5));
|
|
7858
|
-
if (!
|
|
8330
|
+
if (!isRecord9(detail.course) || !isRecord9(detail.course.course) || detail.course.course.id !== first2.id || !Array.isArray(detail.course.sections)) {
|
|
7859
8331
|
throw new DeploymentApplyError("MCP_SMOKE_FAILED", "MCP course lookup did not match the listed course");
|
|
7860
8332
|
}
|
|
7861
8333
|
}
|
|
@@ -7879,7 +8351,7 @@ var FetchManagedWorkerClient = class {
|
|
|
7879
8351
|
headers: { authorization: `Bearer ${input2.sessionSyncToken}` }
|
|
7880
8352
|
});
|
|
7881
8353
|
const body = await safeJson(response);
|
|
7882
|
-
if (!response.ok || !
|
|
8354
|
+
if (!response.ok || !isRecord9(body) || typeof body.code !== "string" || typeof body.expiresAt !== "string") {
|
|
7883
8355
|
throw new DeploymentApplyError("PAIRING_UNAVAILABLE", "The Worker could not open a pairing window");
|
|
7884
8356
|
}
|
|
7885
8357
|
return {
|
|
@@ -7916,7 +8388,7 @@ var FetchManagedWorkerClient = class {
|
|
|
7916
8388
|
})
|
|
7917
8389
|
}, isRetryableSessionUpload);
|
|
7918
8390
|
const body = await safeJson(response);
|
|
7919
|
-
if (!response.ok || !
|
|
8391
|
+
if (!response.ok || !isRecord9(body) || body.jsonrpc !== "2.0" || body.id !== id2 || "error" in body || !("result" in body)) {
|
|
7920
8392
|
throw new DeploymentApplyError("MCP_SMOKE_FAILED", `MCP ${method} check failed`);
|
|
7921
8393
|
}
|
|
7922
8394
|
if (method === "tools/call") readableToolResult(body.result);
|
|
@@ -7934,13 +8406,13 @@ var FetchManagedWorkerClient = class {
|
|
|
7934
8406
|
}
|
|
7935
8407
|
};
|
|
7936
8408
|
var PrivateDeploymentReceiptStore = class {
|
|
7937
|
-
constructor(baseDirectory = join10(
|
|
8409
|
+
constructor(baseDirectory = join10(homedir11(), ".config", "moodle-cli", "mcp", "deployments")) {
|
|
7938
8410
|
this.baseDirectory = baseDirectory;
|
|
7939
8411
|
}
|
|
7940
8412
|
baseDirectory;
|
|
7941
8413
|
async read(profile) {
|
|
7942
8414
|
try {
|
|
7943
|
-
const parsed = JSON.parse(await
|
|
8415
|
+
const parsed = JSON.parse(await readFile8(this.path(profile), "utf8"));
|
|
7944
8416
|
return isReceipt(parsed) ? parsed : null;
|
|
7945
8417
|
} catch (error) {
|
|
7946
8418
|
if (isMissing4(error)) {
|
|
@@ -7950,11 +8422,11 @@ var PrivateDeploymentReceiptStore = class {
|
|
|
7950
8422
|
}
|
|
7951
8423
|
}
|
|
7952
8424
|
async write(receipt) {
|
|
7953
|
-
const
|
|
7954
|
-
await mkdir8(dirname7(
|
|
7955
|
-
await writeFile7(
|
|
8425
|
+
const path5 = this.path(receipt.profile);
|
|
8426
|
+
await mkdir8(dirname7(path5), { recursive: true, mode: 448 });
|
|
8427
|
+
await writeFile7(path5, `${JSON.stringify(receipt, null, 2)}
|
|
7956
8428
|
`, { mode: 384 });
|
|
7957
|
-
await chmod5(
|
|
8429
|
+
await chmod5(path5, 384);
|
|
7958
8430
|
}
|
|
7959
8431
|
async delete(profile) {
|
|
7960
8432
|
await rm6(this.path(profile), { force: true });
|
|
@@ -7967,7 +8439,7 @@ var PrivateDeploymentReceiptStore = class {
|
|
|
7967
8439
|
}
|
|
7968
8440
|
};
|
|
7969
8441
|
function createDefaultManagedDeployment(options) {
|
|
7970
|
-
const homeDirectory = options.homeDirectory ??
|
|
8442
|
+
const homeDirectory = options.homeDirectory ?? homedir11();
|
|
7971
8443
|
const platform = options.platform ?? process.platform;
|
|
7972
8444
|
const runtime = runtimeCommand(options.executable, options.executableArgs);
|
|
7973
8445
|
const defaults = {
|
|
@@ -8005,17 +8477,17 @@ function digest(value) {
|
|
|
8005
8477
|
function ownershipId(accountId, workerName) {
|
|
8006
8478
|
return `moodle-cli:${accountId}:${workerName}`;
|
|
8007
8479
|
}
|
|
8008
|
-
function endpointUrl(endpoint,
|
|
8009
|
-
return `${endpoint.replace(/\/$/u, "")}${
|
|
8480
|
+
function endpointUrl(endpoint, path5) {
|
|
8481
|
+
return `${endpoint.replace(/\/$/u, "")}${path5}`;
|
|
8010
8482
|
}
|
|
8011
8483
|
async function pinExpectedHosts(configPath, workerName, productionEndpoint) {
|
|
8012
8484
|
let config;
|
|
8013
8485
|
try {
|
|
8014
|
-
config = JSON.parse(await
|
|
8486
|
+
config = JSON.parse(await readFile8(configPath, "utf8"));
|
|
8015
8487
|
} catch {
|
|
8016
8488
|
throw new DeploymentApplyError("RELEASE_CONFIG_INVALID", "The generated Wrangler configuration is invalid");
|
|
8017
8489
|
}
|
|
8018
|
-
if (!
|
|
8490
|
+
if (!isRecord9(config) || !isRecord9(config.vars)) {
|
|
8019
8491
|
throw new DeploymentApplyError("RELEASE_CONFIG_INVALID", "The generated Wrangler configuration is invalid");
|
|
8020
8492
|
}
|
|
8021
8493
|
const hosts = endpointHosts(workerName, productionEndpoint);
|
|
@@ -8055,18 +8527,18 @@ async function safeJson(response) {
|
|
|
8055
8527
|
}
|
|
8056
8528
|
function firstHealthCheck(body, name) {
|
|
8057
8529
|
const checks = body.checks;
|
|
8058
|
-
if (!
|
|
8530
|
+
if (!isRecord9(checks) || !Array.isArray(checks[name])) {
|
|
8059
8531
|
return null;
|
|
8060
8532
|
}
|
|
8061
8533
|
const check = checks[name][0];
|
|
8062
|
-
return
|
|
8534
|
+
return isRecord9(check) ? check : null;
|
|
8063
8535
|
}
|
|
8064
8536
|
function readableToolResult(result) {
|
|
8065
|
-
if (
|
|
8537
|
+
if (isRecord9(result) && result.isError !== true && Array.isArray(result.content)) {
|
|
8066
8538
|
try {
|
|
8067
|
-
const text2 = result.content.filter((block) =>
|
|
8539
|
+
const text2 = result.content.filter((block) => isRecord9(block) && block.type === "text").map((block) => block.text).join("\n");
|
|
8068
8540
|
const parsed = JSON.parse(text2);
|
|
8069
|
-
if (
|
|
8541
|
+
if (isRecord9(parsed) && isDeepStrictEqual(parsed, result.structuredContent)) return parsed;
|
|
8070
8542
|
} catch {
|
|
8071
8543
|
}
|
|
8072
8544
|
}
|
|
@@ -8074,7 +8546,7 @@ function readableToolResult(result) {
|
|
|
8074
8546
|
}
|
|
8075
8547
|
function mcpUserFullname(result) {
|
|
8076
8548
|
const user = readableToolResult(result).user;
|
|
8077
|
-
return
|
|
8549
|
+
return isRecord9(user) && typeof user.fullname === "string" && user.fullname.trim() ? user.fullname.trim() : null;
|
|
8078
8550
|
}
|
|
8079
8551
|
function parseJsonOutput(output) {
|
|
8080
8552
|
const candidates = [output.indexOf("{"), output.indexOf("[")].filter((index) => index >= 0).sort((a, b) => a - b);
|
|
@@ -8090,7 +8562,7 @@ function parseJsonOutput(output) {
|
|
|
8090
8562
|
function deploymentHistory(value) {
|
|
8091
8563
|
const entries = [];
|
|
8092
8564
|
visit(value, (_key, item) => {
|
|
8093
|
-
if (!
|
|
8565
|
+
if (!isRecord9(item) || !Array.isArray(item.versions)) {
|
|
8094
8566
|
return;
|
|
8095
8567
|
}
|
|
8096
8568
|
const versionId = activeVersionId(item.versions);
|
|
@@ -8098,7 +8570,7 @@ function deploymentHistory(value) {
|
|
|
8098
8570
|
return;
|
|
8099
8571
|
}
|
|
8100
8572
|
const createdOn = typeof item.created_on === "string" ? Date.parse(item.created_on) : Number.NaN;
|
|
8101
|
-
const message =
|
|
8573
|
+
const message = isRecord9(item.annotations) ? item.annotations["workers/message"] : void 0;
|
|
8102
8574
|
entries.push({
|
|
8103
8575
|
versionId,
|
|
8104
8576
|
message: typeof message === "string" ? message : null,
|
|
@@ -8109,7 +8581,7 @@ function deploymentHistory(value) {
|
|
|
8109
8581
|
return entries.sort((a, b) => b.createdOn - a.createdOn || b.index - a.index).map(({ versionId, message }) => ({ versionId, message }));
|
|
8110
8582
|
}
|
|
8111
8583
|
function activeVersionId(versions) {
|
|
8112
|
-
const records = versions.filter(
|
|
8584
|
+
const records = versions.filter(isRecord9);
|
|
8113
8585
|
const active = records.find((version) => version.percentage === 100) ?? records[0];
|
|
8114
8586
|
const id2 = active?.version_id ?? active?.versionId;
|
|
8115
8587
|
return typeof id2 === "string" ? id2 : null;
|
|
@@ -8120,7 +8592,7 @@ function releaseDigestFromMessage(message) {
|
|
|
8120
8592
|
function collectAccountObjects(value) {
|
|
8121
8593
|
const accounts = [];
|
|
8122
8594
|
visit(value, (_key, item) => {
|
|
8123
|
-
if (!
|
|
8595
|
+
if (!isRecord9(item)) {
|
|
8124
8596
|
return;
|
|
8125
8597
|
}
|
|
8126
8598
|
const id2 = typeof item.id === "string" ? item.id : typeof item.account_id === "string" ? item.account_id : null;
|
|
@@ -8149,14 +8621,14 @@ function visit(value, visitor, key = "") {
|
|
|
8149
8621
|
for (const item of value) {
|
|
8150
8622
|
visit(item, visitor);
|
|
8151
8623
|
}
|
|
8152
|
-
} else if (
|
|
8624
|
+
} else if (isRecord9(value)) {
|
|
8153
8625
|
for (const [childKey, item] of Object.entries(value)) {
|
|
8154
8626
|
visit(item, visitor, childKey);
|
|
8155
8627
|
}
|
|
8156
8628
|
}
|
|
8157
8629
|
}
|
|
8158
8630
|
function isReceipt(value) {
|
|
8159
|
-
if (!
|
|
8631
|
+
if (!isRecord9(value)) {
|
|
8160
8632
|
return false;
|
|
8161
8633
|
}
|
|
8162
8634
|
return [
|
|
@@ -8170,7 +8642,7 @@ function isReceipt(value) {
|
|
|
8170
8642
|
"releaseDigest"
|
|
8171
8643
|
].every((key) => typeof value[key] === "string") && typeof value.sessionRevision === "number";
|
|
8172
8644
|
}
|
|
8173
|
-
function
|
|
8645
|
+
function isRecord9(value) {
|
|
8174
8646
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
8175
8647
|
}
|
|
8176
8648
|
function isMissing4(error) {
|
|
@@ -8234,13 +8706,23 @@ var READ_ONLY_ANNOTATIONS = {
|
|
|
8234
8706
|
idempotentHint: true,
|
|
8235
8707
|
openWorldHint: true
|
|
8236
8708
|
};
|
|
8709
|
+
var WRITE_ANNOTATIONS = {
|
|
8710
|
+
readOnlyHint: false,
|
|
8711
|
+
destructiveHint: true,
|
|
8712
|
+
idempotentHint: false,
|
|
8713
|
+
openWorldHint: true
|
|
8714
|
+
};
|
|
8715
|
+
var WRITE_TOOLS = /* @__PURE__ */ new Set(["submit"]);
|
|
8237
8716
|
var aliases = { get_overview: "home", list_courses: "units", get_course: "unit", get_activity: "item", get_grades: "grades", get_thread: "thread", get_file: "file" };
|
|
8238
8717
|
var TOOL_CATALOG = Object.entries(intentContracts).map(([name, contract]) => ({
|
|
8239
8718
|
name,
|
|
8240
8719
|
description: intentDescription(name),
|
|
8241
8720
|
inputSchema: compactSchema(z4.toJSONSchema(contract.input, { io: "input" })),
|
|
8242
|
-
annotations: READ_ONLY_ANNOTATIONS
|
|
8721
|
+
annotations: WRITE_TOOLS.has(name) ? WRITE_ANNOTATIONS : READ_ONLY_ANNOTATIONS
|
|
8243
8722
|
}));
|
|
8723
|
+
function toolsFor(gateway) {
|
|
8724
|
+
return gateway.submitAssignment ? TOOL_CATALOG : TOOL_CATALOG.filter((tool) => !WRITE_TOOLS.has(tool.name));
|
|
8725
|
+
}
|
|
8244
8726
|
var TOOL_OUTPUT_SCHEMAS = Object.fromEntries(Object.entries(intentContracts).map(([name, contract]) => [name, compactSchema(z4.toJSONSchema(contract.output))]));
|
|
8245
8727
|
function compactSchema(value) {
|
|
8246
8728
|
if (Array.isArray(value)) return value.map(compactSchema);
|
|
@@ -8278,7 +8760,7 @@ function createMoodleMcpServer(gateway, options = {}) {
|
|
|
8278
8760
|
protocolVersion: protocolVersion2,
|
|
8279
8761
|
capabilities: { tools: { listChanged: false } },
|
|
8280
8762
|
serverInfo,
|
|
8281
|
-
instructions: "Read-only access to the authenticated user's Moodle data."
|
|
8763
|
+
instructions: gateway.submitAssignment ? "Access to the authenticated user's Moodle data. Only submit writes; it defaults to a dry run." : "Read-only access to the authenticated user's Moodle data."
|
|
8282
8764
|
});
|
|
8283
8765
|
}
|
|
8284
8766
|
if (request.method === "ping") {
|
|
@@ -8286,7 +8768,7 @@ function createMoodleMcpServer(gateway, options = {}) {
|
|
|
8286
8768
|
}
|
|
8287
8769
|
if (request.method === "tools/list") {
|
|
8288
8770
|
return jsonRpcSuccess(id2, {
|
|
8289
|
-
tools:
|
|
8771
|
+
tools: toolsFor(gateway),
|
|
8290
8772
|
resultType: "complete",
|
|
8291
8773
|
_meta: RESULT_META
|
|
8292
8774
|
});
|
|
@@ -8342,8 +8824,9 @@ async function callTool(gateway, params) {
|
|
|
8342
8824
|
const requested = typeof params?.name === "string" ? params.name : "";
|
|
8343
8825
|
const name = Object.hasOwn(aliases, requested) ? aliases[requested] : requested;
|
|
8344
8826
|
if (!Object.hasOwn(intentContracts, name) && !["get_user", "list_activities", "list_forums"].includes(name)) throw new McpCallError("TOOL_NOT_FOUND", `Unknown Moodle tool: ${requested || "<missing>"}`);
|
|
8827
|
+
if (WRITE_TOOLS.has(name) && !gateway.submitAssignment) throw new McpCallError("TOOL_NOT_FOUND", `${requested} is only available on a local MCP server with access to the files.`);
|
|
8345
8828
|
const raw = params?.arguments ?? {};
|
|
8346
|
-
if (!
|
|
8829
|
+
if (!isRecord10(raw)) throw new McpCallError("INVALID_TOOL_ARGUMENTS", "Tool arguments must be an object.");
|
|
8347
8830
|
const args = { ...raw };
|
|
8348
8831
|
if (Object.hasOwn(aliases, requested)) {
|
|
8349
8832
|
const renames = { courseId: "unit", activityId: "ref", discussionId: "discussion_id", source: "ref", todoDays: "days", gradedOnly: "graded_only" };
|
|
@@ -8391,7 +8874,7 @@ async function callTool(gateway, params) {
|
|
|
8391
8874
|
const mapped2 = { type: "MOODLE_RESULT_INVALID", message: `Moodle returned ${name} data in an unexpected shape.`, hint: "Retry once; if it persists, run the same command locally with --verbose and report the tool name.", issues: error.issues.slice(0, 5).map((issue) => ({ path: issue.path.join("."), message: issue.message })) };
|
|
8392
8875
|
return { content: [{ type: "text", text: JSON.stringify({ error: mapped2 }) }], structuredContent: { error: mapped2 }, isError: true, resultType: "complete", _meta: RESULT_META };
|
|
8393
8876
|
}
|
|
8394
|
-
const mapped = error instanceof ReferenceError ? { type: error.code, code: error.code, message: error.message, hint: error.hint, candidates: error.candidates } : mapMoodleError(error);
|
|
8877
|
+
const mapped = error instanceof ReferenceError ? { type: error.code, code: error.code, message: error.message, hint: error.hint, candidates: error.candidates } : mapMoodleError(error, WRITE_TOOLS.has(name));
|
|
8395
8878
|
return { content: [{ type: "text", text: JSON.stringify({ error: mapped }) }], structuredContent: { error: mapped }, isError: true, resultType: "complete", _meta: RESULT_META };
|
|
8396
8879
|
}
|
|
8397
8880
|
}
|
|
@@ -8410,9 +8893,11 @@ function toolContent(name, payload, structuredContent) {
|
|
|
8410
8893
|
}
|
|
8411
8894
|
];
|
|
8412
8895
|
}
|
|
8413
|
-
function mapMoodleError(error) {
|
|
8414
|
-
const
|
|
8415
|
-
const code = typeof
|
|
8896
|
+
function mapMoodleError(error, verbatim = false) {
|
|
8897
|
+
const record3 = isRecord10(error) ? error : {};
|
|
8898
|
+
const code = typeof record3.code === "string" ? record3.code : "";
|
|
8899
|
+
const own = verbatim && typeof record3.message === "string" && record3.message.trim() && code !== "auth" ? record3.message : void 0;
|
|
8900
|
+
const ownHint = verbatim && typeof record3.hint === "string" && record3.hint.trim() ? record3.hint : void 0;
|
|
8416
8901
|
const typeByCode = {
|
|
8417
8902
|
auth: "MOODLE_AUTH_REQUIRED",
|
|
8418
8903
|
not_found: "MOODLE_NOT_FOUND",
|
|
@@ -8421,8 +8906,8 @@ function mapMoodleError(error) {
|
|
|
8421
8906
|
};
|
|
8422
8907
|
const type = code.startsWith("MOODLE_") ? code : typeByCode[code] ?? "MOODLE_UPSTREAM_ERROR";
|
|
8423
8908
|
const message = type === "MOODLE_AUTH_REQUIRED" ? "The Moodle session has expired. Sign in again." : type === "MOODLE_NOT_FOUND" || type === "MOODLE_COURSE_NOT_FOUND" ? "The requested Moodle item was not found." : type === "MOODLE_INVALID_REQUEST" ? "The Moodle request is invalid." : "Moodle could not complete the request.";
|
|
8424
|
-
const moodleCode = typeof
|
|
8425
|
-
return { type, message, hint: type === "MOODLE_AUTH_REQUIRED" ? "Run moodle mcp login for a remote server, or moodle auth login locally; then retry." : "Run moodle doctor, or refine the request using units and find.", ...type === "MOODLE_AUTH_REQUIRED" ? { recovery: { action: "moodle mcp login", where: "machine running moodle-cli", then: "retry this tool" } } : {}, ...moodleCode ? { moodleCode } : {} };
|
|
8909
|
+
const moodleCode = typeof record3.moodleErrorCode === "string" && /^[a-z][a-z0-9_]{0,63}$/u.test(record3.moodleErrorCode) ? record3.moodleErrorCode : void 0;
|
|
8910
|
+
return { type, message: own ?? message, hint: ownHint ?? (type === "MOODLE_AUTH_REQUIRED" ? "Run moodle mcp login for a remote server, or moodle auth login locally; then retry." : "Run moodle doctor, or refine the request using units and find."), ...type === "MOODLE_AUTH_REQUIRED" ? { recovery: { action: "moodle mcp login", where: "machine running moodle-cli", then: "retry this tool" } } : {}, ...moodleCode ? { moodleCode } : {} };
|
|
8426
8911
|
}
|
|
8427
8912
|
var McpCallError = class extends Error {
|
|
8428
8913
|
type;
|
|
@@ -8434,11 +8919,11 @@ var McpCallError = class extends Error {
|
|
|
8434
8919
|
this.details = details;
|
|
8435
8920
|
}
|
|
8436
8921
|
};
|
|
8437
|
-
function
|
|
8922
|
+
function isRecord10(value) {
|
|
8438
8923
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
8439
8924
|
}
|
|
8440
8925
|
function isMoodleFile(value) {
|
|
8441
|
-
return
|
|
8926
|
+
return isRecord10(value) && typeof value.name === "string" && typeof value.mimeType === "string" && typeof value.bytes === "number" && typeof value.uri === "string" && typeof value.blob === "string";
|
|
8442
8927
|
}
|
|
8443
8928
|
|
|
8444
8929
|
// src/mcp/stdio.ts
|
|
@@ -8494,12 +8979,12 @@ async function writeResponse2(output, response) {
|
|
|
8494
8979
|
`);
|
|
8495
8980
|
}
|
|
8496
8981
|
function initializeProtocolVersion(input2) {
|
|
8497
|
-
if (!
|
|
8982
|
+
if (!isRecord11(input2) || input2.method !== "initialize" || !isRecord11(input2.params)) {
|
|
8498
8983
|
return void 0;
|
|
8499
8984
|
}
|
|
8500
8985
|
return typeof input2.params.protocolVersion === "string" ? input2.params.protocolVersion : void 0;
|
|
8501
8986
|
}
|
|
8502
|
-
function
|
|
8987
|
+
function isRecord11(value) {
|
|
8503
8988
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
8504
8989
|
}
|
|
8505
8990
|
|
|
@@ -8520,7 +9005,7 @@ function deriveMcpWorkerName(moodleOrigin) {
|
|
|
8520
9005
|
var DefaultMcpCommandService = class {
|
|
8521
9006
|
constructor(options) {
|
|
8522
9007
|
this.options = options;
|
|
8523
|
-
this.homeDirectory = options.homeDir ??
|
|
9008
|
+
this.homeDirectory = options.homeDir ?? homedir12();
|
|
8524
9009
|
this.wranglerInstance = options.wrangler;
|
|
8525
9010
|
this.receipts = options.receipts ?? new PrivateDeploymentReceiptStore(join11(this.homeDirectory, ".config", "moodle-cli", "mcp", "deployments"));
|
|
8526
9011
|
this.credentials = options.credentials ?? createDefaultCredentialStore({ platform: process.platform, homeDirectory: this.homeDirectory });
|
|
@@ -9077,7 +9562,7 @@ Selection: `)).trim());
|
|
|
9077
9562
|
if (this.options.prompt) return this.options.prompt(question);
|
|
9078
9563
|
const input2 = this.options.stdin ?? process.stdin;
|
|
9079
9564
|
const output = this.options.stderr ?? process.stderr;
|
|
9080
|
-
const readline =
|
|
9565
|
+
const readline = createInterface({ input: input2, output });
|
|
9081
9566
|
return readline.question(question).finally(() => readline.close());
|
|
9082
9567
|
}
|
|
9083
9568
|
workerBundlePath() {
|
|
@@ -9088,7 +9573,7 @@ Selection: `)).trim());
|
|
|
9088
9573
|
return this.wranglerInstance;
|
|
9089
9574
|
}
|
|
9090
9575
|
releaseDigest() {
|
|
9091
|
-
return
|
|
9576
|
+
return readFile9(this.workerBundlePath()).then((content) => sha256(content));
|
|
9092
9577
|
}
|
|
9093
9578
|
// True when a deployment receipt exists but its recorded release digest no
|
|
9094
9579
|
// longer matches the Worker bundle shipped with this CLI, i.e. the remote
|
|
@@ -9233,12 +9718,12 @@ function sha256(value) {
|
|
|
9233
9718
|
// src/cli.ts
|
|
9234
9719
|
var NOUNS = [
|
|
9235
9720
|
{ name: "units", aliases: ["courses"], verbs: ["list", "show"], defaultByArity: { 0: "list", 1: "show" } },
|
|
9236
|
-
{ name: "activities", verbs: ["list", "show"], defaultByArity: { 1: "list" }, valueFlags: ["--limit", "--section"] },
|
|
9721
|
+
{ name: "activities", verbs: ["list", "show"], defaultByArity: { 0: "list", 1: "list" }, valueFlags: ["--limit", "--section"] },
|
|
9237
9722
|
{ name: "grades", verbs: ["list"], defaultByArity: { 0: "list", 1: "list" } },
|
|
9238
9723
|
{
|
|
9239
9724
|
name: "forums",
|
|
9240
9725
|
verbs: ["list", "show", "search"],
|
|
9241
|
-
defaultByArity: { 1: "list" },
|
|
9726
|
+
defaultByArity: { 0: "list", 1: "list" },
|
|
9242
9727
|
valueFlags: ["--limit", "--course", "--forum", "--limit-forums", "--limit-discussions", "--unit"]
|
|
9243
9728
|
},
|
|
9244
9729
|
{ name: "threads", verbs: ["show"], defaultByArity: { 1: "show" }, valueFlags: ["--post", "--limit", "--offset"] }
|
|
@@ -9246,7 +9731,8 @@ var NOUNS = [
|
|
|
9246
9731
|
function buildProgram(io = {}) {
|
|
9247
9732
|
const stdout = io.stdout ?? process.stdout;
|
|
9248
9733
|
const stderr = io.stderr ?? process.stderr;
|
|
9249
|
-
const program = createProgram({ name: "moodle", version: VERSION, description:
|
|
9734
|
+
const program = createProgram({ name: "moodle", version: VERSION, description: MOODLE_TAGLINE });
|
|
9735
|
+
banner(program, MOODLE_WORDMARK);
|
|
9250
9736
|
program.configureOutput({
|
|
9251
9737
|
writeOut: (text2) => stdout.write(text2),
|
|
9252
9738
|
writeErr: (text2) => stderr.write(text2),
|
|
@@ -9271,9 +9757,11 @@ function buildProgram(io = {}) {
|
|
|
9271
9757
|
width: stdout.columns || void 0,
|
|
9272
9758
|
color: !process.env.NO_COLOR && program.opts().color !== false && outputFormat({ ...program.opts(), ...options }, stdout) === "table"
|
|
9273
9759
|
});
|
|
9760
|
+
configureTerminalTables({ color: () => colorEnabled2(stdout, io.env) && program.opts().color !== false });
|
|
9274
9761
|
const count = (key, local, fallback) => program.opts()[key] ?? local ?? fallback;
|
|
9275
9762
|
const runtime = {
|
|
9276
9763
|
client: null,
|
|
9764
|
+
busy: false,
|
|
9277
9765
|
screen,
|
|
9278
9766
|
count,
|
|
9279
9767
|
baseUrl: async () => (await loadConfig({ env: io.env, cwd: io.cwd, homeDir: io.homeDir, stdin: io.stdin, stderr, fetch: io.fetchImpl })).baseUrl,
|
|
@@ -9283,11 +9771,11 @@ function buildProgram(io = {}) {
|
|
|
9283
9771
|
let inflight = 0;
|
|
9284
9772
|
let displayed = false;
|
|
9285
9773
|
let timer;
|
|
9286
|
-
|
|
9774
|
+
const connect = () => createMoodleClient(baseUrl, {
|
|
9287
9775
|
env: io.env,
|
|
9288
9776
|
fetchImpl: async (input2, init) => {
|
|
9289
9777
|
const started2 = Date.now();
|
|
9290
|
-
const tty = Boolean("isTTY" in stderr && stderr.isTTY) && !program.opts().json && !io.rootArgs?.includes("--json");
|
|
9778
|
+
const tty = Boolean("isTTY" in stderr && stderr.isTTY) && !program.opts().json && !io.rootArgs?.includes("--json") && !runtime.busy;
|
|
9291
9779
|
if (tty && inflight++ === 0) timer = setTimeout(() => {
|
|
9292
9780
|
displayed = true;
|
|
9293
9781
|
stderr.write("Loading Moodle\u2026");
|
|
@@ -9313,14 +9801,21 @@ function buildProgram(io = {}) {
|
|
|
9313
9801
|
homeDir: io.homeDir,
|
|
9314
9802
|
noCache: Boolean(program.opts().cache === false)
|
|
9315
9803
|
});
|
|
9804
|
+
try {
|
|
9805
|
+
runtime.client = await connect();
|
|
9806
|
+
} catch (error) {
|
|
9807
|
+
if (!(error instanceof AuthError) || !human()) throw error;
|
|
9808
|
+
await signIn(baseUrl);
|
|
9809
|
+
runtime.client = await connect();
|
|
9810
|
+
}
|
|
9316
9811
|
}
|
|
9317
9812
|
return runtime.client;
|
|
9318
9813
|
},
|
|
9319
9814
|
output: async (data, formatter, options) => {
|
|
9320
9815
|
const merged = { ...program.opts(), ...options };
|
|
9321
9816
|
const format = outputFormat(merged, stdout);
|
|
9322
|
-
const
|
|
9323
|
-
const text2 = format === "table" ? `${
|
|
9817
|
+
const human2 = format === "table" ? formatter() : "";
|
|
9818
|
+
const text2 = format === "table" ? `${human2}${human2.includes("Try ") ? "" : "\n\nTry moodle due \xB7 moodle units \xB7 moodle --help"}
|
|
9324
9819
|
` : format === "json" ? `${JSON.stringify(JSON.parse(render(data, { format, fields: parseFields(data, merged.fields) })), null, merged.pretty ? 2 : void 0)}
|
|
9325
9820
|
` : render(data, { format, fields: parseFields(data, merged.fields) });
|
|
9326
9821
|
if (io.stdout && !merged.output) {
|
|
@@ -9348,23 +9843,40 @@ function buildProgram(io = {}) {
|
|
|
9348
9843
|
const result = await runner.run(name, args);
|
|
9349
9844
|
await runtime.output(result, () => screen(result, options), options);
|
|
9350
9845
|
};
|
|
9846
|
+
const human = () => detectAudience({
|
|
9847
|
+
stdin: io.stdin ?? process.stdin,
|
|
9848
|
+
stdout: { isTTY: Boolean(stdout && "isTTY" in stdout && stdout.isTTY) },
|
|
9849
|
+
env: io.env ?? process.env,
|
|
9850
|
+
format: outputFormat(program.opts(), stdout)
|
|
9851
|
+
}) === "human";
|
|
9852
|
+
const theme = () => createTheme3(colorEnabled2(stderr, io.env) && program.opts().color !== false);
|
|
9853
|
+
const signIn = async (baseUrl) => {
|
|
9854
|
+
const ui = createUi3({ input: io.stdin ?? process.stdin, output: stderr, interactive: true });
|
|
9855
|
+
showWordmark(ui);
|
|
9856
|
+
ui.note(`No Moodle session for ${baseUrl}.
|
|
9857
|
+
Sign in once in your browser; later commands reuse that session.`, "One-time setup");
|
|
9858
|
+
if (!await ui.confirm("Open the browser to sign in?", { initial: true })) {
|
|
9859
|
+
throw new AuthError(`No usable MoodleSession found for ${baseUrl}.`, "Run moodle auth login when you are ready.");
|
|
9860
|
+
}
|
|
9861
|
+
const spin = ui.spinner();
|
|
9862
|
+
spin.start("Opening the browser");
|
|
9863
|
+
try {
|
|
9864
|
+
const session = await getAuthenticatedSessionWithBrowserFallback(baseUrl, { env: io.env, fetch: io.fetchImpl, homeDir: io.homeDir, onBrowserOpened: (url) => spin.message(`Finish signing in at ${url}`) });
|
|
9865
|
+
spin.stop(`Signed in as userid ${session.userid}`);
|
|
9866
|
+
} catch (error) {
|
|
9867
|
+
spin.error("Sign-in did not complete");
|
|
9868
|
+
throw error;
|
|
9869
|
+
}
|
|
9870
|
+
};
|
|
9351
9871
|
const choose = async (action, retry) => {
|
|
9352
9872
|
try {
|
|
9353
9873
|
return await action();
|
|
9354
9874
|
} catch (error) {
|
|
9355
|
-
if (!(error instanceof ReferenceError) || error.code !== "ambiguous" || !(
|
|
9356
|
-
|
|
9357
|
-
|
|
9358
|
-
|
|
9359
|
-
|
|
9360
|
-
try {
|
|
9361
|
-
const answer = await reader.question(`Pick [1-${error.candidates.length}]: `);
|
|
9362
|
-
const chosen = error.candidates[Number(answer) - 1];
|
|
9363
|
-
if (!chosen) throw error;
|
|
9364
|
-
return await retry(chosen.id);
|
|
9365
|
-
} finally {
|
|
9366
|
-
reader.close();
|
|
9367
|
-
}
|
|
9875
|
+
if (!(error instanceof ReferenceError) || error.code !== "ambiguous" || !human()) throw error;
|
|
9876
|
+
const ui = createUi3({ input: io.stdin ?? process.stdin, output: stderr, interactive: true });
|
|
9877
|
+
const hint = (c) => c.code ?? c.type;
|
|
9878
|
+
const chosen = await ui.select(error.message, error.candidates.map((c) => ({ value: c.id, label: c.name, ...hint(c) ? { hint: hint(c) } : {} })));
|
|
9879
|
+
return await retry(chosen);
|
|
9368
9880
|
}
|
|
9369
9881
|
};
|
|
9370
9882
|
program.action(async (targets, options) => {
|
|
@@ -9429,15 +9941,42 @@ ${error.candidates.map((c, i) => ` ${i + 1} ${c.name}`).join("\n")}
|
|
|
9429
9941
|
if (name === "due") command.option("--days <number>", "Deadline window in days.", parsePositiveInt);
|
|
9430
9942
|
command.option("--limit <number>", "Maximum returned rows.", parsePositiveInt).action(async (unit, options) => execute(name, { unit, limit: count("limit", options.limit), ...name === "due" ? { days: count("days", options.days) } : {} }, options));
|
|
9431
9943
|
}
|
|
9432
|
-
addOutputOptions(program.command("find").description(humanDescription("find")).argument("<query>").argument("[unit]")).option("--limit <number>", "Maximum returned rows.", parsePositiveInt).option("--types <types>", "Comma-separated activity types.").action(async (query, unit, options) => execute("find", { query, unit, limit: count("limit", options.limit), types: options.types?.split(",") }, options));
|
|
9433
|
-
addOutputOptions(program.command("get").description("Download a resource by id, URL, or UNIT TASK phrase.").argument("<ref>")).option("--to <directory>", "Destination directory.").option("--force", "Replace an existing file atomically.").action(async (ref2, options) => {
|
|
9944
|
+
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));
|
|
9945
|
+
addOutputOptions(program.command("get").description("Download a resource by id, URL, or UNIT TASK phrase.").argument("<ref>", "Resource id, URL, or UNIT TASK phrase")).option("--to <directory>", "Destination directory.").option("--force", "Replace an existing file atomically.").action(async (ref2, options) => {
|
|
9434
9946
|
const client = await runtime.getClient();
|
|
9435
9947
|
const service = createIntentService(createMoodleGateway(client));
|
|
9436
9948
|
const source = await choose(() => service.fileSource(ref2), (id2) => Promise.resolve(id2));
|
|
9437
|
-
const receipt = await downloadMoodleFile(client, { source: String(source), directory: options.to ?
|
|
9949
|
+
const receipt = await downloadMoodleFile(client, { source: String(source), directory: options.to ? path4.resolve(io.cwd ?? process.cwd(), options.to) : void 0, force: options.force });
|
|
9438
9950
|
await runtime.output(receipt, () => formatDownloadReceipt(receipt), options);
|
|
9439
9951
|
});
|
|
9440
|
-
addOutputOptions(program.command("
|
|
9952
|
+
addOutputOptions(mutating(program.command("submit").description(humanDescription("submit")).summary("Upload files into an assignment").argument("<ref>", "Assignment id, URL, or UNIT TASK phrase").argument("[files...]", "Local files to upload"))).option("--final", "Also submit for grading. Moodle does not allow undoing this.").option("--replace", "Remove the files already in the submission first.").option("--accept-statement", "Agree to the site's submission statement when it requires one.").action(async (ref2, files, options) => {
|
|
9953
|
+
const interactive = human();
|
|
9954
|
+
if (!program.opts().dryRun && !program.opts().yes && !interactive) throw new UsageError("Mutation requires --yes when stdin is not interactive.", "Run with --dry-run to see the plan first.");
|
|
9955
|
+
const client = await runtime.getClient();
|
|
9956
|
+
const service = createIntentService(createMoodleGateway(client));
|
|
9957
|
+
const args = { files: files.map((file2) => resolveSubmissionPath(file2, io.cwd ?? process.cwd())), final: Boolean(options.final), replace: Boolean(options.replace), accept_statement: Boolean(options.acceptStatement) };
|
|
9958
|
+
const plan = await choose(() => service.run("submit", { ref: ref2, ...args, dry_run: true }), (id2) => service.run("submit", { ref: id2, ...args, dry_run: true }));
|
|
9959
|
+
const planned = plan.submission;
|
|
9960
|
+
if (program.opts().dryRun) return runtime.output(plan, () => formatSubmissionReceipt(planned), options);
|
|
9961
|
+
if (!await confirm({ summary: submissionSummary(planned, args.final, theme()) }, { yes: Boolean(program.opts().yes), dryRun: false, interactive })) return;
|
|
9962
|
+
const spin = interactive ? createUi3({ input: io.stdin ?? process.stdin, output: stderr, interactive: true }).spinner() : void 0;
|
|
9963
|
+
runtime.busy = true;
|
|
9964
|
+
spin?.start("Preparing the upload");
|
|
9965
|
+
let result;
|
|
9966
|
+
try {
|
|
9967
|
+
const live = createIntentService(createMoodleGateway(client, { onSubmitProgress: (message) => spin?.message(message) }));
|
|
9968
|
+
result = await live.run("submit", { ref: planned.id, ...args, dry_run: false });
|
|
9969
|
+
const receipt = result.submission;
|
|
9970
|
+
spin?.stop(receipt.uploads.length ? `Uploaded ${receipt.uploads.map((file2) => file2.name).join(", ")} to ${receipt.name}` : `Submitted ${receipt.name}`);
|
|
9971
|
+
} catch (error) {
|
|
9972
|
+
spin?.error("The upload did not complete");
|
|
9973
|
+
throw error;
|
|
9974
|
+
} finally {
|
|
9975
|
+
runtime.busy = false;
|
|
9976
|
+
}
|
|
9977
|
+
await runtime.output(result, () => formatSubmissionReceipt(result.submission), options);
|
|
9978
|
+
});
|
|
9979
|
+
addOutputOptions(program.command("open").description("Open a unit or activity reference in the browser.").argument("<ref>", "Unit or activity id, URL, or UNIT TASK phrase")).action(async (ref2, options) => {
|
|
9441
9980
|
const client = await runtime.getClient();
|
|
9442
9981
|
let url;
|
|
9443
9982
|
if (looksLikeUrl(ref2)) {
|
|
@@ -9495,7 +10034,7 @@ ${error.candidates.map((c, i) => ` ${i + 1} ${c.name}`).join("\n")}
|
|
|
9495
10034
|
const result = stripEmpty({ activities: rows.slice(0, count("limit", options.limit)), total: rows.length });
|
|
9496
10035
|
await runtime.output(result, () => runtime.screen(result, options), options);
|
|
9497
10036
|
});
|
|
9498
|
-
addOutputOptions(activities.command("show").description("Show activity details; resource and folder files can be passed to moodle get or download.").argument("<id>", "Course-module ID")).action(
|
|
10037
|
+
addOutputOptions(activities.command("show").description("Show activity details; resource and folder files can be passed to moodle get or download.").summary("Show activity details").argument("<id>", "Course-module ID")).action(
|
|
9499
10038
|
async (id2, options) => {
|
|
9500
10039
|
await execute("item", { ref: parsePositiveInt(id2) }, options);
|
|
9501
10040
|
}
|
|
@@ -9503,7 +10042,7 @@ ${error.candidates.map((c, i) => ` ${i + 1} ${c.name}`).join("\n")}
|
|
|
9503
10042
|
addOutputOptions(
|
|
9504
10043
|
program.command("download").alias("dl").description("Download one authenticated Moodle file.").argument("<source>", "Course-module ID or authenticated Moodle file URL").option("--dest <path>", "Exact downloaded file path").option("--force", "Atomically replace an existing destination")
|
|
9505
10044
|
).action(async (source, options) => {
|
|
9506
|
-
const destination = options.dest ?
|
|
10045
|
+
const destination = options.dest ? path4.resolve(io.cwd ?? process.cwd(), options.dest) : void 0;
|
|
9507
10046
|
const receipt = await downloadMoodleFile(await runtime.getClient(), {
|
|
9508
10047
|
source,
|
|
9509
10048
|
destination,
|
|
@@ -9548,12 +10087,12 @@ ${error.candidates.map((c, i) => ` ${i + 1} ${c.name}`).join("\n")}
|
|
|
9548
10087
|
await runtime.output(stripEmpty({ forums: forums2.map((f) => ({ id: f.id, name: f.name, unit_id: f.course_id })), total }), () => formatForumActivities(forums2), options);
|
|
9549
10088
|
});
|
|
9550
10089
|
addForumSearchCommand(forums.command("search").description("Search forum discussion titles and post text."), runtime, 20);
|
|
9551
|
-
addOutputOptions(program.command("doctor").description("Diagnose runtime, browser access, session, background jobs and MCP setup.")).action(async (options) => {
|
|
10090
|
+
addOutputOptions(program.command("doctor").description("Diagnose runtime, browser access, session, background jobs and MCP setup.").summary("Diagnose runtime, session and MCP setup")).action(async (options) => {
|
|
9552
10091
|
const result = await doctor(io);
|
|
9553
10092
|
await runtime.output(result, () => result.checks.map((c) => `${c.status.toUpperCase()} ${c.name}: ${c.detail}${c.hint ? `
|
|
9554
10093
|
${c.hint}` : ""}`).join("\n") + "\n\nTry moodle auth login \xB7 moodle mcp status", options);
|
|
9555
10094
|
});
|
|
9556
|
-
program.command("completion").description("Print shell completion for zsh, bash or fish.").
|
|
10095
|
+
program.command("completion").description("Print shell completion for zsh, bash or fish.").addArgument(program.createArgument("<shell>", "Shell to target").choices(["zsh", "bash", "fish"])).action((shell) => {
|
|
9557
10096
|
const names = program.commands.filter((c) => c.name() !== "help").flatMap((c) => [c.name(), ...c.aliases()]);
|
|
9558
10097
|
if (shell === "bash") stdout.write(`complete -W '${names.join(" ")}' moodle
|
|
9559
10098
|
`);
|
|
@@ -9563,22 +10102,22 @@ _arguments '1:command:(${names.join(" ")})' '*:reference:'
|
|
|
9563
10102
|
else if (shell === "fish") stdout.write(names.map((n2) => `complete -c moodle -f -a '${n2}'`).join("\n") + "\n");
|
|
9564
10103
|
else throw new UsageError("Choose zsh, bash or fish.");
|
|
9565
10104
|
});
|
|
9566
|
-
addOutputOptions(mutating(program.command("uninstall").description("Remove local background jobs; optionally remove the selected Worker and configuration."))).option("--remote", "Also remove the configured managed MCP deployment.").option("--purge", "Also delete local Moodle CLI configuration, receipts and cache.").action(async (options) => {
|
|
9567
|
-
const home = io.homeDir ??
|
|
10105
|
+
addOutputOptions(mutating(program.command("uninstall").description("Remove local background jobs; optionally remove the selected Worker and configuration.").summary("Remove background jobs, Worker and config"))).option("--remote", "Also remove the configured managed MCP deployment.").option("--purge", "Also delete local Moodle CLI configuration, receipts and cache.").action(async (options) => {
|
|
10106
|
+
const home = io.homeDir ?? homedir13();
|
|
9568
10107
|
const jobs = await ownedJobs(home);
|
|
9569
|
-
const receipts = await readdir3(
|
|
9570
|
-
const result = { jobs: jobs.map((j) => j.path), remote: Boolean(options.remote), purge: Boolean(options.purge), config:
|
|
10108
|
+
const receipts = await readdir3(path4.join(home, ".config", "moodle-cli", "mcp", "deployments")).catch(() => []);
|
|
10109
|
+
const result = { jobs: jobs.map((j) => j.path), remote: Boolean(options.remote), purge: Boolean(options.purge), config: path4.join(home, CONFIG_DIR_NAME), cache: path4.join(home, CACHE_DIR_NAME), package_command: "npm rm -g moodle-cli (or bun remove -g moodle-cli); for the standalone install: rm ~/.local/bin/moodle", remaining: options.remote ? "Only the configured Worker is removed. Other profiles remain remote." : "Remote Workers and credentials remain unless removed with moodle mcp remove." };
|
|
9571
10110
|
if (program.opts().dryRun) return runtime.output(result, () => JSON.stringify(result, null, 2), options);
|
|
9572
10111
|
if (options.purge && receipts.length && !options.remote) throw new UsageError("Managed deployment receipts exist; remove the Worker before purging its recovery information.", "Run moodle mcp remove for each configured site, then moodle uninstall --purge.");
|
|
9573
10112
|
if (options.purge && receipts.length > 1) throw new UsageError("Multiple managed deployment receipts exist; remove each Worker before purging configuration.");
|
|
9574
|
-
if (!await confirm({ summary: `Remove Moodle background jobs${options.remote ? ", the configured Worker" : ""}${options.purge ? ", configuration and cache" : ""}.` }, { yes: Boolean(program.opts().yes), dryRun: false, interactive:
|
|
10113
|
+
if (!await confirm({ summary: `Remove Moodle background jobs${options.remote ? ", the configured Worker" : ""}${options.purge ? ", configuration and cache" : ""}.` }, { yes: Boolean(program.opts().yes), dryRun: false, interactive: human() })) return;
|
|
9575
10114
|
if (options.remote) await getMcpService().remove({ yes: true });
|
|
9576
10115
|
if (process.platform === "darwin") await uninstallKeepalive({ homeDir: home });
|
|
9577
10116
|
const renewal2 = new DefaultRenewalIntegration({ homeDirectory: home, executable: process.execPath });
|
|
9578
10117
|
const profiles = new Set([...jobs.map((j) => j.profile), ...receipts.map((n2) => n2.replace(/\.json$/u, ""))].filter((p) => Boolean(p) && /^[a-z0-9_-]+$/u.test(p)));
|
|
9579
10118
|
for (const profile of profiles) {
|
|
9580
10119
|
await renewal2.remove(profile);
|
|
9581
|
-
if (options.purge) await rm7(
|
|
10120
|
+
if (options.purge) await rm7(path4.join(home, "Library", "Logs", `com.moodle-cli.mcp-renewal.${profile}.log`), { force: true });
|
|
9582
10121
|
}
|
|
9583
10122
|
if (options.purge) {
|
|
9584
10123
|
await rm7(result.config, { recursive: true, force: true });
|
|
@@ -9617,7 +10156,7 @@ ${url}
|
|
|
9617
10156
|
}
|
|
9618
10157
|
);
|
|
9619
10158
|
const keepalive = addOutputOptions(
|
|
9620
|
-
auth.command("keepalive").description("Renew the Moodle session once; used by the background keepalive agent.").option("--no-renew", "Only touch the session; skip re-login when it is expired.")
|
|
10159
|
+
auth.command("keepalive").description("Renew the Moodle session once; used by the background keepalive agent.").summary("Renew the session once").option("--no-renew", "Only touch the session; skip re-login when it is expired.")
|
|
9621
10160
|
).action(async (options) => {
|
|
9622
10161
|
const baseUrl = await runtime.baseUrl();
|
|
9623
10162
|
const result = await keepAliveOnce(baseUrl, { homeDir: io.homeDir, fetchImpl: io.fetchImpl, renewOnExpiry: options.renew });
|
|
@@ -9629,7 +10168,7 @@ ${url}
|
|
|
9629
10168
|
const globals = program.opts();
|
|
9630
10169
|
if (!await confirm(
|
|
9631
10170
|
{ summary: `Install the Moodle session keepalive agent${options.interval ? ` with a ${options.interval}-minute interval` : ""}.` },
|
|
9632
|
-
{ yes: Boolean(globals.yes), dryRun: Boolean(globals.dryRun), interactive:
|
|
10171
|
+
{ yes: Boolean(globals.yes), dryRun: Boolean(globals.dryRun), interactive: human() }
|
|
9633
10172
|
)) return;
|
|
9634
10173
|
const baseUrl = await runtime.baseUrl();
|
|
9635
10174
|
await getAuthenticatedSessionWithBrowserFallback(baseUrl, { env: io.env, homeDir: io.homeDir, fetch: io.fetchImpl, noCache: true, nonInteractive: true });
|
|
@@ -9643,7 +10182,7 @@ Log: ${result.log_path}`, options);
|
|
|
9643
10182
|
const globals = program.opts();
|
|
9644
10183
|
if (!await confirm(
|
|
9645
10184
|
{ summary: "Remove the Moodle session keepalive agent." },
|
|
9646
|
-
{ yes: Boolean(globals.yes), dryRun: Boolean(globals.dryRun), interactive:
|
|
10185
|
+
{ yes: Boolean(globals.yes), dryRun: Boolean(globals.dryRun), interactive: human() }
|
|
9647
10186
|
)) return;
|
|
9648
10187
|
const result = await uninstallKeepalive({ homeDir: io.homeDir });
|
|
9649
10188
|
await runtime.output(result, () => `Keepalive removed (${result.plist_path})`, options);
|
|
@@ -9655,7 +10194,7 @@ Log: ${result.log_path}`, options);
|
|
|
9655
10194
|
await runtime.output(result, () => result.installed ? `Keepalive installed (${result.plist_path})` : "Keepalive not installed", options);
|
|
9656
10195
|
}
|
|
9657
10196
|
);
|
|
9658
|
-
const mcp = program.command("mcp").description("Deploy a private MCP Worker on Cloudflare; encrypted session storage and local renewal. Free-tier limits apply.");
|
|
10197
|
+
const mcp = program.command("mcp").description("Deploy a private MCP Worker on Cloudflare; encrypted session storage and local renewal. Free-tier limits apply.").summary("Private MCP server on Cloudflare");
|
|
9659
10198
|
addOutputOptions(mutating(mcp.command("deploy").description("Deploy or update the managed Moodle MCP server."))).option("--dry-run", "Preview deployment changes without applying them.").option("--repair", "Repair authentication and managed deployment state.").option("--rotate-key", "Rotate the session encryption key and migrate the active session.").option("--rotate-token", "Rotate the MCP access token with an overlap window.").option("--rollback", "Restore the previous healthy Worker release.").action(async (options) => {
|
|
9660
10199
|
const dryRun = Boolean(options.dryRun || program.opts().dryRun);
|
|
9661
10200
|
if (!dryRun && !await confirm(
|
|
@@ -9663,7 +10202,7 @@ Log: ${result.log_path}`, options);
|
|
|
9663
10202
|
{
|
|
9664
10203
|
yes: Boolean(program.opts().yes),
|
|
9665
10204
|
dryRun: false,
|
|
9666
|
-
interactive:
|
|
10205
|
+
interactive: human()
|
|
9667
10206
|
}
|
|
9668
10207
|
)) return;
|
|
9669
10208
|
const result = await getMcpService().deploy({
|
|
@@ -9705,11 +10244,11 @@ Log: ${result.log_path}`, options);
|
|
|
9705
10244
|
addOutputOptions(mcp.command("clients").description("List pending and approved OAuth clients.")).action(async (options) => {
|
|
9706
10245
|
await outputMcpResult(runtime, await getMcpService().manageClients({}), options);
|
|
9707
10246
|
});
|
|
9708
|
-
addOutputOptions(mutating(mcp.command("revoke").description("Revoke an OAuth client or all OAuth access.").argument("[client-id]"))).option("--all", "Revoke every client, token, pending authorization, and pairing window.").action(async (clientId, options) => {
|
|
10247
|
+
addOutputOptions(mutating(mcp.command("revoke").description("Revoke an OAuth client or all OAuth access.").argument("[client-id]", "OAuth client id; omit with --all"))).option("--all", "Revoke every client, token, pending authorization, and pairing window.").action(async (clientId, options) => {
|
|
9709
10248
|
if (Boolean(clientId) === Boolean(options.all)) throw new UsageError("Provide a client ID or --all.");
|
|
9710
10249
|
await outputMcpResult(runtime, await getMcpService().manageClients({ revoke: true, clientId }), options);
|
|
9711
10250
|
});
|
|
9712
|
-
addOutputOptions(mutating(mcp.command("pair").description("Open a pairing window so Claude can connect to the remote MCP server."))).action(
|
|
10251
|
+
addOutputOptions(mutating(mcp.command("pair").description("Open a pairing window so Claude can connect to the remote MCP server.").summary("Open a pairing window for Claude"))).action(
|
|
9713
10252
|
async (options) => {
|
|
9714
10253
|
await outputMcpResult(runtime, await getMcpService().pair(), options);
|
|
9715
10254
|
}
|
|
@@ -9742,7 +10281,7 @@ Log: ${result.log_path}`, options);
|
|
|
9742
10281
|
await runtime.output(description, () => JSON.stringify(description, null, 2), options);
|
|
9743
10282
|
}
|
|
9744
10283
|
);
|
|
9745
|
-
const skills = program.command("skills").description("Show skill metadata or delegate to the shared skills CLI.");
|
|
10284
|
+
const skills = program.command("skills").description("Show skill metadata or delegate to the shared skills CLI.").summary("Agent skill metadata");
|
|
9746
10285
|
skills.action(() => {
|
|
9747
10286
|
stdout.write(`${formatSkillSummary()}
|
|
9748
10287
|
`);
|
|
@@ -9752,18 +10291,40 @@ Log: ${result.log_path}`, options);
|
|
|
9752
10291
|
stdout.write("Generated Moodle skill bundle\n");
|
|
9753
10292
|
});
|
|
9754
10293
|
skills.command("add").description("Install the published skill through npx skills add.").allowUnknownOption(true).action((_options, command) => installSkill(command.args));
|
|
10294
|
+
for (const [title, names] of Object.entries(HELP_SECTIONS)) {
|
|
10295
|
+
for (const name of names) for (const command of program.commands) if (command.name() === name) helpSection(command, title);
|
|
10296
|
+
}
|
|
10297
|
+
examples(program, [
|
|
10298
|
+
"moodle # today: due items, alerts and news",
|
|
10299
|
+
"moodle UNIT grades",
|
|
10300
|
+
'moodle submit UNIT "Assignment 2" report.pdf'
|
|
10301
|
+
]);
|
|
9755
10302
|
return program;
|
|
9756
10303
|
}
|
|
10304
|
+
var HELP_SECTIONS = {
|
|
10305
|
+
"Core commands": ["due", "news", "find", "get", "open", "submit", "units", "activities", "grades", "threads", "forums"],
|
|
10306
|
+
"Additional commands": ["user", "todo", "alerts", "overview", "download", "auth", "doctor", "completion", "uninstall"],
|
|
10307
|
+
"Agent commands": ["mcp", "commands", "skills"]
|
|
10308
|
+
};
|
|
9757
10309
|
async function runCli(argv = process.argv, io = {}) {
|
|
9758
10310
|
const stderr = io.stderr ?? process.stderr;
|
|
9759
10311
|
const stdout = io.stdout ?? process.stdout;
|
|
9760
10312
|
const args = insertDefaultVerb(argv.slice(2), NOUNS);
|
|
9761
|
-
const
|
|
10313
|
+
const ui = createUi3({
|
|
10314
|
+
input: io.stdin ?? process.stdin,
|
|
10315
|
+
output: stderr,
|
|
10316
|
+
interactive: detectAudience({
|
|
10317
|
+
stdin: io.stdin ?? process.stdin,
|
|
10318
|
+
stdout: { isTTY: Boolean(stdout && "isTTY" in stdout && stdout.isTTY) },
|
|
10319
|
+
env: io.env ?? process.env,
|
|
10320
|
+
format: errorOutputFormat(args, stdout)
|
|
10321
|
+
}) === "human"
|
|
10322
|
+
});
|
|
9762
10323
|
try {
|
|
9763
|
-
await
|
|
10324
|
+
await parseWithPrompts(() => buildProgram({ ...io, rootArgs: args }), args, { ui, fillers: { unit: pickUnit(io, ui) } });
|
|
9764
10325
|
return 0;
|
|
9765
10326
|
} catch (error) {
|
|
9766
|
-
if (
|
|
10327
|
+
if (isInformationalExit(error)) {
|
|
9767
10328
|
return 0;
|
|
9768
10329
|
}
|
|
9769
10330
|
const format = errorOutputFormat(args, stdout);
|
|
@@ -9843,11 +10404,26 @@ function addForumSearchCommand(command, runtime, defaultLimit) {
|
|
|
9843
10404
|
});
|
|
9844
10405
|
}
|
|
9845
10406
|
function addOutputOptions(command) {
|
|
9846
|
-
|
|
10407
|
+
for (const [flags, description] of [
|
|
10408
|
+
["--pretty", "Indent JSON output."],
|
|
10409
|
+
["--json", "Output as JSON."],
|
|
10410
|
+
["--yaml", "Output as YAML."],
|
|
10411
|
+
["--table", "Force human output."],
|
|
10412
|
+
["--fields <fields>", "Keep only listed top-level fields in structured output."]
|
|
10413
|
+
]) command.addOption(command.createOption(flags, description).hideHelp());
|
|
10414
|
+
return command;
|
|
9847
10415
|
}
|
|
9848
10416
|
function outputFormat(options, stdout) {
|
|
9849
10417
|
return resolveFormat(options, Boolean(stdout && "isTTY" in stdout && stdout.isTTY));
|
|
9850
10418
|
}
|
|
10419
|
+
function submissionSummary(plan, final, theme) {
|
|
10420
|
+
const destination = `${theme.target(plan.name)}${plan.unit_id ? theme.dim(` unit ${plan.unit_id}`) : ""}`;
|
|
10421
|
+
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`];
|
|
10422
|
+
if (plan.removed.length) lines.push(`${theme.dim("Remove")} ${theme.tone("danger", plan.removed.join(", "))} ${theme.dim("first")}`);
|
|
10423
|
+
if (plan.statement) lines.push(`${theme.dim(" Agree")} "${plan.statement}"`);
|
|
10424
|
+
lines.push(final ? theme.tone("warning", "Then submit for grading. Moodle does not allow undoing this.") : theme.dim("Moodle keeps a draft where the assignment allows drafts; otherwise it submits at once."));
|
|
10425
|
+
return lines.join("\n");
|
|
10426
|
+
}
|
|
9851
10427
|
function parsePositiveInt(value) {
|
|
9852
10428
|
const parsed = Number(value);
|
|
9853
10429
|
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
@@ -9888,11 +10464,24 @@ function parseFields(data, value) {
|
|
|
9888
10464
|
}
|
|
9889
10465
|
return fields2;
|
|
9890
10466
|
}
|
|
9891
|
-
function
|
|
9892
|
-
|
|
9893
|
-
|
|
9894
|
-
|
|
9895
|
-
|
|
10467
|
+
function pickUnit(io, ui) {
|
|
10468
|
+
return async () => {
|
|
10469
|
+
const spin = ui.spinner();
|
|
10470
|
+
spin.start("Loading your units");
|
|
10471
|
+
try {
|
|
10472
|
+
return await selectUnit(spin);
|
|
10473
|
+
} catch (error) {
|
|
10474
|
+
spin.stop("Could not load your units");
|
|
10475
|
+
throw error;
|
|
10476
|
+
}
|
|
10477
|
+
};
|
|
10478
|
+
async function selectUnit(spin) {
|
|
10479
|
+
const { baseUrl } = await loadConfig({ env: io.env, cwd: io.cwd, homeDir: io.homeDir, stdin: io.stdin, stderr: io.stderr ?? process.stderr, fetch: io.fetchImpl });
|
|
10480
|
+
const client = await createMoodleClient(baseUrl, { env: io.env, fetchImpl: io.fetchImpl, homeDir: io.homeDir });
|
|
10481
|
+
const courses = await client.getCourses();
|
|
10482
|
+
spin.stop(`${courses.length} units`);
|
|
10483
|
+
return ui.select("Which unit?", courses.map((course) => ({ value: String(course.id), label: course.fullname, ...course.shortname ? { hint: course.shortname } : {} })));
|
|
10484
|
+
}
|
|
9896
10485
|
}
|
|
9897
10486
|
function parseRootOutputOptions(args) {
|
|
9898
10487
|
const fieldsIndex = args.findIndex((arg) => arg === "--fields" || arg.startsWith("--fields="));
|