neuro-canvas-lms 2.13.0 → 2.14.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/AGENTS.md CHANGED
@@ -15,6 +15,12 @@ code (the school comes from Worker vars). It is a community project provided as
15
15
  GitHub Actions (`.github/workflows/test.yml`) runs the tests, `npm run format:check` and the
16
16
  dry-run build on every push and pull request.
17
17
  - Never rename or remove MCP tools (clients store per-tool approvals by name); only add.
18
+ - Versions: every release makes every user's AI announce an update, so release only what users
19
+ gain from. Patch: fixes. Minor: new tools or parameters, a tool answering differently, install
20
+ changes. Major: only if users must act (reconnect, change Cloudflare settings, newer Node).
21
+ Docs, CI and test changes are not released on their own. Bump the version only in the release
22
+ commit (version.mjs, `npm version x.y.z --no-git-tag-version`, server.json), after testing on a
23
+ real connector and in a new chat.
18
24
 
19
25
  Code map:
20
26
  - `worker.mjs`: OAuth + MCP entry, /healthz. `config.mjs`: school settings from Worker vars.
@@ -26,7 +32,9 @@ Code map:
26
32
  - Files: `file-content.mjs` (text extraction), `file-types.mjs` (file type detection shared by all
27
33
  readers), `zip-content.mjs` (server-side ZIP unpacking), `find-files.mjs` (finds files a course
28
34
  links to when its Files list is hidden), `external-links.mjs` (Google/Dropbox/GitHub share links →
29
- the file itself), `module-file.mjs` + `file-resource.mjs` (original files as MCP resources),
35
+ the file itself), `link-content.mjs` (text of a file linked in the syllabus, a page, an
36
+ assignment or an announcement; only links that content really contains), `module-file.mjs` +
37
+ `file-resource.mjs` (original files as MCP resources),
30
38
  `download-link.mjs` (one-time, minutes-long Canvas storage link so coding agents can curl an original
31
39
  file to disk).
32
40
  - `owner.mjs`: owner binding, allowed OAuth clients. `i18n.mjs` + `pages.mjs`: web pages.
@@ -39,3 +47,8 @@ Code map:
39
47
  settings (without a terminal it does not ask). `installer/verify.mjs`: the post-deploy online
40
48
  check, also run alone by `npx -y neuro-canvas-lms check`.
41
49
  - `version.mjs`: shown on the home page and /healthz, compared with npm by update-check.mjs.
50
+ - `server.json`: the entry in the official MCP Registry (one remote whose host each user fills in).
51
+ Same version as `version.mjs` and package.json (`version.test.mjs`).
52
+ - Releases: bump those three, push to main, then push a tag `vX.Y.Z`;
53
+ `.github/workflows/publish.yml` tests, publishes to npm and updates the MCP Registry, with
54
+ no stored secrets (OIDC trusted publishing).
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  **English** · [简体中文](docs/README.zh-CN.md) · [Español](docs/README.es.md) · [日本語](docs/README.ja.md) · [हिन्दी](docs/README.hi.md)
4
4
 
5
+ [![npm](https://img.shields.io/npm/v/neuro-canvas-lms?label=npm&color=c9a462)](https://www.npmjs.com/package/neuro-canvas-lms) [![tests](https://github.com/BloodyReginaa/Neuro-CanvasLMS/actions/workflows/test.yml/badge.svg)](https://github.com/BloodyReginaa/Neuro-CanvasLMS/actions/workflows/test.yml) [![license: MIT](https://img.shields.io/badge/license-MIT-blue)](https://github.com/BloodyReginaa/Neuro-CanvasLMS/blob/main/LICENSE)
6
+
5
7
  ![Neuro-CanvasLMS answering "What's due this week?" in Claude](https://raw.githubusercontent.com/BloodyReginaa/Neuro-CanvasLMS/main/docs/images/banner.png)
6
8
 
7
9
  **Wire your Canvas LMS straight into Claude and ChatGPT.** Your courses, deadlines, grades and lecture files become part of what your AI knows, in any chat and on any device. It is a read-only remote MCP server that one command deploys to your own free Cloudflare account. Works at any Canvas school that lets students create access tokens ([30-second check](#can-you-use-it)).
@@ -21,9 +23,9 @@ Local Canvas tools only work while your own computer runs them. Hosted services
21
23
 
22
24
  ## What you get
23
25
 
24
- - **64 read-only tools:** courses, modules, pages, files, assignments, submissions, grades and feedback, rubrics, announcements, discussions, inbox, calendar, to-dos, groups, quiz history and profile settings.
26
+ - **65 read-only tools:** courses, modules, pages, files, assignments, submissions, grades and feedback, rubrics, announcements, discussions, inbox, calendar, to-dos, groups, quiz history and profile settings.
25
27
  - **Real file reading:** text from PDF, Word, PowerPoint and Excel, about 90 kinds of code and text files, and Jupyter notebooks (cells and outputs); ZIP archives unpacked on the server (listing plus every code/text file); scanned or handwritten PDFs are recognised and their original pages handed to the AI to look at.
26
- - **Linked files too:** course files that a module links on Google Docs/Slides/Sheets, Google Drive, Dropbox (files and folders) and GitHub (files and repositories) are read as text, as long as they are shared publicly ("Anyone with the link").
28
+ - **Linked files too:** course files that a module, the syllabus, a page, an assignment or an announcement links on Google Docs/Slides/Sheets, Google Drive, Dropbox (files and folders) and GitHub (files and repositories) are read as text, as long as they are shared publicly ("Anyone with the link").
27
29
  - **Finds files the course hides:** many courses hide their Files list from students. The connector searches the front page, syllabus, modules, assignments, announcements and the pages they link to, so the AI can still find "the Lecture 12 slides" by name.
28
30
  - **Starter code onto your computer:** in Claude Code, Codex and other coding agents, ask "set up PA2 from Canvas". The agent gets a one-time download link that expires in minutes, saves the assignment's starter files in your folder and unpacks them.
29
31
  - **Read-only by design:** only fixed `GET` requests. It never submits work, sends messages, starts quizzes or changes anything in Canvas.
@@ -135,9 +135,12 @@ export async function parseCanvasResponse(bytes) {
135
135
  }
136
136
  // A JSON answer is Canvas itself speaking; HTML (or a 406) means something in front of Canvas
137
137
  // rejected the request, which is when the socket path is worth trying.
138
+ // A rate limit (429, or Canvas's own 403 carrying X-Rate-Limit-Remaining) is Canvas speaking too:
139
+ // asking again at once over the socket would only add to the count.
138
140
  export function shouldFallBack(response) {
139
141
  if (response.status === 406) return true;
140
- if (response.status < 400) return false;
142
+ if (response.status < 400 || response.status === 429) return false;
143
+ if (response.headers.has('x-rate-limit-remaining')) return false;
141
144
  return !(response.headers.get('content-type') ?? '').toLowerCase().includes('json');
142
145
  }
143
146
 
package/canvas.mjs CHANGED
@@ -219,7 +219,19 @@ export async function callCanvas(name, args, token, fetcher = fetch, opts = {})
219
219
  }
220
220
  if (name === 'canvas_file_content') return readFileContent(r.data, args, fetcher);
221
221
  if (args.raw) return r;
222
- const out = slim(name, r, { timeZone: opts.timeZone });
222
+ // Compact grade rows name each assignment's group and come with the weights and drop rules:
223
+ // one more request, and the rows still come back without them if it fails.
224
+ let groups;
225
+ if (name === 'canvas_course_submissions' && !args.full)
226
+ groups = await canvasGet(
227
+ endpoint('canvas_assignment_groups', { course_id: args.course_id }),
228
+ token,
229
+ fetcher,
230
+ ).then(
231
+ g => (Array.isArray(g.data) ? g.data : undefined),
232
+ () => undefined,
233
+ );
234
+ const out = slim(name, r, { timeZone: opts.timeZone, full: args.full === true, groups });
223
235
  if (name === 'canvas_calendar_events' && Array.isArray(out.data) && out.data.length === 0) {
224
236
  const where = args.context_codes ? 'these calendars' : 'your personal calendar';
225
237
  const what =
@@ -246,6 +258,36 @@ export function unreachable(e) {
246
258
 
247
259
  // One GET to the Canvas API: fixed headers, no redirects, Canvas errors turned into safe messages
248
260
  // (never the upstream body). `e.status` is set when Canvas refused this item (not a token problem).
261
+ // Canvas throttles API use per token: it answers "403 Forbidden (Rate Limit Exceeded)" (its own
262
+ // answers carry X-Rate-Limit-Remaining), and a proxy in front may answer 429. That is not "this
263
+ // course hides its files" (no find_files hint, no retry over the socket): the AI is told to wait.
264
+ // The error has no `status`, so nothing treats it as a hidden resource.
265
+ async function rateLimit(r) {
266
+ if (r.status !== 429 && r.status !== 403) return null;
267
+ let limited = r.status === 429;
268
+ if (!limited) {
269
+ const remaining = r.headers.get('x-rate-limit-remaining');
270
+ if (remaining !== null && Number(remaining) <= 0) limited = true;
271
+ else if (!(r.headers.get('content-type') ?? '').includes('json'))
272
+ try {
273
+ limited = /rate limit/i.test((await r.clone().text()).slice(0, 2000));
274
+ } catch {}
275
+ }
276
+ if (!limited) return null;
277
+ await r.body?.cancel().catch(() => {});
278
+ const seconds = Number(r.headers.get('retry-after'));
279
+ const wait =
280
+ Number.isFinite(seconds) && seconds > 0
281
+ ? `${Math.min(Math.ceil(seconds), 3600)} seconds`
282
+ : 'about a minute';
283
+ return Object.assign(
284
+ new Error(
285
+ `Canvas rate limit reached (HTTP ${r.status}): too many Canvas requests in a short time. Wait ${wait} before the next Canvas call and do not retry in a loop. The token and the connection are fine; this is not a permission problem.`,
286
+ ),
287
+ { rateLimited: true },
288
+ );
289
+ }
290
+
249
291
  export async function canvasGet(u, token, fetcher = fetch) {
250
292
  let r;
251
293
  try {
@@ -262,6 +304,8 @@ export async function canvasGet(u, token, fetcher = fetch) {
262
304
  } catch (e) {
263
305
  throw unreachable(e);
264
306
  }
307
+ const limited = await rateLimit(r);
308
+ if (limited) throw limited;
265
309
  if (
266
310
  r.status === 406 ||
267
311
  (r.status >= 500 && !(r.headers.get('content-type') ?? '').includes('json'))
package/download-link.mjs CHANGED
@@ -160,12 +160,20 @@ export function newDownloadId() {
160
160
  .replace(/=+$/, '');
161
161
  }
162
162
 
163
+ // Cloudflare KV failed (briefly unavailable, or the free plan's 1000 writes a day are used up).
164
+ export const STORAGE_DOWN =
165
+ "Connector storage (Cloudflare KV) is unavailable right now, so no download link could be made. It is often the free plan's daily KV limit, which resets at 00:00 UTC. Meanwhile use canvas_file_original or canvas_file_content; the Canvas connection itself is fine.";
166
+
163
167
  // Store the Canvas address for 5 minutes and return the Worker link that leads to it.
164
168
  export async function issueDownload(kv, origin, target, now = Date.now) {
165
169
  const id = newDownloadId();
166
- await kv.put('dl:' + id, JSON.stringify({ target, exp: now() + DOWNLOAD_TTL * 1000 }), {
167
- expirationTtl: DOWNLOAD_TTL,
168
- });
170
+ try {
171
+ await kv.put('dl:' + id, JSON.stringify({ target, exp: now() + DOWNLOAD_TTL * 1000 }), {
172
+ expirationTtl: DOWNLOAD_TTL,
173
+ });
174
+ } catch {
175
+ throw new Error(STORAGE_DOWN);
176
+ }
169
177
  return `${origin}/dl/${id}`;
170
178
  }
171
179
 
@@ -188,11 +196,22 @@ export async function redeemDownload(request, kv, id, now = Date.now) {
188
196
  { status: 403, headers: plain },
189
197
  );
190
198
  const key = 'dl:' + id;
199
+ let stored;
200
+ try {
201
+ stored = await kv.get(key);
202
+ } catch {
203
+ // not "expired": the link may be fine, the storage is not
204
+ return new Response(
205
+ "The connector's storage (Cloudflare KV) is unavailable right now. Try again in a minute; if it keeps failing, the free plan's daily limit may be used up (it resets at 00:00 UTC).\n",
206
+ { status: 503, headers: plain },
207
+ );
208
+ }
191
209
  let entry = null;
192
210
  try {
193
- entry = JSON.parse((await kv.get(key)) ?? 'null');
211
+ entry = JSON.parse(stored ?? 'null');
194
212
  } catch {}
195
- if (entry) await kv.delete(key);
213
+ // Used up before it is used: a failed delete does not stop this download (it still expires).
214
+ if (entry) await kv.delete(key).catch(() => {});
196
215
  // KV expiry can lag a little: the stored deadline is checked too.
197
216
  if (!entry?.target || !(now() < entry.exp))
198
217
  return new Response(
package/find-files.mjs CHANGED
@@ -89,7 +89,7 @@ const FILE_HOSTS =
89
89
  /(^|\.)(dropbox\.com|drive\.google\.com|docs\.google\.com|onedrive\.live\.com|1drv\.ms|sharepoint\.com|box\.com|github\.com)$/i;
90
90
  const FILE_EXT =
91
91
  /\.(pdf|zip|pptx?|docx?|xlsx?|csv|txt|md|ipynb|py|java|c|cpp|h|js|tar|gz|tgz|7z|rar)$/i;
92
- function externalFileLink(raw) {
92
+ export function externalFileLink(raw) {
93
93
  let u;
94
94
  try {
95
95
  u = new URL(raw);
@@ -183,15 +183,17 @@ export async function findFiles(args, token, fetcher) {
183
183
  // A source being hidden or not enabled (401/403/404) is normal. A source that failed (timeout,
184
184
  // network, rate limit, Canvas error) was not searched: the result is then incomplete, and if
185
185
  // nothing could be searched at all the error is passed on instead of an empty "complete" list.
186
- // An expired token or an HTML error page (Canvas down, a firewall) stops everything.
186
+ // An expired token, a rate limit or an HTML error page (Canvas down, a firewall) stops everything.
187
187
  const failures = [];
188
188
  const hidden = e => [401, 403, 404].includes(e.status);
189
+ const stops = e =>
190
+ e.rateLimited || /authorization expired|HTML error page/.test(e?.message ?? '');
189
191
  const attempt = async (source, run) => {
190
192
  try {
191
193
  const n = await run();
192
194
  searched[source] = n === undefined ? 'ok' : `ok (${n})`;
193
195
  } catch (e) {
194
- if (/authorization expired|HTML error page/.test(e.message ?? '')) throw e;
196
+ if (stops(e)) throw e;
195
197
  if (hidden(e)) searched[source] = `not available (HTTP ${e.status})`;
196
198
  else {
197
199
  failures.push(e);
@@ -248,9 +250,30 @@ export async function findFiles(args, token, fetcher) {
248
250
  if (e.found_in.length < MAX_PLACES && !e.found_in.some(p => JSON.stringify(p) === key))
249
251
  e.found_in.push(where);
250
252
  };
253
+ // Where a link was written, as canvas_link_content takes it (link-content.mjs).
254
+ const linkSource = where =>
255
+ where.type === 'syllabus'
256
+ ? { source: 'syllabus' }
257
+ : where.type === 'page'
258
+ ? where.page_url
259
+ ? { source: 'page', source_id: where.page_url }
260
+ : { source: 'front_page' }
261
+ : where.type === 'assignment' && Number.isSafeInteger(where.assignment_id)
262
+ ? { source: 'assignment', source_id: String(where.assignment_id) }
263
+ : where.type === 'announcement' && Number.isSafeInteger(where.topic_id)
264
+ ? { source: 'discussion', source_id: String(where.topic_id) }
265
+ : null;
251
266
  const addExternal = (x, where) => {
252
- if (external.length < MAX_EXTERNAL && !external.some(y => y.url === x.url))
253
- external.push({ ...x, found_in: where });
267
+ if (external.length < MAX_EXTERNAL && !external.some(y => y.url === x.url)) {
268
+ const from = linkSource(where);
269
+ external.push({
270
+ ...x,
271
+ found_in: where,
272
+ ...(from
273
+ ? { read_with: { tool: 'canvas_link_content', course_id: c, ...from, url: x.url } }
274
+ : {}),
275
+ });
276
+ }
254
277
  };
255
278
  const addHtml = (html, where) => {
256
279
  const r = scanHtml(html, c);
@@ -365,7 +388,7 @@ export async function findFiles(args, token, fetcher) {
365
388
  page_url: r.data?.url ?? slug,
366
389
  });
367
390
  } catch (e) {
368
- if (/authorization expired|HTML error page/.test(e.message ?? '')) throw e;
391
+ if (stops(e)) throw e;
369
392
  if (hidden(e)) pagesUnavailable++;
370
393
  else {
371
394
  failures.push(e);
@@ -438,6 +461,6 @@ export async function findFiles(args, token, fetcher) {
438
461
  }
439
462
  : {}),
440
463
  how_to_read:
441
- 'Read a file_id with canvas_file_content (text from PDF/Office/code, ZIP listing and contents) or canvas_file_original (original bytes, e.g. scanned PDFs). An external link with read_with (a module item): call canvas_module_file_content with those IDs for its text (Google Docs/Slides/Sheets/Drive, Dropbox, GitHub), or canvas_module_file for the original file. Other external links cannot be read by this connector: give the user the link.',
464
+ 'Read a file_id with canvas_file_content (text from PDF/Office/code, ZIP listing and contents) or canvas_file_original (original bytes, e.g. scanned PDFs). An external link with read_with: call the tool it names with those arguments for its text (Google Docs/Slides/Sheets/Drive, Dropbox, GitHub, direct file links); for a module item canvas_module_file gives the original file. Other external links cannot be read by this connector: give the user the link.',
442
465
  };
443
466
  }
package/i18n.mjs CHANGED
@@ -51,6 +51,9 @@ const S = {
51
51
  down_title: 'Canvas is not answering right now',
52
52
  down_hint:
53
53
  'Your token is probably fine: Canvas is slow, down for maintenance, or could not be reached. Wait a few minutes, then go back and try again.',
54
+ busy_title: 'Something went wrong on your connector',
55
+ busy_body:
56
+ "Usually its Cloudflare storage is briefly unavailable, or the free plan's daily storage limit is used up (it resets at 00:00 UTC). Your token and your connection are not affected. Try again later.",
54
57
  footer: 'Open source · read-only · runs on your own Cloudflare account',
55
58
  version_label: 'Version',
56
59
  not_configured: 'Not configured yet. Run the installer.',
@@ -101,6 +104,9 @@ const S = {
101
104
  expired_body: '这个页面打开太久了,或者在另一个标签页里又打开过一次。请返回,重新粘贴令牌。',
102
105
  down_title: 'Canvas 暂时没有响应',
103
106
  down_hint: '令牌多半没有问题:Canvas 现在很慢、正在维护,或者连不上。等几分钟后返回重试。',
107
+ busy_title: '你的连接器出了点问题',
108
+ busy_body:
109
+ '通常是它的 Cloudflare 存储暂时不可用,或者免费版当天的存储额度用完了(每天 UTC 0 点重置)。你的令牌和已有的连接都不受影响,请稍后再试。',
104
110
  footer: '开源 · 只读 · 运行在你自己的 Cloudflare 账号上',
105
111
  version_label: '版本',
106
112
  not_configured: '还没配置,请运行安装向导。',
@@ -155,6 +161,9 @@ const S = {
155
161
  down_title: 'Canvas no responde ahora mismo',
156
162
  down_hint:
157
163
  'Seguramente tu token está bien: Canvas va lento, está en mantenimiento o no se pudo contactar. Espera unos minutos, luego vuelve e inténtalo de nuevo.',
164
+ busy_title: 'Algo salió mal en tu conector',
165
+ busy_body:
166
+ 'Normalmente su almacenamiento de Cloudflare no está disponible por un momento, o se agotó el límite diario del plan gratuito (se reinicia a las 00:00 UTC). Tu token y tu conexión no se ven afectados. Inténtalo más tarde.',
158
167
  footer: 'Código abierto · solo lectura · en tu propia cuenta de Cloudflare',
159
168
  version_label: 'Versión',
160
169
  not_configured: 'Aún no configurado. Ejecuta el instalador.',
@@ -211,6 +220,9 @@ const S = {
211
220
  down_title: 'Canvas が今は応答していません',
212
221
  down_hint:
213
222
  'トークンはおそらく問題ありません。Canvas が遅いか、メンテナンス中か、接続できませんでした。数分待ってから、戻ってもう一度お試しください。',
223
+ busy_title: 'コネクタで問題が発生しました',
224
+ busy_body:
225
+ '多くの場合、Cloudflare のストレージが一時的に使えないか、無料プランの 1 日あたりのストレージ上限に達しています(UTC 0:00 にリセット)。トークンと接続には影響ありません。しばらくしてからもう一度お試しください。',
214
226
  footer: 'オープンソース · 読み取り専用 · あなたの Cloudflare アカウントで動作',
215
227
  version_label: 'バージョン',
216
228
  not_configured: '未設定です。インストーラーを実行してください。',
@@ -266,6 +278,9 @@ const S = {
266
278
  down_title: 'Canvas अभी जवाब नहीं दे रहा',
267
279
  down_hint:
268
280
  'आपका टोकन शायद ठीक है: Canvas धीमा है, रखरखाव में है, या उससे संपर्क नहीं हो सका। कुछ मिनट रुकें, फिर वापस जाकर दोबारा कोशिश करें।',
281
+ busy_title: 'आपके कनेक्टर में कुछ गड़बड़ हुई',
282
+ busy_body:
283
+ 'आम तौर पर इसका Cloudflare स्टोरेज कुछ देर के लिए उपलब्ध नहीं होता, या फ़्री प्लान की रोज़ की स्टोरेज सीमा खत्म हो गई है (यह 00:00 UTC पर रीसेट होती है)। आपका टोकन और कनेक्शन प्रभावित नहीं हैं। बाद में फिर से कोशिश करें।',
269
284
  footer: 'ओपन सोर्स · केवल पढ़ना · आपके अपने Cloudflare खाते पर चलता है',
270
285
  version_label: 'संस्करण',
271
286
  not_configured: 'अभी कॉन्फ़िगर नहीं है। इंस्टॉलर चलाएँ।',
package/installer/cli.mjs CHANGED
@@ -6,7 +6,8 @@
6
6
  // Running it again updates the connector to the code you are running and keeps its storage, so
7
7
  // existing Claude/ChatGPT connections keep working.
8
8
  // Language: your system language if supported (en, zh, es, ja, hi), else English; --lang to choose.
9
- // Automation/tests: --name, --canvas-host, --school, --account-id, --yes; env NCLMS_HOME, NCLMS_LANG.
9
+ // Automation/tests: --name, --canvas-host, --school, --account-id, --yes; env NCLMS_HOME, NCLMS_LANG
10
+ // (tests only: NCLMS_WRANGLER, a stand-in for wrangler's bin script).
10
11
  // Exit codes: 0 done, 1 failed, 2 deployed but not confirmed online yet (run `check` later).
11
12
  import { spawnSync } from 'node:child_process';
12
13
  import { createRequire } from 'node:module';
@@ -40,12 +41,16 @@ import { ensureSubdomain } from './subdomain.mjs';
40
41
 
41
42
  const ROOT = path.resolve(fileURLToPath(new URL('..', import.meta.url)));
42
43
  // wrangler's package "exports" hide bin/; find the package root from its main entry instead.
43
- const WRANGLER = (() => {
44
- let dir = path.dirname(createRequire(import.meta.url).resolve('wrangler'));
45
- while (path.basename(dir) !== 'wrangler' || !fs.existsSync(path.join(dir, 'package.json')))
46
- dir = path.dirname(dir);
47
- return path.join(dir, 'bin', 'wrangler.js');
48
- })();
44
+ const WRANGLER =
45
+ process.env.NCLMS_WRANGLER ||
46
+ (() => {
47
+ let dir = path.dirname(createRequire(import.meta.url).resolve('wrangler'));
48
+ while (path.basename(dir) !== 'wrangler' || !fs.existsSync(path.join(dir, 'package.json'))) {
49
+ if (path.dirname(dir) === dir) throw new Error('wrangler is not installed');
50
+ dir = path.dirname(dir);
51
+ }
52
+ return path.join(dir, 'bin', 'wrangler.js');
53
+ })();
49
54
  const HOME = process.env.NCLMS_HOME || path.join(os.homedir(), '.neuro-canvas-lms');
50
55
  const args = parseArgs(process.argv.slice(2));
51
56
  function systemLanguages() {
@@ -67,7 +72,9 @@ const fail = detail => {
67
72
  };
68
73
 
69
74
  let accountEnv = {};
70
- function wrangler(argv, { cwd, inherit, timeout } = {}) {
75
+ // Every wrangler step has a time limit (a hung network must not hang the installer); only the
76
+ // interactive login waits as long as the user needs.
77
+ function wrangler(argv, { cwd, inherit, timeout = inherit ? undefined : 2 * 60 * 1000 } = {}) {
71
78
  const r = spawnSync(process.execPath, [WRANGLER, ...argv], {
72
79
  cwd,
73
80
  encoding: 'utf8',
@@ -75,7 +82,37 @@ function wrangler(argv, { cwd, inherit, timeout } = {}) {
75
82
  timeout,
76
83
  env: { ...process.env, WRANGLER_SEND_METRICS: 'false', ...accountEnv },
77
84
  });
78
- return { ok: r.status === 0, out: `${r.stdout ?? ''}${r.stderr ?? ''}`, stdout: r.stdout ?? '' };
85
+ let out = `${r.stdout ?? ''}${r.stderr ?? ''}`;
86
+ if (r.error?.code === 'ETIMEDOUT') out += '\n' + msg(lang, 'timed_out', { s: timeout / 1000 });
87
+ return { ok: r.status === 0, out, stdout: r.stdout ?? '' };
88
+ }
89
+ // JSON that wrangler printed on stdout (warnings and banners go to stderr, or come before it).
90
+ function stdoutJson(r, open) {
91
+ try {
92
+ return JSON.parse(r.stdout.slice(r.stdout.indexOf(open)));
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ // The local install record. A damaged file is moved aside (and said so) instead of failing every
99
+ // run with the same JSON error; it is written to a temporary file first, then renamed, so an
100
+ // interrupted write cannot damage it.
101
+ function readState(statePath) {
102
+ if (!fs.existsSync(statePath)) return {};
103
+ try {
104
+ const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
105
+ if (state && typeof state === 'object' && !Array.isArray(state)) return state;
106
+ } catch {}
107
+ const moved = `${statePath}.damaged-${Date.now()}`;
108
+ fs.renameSync(statePath, moved);
109
+ say('state_broken', { moved });
110
+ return {};
111
+ }
112
+ function writeState(statePath, state) {
113
+ const tmp = `${statePath}.tmp`;
114
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
115
+ fs.renameSync(tmp, statePath);
79
116
  }
80
117
  // A failed wrangler step: an unverified Cloudflare email gets its own clear message.
81
118
  const failStep = (what, out) =>
@@ -140,12 +177,13 @@ async function ensureLogin(saved) {
140
177
  }
141
178
  if (!who.ok) fail(msg(lang, 'login_failed'));
142
179
  }
143
- const info = JSON.parse(who.out.slice(who.out.indexOf('{')));
180
+ const info = stdoutJson(who, '{');
181
+ if (!info) failStep('wrangler whoami', who.out);
144
182
  ok('logged_in', { email: info.email ?? '' });
145
183
  const accounts = info.accounts ?? [];
146
184
  if (args.accountId) return args.accountId;
147
185
  if (saved && accounts.some(a => a.id === saved)) return saved;
148
- if (!accounts.length) fail('no Cloudflare account found');
186
+ if (!accounts.length) fail(msg(lang, 'no_account'));
149
187
  if (accounts.length === 1) return accounts[0].id;
150
188
  // Run by an agent (no terminal) or with --yes: never pick an account silently.
151
189
  if (args.yes || !process.stdin.isTTY)
@@ -174,13 +212,17 @@ async function checkCanvas(host) {
174
212
  try {
175
213
  const r = await fetch(`https://${host}/api/v1/users/self`, {
176
214
  redirect: 'manual',
215
+ signal: AbortSignal.timeout(20 * 1000),
177
216
  headers: { 'User-Agent': 'Neuro-CanvasLMS-installer', Accept: 'application/json' },
178
217
  });
179
218
  const ct = (r.headers.get('content-type') ?? '').split(';')[0];
180
219
  if (r.status === 401 && ct.includes('json')) return { ok: true };
181
220
  return { ok: false, detail: `HTTP ${r.status}${ct ? ', ' + ct : ''}` };
182
221
  } catch (e) {
183
- return { ok: false, detail: e.cause?.code ?? e.message };
222
+ return {
223
+ ok: false,
224
+ detail: e.name === 'TimeoutError' ? 'timeout' : (e.cause?.code ?? e.message),
225
+ };
184
226
  }
185
227
  }
186
228
  async function askCanvasHost(previous, { quiet = false } = {}) {
@@ -205,19 +247,15 @@ async function askCanvasHost(previous, { quiet = false } = {}) {
205
247
  fail(msg(lang, 'cancelled'));
206
248
  }
207
249
 
208
- // null = the list could not be read (never treat that as "no storage").
250
+ // { list } or, when the list could not be read, { detail } (never treat that as "no storage").
209
251
  function listNamespaces() {
210
252
  const r = wrangler(['kv', 'namespace', 'list']);
211
- if (!r.ok) return null;
212
- try {
213
- return JSON.parse(r.out.slice(r.out.indexOf('[')));
214
- } catch {
215
- return null;
216
- }
253
+ const list = r.ok ? stdoutJson(r, '[') : null;
254
+ return Array.isArray(list) ? { list } : { detail: lastLines(r.out) || 'no answer' };
217
255
  }
218
256
  function ensureKv(name, state) {
219
- const namespaces = listNamespaces();
220
- if (!namespaces) fail('could not list storage');
257
+ const { list: namespaces, detail } = listNamespaces();
258
+ if (!namespaces) failStep(msg(lang, 'step_kv_list'), detail);
221
259
  const existing =
222
260
  namespaces.find(n => n.id === state.kvId) ?? namespaces.find(n => n.title === `${name}-oauth`);
223
261
  if (existing) {
@@ -227,7 +265,7 @@ function ensureKv(name, state) {
227
265
  say('kv');
228
266
  const r = wrangler(['kv', 'namespace', 'create', `${name}-oauth`]);
229
267
  const id = r.out.match(/"id":\s*"([a-f0-9]{32})"/)?.[1];
230
- if (!id) failStep('could not create storage', r.out);
268
+ if (!id) failStep(msg(lang, 'step_kv_create'), r.out);
231
269
  return id;
232
270
  }
233
271
 
@@ -250,11 +288,11 @@ async function install() {
250
288
  const [major] = process.versions.node.split('.').map(Number);
251
289
  if (major < 22) fail(msg(lang, 'node_old', { need: 22, have: process.versions.node }));
252
290
  const name = args.name ?? DEFAULT_NAME;
253
- if (!validWorkerName(name)) fail('invalid --name');
291
+ if (!validWorkerName(name)) fail(msg(lang, 'invalid_name'));
254
292
  const dir = path.join(HOME, name);
255
293
  fs.mkdirSync(dir, { recursive: true });
256
294
  const statePath = path.join(dir, 'state.json');
257
- const state = fs.existsSync(statePath) ? JSON.parse(fs.readFileSync(statePath, 'utf8')) : {};
295
+ const state = readState(statePath);
258
296
  const plan = updateChoices(state, args);
259
297
  if (plan.updating) {
260
298
  const from = await runningVersion(state.url);
@@ -270,10 +308,7 @@ async function install() {
270
308
  const school = plan.school;
271
309
  const kvId = ensureKv(name, state);
272
310
  const save = extra =>
273
- fs.writeFileSync(
274
- statePath,
275
- JSON.stringify({ ...state, name, accountId, kvId, host, school, ...extra }, null, 2),
276
- );
311
+ writeState(statePath, { ...state, name, accountId, kvId, host, school, ...extra });
277
312
  save({});
278
313
 
279
314
  say('deploy', { version: VERSION });
@@ -281,7 +316,9 @@ async function install() {
281
316
  const main = path.relative(dir, path.join(ROOT, 'worker.mjs'));
282
317
  const config = connectorConfig({ name, accountId, kvId, main, template, host, school });
283
318
  fs.writeFileSync(path.join(dir, 'wrangler.jsonc'), JSON.stringify(config, null, 2));
284
- let r = wrangler(['deploy', '-c', 'wrangler.jsonc'], { cwd: dir });
319
+ const deploy = () =>
320
+ wrangler(['deploy', '-c', 'wrangler.jsonc'], { cwd: dir, timeout: 5 * 60 * 1000 });
321
+ let r = deploy();
285
322
  // New Cloudflare account: the user picks a workers.dev subdomain in the dashboard, then we retry.
286
323
  for (let i = 0; !r.ok && needsSubdomain(r.out) && i < 3; i++) {
287
324
  const link = subdomainLink(accountId);
@@ -289,11 +326,11 @@ async function install() {
289
326
  say('subdomain_needed', { link });
290
327
  openUrl(link);
291
328
  await ask(msg(lang, 'subdomain_wait'));
292
- r = wrangler(['deploy', '-c', 'wrangler.jsonc'], { cwd: dir });
329
+ r = deploy();
293
330
  }
294
- if (!r.ok) failStep('deploy failed', r.out);
331
+ if (!r.ok) failStep(msg(lang, 'step_deploy'), r.out);
295
332
  const url = parseWorkerUrl(r.out) ?? state.url;
296
- if (!url) fail('could not read the connector URL: ' + lastLines(r.out));
333
+ if (!url) fail(`${msg(lang, 'step_url')}: ${lastLines(r.out)}`);
297
334
  ok('deploy_ok', { url });
298
335
  save({ url, version: VERSION });
299
336
 
@@ -357,9 +394,9 @@ function nextSteps(url, host, { tokenAsked = false } = {}) {
357
394
  async function check() {
358
395
  console.log(`\n ${msg(lang, 'title')}\n`);
359
396
  const name = args.name ?? DEFAULT_NAME;
360
- if (!validWorkerName(name)) fail('invalid --name');
397
+ if (!validWorkerName(name)) fail(msg(lang, 'invalid_name'));
361
398
  const statePath = path.join(HOME, name, 'state.json');
362
- const state = fs.existsSync(statePath) ? JSON.parse(fs.readFileSync(statePath, 'utf8')) : {};
399
+ const state = readState(statePath);
363
400
  if (!state.url) {
364
401
  console.error(' ✗ ' + msg(lang, 'check_none', { name }));
365
402
  process.exitCode = 1;
@@ -379,7 +416,7 @@ async function uninstall() {
379
416
  const name = args.name ?? DEFAULT_NAME;
380
417
  const dir = path.join(HOME, name);
381
418
  const statePath = path.join(dir, 'state.json');
382
- const state = fs.existsSync(statePath) ? JSON.parse(fs.readFileSync(statePath, 'utf8')) : {};
419
+ const state = readState(statePath);
383
420
  // The account the connector was installed in. In any other account "Worker does not exist"
384
421
  // would look like success while the Worker and its storage (with the token grants) stay online.
385
422
  const accountId = await ensureLogin(state.accountId);
@@ -393,8 +430,15 @@ async function uninstall() {
393
430
  if (!worker.ok && !/\b10090\b|does not exist/i.test(worker.out))
394
431
  fail(msg(lang, 'un_failed', { what: `Worker "${name}"`, detail: lastLines(worker.out) }));
395
432
  const before = listNamespaces();
396
- if (!before) fail(msg(lang, 'un_failed', { what: 'storage', detail: 'could not list storage' }));
397
- const kv = before.find(n => n.id === state.kvId) ?? before.find(n => n.title === `${name}-oauth`);
433
+ if (!before.list)
434
+ fail(
435
+ msg(lang, 'un_failed', {
436
+ what: msg(lang, 'step_kv_list'),
437
+ detail: before.detail,
438
+ }),
439
+ );
440
+ const kv =
441
+ before.list.find(n => n.id === state.kvId) ?? before.find(n => n.title === `${name}-oauth`);
398
442
  if (kv) {
399
443
  const r = wrangler([
400
444
  'kv',
@@ -404,11 +448,12 @@ async function uninstall() {
404
448
  kv.id,
405
449
  '--skip-confirmation',
406
450
  ]);
451
+ const what = msg(lang, 'storage_label', { title: kv.title });
407
452
  if (!r.ok && !/\b10013\b|not found/i.test(r.out))
408
- fail(msg(lang, 'un_failed', { what: `storage "${kv.title}"`, detail: lastLines(r.out) }));
453
+ fail(msg(lang, 'un_failed', { what, detail: lastLines(r.out) }));
409
454
  const after = listNamespaces();
410
- if (!after || after.some(n => n.id === kv.id))
411
- fail(msg(lang, 'un_failed', { what: `storage "${kv.title}"`, detail: 'still listed' }));
455
+ if (!after.list || after.list.some(n => n.id === kv.id))
456
+ fail(msg(lang, 'un_failed', { what, detail: after.detail ?? msg(lang, 'still_listed') }));
412
457
  }
413
458
  fs.rmSync(dir, { recursive: true, force: true });
414
459
  ok('un_done');