recess-cli 1.9.1 → 1.9.2
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/dist/auth.js +6 -2
- package/dist/cli.js +127 -1
- package/package.json +1 -1
package/dist/auth.js
CHANGED
|
@@ -4,12 +4,16 @@ import { randomBytes } from "node:crypto";
|
|
|
4
4
|
import { clearPendingDeviceAuth, updateStoredConfig } from "./config.js";
|
|
5
5
|
import { apiError, CliError } from "./errors.js";
|
|
6
6
|
function openBrowser(url) {
|
|
7
|
+
// Windows goes through rundll32, not `cmd /c start`: cmd treats `&` (and the
|
|
8
|
+
// `%`-encoded redirect_uri) as metacharacters and chops the URL at the first
|
|
9
|
+
// `&`, so the browser only ever received `?client_id=…` and the OAuth page
|
|
10
|
+
// rejected it. rundll32 takes the URL as a single, unparsed argument.
|
|
7
11
|
const command = process.platform === "darwin"
|
|
8
12
|
? "open"
|
|
9
13
|
: process.platform === "win32"
|
|
10
|
-
? "
|
|
14
|
+
? "rundll32"
|
|
11
15
|
: "xdg-open";
|
|
12
|
-
const args = process.platform === "win32" ? ["
|
|
16
|
+
const args = process.platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
|
|
13
17
|
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
14
18
|
child.unref();
|
|
15
19
|
}
|
package/dist/cli.js
CHANGED
|
@@ -176,6 +176,10 @@ Usage:
|
|
|
176
176
|
[--confirm --approval-token TOKEN]
|
|
177
177
|
recess [--json] goals edit <goal-id> --student <kid-id> --patch-file <path/patch.json>
|
|
178
178
|
--delta TEXT [--confirm --approval-token TOKEN]
|
|
179
|
+
recess [--json] goals queue get <goal-id> --student <kid-id>
|
|
180
|
+
recess [--json] goals queue set <goal-id> --student <kid-id>
|
|
181
|
+
--entries-file <path.json> --delta TEXT [--replace-description-pointer]
|
|
182
|
+
[--confirm --approval-token TOKEN]
|
|
179
183
|
recess [--json] todos create --student <kid-id> --title TEXT
|
|
180
184
|
[--due-date YYYY-MM-DD] [--estimated-minutes N] [--url URL] [--confirm]
|
|
181
185
|
recess [--json] todos edit <todo-id> --patch-file <path/patch.json>
|
|
@@ -368,6 +372,8 @@ async function writeCommand(parsed, preview, execute) {
|
|
|
368
372
|
const GOAL_WORKSPACE_WRITE_MAX_FILES = 1_000;
|
|
369
373
|
const GOAL_WORKSPACE_WRITE_MAX_TOTAL_BYTES = 20 * 1024 * 1024;
|
|
370
374
|
const GOAL_PDF_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024;
|
|
375
|
+
const GOAL_QUEUE_MAX_ENTRIES = 500;
|
|
376
|
+
const URL_QUEUE_DESCRIPTION_POINTER = "Skill queue managed by the system.";
|
|
371
377
|
function approvalTokenFor(preview) {
|
|
372
378
|
return createHash("sha256").update(JSON.stringify(preview)).digest("hex");
|
|
373
379
|
}
|
|
@@ -693,6 +699,61 @@ async function readJsonFile(filePath, label) {
|
|
|
693
699
|
}
|
|
694
700
|
return parsed;
|
|
695
701
|
}
|
|
702
|
+
function parseGoalQueueEntries(value, label) {
|
|
703
|
+
if (!Array.isArray(value)) {
|
|
704
|
+
throw new CliError("invalid_arguments", `${label} must be a JSON array of queue entries.`);
|
|
705
|
+
}
|
|
706
|
+
if (value.length > GOAL_QUEUE_MAX_ENTRIES) {
|
|
707
|
+
throw new CliError("invalid_arguments", `${label} has ${value.length} entries; the maximum is ${GOAL_QUEUE_MAX_ENTRIES}.`);
|
|
708
|
+
}
|
|
709
|
+
return value.map((raw, index) => parseGoalQueueEntry(raw, `${label}[${index}]`));
|
|
710
|
+
}
|
|
711
|
+
function parseGoalQueueEntry(value, label) {
|
|
712
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
713
|
+
throw new CliError("invalid_arguments", `${label} must be an object with title and url.`);
|
|
714
|
+
}
|
|
715
|
+
const record = value;
|
|
716
|
+
const { title, url } = record;
|
|
717
|
+
if (typeof title !== "string" || !title.trim()) {
|
|
718
|
+
throw new CliError("invalid_arguments", `${label}.title is required.`);
|
|
719
|
+
}
|
|
720
|
+
if (typeof url !== "string" || !/^https:\/\//i.test(url.trim())) {
|
|
721
|
+
throw new CliError("invalid_arguments", `${label}.url must be an https URL.`);
|
|
722
|
+
}
|
|
723
|
+
const entry = { title: title.trim(), url: url.trim() };
|
|
724
|
+
if (record.completed !== undefined) {
|
|
725
|
+
if (typeof record.completed !== "boolean") {
|
|
726
|
+
throw new CliError("invalid_arguments", `${label}.completed must be a boolean.`);
|
|
727
|
+
}
|
|
728
|
+
entry.completed = record.completed;
|
|
729
|
+
}
|
|
730
|
+
if (record.sourceMeta !== undefined) {
|
|
731
|
+
if (!record.sourceMeta ||
|
|
732
|
+
typeof record.sourceMeta !== "object" ||
|
|
733
|
+
Array.isArray(record.sourceMeta)) {
|
|
734
|
+
throw new CliError("invalid_arguments", `${label}.sourceMeta must be an object.`);
|
|
735
|
+
}
|
|
736
|
+
const meta = record.sourceMeta;
|
|
737
|
+
const sourceMeta = {};
|
|
738
|
+
for (const key of [
|
|
739
|
+
"permacode",
|
|
740
|
+
"skillId",
|
|
741
|
+
"section",
|
|
742
|
+
"subjectKey",
|
|
743
|
+
"planKey",
|
|
744
|
+
]) {
|
|
745
|
+
const raw = meta[key];
|
|
746
|
+
if (raw === undefined)
|
|
747
|
+
continue;
|
|
748
|
+
if (typeof raw !== "string") {
|
|
749
|
+
throw new CliError("invalid_arguments", `${label}.sourceMeta.${key} must be a string.`);
|
|
750
|
+
}
|
|
751
|
+
sourceMeta[key] = raw;
|
|
752
|
+
}
|
|
753
|
+
entry.sourceMeta = sourceMeta;
|
|
754
|
+
}
|
|
755
|
+
return entry;
|
|
756
|
+
}
|
|
696
757
|
function contentLibrarySubmitItem(value, label) {
|
|
697
758
|
const record = typeof value === "string"
|
|
698
759
|
? { url: value }
|
|
@@ -3337,7 +3398,72 @@ export async function runCommand(argv) {
|
|
|
3337
3398
|
body: patch,
|
|
3338
3399
|
}));
|
|
3339
3400
|
}
|
|
3340
|
-
|
|
3401
|
+
if (verb === "queue") {
|
|
3402
|
+
const subverb = positional(parsed, 2, "queue action (get|set)");
|
|
3403
|
+
const goalId = positional(parsed, 3, "goal ID");
|
|
3404
|
+
const studentId = flagString(parsed, "student", { required: true });
|
|
3405
|
+
if (subverb === "get") {
|
|
3406
|
+
return unwrap(await api.client.GET("/tutor/browser/students/goals/{goalId}/queue/", { params: { path: { goalId } } }));
|
|
3407
|
+
}
|
|
3408
|
+
if (subverb === "set") {
|
|
3409
|
+
const entriesFile = flagString(parsed, "entries-file", {
|
|
3410
|
+
required: true,
|
|
3411
|
+
});
|
|
3412
|
+
const delta = flagString(parsed, "delta", { required: true });
|
|
3413
|
+
const replaceDescriptionPointer = hasFlag(parsed, "replace-description-pointer");
|
|
3414
|
+
const { absolutePath, parsed: rawEntries } = await readJsonValue(entriesFile, "Queue entries file");
|
|
3415
|
+
const entries = parseGoalQueueEntries(rawEntries, "Queue entries file");
|
|
3416
|
+
const goal = unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
|
|
3417
|
+
params: { path: { userId: studentId } },
|
|
3418
|
+
})).goals.find((candidate) => candidate.id === goalId);
|
|
3419
|
+
if (!goal) {
|
|
3420
|
+
throw new CliError("not_found", `Goal ${goalId} was not found for student ${studentId}.`);
|
|
3421
|
+
}
|
|
3422
|
+
const current = unwrap(await api.client.GET("/tutor/browser/students/goals/{goalId}/queue/", { params: { path: { goalId } } }));
|
|
3423
|
+
const currentUrls = new Set(current.queue.map((item) => item.url));
|
|
3424
|
+
const proposedUrls = new Set(entries.map((entry) => entry.url));
|
|
3425
|
+
const preview = {
|
|
3426
|
+
action: "replace a goal's URL skill queue for a managed student",
|
|
3427
|
+
target: { goalId, studentUserId: studentId },
|
|
3428
|
+
request: {
|
|
3429
|
+
entriesFile: absolutePath,
|
|
3430
|
+
entryCount: entries.length,
|
|
3431
|
+
delta,
|
|
3432
|
+
replaceDescriptionPointer,
|
|
3433
|
+
expectedUpdatedAt: goal.updatedAt,
|
|
3434
|
+
},
|
|
3435
|
+
details: {
|
|
3436
|
+
currentQueue: current.queue.map((item) => ({
|
|
3437
|
+
moduleRef: item.moduleRef,
|
|
3438
|
+
url: item.url,
|
|
3439
|
+
completedAt: item.completedAt,
|
|
3440
|
+
})),
|
|
3441
|
+
added: entries
|
|
3442
|
+
.filter((entry) => !currentUrls.has(entry.url))
|
|
3443
|
+
.map((entry) => entry.url),
|
|
3444
|
+
removed: current.queue
|
|
3445
|
+
.filter((item) => !proposedUrls.has(item.url))
|
|
3446
|
+
.map((item) => item.url),
|
|
3447
|
+
note: "Replaces the entire EXTERNAL_URL queue. Completion is preserved for unchanged URLs; WORKSPACE modules are untouched. Daily generation mints the head entry for a flag-on kid.",
|
|
3448
|
+
},
|
|
3449
|
+
};
|
|
3450
|
+
requirePreviewBoundConfirmation(parsed, preview);
|
|
3451
|
+
const body = {
|
|
3452
|
+
delta,
|
|
3453
|
+
expectedUpdatedAt: goal.updatedAt,
|
|
3454
|
+
queue: entries,
|
|
3455
|
+
...(replaceDescriptionPointer
|
|
3456
|
+
? { description: URL_QUEUE_DESCRIPTION_POINTER }
|
|
3457
|
+
: {}),
|
|
3458
|
+
};
|
|
3459
|
+
return unwrap(await api.client.PATCH("/tutor/browser/students/goals/{goalId}/", {
|
|
3460
|
+
params: { path: { goalId } },
|
|
3461
|
+
body,
|
|
3462
|
+
}));
|
|
3463
|
+
}
|
|
3464
|
+
throw new CliError("invalid_arguments", "Use goals queue get|set.");
|
|
3465
|
+
}
|
|
3466
|
+
throw new CliError("invalid_arguments", "Use goals list|create|edit|queue|files|pdf.");
|
|
3341
3467
|
}
|
|
3342
3468
|
if (noun === "students") {
|
|
3343
3469
|
if (verb === "list") {
|