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 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
- ? "cmd"
14
+ ? "rundll32"
11
15
  : "xdg-open";
12
- const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
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
- throw new CliError("invalid_arguments", "Use goals list|create|edit|files|pdf.");
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") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "recess-cli",
3
- "version": "1.9.1",
3
+ "version": "1.9.2",
4
4
  "description": "Safe Recess administration and family AI tools from the command line.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {