neuro-canvas-lms 2.13.0 → 2.13.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/AGENTS.md CHANGED
@@ -39,3 +39,8 @@ Code map:
39
39
  settings (without a terminal it does not ask). `installer/verify.mjs`: the post-deploy online
40
40
  check, also run alone by `npx -y neuro-canvas-lms check`.
41
41
  - `version.mjs`: shown on the home page and /healthz, compared with npm by update-check.mjs.
42
+ - `server.json`: the entry in the official MCP Registry (one remote whose host each user fills in).
43
+ Same version as `version.mjs` and package.json (`version.test.mjs`).
44
+ - Releases: bump those three, push to main, then push a tag `vX.Y.Z`;
45
+ `.github/workflows/publish.yml` tests, publishes to npm and updates the MCP Registry, with
46
+ 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)).
@@ -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
@@ -246,6 +246,36 @@ export function unreachable(e) {
246
246
 
247
247
  // One GET to the Canvas API: fixed headers, no redirects, Canvas errors turned into safe messages
248
248
  // (never the upstream body). `e.status` is set when Canvas refused this item (not a token problem).
249
+ // Canvas throttles API use per token: it answers "403 Forbidden (Rate Limit Exceeded)" (its own
250
+ // answers carry X-Rate-Limit-Remaining), and a proxy in front may answer 429. That is not "this
251
+ // course hides its files" (no find_files hint, no retry over the socket): the AI is told to wait.
252
+ // The error has no `status`, so nothing treats it as a hidden resource.
253
+ async function rateLimit(r) {
254
+ if (r.status !== 429 && r.status !== 403) return null;
255
+ let limited = r.status === 429;
256
+ if (!limited) {
257
+ const remaining = r.headers.get('x-rate-limit-remaining');
258
+ if (remaining !== null && Number(remaining) <= 0) limited = true;
259
+ else if (!(r.headers.get('content-type') ?? '').includes('json'))
260
+ try {
261
+ limited = /rate limit/i.test((await r.clone().text()).slice(0, 2000));
262
+ } catch {}
263
+ }
264
+ if (!limited) return null;
265
+ await r.body?.cancel().catch(() => {});
266
+ const seconds = Number(r.headers.get('retry-after'));
267
+ const wait =
268
+ Number.isFinite(seconds) && seconds > 0
269
+ ? `${Math.min(Math.ceil(seconds), 3600)} seconds`
270
+ : 'about a minute';
271
+ return Object.assign(
272
+ new Error(
273
+ `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.`,
274
+ ),
275
+ { rateLimited: true },
276
+ );
277
+ }
278
+
249
279
  export async function canvasGet(u, token, fetcher = fetch) {
250
280
  let r;
251
281
  try {
@@ -262,6 +292,8 @@ export async function canvasGet(u, token, fetcher = fetch) {
262
292
  } catch (e) {
263
293
  throw unreachable(e);
264
294
  }
295
+ const limited = await rateLimit(r);
296
+ if (limited) throw limited;
265
297
  if (
266
298
  r.status === 406 ||
267
299
  (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
@@ -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);
@@ -365,7 +367,7 @@ export async function findFiles(args, token, fetcher) {
365
367
  page_url: r.data?.url ?? slug,
366
368
  });
367
369
  } catch (e) {
368
- if (/authorization expired|HTML error page/.test(e.message ?? '')) throw e;
370
+ if (stops(e)) throw e;
369
371
  if (hidden(e)) pagesUnavailable++;
370
372
  else {
371
373
  failures.push(e);
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');
@@ -80,6 +80,19 @@ const M = {
80
80
  update_cancelled: 'Nothing changed. Run the same command again whenever you want to update.',
81
81
  failed:
82
82
  'Something went wrong: {detail}\n Nothing is lost: run the same command again to continue.',
83
+ state_broken:
84
+ 'The local install record could not be read (damaged). It was moved aside to {moved}; carrying on without it.',
85
+ no_account:
86
+ 'This Cloudflare login has no account yet. Finish signing up at https://dash.cloudflare.com, then run this again.',
87
+ invalid_name:
88
+ '--name can only use lowercase letters, digits and dashes (at most 63 characters).',
89
+ step_kv_list: 'Could not read the list of your Cloudflare storage (KV)',
90
+ step_kv_create: 'Could not create the Cloudflare storage (KV)',
91
+ step_deploy: 'Uploading the connector to Cloudflare failed',
92
+ step_url: 'The connector was uploaded, but Cloudflare did not say its address',
93
+ storage_label: 'the storage "{title}"',
94
+ still_listed: 'Cloudflare still lists it after deleting',
95
+ timed_out: 'Cloudflare did not answer within {s} seconds (check your internet connection)',
83
96
  },
84
97
  zh: {
85
98
  title: 'Neuro-CanvasLMS 安装向导:把你的 Canvas 接进 Claude 或 ChatGPT',
@@ -87,7 +100,7 @@ const M = {
87
100
  '需要 Node.js {need} 或更新的版本(你现在是 {have})。请到 https://nodejs.org 安装后再运行一次。',
88
101
  login_needed:
89
102
  '第 1/4 步 · 登录 Cloudflare(没有账号可以免费注册:https://dash.cloudflare.com/sign-up)。\n 浏览器会打开一个 Cloudflare 页面(可能在后台标签页里),请在 2 分钟内点蓝色的 "Authorize" 按钮。',
90
- login_retry: '没有完成登录。请关掉旧的 Cloudflare 标签页,现在重新打开一个。',
103
+ login_retry: '没有完成登录。请关掉旧的 Cloudflare 标签页,马上会自动打开一个新页面。',
91
104
  login_failed: '没能登录 Cloudflare。准备好之后再运行一次这条命令。',
92
105
  logged_in: '已登录 Cloudflare:{email}',
93
106
  pick_account: '把连接器装在哪个 Cloudflare 账号里?',
@@ -149,7 +162,18 @@ const M = {
149
162
  update_q_unknown:
150
163
  '找到你已有的安装「{name}」。要更新到 {to} 吗?设置会沿用,AI 应用不用重新连接。(y/n)',
151
164
  update_cancelled: '什么都没改。想更新时再运行一次同一条命令就行。',
152
- failed: '出错了:{detail}\n 没有任何东西丢失:再运行一次同一条命令就能接着装。',
165
+ failed: '出错了:{detail}\n 没有任何东西丢失:再运行一次同一条命令就能继续。',
166
+ state_broken: '本地安装记录读不出来(文件已损坏),已挪到 {moved},接下来不再用它。',
167
+ no_account:
168
+ '这个 Cloudflare 登录下还没有账号。先在 https://dash.cloudflare.com 完成注册,再运行一次这条命令。',
169
+ invalid_name: '--name 只能用小写字母、数字和短横线(最多 63 个字符)。',
170
+ step_kv_list: '没能读取你的 Cloudflare 存储(KV)列表',
171
+ step_kv_create: '没能创建 Cloudflare 存储(KV)',
172
+ step_deploy: '把连接器上传到 Cloudflare 失败',
173
+ step_url: '连接器已经上传,但 Cloudflare 没有告诉它的地址',
174
+ storage_label: '存储「{title}」',
175
+ still_listed: '删除后 Cloudflare 仍然列出了它',
176
+ timed_out: 'Cloudflare 在 {s} 秒内没有回应(请检查网络连接)',
153
177
  },
154
178
  es: {
155
179
  title: 'Instalador de Neuro-CanvasLMS — conecta tu Canvas a Claude o ChatGPT',
@@ -233,6 +257,18 @@ const M = {
233
257
  update_cancelled: 'No se cambió nada. Ejecuta el mismo comando cuando quieras actualizar.',
234
258
  failed:
235
259
  'Algo salió mal: {detail}\n No se perdió nada: vuelve a ejecutar el mismo comando para continuar.',
260
+ state_broken:
261
+ 'No se pudo leer el registro local de la instalación (está dañado). Se movió a {moved}; se continúa sin él.',
262
+ no_account:
263
+ 'Este inicio de sesión de Cloudflare aún no tiene cuenta. Termina el registro en https://dash.cloudflare.com y vuelve a ejecutar esto.',
264
+ invalid_name: '--name solo puede usar minúsculas, dígitos y guiones (máximo 63 caracteres).',
265
+ step_kv_list: 'No se pudo leer la lista de tu almacenamiento de Cloudflare (KV)',
266
+ step_kv_create: 'No se pudo crear el almacenamiento de Cloudflare (KV)',
267
+ step_deploy: 'Falló la subida del conector a Cloudflare',
268
+ step_url: 'El conector se subió, pero Cloudflare no indicó su dirección',
269
+ storage_label: 'el almacenamiento "{title}"',
270
+ still_listed: 'Cloudflare aún lo muestra después de borrarlo',
271
+ timed_out: 'Cloudflare no respondió en {s} segundos (revisa tu conexión a internet)',
236
272
  },
237
273
  ja: {
238
274
  title: 'Neuro-CanvasLMS インストーラー — Canvas を Claude や ChatGPT につなぐ',
@@ -316,6 +352,19 @@ const M = {
316
352
  '何も変更していません。更新したいときに同じコマンドをもう一度実行してください。',
317
353
  failed:
318
354
  'エラーが発生しました:{detail}\n 何も失われていません。同じコマンドをもう一度実行すると続きから進みます。',
355
+ state_broken:
356
+ 'ローカルのインストール記録を読み取れませんでした(破損しています)。{moved} に移動し、使わずに続行します。',
357
+ no_account:
358
+ 'この Cloudflare ログインにはまだアカウントがありません。https://dash.cloudflare.com で登録を完了してから、もう一度実行してください。',
359
+ invalid_name: '--name に使えるのは英小文字・数字・ハイフンだけです(63 文字まで)。',
360
+ step_kv_list: 'Cloudflare のストレージ(KV)の一覧を読み取れませんでした',
361
+ step_kv_create: 'Cloudflare のストレージ(KV)を作成できませんでした',
362
+ step_deploy: 'コネクタを Cloudflare にアップロードできませんでした',
363
+ step_url: 'コネクタはアップロードされましたが、Cloudflare がアドレスを返しませんでした',
364
+ storage_label: 'ストレージ「{title}」',
365
+ still_listed: '削除後も Cloudflare の一覧に残っています',
366
+ timed_out:
367
+ 'Cloudflare が {s} 秒以内に応答しませんでした(インターネット接続を確認してください)',
319
368
  },
320
369
  hi: {
321
370
  title: 'Neuro-CanvasLMS इंस्टॉलर — अपने Canvas को Claude या ChatGPT से जोड़ें',
@@ -396,6 +445,19 @@ const M = {
396
445
  update_cancelled: 'कुछ नहीं बदला। जब अपडेट करना हो, वही कमांड फिर से चलाएँ।',
397
446
  failed:
398
447
  'कुछ गलत हो गया: {detail}\n कुछ भी नहीं खोया: जारी रखने के लिए वही कमांड फिर से चलाएँ।',
448
+ state_broken:
449
+ 'लोकल इंस्टॉल रिकॉर्ड पढ़ा नहीं जा सका (खराब है)। उसे {moved} पर हटा दिया गया; उसके बिना आगे बढ़ रहे हैं।',
450
+ no_account:
451
+ 'इस Cloudflare लॉगिन में अभी कोई अकाउंट नहीं है। https://dash.cloudflare.com पर साइन-अप पूरा करें, फिर इसे दोबारा चलाएँ।',
452
+ invalid_name:
453
+ '--name में सिर्फ़ छोटे अक्षर, अंक और डैश हो सकते हैं (ज़्यादा से ज़्यादा 63 अक्षर)।',
454
+ step_kv_list: 'आपके Cloudflare स्टोरेज (KV) की सूची पढ़ी नहीं जा सकी',
455
+ step_kv_create: 'Cloudflare स्टोरेज (KV) बनाया नहीं जा सका',
456
+ step_deploy: 'कनेक्टर को Cloudflare पर अपलोड करना विफल रहा',
457
+ step_url: 'कनेक्टर अपलोड हो गया, लेकिन Cloudflare ने उसका पता नहीं बताया',
458
+ storage_label: 'स्टोरेज "{title}"',
459
+ still_listed: 'हटाने के बाद भी Cloudflare उसे दिखा रहा है',
460
+ timed_out: 'Cloudflare ने {s} सेकंड में जवाब नहीं दिया (अपना इंटरनेट कनेक्शन जाँचें)',
399
461
  },
400
462
  };
401
463
  export const msg = (lang, key, vars = {}) =>