brightspace-mcp-server 5.1.44 → 5.2.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 CHANGED
@@ -12,7 +12,7 @@ Lets your AI see your Brightspace classes, so you can just ask:
12
12
 
13
13
  > "What's due this week?" · "Am I passing all my classes?" · "Summarize today's announcements" · "Turn my lecture slides into flashcards"
14
14
 
15
- Works with Claude Desktop, Claude Code, Cursor, ChatGPT Desktop, Windsurf, and any other app that supports [MCP](https://modelcontextprotocol.io) (the standard way to plug tools into an AI). It only **reads** your courses — it never submits, edits, or deletes anything.
15
+ Works with Claude Desktop, Claude Code, Cursor, ChatGPT Desktop, Windsurf, and any other app that supports [MCP](https://modelcontextprotocol.io). It only **reads** your courses. It never submits, edits, or deletes anything.
16
16
 
17
17
  <p align="center">
18
18
  <img src="https://raw.githubusercontent.com/RohanMuppa/brightspace-mcp-server/main/docs/how-it-works.svg" alt="Architecture diagram" width="100%">
@@ -20,19 +20,19 @@ Works with Claude Desktop, Claude Code, Cursor, ChatGPT Desktop, Windsurf, and a
20
20
 
21
21
  ## Get started in 3 steps
22
22
 
23
- 1. **Install [Node.js](https://nodejs.org/)** (version 22 or newer) if you don't have it. Download, run the installer, done.
24
- 2. **Open a terminal and run the setup:**
23
+ 1. **Install [Node.js](https://nodejs.org/)** version 22 or newer.
24
+ 2. **Run the setup in a terminal:**
25
25
  ```bash
26
26
  npx -y brightspace-mcp-server@latest setup
27
27
  ```
28
- It asks for your school's Brightspace address, your username and password, and how you confirm it's you when you sign in (a phone app, or another way like a text message). Your password goes into your computer's own password store (Keychain on Mac, Credential Manager on Windows), never into a file. Before signing you in, it connects itself to Claude Desktop, Cursor, Antigravity, Codex, or Claude Code if you have them. If one of them already has a `brightspace` entry pointing somewhere else (an old local build, a fork), setup shows you that entry and asks before replacing it.
28
+ It asks for your school's Brightspace address, your username and password, and how you confirm it's you (a phone app, or another way such as a text message). Your password goes into your computer's password store (Keychain on Mac, Credential Manager on Windows), never into a file. Setup also connects the server to Claude Desktop, Cursor, Antigravity, Codex, or Claude Code if you have them. If one already has a `brightspace` entry pointing somewhere else, setup asks before replacing it.
29
29
 
30
- **On a Mac:** when Keychain asks whether `node` may use the saved Brightspace password, choose **Always Allow**, not just Allow. That lets later sign-ins (the `auth` command and questions from your AI app) read it without asking again.
31
- 3. **Restart your AI app and ask it something** — the first question signs you in. If your phone asks you to approve a sign-in, approve it and ask again.
30
+ If your school is in the table below, add its flag (such as `--purdue`) to skip typing the address.
32
31
 
33
- At one of these schools, add the flag and skip typing the address: `--purdue`, `--suny`, `--western`, `--tudelft`, `--cuny`, `--leiden`, `--mcgill`, `--mun`, `--ngeeann`, `--javeriana`.
32
+ **On a Mac:** when Keychain asks whether `node` may use the saved Brightspace password, choose **Always Allow**, not just Allow.
33
+ 3. **Restart your AI app and ask it something.** The first question signs you in. If your phone asks you to approve a sign-in, approve it and ask again.
34
34
 
35
- **Rather have the AI install it for you?** Paste this into Claude Code, Cursor, Windsurf, Copilot, or Codex:
35
+ **Rather have the AI install it?** Paste this into Claude Code, Cursor, Windsurf, Copilot, or Codex:
36
36
 
37
37
  ```
38
38
  Install brightspace-mcp-server for me by following
@@ -40,9 +40,9 @@ https://github.com/RohanMuppa/brightspace-mcp-server/blob/main/LLMs.md
40
40
  (use --purdue at Purdue, --suny at SUNY, --tudelft at TU Delft, --cuny at CUNY, --leiden at Leiden, --mcgill at McGill, --mun at Memorial University, --ngeeann at Ngee Ann Polytechnic, or --javeriana at Javeriana Cali).
41
41
  ```
42
42
 
43
- The AI does everything it can by itself: it checks your computer, downloads the browser sign-in uses, and connects your AI apps. Then it gives you one command to run in a terminal. That command asks for your password and waits for you to approve the sign-in on your phone. That's the only part that needs you. Afterwards the AI checks that it can see your courses.
43
+ The AI does everything it can by itself, then gives you one terminal command. That command asks for your password and waits for you to approve the sign-in on your phone. That's the only part that needs you. Afterwards the AI checks that it can see your courses.
44
44
 
45
- Using a different AI app? Add the command `npx -y brightspace-mcp-server@latest` to its MCP settings (on Windows: `cmd /c npx -y brightspace-mcp-server@latest`). Exact steps for each app: [docs/troubleshooting.md](docs/troubleshooting.md).
45
+ Using a different AI app? Add the command `npx -y brightspace-mcp-server@latest` to its MCP settings (on Windows: `cmd /c npx -y brightspace-mcp-server@latest`). Codex and Claude Code commands, and general steps, are in [docs/troubleshooting.md](docs/troubleshooting.md).
46
46
 
47
47
  ## Does my school work?
48
48
 
@@ -60,124 +60,125 @@ If your school uses D2L Brightspace, yes. These have automatic sign-in built in:
60
60
  | Memorial University of Newfoundland | MUN Login (CAS, no two-factor when tested) | `--mun` |
61
61
  | Javeriana Cali | OneGate (password + authenticator code) | `--javeriana` |
62
62
  | Ngee Ann Polytechnic | Microsoft Entra | `--ngeeann` |
63
- | Any other D2L school | Paste your Brightspace address. If the login page isn't recognized, pick option 2 at setup's sign-in question so the page opens for you to finish by hand | none |
63
+ | Any other D2L school | Paste your Brightspace address at setup. If the sign-in page isn't recognized, answer 2 to setup's sign-in question to finish it by hand | none |
64
64
 
65
- School-specific quirks: [docs/sign-in.md](docs/sign-in.md#per-school-notes).
65
+ School-specific quirks are in [docs/sign-in.md](docs/sign-in.md#per-school-notes).
66
66
 
67
67
  ## Things to ask
68
68
 
69
69
  | About | Try |
70
70
  |-------|-----|
71
- | Grades | "Am I passing all my classes?" · "Compare my grades across courses" · "What did my professor say about Mini-Assignment 1?" (own assignment feedback is read from every route Brightspace documents for students) · "Is there feedback on my midterm?" (quiz-scored grades link to the quiz, since quiz feedback, including feedback only viewable in LockDown Browser, isn't readable through the API) |
71
+ | Grades | "Am I passing all my classes?" · "Compare my grades across courses" · "Is there feedback on my midterm?" (quiz feedback often can't be read through the API, so the answer links to the quiz instead) |
72
72
  | Due dates | "What's due in the next 48 hours?" · "Build me a study schedule for the week" |
73
- | Assignments and rubrics | "What does the lab 4 spec actually ask for?" · "Why did I lose points on the analysis criterion?" · "Did I turn in HW 3?" (if Brightspace won't give students their own submission list, the assignment's feedback page is checked instead; when neither shows a submission it's reported as unknown, never as missing) |
74
- | Quizzes and exams | "Which quizzes close this week?" · "Is there a midterm in the gradebook that isn't on my assignments list?" (quizzes are collected from the quiz list, the course content, and the gradebook, so a closed quiz or one the list leaves out still shows up; each course says whether its quiz sources all answered, and if Brightspace refused one it's marked incomplete rather than passed off as everything; "complete" means every source answered, not that every quiz is guaranteed to be listed. A gradebook column linked to a quiz the list omits now shows up as a quiz with its dates and attempts instead of a bare grade-only row when the quiz can be read. Gradebook-only rows now also appear on tenants that send only the documented `GradeType` on grade columns; hidden grade columns, hidden assignments and inactive quizzes never produce one. Content quizzes beyond the first 30 per course are listed from the course content alone and the course is marked incomplete. Quiz rows may also say whether they are open, closed, or not yet open, and mark a quiz whose details Brightspace would not return; gradebook lookups for unlisted quizzes are capped at 20 per course, or 5 when the course's quiz tool can't be identified) |
73
+ | Assignments and rubrics | "What does the lab 4 spec actually ask for?" · "Why did I lose points on the analysis criterion?" · "Did I turn in HW 3?" |
74
+ | Quizzes and exams | "Which quizzes close this week?" · "Is there a midterm in the gradebook that isn't on my assignments list?" |
75
75
  | Announcements | "Did any professor post something important today?" · "Read the file attached to today's announcement" |
76
- | Course content | "Find the midterm review slides" · "Download every PDF from Module 5" · "Search this course for office hours" · or just read a file inline instead of saving it |
77
- | Syllabus | "What's the late policy in ECE 264's syllabus?" (if the course keeps its syllabus in an external tool such as Simple Syllabus, you get the link to open instead) |
76
+ | Course content | "Find the midterm review slides" · "Download every PDF from Module 5" · "Save this whole course to a folder on my computer" · "Search this course for office hours" · or read a file inline instead of saving it |
77
+ | Syllabus | "What's the late policy in ECE 264's syllabus?" (if the syllabus lives in an external tool, you get the link to open instead) |
78
78
  | People | "Who are the TAs for ECE 264?" · "Who is in my project group?" |
79
79
  | Discussions | "Summarize the latest posts in the final project thread" |
80
- | Lecture videos | "What did the professor say about pinch-off in Tuesday's recording?" (Kaltura, including BoilerCast LTI links, and YouTube. When YouTube asks for a sign-in or a "not a bot" check, the answer says so, with the video link and how to get the transcript yourself: open the link in your browser, choose "Show transcript", and paste it in. The server never gets past that check for you) |
80
+ | Lecture videos | "What did the professor say about pinch-off in Tuesday's recording?" (Kaltura and YouTube. If YouTube asks for a sign-in or "not a bot" check, the answer gives the link: open it, choose "Show transcript", and paste that text here) |
81
81
  | Calendar | "When is my midterm?" · "Is lab cancelled on Thursday?" |
82
- | For instructors and TAs | "Which students haven't submitted Lab 4 yet?" · "What feedback did I leave on this student's homework?" · "Download that student's submitted PDF" (students get a clear "instructor access required" note for these, but can read their own released feedback, below) |
83
- | Signing out | "Sign me out of Brightspace" (your AI app may ask you to confirm first; details under [When it asks you to sign in](#when-it-asks-you-to-sign-in)) |
82
+ | Instructors and TAs | "Which students haven't submitted Lab 4 yet?" (students see "Instructor or TA access required" for these) |
84
83
 
85
- ## Prompts
86
-
87
- Apps with a prompt picker (like Claude Desktop) also offer four ready-made prompts, so you can start from one click instead of typing a question:
84
+ If Brightspace hides part of the data, such as a submission, quiz attempts, or part of the quiz list, the answer says "unknown" or "incomplete" rather than "missing" or "complete". Full tool reference: [LLMs.md](LLMs.md).
88
85
 
89
- | Prompt | Arguments | What it asks for |
90
- |--------|-----------|------------------|
91
- | `weekly_briefing` | — | A 7-day rollup of what's due, what's new, and what changed in your grades, across every course |
92
- | `grade_audit` | `courseId` (optional) | Analyzes grades for one course, or all of them, and flags missing or low-scoring items |
93
- | `study_planner` | `daysAhead` (optional, default 7) | Plans study time from upcoming due dates and calendar events |
94
- | `course_summary` | `courseId` (required) | Syllabus, content outline, assignments, and grades for one course |
86
+ ## Download a whole course
95
87
 
96
- ## Settings you might want
88
+ Ask your assistant to save a course, or run it yourself from a terminal:
97
89
 
98
- All optional, set in your AI app's MCP `env` config or your shell. The full list is in [LLMs.md](LLMs.md).
99
-
100
- | Setting | What it does |
101
- |---------|--------------|
102
- | `D2L_REMEMBER_MFA=true` | Tick Microsoft's "Don't ask again" box so later sign-ins skip the second factor (off by default; not for shared computers). `setup` asks this as option 2 of its one "how often do you want to approve sign-ins" question and saves it as `rememberMfa` in `config.json`; the env var overrides it ([details](docs/sign-in.md#fewer-2fa-prompts)) |
103
- | `D2L_TOTP_SECRET` | An authenticator setup key, so a Microsoft Entra "enter a code" challenge is answered on this computer instead of on your phone (off by default). Prefer `setup` (option 3), which keeps the key in your OS keychain; this variable is for CI and containers with no keychain and is weaker, because anything that can read the process environment gets your second factor ([details](docs/sign-in.md#fewer-2fa-prompts)) |
104
- | `D2L_HEADLESS=false` | Show the browser window during sign-in, for MFA methods that need a click |
105
- | `D2L_DUO_PASSCODE=1` | On Duo, type a passcode instead of waiting for a push |
106
- | `D2L_SESSION_COOKIE` / `D2L_ACCESS_TOKEN` | Skip the browser entirely with a pasted cookie or token ([how](docs/sign-in.md#no-browser-paste-a-session-cookie-or-token)) |
107
- | `D2L_INCLUDE_COURSES` / `D2L_EXCLUDE_COURSES` | Limit which courses the AI sees, by course id |
108
- | `D2L_ACTIVE_ONLY=false` | Include courses whose enrollment has ended |
109
- | `D2L_CURRENT_ONLY=true` | Only show courses in their current enrollment window, like Brightspace's "Current Courses" widget |
110
- | `D2L_NO_UPDATE_CHECK=1` | Turn off the new-version notice |
111
- | `D2L_RUNTIME_IDLE_SECONDS` | How long the shared background server waits with no question running before it stops (default 600) |
112
- | `D2L_DEV_MODE=true` | Write a local, content-free log of tool calls and sign-in events, for debugging session problems ([how](docs/dev-mode.md)) |
113
-
114
- ## One server for all your chats
90
+ ```bash
91
+ npx -y brightspace-mcp-server@latest sync --course 123456 --dest ~/Courses/ECE-264
92
+ ```
115
93
 
116
- Every chat already shares one Brightspace server, with nothing to set up. Each chat still launches the usual command, but that command is now only a small relay: the first question from any chat starts one shared background server for this computer, and every other chat (Claude Desktop, Codex, Cursor, …) uses that same one. That means one sign-in, one saved session, and one cache for all of them. Two chats starting at the same moment still end up with one server. Closing a chat never interrupts another chat's question. After 10 minutes with no question running, the shared server stops by itself, and the next question starts it again.
94
+ Every file in the course's Content area is saved into folders named after its modules. Running it again fetches only what is new.
117
95
 
118
- Chats share a server only when they use the same settings and the same version of this package. A chat started with different `D2L_*` settings, or after an update, gets its own. Because the background server isn't attached to any chat, its log goes to `runtime.log` under `~/.d2l-session/…/shared-runtime/`. Set `D2L_RUNTIME_IDLE_SECONDS` to change how long it waits before stopping.
96
+ | Option | What it does |
97
+ |--------|--------------|
98
+ | `--existing skip\|replace\|keep_both` | A file already there: `skip` (default) leaves it, `replace` re-downloads only files the sync saved and you haven't edited, `keep_both` saves a different copy as `name (1).ext` |
99
+ | `--module <id>` | Only that module and the modules inside it |
100
+ | `--dry-run` | List what would be downloaded, and download nothing |
101
+ | `--max-files <n>` | Most files per run (default 300). Run it again to continue |
102
+ | `--json` | Print the result as JSON on stdout, for scripts |
119
103
 
120
- ### Running the shared server yourself (optional)
104
+ - The sync keeps a `.brightspace-sync.json` manifest in the folder, which is how it knows which files are its own. Only one sync runs in a folder at a time. If a crashed run left its `.brightspace-sync.lock` behind, the next run takes it over.
105
+ - Course names are made safe for your computer, and a course can't create dotfiles such as `.zshrc` in your folder.
106
+ - Each run is capped at 300 files and 5 GB. The assistant's version also stops cleanly after 8 minutes. Ask it again to continue. Ctrl-C stops the CLI cleanly (exit 130) with no partial file left behind.
107
+ - Exit codes: 0 when everything was done, 1 if a file failed or wasn't attempted (including a sign-in that expired partway; saved files are kept), 2 for a usage error. Links and embedded videos aren't files, so the result counts them separately rather than as missing downloads.
121
108
 
122
- You can also run **one** copy yourself and point every chat at it by URL. It uses the same saved password, saved session, and sign-in as before; a question that needs a sign-in still signs in once, for every chat.
109
+ **Some folders are refused.** Downloads can't go to your home folder itself, a drive root, a hidden (dot) folder such as `~/.ssh`, `~/Library` (except cloud-drive and iCloud folders), system folders such as `/etc` or `/System`, or on Windows `Windows`, `Program Files`, `ProgramData` and `AppData`. Anything else is fine, including `/tmp`, external drives and `~/Documents`. Choose a folder like `~/Documents/Brightspace`; the refusal names the rule. The same rule applies to `download_file`, `get_syllabus` and every other tool that saves to disk. The person running the server can allow a folder anyway with `D2L_DOWNLOAD_ROOTS` (see [Settings](#settings-you-might-want)). Full rules: [LLMs.md](LLMs.md).
123
110
 
124
- 1. **Make a secret token, once.** Chats must present it, so other programs and web pages can't read your grades. Generate one:
125
- ```bash
126
- node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
127
- ```
128
- Save it as a user environment variable named `D2L_HTTP_TOKEN`, where both the server and Codex can see it. On Windows: Start → "Edit environment variables for your account" → New, paste it as the value (apps and terminals opened afterwards see it). On macOS or Linux: add `export D2L_HTTP_TOKEN=<token>` to your shell profile (`~/.zshrc`, `~/.bashrc`); a Codex app launched from the Dock or a desktop menu may not read that profile, so start it from a terminal. The server never prints the token.
129
- 2. **Start the server and leave it running** in a terminal of its own:
130
- ```bash
131
- npx -y brightspace-mcp-server@latest http
132
- ```
133
- This needs a release with HTTP support; to try it from a source checkout first, run `npm run build`, then `node build/index.js http`. It listens on `http://127.0.0.1:18765/mcp`, on this computer only. Pick another port with `--port 18766` or `D2L_HTTP_PORT`, and use that same port in the URL in step 3. Start it once per login: chats do not start it, and a second copy on the same port stops at once with "already in use" rather than running alongside the first. Nothing signs in at startup.
134
- 3. **Point Codex at it.** In `~/.codex/config.toml`, replace the `command` and `args` lines of your `[mcp_servers.brightspace]` entry with:
135
- ```toml
136
- [mcp_servers.brightspace]
137
- url = "http://127.0.0.1:18765/mcp"
138
- bearer_token_env_var = "D2L_HTTP_TOKEN"
139
- ```
140
- (`env_http_headers = { "Authorization" = "D2L_HTTP_AUTHORIZATION" }` also works, if that variable holds `Bearer <token>`.) A project's own `.codex/config.toml` overrides this one, so remove any `brightspace` entry there or change it the same way. Rerunning `setup` recognizes this entry and keeps it, token settings included.
141
- 4. **Restart Codex.** Chats that were already open keep the old per-chat server until Codex restarts. If you stop or restart the shared server, calls fail until it is back; if they still fail after that, start a new chat or restart Codex.
111
+ ## Prompts
142
112
 
143
- Things that change:
144
- - **Settings go to the server, not to Codex.** A URL entry has no process to pass an `env` table to, so `D2L_*` settings (the [table above](#settings-you-might-want)) are set in the terminal that runs `http`, in a `.env` file in the directory you start it from, or saved by `setup`.
145
- - **Sign-in messages show up in the server's terminal.** The number to approve still comes back in the chat's answer, and, in clients that show tool progress, while the question is running.
146
- - **Stopping it:** press Ctrl+C. Questions already running finish first; press Ctrl+C again to stop at once.
147
- - **Updating it:** restart the server, not Codex. Restarting a client only reconnects it to the server that is already running the old version. Stop the server and start the same `http` command again with the same `D2L_HTTP_TOKEN`, port, and settings; from a source checkout, run `npm run update` first. Update notices from a shared server say this too.
113
+ Apps with a prompt picker (like Claude Desktop) also offer four one-click prompts:
148
114
 
149
- Any other MCP client that can connect to a Streamable HTTP URL with a bearer token can share the same server. To go back to the automatic shared server, put the `command`/`args` entry back (rerun `setup` and say yes when it offers to switch Codex back) and stop the server you started.
115
+ - `weekly_briefing`: a 7-day rollup of what's due, what's new, and what changed in your grades
116
+ - `grade_audit` (optional `courseId`): flags missing or low-scoring items
117
+ - `study_planner` (optional `daysAhead`, default 7): plans study time from due dates and calendar events
118
+ - `course_summary` (required `courseId`): syllabus, outline, assignments and grades for one course
150
119
 
151
120
  ## When it asks you to sign in
152
121
 
153
- There's no separate login — asking a question signs you in, and it stays signed in on its own most days. When your school wants two-factor again, the number to approve shows up right in the answer; approve it on your phone and the assistant keeps checking on its own — each check waits up to 45 seconds and the original question completes as soon as the sign-in does, without you having to say you've approved it. If your AI client shows tool progress messages, the number appears while the question is still running instead. If it ever gets stuck, run this in a terminal:
122
+ There's no separate login. Asking a question signs you in, and it stays signed in on its own most days. When your school wants two-factor again, the number to approve shows up in the answer. Approve it on your phone, and the assistant keeps checking, so the original question completes as soon as sign-in does. If it gets stuck, run this in a terminal:
154
123
 
155
124
  ```bash
156
125
  npx -y brightspace-mcp-server@latest auth
157
126
  ```
158
127
 
159
- **On a Mac,** sign-in first reads your saved password from Keychain. If you picked a one-time **Allow** at the Keychain prompt, a later sign-in from your AI app or another terminal can fail or hang waiting for an approval nobody sees; pick **Always Allow** instead. That permission only lets the local Brightspace server read its own saved sign-in password, nothing else in your Keychain.
128
+ How often your school asks for two-factor is up to the school, not this tool.
129
+
130
+ **On a Mac,** a one-time **Allow** at the Keychain prompt can make later sign-ins from your AI app or another terminal fail or hang. Pick **Always Allow** instead. That permission only lets the local Brightspace server read its own saved sign-in password, nothing else in your Keychain.
131
+
132
+ **Want fewer 2FA prompts?** `setup` asks **How often do you want to approve sign-ins on your phone?** Pick **1** every time (the default), **2** to remember this computer, or **3** to let it answer Microsoft's code requests (needs an authenticator setup key). Options 2 and 3 are not for shared computers. Steps and tradeoffs: [docs/sign-in.md](docs/sign-in.md#fewer-2fa-prompts).
160
133
 
161
- **Want fewer 2FA prompts?** `setup` asks one question, "How often do you want to approve sign-ins on your phone?": **1** every time (default), **2** remember this computer, or **3** let this computer answer Microsoft's code requests itself (needs an authenticator setup key). Options 2 and 3 are opt-in and not for shared computers; to turn them off, rerun `setup` and pick 1. The tradeoffs and the steps for getting the key are in [docs/sign-in.md](docs/sign-in.md#fewer-2fa-prompts).
134
+ **To sign in** from a chat, ask your AI app to sign you in to Brightspace. It can start the sign-in itself, and opens a window for you when no password is saved.
162
135
 
163
- **To sign in,** just ask your AI app to sign you in to Brightspace — it can start the sign-in itself now, and it opens a window for you when no password is saved. You can also run `npx -y brightspace-mcp-server@latest auth` in a terminal.
136
+ **To sign out early** (on a shared computer, say), ask your AI app to sign you out of Brightspace, or run `npx -y brightspace-mcp-server@latest auth --logout`. This deletes this computer's saved session, so the next question does a full sign-in. Your password and authenticator key are kept. It doesn't sign you out of Microsoft or Brightspace elsewhere. On a shared computer, also delete the saved `brightspace-mcp-server` entries from Keychain Access (Mac) or Credential Manager (Windows). Details: [docs/sign-in.md](docs/sign-in.md#signing-out-early).
164
137
 
165
- **To sign out early** (on a shared computer, say), just ask your AI app to sign you out of Brightspace, or run `npx -y brightspace-mcp-server@latest auth --logout` in a terminal. It deletes this computer's saved Brightspace session, so the next question does a full sign-in. Your saved password, authenticator key, and settings are kept, so with a saved authenticator key that sign-in can finish without you; on a shared computer, also delete the saved `brightspace-mcp-server` entries from your computer's password store (Keychain Access on a Mac, Credential Manager on Windows). Other AI apps open on this computer are signed out too and will ask you to sign in on their next question. (An app you opened before updating may keep answering for up to an hour; quit and reopen it to sign it out right away.) It only clears this computer; it does not sign you out of Microsoft or Brightspace anywhere else. Both also cancel a sign-in an AI app started that is still waiting for you to approve it (`auth --logout` can't on Windows, and leaves an `auth` run in another terminal alone, deleting nothing). Cancelling doesn't get you a new prompt any sooner: Microsoft won't send another until the earlier one expires, up to 5 minutes after it was sent.
138
+ Duo, authenticator codes, visible-browser mode, and running without a browser at all (Docker, WSL, hardware keys) are covered in [docs/sign-in.md](docs/sign-in.md).
139
+
140
+ ## Settings you might want
166
141
 
167
- How often you're asked is up to your school, not this tool. Everything else about sign-in — Duo, authenticator codes, visible-browser mode, and running without a browser at all (Docker, WSL, hardware keys) — is in [docs/sign-in.md](docs/sign-in.md).
142
+ All optional. Set them in your AI app's MCP `env` config or in your shell. The full list is in [LLMs.md](LLMs.md#environment-variables).
143
+
144
+ | Setting | What it does |
145
+ |---------|--------------|
146
+ | `D2L_REMEMBER_MFA=true` | Tick Microsoft's "Don't ask again" box so later sign-ins skip the second factor. Off by default, and not for shared computers. `setup` option 2 saves the same choice |
147
+ | `D2L_TOTP_SECRET` | An authenticator setup key, so a Microsoft code challenge is answered on this computer. Prefer `setup` option 3, which keeps the key in your OS keychain. Environment only for CI and containers: anything that can read the environment gets your second factor ([details](docs/sign-in.md#fewer-2fa-prompts)) |
148
+ | `D2L_HEADLESS=false` | Show the browser window during sign-in, for MFA methods that need a click |
149
+ | `D2L_DUO_PASSCODE=1` | On Duo, type a passcode instead of waiting for a push |
150
+ | `D2L_SESSION_COOKIE` / `D2L_ACCESS_TOKEN` | Skip the browser entirely with a pasted cookie or token. See [docs/sign-in.md](docs/sign-in.md#no-browser-paste-a-session-cookie-or-token) |
151
+ | `D2L_INCLUDE_COURSES` / `D2L_EXCLUDE_COURSES` | Comma-separated course ids to show or hide |
152
+ | `D2L_ACTIVE_ONLY=false` | Include courses whose enrollment has ended |
153
+ | `D2L_CURRENT_ONLY=true` | Only show courses in their current enrollment window (Brightspace's "Current Courses" widget) |
154
+ | `D2L_DOWNLOAD_ROOTS=/data:/srv/courses` | Folders downloads may always use, even where the refusals above say no. Separate paths with `:` on macOS and Linux, `;` on Windows. Only the person running the server can set it |
155
+ | `D2L_NO_UPDATE_CHECK=1` | Turn off the new-version notice |
156
+ | `D2L_DEV_MODE=true` | Write a local, content-free log of tool calls and sign-in events, for debugging ([how](docs/dev-mode.md)) |
157
+
158
+ ## One server for all your chats
159
+
160
+ Every chat shares one Brightspace server automatically, with one sign-in, one session and one cache. Closing a chat never interrupts another. Codex users, or anyone who wants to run one server themselves, can follow [docs/shared-server.md](docs/shared-server.md).
161
+
162
+ ## Privacy and security
163
+
164
+ Your password is stored in your computer's password store, never in a plaintext file. Saved session state is encrypted with AES-256-GCM. Approvals (the tap or number) stay on your phone, and the server never sees them. A code you type, or one generated from a saved authenticator key, goes only to the sign-in page. None of the tools submit, edit or delete anything in your courses. More detail: [docs/sign-in.md](docs/sign-in.md#whats-stored-where).
168
165
 
169
166
  ## Something not working?
170
167
 
171
- - Run `npx -y brightspace-mcp-server@latest doctor` first — it checks your Node version, the sign-in browser, saved setup, credential store, Brightspace connectivity, saved sign-in, and installed version, and tells you exactly what to fix. For a global installation, use `brightspace-mcp-server doctor` to check the copy your client launches.
172
- - **Browser removed during disk cleanup?** Sign-in restores a missing Chromium download once and retries automatically. It uses the installed Playwright dependency's browser revision and preserves saved credentials, MFA enrollment and sessions. Downloads have a two-minute limit; offline or failed repairs return an actionable installation error instead of asking you to reset credentials.
173
- - **Repair without signing in or changing client configuration:** run `brightspace-mcp-server repair` for a global install, or `npx -y brightspace-mcp-server@latest repair` for an npx installation. Add `--json` for automation. Diagnostics print an absolute command for the exact installation being checked. Repair verifies both the full browser executable and a real headless launch and close. It does not register clients, update school settings, or start SSO/MFA.
174
- - Browser downloads are disposable; files inside an installed package's `node_modules` are dependency code. If those files were deleted, diagnostics give an installation-scoped npm command to restore the package's declared dependencies first. Browser repair does not run npm or upgrade packages automatically.
175
- - **`Authentication failed: The native credential store is locked or unavailable.`** Your password store is locked or hasn't let this server read the saved password. Unlock it (on a Mac, log in or unlock Keychain Access), then run `npx -y brightspace-mcp-server@latest auth` again, or `setup` if that still fails, and choose **Always Allow** when Keychain asks.
176
- - **It works in the terminal but not in the app:** the app starts it separately and may not see your password store yet — see [docs/troubleshooting.md](docs/troubleshooting.md).
177
- - **The app says the server timed out while starting:** open the app's MCP server log. Each startup step prints a timestamped `Startup: ...` line before it begins and `done in N ms` when it ends, and a step still running after 10 seconds prints `Startup: still ...` once. The last such line names the step it was stuck in, often the saved-password lookup in your password store. These lines never include your username, password, or cookies.
168
+ - Run `npx -y brightspace-mcp-server@latest doctor` first. It checks the whole setup and tells you what to fix. For a global install, use `brightspace-mcp-server doctor`.
169
+ - **Browser removed** (say, by a disk cleaner): sign-in restores it once by itself. To do it now, run `npx -y brightspace-mcp-server@latest repair`. It doesn't sign in or change your AI apps.
170
+ - **`npx` fails with `EPERM` on a Mac:** run it from your home folder. macOS blocks `npx` from Documents, Desktop and Downloads unless you grant Files and Folders access.
171
+ - **`Authentication failed: The native credential store is locked or unavailable.`** Unlock your password store (on a Mac, log in or unlock Keychain Access), run `npx -y brightspace-mcp-server@latest auth` again (or `setup` if that still fails), and choose **Always Allow** when Keychain asks.
172
+ - **Works in a terminal but not in the app:** the app starts the server separately and may not see your password store yet.
173
+ - **The app says the server timed out while starting:** see the startup notes in [docs/troubleshooting.md](docs/troubleshooting.md).
178
174
  - **Which version do I have?** Ask your AI "which version of the Brightspace server am I running?"
175
+ - **Anything else:** see [docs/troubleshooting.md](docs/troubleshooting.md).
179
176
  - **Still stuck?** [Open an issue](https://github.com/RohanMuppa/brightspace-mcp-server/issues) and paste what the terminal printed.
180
177
 
181
178
  ## For developers
182
179
 
183
- Tool reference, response fields, and environment variables: [LLMs.md](LLMs.md). What's safe to build against: [STABILITY.md](STABILITY.md). Licensed under the MIT License.
180
+ Tool reference, response fields, environment variables and the codebase map: [LLMs.md](LLMs.md). What's safe to build against: [STABILITY.md](STABILITY.md).
181
+
182
+ ## License
183
+
184
+ MIT. See [LICENSE](LICENSE).
@@ -293,7 +293,9 @@ export class D2LApiClient {
293
293
  // Join an identical GET already in flight rather than issuing a second
294
294
  // one. Keyed on the same unresolved path the cache above uses, so the
295
295
  // join happens before version discovery or a token is even touched.
296
- const pending = this.pendingGets.get(path);
296
+ // A caller with a signal is never joined to another's request (its abort
297
+ // must end only its own) and never joined by one.
298
+ const pending = options?.signal ? undefined : this.pendingGets.get(path);
297
299
  if (pending) {
298
300
  this.statsData.coalescedJoins++;
299
301
  log("DEBUG", `Coalescing GET onto an in-flight request: ${path}`);
@@ -302,7 +304,7 @@ export class D2LApiClient {
302
304
  const generation = this.cacheGeneration;
303
305
  const inFlight = (async () => {
304
306
  const resolved = await this.resolvePath(path);
305
- const data = await this.withAuthentication(resolved, token => this.makeRequest(resolved, token));
307
+ const data = await this.withAuthentication(resolved, token => this.makeRequest(resolved, token, options?.signal), options?.signal);
306
308
  if (options?.ttl && generation === this.cacheGeneration) {
307
309
  this.cache.set(path, data, options.ttl);
308
310
  log("DEBUG", `Cached response for ${path} (TTL: ${options.ttl}ms)`);
@@ -312,7 +314,8 @@ export class D2LApiClient {
312
314
  if (this.pendingGets.get(path) === inFlight)
313
315
  this.pendingGets.delete(path);
314
316
  });
315
- this.pendingGets.set(path, inFlight);
317
+ if (!options?.signal)
318
+ this.pendingGets.set(path, inFlight);
316
319
  return inFlight;
317
320
  }
318
321
  /**
@@ -325,9 +328,10 @@ export class D2LApiClient {
325
328
  * @throws ApiError on HTTP errors (401, 403, 429, etc.)
326
329
  * @throws NetworkError on network/fetch failures
327
330
  */
328
- async getRaw(path) {
331
+ async getRaw(path, options) {
332
+ options?.signal?.throwIfAborted();
329
333
  const resolved = await this.resolvePath(path);
330
- return this.withAuthentication(resolved, token => this.makeRawRequest(resolved, token));
334
+ return this.withAuthentication(resolved, token => this.makeRawRequest(resolved, token, options?.signal), options?.signal);
331
335
  }
332
336
  /**
333
337
  * Fetch a Brightspace web page (not an API route) as the signed-in user.
@@ -379,14 +383,14 @@ export class D2LApiClient {
379
383
  }
380
384
  }
381
385
  /** One HTTP refresh and at most one browser login per caller. */
382
- async withAuthentication(path, request) {
386
+ async withAuthentication(path, request, signal) {
383
387
  let token = await this.tokenManager.getToken();
384
388
  let authenticated = false;
385
389
  if (!token) {
386
390
  token = await this.tryAutoReauth(path);
387
391
  authenticated = true;
388
392
  }
389
- const send = (current) => this.retrying(() => this.throttled(() => request(current)));
393
+ const send = (current) => this.retrying(() => this.throttled(() => request(current)), signal);
390
394
  try {
391
395
  return await send(token);
392
396
  }
@@ -446,9 +450,24 @@ export class D2LApiClient {
446
450
  return fn();
447
451
  }
448
452
  /** Retry 429, 5xx, and network failures. A 401 is never retried here. */
449
- retrying(fn) {
450
- return withRetry(fn, {
453
+ retrying(fn, signal) {
454
+ if (!signal) {
455
+ return withRetry(fn, { ...this.retryConfig, shouldRetry: isRetryableFailure, retryAfterMs: retryAfterMsFrom });
456
+ }
457
+ // A caller that has given up gets no further attempt and does not sit out a backoff.
458
+ const sleep = this.retryConfig.sleep ?? ((ms) => new Promise(resolve => setTimeout(resolve, ms)));
459
+ return withRetry(() => {
460
+ signal.throwIfAborted();
461
+ return fn();
462
+ }, {
451
463
  ...this.retryConfig,
464
+ sleep: ms => new Promise((resolve, reject) => {
465
+ if (signal.aborted)
466
+ return reject(signal.reason);
467
+ const onAbort = () => reject(signal.reason);
468
+ signal.addEventListener("abort", onAbort, { once: true });
469
+ sleep(ms).then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
470
+ }),
452
471
  shouldRetry: isRetryableFailure,
453
472
  retryAfterMs: retryAfterMsFrom,
454
473
  });
@@ -478,7 +497,7 @@ export class D2LApiClient {
478
497
  /**
479
498
  * Make one HTTP request. Authentication recovery is shared with raw downloads.
480
499
  */
481
- async makeRequest(path, token) {
500
+ async makeRequest(path, token, signal) {
482
501
  const url = `${this.baseUrl}${path}`;
483
502
  const headers = this.buildAuthHeaders(token);
484
503
  try {
@@ -486,7 +505,7 @@ export class D2LApiClient {
486
505
  const response = await fetch(url, {
487
506
  method: "GET",
488
507
  headers,
489
- signal: AbortSignal.timeout(this.timeoutMs),
508
+ signal: signal ? AbortSignal.any([AbortSignal.timeout(this.timeoutMs), signal]) : AbortSignal.timeout(this.timeoutMs),
490
509
  });
491
510
  this.recordStatus(response.status, path);
492
511
  // Preserve cookie material: a 401 only rejects this access token.
@@ -543,7 +562,7 @@ export class D2LApiClient {
543
562
  /**
544
563
  * Make one HTTP request for raw binary data.
545
564
  */
546
- async makeRawRequest(path, token) {
565
+ async makeRawRequest(path, token, signal) {
547
566
  const url = `${this.baseUrl}${path}`;
548
567
  const headers = this.buildAuthHeaders(token);
549
568
  // A file can take far longer than timeoutMs to arrive. Time out on a
@@ -554,7 +573,7 @@ export class D2LApiClient {
554
573
  const response = await fetch(url, {
555
574
  method: "GET",
556
575
  headers,
557
- signal: idle.signal,
576
+ signal: signal ? AbortSignal.any([idle.signal, signal]) : idle.signal,
558
577
  });
559
578
  this.recordStatus(response.status, path);
560
579
  // Preserve cookie material for the shared HTTP refresh path.
package/build/index.js CHANGED
@@ -26,7 +26,7 @@ const PKG_VERSION = (() => {
26
26
  })();
27
27
  // ── Subcommand routing (before any MCP initialization) ──────────────
28
28
  const subcommand = process.argv[2];
29
- const COMMANDS = ['setup', 'auth', 'doctor', 'repair', 'http', 'runtime'];
29
+ const COMMANDS = ['setup', 'auth', 'doctor', 'repair', 'sync', 'http', 'runtime'];
30
30
  // A mistyped command (`htp`) must not quietly start a stdio server that sits
31
31
  // waiting on stdin. Flags alone still mean the default stdio server.
32
32
  if (subcommand !== undefined && !subcommand.startsWith('-') && !COMMANDS.includes(subcommand)) {
@@ -38,6 +38,7 @@ if (subcommand !== undefined && !subcommand.startsWith('-') && !COMMANDS.include
38
38
  ' setup configure your school and sign-in\n' +
39
39
  ' auth sign in to Brightspace now\n' +
40
40
  ' doctor check the install and configuration\n' +
41
+ ' sync download every file of a course (sync --help for options)\n' +
41
42
  ' repair restore and verify this installation\'s Chromium\n');
42
43
  process.exit(2);
43
44
  }
@@ -50,6 +51,9 @@ else if (subcommand === 'auth') {
50
51
  else if (subcommand === 'doctor') {
51
52
  await import('./doctor.js');
52
53
  }
54
+ else if (subcommand === 'sync') {
55
+ await (await import('./sync/sync-cli.js')).main(PKG_VERSION);
56
+ }
53
57
  else if (subcommand === 'repair') {
54
58
  await (await import('./repair.js')).main();
55
59
  }
@@ -138,7 +142,7 @@ else {
138
142
  const { resolveHttpPort, resolveHttpToken, startHttpServer } = await import("./http-server.js");
139
143
  const running = await startHttpServer({ runtime, token: resolveHttpToken(), port: resolveHttpPort() });
140
144
  stopServing = running.close;
141
- log("INFO", `Brightspace MCP Server by Rohan Muppa — shared HTTP server listening on ${running.url} (28 tools, 4 prompts registered)`);
145
+ log("INFO", `Brightspace MCP Server by Rohan Muppa — shared HTTP server listening on ${running.url} (29 tools, 4 prompts registered)`);
142
146
  log("INFO", "Leave this running; every MCP client on this computer connects to that URL. Press Ctrl+C to stop.");
143
147
  }
144
148
  catch (error) {
@@ -35,6 +35,13 @@ class Unreachable extends Error {
35
35
  */
36
36
  export async function relaySharedRuntime(transport, { dir, startRuntime, startTimeoutMs = DEFAULT_START_TIMEOUT_MS }) {
37
37
  const disconnected = new AbortController();
38
+ /**
39
+ * Each request is its own POST, so the runtime cannot match a later
40
+ * notifications/cancelled to it. The relay can: it keeps the POST of every
41
+ * request in progress and aborts it when the client cancels that request.
42
+ * The runtime ends the tool's own signal when its HTTP request goes away.
43
+ */
44
+ const inProgress = new Map();
38
45
  let current;
39
46
  let locating;
40
47
  /** The address to use, after `failed` (if given) did not answer. */
@@ -71,6 +78,15 @@ export async function relaySharedRuntime(transport, { dir, startRuntime, startTi
71
78
  };
72
79
  const forward = async (message) => {
73
80
  const id = "method" in message && "id" in message ? message.id : undefined;
81
+ if ("method" in message && message.method === "notifications/cancelled") {
82
+ const target = message.params?.requestId;
83
+ if (target !== undefined)
84
+ inProgress.get(target)?.abort();
85
+ }
86
+ const cancelled = new AbortController();
87
+ if (id !== undefined)
88
+ inProgress.set(id, cancelled);
89
+ const signal = AbortSignal.any([disconnected.signal, cancelled.signal]);
74
90
  let answered = false;
75
91
  const reply = (out) => {
76
92
  if (id !== undefined && "id" in out && out.id === id && !("method" in out))
@@ -82,7 +98,7 @@ export async function relaySharedRuntime(transport, { dir, startRuntime, startTi
82
98
  for (let attempt = 1;; attempt += 1) {
83
99
  const address = await locate(failed);
84
100
  try {
85
- await post(address, message, disconnected.signal, reply);
101
+ await post(address, message, signal, reply);
86
102
  if (id !== undefined && !answered)
87
103
  throw new Error("the runtime ended the call without answering it");
88
104
  return;
@@ -98,10 +114,17 @@ export async function relaySharedRuntime(transport, { dir, startRuntime, startTi
98
114
  catch (error) {
99
115
  if (disconnected.signal.aborted)
100
116
  return;
117
+ // The client cancelled this request: it is no longer waiting for an answer.
118
+ if (cancelled.signal.aborted)
119
+ return;
101
120
  log("ERROR", "Relaying to the shared runtime failed", error);
102
121
  if (id !== undefined && !answered)
103
122
  reply(failure(id, error));
104
123
  }
124
+ finally {
125
+ if (id !== undefined && inProgress.get(id) === cancelled)
126
+ inProgress.delete(id);
127
+ }
105
128
  };
106
129
  transport.onmessage = (message) => { void forward(message); };
107
130
  transport.onclose = () => disconnected.abort();
package/build/server.js CHANGED
@@ -9,7 +9,7 @@ import { D2LApiClient } from "./api/index.js";
9
9
  import { trackToolActivity } from "./utils/dev-activity.js";
10
10
  import { log } from "./utils/logger.js";
11
11
  import { envCredentialRejectedMessage, ENV_COOKIE_REJECTED } from "./utils/commands.js";
12
- import { registerGetMyCourses, registerGetUpcomingDueDates, registerGetCalendarEvents, registerGetMyGrades, registerGetAnnouncements, registerGetAssignments, registerGetAssignmentRubric, registerGetAssignmentFiles, registerGetAnnouncementFiles, registerGetCourseContent, registerDownloadFile, COURSE_FILE_NEEDS_COOKIE, registerGetClasslistEmails, registerGetRoster, registerGetMyGroups, registerGetAvailableGroups, registerGetSyllabus, registerGetDiscussions, registerGetVideoTranscript, registerGetServerInfo, registerSearchCourse, registerSignOut, registerSignIn, registerGetDropboxFolders, registerGetDropboxSubmissions, registerGetDropboxUserSubmissions, registerGetDropboxFeedback, registerGetRubricsForObject, registerDownloadDropboxSubmissionFile, acceptOmittedArguments, relayMfaChallenges, currentMfaAnnouncer, } from "./tools/index.js";
12
+ import { registerGetMyCourses, registerGetUpcomingDueDates, registerGetCalendarEvents, registerGetMyGrades, registerGetAnnouncements, registerGetAssignments, registerGetAssignmentRubric, registerGetAssignmentFiles, registerGetAnnouncementFiles, registerGetCourseContent, registerDownloadFile, COURSE_FILE_NEEDS_COOKIE, registerDownloadCourseFiles, registerGetClasslistEmails, registerGetRoster, registerGetMyGroups, registerGetAvailableGroups, registerGetSyllabus, registerGetDiscussions, registerGetVideoTranscript, registerGetServerInfo, registerSearchCourse, registerSignOut, registerSignIn, registerGetDropboxFolders, registerGetDropboxSubmissions, registerGetDropboxUserSubmissions, registerGetDropboxFeedback, registerGetRubricsForObject, registerDownloadDropboxSubmissionFile, acceptOmittedArguments, relayMfaChallenges, currentMfaAnnouncer, } from "./tools/index.js";
13
13
  import { registerWeeklyBriefingPrompt, registerGradeAuditPrompt, registerStudyPlannerPrompt, registerCourseSummaryPrompt, } from "./prompts/index.js";
14
14
  /**
15
15
  * Build the shared runtime. Nothing here reaches Brightspace: API versions
@@ -85,6 +85,7 @@ export function createMcpServer(runtime) {
85
85
  registerGetAnnouncementFiles(server, apiClient);
86
86
  registerGetCourseContent(server, apiClient);
87
87
  registerDownloadFile(server, apiClient, config.envSessionCookie ? ENV_COOKIE_REJECTED : config.envAccessToken ? COURSE_FILE_NEEDS_COOKIE : undefined);
88
+ registerDownloadCourseFiles(server, apiClient);
88
89
  registerGetClasslistEmails(server, apiClient);
89
90
  registerGetRoster(server, apiClient);
90
91
  registerGetMyGroups(server, apiClient);
@@ -103,7 +104,7 @@ export function createMcpServer(runtime) {
103
104
  registerGetDropboxFeedback(server, apiClient);
104
105
  registerGetRubricsForObject(server, apiClient);
105
106
  registerDownloadDropboxSubmissionFile(server, apiClient);
106
- log("DEBUG", "MCP tools registered (28 tools)");
107
+ log("DEBUG", "MCP tools registered (29 tools)");
107
108
  // Register MCP prompts — surfaced in clients (e.g. Claude Desktop) as a
108
109
  // picker, distinct from tools. Each one is a canned user message that
109
110
  // names our own tools by name, so a client with no idea what to ask
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Purdue Brightspace MCP Server
3
+ * Copyright (c) 2026 Rohan Muppa. All rights reserved.
4
+ * Licensed under MIT — see LICENSE file for details.
5
+ */
6
+ import fs from "node:fs/promises";
7
+ import path from "node:path";
8
+ import { log } from "../utils/logger.js";
9
+ /** A partial download older than this belongs to a run that was killed, not one still going. */
10
+ export const PARTIAL_MAX_AGE_MS = 60 * 60 * 1000;
11
+ /** Exactly what secureStreamDownload names its temporary file. */
12
+ const PARTIAL = /^\.download-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.part$/;
13
+ const MAX_VISITED = 50_000;
14
+ const MAX_DEPTH = 64;
15
+ /**
16
+ * Remove the `.download-<uuid>.part` files a killed run (kill -9, power loss)
17
+ * left in the destination tree. Only that exact name, only regular files
18
+ * older than an hour, and nothing is followed through a symbolic link or into
19
+ * a hidden folder. Returns how many were removed.
20
+ */
21
+ export async function removeStalePartials(root, now = Date.now()) {
22
+ let removed = 0;
23
+ let visited = 0;
24
+ const walk = async (dir, depth) => {
25
+ if (depth > MAX_DEPTH || visited > MAX_VISITED)
26
+ return;
27
+ let entries;
28
+ try {
29
+ entries = await fs.readdir(dir, { withFileTypes: true });
30
+ }
31
+ catch {
32
+ return;
33
+ }
34
+ for (const entry of entries) {
35
+ if (++visited > MAX_VISITED)
36
+ return;
37
+ const full = path.join(dir, entry.name);
38
+ if (entry.isDirectory()) {
39
+ if (!entry.name.startsWith("."))
40
+ await walk(full, depth + 1);
41
+ }
42
+ else if (entry.isFile() && PARTIAL.test(entry.name)) {
43
+ try {
44
+ // lstat again: the entry type above could have changed, and a link is never touched.
45
+ const stat = await fs.lstat(full);
46
+ if (stat.isFile() && now - stat.mtimeMs > PARTIAL_MAX_AGE_MS) {
47
+ await fs.rm(full, { force: true });
48
+ removed++;
49
+ }
50
+ }
51
+ catch {
52
+ // Gone already, or not ours to remove.
53
+ }
54
+ }
55
+ }
56
+ };
57
+ await walk(root, 0);
58
+ if (removed > 0)
59
+ log("INFO", `Course sync: removed ${removed} partial download(s) left by an interrupted run`);
60
+ return removed;
61
+ }