recess-cli 1.9.0 → 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/README.md +4 -1
- package/dist/auth.js +6 -2
- package/dist/cli.js +186 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -197,13 +197,16 @@ recess --json students schedule --student <kid-id> --days 30
|
|
|
197
197
|
recess --json students xp-history --student <kid-id> --range month
|
|
198
198
|
recess --json goals list --student <kid-id>
|
|
199
199
|
recess --json todos create --student <kid-id> --title "Read chapter 4"
|
|
200
|
+
recess --json todos generate-applet <todo-id> --student <kid-id> [--due-date YYYY-MM-DD]
|
|
200
201
|
recess --json memories context --student <kid-id>
|
|
201
202
|
recess --json memories log --student <kid-id> --date 2026-08-12
|
|
202
203
|
recess --json rocky get --student <kid-id>
|
|
203
204
|
```
|
|
204
205
|
|
|
205
206
|
All family writes still preview first. Goal/todo/Rocky edits also carry the current server version
|
|
206
|
-
into the confirmed request.
|
|
207
|
+
into the confirmed request. Applet generation is staff-only: it resolves the todo's latest learning
|
|
208
|
+
analysis, defaults the generated todo to tomorrow in the student's timezone, and lets the server
|
|
209
|
+
select v1 or v2 for that student. A guardian cannot target another family, inspect frozen/deleted
|
|
207
210
|
template history, choose todo rewards/completion/internal fields, or use the ADMIN-only
|
|
208
211
|
device-authorization flow. `memories context` and `memories log` read only the tutor repository's
|
|
209
212
|
spine, rules, reminders, and session logs; private guide remarks are never returned. The CLI does
|
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,10 +176,16 @@ 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>
|
|
182
186
|
[--confirm --approval-token TOKEN]
|
|
187
|
+
recess [--json] todos generate-applet <todo-id> --student <kid-id>
|
|
188
|
+
[--due-date YYYY-MM-DD] [--confirm --approval-token TOKEN]
|
|
183
189
|
recess [--json] memories context --student <kid-id>
|
|
184
190
|
recess [--json] memories log --student <kid-id> --date YYYY-MM-DD
|
|
185
191
|
recess [--json] rocky get --student <kid-id>
|
|
@@ -366,6 +372,8 @@ async function writeCommand(parsed, preview, execute) {
|
|
|
366
372
|
const GOAL_WORKSPACE_WRITE_MAX_FILES = 1_000;
|
|
367
373
|
const GOAL_WORKSPACE_WRITE_MAX_TOTAL_BYTES = 20 * 1024 * 1024;
|
|
368
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.";
|
|
369
377
|
function approvalTokenFor(preview) {
|
|
370
378
|
return createHash("sha256").update(JSON.stringify(preview)).digest("hex");
|
|
371
379
|
}
|
|
@@ -691,6 +699,61 @@ async function readJsonFile(filePath, label) {
|
|
|
691
699
|
}
|
|
692
700
|
return parsed;
|
|
693
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
|
+
}
|
|
694
757
|
function contentLibrarySubmitItem(value, label) {
|
|
695
758
|
const record = typeof value === "string"
|
|
696
759
|
? { url: value }
|
|
@@ -1175,6 +1238,20 @@ function flagItemDateMs(parsed) {
|
|
|
1175
1238
|
}
|
|
1176
1239
|
return ms;
|
|
1177
1240
|
}
|
|
1241
|
+
function flagDateOnly(parsed, name) {
|
|
1242
|
+
const raw = flagString(parsed, name);
|
|
1243
|
+
if (raw === undefined)
|
|
1244
|
+
return undefined;
|
|
1245
|
+
const parsedDate = /^\d{4}-\d{2}-\d{2}$/.test(raw)
|
|
1246
|
+
? new Date(`${raw}T00:00:00.000Z`)
|
|
1247
|
+
: null;
|
|
1248
|
+
if (!parsedDate ||
|
|
1249
|
+
Number.isNaN(parsedDate.getTime()) ||
|
|
1250
|
+
parsedDate.toISOString().slice(0, 10) !== raw) {
|
|
1251
|
+
throw new CliError("invalid_arguments", `--${name} must be a valid YYYY-MM-DD date.`);
|
|
1252
|
+
}
|
|
1253
|
+
return raw;
|
|
1254
|
+
}
|
|
1178
1255
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
1179
1256
|
// The cohort event routes take zoneless local wall-clock datetimes
|
|
1180
1257
|
// (interpreted in the cohort's timezone server-side).
|
|
@@ -3321,7 +3398,72 @@ export async function runCommand(argv) {
|
|
|
3321
3398
|
body: patch,
|
|
3322
3399
|
}));
|
|
3323
3400
|
}
|
|
3324
|
-
|
|
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.");
|
|
3325
3467
|
}
|
|
3326
3468
|
if (noun === "students") {
|
|
3327
3469
|
if (verb === "list") {
|
|
@@ -3423,7 +3565,49 @@ export async function runCommand(argv) {
|
|
|
3423
3565
|
body,
|
|
3424
3566
|
}));
|
|
3425
3567
|
}
|
|
3426
|
-
|
|
3568
|
+
if (verb === "generate-applet") {
|
|
3569
|
+
const todoId = positional(parsed, 2, "todo ID");
|
|
3570
|
+
const studentId = flagString(parsed, "student", { required: true });
|
|
3571
|
+
const targetDueDateISO = flagDateOnly(parsed, "due-date");
|
|
3572
|
+
const [todo, learningAnalysis] = await Promise.all([
|
|
3573
|
+
api.client.GET("/tutor/browser/todos/{id}/", {
|
|
3574
|
+
params: { path: { id: todoId } },
|
|
3575
|
+
}),
|
|
3576
|
+
api.client.GET("/tutor/students/{studentId}/todos/{todoId}/learning-analysis", {
|
|
3577
|
+
params: { path: { studentId, todoId } },
|
|
3578
|
+
}),
|
|
3579
|
+
]);
|
|
3580
|
+
const currentTodo = unwrap(todo);
|
|
3581
|
+
if (currentTodo.userId !== studentId) {
|
|
3582
|
+
throw new CliError("not_found", "The todo does not belong to the requested student.");
|
|
3583
|
+
}
|
|
3584
|
+
const analysis = unwrap(learningAnalysis).analysis;
|
|
3585
|
+
if (!analysis) {
|
|
3586
|
+
throw new CliError("not_found", "This todo does not have a learning analysis to generate an applet from.");
|
|
3587
|
+
}
|
|
3588
|
+
const preview = {
|
|
3589
|
+
action: "generate an applet from a student's todo analysis",
|
|
3590
|
+
target: {
|
|
3591
|
+
studentUserId: studentId,
|
|
3592
|
+
todoId,
|
|
3593
|
+
todoTitle: currentTodo.title,
|
|
3594
|
+
analysisId: analysis.id,
|
|
3595
|
+
},
|
|
3596
|
+
request: {
|
|
3597
|
+
targetDueDateISO: targetDueDateISO ?? null,
|
|
3598
|
+
},
|
|
3599
|
+
details: {
|
|
3600
|
+
dueDate: targetDueDateISO ?? "tomorrow in the student's timezone",
|
|
3601
|
+
versionRouting: "The server evaluates applet-gen-v2 for this student and uses v1 when the flag is off or its evaluation fails.",
|
|
3602
|
+
},
|
|
3603
|
+
};
|
|
3604
|
+
requirePreviewBoundConfirmation(parsed, preview);
|
|
3605
|
+
return unwrap(await api.client.POST("/admin/learning-pipeline/{analysisId}/generate-applet/", {
|
|
3606
|
+
params: { path: { analysisId: analysis.id } },
|
|
3607
|
+
body: targetDueDateISO ? { targetDueDateISO } : {},
|
|
3608
|
+
}));
|
|
3609
|
+
}
|
|
3610
|
+
throw new CliError("invalid_arguments", "Use todos create|edit|generate-applet.");
|
|
3427
3611
|
}
|
|
3428
3612
|
if (noun === "memories") {
|
|
3429
3613
|
const studentId = flagString(parsed, "student", { required: true });
|