recess-cli 2.2.0 → 2.3.0
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 +18 -0
- package/dist/api.js +17 -0
- package/dist/auth.js +7 -2
- package/dist/cli.js +123 -5
- package/dist/commands/onboarding.js +186 -0
- package/dist/commands/school.js +262 -0
- package/dist/help.js +52 -3
- package/dist/http.js +45 -2
- package/package.json +1 -1
- package/skill/recess-cli/SKILL.md +3 -1
- package/skill/recess-cli/agents/version.json +1 -1
package/README.md
CHANGED
|
@@ -121,6 +121,24 @@ row per request. The human-only `recess ui` console is explicitly exempt and ide
|
|
|
121
121
|
|
|
122
122
|
## Common flow
|
|
123
123
|
|
|
124
|
+
School-onboarding work is family-first: one composite view, then a loop of "what's
|
|
125
|
+
next" → the suggested command → "what's next" again, with `doctor` as the
|
|
126
|
+
gate-observability instrument when something is unexpectedly dark:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
recess --json onboarding family <family-id> --reason "One-screen view of this school family"
|
|
130
|
+
recess --json onboarding next <family-id> --reason "What should happen next for this family"
|
|
131
|
+
recess --json onboarding doctor --family <family-id> --reason "Why is the school surface dark for them"
|
|
132
|
+
recess --json onboarding starter-coverage --reason "Run the pre-flip coverage gate"
|
|
133
|
+
recess --json onboarding backfill-trackers --reason "Census the WS-H guardian trackers"
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`next` returns the mission-control queue's current action plus `suggestedCommands`
|
|
137
|
+
with real ids substituted; every suggested write still previews and requires its
|
|
138
|
+
own `--confirm`. `doctor` reports the env kill-switches, comms mode, and
|
|
139
|
+
per-guardian/per-kid flag + capability-lock + cohort-gate state — its env values
|
|
140
|
+
are the answering service's only (the Worker can differ).
|
|
141
|
+
|
|
124
142
|
```bash
|
|
125
143
|
recess --json users search "Morgan Rivera" --reason "Find the exact student record"
|
|
126
144
|
recess --json users tier get <kid-id> --reason "Inspect the student's current tier"
|
package/dist/api.js
CHANGED
|
@@ -142,6 +142,23 @@ export class RecessAdminApi {
|
|
|
142
142
|
throw apiError(response.status, body);
|
|
143
143
|
return body;
|
|
144
144
|
}
|
|
145
|
+
async uploadPartnerLogo(image, fileName, mimeType) {
|
|
146
|
+
this.requireAuth();
|
|
147
|
+
const formData = new FormData();
|
|
148
|
+
const arrayBuffer = image.buffer.slice(image.byteOffset, image.byteOffset + image.byteLength);
|
|
149
|
+
formData.append("file", new Blob([arrayBuffer], { type: mimeType }), fileName);
|
|
150
|
+
const headers = cliRequestHeaders({ cookie: this.config.sessionCookie }, this.reason, this.clientTag);
|
|
151
|
+
applyIdempotencyHeaders(headers);
|
|
152
|
+
const response = await fetch(new URL("/admin/partner/logo-upload", this.config.apiOrigin), {
|
|
153
|
+
method: "POST",
|
|
154
|
+
headers,
|
|
155
|
+
body: formData,
|
|
156
|
+
});
|
|
157
|
+
const body = await readResponseBody(response);
|
|
158
|
+
if (!response.ok)
|
|
159
|
+
throw apiError(response.status, body);
|
|
160
|
+
return body;
|
|
161
|
+
}
|
|
145
162
|
async uploadMapTestScores(studentId, pdf, fileName) {
|
|
146
163
|
this.requireAuth();
|
|
147
164
|
const formData = new FormData();
|
package/dist/auth.js
CHANGED
|
@@ -100,6 +100,11 @@ export async function login(config, options) {
|
|
|
100
100
|
// Start the headless (device-authorization) sign-in. Non-blocking: it stores the secret
|
|
101
101
|
// device code locally and returns the approval URL for the agent to surface to a human, who
|
|
102
102
|
// approves it in a browser. The agent then calls `pollDeviceAuth` to collect the session.
|
|
103
|
+
//
|
|
104
|
+
// The approver grants their own scope: a guardian approving yields a family-scoped session
|
|
105
|
+
// over their own kids, an admin a full-admin one. That is what lets an agent running
|
|
106
|
+
// anywhere — a cloud box with no browser — hold a session at all, instead of a human having
|
|
107
|
+
// to be the hands for every command.
|
|
103
108
|
export async function requestDeviceAuth(config, options) {
|
|
104
109
|
const response = await fetch(new URL("/auth/admin-cli/device/authorize/", config.apiOrigin), {
|
|
105
110
|
method: "POST",
|
|
@@ -123,11 +128,11 @@ export async function requestDeviceAuth(config, options) {
|
|
|
123
128
|
userCode: authorize.userCode,
|
|
124
129
|
expiresAt: authorize.expiresAt,
|
|
125
130
|
interval: authorize.interval,
|
|
126
|
-
instructions: `Open ${authorize.approvalUrl} in a browser
|
|
131
|
+
instructions: `Open ${authorize.approvalUrl} in a browser and approve it while signed in to Recess as the person this agent should act as — the session takes on THAT account's scope, so a guardian grants family-only access. Then run \`recess auth poll\`.`,
|
|
127
132
|
};
|
|
128
133
|
}
|
|
129
134
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
130
|
-
// Poll the device-authorization request until
|
|
135
|
+
// Poll the device-authorization request until a human approves it (then store the session),
|
|
131
136
|
// or it is denied / expires / the timeout elapses. On a plain timeout the pending request is
|
|
132
137
|
// preserved so the agent can call `auth poll` again after the human approves.
|
|
133
138
|
export async function pollDeviceAuth(config, options) {
|
package/dist/cli.js
CHANGED
|
@@ -24,6 +24,52 @@ import { appendJobEvent, getJob, listJobs, pruneJobs } from "./jobs.js";
|
|
|
24
24
|
function requiredRequestReason(parsed) {
|
|
25
25
|
return requireCliRequestReason(flagString(parsed, "reason", { required: true }));
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* Role of whoever this session belongs to.
|
|
29
|
+
*
|
|
30
|
+
* The stored identity only describes an `auth login` session; an env-cookie
|
|
31
|
+
* session may belong to someone else entirely, so ask the server rather than
|
|
32
|
+
* trusting a stale login. Used by commands that must refuse *before* their
|
|
33
|
+
* confirmation gate — an approval a human grants and the server then rejects is
|
|
34
|
+
* worse than no preview at all.
|
|
35
|
+
*/
|
|
36
|
+
async function resolveSessionRole(api) {
|
|
37
|
+
if (api.config.authSource === "config" && api.config.user?.role) {
|
|
38
|
+
return api.config.user.role;
|
|
39
|
+
}
|
|
40
|
+
const session = await api.client.GET("/auth/admin-cli/session/");
|
|
41
|
+
return session.data?.user.role;
|
|
42
|
+
}
|
|
43
|
+
/** Status values `goals edit` may send. Deliberately narrow: the backend patch
|
|
44
|
+
* route is pause/resume only, and reaching the terminal state is
|
|
45
|
+
* `goals archive`, which frees an open-goal slot and writes a different audit
|
|
46
|
+
* row. */
|
|
47
|
+
const EDITABLE_GOAL_STATUSES = ["ACTIVE", "PAUSED"];
|
|
48
|
+
/**
|
|
49
|
+
* Reject bad enum values in a goal patch file *before* the preview is built.
|
|
50
|
+
*
|
|
51
|
+
* A preview is what a human approves, so it must not describe a write the
|
|
52
|
+
* server will refuse. Sending `{"status":"COMPLETED"}` used to preview cleanly,
|
|
53
|
+
* resolve a compare-and-set token, mint an approval token, and only then fail
|
|
54
|
+
* at confirm — spending a human approval on an impossible request.
|
|
55
|
+
*/
|
|
56
|
+
function assertGoalPatchValues(patch, patchFile) {
|
|
57
|
+
const status = patch.status;
|
|
58
|
+
if (status === undefined)
|
|
59
|
+
return;
|
|
60
|
+
if (typeof status !== "string" ||
|
|
61
|
+
!EDITABLE_GOAL_STATUSES.includes(status)) {
|
|
62
|
+
const archiveHint = status === "COMPLETED"
|
|
63
|
+
? " To close out a finished course and free an open-goal slot, use `recess goals archive <goal-id> --student <kid-id>` instead — `goals edit` cannot set a terminal status."
|
|
64
|
+
: "";
|
|
65
|
+
throw new CliError("invalid_arguments", `${path.resolve(patchFile)} sets status to ${JSON.stringify(status)}, which \`goals edit\` does not accept. Allowed: ${EDITABLE_GOAL_STATUSES.join(", ")}.${archiveHint}`, 1, {
|
|
66
|
+
field: "status",
|
|
67
|
+
value: status,
|
|
68
|
+
allowed: [...EDITABLE_GOAL_STATUSES],
|
|
69
|
+
patchFile: path.resolve(patchFile),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
27
73
|
async function sessionStatus(config) {
|
|
28
74
|
if (!config.sessionCookie) {
|
|
29
75
|
return {
|
|
@@ -131,7 +177,7 @@ async function writeCommand(parsed, preview, execute) {
|
|
|
131
177
|
details: {
|
|
132
178
|
...preview.details,
|
|
133
179
|
operationKey,
|
|
134
|
-
retry: "Rerun the unchanged command with --confirm --operation-key <operationKey>. Reuse that same key after an interrupted invocation.",
|
|
180
|
+
retry: "Rerun the unchanged command with --confirm --operation-key <operationKey>. Reuse that same key after an interrupted invocation. Previewing again is harmless: it mints another key that is equally valid, because keys are bound to the payload rather than to one preview run.",
|
|
135
181
|
},
|
|
136
182
|
};
|
|
137
183
|
await appendJobEvent({
|
|
@@ -145,7 +191,7 @@ async function writeCommand(parsed, preview, execute) {
|
|
|
145
191
|
requireConfirmation(false, boundPreview);
|
|
146
192
|
}
|
|
147
193
|
if (!suppliedOperationKey) {
|
|
148
|
-
throw new CliError("confirmation_required", "A confirmed write requires the
|
|
194
|
+
throw new CliError("confirmation_required", "A confirmed write requires --operation-key. Run this exact command without --confirm, then pass the operationKey from the preview details. Any preview of this same payload yields a usable key.", 2, { preview, requiredFlag: "--operation-key" });
|
|
149
195
|
}
|
|
150
196
|
assertOperationKeyMatchesPreview(suppliedOperationKey, fingerprint, preview);
|
|
151
197
|
await appendJobEvent({
|
|
@@ -477,9 +523,26 @@ function approvalTokenFor(preview) {
|
|
|
477
523
|
function operationKeyFor(fingerprint) {
|
|
478
524
|
return `op_${randomUUID().replaceAll("-", "")}_${fingerprint}`;
|
|
479
525
|
}
|
|
526
|
+
/**
|
|
527
|
+
* An operation key is `op_<random>_<fingerprint>`, and the fingerprint is a
|
|
528
|
+
* hash of the whole preview. Only the suffix is checked, which means **any**
|
|
529
|
+
* key minted from a preview of this exact payload works — including one from an
|
|
530
|
+
* earlier preview of the same command. That matters: previewing twice mints two
|
|
531
|
+
* different keys, both valid, and reaching for the newer one is not a mistake.
|
|
532
|
+
*
|
|
533
|
+
* The suffix is also what makes the key safe. It cannot be transplanted onto a
|
|
534
|
+
* different write, because a payload that differs by one character hashes
|
|
535
|
+
* differently and no key from the old preview will match.
|
|
536
|
+
*/
|
|
480
537
|
function assertOperationKeyMatchesPreview(operationKey, fingerprint, preview) {
|
|
481
538
|
if (!operationKey.endsWith(`_${fingerprint}`)) {
|
|
482
|
-
throw new CliError("approval_mismatch",
|
|
539
|
+
throw new CliError("approval_mismatch", `This operation key was minted for a different payload, so it cannot approve this one. The key must end in _${fingerprint} — copy the operationKey out of the preview for THIS exact command (any preview of the same payload will do, newest or oldest). If the payload really did change, that is the gate working: get approval on the current preview.`, 2, {
|
|
540
|
+
preview,
|
|
541
|
+
requiredFlag: "--operation-key",
|
|
542
|
+
expectedKeySuffix: `_${fingerprint}`,
|
|
543
|
+
suppliedKey: operationKey,
|
|
544
|
+
note: "Keys are bound to the payload, not to a particular preview run. Re-previewing an unchanged command mints a new key with the same suffix; every one of them is accepted.",
|
|
545
|
+
});
|
|
483
546
|
}
|
|
484
547
|
}
|
|
485
548
|
async function requirePreviewBoundConfirmation(parsed, preview) {
|
|
@@ -509,7 +572,7 @@ async function requirePreviewBoundConfirmation(parsed, preview) {
|
|
|
509
572
|
throw new CliError("approval_mismatch", "The approved preview no longer matches this request. Review the current preview and rerun with --confirm --approval-token <token>.", 2, { preview: boundPreview, requiredFlag: "--approval-token" });
|
|
510
573
|
}
|
|
511
574
|
if (!suppliedOperationKey) {
|
|
512
|
-
throw new CliError("confirmation_required", "A confirmed write requires the
|
|
575
|
+
throw new CliError("confirmation_required", "A confirmed write requires --operation-key. Run this exact command without --confirm, then pass the operationKey from the preview details. Any preview of this same payload yields a usable key.", 2, { preview: boundPreview, requiredFlag: "--operation-key" });
|
|
513
576
|
}
|
|
514
577
|
assertOperationKeyMatchesPreview(suppliedOperationKey, approvalToken, boundPreview);
|
|
515
578
|
await appendJobEvent({
|
|
@@ -3559,6 +3622,51 @@ export async function runCommand(argv) {
|
|
|
3559
3622
|
params: { path: { goalId } },
|
|
3560
3623
|
})));
|
|
3561
3624
|
}
|
|
3625
|
+
if (verb === "archive" || verb === "unarchive") {
|
|
3626
|
+
const goalId = positional(parsed, 2, "goal ID");
|
|
3627
|
+
const studentId = flagString(parsed, "student", { required: true });
|
|
3628
|
+
const reopen = verb === "unarchive";
|
|
3629
|
+
const current = unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
|
|
3630
|
+
params: { path: { userId: studentId } },
|
|
3631
|
+
})).goals.find((goal) => goal.id === goalId);
|
|
3632
|
+
if (!current) {
|
|
3633
|
+
throw new CliError("not_found", `Goal ${goalId} was not found for student ${studentId}.`);
|
|
3634
|
+
}
|
|
3635
|
+
// Refuse the no-op before asking a human to approve it. Approving
|
|
3636
|
+
// "archive a goal" only to be told it was already archived teaches an
|
|
3637
|
+
// agent to re-approve noise.
|
|
3638
|
+
if (!reopen && current.status === "COMPLETED") {
|
|
3639
|
+
throw new CliError("already_archived", `Goal ${goalId} ("${current.title}") is already archived and is not holding an open-goal slot.`);
|
|
3640
|
+
}
|
|
3641
|
+
if (reopen && current.status !== "COMPLETED") {
|
|
3642
|
+
throw new CliError("not_archived", `Goal ${goalId} ("${current.title}") is ${current.status}, not archived. There is nothing to reopen.`);
|
|
3643
|
+
}
|
|
3644
|
+
const preview = {
|
|
3645
|
+
action: reopen
|
|
3646
|
+
? "reopen an archived goal (consumes one of the kid's open-goal slots)"
|
|
3647
|
+
: "archive a finished goal (frees one of the kid's open-goal slots)",
|
|
3648
|
+
target: {
|
|
3649
|
+
goalId,
|
|
3650
|
+
studentUserId: studentId,
|
|
3651
|
+
title: current.title,
|
|
3652
|
+
status: current.status,
|
|
3653
|
+
progress: current.progress,
|
|
3654
|
+
},
|
|
3655
|
+
request: { goalId },
|
|
3656
|
+
details: {
|
|
3657
|
+
note: reopen
|
|
3658
|
+
? "Returns the goal to ACTIVE so it generates daily todos again. Refused with GOAL_LIMIT_REACHED if the kid is already at the open-goal cap, and refused for goals completed through the staff reward path (those need `goals undo-completion`, which reverses the coins)."
|
|
3659
|
+
: "Sets the terminal COMPLETED status. Grants no coins and posts no activity — use `goals complete` (staff only) when the kid should receive the goal's reward. The goal, its history, and its past todos all stay; only the open-goal slot is released. Any already-scheduled future todos on this goal are left alone, matching `goals complete`.",
|
|
3660
|
+
},
|
|
3661
|
+
};
|
|
3662
|
+
return previewBoundWrite(parsed, preview, async () => unwrap(reopen
|
|
3663
|
+
? await api.client.POST("/ai/goals/{goalId}/unarchive", {
|
|
3664
|
+
params: { path: { goalId } },
|
|
3665
|
+
})
|
|
3666
|
+
: await api.client.POST("/ai/goals/{goalId}/archive", {
|
|
3667
|
+
params: { path: { goalId } },
|
|
3668
|
+
})));
|
|
3669
|
+
}
|
|
3562
3670
|
if (verb === "list") {
|
|
3563
3671
|
const userId = flagString(parsed, "student", { required: true });
|
|
3564
3672
|
return unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
|
|
@@ -3727,6 +3835,7 @@ export async function runCommand(argv) {
|
|
|
3727
3835
|
const studentId = flagString(parsed, "student", { required: true });
|
|
3728
3836
|
const patchFile = flagString(parsed, "patch-file", { required: true });
|
|
3729
3837
|
const patch = (await readJsonFile(patchFile, "Goal patch file"));
|
|
3838
|
+
assertGoalPatchValues(patch, patchFile);
|
|
3730
3839
|
patch.delta = flagString(parsed, "delta", { required: true });
|
|
3731
3840
|
const current = unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
|
|
3732
3841
|
params: { path: { userId: studentId } },
|
|
@@ -3752,6 +3861,15 @@ export async function runCommand(argv) {
|
|
|
3752
3861
|
if (verb === "delete") {
|
|
3753
3862
|
const goalId = positional(parsed, 2, "goal ID");
|
|
3754
3863
|
const studentId = flagString(parsed, "student", { required: true });
|
|
3864
|
+
// Deletion is an /admin/* route, so a guardian or guide session can never
|
|
3865
|
+
// complete it. Refuse here, before the preview, rather than rendering a
|
|
3866
|
+
// full and entirely plausible preview — resolved goal, named title,
|
|
3867
|
+
// described soft-delete — that a human approves and the server then
|
|
3868
|
+
// answers 401. A preview is a promise about what --confirm will do.
|
|
3869
|
+
const sessionRole = await resolveSessionRole(api);
|
|
3870
|
+
if (sessionRole && sessionRole !== "ADMIN") {
|
|
3871
|
+
throw new CliError("forbidden", "Deleting a goal is staff-only. To free an open-goal slot for a finished course, use `recess goals archive <goal-id> --student <kid-id>` — it sets the terminal status, keeps the goal and its history, and needs no staff involvement.", 1, { sessionRole, requiredRole: "ADMIN", alternative: "goals archive" });
|
|
3872
|
+
}
|
|
3755
3873
|
const current = unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
|
|
3756
3874
|
params: { path: { userId: studentId } },
|
|
3757
3875
|
})).goals.find((goal) => goal.id === goalId);
|
|
@@ -3869,7 +3987,7 @@ export async function runCommand(argv) {
|
|
|
3869
3987
|
}
|
|
3870
3988
|
throw new CliError("invalid_arguments", "Use goals queue get|set.");
|
|
3871
3989
|
}
|
|
3872
|
-
throw new CliError("invalid_arguments", "Use goals list|create|edit|delete|complete|undo-completion|queue|files|pdf.");
|
|
3990
|
+
throw new CliError("invalid_arguments", "Use goals list|create|edit|delete|archive|unarchive|complete|undo-completion|queue|files|pdf.");
|
|
3873
3991
|
}
|
|
3874
3992
|
if (noun === "students") {
|
|
3875
3993
|
if (verb === "list") {
|
|
@@ -48,6 +48,75 @@ function summaryPreview(text, max = 600) {
|
|
|
48
48
|
return null;
|
|
49
49
|
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
|
50
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* The mission-control queue rule → the CLI command(s) that perform it, with
|
|
53
|
+
* real ids substituted where the action carries them. Placeholders stay in
|
|
54
|
+
* angle brackets where a human choice remains (a template, a cohort, a
|
|
55
|
+
* transcript). Suggestions only: every write below still previews and demands
|
|
56
|
+
* its own --confirm when actually run.
|
|
57
|
+
*/
|
|
58
|
+
function suggestedCommandsForQueueAction(action, familyId) {
|
|
59
|
+
const slug = action.partnerSlug ?? "<institution-slug>";
|
|
60
|
+
const invite = action.inviteId ?? "<code-id>";
|
|
61
|
+
const kid = (action.kids.find((row) => row.activeGoalCount === 0) ?? action.kids[0])
|
|
62
|
+
?.id ?? "<kid-id>";
|
|
63
|
+
switch (action.key) {
|
|
64
|
+
case "invite_waiting":
|
|
65
|
+
return [
|
|
66
|
+
`recess school codes get ${invite} --school ${slug}`,
|
|
67
|
+
`recess school codes resend ${invite} --school ${slug}`,
|
|
68
|
+
];
|
|
69
|
+
case "invite_expired":
|
|
70
|
+
return [
|
|
71
|
+
`recess school codes replace ${invite} --school ${slug} --data-file <invite.json>`,
|
|
72
|
+
];
|
|
73
|
+
case "parked":
|
|
74
|
+
return [`recess onboarding set-account-state ${familyId} --state ACTIVE`];
|
|
75
|
+
case "start_intake":
|
|
76
|
+
return [`recess onboarding intake-session-create ${familyId}`];
|
|
77
|
+
case "fill_intake":
|
|
78
|
+
return [
|
|
79
|
+
`recess onboarding extract ${familyId} --session <session-id> --granola <ref>`,
|
|
80
|
+
`recess onboarding set-intake ${familyId} --session <session-id> --data <json>`,
|
|
81
|
+
];
|
|
82
|
+
case "send_welcome":
|
|
83
|
+
return [`recess onboarding send-welcome ${familyId}`];
|
|
84
|
+
case "nudge_confirm":
|
|
85
|
+
return [
|
|
86
|
+
`recess onboarding send-comms --subject family:${familyId} --kind confirm_nudge_1`,
|
|
87
|
+
];
|
|
88
|
+
case "load_goals":
|
|
89
|
+
return [
|
|
90
|
+
`recess goal-templates list --starter-only`,
|
|
91
|
+
`recess goal-templates apply-starter <template-id> --student ${kid}`,
|
|
92
|
+
];
|
|
93
|
+
case "assign_tutor":
|
|
94
|
+
return [
|
|
95
|
+
`recess onboarding active-tutors ${familyId}`,
|
|
96
|
+
`recess onboarding set-primary-tutor ${familyId} --tutor <user-id>`,
|
|
97
|
+
];
|
|
98
|
+
case "attest":
|
|
99
|
+
return [
|
|
100
|
+
`recess onboarding attest ${familyId} --condition <app_downloaded|tutor_met|goals_loaded|ma_diagnostic>`,
|
|
101
|
+
];
|
|
102
|
+
case "clear_for_cohort":
|
|
103
|
+
return [`recess onboarding clear-for-cohort ${familyId}`];
|
|
104
|
+
case "register_cohort":
|
|
105
|
+
return [
|
|
106
|
+
`recess onboarding cohort-options ${familyId} --kid ${kid}`,
|
|
107
|
+
`recess onboarding register-cohort ${familyId} --kid ${kid} --cohort <event-series-id>`,
|
|
108
|
+
];
|
|
109
|
+
case "at_risk":
|
|
110
|
+
return [
|
|
111
|
+
`recess onboarding readiness ${familyId}`,
|
|
112
|
+
`recess onboarding timeline ${familyId}`,
|
|
113
|
+
];
|
|
114
|
+
case "mark_complete":
|
|
115
|
+
return [`recess onboarding set-stage ${familyId} --stage COMPLETE`];
|
|
116
|
+
default:
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
}
|
|
51
120
|
export async function runOnboardingCommand({ parsed, api, writeCommand, }) {
|
|
52
121
|
const noun = parsed.positionals[0] ?? "";
|
|
53
122
|
const verb = parsed.positionals[1] ?? "";
|
|
@@ -97,6 +166,123 @@ export async function runOnboardingCommand({ parsed, api, writeCommand, }) {
|
|
|
97
166
|
const familyId = positional(parsed, 2, "family ID");
|
|
98
167
|
return unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/active-tutors", { params: { path: { familyId } } }));
|
|
99
168
|
}
|
|
169
|
+
if (verb === "family") {
|
|
170
|
+
const familyId = positional(parsed, 2, "family ID");
|
|
171
|
+
// The whole family on one screen: status + readiness + the queue's next
|
|
172
|
+
// action + the current intake session + active tutors, in parallel.
|
|
173
|
+
// `status` is the required backbone (a missing family fails the command);
|
|
174
|
+
// the flag-gated reads degrade to a labeled `unavailable` instead of
|
|
175
|
+
// failing the view — the doctor command is the tool for "why".
|
|
176
|
+
const soft = async (read) => {
|
|
177
|
+
try {
|
|
178
|
+
return await read();
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
return {
|
|
182
|
+
unavailable: error instanceof CliError ? error.message : String(error),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
const [status, readiness, nextAction, intakeSession, activeTutors] = await Promise.all([
|
|
187
|
+
api.client
|
|
188
|
+
.GET("/admin/onboarding/families/{familyId}/status", {
|
|
189
|
+
params: { path: { familyId } },
|
|
190
|
+
})
|
|
191
|
+
.then(unwrap),
|
|
192
|
+
soft(() => api.client
|
|
193
|
+
.GET("/admin/onboarding/families/{familyId}/readiness", {
|
|
194
|
+
params: { path: { familyId } },
|
|
195
|
+
})
|
|
196
|
+
.then(unwrap)),
|
|
197
|
+
soft(() => api.client
|
|
198
|
+
.GET("/admin/onboarding/families/{familyId}/next-action", {
|
|
199
|
+
params: { path: { familyId } },
|
|
200
|
+
})
|
|
201
|
+
.then(unwrap)),
|
|
202
|
+
soft(async () => {
|
|
203
|
+
try {
|
|
204
|
+
return await api.rawGet(`/admin/onboarding/families/${encodeURIComponent(familyId)}/intake-session`);
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
if (error instanceof CliError &&
|
|
208
|
+
typeof error.details === "object" &&
|
|
209
|
+
error.details !== null &&
|
|
210
|
+
error.details.status === 404) {
|
|
211
|
+
return { intakeSession: null };
|
|
212
|
+
}
|
|
213
|
+
throw error;
|
|
214
|
+
}
|
|
215
|
+
}),
|
|
216
|
+
soft(() => api.client
|
|
217
|
+
.GET("/admin/onboarding/families/{familyId}/active-tutors", {
|
|
218
|
+
params: { path: { familyId } },
|
|
219
|
+
})
|
|
220
|
+
.then(unwrap)),
|
|
221
|
+
]);
|
|
222
|
+
return {
|
|
223
|
+
familyId,
|
|
224
|
+
status,
|
|
225
|
+
readiness,
|
|
226
|
+
nextAction,
|
|
227
|
+
intakeSession,
|
|
228
|
+
activeTutors,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
if (verb === "next") {
|
|
232
|
+
const familyId = positional(parsed, 2, "family ID");
|
|
233
|
+
const result = unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/next-action", { params: { path: { familyId } } }));
|
|
234
|
+
const action = result.action;
|
|
235
|
+
return {
|
|
236
|
+
...result,
|
|
237
|
+
// The queue rule mapped to the command that performs it, with real ids
|
|
238
|
+
// substituted where the action carries them — so "what's next" arrives
|
|
239
|
+
// holding "and here is how", instead of making the agent re-derive the
|
|
240
|
+
// funnel from documentation. Every suggested write still previews and
|
|
241
|
+
// requires its own --confirm.
|
|
242
|
+
suggestedCommands: action === null
|
|
243
|
+
? []
|
|
244
|
+
: suggestedCommandsForQueueAction(action, familyId),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
if (verb === "doctor") {
|
|
248
|
+
// --family is a flag rather than an optional positional: the schema
|
|
249
|
+
// derives positional bounds from the usage line, which cannot express
|
|
250
|
+
// optionality.
|
|
251
|
+
const familyId = flagString(parsed, "family");
|
|
252
|
+
return unwrap(await api.client.GET("/admin/onboarding/doctor", {
|
|
253
|
+
params: { query: familyId ? { familyId } : {} },
|
|
254
|
+
}));
|
|
255
|
+
}
|
|
256
|
+
if (verb === "starter-coverage") {
|
|
257
|
+
// The read-only pre-flip gate (same evaluation as the runbook's
|
|
258
|
+
// check-starter-coverage script). `ok: false` = do not flip the flag.
|
|
259
|
+
return unwrap(await api.client.GET("/admin/onboarding/starter-coverage"));
|
|
260
|
+
}
|
|
261
|
+
if (verb === "backfill-trackers") {
|
|
262
|
+
// WS-H over the wire. Without --apply this is the read-only census
|
|
263
|
+
// (dry-run POST, same precedent as `goal-templates apply --dry-run`).
|
|
264
|
+
// With --apply, the census is re-taken as the preview — the runbook's
|
|
265
|
+
// "fresh dry run on the identical predicate immediately before
|
|
266
|
+
// applying" — and the confirmed write re-derives every conjunct under a
|
|
267
|
+
// per-guardian lock server-side anyway.
|
|
268
|
+
if (!hasFlag(parsed, "apply")) {
|
|
269
|
+
return unwrap(await api.client.POST("/admin/onboarding/backfill-guardian-trackers", {
|
|
270
|
+
body: { apply: false },
|
|
271
|
+
}));
|
|
272
|
+
}
|
|
273
|
+
const census = unwrap(await api.client.POST("/admin/onboarding/backfill-guardian-trackers", {
|
|
274
|
+
body: { apply: false },
|
|
275
|
+
}));
|
|
276
|
+
return writeCommand(parsed, {
|
|
277
|
+
action: "apply the WS-H guardian-tracker backfill: stamp parentOnboardingCompletedAt for the arm-1 (crash-between-commits) school guardians below",
|
|
278
|
+
target: { repairableCount: census.repairable.length },
|
|
279
|
+
request: { apply: true },
|
|
280
|
+
details: {
|
|
281
|
+
freshCensus: census,
|
|
282
|
+
authority: "The census is a photograph; the server re-derives every conjunct under a per-guardian lock at write time and skips anyone who no longer qualifies. Exact-ADMIN only.",
|
|
283
|
+
},
|
|
284
|
+
}, async () => unwrap(await api.client.POST("/admin/onboarding/backfill-guardian-trackers", { body: { apply: true } })));
|
|
285
|
+
}
|
|
100
286
|
if (verb === "meetings") {
|
|
101
287
|
const familyId = positional(parsed, 2, "family ID");
|
|
102
288
|
return unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/meetings", {
|
package/dist/commands/school.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
1
4
|
import { unwrap } from "../api.js";
|
|
2
5
|
import { flagList, flagString, hasFlag } from "../args.js";
|
|
3
6
|
import { CliError } from "../errors.js";
|
|
@@ -9,6 +12,7 @@ const SCHOOL_TIERS = [
|
|
|
9
12
|
"complete",
|
|
10
13
|
"platform",
|
|
11
14
|
];
|
|
15
|
+
const PROGRAM_TYPES = ["PARTNER", "SCHOOL"];
|
|
12
16
|
const RESOLVE_ACTIONS = ["waive", "cancel"];
|
|
13
17
|
const RECONCILIATION_OUTCOMES = ["refunded", "kept", "written_off"];
|
|
14
18
|
/** post.start-memberships.ts caps the body at ten kids per call. */
|
|
@@ -16,6 +20,38 @@ const MEMBERSHIP_KID_LIMIT = 10;
|
|
|
16
20
|
function dollars(cents) {
|
|
17
21
|
return `$${(cents / 100).toFixed(2)}`;
|
|
18
22
|
}
|
|
23
|
+
/** The exact type set post.partner-logo-upload.ts accepts, keyed by extension. */
|
|
24
|
+
const LOGO_MIME_BY_EXT = {
|
|
25
|
+
".jpg": "image/jpeg",
|
|
26
|
+
".jpeg": "image/jpeg",
|
|
27
|
+
".png": "image/png",
|
|
28
|
+
".gif": "image/gif",
|
|
29
|
+
".webp": "image/webp",
|
|
30
|
+
".svg": "image/svg+xml",
|
|
31
|
+
};
|
|
32
|
+
async function readLogoFile(filePath) {
|
|
33
|
+
const absolutePath = path.resolve(filePath);
|
|
34
|
+
const mimeType = LOGO_MIME_BY_EXT[path.extname(absolutePath).toLowerCase()];
|
|
35
|
+
if (!mimeType) {
|
|
36
|
+
throw new CliError("invalid_arguments", "--file must be a .png, .jpg, .jpeg, .gif, .webp, or .svg image.");
|
|
37
|
+
}
|
|
38
|
+
let bytes;
|
|
39
|
+
try {
|
|
40
|
+
bytes = await fs.readFile(absolutePath);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
if (error.code === "ENOENT") {
|
|
44
|
+
throw new CliError("invalid_arguments", `Logo file does not exist: ${absolutePath}`);
|
|
45
|
+
}
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
bytes,
|
|
50
|
+
fileName: path.basename(absolutePath),
|
|
51
|
+
mimeType,
|
|
52
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
19
55
|
function kidName(firstName, lastName, fallback) {
|
|
20
56
|
return [firstName, lastName].filter(Boolean).join(" ") || fallback;
|
|
21
57
|
}
|
|
@@ -460,6 +496,232 @@ export async function runSchoolCommand({ parsed, api, writeCommand, }) {
|
|
|
460
496
|
}
|
|
461
497
|
throw new CliError("invalid_arguments", "Use school codes list|get|create|set-kids|replace|resend|revoke.");
|
|
462
498
|
}
|
|
499
|
+
if (verb === "create") {
|
|
500
|
+
const slug = flagString(parsed, "slug", { required: true });
|
|
501
|
+
const name = flagString(parsed, "name", { required: true });
|
|
502
|
+
const logoUrl = flagString(parsed, "logo-url");
|
|
503
|
+
const creditGrant = flagInteger(parsed, "credit-grant", {
|
|
504
|
+
required: true,
|
|
505
|
+
min: 0,
|
|
506
|
+
});
|
|
507
|
+
const programTypeRaw = flagString(parsed, "program-type");
|
|
508
|
+
const programType = programTypeRaw
|
|
509
|
+
? assertChoice(programTypeRaw, PROGRAM_TYPES, "--program-type")
|
|
510
|
+
: undefined;
|
|
511
|
+
const tokenTopUpCents = flagInteger(parsed, "token-topup-cents", {
|
|
512
|
+
min: 0,
|
|
513
|
+
max: 10_000_000,
|
|
514
|
+
});
|
|
515
|
+
if (programType === "SCHOOL" && tokenTopUpCents !== creditGrant) {
|
|
516
|
+
throw new CliError("invalid_arguments", "SCHOOL programs require --token-topup-cents equal to --credit-grant (the server enforces the same rule).");
|
|
517
|
+
}
|
|
518
|
+
const body = {
|
|
519
|
+
slug,
|
|
520
|
+
name,
|
|
521
|
+
defaultInitialCreditGrant: creditGrant,
|
|
522
|
+
...(logoUrl ? { logoUrl } : {}),
|
|
523
|
+
...(programType ? { programType } : {}),
|
|
524
|
+
...(tokenTopUpCents !== undefined
|
|
525
|
+
? { monthlyTokenTopUpCents: tokenTopUpCents }
|
|
526
|
+
: {}),
|
|
527
|
+
};
|
|
528
|
+
return writeCommand(parsed, {
|
|
529
|
+
action: `create the ${programType ?? "PARTNER"}-program institution "${name}" (${slug})`,
|
|
530
|
+
target: { slug },
|
|
531
|
+
request: body,
|
|
532
|
+
details: {
|
|
533
|
+
moneyLevers: {
|
|
534
|
+
defaultInitialCreditGrant: creditGrant,
|
|
535
|
+
monthlyTokenTopUpCents: tokenTopUpCents ?? null,
|
|
536
|
+
warning: "For SCHOOL programs the monthly target tops EVERY kid in the institution up to it on the next cron run — these numbers spend real money at scale.",
|
|
537
|
+
},
|
|
538
|
+
duplicateGuard: "A taken slug 409s; pick another rather than retrying.",
|
|
539
|
+
},
|
|
540
|
+
}, async () => unwrap(await api.client.POST("/admin/partner/", { body })));
|
|
541
|
+
}
|
|
542
|
+
if (verb === "update") {
|
|
543
|
+
const institutionId = positional(parsed, 2, "institution ID");
|
|
544
|
+
const slug = flagString(parsed, "slug");
|
|
545
|
+
const name = flagString(parsed, "name");
|
|
546
|
+
const logoUrl = flagString(parsed, "logo-url");
|
|
547
|
+
const creditGrant = flagInteger(parsed, "credit-grant", { min: 0 });
|
|
548
|
+
const programTypeRaw = flagString(parsed, "program-type");
|
|
549
|
+
const programType = programTypeRaw
|
|
550
|
+
? assertChoice(programTypeRaw, PROGRAM_TYPES, "--program-type")
|
|
551
|
+
: undefined;
|
|
552
|
+
const tokenTopUpRaw = flagString(parsed, "token-topup-cents");
|
|
553
|
+
const tokenTopUpCents = tokenTopUpRaw === undefined
|
|
554
|
+
? undefined
|
|
555
|
+
: tokenTopUpRaw === "none"
|
|
556
|
+
? null
|
|
557
|
+
: flagInteger(parsed, "token-topup-cents", {
|
|
558
|
+
min: 0,
|
|
559
|
+
max: 10_000_000,
|
|
560
|
+
});
|
|
561
|
+
const body = {
|
|
562
|
+
...(slug ? { slug } : {}),
|
|
563
|
+
...(name ? { name } : {}),
|
|
564
|
+
...(logoUrl ? { logoUrl } : {}),
|
|
565
|
+
...(creditGrant !== undefined
|
|
566
|
+
? { defaultInitialCreditGrant: creditGrant }
|
|
567
|
+
: {}),
|
|
568
|
+
...(programType ? { programType } : {}),
|
|
569
|
+
...(tokenTopUpCents !== undefined
|
|
570
|
+
? { monthlyTokenTopUpCents: tokenTopUpCents }
|
|
571
|
+
: {}),
|
|
572
|
+
};
|
|
573
|
+
if (Object.keys(body).length === 0) {
|
|
574
|
+
throw new CliError("invalid_arguments", "Pass at least one field to change (--slug, --name, --logo-url, --credit-grant, --program-type, --token-topup-cents).");
|
|
575
|
+
}
|
|
576
|
+
const touchesMoney = creditGrant !== undefined ||
|
|
577
|
+
tokenTopUpCents !== undefined ||
|
|
578
|
+
programType !== undefined;
|
|
579
|
+
return writeCommand(parsed, {
|
|
580
|
+
action: touchesMoney
|
|
581
|
+
? "edit this institution INCLUDING a money lever or the program type (exact-ADMIN only)"
|
|
582
|
+
: "edit this institution's descriptive metadata (name/slug/logo)",
|
|
583
|
+
target: { institutionId },
|
|
584
|
+
request: body,
|
|
585
|
+
details: {
|
|
586
|
+
...(touchesMoney
|
|
587
|
+
? {
|
|
588
|
+
moneyWarning: "defaultInitialCreditGrant / monthlyTokenTopUpCents / programType are money levers: the monthly target tops EVERY kid up to it on the next cron. A GUIDE or PROGRAM session gets 403 for these fields.",
|
|
589
|
+
}
|
|
590
|
+
: {}),
|
|
591
|
+
...(programType
|
|
592
|
+
? {
|
|
593
|
+
programTypeGuard: "A type flip is refused while families are attached or unused invite codes exist — resolve those first.",
|
|
594
|
+
}
|
|
595
|
+
: {}),
|
|
596
|
+
},
|
|
597
|
+
}, async () => unwrap(await api.client.PATCH("/admin/partner/{id}", {
|
|
598
|
+
params: { path: { id: institutionId } },
|
|
599
|
+
body,
|
|
600
|
+
})));
|
|
601
|
+
}
|
|
602
|
+
if (verb === "representatives") {
|
|
603
|
+
const subverb = parsed.positionals[2] ?? "";
|
|
604
|
+
if (subverb === "list") {
|
|
605
|
+
return unwrap(await api.client.GET("/admin/partner/{slug}/representatives", {
|
|
606
|
+
params: { path: { slug: requireSlug() } },
|
|
607
|
+
}));
|
|
608
|
+
}
|
|
609
|
+
if (subverb === "search") {
|
|
610
|
+
const q = flagString(parsed, "query", { required: true });
|
|
611
|
+
return unwrap(await api.client.GET("/admin/partner/representatives/search", {
|
|
612
|
+
params: { query: { q } },
|
|
613
|
+
}));
|
|
614
|
+
}
|
|
615
|
+
if (subverb === "add") {
|
|
616
|
+
const slug = requireSlug();
|
|
617
|
+
const userId = flagString(parsed, "user", { required: true });
|
|
618
|
+
// Resolve the exact person first so the approval names who gains the
|
|
619
|
+
// representative surface, not a bare id.
|
|
620
|
+
const { user } = unwrap(await api.client.GET("/admin/users/{userId}", {
|
|
621
|
+
params: { path: { userId } },
|
|
622
|
+
}));
|
|
623
|
+
return writeCommand(parsed, {
|
|
624
|
+
action: "add this GUIDE/ADMIN as a representative of the institution",
|
|
625
|
+
target: {
|
|
626
|
+
slug,
|
|
627
|
+
userId,
|
|
628
|
+
name: kidName(user.firstName, user.lastName, userId),
|
|
629
|
+
role: user.role,
|
|
630
|
+
},
|
|
631
|
+
request: { userId },
|
|
632
|
+
details: {
|
|
633
|
+
refusal: "Non-GUIDE/ADMIN users are refused by the server.",
|
|
634
|
+
},
|
|
635
|
+
}, async () => unwrap(await api.client.POST("/admin/partner/{slug}/representatives", {
|
|
636
|
+
params: { path: { slug } },
|
|
637
|
+
body: { userId },
|
|
638
|
+
})));
|
|
639
|
+
}
|
|
640
|
+
if (subverb === "remove") {
|
|
641
|
+
const slug = requireSlug();
|
|
642
|
+
const userId = flagString(parsed, "user", { required: true });
|
|
643
|
+
return writeCommand(parsed, {
|
|
644
|
+
action: "remove this representative from the institution",
|
|
645
|
+
target: { slug, userId },
|
|
646
|
+
request: {},
|
|
647
|
+
}, async () => unwrap(await api.client.DELETE("/admin/partner/{slug}/representatives/{userId}", { params: { path: { slug, userId } } })));
|
|
648
|
+
}
|
|
649
|
+
throw new CliError("invalid_arguments", "Use school representatives list|search|add|remove.");
|
|
650
|
+
}
|
|
651
|
+
if (verb === "credit-transactions") {
|
|
652
|
+
const slug = requireSlug();
|
|
653
|
+
const page = flagInteger(parsed, "page", { min: 0 });
|
|
654
|
+
const limit = flagInteger(parsed, "limit", { min: 1, max: 100 });
|
|
655
|
+
return unwrap(await api.client.GET("/admin/partner/{slug}/credit-transactions", {
|
|
656
|
+
params: {
|
|
657
|
+
path: { slug },
|
|
658
|
+
query: {
|
|
659
|
+
...(page !== undefined ? { page } : {}),
|
|
660
|
+
...(limit !== undefined ? { limit } : {}),
|
|
661
|
+
},
|
|
662
|
+
},
|
|
663
|
+
}));
|
|
664
|
+
}
|
|
665
|
+
if (verb === "kid-slots") {
|
|
666
|
+
const kidId = positional(parsed, 2, "kid ID");
|
|
667
|
+
const slug = requireSlug();
|
|
668
|
+
const slotsRaw = flagString(parsed, "slots", { required: true });
|
|
669
|
+
const concurrentClassSlots = slotsRaw === "unlimited"
|
|
670
|
+
? null
|
|
671
|
+
: flagInteger(parsed, "slots", { min: 0 });
|
|
672
|
+
const premiumClassSlots = flagInteger(parsed, "premium-slots", {
|
|
673
|
+
min: 0,
|
|
674
|
+
max: 20,
|
|
675
|
+
});
|
|
676
|
+
const body = {
|
|
677
|
+
concurrentClassSlots,
|
|
678
|
+
...(premiumClassSlots !== undefined ? { premiumClassSlots } : {}),
|
|
679
|
+
};
|
|
680
|
+
return writeCommand(parsed, {
|
|
681
|
+
action: "set this school kid's concurrent class slots directly (the raw slot override — `users tier set` is the tier-driven path)",
|
|
682
|
+
target: { kidId, slug },
|
|
683
|
+
request: body,
|
|
684
|
+
details: {
|
|
685
|
+
slots: concurrentClassSlots ?? "unlimited",
|
|
686
|
+
...(premiumClassSlots !== undefined
|
|
687
|
+
? { premiumSlots: premiumClassSlots }
|
|
688
|
+
: {}),
|
|
689
|
+
note: "SCHOOL-program kids only; the write re-checks membership under the family lock so it cannot race a concurrent revert.",
|
|
690
|
+
},
|
|
691
|
+
}, async () => unwrap(await api.client.PATCH("/admin/partner/{slug}/kids/{kidId}/slots", {
|
|
692
|
+
params: { path: { slug, kidId } },
|
|
693
|
+
body,
|
|
694
|
+
})));
|
|
695
|
+
}
|
|
696
|
+
if (verb === "partner-family") {
|
|
697
|
+
const familyId = positional(parsed, 2, "family ID");
|
|
698
|
+
const slug = requireSlug();
|
|
699
|
+
const enabledRaw = flagString(parsed, "enabled", { required: true });
|
|
700
|
+
const enabled = assertChoice(enabledRaw, ["true", "false"], "--enabled") ===
|
|
701
|
+
"true";
|
|
702
|
+
return writeCommand(parsed, {
|
|
703
|
+
action: enabled
|
|
704
|
+
? "attach this family to the PARTNER institution"
|
|
705
|
+
: "detach this family from the PARTNER institution",
|
|
706
|
+
target: { familyId, slug },
|
|
707
|
+
request: { enabled },
|
|
708
|
+
details: {
|
|
709
|
+
scope: "PARTNER programs only — the server refuses SCHOOL institutions here, because school families carry entitlements this generic toggle cannot safely provision or remove (use `school convert`/`school revert`).",
|
|
710
|
+
},
|
|
711
|
+
}, async () => unwrap(await api.client.PATCH("/admin/partner/{slug}/families/{familyId}", {
|
|
712
|
+
params: { path: { slug, familyId } },
|
|
713
|
+
body: { enabled },
|
|
714
|
+
})));
|
|
715
|
+
}
|
|
716
|
+
if (verb === "logo-upload") {
|
|
717
|
+
const filePath = flagString(parsed, "file", { required: true });
|
|
718
|
+
const { bytes, fileName, mimeType, sha256 } = await readLogoFile(filePath);
|
|
719
|
+
return writeCommand(parsed, {
|
|
720
|
+
action: "upload this image to the assets CDN as a partner-institution logo (returns a URL for `school create/update --logo-url`)",
|
|
721
|
+
target: { fileName },
|
|
722
|
+
request: { fileName, mimeType, bytes: bytes.byteLength, sha256 },
|
|
723
|
+
}, async () => api.uploadPartnerLogo(bytes, fileName, mimeType));
|
|
724
|
+
}
|
|
463
725
|
throw new CliError("invalid_arguments", "Unknown school command. Run `recess school --help` for the current command list.");
|
|
464
726
|
}
|
|
465
727
|
//# sourceMappingURL=school.js.map
|
package/dist/help.js
CHANGED
|
@@ -133,6 +133,11 @@ Usage:
|
|
|
133
133
|
[--limit N] [--stage-filter all|scheduled|oriented|course|converted|lost]
|
|
134
134
|
recess [--json] onboarding timeline <family-id>
|
|
135
135
|
recess [--json] onboarding readiness <family-id>
|
|
136
|
+
recess [--json] onboarding family <family-id>
|
|
137
|
+
recess [--json] onboarding next <family-id>
|
|
138
|
+
recess [--json] onboarding doctor [--family <family-id>]
|
|
139
|
+
recess [--json] onboarding starter-coverage
|
|
140
|
+
recess [--json] onboarding backfill-trackers [--apply] [--confirm]
|
|
136
141
|
recess [--json] onboarding active-tutors <family-id>
|
|
137
142
|
recess [--json] onboarding meetings <family-id>
|
|
138
143
|
recess [--json] onboarding cohort-options <family-id> [--kid <kid-id>]
|
|
@@ -223,6 +228,24 @@ Usage:
|
|
|
223
228
|
--data-file <invite.json> [--confirm]
|
|
224
229
|
recess [--json] school codes resend <code-id> --school <institution-slug> [--confirm]
|
|
225
230
|
recess [--json] school codes revoke <code-id> --school <institution-slug> [--confirm]
|
|
231
|
+
recess [--json] school create --slug <slug> --name TEXT --credit-grant N
|
|
232
|
+
[--logo-url URL] [--program-type PARTNER|SCHOOL] [--token-topup-cents N] [--confirm]
|
|
233
|
+
recess [--json] school update <institution-id> [--slug <slug>] [--name TEXT]
|
|
234
|
+
[--logo-url URL] [--credit-grant N] [--program-type PARTNER|SCHOOL]
|
|
235
|
+
[--token-topup-cents N] [--confirm]
|
|
236
|
+
recess [--json] school representatives list --school <institution-slug>
|
|
237
|
+
recess [--json] school representatives search --query TEXT
|
|
238
|
+
recess [--json] school representatives add --school <institution-slug>
|
|
239
|
+
--user <user-id> [--confirm]
|
|
240
|
+
recess [--json] school representatives remove --school <institution-slug>
|
|
241
|
+
--user <user-id> [--confirm]
|
|
242
|
+
recess [--json] school credit-transactions --school <institution-slug>
|
|
243
|
+
[--page 0] [--limit 50]
|
|
244
|
+
recess [--json] school kid-slots <kid-id> --school <institution-slug>
|
|
245
|
+
--slots N [--premium-slots N] [--confirm]
|
|
246
|
+
recess [--json] school partner-family <family-id> --school <institution-slug>
|
|
247
|
+
--enabled true|false [--confirm]
|
|
248
|
+
recess [--json] school logo-upload --file </path/logo.png> [--confirm]
|
|
226
249
|
recess [--json] village models list [--world village-1] [--query TEXT] [--archived]
|
|
227
250
|
recess [--json] village models upload --file </path/model.glb>
|
|
228
251
|
[--world village-1] [--name TEXT] [--id ID] [--description TEXT] [--tags A,B]
|
|
@@ -309,6 +332,10 @@ Usage:
|
|
|
309
332
|
[--confirm --approval-token TOKEN]
|
|
310
333
|
recess [--json] goals complete <goal-id> [--confirm]
|
|
311
334
|
recess [--json] goals undo-completion <goal-id> [--confirm]
|
|
335
|
+
recess [--json] goals archive <goal-id> --student <kid-id>
|
|
336
|
+
[--confirm --approval-token TOKEN]
|
|
337
|
+
recess [--json] goals unarchive <goal-id> --student <kid-id>
|
|
338
|
+
[--confirm --approval-token TOKEN]
|
|
312
339
|
recess [--json] goals queue get <goal-id> --student <kid-id>
|
|
313
340
|
recess [--json] goals queue set <goal-id> --student <kid-id>
|
|
314
341
|
--entries-file <path.json> --delta TEXT [--replace-description-pointer]
|
|
@@ -366,6 +393,26 @@ the backend's own dryRun before the gate and previews the per-student outcome.
|
|
|
366
393
|
409s STALE_WRITE and writes nothing. The spec is unreachable from "set-metadata"
|
|
367
394
|
by design — an existing spec is edited only through the guarded /ai patch path.
|
|
368
395
|
|
|
396
|
+
School-onboarding workflow notes: "onboarding family" is the one-screen view
|
|
397
|
+
(status + readiness + next action + intake session + active tutors, in
|
|
398
|
+
parallel; flag-gated pieces degrade to a labeled "unavailable"). "onboarding
|
|
399
|
+
next" returns the mission-control queue's current action for the family PLUS
|
|
400
|
+
suggestedCommands — the exact commands that perform it, ids substituted.
|
|
401
|
+
"onboarding doctor" answers "who actually sees the school-onboarding surface
|
|
402
|
+
and why": the env kill-switches (SCHOOL_ONBOARDING_V1_FORCE,
|
|
403
|
+
SCHOOL_ONBOARDING_CUTOVER), comms master switch + mode, the acting staffer's
|
|
404
|
+
flag evaluation, and per-guardian/per-kid flag, capability-lock, and
|
|
405
|
+
cohort-gate state; env values are the answering service's only — the Worker
|
|
406
|
+
can differ. "onboarding starter-coverage" is the read-only pre-flip gate
|
|
407
|
+
(ok:false = do not flip the flag). "onboarding backfill-trackers" is the WS-H
|
|
408
|
+
census (read-only); --apply re-takes the census as the preview and stamps only
|
|
409
|
+
the arm-1 guardians, exact-ADMIN, re-derived under a per-guardian lock
|
|
410
|
+
server-side. "school create/update" carry money levers
|
|
411
|
+
(--credit-grant/--token-topup-cents/--program-type spend real money via the
|
|
412
|
+
monthly top-up cron and are exact-ADMIN on update); descriptive edits
|
|
413
|
+
(name/slug/logo) are ordinary staff writes. "school kid-slots" is the raw
|
|
414
|
+
slot override; "users tier set" is the tier-driven path.
|
|
415
|
+
|
|
369
416
|
Onboarding notes: "status" and "intake-session" are reads — "intake-session"
|
|
370
417
|
looks up the current IN_PROGRESS session without creating one (prints a "none
|
|
371
418
|
yet" result when absent). "intake-session-create" is the explicit write that
|
|
@@ -392,9 +439,11 @@ endDate minus one day) and shows the computed date in the preview.
|
|
|
392
439
|
Auth notes: "auth login" runs the browser loopback flow for ADMIN, GUIDE, a GUARDIAN with
|
|
393
440
|
access:ai, or a KID using only Village home building. Guardian sessions are family-scoped and
|
|
394
441
|
cannot call /admin; KID sessions cannot call any non-Village API command. For a headless
|
|
395
|
-
cloud agent
|
|
396
|
-
|
|
397
|
-
|
|
442
|
+
cloud agent that has no local browser to open, "auth request" prints an approval URL; whoever
|
|
443
|
+
opens it and approves in a signed-in web session grants THEIR OWN scope, so a guardian approving
|
|
444
|
+
mints a family-scoped session and only an admin can mint a full-admin one. "auth poll" then
|
|
445
|
+
collects the 12h session. Kids cannot approve. When it lapses, run "auth request" again for a
|
|
446
|
+
fresh link. Both paths yield the same session.
|
|
398
447
|
|
|
399
448
|
Skill notes: this CLI's own agent skill ships inside the npm package AND is served
|
|
400
449
|
by the server, so wording/Gotcha updates arrive without an npm release. "setup"
|
package/dist/http.js
CHANGED
|
@@ -3,6 +3,44 @@ export const RECESS_CLIENT_HEADER = "x-recess-client";
|
|
|
3
3
|
export const RECESS_CLIENT_CLI = "cli";
|
|
4
4
|
export const RECESS_CLIENT_CLI_UI = "cli-ui";
|
|
5
5
|
export const RECESS_REASON_HEADER = "x-recess-reason";
|
|
6
|
+
/**
|
|
7
|
+
* Set alongside the reason when it had to be percent-encoded to survive the
|
|
8
|
+
* header. The server decodes only when this is present, so a literal `%` in an
|
|
9
|
+
* all-ASCII reason is never mangled.
|
|
10
|
+
*/
|
|
11
|
+
export const RECESS_REASON_ENCODING_HEADER = "x-recess-reason-encoding";
|
|
12
|
+
export const RECESS_REASON_ENCODING_UTF8 = "utf-8-percent";
|
|
13
|
+
const ASCII_PRINTABLE = /^[\x20-\x7E]*$/;
|
|
14
|
+
/**
|
|
15
|
+
* Make a human-written reason safe to put in an HTTP header.
|
|
16
|
+
*
|
|
17
|
+
* Headers are ByteStrings: any code point above U+00FF throws
|
|
18
|
+
* "Cannot convert argument to a ByteString because the character at index N
|
|
19
|
+
* has a value of 8212" — 8212 being an em dash. The error names no field, so
|
|
20
|
+
* the failure looks like a bug in whatever command you happened to run, and
|
|
21
|
+
* typing an em dash or a curly quote in `--reason` is completely ordinary.
|
|
22
|
+
*
|
|
23
|
+
* Common typography is folded to its ASCII equivalent so the audit log stays
|
|
24
|
+
* readable; anything still non-ASCII (accented names, CJK, emoji) is
|
|
25
|
+
* UTF-8 percent-encoded and flagged for the server to decode, which is lossless.
|
|
26
|
+
*/
|
|
27
|
+
export function encodeReasonHeader(reason) {
|
|
28
|
+
const folded = reason
|
|
29
|
+
// Typography first, so the audit log keeps a readable reason instead of a
|
|
30
|
+
// percent-escaped one. These are what people actually type.
|
|
31
|
+
.replace(/[\u2010-\u2015\u2212]/g, "-") // hyphens, en/em dashes, minus
|
|
32
|
+
.replace(/[\u2018\u2019\u201A\u201B]/g, "'")
|
|
33
|
+
.replace(/[\u201C\u201D\u201E\u201F]/g, '"')
|
|
34
|
+
.replace(/\u2026/g, "...")
|
|
35
|
+
.replace(/[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g, " ")
|
|
36
|
+
.replace(/[\u200B-\u200D\uFEFF]/g, "");
|
|
37
|
+
if (ASCII_PRINTABLE.test(folded))
|
|
38
|
+
return { value: folded };
|
|
39
|
+
return {
|
|
40
|
+
value: encodeURIComponent(folded),
|
|
41
|
+
encoding: RECESS_REASON_ENCODING_UTF8,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
6
44
|
export function requireCliRequestReason(value) {
|
|
7
45
|
const reason = value?.trim();
|
|
8
46
|
if (!reason) {
|
|
@@ -15,8 +53,13 @@ export function requireCliRequestReason(value) {
|
|
|
15
53
|
}
|
|
16
54
|
export function markCliRequest(headers, reason, client = RECESS_CLIENT_CLI) {
|
|
17
55
|
headers.set(RECESS_CLIENT_HEADER, client);
|
|
18
|
-
if (reason)
|
|
19
|
-
|
|
56
|
+
if (reason) {
|
|
57
|
+
const encoded = encodeReasonHeader(reason);
|
|
58
|
+
headers.set(RECESS_REASON_HEADER, encoded.value);
|
|
59
|
+
if (encoded.encoding) {
|
|
60
|
+
headers.set(RECESS_REASON_ENCODING_HEADER, encoded.encoding);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
20
63
|
return headers;
|
|
21
64
|
}
|
|
22
65
|
export function cliRequestHeaders(init, reason, client = RECESS_CLIENT_CLI) {
|
package/package.json
CHANGED
|
@@ -38,7 +38,9 @@ For machine-readable discovery, use `recess --json agent-context`; for a smaller
|
|
|
38
38
|
## Authentication
|
|
39
39
|
|
|
40
40
|
- Workstation: `recess --json auth login` opens Recess SSO.
|
|
41
|
-
- Headless
|
|
41
|
+
- Headless (no browser): `auth request`, then a human approves the printed URL while signed in to
|
|
42
|
+
Recess, then `auth poll`. The session takes on the **approver's** scope — a guardian approving
|
|
43
|
+
grants family-only access, not staff access. Kids cannot approve.
|
|
42
44
|
- Sessions last 12 hours and are rechecked against the live user role and permissions.
|
|
43
45
|
- `auth status` inspects the current session; `auth logout` clears the stored session.
|
|
44
46
|
- The default API is production. If `RECESS_CLI_API_ORIGIN` is set, state the non-default origin before acting.
|