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/build/setup.js CHANGED
@@ -5,104 +5,44 @@
5
5
  * Licensed under MIT — see LICENSE file for details.
6
6
  *
7
7
  * https://github.com/rohanmuppa/brightspace-mcp-server
8
+ *
9
+ * `setup`, in three modes (see src/setup/args.ts): the interactive wizard,
10
+ * `--prepare` for an AI agent (src/setup/prepare.ts, no prompts), and
11
+ * `--sign-in`, the person's last step after prepare.
8
12
  */
9
13
  import * as readline from "node:readline";
10
- import * as fs from "node:fs";
11
14
  import * as path from "node:path";
12
- import * as os from "node:os";
13
15
  import { spawn } from "node:child_process";
14
16
  import { fileURLToPath } from "node:url";
15
- import { configStoreExists, getConfigStorePath, loadConfigStore, } from "./utils/config-store.js";
17
+ import { getConfigStorePath } from "./utils/config-store.js";
16
18
  import { saveSecureConfig } from "./utils/secure-config.js";
17
- import { deleteStoredTotpUri, getStoredTotpUri } from "./auth/credential-store.js";
19
+ import { deleteStoredTotpUri, getStoredPassword, getStoredTotpUri } from "./auth/credential-store.js";
18
20
  import { askShortcutQuestion } from "./utils/two-factor-shortcuts.js";
19
- import { writeFileAtomicSync } from "./utils/atomic-write.js";
20
- import { AUTH_COMMAND, DOCTOR_COMMAND } from "./utils/commands.js";
21
- import { classifyRegistration, cliMcpClients, inspectCliMcpClient, isCliAvailable, registerCliMcpClient, serverCommand, } from "./utils/mcp-client-cli.js";
21
+ import { DOCTOR_COMMAND, prepareCommand, signInCommand } from "./utils/commands.js";
22
+ import { serverCommand } from "./utils/mcp-client-cli.js";
23
+ import { parseSetupArgs, SETUP_USAGE } from "./setup/args.js";
24
+ import { buildConfigToSave, isValidUrl, normalizeUrl, readExistingConfig, sameSchool } from "./setup/config.js";
25
+ import { detectMcpClients, isChatGPTInstalled } from "./setup/clients.js";
26
+ import { presetForBaseUrl } from "./setup/presets.js";
27
+ import { formatPrepare, runPrepare } from "./setup/prepare.js";
28
+ // Kept importable from here for existing callers and tests.
29
+ export { SCHOOL_PRESETS } from "./setup/presets.js";
30
+ export { buildConfigToSave, readExistingConfig } from "./setup/config.js";
31
+ export { configureMcpClient, inspectMcpClient } from "./setup/clients.js";
22
32
  // ANSI helpers
23
33
  const bold = (s) => `\x1b[1m${s}\x1b[0m`;
24
34
  const green = (s) => `\x1b[32m${s}\x1b[0m`;
25
35
  const dim = (s) => `\x1b[2m${s}\x1b[0m`;
26
36
  const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
27
37
  const thisDir = path.dirname(fileURLToPath(import.meta.url));
28
- export const SCHOOL_PRESETS = {
29
- tudelft: {
30
- name: "TU Delft",
31
- baseUrl: "https://brightspace.tudelft.nl",
32
- usernameLabel: "TU Delft NetID",
33
- mfaNote: "NetID sign-in normally runs headlessly without MFA.",
34
- usernameHint: "Use your NetID, not your student email address.",
35
- },
36
- leiden: {
37
- name: "Leiden University",
38
- baseUrl: "https://brightspace.universiteitleiden.nl",
39
- usernameLabel: "Leiden University username",
40
- mfaNote: "Leiden usually asks for a code from your authenticator app, which you can type here.",
41
- usernameHint: "Use the full address you sign in to Microsoft with, e.g. s1234567@vuw.leidenuniv.nl.",
42
- },
43
- purdue: {
44
- name: "Purdue University",
45
- baseUrl: "https://purdue.brightspace.com",
46
- usernameLabel: "Purdue career account username or full email",
47
- mfaNote: "Microsoft Authenticator number matching can run without a browser window.",
48
- },
49
- suny: {
50
- name: "SUNY",
51
- baseUrl: "https://mylearning.suny.edu",
52
- usernameLabel: "SUNY campus username",
53
- mfaNote: "Approve the sign-in request from your campus MFA app.",
54
- campusPrompt: "Which SUNY campus are you at? (e.g. SUNY Poly)",
55
- usernameHint: "Most campuses want the full sign-in address, e.g. abc123@sunypoly.edu",
56
- },
57
- western: {
58
- name: "Western University",
59
- baseUrl: "https://westernu.brightspace.com",
60
- usernameLabel: "Western account username or full email",
61
- mfaNote: "Approve the sign-in request from your MFA app.",
62
- usernameHint: "Use your full sign-in address if your Western account requires it.",
63
- },
64
- cuny: {
65
- name: "CUNY",
66
- baseUrl: "https://brightspace.cuny.edu",
67
- usernameLabel: "CUNY Login username",
68
- mfaNote: "Type the 6-digit code from your authenticator app when prompted.",
69
- usernameHint: "Use your full CUNY Login address, e.g. firstname.lastname01@login.cuny.edu",
70
- },
71
- ngeeann: {
72
- name: "Ngee Ann Polytechnic",
73
- baseUrl: "https://nplms.polite.edu.sg",
74
- usernameLabel: "Ngee Ann Polytechnic account username or full email",
75
- mfaNote: "Approve the sign-in request from your MFA app.",
76
- usernameHint: "Use your full sign-in address if your Ngee Ann Polytechnic account requires it.",
77
- },
78
- mcgill: {
79
- name: "McGill University",
80
- baseUrl: "https://mycourses2.mcgill.ca",
81
- usernameLabel: "McGill username or full email",
82
- mfaNote: "Approve the sign-in request from your MFA app.",
83
- usernameHint: "Use your full sign-in address if your McGill account requires it.",
84
- },
85
- javeriana: {
86
- name: "Pontificia Universidad Javeriana Cali",
87
- baseUrl: "https://auladigital.javerianacali.edu.co",
88
- usernameLabel: "Javeriana username",
89
- mfaNote: "OneGate asks for a code from your authenticator app when you sign in.",
90
- },
91
- };
92
38
  /**
93
- * Pick the school preset named by `--purdue`, `--suny`, `--western`, `--cuny`, `--mcgill`, `--ngeeann`, `--javeriana`, etc.
94
- *
95
- * Own properties only: a bare index would make `--constructor` or
96
- * `--__proto__` resolve to something off `Object.prototype` and hand the
97
- * wizard an object with no `baseUrl`.
39
+ * The school preset named by a flag such as `--purdue`, anywhere on the
40
+ * command line. Undefined when there is none or the flags don't parse.
98
41
  */
99
42
  export function presetForArgv(argv = process.argv) {
100
- const flag = argv.find((a) => a.startsWith("--"))?.replace(/^--/, "").toLowerCase();
101
- if (!flag || !Object.prototype.hasOwnProperty.call(SCHOOL_PRESETS, flag))
102
- return undefined;
103
- return SCHOOL_PRESETS[flag];
43
+ const parsed = parseSetupArgs(argv);
44
+ return parsed.ok ? parsed.args.preset : undefined;
104
45
  }
105
- const preset = presetForArgv();
106
46
  // ── Readline helpers ───────────────────────────────────────────────
107
47
  function ask(rl, question) {
108
48
  return new Promise((resolve) => {
@@ -191,223 +131,54 @@ function askPassword(prompt) {
191
131
  process.stdin.on("data", onData);
192
132
  });
193
133
  }
194
- // ── URL validation ─────────────────────────────────────────────────
195
- function normalizeUrl(input) {
196
- let url = input.trim();
197
- // Strip trailing slashes
198
- url = url.replace(/\/+$/, "");
199
- // Auto-prepend https://
200
- if (!/^https?:\/\//i.test(url)) {
201
- url = `https://${url}`;
202
- }
203
- // Upgrade http:// to https://
204
- url = url.replace(/^http:\/\//i, "https://");
205
- return url;
206
- }
207
- function isValidUrl(url) {
208
- try {
209
- const parsed = new URL(url);
210
- return parsed.protocol === "https:" && !parsed.username && !parsed.password;
211
- }
212
- catch {
213
- return false;
214
- }
215
- }
216
- function getClaudeDesktopConfigPath() {
217
- const platform = os.platform();
218
- if (platform === "darwin") {
219
- return path.join(os.homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
220
- }
221
- if (platform === "win32") {
222
- const appdata = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
223
- return path.join(appdata, "Claude", "claude_desktop_config.json");
224
- }
225
- if (platform === "linux") {
226
- return path.join(os.homedir(), ".config", "Claude", "claude_desktop_config.json");
227
- }
228
- return null;
229
- }
230
- function isChatGPTInstalled() {
231
- const platform = os.platform();
232
- if (platform === "darwin") {
233
- return (fs.existsSync("/Applications/ChatGPT.app") ||
234
- fs.existsSync(path.join(os.homedir(), "Applications", "ChatGPT.app")));
235
- }
236
- if (platform === "win32") {
237
- const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
238
- return fs.existsSync(path.join(localAppData, "Programs", "ChatGPT", "ChatGPT.exe"));
239
- }
240
- return false;
241
- }
242
- function getCursorConfigPath() {
243
- return path.join(os.homedir(), ".cursor", "mcp.json");
244
- }
245
- function getAntigravityConfigPath() {
246
- return path.join(os.homedir(), ".gemini", "antigravity", "mcp_config.json");
247
- }
248
- /**
249
- * A JSON value we can safely merge a server entry into. An array passes
250
- * `typeof x === "object"` but drops every added key when it is stringified
251
- * again, so it has to be rejected alongside `null`.
252
- */
253
- function isJsonObject(value) {
254
- return typeof value === "object" && value !== null && !Array.isArray(value);
255
- }
256
- /** How the client's existing "brightspace" entry compares with ours. */
257
- export function inspectMcpClient(configPath) {
258
- let config;
259
- try {
260
- config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
261
- }
262
- catch {
263
- // No file, or one configureMcpClient would replace anyway.
264
- return { state: "missing" };
265
- }
266
- const servers = isJsonObject(config) ? config.mcpServers : undefined;
267
- return classifyRegistration(isJsonObject(servers) ? servers.brightspace : undefined);
268
- }
269
- export function configureMcpClient(configPath) {
270
- let config = {};
271
- // Read existing config if present
272
- if (fs.existsSync(configPath)) {
273
- let parsed;
274
- let readable = true;
275
- try {
276
- parsed = JSON.parse(fs.readFileSync(configPath, "utf-8"));
277
- }
278
- catch {
279
- readable = false;
280
- }
281
- if (isJsonObject(parsed)) {
282
- config = parsed;
283
- }
284
- else {
285
- // Unparseable, or valid JSON that is not an object (null, an array, a
286
- // bare string). Merging into it would either throw or silently discard
287
- // the entry we just reported as written, so start fresh and say so.
288
- console.log(yellow(` Warning: existing config was ${readable ? "not a JSON object" : "invalid"}, creating new one.`));
289
- config = {};
290
- }
291
- }
292
- // Same reasoning for the servers map itself, which is hand-edited far more
293
- // often than the file around it.
294
- const servers = isJsonObject(config.mcpServers) ? config.mcpServers : {};
295
- config.mcpServers = servers;
296
- // Add/update brightspace entry
297
- const [command, ...args] = serverCommand();
298
- servers["brightspace"] = { command, args };
299
- // Ensure parent directory exists
300
- const dir = path.dirname(configPath);
301
- if (!fs.existsSync(dir)) {
302
- fs.mkdirSync(dir, { recursive: true });
303
- }
304
- // This file holds every MCP server the user has configured, not just ours.
305
- // A plain write truncates it first, so a write that fails part way through
306
- // (a full disk, an I/O error) would leave the user with no MCP servers at
307
- // all. Staging and renaming leaves either the old file or the new one.
308
- // The existing permissions are carried over, since the rename would
309
- // otherwise replace them with the umask default.
310
- let mode;
311
- try {
312
- if (fs.existsSync(configPath))
313
- mode = fs.statSync(configPath).mode & 0o777;
314
- }
315
- catch {
316
- // Unreadable metadata is not a reason to skip the write.
317
- }
318
- writeFileAtomicSync(configPath, JSON.stringify(config, null, 2) + "\n", mode === undefined ? {} : { mode });
319
- return true;
320
- }
321
- /** The settings already on disk, or null when there are none to read. */
322
- export function readExistingConfig() {
323
- try {
324
- return configStoreExists() ? loadConfigStore() : null;
325
- }
326
- catch {
327
- // An unreadable config is replaced by the setup values.
328
- return null;
329
- }
330
- }
331
- function sameSchool(stored, chosen) {
332
- // A config that never recorded a school (environment-driven installs) is
333
- // not a *different* school, so its settings are still ours to keep.
334
- if (!stored)
335
- return true;
336
- try {
337
- return new URL(stored).origin === new URL(chosen).origin;
338
- }
339
- catch {
340
- return false;
341
- }
342
- }
343
134
  /**
344
- * Merge the wizard's answers over the settings already saved.
345
- *
346
- * `saveConfigStore` replaces the whole file, and setup is the documented way
347
- * to update a saved password — so it runs again on configurations that carry
348
- * settings it never prompts for: the SUNY campus, course filters, a custom
349
- * session directory or token TTL. Writing only the answers deleted all of
350
- * them; most visibly, a SUNY user who reran plain `setup` lost the campus
351
- * that lets sign-in skip the shared campus picker.
352
- *
353
- * Settings are carried only within one school, since course ids and the
354
- * campus belong to a single tenant.
135
+ * The wizard asks everything, as it always has. Sign-in mode runs after
136
+ * `--prepare`, so it asks only what prepare couldn't know: the password (or
137
+ * whether to keep a saved one), and the username or campus if the agent
138
+ * didn't pass them. Registering apps was prepare's job.
355
139
  */
356
- export function buildConfigToSave(existing, answers) {
357
- const carried = existing && sameSchool(existing.baseUrl, answers.baseUrl) ? existing : null;
358
- const config = {
359
- ...carried,
360
- baseUrl: answers.baseUrl,
361
- username: answers.username,
362
- // Always the freshly typed one: a carried v1 plaintext password would
363
- // otherwise be the value written to the native store.
364
- password: answers.password,
365
- // Only ever the freshly typed one. Never read back out of `carried`: the
366
- // enrollment lives in the credential store, never in config.json, so a
367
- // value here could only have come from this run.
368
- ...(answers.totpUri ? { totpUri: answers.totpUri } : {}),
369
- headless: answers.headless,
140
+ export function planQuestions(mode, known) {
141
+ const signIn = mode === "sign-in";
142
+ return {
143
+ askUrl: !known.baseUrl,
144
+ askCampus: known.campusNeeded && !known.campus,
145
+ askUsername: !known.username,
146
+ password: signIn && known.passwordSaved ? "keep-or-ask" : "ask",
147
+ registerApps: !signIn,
148
+ confirmSignIn: !signIn,
370
149
  };
371
- if (answers.rememberMfa !== undefined)
372
- config.rememberMfa = answers.rememberMfa;
373
- // Setup no longer offers passwordless sign-in and always saves a password,
374
- // so a carried `passwordless: true` would make sign-in ignore that password.
375
- // D2L_PASSWORDLESS still turns it on for anyone who wants it.
376
- delete config.passwordless;
377
- if (answers.campus)
378
- config.campus = answers.campus;
379
- return config;
380
150
  }
381
- // ── Auth spawn ─────────────────────────────────────────────────────
151
+ // ── Registering with one app ───────────────────────────────────────
382
152
  /**
383
- * Registers Brightspace in one client, never silently replacing an entry
153
+ * Registers Brightspace in one app, never silently replacing an entry
384
154
  * that points somewhere else: a matching entry is left alone, and a different
385
155
  * one is printed (so it can be restored) and replaced only on a yes.
386
- * Resolves true when the client ends up running the expected entry.
156
+ * Resolves true when the app ends up running the expected entry.
387
157
  */
388
- async function registerWithClient(rl, displayName, existing, write) {
158
+ async function registerWithClient(rl, target) {
159
+ const existing = target.inspect();
389
160
  if (existing.state === "current") {
390
- console.log(green(` ${displayName} already has Brightspace configured.`));
161
+ console.log(green(` ${target.name} already has Brightspace configured.`));
391
162
  return true;
392
163
  }
393
164
  if (existing.state === "different") {
394
- console.log(yellow(` ${displayName} has "brightspace" pointing at:`));
165
+ console.log(yellow(` ${target.name} has "brightspace" pointing at:`));
395
166
  console.log(` ${existing.current}`);
396
167
  const replace = await ask(rl, ` Replace it with ${serverCommand().join(" ")}? (yes/no): `);
397
168
  if (!/^y(es)?$/i.test(replace)) {
398
- console.log(dim(` Left ${displayName} unchanged.`));
169
+ console.log(dim(` Left ${target.name} unchanged.`));
399
170
  return false;
400
171
  }
401
172
  }
402
173
  try {
403
- if (write(existing.state === "different")) {
404
- console.log(green(` ${displayName} configured!`));
174
+ if (target.write(existing.state === "different")) {
175
+ console.log(green(` ${target.name} configured!`));
405
176
  return true;
406
177
  }
407
- console.log(yellow(` Could not configure ${displayName}. See README.md for the manual command.`));
178
+ console.log(yellow(` Could not configure ${target.name}. See README.md for the manual command.`));
408
179
  }
409
180
  catch (err) {
410
- console.log(yellow(` Could not configure ${displayName}: ${err instanceof Error ? err.message : String(err)}`));
181
+ console.log(yellow(` Could not configure ${target.name}: ${err instanceof Error ? err.message : String(err)}`));
411
182
  }
412
183
  return false;
413
184
  }
@@ -422,22 +193,90 @@ function runAuth() {
422
193
  child.once("close", (code) => resolve(code === 0));
423
194
  });
424
195
  }
425
- // ── Main wizard ────────────────────────────────────────────────────
426
- async function main() {
196
+ function printChatGPTInstructions() {
197
+ if (!isChatGPTInstalled())
198
+ return;
199
+ const isWindows = process.platform === "win32";
200
+ const mcpJson = isWindows
201
+ ? `{\n "command": "cmd",\n "args": ["/c", "npx", "-y", "brightspace-mcp-server@latest"]\n}`
202
+ : `{\n "command": "npx",\n "args": ["-y", "brightspace-mcp-server@latest"]\n}`;
203
+ console.log(yellow(" ChatGPT Desktop detected."));
204
+ console.log(dim(" ChatGPT doesn't support automatic MCP config — add it manually:"));
205
+ console.log(dim(" 1. Open ChatGPT Desktop → Settings → Tools → Add MCP tool → Add manually"));
206
+ console.log(dim(" 2. Paste this config:"));
207
+ console.log("");
208
+ console.log(mcpJson);
209
+ console.log("");
210
+ }
211
+ // ── Prepare (no prompts) ───────────────────────────────────────────
212
+ async function prepare(args) {
213
+ const result = await runPrepare(args);
214
+ const lines = formatPrepare(result);
215
+ if (args.json) {
216
+ // stdout carries only the JSON an agent parses.
217
+ for (const line of lines)
218
+ console.error(line);
219
+ console.log(JSON.stringify(result, null, 2));
220
+ }
221
+ else {
222
+ for (const line of lines)
223
+ console.log(line);
224
+ }
225
+ process.exitCode = result.state === "ready_for_sign_in" || result.state === "signed_in" ? 0 : 1;
226
+ }
227
+ // ── Wizard and sign-in (interactive) ───────────────────────────────
228
+ async function interactive(args) {
229
+ const mode = args.mode === "sign-in" ? "sign-in" : "wizard";
427
230
  // Handle Ctrl+C gracefully
428
231
  process.on("SIGINT", () => {
429
232
  console.log("\n\nSetup cancelled.");
430
233
  process.exit(0);
431
234
  });
432
- console.log("");
433
- if (preset) {
434
- console.log(bold(`Brightspace MCP Server — ${preset.name} Setup`));
435
- console.log("=".repeat(`Brightspace MCP Server — ${preset.name} Setup`.length));
235
+ const saved = readExistingConfig();
236
+ // ── School ───────────────────────────────────────────────────────
237
+ let baseUrl = args.preset?.baseUrl;
238
+ if (!baseUrl && args.url) {
239
+ const normalized = normalizeUrl(args.url);
240
+ if (!isValidUrl(normalized)) {
241
+ console.error(`"${args.url}" isn't a valid Brightspace address. Pass it like --url purdue.brightspace.com`);
242
+ process.exitCode = 2;
243
+ return;
244
+ }
245
+ baseUrl = new URL(normalized).origin;
436
246
  }
437
- else {
438
- console.log(bold("Brightspace MCP Server — Setup Wizard"));
439
- console.log("======================================");
247
+ if (!baseUrl && mode === "sign-in") {
248
+ if (!saved?.baseUrl) {
249
+ console.error(`No school is saved yet. Run ${prepareCommand()} with your school's flag first, or pass the flag here.`);
250
+ process.exitCode = 2;
251
+ return;
252
+ }
253
+ baseUrl = saved.baseUrl;
440
254
  }
255
+ let preset = args.preset ?? (baseUrl ? presetForBaseUrl(baseUrl)?.preset : undefined);
256
+ const carried = baseUrl && saved && sameSchool(saved.baseUrl, baseUrl) ? saved : null;
257
+ const knownUsername = args.username ?? (mode === "sign-in" ? carried?.username : undefined);
258
+ let passwordSaved = false;
259
+ if (mode === "sign-in" && baseUrl && knownUsername) {
260
+ try {
261
+ passwordSaved = Boolean(await getStoredPassword(baseUrl, knownUsername));
262
+ }
263
+ catch {
264
+ // A locked store: ask for the password, and saving it reports the problem.
265
+ }
266
+ }
267
+ const plan = planQuestions(mode, {
268
+ baseUrl,
269
+ campusNeeded: Boolean(preset?.campusPrompt),
270
+ campus: args.campus ?? (mode === "sign-in" ? carried?.campus : undefined),
271
+ username: knownUsername,
272
+ passwordSaved,
273
+ });
274
+ console.log("");
275
+ const title = mode === "sign-in"
276
+ ? `Brightspace MCP Server — ${preset ? `${preset.name} ` : ""}Sign-in`
277
+ : preset ? `Brightspace MCP Server — ${preset.name} Setup` : "Brightspace MCP Server — Setup Wizard";
278
+ console.log(bold(title));
279
+ console.log("=".repeat(title.length));
441
280
  console.log(dim(" By Rohan Muppa — github.com/rohanmuppa/brightspace-mcp-server"));
442
281
  console.log("");
443
282
  // An assistant often opens this wizard in a side panel the user has never
@@ -449,9 +288,7 @@ async function main() {
449
288
  output: process.stdout,
450
289
  });
451
290
  // ── Step 1: Brightspace URL ──────────────────────────────────────
452
- let baseUrl = "";
453
- if (preset) {
454
- baseUrl = preset.baseUrl;
291
+ if (!plan.askUrl && baseUrl) {
455
292
  console.log(dim(` Brightspace URL: ${baseUrl}`));
456
293
  console.log("");
457
294
  }
@@ -466,56 +303,68 @@ async function main() {
466
303
  }
467
304
  baseUrl = normalized;
468
305
  }
306
+ preset ??= presetForBaseUrl(baseUrl)?.preset;
469
307
  console.log(dim(` → ${baseUrl}`));
470
308
  console.log("");
471
309
  }
472
310
  // ── Campus (shared multi-campus instances only) ──────────────────
473
- let campus = "";
474
- if (preset?.campusPrompt) {
311
+ let campus = args.campus ?? (mode === "sign-in" ? carried?.campus ?? "" : "");
312
+ if (plan.askCampus && preset?.campusPrompt) {
475
313
  console.log(dim(" Several campuses share this Brightspace site."));
476
314
  while (!campus) {
477
315
  campus = await ask(rl, `${preset.campusPrompt} `);
478
316
  if (!campus)
479
317
  console.log(yellow(" Campus is required for automatic sign-in."));
480
318
  }
481
- console.log(campus
482
- ? dim(` → ${campus}`)
483
- : dim(" Set your campus before authenticating."));
319
+ console.log(dim(` → ${campus}`));
484
320
  console.log("");
485
321
  }
486
322
  // ── Step 2: Username ─────────────────────────────────────────────
487
- const usernamePrompt = preset
488
- ? `What is your ${preset.usernameLabel}? `
489
- : "What username do you use to sign in to Brightspace? ";
490
- if (preset?.usernameHint) {
491
- console.log(dim(` ${preset.usernameHint}`));
323
+ let username = knownUsername ?? "";
324
+ if (plan.askUsername) {
325
+ const usernamePrompt = preset
326
+ ? `What is your ${preset.usernameLabel}? `
327
+ : "What username do you use to sign in to Brightspace? ";
328
+ if (preset?.usernameHint) {
329
+ console.log(dim(` ${preset.usernameHint}`));
330
+ }
331
+ else {
332
+ console.log(dim(" The same username you type on your school's Brightspace sign-in page."));
333
+ }
334
+ while (!username) {
335
+ username = await ask(rl, usernamePrompt);
336
+ if (!username) {
337
+ console.log(yellow(" Username is required."));
338
+ }
339
+ }
492
340
  }
493
341
  else {
494
- console.log(dim(" The same username you type on your school's Brightspace sign-in page."));
495
- }
496
- let username = "";
497
- while (!username) {
498
- username = await ask(rl, usernamePrompt);
499
- if (!username) {
500
- console.log(yellow(" Username is required."));
501
- }
342
+ console.log(dim(` Username: ${username}`));
502
343
  }
503
344
  console.log("");
504
345
  // ── Step 3: Password (hidden) ────────────────────────────────────
346
+ let keepPassword = false;
347
+ if (plan.password === "keep-or-ask") {
348
+ const answer = await ask(rl, ` A password is already saved for ${username}. Keep it? (yes/no) [yes]: `);
349
+ keepPassword = !answer || /^y(es)?$/i.test(answer);
350
+ console.log("");
351
+ }
505
352
  // Close the rl temporarily since askPassword manages its own
506
353
  rl.close();
507
354
  let password = "";
508
- console.log(dim(" The password you use to sign in. It won't be shown as you type — asterisks stand in for each character."));
509
- const passwordPrompt = preset
510
- ? `What is your ${preset.name} password? `
511
- : "What is your Brightspace password? ";
512
- while (!password) {
513
- password = await askPassword(passwordPrompt);
514
- if (!password) {
515
- console.log(yellow(" Password is required."));
355
+ if (!keepPassword) {
356
+ console.log(dim(" The password you use to sign in. It won't be shown as you type — asterisks stand in for each character."));
357
+ const passwordPrompt = preset
358
+ ? `What is your ${preset.name} password? `
359
+ : "What is your Brightspace password? ";
360
+ while (!password) {
361
+ password = await askPassword(passwordPrompt);
362
+ if (!password) {
363
+ console.log(yellow(" Password is required."));
364
+ }
516
365
  }
366
+ console.log("");
517
367
  }
518
- console.log("");
519
368
  // Re-open readline for remaining prompts
520
369
  let rl2 = readline.createInterface({
521
370
  input: process.stdin,
@@ -538,16 +387,8 @@ async function main() {
538
387
  console.log(" How do you confirm it's you when you sign in?");
539
388
  console.log(" 1. With a phone app: I tap Approve or type a code (recommended)");
540
389
  console.log(" 2. Another way, like a text message: show me the sign-in page");
541
- let savedHeadless;
542
- let savedRememberMfa;
543
- try {
544
- const saved = configStoreExists() ? loadConfigStore() : undefined;
545
- savedHeadless = saved?.headless;
546
- savedRememberMfa = saved?.rememberMfa;
547
- }
548
- catch {
549
- // An invalid old config is replaced by the setup values below.
550
- }
390
+ const savedHeadless = saved?.headless;
391
+ const savedRememberMfa = saved?.rememberMfa;
551
392
  const defaultMfaChoice = savedHeadless === false ? "2" : "1";
552
393
  let mfaChoice = "";
553
394
  while (!/^[12]$/.test(mfaChoice)) {
@@ -599,14 +440,17 @@ async function main() {
599
440
  const config = buildConfigToSave(readExistingConfig(), {
600
441
  baseUrl,
601
442
  username,
602
- password,
443
+ // Undefined when the saved one is kept: the credential store is left alone.
444
+ password: password || undefined,
603
445
  totpUri,
604
446
  headless,
605
447
  rememberMfa,
606
448
  campus: campus || undefined,
607
449
  });
608
450
  await saveSecureConfig(config);
609
- console.log(green(" Password saved in your operating system credential store."));
451
+ console.log(green(keepPassword
452
+ ? " Kept the password already saved in your operating system credential store."
453
+ : " Password saved in your operating system credential store."));
610
454
  if (totpUri)
611
455
  console.log(green(" Authenticator enrollment saved in your operating system credential store."));
612
456
  if (shortcuts?.removeKey) {
@@ -620,90 +464,60 @@ async function main() {
620
464
  }
621
465
  console.log(green(" Config saved to: " + getConfigStorePath()));
622
466
  console.log("");
623
- // ── Step 6: Authenticate now? ────────────────────────────────────
624
- const authNow = await ask(rl2, "Sign in now, so everything is ready to go? (yes/no): ");
625
- if (/^y(es)?$/i.test(authNow)) {
626
- rl2.close();
467
+ // ── Step 6: AI apps ──────────────────────────────────────────────
468
+ // Before signing in, so someone who stops at the approval step still has
469
+ // the server registered, and the next question in their AI app signs in.
470
+ if (plan.registerApps) {
471
+ for (const target of detectMcpClients()) {
472
+ const answer = await ask(rl2, target.question);
473
+ if (/^y(es)?$/i.test(answer) && await registerWithClient(rl2, target)) {
474
+ configuredClients.push(target.name);
475
+ }
476
+ console.log("");
477
+ }
478
+ printChatGPTInstructions();
479
+ }
480
+ // ── Step 7: Sign in ──────────────────────────────────────────────
481
+ const retry = signInCommand(baseUrl);
482
+ let signInNow = true;
483
+ if (plan.confirmSignIn) {
484
+ const authNow = await ask(rl2, "Sign in now, so everything is ready to go? (yes/no): ");
485
+ signInNow = /^y(es)?$/i.test(authNow);
486
+ }
487
+ rl2.close();
488
+ let signedIn = false;
489
+ if (signInNow) {
627
490
  console.log("");
628
491
  console.log(dim(" Signing you in..."));
629
492
  console.log("");
630
- const ok = await runAuth();
631
- rl2 = readline.createInterface({ input: process.stdin, output: process.stdout });
632
- if (ok) {
493
+ signedIn = await runAuth();
494
+ if (signedIn) {
633
495
  console.log(green("\n Signed in!"));
634
496
  }
635
497
  else {
636
- console.log(yellow(`\n Sign-in didn't finish. You can try again later by running: ${AUTH_COMMAND}`));
498
+ console.log(yellow(`\n Sign-in didn't finish. To try again, run: ${retry}`));
637
499
  }
638
500
  }
639
501
  else {
640
- console.log(dim(` You can sign in later by running: ${AUTH_COMMAND}`));
502
+ console.log(dim(` You can sign in later by running: ${retry}`));
641
503
  }
642
504
  console.log("");
643
- // ── Step 7: Claude Desktop auto-config ───────────────────────────
644
- const claudePath = getClaudeDesktopConfigPath();
645
- if (claudePath) {
646
- const configClaude = await ask(rl2, "Would you like to automatically configure Claude Desktop? Highly recommended. (yes/no): ");
647
- if (/^y(es)?$/i.test(configClaude) &&
648
- await registerWithClient(rl2, "Claude Desktop", inspectMcpClient(claudePath), () => configureMcpClient(claudePath))) {
649
- configuredClients.push("Claude Desktop");
650
- }
651
- console.log("");
652
- }
653
- // ── Step 8: Cursor auto-config ───────────────────────────────────
654
- const cursorPath = getCursorConfigPath();
655
- const cursorExists = fs.existsSync(path.dirname(cursorPath));
656
- if (cursorExists) {
657
- const configCursor = await ask(rl2, "Cursor detected. Would you like to configure it too? (yes/no): ");
658
- if (/^y(es)?$/i.test(configCursor) &&
659
- await registerWithClient(rl2, "Cursor", inspectMcpClient(cursorPath), () => configureMcpClient(cursorPath))) {
660
- configuredClients.push("Cursor");
661
- }
662
- console.log("");
663
- }
664
- // ── Antigravity auto-config ──────────────────────────────────────
665
- const antigravityPath = getAntigravityConfigPath();
666
- if (fs.existsSync(path.dirname(antigravityPath))) {
667
- const configAntigravity = await ask(rl2, "Antigravity detected. Would you like to configure it too? (yes/no): ");
668
- if (/^y(es)?$/i.test(configAntigravity) &&
669
- await registerWithClient(rl2, "Antigravity", inspectMcpClient(antigravityPath), () => configureMcpClient(antigravityPath))) {
670
- configuredClients.push("Antigravity");
671
- }
672
- console.log("");
673
- }
674
- // ── Step 9: Codex and Claude Code auto-config ─────────────────────
675
- for (const client of cliMcpClients()) {
676
- if (!isCliAvailable(client))
677
- continue;
678
- const configureClient = await ask(rl2, `${client.displayName} detected. Configure it automatically? Highly recommended. (yes/no): `);
679
- if (/^y(es)?$/i.test(configureClient) &&
680
- await registerWithClient(rl2, client.displayName, inspectCliMcpClient(client), (replace) => registerCliMcpClient(client, { replace }))) {
681
- configuredClients.push(client.displayName);
682
- }
683
- console.log("");
684
- }
685
- // ── Step 10: ChatGPT Desktop instructions ────────────────────────
686
- if (isChatGPTInstalled()) {
687
- const isWindows = process.platform === "win32";
688
- const mcpJson = isWindows
689
- ? `{\n "command": "cmd",\n "args": ["/c", "npx", "-y", "brightspace-mcp-server@latest"]\n}`
690
- : `{\n "command": "npx",\n "args": ["-y", "brightspace-mcp-server@latest"]\n}`;
691
- console.log(yellow(" ChatGPT Desktop detected."));
692
- console.log(dim(" ChatGPT doesn't support automatic MCP config — add it manually:"));
693
- console.log(dim(" 1. Open ChatGPT Desktop → Settings → Tools → Add MCP tool → Add manually"));
694
- console.log(dim(" 2. Paste this config:"));
695
- console.log("");
696
- console.log(mcpJson);
697
- console.log("");
505
+ // Sign-in mode exists to sign in, so it reports done only once that worked.
506
+ if (mode === "sign-in" && !signedIn) {
507
+ process.exitCode = 1;
508
+ return;
698
509
  }
699
- rl2.close();
700
510
  // ── Final summary ────────────────────────────────────────────────
701
511
  console.log(bold("Setup complete!"));
702
512
  console.log("");
703
513
  console.log(` Config saved to: ${dim(getConfigStorePath())}`);
704
514
  console.log("");
705
515
  console.log(" Next steps:");
706
- if (configuredClients.length > 0) {
516
+ if (mode === "sign-in") {
517
+ console.log(" 1. Go back to your AI app. If it was open while setup ran, restart it.");
518
+ console.log(` 2. Ask it: "What's due this week?"`);
519
+ }
520
+ else if (configuredClients.length > 0) {
707
521
  console.log(` 1. Restart ${configuredClients.join(", ")}`);
708
522
  console.log(` 2. Ask it: "What's due this week?"`);
709
523
  console.log(" Sign-in runs automatically if your saved session has expired.");
@@ -716,10 +530,39 @@ async function main() {
716
530
  console.log(dim(` If something doesn't work, run: ${DOCTOR_COMMAND}`));
717
531
  console.log("");
718
532
  }
533
+ async function main() {
534
+ const parsed = parseSetupArgs();
535
+ if (!parsed.ok) {
536
+ console.error(parsed.error);
537
+ console.error("");
538
+ console.error(SETUP_USAGE);
539
+ process.exitCode = 2;
540
+ return;
541
+ }
542
+ const args = parsed.args;
543
+ if (args.help) {
544
+ console.log(SETUP_USAGE);
545
+ return;
546
+ }
547
+ if (args.mode === "prepare") {
548
+ await prepare(args);
549
+ return;
550
+ }
551
+ // The wizard and sign-in wait for typing. Without a terminal (an agent's
552
+ // shell, a pipe) they would hang, so say what to run instead.
553
+ if (!process.stdin.isTTY) {
554
+ console.error("Setup asks questions, so it has to run in a terminal someone can type in.");
555
+ const school = args.preset?.baseUrl ?? (args.url && isValidUrl(normalizeUrl(args.url)) ? normalizeUrl(args.url) : undefined);
556
+ console.error(`An AI agent should run ${prepareCommand(school)} instead, then have the person run the sign-in command it prints.`);
557
+ process.exitCode = 2;
558
+ return;
559
+ }
560
+ await interactive(args);
561
+ }
719
562
  // Both entry points — the `brightspace-setup` bin and `brightspace-mcp-server
720
- // setup`, which imports this module — start the wizard here. VITEST is set
721
- // only by the test runner, which imports the module for the helpers above and
722
- // must not open prompts on stdin; no user environment sets it.
563
+ // setup`, which imports this module — start setup here. VITEST is set only by
564
+ // the test runner, which imports the module for the helpers above and must not
565
+ // open prompts on stdin; no user environment sets it.
723
566
  if (!process.env.VITEST) {
724
567
  main().catch((err) => {
725
568
  console.error("Setup failed:", err instanceof Error ? err.message : String(err));