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 +14 -1
- package/README.md +4 -2
- package/canvas-transport.mjs +4 -1
- package/canvas.mjs +45 -1
- package/download-link.mjs +24 -5
- package/find-files.mjs +30 -7
- package/i18n.mjs +15 -0
- package/installer/cli.mjs +85 -40
- package/installer/messages.mjs +64 -2
- package/link-content.mjs +162 -0
- package/module-file.mjs +20 -16
- package/npm-shrinkwrap.json +2779 -0
- package/package.json +1 -1
- package/read-tools.mjs +21 -13
- package/server.json +32 -0
- package/slim.mjs +186 -1
- package/time.mjs +36 -6
- package/update-check.mjs +6 -2
- package/version.mjs +3 -2
- package/worker.mjs +107 -18
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), `
|
|
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
|
+
[](https://www.npmjs.com/package/neuro-canvas-lms) [](https://github.com/BloodyReginaa/Neuro-CanvasLMS/actions/workflows/test.yml) [](https://github.com/BloodyReginaa/Neuro-CanvasLMS/blob/main/LICENSE)
|
|
6
|
+
|
|
5
7
|

|
|
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
|
-
- **
|
|
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.
|
package/canvas-transport.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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(
|
|
211
|
+
entry = JSON.parse(stored ?? 'null');
|
|
194
212
|
} catch {}
|
|
195
|
-
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
dir = path.dirname(
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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 {
|
|
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
|
-
//
|
|
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
|
-
|
|
212
|
-
|
|
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)
|
|
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(
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
329
|
+
r = deploy();
|
|
293
330
|
}
|
|
294
|
-
if (!r.ok) failStep(
|
|
331
|
+
if (!r.ok) failStep(msg(lang, 'step_deploy'), r.out);
|
|
295
332
|
const url = parseWorkerUrl(r.out) ?? state.url;
|
|
296
|
-
if (!url) fail('
|
|
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(
|
|
397
|
+
if (!validWorkerName(name)) fail(msg(lang, 'invalid_name'));
|
|
361
398
|
const statePath = path.join(HOME, name, 'state.json');
|
|
362
|
-
const state =
|
|
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 =
|
|
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
|
|
397
|
-
|
|
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
|
|
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:
|
|
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');
|