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 +5 -0
- package/README.md +2 -0
- package/canvas-transport.mjs +4 -1
- package/canvas.mjs +32 -0
- package/download-link.mjs +24 -5
- package/find-files.mjs +5 -3
- package/i18n.mjs +15 -0
- package/installer/cli.mjs +85 -40
- package/installer/messages.mjs +64 -2
- package/npm-shrinkwrap.json +2779 -0
- package/package.json +1 -1
- package/server.json +32 -0
- package/time.mjs +36 -6
- package/update-check.mjs +6 -2
- package/version.mjs +3 -2
- package/worker.mjs +87 -3
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
|
+
[](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)).
|
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
|
@@ -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
|
-
|
|
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
|
@@ -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);
|
|
@@ -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 (
|
|
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
|
-
|
|
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');
|
package/installer/messages.mjs
CHANGED
|
@@ -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 = {}) =>
|