recess-cli 2.1.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 +206 -8
- package/dist/command-schema.js +1 -0
- package/dist/commands/onboarding.js +360 -0
- package/dist/commands/school.js +727 -0
- package/dist/help.js +93 -5
- 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
|
@@ -37,6 +37,86 @@ const LIFECYCLE_PROMPT_KINDS = [
|
|
|
37
37
|
"TWO_WEEK_CHECK_IN",
|
|
38
38
|
"TWO_MONTH_TESTIMONIAL",
|
|
39
39
|
];
|
|
40
|
+
// device-pairing.ts PAIRING_TTL_MS — the code dies 5 minutes after it is
|
|
41
|
+
// minted, so the confirm step is what starts the clock, not the preview.
|
|
42
|
+
const PAIRING_CODE_TTL_MINUTES = 5;
|
|
43
|
+
function personName(firstName, lastName, fallback) {
|
|
44
|
+
return [firstName, lastName].filter(Boolean).join(" ") || fallback;
|
|
45
|
+
}
|
|
46
|
+
function summaryPreview(text, max = 600) {
|
|
47
|
+
if (!text)
|
|
48
|
+
return null;
|
|
49
|
+
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
|
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
|
+
}
|
|
40
120
|
export async function runOnboardingCommand({ parsed, api, writeCommand, }) {
|
|
41
121
|
const noun = parsed.positionals[0] ?? "";
|
|
42
122
|
const verb = parsed.positionals[1] ?? "";
|
|
@@ -86,6 +166,123 @@ export async function runOnboardingCommand({ parsed, api, writeCommand, }) {
|
|
|
86
166
|
const familyId = positional(parsed, 2, "family ID");
|
|
87
167
|
return unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/active-tutors", { params: { path: { familyId } } }));
|
|
88
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
|
+
}
|
|
89
286
|
if (verb === "meetings") {
|
|
90
287
|
const familyId = positional(parsed, 2, "family ID");
|
|
91
288
|
return unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/meetings", {
|
|
@@ -156,6 +353,26 @@ export async function runOnboardingCommand({ parsed, api, writeCommand, }) {
|
|
|
156
353
|
throw error;
|
|
157
354
|
}
|
|
158
355
|
}
|
|
356
|
+
if (verb === "reviews") {
|
|
357
|
+
const familyId = positional(parsed, 2, "family ID");
|
|
358
|
+
// The family workspace's review card: the handoff session(s) for this
|
|
359
|
+
// family. get.reviews.ts binds the family scope to the canonical (oldest)
|
|
360
|
+
// guardian — the same one the intake commands operate on — so a
|
|
361
|
+
// two-guardian family never surfaces the other guardian's session.
|
|
362
|
+
// `guardianMissing: true` with an empty list means the family has no
|
|
363
|
+
// guardian at all, not that no session was ever started.
|
|
364
|
+
return unwrap(await api.client.GET("/ai/onboarding/reviews", {
|
|
365
|
+
params: { query: { familyId } },
|
|
366
|
+
}));
|
|
367
|
+
}
|
|
368
|
+
if (verb === "review") {
|
|
369
|
+
const sessionId = positional(parsed, 2, "onboarding session ID");
|
|
370
|
+
// The full handoff record behind the workspace's handoff page: summary,
|
|
371
|
+
// collected data, artifacts, progress. Read this before mark-reviewed.
|
|
372
|
+
return unwrap(await api.client.GET("/ai/onboarding/reviews/{id}", {
|
|
373
|
+
params: { path: { id: sessionId } },
|
|
374
|
+
}));
|
|
375
|
+
}
|
|
159
376
|
if (verb === "intake-session-create") {
|
|
160
377
|
const familyId = positional(parsed, 2, "family ID");
|
|
161
378
|
// The explicit create: get-or-create the intake session. Mints a blank
|
|
@@ -616,6 +833,149 @@ export async function runOnboardingCommand({ parsed, api, writeCommand, }) {
|
|
|
616
833
|
request: previewRequest,
|
|
617
834
|
}, async () => unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/intake-extract", { params: { path: { familyId } }, body })));
|
|
618
835
|
}
|
|
836
|
+
if (verb === "contracts") {
|
|
837
|
+
const kidUserId = flagString(parsed, "kid");
|
|
838
|
+
// The route takes no filter: it returns the newest 100 contracts across
|
|
839
|
+
// ALL families (get.contracts.ts), so --kid narrows client-side and a
|
|
840
|
+
// contract older than that window is simply not in the window.
|
|
841
|
+
const result = unwrap(await api.client.GET("/admin/enrollment-contracts/"));
|
|
842
|
+
if (!kidUserId)
|
|
843
|
+
return result;
|
|
844
|
+
return {
|
|
845
|
+
...result,
|
|
846
|
+
contracts: result.contracts.filter((contract) => contract.studentUserId === kidUserId),
|
|
847
|
+
scope: "newest 100 contracts across all families, filtered by --kid",
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
if (verb === "send-contract") {
|
|
851
|
+
const kidUserId = positional(parsed, 2, "kid user ID");
|
|
852
|
+
const tuitionCents = flagInteger(parsed, "tuition-cents", { min: 0 });
|
|
853
|
+
const parentEmail = flagString(parsed, "parent-email");
|
|
854
|
+
const partnerName = flagString(parsed, "partner-name");
|
|
855
|
+
const partnerEmail = flagString(parsed, "partner-email");
|
|
856
|
+
// Read-only preflight. Three things the approver must see before a legal
|
|
857
|
+
// document goes out under someone's name: that this id is the intended
|
|
858
|
+
// KID, that a live contract is not already out with the parent (the
|
|
859
|
+
// workspace hides its button in that case — here --resend is the explicit
|
|
860
|
+
// override), and who the signature request will actually reach.
|
|
861
|
+
const { user } = unwrap(await api.client.GET("/admin/users/{userId}", {
|
|
862
|
+
params: { path: { userId: kidUserId } },
|
|
863
|
+
}));
|
|
864
|
+
if (user.role !== "KID") {
|
|
865
|
+
throw new CliError("invalid_arguments", `User ${kidUserId} has role ${user.role}; an enrollment contract is sent for a kid.`);
|
|
866
|
+
}
|
|
867
|
+
if (!user.familyId) {
|
|
868
|
+
throw new CliError("invalid_arguments", `Kid ${kidUserId} has no family, so there is no guardian to sign.`);
|
|
869
|
+
}
|
|
870
|
+
const familyId = user.familyId;
|
|
871
|
+
const [contracts, status] = await Promise.all([
|
|
872
|
+
unwrap(await api.client.GET("/admin/enrollment-contracts/")),
|
|
873
|
+
unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/status", {
|
|
874
|
+
params: { path: { familyId } },
|
|
875
|
+
})),
|
|
876
|
+
]);
|
|
877
|
+
const live = contracts.contracts.find((contract) => contract.studentUserId === kidUserId &&
|
|
878
|
+
contract.status !== "DECLINED" &&
|
|
879
|
+
contract.status !== "VOIDED");
|
|
880
|
+
if (live && !hasFlag(parsed, "resend")) {
|
|
881
|
+
throw new CliError("invalid_arguments", `Contract ${live.id} for this kid is already ${live.status} (sent ${live.sentAt}). Sending again originates a SECOND Documenso document to the parent — pass --resend if that is what you mean.`);
|
|
882
|
+
}
|
|
883
|
+
const body = {
|
|
884
|
+
studentUserId: kidUserId,
|
|
885
|
+
...(tuitionCents !== undefined ? { tuitionCents } : {}),
|
|
886
|
+
...(parentEmail ? { parentEmail } : {}),
|
|
887
|
+
...(partnerName ? { partnerName } : {}),
|
|
888
|
+
...(partnerEmail ? { partnerEmail } : {}),
|
|
889
|
+
};
|
|
890
|
+
return writeCommand(parsed, {
|
|
891
|
+
action: "originate the two-signer WonderED enrollment contract (partner signs first, then the parent) and email the signature request",
|
|
892
|
+
target: { kidUserId, familyId, familyName: status.familyName },
|
|
893
|
+
request: body,
|
|
894
|
+
details: {
|
|
895
|
+
student: personName(user.firstName, user.lastName, kidUserId),
|
|
896
|
+
// The send resolves the family's OLDEST guardian; this is the
|
|
897
|
+
// family's predicted parent email (the oldest EMAIL-BEARING
|
|
898
|
+
// guardian), so the two can differ when the oldest guardian has no
|
|
899
|
+
// email — which is the case the route 400s on. --parent-email pins
|
|
900
|
+
// the signer either way.
|
|
901
|
+
parentEmailPredicted: status.welcome.recipientEmail,
|
|
902
|
+
...(parentEmail ? { parentEmailOverride: parentEmail } : {}),
|
|
903
|
+
tuition: tuitionCents === undefined
|
|
904
|
+
? "resolved server-side from the family's newest ACCEPTED quote (the send 400s when no accepted quote covers this kid) — pass --tuition-cents to pin the amount in this approval"
|
|
905
|
+
: `$${(tuitionCents / 100).toFixed(2)}`,
|
|
906
|
+
partnerSigner: partnerName || partnerEmail
|
|
907
|
+
? { partnerName, partnerEmail }
|
|
908
|
+
: "the WONDERED_SIGNATORY_* env pair",
|
|
909
|
+
...(live
|
|
910
|
+
? {
|
|
911
|
+
resend: `Overriding live contract ${live.id} (${live.status}); this creates a SECOND document, it does not replace the first.`,
|
|
912
|
+
}
|
|
913
|
+
: {}),
|
|
914
|
+
note: "Contracts normally auto-send when onboarding finalizes (enrollment-contract-autosend.ts). This command is for families priced after the fact, or whose auto-send was skipped for want of an accepted quote.",
|
|
915
|
+
},
|
|
916
|
+
}, async () => unwrap(await api.client.POST("/admin/enrollment-contracts/send/", { body })));
|
|
917
|
+
}
|
|
918
|
+
if (verb === "mark-reviewed") {
|
|
919
|
+
const sessionId = positional(parsed, 2, "onboarding session ID");
|
|
920
|
+
// Read-only preflight: the human approving this signs their name to a
|
|
921
|
+
// specific family's handoff, so the preview must carry the summary they
|
|
922
|
+
// are attesting to — and post.review.ts accepts ONLY HANDED_OFF, so
|
|
923
|
+
// refuse the states it would reject before the gate, not after approval.
|
|
924
|
+
const session = unwrap(await api.client.GET("/ai/onboarding/reviews/{id}", {
|
|
925
|
+
params: { path: { id: sessionId } },
|
|
926
|
+
}));
|
|
927
|
+
if (session.status !== "HANDED_OFF" && session.status !== "REVIEWED") {
|
|
928
|
+
throw new CliError("invalid_arguments", `Onboarding session ${sessionId} is ${session.status}; only a HANDED_OFF session can be marked reviewed. A COMPLETED session still needs the parent confirmation that creates the kid accounts.`);
|
|
929
|
+
}
|
|
930
|
+
const reviewer = session.reviewer
|
|
931
|
+
? personName(session.reviewer.firstName, session.reviewer.lastName, session.reviewer.id)
|
|
932
|
+
: null;
|
|
933
|
+
return writeCommand(parsed, {
|
|
934
|
+
action: "mark the parent onboarding handoff as reviewed by you",
|
|
935
|
+
target: { sessionId, familyId: session.familyId },
|
|
936
|
+
request: {},
|
|
937
|
+
details: {
|
|
938
|
+
status: session.status,
|
|
939
|
+
parent: personName(session.parent.firstName, session.parent.lastName, session.parent.email ?? session.parent.id),
|
|
940
|
+
handoffSentAt: session.handoffSentAt,
|
|
941
|
+
// The stamp attests to THIS text; show it with the approval ask.
|
|
942
|
+
handoffSummary: summaryPreview(session.handoffSummary),
|
|
943
|
+
fullRecord: `recess --json onboarding review ${sessionId}`,
|
|
944
|
+
...(session.status === "REVIEWED"
|
|
945
|
+
? {
|
|
946
|
+
note: `Already reviewed by ${reviewer ?? "another admin"} at ${session.handoffReviewedAt ?? "an unrecorded time"}. Confirming is a first-reviewer-wins no-op that preserves that attribution.`,
|
|
947
|
+
}
|
|
948
|
+
: {}),
|
|
949
|
+
},
|
|
950
|
+
}, async () => unwrap(await api.client.POST("/ai/onboarding/reviews/{id}", {
|
|
951
|
+
params: { path: { id: sessionId } },
|
|
952
|
+
})));
|
|
953
|
+
}
|
|
954
|
+
if (verb === "pairing-code") {
|
|
955
|
+
const kidUserId = positional(parsed, 2, "kid user ID");
|
|
956
|
+
// Read-only preflight: name the exact kid whose account this code signs
|
|
957
|
+
// in, and refuse a non-KID before the gate (the backend enforces the
|
|
958
|
+
// same rule, and an admin can pair ANY kid — there is no family fence to
|
|
959
|
+
// catch a mistyped id).
|
|
960
|
+
const { user } = unwrap(await api.client.GET("/admin/users/{userId}", {
|
|
961
|
+
params: { path: { userId: kidUserId } },
|
|
962
|
+
}));
|
|
963
|
+
if (user.role !== "KID") {
|
|
964
|
+
throw new CliError("invalid_arguments", `User ${kidUserId} has role ${user.role}; pairing codes can only be issued for kids.`);
|
|
965
|
+
}
|
|
966
|
+
return writeCommand(parsed, {
|
|
967
|
+
action: "issue a single-use device-pairing code that signs this kid into a new device",
|
|
968
|
+
target: { kidUserId, familyId: user.familyId },
|
|
969
|
+
request: { kidUserId },
|
|
970
|
+
details: {
|
|
971
|
+
kid: personName(user.firstName, user.lastName, kidUserId),
|
|
972
|
+
expiresInMinutes: PAIRING_CODE_TTL_MINUTES,
|
|
973
|
+
credential: "The result is a live sign-in credential (token, shortCode, pairingUrl). Read it to the family for the device in front of them; never paste it into a ticket, log, or shared channel.",
|
|
974
|
+
},
|
|
975
|
+
}, async () => unwrap(await api.client.POST("/auth/device-pairing/generate/", {
|
|
976
|
+
body: { kidUserId },
|
|
977
|
+
})));
|
|
978
|
+
}
|
|
619
979
|
throw new CliError("invalid_arguments", "Unknown onboarding command. Run `recess onboarding --help` for the current command list.");
|
|
620
980
|
}
|
|
621
981
|
throw new CliError("invalid_arguments", "Unknown onboarding command.");
|