@lukeguo12210/canvas-cli 0.0.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/LICENSE +99 -0
- package/README.md +216 -0
- package/dist/bin/canvas.d.ts +2 -0
- package/dist/bin/canvas.js +1160 -0
- package/dist/bin/canvas.js.map +1 -0
- package/package.json +49 -0
- package/skills/canvas-assignments/SKILL.md +30 -0
- package/skills/canvas-courses/SKILL.md +121 -0
- package/skills/canvas-files/SKILL.md +32 -0
- package/skills/canvas-modules/SKILL.md +42 -0
- package/skills/canvas-review/SKILL.md +40 -0
- package/skills/canvas-shared/SKILL.md +82 -0
- package/skills/canvas-shared/references/auth.md +67 -0
- package/skills/canvas-shared/references/output.md +46 -0
- package/skills/canvas-shared/references/pagination.md +24 -0
|
@@ -0,0 +1,1160 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/core/redaction.ts
|
|
4
|
+
var REDACTED = "[REDACTED]";
|
|
5
|
+
var SECRET_KEY_PATTERN = /(^|[_-])(authorization|access[_-]?token|token|api[_-]?key|secret)$/i;
|
|
6
|
+
var BEARER_PATTERN = /Bearer\s+[A-Za-z0-9._~+/=-]+/gi;
|
|
7
|
+
var QUERY_SECRET_PATTERN = /([?&](?:access_token|token|api_key|verifier|code)=)[^&#\s]+/gi;
|
|
8
|
+
function redactSecrets(value) {
|
|
9
|
+
if (typeof value === "string") {
|
|
10
|
+
return redactString(value);
|
|
11
|
+
}
|
|
12
|
+
if (Array.isArray(value)) {
|
|
13
|
+
return value.map((item) => redactSecrets(item));
|
|
14
|
+
}
|
|
15
|
+
if (value && typeof value === "object") {
|
|
16
|
+
const output2 = {};
|
|
17
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
18
|
+
output2[key] = SECRET_KEY_PATTERN.test(key) ? REDACTED : redactSecrets(nested);
|
|
19
|
+
}
|
|
20
|
+
return output2;
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
function redactString(value) {
|
|
25
|
+
return value.replace(BEARER_PATTERN, `Bearer ${REDACTED}`).replace(QUERY_SECRET_PATTERN, `$1${REDACTED}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// src/core/output.ts
|
|
29
|
+
function formatOutput(result, options = {}) {
|
|
30
|
+
const format = options.format ?? "json";
|
|
31
|
+
const safeResult = redactSecrets(result);
|
|
32
|
+
if (format === "json") {
|
|
33
|
+
return `${JSON.stringify(safeResult, null, 2)}
|
|
34
|
+
`;
|
|
35
|
+
}
|
|
36
|
+
if (format === "ndjson") {
|
|
37
|
+
return `${JSON.stringify(safeResult)}
|
|
38
|
+
`;
|
|
39
|
+
}
|
|
40
|
+
if (!safeResult.ok) {
|
|
41
|
+
const status = safeResult.error.status ? ` (${safeResult.error.status})` : "";
|
|
42
|
+
return `Error ${safeResult.error.code}${status}: ${safeResult.error.message}
|
|
43
|
+
`;
|
|
44
|
+
}
|
|
45
|
+
if (format === "table") {
|
|
46
|
+
return tableOutput(safeResult.data);
|
|
47
|
+
}
|
|
48
|
+
return prettyOutput(safeResult.data);
|
|
49
|
+
}
|
|
50
|
+
async function writeOutput(result, options = {}) {
|
|
51
|
+
const stream = result.ok ? process.stdout : process.stderr;
|
|
52
|
+
stream.write(formatOutput(result, options));
|
|
53
|
+
}
|
|
54
|
+
function prettyOutput(data) {
|
|
55
|
+
if (typeof data === "string") {
|
|
56
|
+
return `${data}
|
|
57
|
+
`;
|
|
58
|
+
}
|
|
59
|
+
return `${JSON.stringify(data, null, 2)}
|
|
60
|
+
`;
|
|
61
|
+
}
|
|
62
|
+
function tableOutput(data) {
|
|
63
|
+
if (!Array.isArray(data)) {
|
|
64
|
+
return prettyOutput(data);
|
|
65
|
+
}
|
|
66
|
+
if (data.length === 0) {
|
|
67
|
+
return "\n";
|
|
68
|
+
}
|
|
69
|
+
const rows = data.filter((row) => {
|
|
70
|
+
return row !== null && typeof row === "object" && !Array.isArray(row);
|
|
71
|
+
});
|
|
72
|
+
if (rows.length === 0) {
|
|
73
|
+
return prettyOutput(data);
|
|
74
|
+
}
|
|
75
|
+
const columns = Object.keys(rows[0] ?? {});
|
|
76
|
+
const widths = columns.map(
|
|
77
|
+
(column) => Math.max(column.length, ...rows.map((row) => String(row[column] ?? "").length))
|
|
78
|
+
);
|
|
79
|
+
const line = (values) => `${values.map((value, index) => value.padEnd(widths[index] ?? value.length)).join(" ")}
|
|
80
|
+
`;
|
|
81
|
+
let output2 = line(columns);
|
|
82
|
+
output2 += line(widths.map((width) => "-".repeat(width)));
|
|
83
|
+
for (const row of rows) {
|
|
84
|
+
output2 += line(columns.map((column) => String(row[column] ?? "")));
|
|
85
|
+
}
|
|
86
|
+
return output2;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// src/core/errors.ts
|
|
90
|
+
var CanvasCliError = class extends Error {
|
|
91
|
+
code;
|
|
92
|
+
status;
|
|
93
|
+
retryable;
|
|
94
|
+
constructor(code, message, options = {}) {
|
|
95
|
+
super(message, { cause: options.cause });
|
|
96
|
+
this.name = "CanvasCliError";
|
|
97
|
+
this.code = code;
|
|
98
|
+
this.status = options.status;
|
|
99
|
+
this.retryable = options.retryable ?? false;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
function toErrorEnvelope(error) {
|
|
103
|
+
if (error instanceof CanvasCliError) {
|
|
104
|
+
return {
|
|
105
|
+
ok: false,
|
|
106
|
+
error: {
|
|
107
|
+
code: error.code,
|
|
108
|
+
message: error.message,
|
|
109
|
+
status: error.status,
|
|
110
|
+
retryable: error.retryable
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
115
|
+
return {
|
|
116
|
+
ok: false,
|
|
117
|
+
error: {
|
|
118
|
+
code: "UNEXPECTED_ERROR",
|
|
119
|
+
message,
|
|
120
|
+
retryable: false
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// src/core/pagination.ts
|
|
126
|
+
function parseLinkHeader(header) {
|
|
127
|
+
if (!header) {
|
|
128
|
+
return {};
|
|
129
|
+
}
|
|
130
|
+
const links = {};
|
|
131
|
+
for (const part of splitHeader(header)) {
|
|
132
|
+
const match = part.match(/^\s*<([^>]+)>\s*;\s*rel="([^"]+)"\s*$/i);
|
|
133
|
+
if (!match) {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const [, url, rel] = match;
|
|
137
|
+
if (isLinkRelation(rel)) {
|
|
138
|
+
links[rel] = url;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return links;
|
|
142
|
+
}
|
|
143
|
+
function splitHeader(header) {
|
|
144
|
+
const parts = [];
|
|
145
|
+
let current = "";
|
|
146
|
+
let inQuotes = false;
|
|
147
|
+
for (const char of header) {
|
|
148
|
+
if (char === '"') {
|
|
149
|
+
inQuotes = !inQuotes;
|
|
150
|
+
}
|
|
151
|
+
if (char === "," && !inQuotes) {
|
|
152
|
+
parts.push(current);
|
|
153
|
+
current = "";
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
current += char;
|
|
157
|
+
}
|
|
158
|
+
if (current.trim()) {
|
|
159
|
+
parts.push(current);
|
|
160
|
+
}
|
|
161
|
+
return parts;
|
|
162
|
+
}
|
|
163
|
+
function isLinkRelation(value) {
|
|
164
|
+
return ["current", "next", "prev", "first", "last"].includes(value);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// src/core/canvas-client.ts
|
|
168
|
+
var CanvasClient = class {
|
|
169
|
+
baseUrl;
|
|
170
|
+
token;
|
|
171
|
+
fetchImpl;
|
|
172
|
+
constructor(options) {
|
|
173
|
+
this.baseUrl = normalizeBaseUrl(options.baseUrl);
|
|
174
|
+
this.token = options.token;
|
|
175
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
176
|
+
}
|
|
177
|
+
async get(path2, options = {}) {
|
|
178
|
+
if (!path2.startsWith("/api/v1/")) {
|
|
179
|
+
throw new CanvasCliError("INVALID_API_PATH", "Canvas API paths must start with /api/v1/.");
|
|
180
|
+
}
|
|
181
|
+
let nextUrl = buildUrl(this.baseUrl, path2, options.query);
|
|
182
|
+
const pages = [];
|
|
183
|
+
let pagesFetched = 0;
|
|
184
|
+
const pageLimit = options.pageLimit ?? 50;
|
|
185
|
+
let hasNext = false;
|
|
186
|
+
while (nextUrl) {
|
|
187
|
+
pagesFetched += 1;
|
|
188
|
+
if (pagesFetched > pageLimit) {
|
|
189
|
+
throw new CanvasCliError(
|
|
190
|
+
"PAGE_LIMIT_EXCEEDED",
|
|
191
|
+
`Stopped after ${pageLimit} pages. Increase --page-limit if needed.`
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
const response = await this.fetchImpl(nextUrl, {
|
|
195
|
+
method: "GET",
|
|
196
|
+
headers: {
|
|
197
|
+
Authorization: `Bearer ${this.token}`,
|
|
198
|
+
Accept: "application/json+canvas-string-ids"
|
|
199
|
+
}
|
|
200
|
+
}).catch((error) => {
|
|
201
|
+
throw new CanvasCliError("CANVAS_NETWORK_ERROR", "Could not reach Canvas.", {
|
|
202
|
+
retryable: true,
|
|
203
|
+
cause: error
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
if (!response.ok) {
|
|
207
|
+
throw new CanvasCliError(
|
|
208
|
+
mapStatusCode(response.status),
|
|
209
|
+
`Canvas request failed with status ${response.status}.`,
|
|
210
|
+
{ status: response.status, retryable: response.status === 429 || response.status >= 500 }
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
const data = await response.json();
|
|
214
|
+
pages.push(data);
|
|
215
|
+
const links = parseLinkHeader(response.headers.get("link"));
|
|
216
|
+
hasNext = Boolean(links.next);
|
|
217
|
+
nextUrl = options.pageAll ? links.next ?? null : null;
|
|
218
|
+
}
|
|
219
|
+
const merged = mergePages(pages);
|
|
220
|
+
return {
|
|
221
|
+
data: redactSecrets(merged),
|
|
222
|
+
meta: {
|
|
223
|
+
request: {
|
|
224
|
+
method: "GET",
|
|
225
|
+
path: path2
|
|
226
|
+
},
|
|
227
|
+
pagination: {
|
|
228
|
+
pagesFetched,
|
|
229
|
+
hasNext
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
function normalizeBaseUrl(input2) {
|
|
236
|
+
const trimmed = input2.trim().replace(/\/+$/, "");
|
|
237
|
+
const url = new URL(trimmed);
|
|
238
|
+
if (url.protocol !== "https:") {
|
|
239
|
+
throw new CanvasCliError("INVALID_BASE_URL", "Canvas base URL must use https://.");
|
|
240
|
+
}
|
|
241
|
+
if (url.pathname.includes("/api/")) {
|
|
242
|
+
throw new CanvasCliError("INVALID_BASE_URL", "Canvas base URL should not include /api/.");
|
|
243
|
+
}
|
|
244
|
+
url.pathname = url.pathname.replace(/\/+$/, "");
|
|
245
|
+
url.search = "";
|
|
246
|
+
url.hash = "";
|
|
247
|
+
return url.toString().replace(/\/+$/, "");
|
|
248
|
+
}
|
|
249
|
+
function buildUrl(baseUrl, path2, query = {}) {
|
|
250
|
+
const url = new URL(path2, baseUrl);
|
|
251
|
+
for (const [key, value] of Object.entries(query)) {
|
|
252
|
+
if (value === void 0) {
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (Array.isArray(value)) {
|
|
256
|
+
for (const item of value) {
|
|
257
|
+
url.searchParams.append(key, String(item));
|
|
258
|
+
}
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
url.searchParams.set(key, String(value));
|
|
262
|
+
}
|
|
263
|
+
return url.toString();
|
|
264
|
+
}
|
|
265
|
+
function mergePages(pages) {
|
|
266
|
+
if (pages.length === 1) {
|
|
267
|
+
return pages[0];
|
|
268
|
+
}
|
|
269
|
+
if (pages.every(Array.isArray)) {
|
|
270
|
+
return pages.flat();
|
|
271
|
+
}
|
|
272
|
+
return pages;
|
|
273
|
+
}
|
|
274
|
+
function mapStatusCode(status) {
|
|
275
|
+
if (status === 401) return "CANVAS_UNAUTHORIZED";
|
|
276
|
+
if (status === 403) return "CANVAS_FORBIDDEN";
|
|
277
|
+
if (status === 404) return "CANVAS_NOT_FOUND";
|
|
278
|
+
if (status === 429) return "CANVAS_RATE_LIMITED";
|
|
279
|
+
if (status >= 500) return "CANVAS_SERVER_ERROR";
|
|
280
|
+
return "CANVAS_REQUEST_FAILED";
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// src/core/config-store.ts
|
|
284
|
+
import { mkdir, readFile, rm, writeFile } from "fs/promises";
|
|
285
|
+
import { dirname } from "path";
|
|
286
|
+
|
|
287
|
+
// src/core/paths.ts
|
|
288
|
+
import os from "os";
|
|
289
|
+
import path from "path";
|
|
290
|
+
function canvasHome() {
|
|
291
|
+
return process.env.CANVAS_HOME || path.join(os.homedir(), ".canvas");
|
|
292
|
+
}
|
|
293
|
+
function configPath() {
|
|
294
|
+
return path.join(canvasHome(), "config.json");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// src/core/config-store.ts
|
|
298
|
+
var ConfigStore = class {
|
|
299
|
+
constructor(filePath = configPath()) {
|
|
300
|
+
this.filePath = filePath;
|
|
301
|
+
}
|
|
302
|
+
filePath;
|
|
303
|
+
async read() {
|
|
304
|
+
try {
|
|
305
|
+
const raw = await readFile(this.filePath, "utf8");
|
|
306
|
+
return JSON.parse(raw);
|
|
307
|
+
} catch (error) {
|
|
308
|
+
if (isNotFound(error)) {
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
throw error;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
async write(config) {
|
|
315
|
+
await mkdir(dirname(this.filePath), { recursive: true, mode: 448 });
|
|
316
|
+
await writeFile(this.filePath, `${JSON.stringify(config, null, 2)}
|
|
317
|
+
`, {
|
|
318
|
+
encoding: "utf8",
|
|
319
|
+
mode: 384
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
async remove() {
|
|
323
|
+
await rm(this.filePath, { force: true });
|
|
324
|
+
}
|
|
325
|
+
async readRedacted() {
|
|
326
|
+
const config = await this.read();
|
|
327
|
+
return config ? redactSecrets(config) : null;
|
|
328
|
+
}
|
|
329
|
+
async activeProfile() {
|
|
330
|
+
const config = await this.read();
|
|
331
|
+
if (!config) {
|
|
332
|
+
throw new CanvasCliError("NO_AUTH_CONFIG", "No Canvas auth config found. Run canvas auth login.");
|
|
333
|
+
}
|
|
334
|
+
const profile = config.profiles[config.activeProfile];
|
|
335
|
+
if (!profile) {
|
|
336
|
+
throw new CanvasCliError(
|
|
337
|
+
"NO_ACTIVE_PROFILE",
|
|
338
|
+
`Active Canvas profile not found: ${config.activeProfile}`
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
return profile;
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
function isNotFound(error) {
|
|
345
|
+
return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// src/core/browser.ts
|
|
349
|
+
import { spawn } from "child_process";
|
|
350
|
+
async function openBrowser(url) {
|
|
351
|
+
const command = openCommand(url);
|
|
352
|
+
const child = spawn(command.command, command.args, {
|
|
353
|
+
detached: true,
|
|
354
|
+
stdio: "ignore"
|
|
355
|
+
});
|
|
356
|
+
child.unref();
|
|
357
|
+
}
|
|
358
|
+
function openCommand(url) {
|
|
359
|
+
if (process.platform === "darwin") {
|
|
360
|
+
return { command: "open", args: [url] };
|
|
361
|
+
}
|
|
362
|
+
if (process.platform === "win32") {
|
|
363
|
+
return { command: "cmd", args: ["/c", "start", "", url] };
|
|
364
|
+
}
|
|
365
|
+
return { command: "xdg-open", args: [url] };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// src/core/prompt.ts
|
|
369
|
+
import { createInterface } from "readline/promises";
|
|
370
|
+
import { stdin as input, stdout as output } from "process";
|
|
371
|
+
function createPrompt() {
|
|
372
|
+
return createInterface({ input, output });
|
|
373
|
+
}
|
|
374
|
+
async function promptHidden(prompt) {
|
|
375
|
+
if (!process.stdin.isTTY) {
|
|
376
|
+
const io = createPrompt();
|
|
377
|
+
try {
|
|
378
|
+
return (await io.question(prompt)).trim();
|
|
379
|
+
} finally {
|
|
380
|
+
io.close();
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return new Promise((resolve, reject) => {
|
|
384
|
+
const stdin = process.stdin;
|
|
385
|
+
const onData = (char) => {
|
|
386
|
+
const value = char.toString("utf8");
|
|
387
|
+
if (value === "\n" || value === "\r" || value === "\r\n") {
|
|
388
|
+
stdin.setRawMode(false);
|
|
389
|
+
stdin.pause();
|
|
390
|
+
stdin.off("data", onData);
|
|
391
|
+
process.stdout.write("\n");
|
|
392
|
+
resolve(buffer.trim());
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
if (value === "") {
|
|
396
|
+
stdin.setRawMode(false);
|
|
397
|
+
stdin.pause();
|
|
398
|
+
stdin.off("data", onData);
|
|
399
|
+
process.stdout.write("\n");
|
|
400
|
+
reject(new Error("Prompt cancelled."));
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
if (value === "\x7F") {
|
|
404
|
+
buffer = buffer.slice(0, -1);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
buffer += value;
|
|
408
|
+
};
|
|
409
|
+
let buffer = "";
|
|
410
|
+
process.stdout.write(prompt);
|
|
411
|
+
stdin.setRawMode(true);
|
|
412
|
+
stdin.resume();
|
|
413
|
+
stdin.on("data", onData);
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// src/registry/schools.ts
|
|
418
|
+
var SCHOOLS = [
|
|
419
|
+
{ name: "Brown University", url: "https://canvas.brown.edu" },
|
|
420
|
+
{ name: "Carnegie Mellon University", url: "https://canvas.cmu.edu" },
|
|
421
|
+
{ name: "Columbia University (CourseWorks)", url: "https://courseworks2.columbia.edu" },
|
|
422
|
+
{ name: "Cornell University", url: "https://canvas.cornell.edu" },
|
|
423
|
+
{ name: "Dartmouth College", url: "https://canvas.dartmouth.edu" },
|
|
424
|
+
{ name: "Duke University", url: "https://go.canvas.duke.edu" },
|
|
425
|
+
{ name: "Emory University", url: "https://canvas.emory.edu" },
|
|
426
|
+
{ name: "Georgetown University", url: "https://canvas.georgetown.edu" },
|
|
427
|
+
{ name: "Georgia Institute of Technology", url: "https://canvas.gatech.edu" },
|
|
428
|
+
{ name: "Harvard University", url: "https://canvas.harvard.edu" },
|
|
429
|
+
{ name: "Massachusetts Institute of Technology (MIT)", url: "https://canvas.mit.edu" },
|
|
430
|
+
{ name: "Northeastern University", url: "https://canvas.northeastern.edu" },
|
|
431
|
+
{ name: "Northwestern University", url: "https://canvas.northwestern.edu" },
|
|
432
|
+
{ name: "Ohio State University", url: "https://canvas.osu.edu" },
|
|
433
|
+
{ name: "Pennsylvania State University", url: "https://canvas.psu.edu" },
|
|
434
|
+
{ name: "Princeton University", url: "https://canvas.princeton.edu" },
|
|
435
|
+
{ name: "Rice University", url: "https://canvas.rice.edu" },
|
|
436
|
+
{ name: "Stanford University", url: "https://canvas.stanford.edu" },
|
|
437
|
+
{ name: "Tufts University", url: "https://canvas.tufts.edu" },
|
|
438
|
+
{ name: "University of California, Berkeley (bCourses)", url: "https://bcourses.berkeley.edu" },
|
|
439
|
+
{ name: "University of California, Davis", url: "https://canvas.ucdavis.edu" },
|
|
440
|
+
{ name: "University of California, Irvine", url: "https://canvas.eee.uci.edu" },
|
|
441
|
+
{ name: "University of California, Los Angeles (Bruin Learn)", url: "https://bruinlearn.ucla.edu" },
|
|
442
|
+
{ name: "University of California, San Diego", url: "https://canvas.ucsd.edu" },
|
|
443
|
+
{ name: "University of California, Santa Barbara", url: "https://canvas.ucsb.edu" },
|
|
444
|
+
{ name: "University of California, Santa Cruz", url: "https://canvas.ucsc.edu" },
|
|
445
|
+
{ name: "University of Chicago", url: "https://canvas.uchicago.edu" },
|
|
446
|
+
{ name: "University of Michigan", url: "https://canvas.umich.edu" },
|
|
447
|
+
{ name: "University of North Carolina at Chapel Hill", url: "https://canvas.unc.edu" },
|
|
448
|
+
{ name: "University of Notre Dame", url: "https://canvas.nd.edu" },
|
|
449
|
+
{ name: "University of Pennsylvania", url: "https://canvas.upenn.edu" },
|
|
450
|
+
{ name: "University of Southern California", url: "https://canvas.usc.edu" },
|
|
451
|
+
{ name: "University of Virginia", url: "https://canvas.its.virginia.edu" },
|
|
452
|
+
{ name: "University of Washington", url: "https://canvas.uw.edu" },
|
|
453
|
+
{ name: "Yale University", url: "https://canvas.yale.edu" }
|
|
454
|
+
];
|
|
455
|
+
function searchSchools(query, limit = 10) {
|
|
456
|
+
const normalized = query.trim().toLowerCase();
|
|
457
|
+
if (!normalized) {
|
|
458
|
+
return SCHOOLS.slice(0, limit);
|
|
459
|
+
}
|
|
460
|
+
return SCHOOLS.filter((school) => {
|
|
461
|
+
return school.name.toLowerCase().includes(normalized) || school.url.toLowerCase().includes(normalized);
|
|
462
|
+
}).slice(0, limit);
|
|
463
|
+
}
|
|
464
|
+
function makeCustomSchool(name, url) {
|
|
465
|
+
return {
|
|
466
|
+
name: name.trim() || "Custom Canvas School",
|
|
467
|
+
url: normalizeBaseUrl(url)
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// src/workflows/context-bootstrap.ts
|
|
472
|
+
async function runPostLoginBootstrap() {
|
|
473
|
+
return {
|
|
474
|
+
skipped: true,
|
|
475
|
+
reason: "Context bootstrap is planned for Phase 4."
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// src/commands/auth.ts
|
|
480
|
+
var TOKEN_PURPOSE = "Hyperknow";
|
|
481
|
+
async function handleAuthCommand(argv, options) {
|
|
482
|
+
const [subcommand] = argv;
|
|
483
|
+
if (subcommand === "login") {
|
|
484
|
+
return authLogin(options);
|
|
485
|
+
}
|
|
486
|
+
if (subcommand === "status") {
|
|
487
|
+
return authStatus(options);
|
|
488
|
+
}
|
|
489
|
+
if (subcommand === "logout") {
|
|
490
|
+
return authLogout(options);
|
|
491
|
+
}
|
|
492
|
+
await writeOutput(
|
|
493
|
+
{
|
|
494
|
+
ok: false,
|
|
495
|
+
error: {
|
|
496
|
+
code: "UNKNOWN_COMMAND",
|
|
497
|
+
message: `Unknown auth command: ${argv.join(" ")}`,
|
|
498
|
+
retryable: false
|
|
499
|
+
}
|
|
500
|
+
},
|
|
501
|
+
options
|
|
502
|
+
);
|
|
503
|
+
return 1;
|
|
504
|
+
}
|
|
505
|
+
async function authLogin(options) {
|
|
506
|
+
const io = createPrompt();
|
|
507
|
+
try {
|
|
508
|
+
const school = await chooseSchool(io);
|
|
509
|
+
const settingsUrl = `${school.url}/profile/settings`;
|
|
510
|
+
process.stdout.write(tokenInstructions(school, settingsUrl));
|
|
511
|
+
await io.question("Press Enter to open Canvas settings in your browser...");
|
|
512
|
+
await openBrowser(settingsUrl);
|
|
513
|
+
process.stdout.write("\nWaiting for your Canvas personal access token.\n");
|
|
514
|
+
const token = await promptHidden("Paste token: ");
|
|
515
|
+
if (!token) {
|
|
516
|
+
throw new CanvasCliError("EMPTY_TOKEN", "No token entered.");
|
|
517
|
+
}
|
|
518
|
+
const client = new CanvasClient({ baseUrl: school.url, token });
|
|
519
|
+
const user = await validateToken(client);
|
|
520
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
521
|
+
const config = {
|
|
522
|
+
version: 1,
|
|
523
|
+
activeProfile: "default",
|
|
524
|
+
profiles: {
|
|
525
|
+
default: {
|
|
526
|
+
schoolName: school.name,
|
|
527
|
+
baseUrl: school.url,
|
|
528
|
+
token,
|
|
529
|
+
createdAt: now,
|
|
530
|
+
validatedAt: now,
|
|
531
|
+
user
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
};
|
|
535
|
+
await new ConfigStore().write(config);
|
|
536
|
+
const bootstrap = await runPostLoginBootstrap();
|
|
537
|
+
await writeOutput(
|
|
538
|
+
{
|
|
539
|
+
ok: true,
|
|
540
|
+
data: {
|
|
541
|
+
authenticated: true,
|
|
542
|
+
school: {
|
|
543
|
+
name: school.name,
|
|
544
|
+
baseUrl: school.url
|
|
545
|
+
},
|
|
546
|
+
user,
|
|
547
|
+
contextBootstrap: bootstrap,
|
|
548
|
+
next: "canvas context show"
|
|
549
|
+
},
|
|
550
|
+
meta: {
|
|
551
|
+
command: "auth login"
|
|
552
|
+
}
|
|
553
|
+
},
|
|
554
|
+
options
|
|
555
|
+
);
|
|
556
|
+
return 0;
|
|
557
|
+
} catch (error) {
|
|
558
|
+
await writeOutput(toErrorEnvelope(error), options);
|
|
559
|
+
return 1;
|
|
560
|
+
} finally {
|
|
561
|
+
io.close();
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
async function authStatus(options) {
|
|
565
|
+
const store = new ConfigStore();
|
|
566
|
+
const config = await store.readRedacted();
|
|
567
|
+
if (!config) {
|
|
568
|
+
await writeOutput(
|
|
569
|
+
{
|
|
570
|
+
ok: true,
|
|
571
|
+
data: {
|
|
572
|
+
authenticated: false,
|
|
573
|
+
message: "No Canvas auth config found. Run canvas auth login."
|
|
574
|
+
},
|
|
575
|
+
meta: {
|
|
576
|
+
command: "auth status"
|
|
577
|
+
}
|
|
578
|
+
},
|
|
579
|
+
options
|
|
580
|
+
);
|
|
581
|
+
return 0;
|
|
582
|
+
}
|
|
583
|
+
await writeOutput(
|
|
584
|
+
{
|
|
585
|
+
ok: true,
|
|
586
|
+
data: {
|
|
587
|
+
authenticated: true,
|
|
588
|
+
activeProfile: config.activeProfile,
|
|
589
|
+
profile: config.profiles[config.activeProfile]
|
|
590
|
+
},
|
|
591
|
+
meta: {
|
|
592
|
+
command: "auth status"
|
|
593
|
+
}
|
|
594
|
+
},
|
|
595
|
+
options
|
|
596
|
+
);
|
|
597
|
+
return 0;
|
|
598
|
+
}
|
|
599
|
+
async function authLogout(options) {
|
|
600
|
+
await new ConfigStore().remove();
|
|
601
|
+
await writeOutput(
|
|
602
|
+
{
|
|
603
|
+
ok: true,
|
|
604
|
+
data: {
|
|
605
|
+
authenticated: false,
|
|
606
|
+
message: "Canvas auth config removed."
|
|
607
|
+
},
|
|
608
|
+
meta: {
|
|
609
|
+
command: "auth logout"
|
|
610
|
+
}
|
|
611
|
+
},
|
|
612
|
+
options
|
|
613
|
+
);
|
|
614
|
+
return 0;
|
|
615
|
+
}
|
|
616
|
+
async function chooseSchool(io, write = (message) => process.stdout.write(message)) {
|
|
617
|
+
write("Search for your school, or press Enter to browse the first matches.\n");
|
|
618
|
+
const query = await io.question("School: ");
|
|
619
|
+
const matches = searchSchools(query);
|
|
620
|
+
if (matches.length === 1) {
|
|
621
|
+
const school2 = matches[0];
|
|
622
|
+
const answer = (await io.question(`Is this your school: ${school2.name} (${school2.url})? Choose: y/n `)).trim().toLowerCase();
|
|
623
|
+
if (answer === "y" || answer === "yes") {
|
|
624
|
+
return {
|
|
625
|
+
name: school2.name,
|
|
626
|
+
url: normalizeBaseUrl(school2.url)
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
if (answer === "n" || answer === "no") {
|
|
630
|
+
return promptCustomSchool(io);
|
|
631
|
+
}
|
|
632
|
+
throw new CanvasCliError("INVALID_SELECTION", "Please answer y or n.");
|
|
633
|
+
}
|
|
634
|
+
for (const [index, school2] of matches.entries()) {
|
|
635
|
+
write(`${index + 1}. ${school2.name}
|
|
636
|
+
${school2.url}
|
|
637
|
+
`);
|
|
638
|
+
}
|
|
639
|
+
write(`${matches.length + 1}. Not found? Add your own
|
|
640
|
+
`);
|
|
641
|
+
const selected = Number.parseInt(await io.question("Choose: "), 10);
|
|
642
|
+
if (!Number.isFinite(selected) || selected < 1 || selected > matches.length + 1) {
|
|
643
|
+
throw new CanvasCliError("INVALID_SELECTION", "Invalid school selection.");
|
|
644
|
+
}
|
|
645
|
+
if (selected === matches.length + 1) {
|
|
646
|
+
return promptCustomSchool(io);
|
|
647
|
+
}
|
|
648
|
+
const school = matches[selected - 1];
|
|
649
|
+
return {
|
|
650
|
+
name: school.name,
|
|
651
|
+
url: normalizeBaseUrl(school.url)
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
async function promptCustomSchool(io) {
|
|
655
|
+
const name = await io.question("School display name: ");
|
|
656
|
+
const url = await io.question("Canvas base URL: ");
|
|
657
|
+
return makeCustomSchool(name, url);
|
|
658
|
+
}
|
|
659
|
+
function tokenInstructions(school, settingsUrl) {
|
|
660
|
+
return `
|
|
661
|
+
Canvas token setup for ${school.name}
|
|
662
|
+
|
|
663
|
+
1. Go to ${settingsUrl}
|
|
664
|
+
2. Click "+ New Access Token"
|
|
665
|
+
3. Enter "${TOKEN_PURPOSE}" as the purpose
|
|
666
|
+
4. Optionally set an expiration date
|
|
667
|
+
5. Click "Generate Token"
|
|
668
|
+
6. Copy the token and paste it back here
|
|
669
|
+
|
|
670
|
+
`;
|
|
671
|
+
}
|
|
672
|
+
async function validateToken(client) {
|
|
673
|
+
try {
|
|
674
|
+
const response = await client.get(
|
|
675
|
+
"/api/v1/users/self/profile"
|
|
676
|
+
);
|
|
677
|
+
return {
|
|
678
|
+
id: response.data.id,
|
|
679
|
+
name: response.data.name ?? response.data.short_name
|
|
680
|
+
};
|
|
681
|
+
} catch (error) {
|
|
682
|
+
if (error instanceof CanvasCliError && error.status === 404) {
|
|
683
|
+
await client.get("/api/v1/courses");
|
|
684
|
+
return {};
|
|
685
|
+
}
|
|
686
|
+
throw error;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// src/commands/config.ts
|
|
691
|
+
async function handleConfigCommand(argv, options) {
|
|
692
|
+
const [subcommand] = argv;
|
|
693
|
+
if (subcommand !== "show") {
|
|
694
|
+
await writeOutput(
|
|
695
|
+
{
|
|
696
|
+
ok: false,
|
|
697
|
+
error: {
|
|
698
|
+
code: "UNKNOWN_COMMAND",
|
|
699
|
+
message: `Unknown config command: ${argv.join(" ")}`,
|
|
700
|
+
retryable: false
|
|
701
|
+
}
|
|
702
|
+
},
|
|
703
|
+
options
|
|
704
|
+
);
|
|
705
|
+
return 1;
|
|
706
|
+
}
|
|
707
|
+
const config = await new ConfigStore().readRedacted();
|
|
708
|
+
await writeOutput(
|
|
709
|
+
{
|
|
710
|
+
ok: true,
|
|
711
|
+
data: config ?? {
|
|
712
|
+
configured: false,
|
|
713
|
+
message: "No Canvas config found. Run canvas auth login."
|
|
714
|
+
},
|
|
715
|
+
meta: {
|
|
716
|
+
command: "config show"
|
|
717
|
+
}
|
|
718
|
+
},
|
|
719
|
+
options
|
|
720
|
+
);
|
|
721
|
+
return 0;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// src/commands/shared.ts
|
|
725
|
+
async function activeCanvas() {
|
|
726
|
+
const profile = await new ConfigStore().activeProfile();
|
|
727
|
+
return {
|
|
728
|
+
profile,
|
|
729
|
+
client: new CanvasClient({
|
|
730
|
+
baseUrl: profile.baseUrl,
|
|
731
|
+
token: profile.token
|
|
732
|
+
})
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
function flagValue(argv, flag) {
|
|
736
|
+
const index = argv.indexOf(flag);
|
|
737
|
+
if (index === -1) {
|
|
738
|
+
return void 0;
|
|
739
|
+
}
|
|
740
|
+
return argv[index + 1];
|
|
741
|
+
}
|
|
742
|
+
function hasFlag(argv, flag) {
|
|
743
|
+
return argv.includes(flag);
|
|
744
|
+
}
|
|
745
|
+
function pageOptions(argv) {
|
|
746
|
+
const pageLimitRaw = flagValue(argv, "--page-limit");
|
|
747
|
+
return {
|
|
748
|
+
pageAll: hasFlag(argv, "--page-all"),
|
|
749
|
+
pageLimit: pageLimitRaw ? Number.parseInt(pageLimitRaw, 10) : void 0
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
function positionalArgs(argv) {
|
|
753
|
+
const valueFlags = /* @__PURE__ */ new Set([
|
|
754
|
+
"--course-id",
|
|
755
|
+
"--module-id",
|
|
756
|
+
"--item-id",
|
|
757
|
+
"--assignment-id",
|
|
758
|
+
"--quiz-id",
|
|
759
|
+
"--topic-id",
|
|
760
|
+
"--page",
|
|
761
|
+
"--path",
|
|
762
|
+
"--out",
|
|
763
|
+
"--format",
|
|
764
|
+
"--page-limit",
|
|
765
|
+
"--page-size",
|
|
766
|
+
"--page-delay",
|
|
767
|
+
"--enrollment-state",
|
|
768
|
+
"--state",
|
|
769
|
+
"--include",
|
|
770
|
+
"--params"
|
|
771
|
+
]);
|
|
772
|
+
const values = [];
|
|
773
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
774
|
+
const arg = argv[index];
|
|
775
|
+
if (arg.startsWith("--")) {
|
|
776
|
+
if (valueFlags.has(arg)) {
|
|
777
|
+
index += 1;
|
|
778
|
+
}
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
values.push(arg);
|
|
782
|
+
}
|
|
783
|
+
return values;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// src/commands/courses.ts
|
|
787
|
+
async function handleCoursesCommand(argv, options) {
|
|
788
|
+
const [subcommand] = argv;
|
|
789
|
+
try {
|
|
790
|
+
if (subcommand === "list") {
|
|
791
|
+
return await listCourses(argv.slice(1), options);
|
|
792
|
+
}
|
|
793
|
+
if (subcommand === "search") {
|
|
794
|
+
return await searchCourses(argv.slice(1), options);
|
|
795
|
+
}
|
|
796
|
+
if (subcommand === "show") {
|
|
797
|
+
return await showCourse(argv.slice(1), options);
|
|
798
|
+
}
|
|
799
|
+
if (subcommand === "overview") {
|
|
800
|
+
return await overviewCourse(argv.slice(1), options);
|
|
801
|
+
}
|
|
802
|
+
await writeOutput(
|
|
803
|
+
{
|
|
804
|
+
ok: false,
|
|
805
|
+
error: {
|
|
806
|
+
code: "UNKNOWN_COMMAND",
|
|
807
|
+
message: `Unknown courses command: ${argv.join(" ")}`,
|
|
808
|
+
retryable: false
|
|
809
|
+
}
|
|
810
|
+
},
|
|
811
|
+
options
|
|
812
|
+
);
|
|
813
|
+
return 1;
|
|
814
|
+
} catch (error) {
|
|
815
|
+
await writeOutput(toErrorEnvelope(error), options);
|
|
816
|
+
return 1;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
async function listCourses(argv, options) {
|
|
820
|
+
const { client, profile } = await activeCanvas();
|
|
821
|
+
const response = await client.get("/api/v1/courses", {
|
|
822
|
+
query: courseListQuery(argv),
|
|
823
|
+
...pageOptions(argv)
|
|
824
|
+
});
|
|
825
|
+
await writeOutput(
|
|
826
|
+
{
|
|
827
|
+
ok: true,
|
|
828
|
+
data: response.data.map(normalizeCourse),
|
|
829
|
+
meta: {
|
|
830
|
+
...response.meta,
|
|
831
|
+
baseUrl: profile.baseUrl
|
|
832
|
+
}
|
|
833
|
+
},
|
|
834
|
+
options
|
|
835
|
+
);
|
|
836
|
+
return 0;
|
|
837
|
+
}
|
|
838
|
+
async function searchCourses(argv, options) {
|
|
839
|
+
const query = positionalArgs(argv).join(" ").trim();
|
|
840
|
+
const { client, profile } = await activeCanvas();
|
|
841
|
+
const response = await client.get("/api/v1/courses", {
|
|
842
|
+
query: courseListQuery(["--active"]),
|
|
843
|
+
pageAll: true
|
|
844
|
+
});
|
|
845
|
+
const matches = response.data.map(normalizeCourse).filter((course) => {
|
|
846
|
+
const haystack = `${course.name ?? ""} ${course.courseCode ?? ""}`.toLowerCase();
|
|
847
|
+
return haystack.includes(query.toLowerCase());
|
|
848
|
+
});
|
|
849
|
+
await writeOutput(
|
|
850
|
+
{
|
|
851
|
+
ok: true,
|
|
852
|
+
data: matches,
|
|
853
|
+
meta: {
|
|
854
|
+
...response.meta,
|
|
855
|
+
baseUrl: profile.baseUrl,
|
|
856
|
+
query
|
|
857
|
+
}
|
|
858
|
+
},
|
|
859
|
+
options
|
|
860
|
+
);
|
|
861
|
+
return 0;
|
|
862
|
+
}
|
|
863
|
+
async function showCourse(argv, options) {
|
|
864
|
+
const courseId = positionalArgs(argv)[0];
|
|
865
|
+
if (!courseId) {
|
|
866
|
+
throw new Error("Usage: canvas courses show <course-id>");
|
|
867
|
+
}
|
|
868
|
+
const { client, profile } = await activeCanvas();
|
|
869
|
+
const response = await client.get(`/api/v1/courses/${courseId}`, {
|
|
870
|
+
query: {
|
|
871
|
+
"include[]": ["term", "course_image", "total_scores", "teachers"]
|
|
872
|
+
}
|
|
873
|
+
});
|
|
874
|
+
await writeOutput(
|
|
875
|
+
{
|
|
876
|
+
ok: true,
|
|
877
|
+
data: normalizeCourse(response.data),
|
|
878
|
+
meta: {
|
|
879
|
+
...response.meta,
|
|
880
|
+
baseUrl: profile.baseUrl
|
|
881
|
+
}
|
|
882
|
+
},
|
|
883
|
+
options
|
|
884
|
+
);
|
|
885
|
+
return 0;
|
|
886
|
+
}
|
|
887
|
+
async function overviewCourse(argv, options) {
|
|
888
|
+
const courseId = positionalArgs(argv)[0];
|
|
889
|
+
if (!courseId) {
|
|
890
|
+
throw new Error("Usage: canvas courses overview <course-id>");
|
|
891
|
+
}
|
|
892
|
+
const { client, profile } = await activeCanvas();
|
|
893
|
+
const [course, tabs, modules, assignments] = await Promise.all([
|
|
894
|
+
client.get(`/api/v1/courses/${courseId}`, {
|
|
895
|
+
query: { "include[]": ["term", "course_image"] }
|
|
896
|
+
}),
|
|
897
|
+
client.get(
|
|
898
|
+
`/api/v1/courses/${courseId}/tabs`
|
|
899
|
+
),
|
|
900
|
+
client.get(
|
|
901
|
+
`/api/v1/courses/${courseId}/modules`,
|
|
902
|
+
{ query: { per_page: 100 } }
|
|
903
|
+
),
|
|
904
|
+
client.get(
|
|
905
|
+
`/api/v1/courses/${courseId}/assignments`,
|
|
906
|
+
{ query: { bucket: "upcoming", per_page: 20 } }
|
|
907
|
+
)
|
|
908
|
+
]);
|
|
909
|
+
await writeOutput(
|
|
910
|
+
{
|
|
911
|
+
ok: true,
|
|
912
|
+
data: {
|
|
913
|
+
course: normalizeCourse(course.data),
|
|
914
|
+
tabs: tabs.data,
|
|
915
|
+
setup: {
|
|
916
|
+
hasModules: modules.data.length > 0,
|
|
917
|
+
hasAssignments: assignments.data.length > 0,
|
|
918
|
+
hasFilesTab: tabs.data.some((tab) => tab.id === "files"),
|
|
919
|
+
isModuleHeavy: modules.data.length > 0
|
|
920
|
+
},
|
|
921
|
+
counts: {
|
|
922
|
+
tabs: tabs.data.length,
|
|
923
|
+
modules: modules.data.length,
|
|
924
|
+
upcomingAssignments: assignments.data.length
|
|
925
|
+
},
|
|
926
|
+
modules: modules.data.map((module) => ({
|
|
927
|
+
id: module.id,
|
|
928
|
+
name: module.name,
|
|
929
|
+
position: module.position
|
|
930
|
+
})),
|
|
931
|
+
upcomingAssignments: assignments.data.map((assignment) => ({
|
|
932
|
+
id: assignment.id,
|
|
933
|
+
name: assignment.name,
|
|
934
|
+
dueAt: assignment.due_at
|
|
935
|
+
}))
|
|
936
|
+
},
|
|
937
|
+
meta: {
|
|
938
|
+
baseUrl: profile.baseUrl,
|
|
939
|
+
request: {
|
|
940
|
+
method: "GET",
|
|
941
|
+
path: `/api/v1/courses/${courseId}/overview`
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
},
|
|
945
|
+
options
|
|
946
|
+
);
|
|
947
|
+
return 0;
|
|
948
|
+
}
|
|
949
|
+
function courseListQuery(argv) {
|
|
950
|
+
return {
|
|
951
|
+
enrollment_state: hasFlag(argv, "--active") ? "active" : flagValue(argv, "--enrollment-state"),
|
|
952
|
+
state: flagValue(argv, "--state"),
|
|
953
|
+
per_page: flagValue(argv, "--page-size"),
|
|
954
|
+
"include[]": ["term", "total_scores"]
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
function normalizeCourse(course) {
|
|
958
|
+
return {
|
|
959
|
+
id: String(course.id),
|
|
960
|
+
name: course.name,
|
|
961
|
+
courseCode: course.course_code,
|
|
962
|
+
workflowState: course.workflow_state,
|
|
963
|
+
enrollmentTermId: course.enrollment_term_id,
|
|
964
|
+
term: course.term ? {
|
|
965
|
+
id: course.term.id,
|
|
966
|
+
name: course.term.name,
|
|
967
|
+
startAt: course.term.start_at,
|
|
968
|
+
endAt: course.term.end_at
|
|
969
|
+
} : void 0,
|
|
970
|
+
enrollments: course.enrollments?.map((enrollment) => ({
|
|
971
|
+
type: enrollment.type,
|
|
972
|
+
role: enrollment.role,
|
|
973
|
+
state: enrollment.enrollment_state
|
|
974
|
+
}))
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// src/commands/me.ts
|
|
979
|
+
async function handleMeCommand(options) {
|
|
980
|
+
try {
|
|
981
|
+
const profile = await new ConfigStore().activeProfile();
|
|
982
|
+
const client = new CanvasClient({
|
|
983
|
+
baseUrl: profile.baseUrl,
|
|
984
|
+
token: profile.token
|
|
985
|
+
});
|
|
986
|
+
const response = await client.get("/api/v1/users/self/profile");
|
|
987
|
+
await writeOutput(
|
|
988
|
+
{
|
|
989
|
+
ok: true,
|
|
990
|
+
data: response.data,
|
|
991
|
+
meta: {
|
|
992
|
+
...response.meta,
|
|
993
|
+
baseUrl: profile.baseUrl
|
|
994
|
+
}
|
|
995
|
+
},
|
|
996
|
+
options
|
|
997
|
+
);
|
|
998
|
+
return 0;
|
|
999
|
+
} catch (error) {
|
|
1000
|
+
await writeOutput(toErrorEnvelope(error), options);
|
|
1001
|
+
return 1;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// src/commands/tabs.ts
|
|
1006
|
+
async function handleTabsCommand(argv, options) {
|
|
1007
|
+
const [subcommand] = argv;
|
|
1008
|
+
if (subcommand !== "list") {
|
|
1009
|
+
await writeOutput(
|
|
1010
|
+
{
|
|
1011
|
+
ok: false,
|
|
1012
|
+
error: {
|
|
1013
|
+
code: "UNKNOWN_COMMAND",
|
|
1014
|
+
message: `Unknown tabs command: ${argv.join(" ")}`,
|
|
1015
|
+
retryable: false
|
|
1016
|
+
}
|
|
1017
|
+
},
|
|
1018
|
+
options
|
|
1019
|
+
);
|
|
1020
|
+
return 1;
|
|
1021
|
+
}
|
|
1022
|
+
try {
|
|
1023
|
+
const courseId = flagValue(argv, "--course-id");
|
|
1024
|
+
if (!courseId) {
|
|
1025
|
+
throw new Error("Usage: canvas tabs list --course-id <course-id>");
|
|
1026
|
+
}
|
|
1027
|
+
const { client, profile } = await activeCanvas();
|
|
1028
|
+
const response = await client.get(`/api/v1/courses/${courseId}/tabs`);
|
|
1029
|
+
await writeOutput(
|
|
1030
|
+
{
|
|
1031
|
+
ok: true,
|
|
1032
|
+
data: response.data.map(normalizeTab),
|
|
1033
|
+
meta: {
|
|
1034
|
+
...response.meta,
|
|
1035
|
+
baseUrl: profile.baseUrl
|
|
1036
|
+
}
|
|
1037
|
+
},
|
|
1038
|
+
options
|
|
1039
|
+
);
|
|
1040
|
+
return 0;
|
|
1041
|
+
} catch (error) {
|
|
1042
|
+
await writeOutput(toErrorEnvelope(error), options);
|
|
1043
|
+
return 1;
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
function normalizeTab(tab) {
|
|
1047
|
+
return {
|
|
1048
|
+
id: tab.id,
|
|
1049
|
+
label: tab.label,
|
|
1050
|
+
type: tab.type,
|
|
1051
|
+
position: tab.position,
|
|
1052
|
+
hidden: tab.hidden,
|
|
1053
|
+
visibility: tab.visibility,
|
|
1054
|
+
htmlUrl: tab.html_url,
|
|
1055
|
+
fullUrl: tab.full_url
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// src/bin/canvas.ts
|
|
1060
|
+
var VERSION = "0.0.0";
|
|
1061
|
+
function helpText() {
|
|
1062
|
+
return `canvas \u2014 Canvas LMS CLI for students and agents.
|
|
1063
|
+
|
|
1064
|
+
USAGE:
|
|
1065
|
+
canvas <command> [options]
|
|
1066
|
+
|
|
1067
|
+
COMMANDS:
|
|
1068
|
+
auth login Interactive Canvas PAT setup
|
|
1069
|
+
auth status Show redacted auth status
|
|
1070
|
+
auth logout Remove local Canvas auth config
|
|
1071
|
+
config show Show redacted local config
|
|
1072
|
+
me Show current Canvas user profile
|
|
1073
|
+
context show Show cached post-login context
|
|
1074
|
+
courses list List active Canvas courses
|
|
1075
|
+
review pack Create a local course review pack
|
|
1076
|
+
version Print CLI version
|
|
1077
|
+
|
|
1078
|
+
FLAGS:
|
|
1079
|
+
-h, --help Show help
|
|
1080
|
+
--format <fmt> Output format: json | pretty | table | ndjson
|
|
1081
|
+
|
|
1082
|
+
MVP STATUS:
|
|
1083
|
+
Auth/config/me/courses/tabs are in progress. Review commands are planned next.
|
|
1084
|
+
`;
|
|
1085
|
+
}
|
|
1086
|
+
async function main(argv) {
|
|
1087
|
+
const parsed = parseGlobalOptions(argv);
|
|
1088
|
+
const [command] = parsed.argv;
|
|
1089
|
+
if (!command || command === "--help" || command === "-h" || command === "help") {
|
|
1090
|
+
process.stdout.write(helpText());
|
|
1091
|
+
return 0;
|
|
1092
|
+
}
|
|
1093
|
+
if (command === "version" || command === "--version" || command === "-v") {
|
|
1094
|
+
await writeOutput(
|
|
1095
|
+
{
|
|
1096
|
+
ok: true,
|
|
1097
|
+
data: { version: VERSION },
|
|
1098
|
+
meta: { command: "version" }
|
|
1099
|
+
},
|
|
1100
|
+
{ format: parsed.format === "json" ? "json" : "pretty" }
|
|
1101
|
+
);
|
|
1102
|
+
return 0;
|
|
1103
|
+
}
|
|
1104
|
+
if (command === "auth") {
|
|
1105
|
+
return handleAuthCommand(parsed.argv.slice(1), { format: parsed.format });
|
|
1106
|
+
}
|
|
1107
|
+
if (command === "config") {
|
|
1108
|
+
return handleConfigCommand(parsed.argv.slice(1), { format: parsed.format });
|
|
1109
|
+
}
|
|
1110
|
+
if (command === "me") {
|
|
1111
|
+
return handleMeCommand({ format: parsed.format });
|
|
1112
|
+
}
|
|
1113
|
+
if (command === "courses") {
|
|
1114
|
+
return handleCoursesCommand(parsed.argv.slice(1), { format: parsed.format });
|
|
1115
|
+
}
|
|
1116
|
+
if (command === "tabs") {
|
|
1117
|
+
return handleTabsCommand(parsed.argv.slice(1), { format: parsed.format });
|
|
1118
|
+
}
|
|
1119
|
+
await writeOutput(
|
|
1120
|
+
{
|
|
1121
|
+
ok: false,
|
|
1122
|
+
error: {
|
|
1123
|
+
code: "UNKNOWN_COMMAND",
|
|
1124
|
+
message: `Unknown command: ${parsed.argv.join(" ")}`,
|
|
1125
|
+
retryable: false
|
|
1126
|
+
}
|
|
1127
|
+
},
|
|
1128
|
+
{ format: parsed.format }
|
|
1129
|
+
);
|
|
1130
|
+
return 1;
|
|
1131
|
+
}
|
|
1132
|
+
function parseGlobalOptions(argv) {
|
|
1133
|
+
const nextArgv = [];
|
|
1134
|
+
let format = "json";
|
|
1135
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
1136
|
+
const arg = argv[index];
|
|
1137
|
+
if (arg === "--format") {
|
|
1138
|
+
const value = argv[index + 1];
|
|
1139
|
+
if (isOutputFormat(value)) {
|
|
1140
|
+
format = value;
|
|
1141
|
+
index += 1;
|
|
1142
|
+
continue;
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
nextArgv.push(arg);
|
|
1146
|
+
}
|
|
1147
|
+
return { argv: nextArgv, format };
|
|
1148
|
+
}
|
|
1149
|
+
function isOutputFormat(value) {
|
|
1150
|
+
return value === "json" || value === "pretty" || value === "table" || value === "ndjson";
|
|
1151
|
+
}
|
|
1152
|
+
main(process.argv.slice(2)).then((code) => {
|
|
1153
|
+
process.exitCode = code;
|
|
1154
|
+
}).catch((error) => {
|
|
1155
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1156
|
+
process.stderr.write(`canvas: ${message}
|
|
1157
|
+
`);
|
|
1158
|
+
process.exitCode = 1;
|
|
1159
|
+
});
|
|
1160
|
+
//# sourceMappingURL=canvas.js.map
|