brightspace-mcp-server 3.15.4 → 4.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/build/auth/browser-auth.js +2 -1
- package/build/doctor.js +47 -47
- package/build/setup/args.js +107 -0
- package/build/setup/clients.js +189 -0
- package/build/setup/config.js +90 -0
- package/build/setup/prepare.js +298 -0
- package/build/setup/presets.js +91 -0
- package/build/setup.js +238 -395
- package/build/tools/get-my-groups.js +14 -2
- package/build/tools/group-membership.js +9 -4
- package/build/utils/browser-install.js +77 -3
- package/build/utils/commands.js +30 -0
- package/build/utils/mcp-client-cli.js +13 -0
- package/build/utils/saved-session.js +59 -0
- package/package.json +1 -1
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brightspace MCP Server
|
|
3
|
+
* Copyright (c) 2026 Rohan Muppa. All rights reserved.
|
|
4
|
+
* Licensed under MIT — see LICENSE file for details.
|
|
5
|
+
*
|
|
6
|
+
* `setup --prepare`: every install step an AI agent can do on its own, so the
|
|
7
|
+
* person is needed only for the last one (their password and sign-in
|
|
8
|
+
* approval). It never reads stdin, never reads or writes a password, and
|
|
9
|
+
* never starts a sign-in. Safe to run again: a second run changes nothing.
|
|
10
|
+
*/
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import { nodeVersionSupported, readSavedSessionToken, sessionDirFor } from "../utils/saved-session.js";
|
|
13
|
+
import { discoverVersions } from "../api/version-discovery.js";
|
|
14
|
+
import { getConfigStorePath } from "../utils/config-store.js";
|
|
15
|
+
import { saveSecureConfig } from "../utils/secure-config.js";
|
|
16
|
+
import { chromiumReady, chromiumStatus, installChromium, PLAYWRIGHT_INSTALL_HINT, } from "../utils/browser-install.js";
|
|
17
|
+
import { DOCTOR_COMMAND, prepareCommand, signInCommand } from "../utils/commands.js";
|
|
18
|
+
import { runCommandWithoutPrompts } from "../utils/mcp-client-cli.js";
|
|
19
|
+
import { buildConfigToSave, isValidUrl, normalizeUrl, readExistingConfig, sameSchool } from "./config.js";
|
|
20
|
+
import { detectMcpClients, registerAll } from "./clients.js";
|
|
21
|
+
import { presetForBaseUrl } from "./presets.js";
|
|
22
|
+
/**
|
|
23
|
+
* Whether the native credential store can be used, without unlocking or
|
|
24
|
+
* reading anything: a keychain read on macOS can pop an "allow access" dialog
|
|
25
|
+
* and a Linux lookup can ask to unlock the keyring, and prepare must never
|
|
26
|
+
* stop for the person. The store is really used at sign-in, which says so if
|
|
27
|
+
* it is locked.
|
|
28
|
+
*/
|
|
29
|
+
export function probeCredentialStore(platform) {
|
|
30
|
+
if (platform === "darwin")
|
|
31
|
+
return { ok: true, line: "Passwords will be kept in the macOS Keychain" };
|
|
32
|
+
if (platform === "win32")
|
|
33
|
+
return { ok: true, line: "Passwords will be kept in Windows Credential Manager" };
|
|
34
|
+
if (platform !== "linux")
|
|
35
|
+
return { ok: false, line: `No supported credential store on ${platform}` };
|
|
36
|
+
// `search` without --unlock only lists, and stdout is discarded: for an
|
|
37
|
+
// unlocked item it would include the secret itself.
|
|
38
|
+
const result = spawnSync("secret-tool", ["search", "--all", "service", "brightspace-mcp-server"], {
|
|
39
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
40
|
+
timeout: 10_000,
|
|
41
|
+
});
|
|
42
|
+
if (result.error) {
|
|
43
|
+
return { ok: false, line: "secret-tool is not installed — install libsecret-tools (Debian/Ubuntu) or your distribution's libsecret tools" };
|
|
44
|
+
}
|
|
45
|
+
if (result.status !== 0) {
|
|
46
|
+
return { ok: false, line: "The Secret Service isn't running — unlock your desktop keyring, then run prepare again" };
|
|
47
|
+
}
|
|
48
|
+
return { ok: true, line: "Passwords will be kept in the Secret Service (libsecret)" };
|
|
49
|
+
}
|
|
50
|
+
function defaultPrepareDeps(json) {
|
|
51
|
+
const progress = json ? (line) => console.error(line) : (line) => console.log(line);
|
|
52
|
+
return {
|
|
53
|
+
nodeVersion: process.version,
|
|
54
|
+
platform: process.platform,
|
|
55
|
+
env: process.env,
|
|
56
|
+
readExistingConfig,
|
|
57
|
+
saveSecureConfig,
|
|
58
|
+
configPath: getConfigStorePath,
|
|
59
|
+
probeCredentialStore,
|
|
60
|
+
discoverVersions,
|
|
61
|
+
chromiumStatus: () => chromiumStatus(),
|
|
62
|
+
installChromium: () => installChromium(),
|
|
63
|
+
detectClients: () => detectMcpClients({ runCommand: runCommandWithoutPrompts, warn: progress }),
|
|
64
|
+
getSessionToken: (baseUrl, sessionDir) => readSavedSessionToken(baseUrl, sessionDir),
|
|
65
|
+
progress,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** Key order doesn't matter when deciding whether the saved settings changed. */
|
|
69
|
+
function sameSettings(a, b) {
|
|
70
|
+
const canonical = (value) => Array.isArray(value)
|
|
71
|
+
? value.map(canonical)
|
|
72
|
+
: value && typeof value === "object"
|
|
73
|
+
? Object.fromEntries(Object.entries(value)
|
|
74
|
+
.filter(([, v]) => v !== undefined)
|
|
75
|
+
.sort(([x], [y]) => x.localeCompare(y))
|
|
76
|
+
.map(([k, v]) => [k, canonical(v)]))
|
|
77
|
+
: value;
|
|
78
|
+
return a !== null && JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
|
|
79
|
+
}
|
|
80
|
+
const ok = (id, line, blocking = true) => ({ id, ok: true, blocking, line: `✓ ${line}` });
|
|
81
|
+
const fail = (id, line, blocking = true) => ({ id, ok: false, blocking, line: `✗ ${line}` });
|
|
82
|
+
function appLine(outcome) {
|
|
83
|
+
switch (outcome.outcome) {
|
|
84
|
+
case "added":
|
|
85
|
+
return ok("app", `${outcome.name}: Brightspace added`, false);
|
|
86
|
+
case "replaced":
|
|
87
|
+
return ok("app", `${outcome.name}: old "brightspace" entry replaced`, false);
|
|
88
|
+
case "current":
|
|
89
|
+
return ok("app", `${outcome.name}: already set up`, false);
|
|
90
|
+
case "different_left":
|
|
91
|
+
return fail("app", `${outcome.name}: "brightspace" already runs ${outcome.current} — left as is (pass --replace to change it)`, false);
|
|
92
|
+
case "failed":
|
|
93
|
+
return fail("app", `${outcome.name}: could not register (${outcome.message})`, false);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export async function runPrepare(args, overrides = {}) {
|
|
97
|
+
const deps = { ...defaultPrepareDeps(args.json), ...overrides };
|
|
98
|
+
const steps = [];
|
|
99
|
+
// ── Node ──
|
|
100
|
+
steps.push(nodeVersionSupported(deps.nodeVersion)
|
|
101
|
+
? ok("node", `Node.js ${deps.nodeVersion} (20 or newer)`)
|
|
102
|
+
: fail("node", `Node.js ${deps.nodeVersion} is too old — install Node 20 or newer from https://nodejs.org/`));
|
|
103
|
+
// ── School ──
|
|
104
|
+
const existing = deps.readExistingConfig();
|
|
105
|
+
let baseUrl;
|
|
106
|
+
let invalidUrl = false;
|
|
107
|
+
let schoolName;
|
|
108
|
+
if (args.preset) {
|
|
109
|
+
baseUrl = args.preset.baseUrl;
|
|
110
|
+
schoolName = args.preset.name;
|
|
111
|
+
}
|
|
112
|
+
else if (args.url) {
|
|
113
|
+
const normalized = normalizeUrl(args.url);
|
|
114
|
+
if (isValidUrl(normalized))
|
|
115
|
+
baseUrl = new URL(normalized).origin;
|
|
116
|
+
else
|
|
117
|
+
invalidUrl = true;
|
|
118
|
+
if (invalidUrl)
|
|
119
|
+
steps.push(fail("school", `"${args.url}" isn't a valid Brightspace address — pass it like --url purdue.brightspace.com`));
|
|
120
|
+
}
|
|
121
|
+
else if (existing?.baseUrl) {
|
|
122
|
+
baseUrl = existing.baseUrl;
|
|
123
|
+
}
|
|
124
|
+
if (baseUrl) {
|
|
125
|
+
schoolName ??= presetForBaseUrl(baseUrl)?.preset.name;
|
|
126
|
+
steps.push(ok("school", schoolName ? `School: ${schoolName} (${baseUrl})` : `School: ${baseUrl}`));
|
|
127
|
+
}
|
|
128
|
+
else if (!args.url) {
|
|
129
|
+
steps.push(fail("school", "No school given — pass your school's flag (for example --purdue) or --url <your Brightspace address>"));
|
|
130
|
+
}
|
|
131
|
+
// ── Credential store ──
|
|
132
|
+
const store = deps.probeCredentialStore(deps.platform);
|
|
133
|
+
steps.push(store.ok ? ok("credential-store", store.line) : fail("credential-store", store.line));
|
|
134
|
+
// ── Chromium ──
|
|
135
|
+
let browser = await deps.chromiumStatus();
|
|
136
|
+
if (!chromiumReady(browser) && args.installBrowser) {
|
|
137
|
+
deps.progress(" Downloading Chromium for sign-in (this can take a few minutes)...");
|
|
138
|
+
const installed = await deps.installChromium();
|
|
139
|
+
browser = installed.ok ? await deps.chromiumStatus() : browser;
|
|
140
|
+
if (!installed.ok && installed.message)
|
|
141
|
+
deps.progress(` ${installed.message}`);
|
|
142
|
+
}
|
|
143
|
+
if (chromiumReady(browser)) {
|
|
144
|
+
steps.push(ok("browser", browser.headless === "unknown"
|
|
145
|
+
? `Chromium is installed (a test launch didn't work here: ${browser.detail ?? "unknown reason"}; sign-in in a terminal normally still works)`
|
|
146
|
+
: "Chromium is installed for sign-in"));
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
steps.push(fail("browser", args.installBrowser
|
|
150
|
+
? `Chromium could not be installed. ${PLAYWRIGHT_INSTALL_HINT}`
|
|
151
|
+
: "Chromium is missing — run prepare again without --no-browser-install"));
|
|
152
|
+
}
|
|
153
|
+
// ── Network, settings, session (need a school) ──
|
|
154
|
+
let signedIn = false;
|
|
155
|
+
if (baseUrl) {
|
|
156
|
+
let reachable = true;
|
|
157
|
+
try {
|
|
158
|
+
await deps.discoverVersions(baseUrl);
|
|
159
|
+
steps.push(ok("network", `${baseUrl} is reachable`));
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
reachable = false;
|
|
163
|
+
steps.push(fail("network", `Could not reach ${baseUrl} — check the internet connection and the school address`));
|
|
164
|
+
}
|
|
165
|
+
const carried = existing && sameSchool(existing.baseUrl, baseUrl) ? existing : null;
|
|
166
|
+
const username = args.username ?? carried?.username;
|
|
167
|
+
const campus = args.campus ?? carried?.campus;
|
|
168
|
+
const config = buildConfigToSave(existing, {
|
|
169
|
+
baseUrl,
|
|
170
|
+
username: username ?? "",
|
|
171
|
+
// No password: the keychain is left exactly as it is.
|
|
172
|
+
password: undefined,
|
|
173
|
+
headless: carried?.headless ?? true,
|
|
174
|
+
rememberMfa: undefined,
|
|
175
|
+
campus,
|
|
176
|
+
});
|
|
177
|
+
if (!username)
|
|
178
|
+
delete config.username;
|
|
179
|
+
// Never a secret: the keychain is the person's step, at sign-in.
|
|
180
|
+
delete config.password;
|
|
181
|
+
delete config.totpUri;
|
|
182
|
+
const notes = [];
|
|
183
|
+
if (carried?.username && args.username && carried.username !== args.username) {
|
|
184
|
+
notes.push(`was ${carried.username}; sign-in starts fresh for the new account`);
|
|
185
|
+
}
|
|
186
|
+
if (!username)
|
|
187
|
+
notes.push("sign-in will ask for the username");
|
|
188
|
+
const preset = presetForBaseUrl(baseUrl)?.preset;
|
|
189
|
+
if (preset?.campusPrompt && !campus)
|
|
190
|
+
notes.push("sign-in will ask which campus");
|
|
191
|
+
const noteText = notes.length ? ` (${notes.join("; ")})` : "";
|
|
192
|
+
if (!reachable) {
|
|
193
|
+
// A mistyped --url must not replace a working setup: nothing is saved
|
|
194
|
+
// until the school answers.
|
|
195
|
+
steps.push(fail("settings", "Settings not saved, because the school couldn't be reached — the saved setup is unchanged"));
|
|
196
|
+
}
|
|
197
|
+
else if (existing?.password) {
|
|
198
|
+
// A password from before v2 still sits in config.json, and loading the
|
|
199
|
+
// config moves it into the credential store. Rewriting the file here
|
|
200
|
+
// would drop the only copy, so it is left exactly as it is.
|
|
201
|
+
steps.push(ok("settings", "Settings left as they are: this computer still has a password from an older version, which the next sign-in moves to the credential store"));
|
|
202
|
+
}
|
|
203
|
+
else if (sameSettings(existing, config)) {
|
|
204
|
+
steps.push(ok("settings", `Settings already saved, no password stored by prepare${noteText}`));
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
try {
|
|
208
|
+
await deps.saveSecureConfig(config);
|
|
209
|
+
steps.push(ok("settings", `Settings saved to ${deps.configPath()}, no password${noteText}`));
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
steps.push(fail("settings", `Could not save settings: ${error instanceof Error ? error.message : String(error)}`));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (username) {
|
|
216
|
+
try {
|
|
217
|
+
const token = await deps.getSessionToken(baseUrl, sessionDirFor(deps.env, config, baseUrl, username));
|
|
218
|
+
signedIn = Boolean(token);
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
signedIn = false;
|
|
222
|
+
}
|
|
223
|
+
steps.push(signedIn
|
|
224
|
+
? ok("session", "Already signed in — a saved sign-in still works", false)
|
|
225
|
+
: ok("session", "Not signed in yet — that's the person's step", false));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
// ── AI apps ──
|
|
229
|
+
const targets = deps.detectClients().filter((target) => target.detected);
|
|
230
|
+
const apps = registerAll(targets, { replace: args.replace });
|
|
231
|
+
steps.push(...apps.map(appLine));
|
|
232
|
+
if (targets.length === 0) {
|
|
233
|
+
steps.push(fail("app", "No supported AI app found — register `npx -y brightspace-mcp-server@latest` in your app by hand", false));
|
|
234
|
+
}
|
|
235
|
+
const restart = apps.filter((a) => a.outcome === "added" || a.outcome === "replaced").map((a) => a.name);
|
|
236
|
+
// ── Outcome ──
|
|
237
|
+
const blocked = steps.some((step) => !step.ok && step.blocking && step.id !== "school");
|
|
238
|
+
const state = !baseUrl ? "needs_input" : blocked ? "blocked" : signedIn ? "signed_in" : "ready_for_sign_in";
|
|
239
|
+
const restartText = restart.length ? `Restart ${restart.join(", ")} so it loads the server. ` : "";
|
|
240
|
+
const next = state === "ready_for_sign_in"
|
|
241
|
+
? {
|
|
242
|
+
command: signInCommand(baseUrl),
|
|
243
|
+
needsHuman: true,
|
|
244
|
+
instructions: "Have the person run this in a terminal they can type in, not a background shell. It asks for their " +
|
|
245
|
+
"password (shown as asterisks) and how they confirm sign-in, then waits for their approval on their phone. " +
|
|
246
|
+
`It is finished when it prints "Setup complete!". Then run: ${DOCTOR_COMMAND} --json. ${restartText}`.trimEnd(),
|
|
247
|
+
}
|
|
248
|
+
: state === "signed_in"
|
|
249
|
+
? {
|
|
250
|
+
command: `${DOCTOR_COMMAND} --json`,
|
|
251
|
+
needsHuman: false,
|
|
252
|
+
instructions: `Already signed in. ${restartText}Then ask it a question, such as "What's due this week?"`,
|
|
253
|
+
}
|
|
254
|
+
: state === "blocked"
|
|
255
|
+
? {
|
|
256
|
+
command: prepareCommand(baseUrl),
|
|
257
|
+
needsHuman: false,
|
|
258
|
+
instructions: "Fix each ✗ step above, then run prepare again.",
|
|
259
|
+
}
|
|
260
|
+
: {
|
|
261
|
+
needsHuman: true,
|
|
262
|
+
instructions: invalidUrl
|
|
263
|
+
? "The address given with --url isn't a valid Brightspace address. Ask the person for the address they open to see their courses, then run prepare again with --url <that address>."
|
|
264
|
+
: "Ask the person which school they're at, then run prepare again with its flag (for example --purdue) or --url <their Brightspace address>.",
|
|
265
|
+
};
|
|
266
|
+
return {
|
|
267
|
+
state,
|
|
268
|
+
...(baseUrl ? { school: { baseUrl, ...(schoolName ? { name: schoolName } : {}) } } : {}),
|
|
269
|
+
steps,
|
|
270
|
+
apps,
|
|
271
|
+
restart,
|
|
272
|
+
next,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
/** The result as a person (or an agent without --json) reads it. */
|
|
276
|
+
export function formatPrepare(result) {
|
|
277
|
+
const lines = ["", "Brightspace MCP Server — prepare", ""];
|
|
278
|
+
for (const step of result.steps)
|
|
279
|
+
lines.push(` ${step.line}`);
|
|
280
|
+
lines.push("");
|
|
281
|
+
if (result.state === "ready_for_sign_in") {
|
|
282
|
+
lines.push("Ready for sign-in. The last step is for the person, in a terminal:");
|
|
283
|
+
}
|
|
284
|
+
else if (result.state === "signed_in") {
|
|
285
|
+
lines.push("Ready to use.");
|
|
286
|
+
}
|
|
287
|
+
else if (result.state === "blocked") {
|
|
288
|
+
lines.push("Not ready yet.");
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
lines.push("Need the school first.");
|
|
292
|
+
}
|
|
293
|
+
if (result.next.command)
|
|
294
|
+
lines.push(` ${result.next.command}`);
|
|
295
|
+
lines.push(` ${result.next.instructions}`);
|
|
296
|
+
lines.push("");
|
|
297
|
+
return lines;
|
|
298
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brightspace MCP Server
|
|
3
|
+
* Copyright (c) 2026 Rohan Muppa. All rights reserved.
|
|
4
|
+
* Licensed under MIT — see LICENSE file for details.
|
|
5
|
+
*/
|
|
6
|
+
export const SCHOOL_PRESETS = {
|
|
7
|
+
tudelft: {
|
|
8
|
+
name: "TU Delft",
|
|
9
|
+
baseUrl: "https://brightspace.tudelft.nl",
|
|
10
|
+
usernameLabel: "TU Delft NetID",
|
|
11
|
+
mfaNote: "NetID sign-in normally runs headlessly without MFA.",
|
|
12
|
+
usernameHint: "Use your NetID, not your student email address.",
|
|
13
|
+
},
|
|
14
|
+
leiden: {
|
|
15
|
+
name: "Leiden University",
|
|
16
|
+
baseUrl: "https://brightspace.universiteitleiden.nl",
|
|
17
|
+
usernameLabel: "Leiden University username",
|
|
18
|
+
mfaNote: "Leiden usually asks for a code from your authenticator app, which you can type here.",
|
|
19
|
+
usernameHint: "Use the full address you sign in to Microsoft with, e.g. s1234567@vuw.leidenuniv.nl.",
|
|
20
|
+
},
|
|
21
|
+
purdue: {
|
|
22
|
+
name: "Purdue University",
|
|
23
|
+
baseUrl: "https://purdue.brightspace.com",
|
|
24
|
+
usernameLabel: "Purdue career account username or full email",
|
|
25
|
+
mfaNote: "Microsoft Authenticator number matching can run without a browser window.",
|
|
26
|
+
},
|
|
27
|
+
suny: {
|
|
28
|
+
name: "SUNY",
|
|
29
|
+
baseUrl: "https://mylearning.suny.edu",
|
|
30
|
+
usernameLabel: "SUNY campus username",
|
|
31
|
+
mfaNote: "Approve the sign-in request from your campus MFA app.",
|
|
32
|
+
campusPrompt: "Which SUNY campus are you at? (e.g. SUNY Poly)",
|
|
33
|
+
usernameHint: "Most campuses want the full sign-in address, e.g. abc123@sunypoly.edu",
|
|
34
|
+
},
|
|
35
|
+
western: {
|
|
36
|
+
name: "Western University",
|
|
37
|
+
baseUrl: "https://westernu.brightspace.com",
|
|
38
|
+
usernameLabel: "Western account username or full email",
|
|
39
|
+
mfaNote: "Approve the sign-in request from your MFA app.",
|
|
40
|
+
usernameHint: "Use your full sign-in address if your Western account requires it.",
|
|
41
|
+
},
|
|
42
|
+
cuny: {
|
|
43
|
+
name: "CUNY",
|
|
44
|
+
baseUrl: "https://brightspace.cuny.edu",
|
|
45
|
+
usernameLabel: "CUNY Login username",
|
|
46
|
+
mfaNote: "Type the 6-digit code from your authenticator app when prompted.",
|
|
47
|
+
usernameHint: "Use your full CUNY Login address, e.g. firstname.lastname01@login.cuny.edu",
|
|
48
|
+
},
|
|
49
|
+
ngeeann: {
|
|
50
|
+
name: "Ngee Ann Polytechnic",
|
|
51
|
+
baseUrl: "https://nplms.polite.edu.sg",
|
|
52
|
+
usernameLabel: "Ngee Ann Polytechnic account username or full email",
|
|
53
|
+
mfaNote: "Approve the sign-in request from your MFA app.",
|
|
54
|
+
usernameHint: "Use your full sign-in address if your Ngee Ann Polytechnic account requires it.",
|
|
55
|
+
},
|
|
56
|
+
mcgill: {
|
|
57
|
+
name: "McGill University",
|
|
58
|
+
baseUrl: "https://mycourses2.mcgill.ca",
|
|
59
|
+
usernameLabel: "McGill username or full email",
|
|
60
|
+
mfaNote: "Approve the sign-in request from your MFA app.",
|
|
61
|
+
usernameHint: "Use your full sign-in address if your McGill account requires it.",
|
|
62
|
+
},
|
|
63
|
+
javeriana: {
|
|
64
|
+
name: "Pontificia Universidad Javeriana Cali",
|
|
65
|
+
baseUrl: "https://auladigital.javerianacali.edu.co",
|
|
66
|
+
usernameLabel: "Javeriana username",
|
|
67
|
+
mfaNote: "OneGate asks for a code from your authenticator app when you sign in.",
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
/** The preset whose school runs at `baseUrl`, compared by origin. */
|
|
71
|
+
export function presetForBaseUrl(baseUrl) {
|
|
72
|
+
let origin;
|
|
73
|
+
try {
|
|
74
|
+
origin = new URL(baseUrl).origin;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
for (const [flag, preset] of Object.entries(SCHOOL_PRESETS)) {
|
|
80
|
+
if (new URL(preset.baseUrl).origin === origin)
|
|
81
|
+
return { flag, preset };
|
|
82
|
+
}
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
/** The preset named by `flag` (no leading dashes), own properties only. */
|
|
86
|
+
export function presetForFlag(flag) {
|
|
87
|
+
const key = flag.toLowerCase();
|
|
88
|
+
// A bare index would make `--constructor` or `--__proto__` resolve to
|
|
89
|
+
// something off `Object.prototype` and hand setup an object with no `baseUrl`.
|
|
90
|
+
return Object.prototype.hasOwnProperty.call(SCHOOL_PRESETS, key) ? SCHOOL_PRESETS[key] : undefined;
|
|
91
|
+
}
|