recess-cli 3.4.1 → 3.5.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/dist/api.js +17 -0
- package/dist/cli.js +78 -4
- package/dist/command-schema.js +5 -0
- package/dist/commands/channels.js +271 -0
- package/dist/commands/families.js +39 -0
- package/dist/commands/rooms.js +70 -0
- package/dist/help.js +35 -0
- package/package.json +1 -1
package/dist/api.js
CHANGED
|
@@ -167,6 +167,23 @@ export class RecessAdminApi {
|
|
|
167
167
|
throw apiError(response.status, body);
|
|
168
168
|
return body;
|
|
169
169
|
}
|
|
170
|
+
async uploadChannelImage(channelId, image, fileName, mimeType) {
|
|
171
|
+
this.requireAuth();
|
|
172
|
+
const formData = new FormData();
|
|
173
|
+
const arrayBuffer = image.buffer.slice(image.byteOffset, image.byteOffset + image.byteLength);
|
|
174
|
+
formData.append("file", new Blob([arrayBuffer], { type: mimeType }), fileName);
|
|
175
|
+
const headers = cliRequestHeaders({ cookie: this.config.sessionCookie }, this.reason, this.clientTag);
|
|
176
|
+
applyIdempotencyHeaders(headers);
|
|
177
|
+
const response = await fetch(new URL(`/admin/channels/${encodeURIComponent(channelId)}/image`, this.config.apiOrigin), {
|
|
178
|
+
method: "POST",
|
|
179
|
+
headers,
|
|
180
|
+
body: formData,
|
|
181
|
+
});
|
|
182
|
+
const body = await readResponseBody(response);
|
|
183
|
+
if (!response.ok)
|
|
184
|
+
throw apiError(response.status, body);
|
|
185
|
+
return body;
|
|
186
|
+
}
|
|
170
187
|
async uploadMapTestScores(studentId, pdf, fileName) {
|
|
171
188
|
this.requireAuth();
|
|
172
189
|
const formData = new FormData();
|
package/dist/cli.js
CHANGED
|
@@ -10,7 +10,10 @@ import { agentContext, buildCommandSchema, findCommandSchema, remoteCommands, sc
|
|
|
10
10
|
import { runApplicationsCommand } from "./commands/applications.js";
|
|
11
11
|
import { runAppsCommand } from "./commands/apps.js";
|
|
12
12
|
import { readAssignmentSelection, runGoalCurriculumCommand, } from "./commands/goal-curriculum.js";
|
|
13
|
+
import { runChannelsCommand } from "./commands/channels.js";
|
|
13
14
|
import { runChatLogsCommand } from "./commands/chat-logs.js";
|
|
15
|
+
import { runRoomsCommand } from "./commands/rooms.js";
|
|
16
|
+
import { runFamiliesCommand } from "./commands/families.js";
|
|
14
17
|
import { runMasteryCommand } from "./commands/mastery.js";
|
|
15
18
|
import { runOnboardingCommand } from "./commands/onboarding.js";
|
|
16
19
|
import { runScheduleCommand, runMemoryFilesCommand, } from "./commands/safe-staff-operations.js";
|
|
@@ -308,6 +311,7 @@ async function localWriteCommand(parsed, preview, execute) {
|
|
|
308
311
|
const GOAL_WORKSPACE_WRITE_MAX_FILES = 1_000;
|
|
309
312
|
const GOAL_WORKSPACE_WRITE_MAX_TOTAL_BYTES = 20 * 1024 * 1024;
|
|
310
313
|
const GOAL_PDF_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024;
|
|
314
|
+
const GOAL_VIDEO_UPLOAD_MAX_BYTES = 500 * 1024 * 1024;
|
|
311
315
|
const GOAL_QUEUE_MAX_ENTRIES = 500;
|
|
312
316
|
const URL_QUEUE_DESCRIPTION_POINTER = "Skill queue managed by the system.";
|
|
313
317
|
const RECESS_GIT_METADATA = "recess-workspace.json";
|
|
@@ -4340,7 +4344,7 @@ export async function executeRecessCommand(argv, options = {}) {
|
|
|
4340
4344
|
}));
|
|
4341
4345
|
const prompt = flagString(parsed, "prompt");
|
|
4342
4346
|
return writeCommand(parsed, {
|
|
4343
|
-
action: "queue paid
|
|
4347
|
+
action: "queue paid challenge-art regeneration",
|
|
4344
4348
|
target: {
|
|
4345
4349
|
templateId: id,
|
|
4346
4350
|
slug: current.slug,
|
|
@@ -4348,7 +4352,7 @@ export async function executeRecessCommand(argv, options = {}) {
|
|
|
4348
4352
|
},
|
|
4349
4353
|
request: { prompt: prompt ?? null },
|
|
4350
4354
|
details: {
|
|
4351
|
-
costNote: "This queues one paid 1024×1024
|
|
4355
|
+
costNote: "This queues one paid 1024×1024 challenge-art painting (the shared lane behind /challenges-v2, from the category's style reference; ~$0.02) and replaces the template image when it finishes. New templates already generate art automatically.",
|
|
4352
4356
|
},
|
|
4353
4357
|
}, async () => unwrap(await api.client.POST("/ai/goal-templates/{id}/generate-image", {
|
|
4354
4358
|
params: { path: { id } },
|
|
@@ -4582,7 +4586,10 @@ export async function executeRecessCommand(argv, options = {}) {
|
|
|
4582
4586
|
}
|
|
4583
4587
|
throw new CliError("invalid_arguments", "Use goals adaptations list|diff.");
|
|
4584
4588
|
}
|
|
4585
|
-
if (noun === "goals" &&
|
|
4589
|
+
if (noun === "goals" &&
|
|
4590
|
+
verb !== "files" &&
|
|
4591
|
+
verb !== "pdf" &&
|
|
4592
|
+
verb !== "video") {
|
|
4586
4593
|
if (verb === "complete" || verb === "undo-completion") {
|
|
4587
4594
|
const goalId = positional(parsed, 2, "goal ID");
|
|
4588
4595
|
const undo = verb === "undo-completion";
|
|
@@ -5074,11 +5081,20 @@ export async function executeRecessCommand(argv, options = {}) {
|
|
|
5074
5081
|
}
|
|
5075
5082
|
throw new CliError("invalid_arguments", "Use goals queue get|set.");
|
|
5076
5083
|
}
|
|
5077
|
-
throw new CliError("invalid_arguments", "Use goals list|create|edit|delete|restore|archive|unarchive|complete|undo-completion|move-module|queue|files|pdf.");
|
|
5084
|
+
throw new CliError("invalid_arguments", "Use goals list|create|edit|delete|restore|archive|unarchive|complete|undo-completion|move-module|queue|files|pdf|video.");
|
|
5085
|
+
}
|
|
5086
|
+
if (noun === "channels") {
|
|
5087
|
+
return runChannelsCommand({ parsed, api, writeCommand });
|
|
5088
|
+
}
|
|
5089
|
+
if (noun === "families") {
|
|
5090
|
+
return runFamiliesCommand({ parsed, api, writeCommand });
|
|
5078
5091
|
}
|
|
5079
5092
|
if (noun === "chat-logs") {
|
|
5080
5093
|
return runChatLogsCommand({ parsed, api, writeCommand });
|
|
5081
5094
|
}
|
|
5095
|
+
if (noun === "rooms") {
|
|
5096
|
+
return runRoomsCommand({ parsed, api, writeCommand });
|
|
5097
|
+
}
|
|
5082
5098
|
if (noun === "students") {
|
|
5083
5099
|
if (verb === "list") {
|
|
5084
5100
|
const requestedScope = flagString(parsed, "scope");
|
|
@@ -5651,6 +5667,64 @@ export async function executeRecessCommand(argv, options = {}) {
|
|
|
5651
5667
|
}
|
|
5652
5668
|
throw new CliError("invalid_arguments", "Use tutor-templates list|get|create|update|delete|assign|unassign.");
|
|
5653
5669
|
}
|
|
5670
|
+
if (noun === "goals" && verb === "video") {
|
|
5671
|
+
const action = positional(parsed, 2, "goals video action");
|
|
5672
|
+
if (action !== "upload") {
|
|
5673
|
+
throw new CliError("invalid_arguments", "Use goals video upload.");
|
|
5674
|
+
}
|
|
5675
|
+
const sourceFile = flagString(parsed, "source-file", { required: true });
|
|
5676
|
+
const absolutePath = path.resolve(sourceFile);
|
|
5677
|
+
const fileStat = await fs.lstat(absolutePath).catch(() => null);
|
|
5678
|
+
if (!fileStat?.isFile() || fileStat.isSymbolicLink()) {
|
|
5679
|
+
throw new CliError("invalid_arguments", `--source-file is not a file: ${absolutePath}`);
|
|
5680
|
+
}
|
|
5681
|
+
if (!absolutePath.toLowerCase().endsWith(".mp4")) {
|
|
5682
|
+
throw new CliError("invalid_arguments", "goals video upload accepts .mp4 files only (H.264 plays everywhere).");
|
|
5683
|
+
}
|
|
5684
|
+
if (fileStat.size <= 0 || fileStat.size > GOAL_VIDEO_UPLOAD_MAX_BYTES) {
|
|
5685
|
+
throw new CliError("invalid_arguments", `Video size must be between 1 byte and ${GOAL_VIDEO_UPLOAD_MAX_BYTES} bytes.`);
|
|
5686
|
+
}
|
|
5687
|
+
const fileName = path.basename(absolutePath);
|
|
5688
|
+
const sourceSha256 = await sha256File(absolutePath);
|
|
5689
|
+
const preview = {
|
|
5690
|
+
action: "publish a video at a public, unlisted URL",
|
|
5691
|
+
target: { fileName },
|
|
5692
|
+
request: {
|
|
5693
|
+
sourceFile: absolutePath,
|
|
5694
|
+
sourceSha256,
|
|
5695
|
+
sizeBytes: fileStat.size,
|
|
5696
|
+
},
|
|
5697
|
+
details: {
|
|
5698
|
+
note: "Anyone with the returned URL can watch it. Paste the URL into a goal module or template (with a short transcript so Rocky knows what it covers); Rocky opens it in the browser.",
|
|
5699
|
+
},
|
|
5700
|
+
};
|
|
5701
|
+
return previewBoundWrite(parsed, preview, async () => {
|
|
5702
|
+
const signed = unwrap(await api.client.POST("/os-v2-mesa/goal-videos/sign", {
|
|
5703
|
+
body: { fileName, sizeBytes: fileStat.size },
|
|
5704
|
+
}));
|
|
5705
|
+
const bytes = await fs.readFile(absolutePath);
|
|
5706
|
+
if (createHash("sha256").update(bytes).digest("hex") !== sourceSha256) {
|
|
5707
|
+
throw new CliError("source_changed", "The video changed after approval; preview the current file again.");
|
|
5708
|
+
}
|
|
5709
|
+
const response = await fetch(signed.uploadUrl, {
|
|
5710
|
+
method: "PUT",
|
|
5711
|
+
headers: signed.uploadHeaders,
|
|
5712
|
+
body: uploadBody(bytes),
|
|
5713
|
+
});
|
|
5714
|
+
if (!response.ok) {
|
|
5715
|
+
throw new CliError("upload_failed", `Video upload failed with HTTP ${response.status}.`);
|
|
5716
|
+
}
|
|
5717
|
+
const done = unwrap(await api.client.POST("/os-v2-mesa/goal-videos/complete", {
|
|
5718
|
+
body: { assetId: signed.assetId },
|
|
5719
|
+
}));
|
|
5720
|
+
return {
|
|
5721
|
+
action: "upload_goal_video",
|
|
5722
|
+
url: done.url,
|
|
5723
|
+
fileName,
|
|
5724
|
+
sizeBytes: fileStat.size,
|
|
5725
|
+
};
|
|
5726
|
+
});
|
|
5727
|
+
}
|
|
5654
5728
|
if (noun === "goals" && verb === "pdf") {
|
|
5655
5729
|
const action = positional(parsed, 2, "goals pdf action");
|
|
5656
5730
|
if (action !== "upload") {
|
package/dist/command-schema.js
CHANGED
|
@@ -26,10 +26,12 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
26
26
|
"keep-tokens",
|
|
27
27
|
"include-deleted",
|
|
28
28
|
"include-archived",
|
|
29
|
+
"include-left",
|
|
29
30
|
"json",
|
|
30
31
|
"mirrored",
|
|
31
32
|
"no-collision",
|
|
32
33
|
"no-invite",
|
|
34
|
+
"no-join",
|
|
33
35
|
"open",
|
|
34
36
|
"refresh",
|
|
35
37
|
"resend",
|
|
@@ -179,6 +181,7 @@ const FAMILY_AI_COMMANDS = new Set([
|
|
|
179
181
|
"goals list",
|
|
180
182
|
"goals move-module",
|
|
181
183
|
"goals pdf upload",
|
|
184
|
+
"goals video upload",
|
|
182
185
|
"goals queue get",
|
|
183
186
|
"goals queue set",
|
|
184
187
|
"goals unarchive",
|
|
@@ -202,6 +205,8 @@ const FAMILY_AI_COMMANDS = new Set([
|
|
|
202
205
|
"tutor-templates unassign",
|
|
203
206
|
]);
|
|
204
207
|
const STAFF_COMMANDS = new Set([
|
|
208
|
+
"rooms transcripts get",
|
|
209
|
+
"rooms transcripts search",
|
|
205
210
|
"cohorts schedule get",
|
|
206
211
|
"cohorts schedule set",
|
|
207
212
|
"cohorts schedule changes",
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { unwrap } from "../api.js";
|
|
5
|
+
import { flagNumber, flagString, hasFlag } from "../args.js";
|
|
6
|
+
import { CliError } from "../errors.js";
|
|
7
|
+
import { assertChoice, positional } from "./shared.js";
|
|
8
|
+
// Runtime mirrors of the generated unions; `satisfies` fails the build if the
|
|
9
|
+
// backend enums move.
|
|
10
|
+
const CHANNEL_TYPES = [
|
|
11
|
+
"COMMUNITY",
|
|
12
|
+
"SYSTEM_GENERATED",
|
|
13
|
+
"COHORT_PARENT",
|
|
14
|
+
"DIRECT_MESSAGE",
|
|
15
|
+
"GROUP_MESSAGE",
|
|
16
|
+
"COURSE_REQUEST",
|
|
17
|
+
"RECESS_EVENT",
|
|
18
|
+
"LFG_GROUP",
|
|
19
|
+
"CHALLENGE_FINISHERS",
|
|
20
|
+
"RECESS",
|
|
21
|
+
"STREAMING",
|
|
22
|
+
];
|
|
23
|
+
const LIST_TYPES = [
|
|
24
|
+
...CHANNEL_TYPES,
|
|
25
|
+
"WORLD",
|
|
26
|
+
];
|
|
27
|
+
const AUDIENCES = ["KID", "PARENT"];
|
|
28
|
+
const STATUSES = [
|
|
29
|
+
"ACTIVE",
|
|
30
|
+
"HIDDEN",
|
|
31
|
+
"ARCHIVED",
|
|
32
|
+
];
|
|
33
|
+
const MEMBER_ROLES = [
|
|
34
|
+
"MEMBER",
|
|
35
|
+
"MODERATOR",
|
|
36
|
+
"ADMIN",
|
|
37
|
+
];
|
|
38
|
+
/** The type set post.channel-image.ts accepts, keyed by extension. */
|
|
39
|
+
const IMAGE_MIME_BY_EXT = {
|
|
40
|
+
".jpg": "image/jpeg",
|
|
41
|
+
".jpeg": "image/jpeg",
|
|
42
|
+
".png": "image/png",
|
|
43
|
+
".gif": "image/gif",
|
|
44
|
+
".webp": "image/webp",
|
|
45
|
+
};
|
|
46
|
+
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
47
|
+
async function readChannelImage(filePath) {
|
|
48
|
+
const absolutePath = path.resolve(filePath);
|
|
49
|
+
const mimeType = IMAGE_MIME_BY_EXT[path.extname(absolutePath).toLowerCase()];
|
|
50
|
+
if (!mimeType) {
|
|
51
|
+
throw new CliError("invalid_arguments", "The image must be a .png, .jpg, .jpeg, .gif, or .webp file.");
|
|
52
|
+
}
|
|
53
|
+
let bytes;
|
|
54
|
+
try {
|
|
55
|
+
bytes = await fs.readFile(absolutePath);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
if (error.code === "ENOENT") {
|
|
59
|
+
throw new CliError("invalid_arguments", `Image file does not exist: ${absolutePath}`);
|
|
60
|
+
}
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
if (bytes.byteLength > MAX_IMAGE_BYTES) {
|
|
64
|
+
throw new CliError("invalid_arguments", "The image must be 10MB or smaller.");
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
bytes,
|
|
68
|
+
fileName: path.basename(absolutePath),
|
|
69
|
+
mimeType,
|
|
70
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export async function runChannelsCommand({ parsed, api, writeCommand, }) {
|
|
74
|
+
const verb = parsed.positionals[1];
|
|
75
|
+
if (verb === "list") {
|
|
76
|
+
const type = flagString(parsed, "type");
|
|
77
|
+
const audience = flagString(parsed, "audience");
|
|
78
|
+
const status = flagString(parsed, "status");
|
|
79
|
+
const limit = flagNumber(parsed, "limit");
|
|
80
|
+
if (limit !== undefined &&
|
|
81
|
+
(!Number.isInteger(limit) || limit < 1 || limit > 200)) {
|
|
82
|
+
throw new CliError("invalid_arguments", "--limit must be an integer from 1 to 200.");
|
|
83
|
+
}
|
|
84
|
+
const q = parsed.positionals.slice(2).join(" ").trim();
|
|
85
|
+
return unwrap(await api.client.GET("/admin/channels/", {
|
|
86
|
+
params: {
|
|
87
|
+
query: {
|
|
88
|
+
...(type ? { type: assertChoice(type, LIST_TYPES, "--type") } : {}),
|
|
89
|
+
...(audience
|
|
90
|
+
? { audience: assertChoice(audience, AUDIENCES, "--audience") }
|
|
91
|
+
: {}),
|
|
92
|
+
...(status
|
|
93
|
+
? { status: assertChoice(status, STATUSES, "--status") }
|
|
94
|
+
: {}),
|
|
95
|
+
...(q ? { q } : {}),
|
|
96
|
+
...(limit !== undefined ? { limit } : {}),
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
if (verb === "create") {
|
|
102
|
+
const name = flagString(parsed, "name", { required: true }).trim();
|
|
103
|
+
if (!name || name.length > 100) {
|
|
104
|
+
throw new CliError("invalid_arguments", "--name must be 1 to 100 characters.");
|
|
105
|
+
}
|
|
106
|
+
const type = assertChoice(flagString(parsed, "type") ?? "COMMUNITY", CHANNEL_TYPES, "--type");
|
|
107
|
+
const audienceFlag = flagString(parsed, "audience");
|
|
108
|
+
const audience = audienceFlag === undefined
|
|
109
|
+
? undefined
|
|
110
|
+
: audienceFlag === "NONE"
|
|
111
|
+
? null
|
|
112
|
+
: assertChoice(audienceFlag, AUDIENCES, "--audience");
|
|
113
|
+
const description = flagString(parsed, "description");
|
|
114
|
+
const joinAsAdmin = !hasFlag(parsed, "no-join");
|
|
115
|
+
const imagePath = flagString(parsed, "image");
|
|
116
|
+
const image = imagePath ? await readChannelImage(imagePath) : undefined;
|
|
117
|
+
const body = {
|
|
118
|
+
name,
|
|
119
|
+
type,
|
|
120
|
+
joinAsAdmin,
|
|
121
|
+
...(audience !== undefined ? { audience } : {}),
|
|
122
|
+
...(description !== undefined ? { description } : {}),
|
|
123
|
+
};
|
|
124
|
+
return writeCommand(parsed, {
|
|
125
|
+
action: type === "COMMUNITY"
|
|
126
|
+
? `create a ${audience ?? "KID"}-audience community channel that ${audience === "PARENT" ? "guardians" : "kids"} can find and join from Browse`
|
|
127
|
+
: `create a standalone ${type} channel (not linked to any cohort, event, challenge, or course)`,
|
|
128
|
+
target: { name, type },
|
|
129
|
+
// The image's hash is in the approved request, so confirming with a
|
|
130
|
+
// different file fails the operation-key check.
|
|
131
|
+
request: image
|
|
132
|
+
? {
|
|
133
|
+
...body,
|
|
134
|
+
image: {
|
|
135
|
+
fileName: image.fileName,
|
|
136
|
+
mimeType: image.mimeType,
|
|
137
|
+
bytes: image.bytes.byteLength,
|
|
138
|
+
sha256: image.sha256,
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
: body,
|
|
142
|
+
...(joinAsAdmin
|
|
143
|
+
? {}
|
|
144
|
+
: {
|
|
145
|
+
details: {
|
|
146
|
+
note: "You will not be a member; use channels join to enter it.",
|
|
147
|
+
},
|
|
148
|
+
}),
|
|
149
|
+
}, async () => {
|
|
150
|
+
const channel = unwrap(await api.client.POST("/admin/channels/", { body }));
|
|
151
|
+
if (!image)
|
|
152
|
+
return channel;
|
|
153
|
+
try {
|
|
154
|
+
const uploaded = (await api.uploadChannelImage(channel.id, image.bytes, image.fileName, image.mimeType));
|
|
155
|
+
return { ...channel, imageUrl: uploaded.imageUrl };
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
throw new CliError("partial_failure", `Channel ${channel.id} was created, but its image upload failed: ${error instanceof Error ? error.message : String(error)}. Retry with channels set-image ${channel.id} <path>.`, 1, { channel });
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
if (verb === "set-image") {
|
|
163
|
+
const channelId = positional(parsed, 2, "channel ID");
|
|
164
|
+
const imagePath = parsed.positionals[3];
|
|
165
|
+
const clear = hasFlag(parsed, "clear");
|
|
166
|
+
if (clear === Boolean(imagePath)) {
|
|
167
|
+
throw new CliError("invalid_arguments", "Pass an image path or --clear (not both).");
|
|
168
|
+
}
|
|
169
|
+
if (clear) {
|
|
170
|
+
return writeCommand(parsed, {
|
|
171
|
+
action: "remove the channel's image",
|
|
172
|
+
target: { channelId },
|
|
173
|
+
request: { clear: true },
|
|
174
|
+
}, async () => unwrap(await api.client.DELETE("/admin/channels/{channelId}/image", {
|
|
175
|
+
params: { path: { channelId } },
|
|
176
|
+
})));
|
|
177
|
+
}
|
|
178
|
+
const image = await readChannelImage(imagePath);
|
|
179
|
+
return writeCommand(parsed, {
|
|
180
|
+
action: "upload this image and set it as the channel's image",
|
|
181
|
+
target: { channelId },
|
|
182
|
+
request: {
|
|
183
|
+
fileName: image.fileName,
|
|
184
|
+
mimeType: image.mimeType,
|
|
185
|
+
bytes: image.bytes.byteLength,
|
|
186
|
+
sha256: image.sha256,
|
|
187
|
+
},
|
|
188
|
+
}, async () => api.uploadChannelImage(channelId, image.bytes, image.fileName, image.mimeType));
|
|
189
|
+
}
|
|
190
|
+
if (verb === "join") {
|
|
191
|
+
const channelId = positional(parsed, 2, "channel ID");
|
|
192
|
+
const body = { channelId };
|
|
193
|
+
return writeCommand(parsed, {
|
|
194
|
+
action: "join the channel yourself (rejoins if you left)",
|
|
195
|
+
target: { channelId },
|
|
196
|
+
request: body,
|
|
197
|
+
}, async () => unwrap(await api.client.POST("/admin/channels/join/", { body })));
|
|
198
|
+
}
|
|
199
|
+
if (verb === "members") {
|
|
200
|
+
const action = parsed.positionals[2];
|
|
201
|
+
const channelId = positional(parsed, 3, "channel ID");
|
|
202
|
+
if (action === "list") {
|
|
203
|
+
return unwrap(await api.client.GET("/admin/channels/{channelId}/members", {
|
|
204
|
+
params: {
|
|
205
|
+
path: { channelId },
|
|
206
|
+
query: {
|
|
207
|
+
includeLeft: hasFlag(parsed, "include-left") ? "true" : "false",
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
}));
|
|
211
|
+
}
|
|
212
|
+
if (action === "add" || action === "set-role" || action === "remove") {
|
|
213
|
+
const userIds = parsed.positionals.slice(4);
|
|
214
|
+
if (userIds.length === 0) {
|
|
215
|
+
throw new CliError("invalid_arguments", "Pass at least one user ID.");
|
|
216
|
+
}
|
|
217
|
+
if (new Set(userIds).size !== userIds.length) {
|
|
218
|
+
throw new CliError("invalid_arguments", "User IDs must be unique.");
|
|
219
|
+
}
|
|
220
|
+
// Read-only preflight: the approval preview names the exact channel
|
|
221
|
+
// (and audience) people are entering or leaving. Only stable fields go
|
|
222
|
+
// in the preview, since the operation key is bound to its hash.
|
|
223
|
+
const current = unwrap(await api.client.GET("/admin/channels/{channelId}/members", {
|
|
224
|
+
params: { path: { channelId }, query: { includeLeft: "false" } },
|
|
225
|
+
}));
|
|
226
|
+
const channel = {
|
|
227
|
+
name: current.channel.name,
|
|
228
|
+
type: current.channel.type,
|
|
229
|
+
audience: current.channel.audience,
|
|
230
|
+
status: current.channel.status,
|
|
231
|
+
};
|
|
232
|
+
if (action === "remove") {
|
|
233
|
+
const active = new Set(current.members.map((m) => m.userId));
|
|
234
|
+
const notMembers = userIds.filter((id) => !active.has(id));
|
|
235
|
+
if (notMembers.length > 0) {
|
|
236
|
+
throw new CliError("invalid_arguments", `Not active members of this channel: ${notMembers.join(", ")}`);
|
|
237
|
+
}
|
|
238
|
+
return writeCommand(parsed, {
|
|
239
|
+
action: `remove ${userIds.length} member(s) from the channel (soft: they can be re-added; a LEFT notice posts in the channel)`,
|
|
240
|
+
target: { channelId, userIds },
|
|
241
|
+
request: { userIds },
|
|
242
|
+
details: { channel },
|
|
243
|
+
}, async () => unwrap(await api.client.POST("/admin/channels/{channelId}/members/remove", { params: { path: { channelId } }, body: { userIds } })));
|
|
244
|
+
}
|
|
245
|
+
const role = assertChoice(flagString(parsed, "role", { required: action === "set-role" }) ??
|
|
246
|
+
"MEMBER", MEMBER_ROLES, "--role");
|
|
247
|
+
if (action === "set-role") {
|
|
248
|
+
const active = new Set(current.members.map((m) => m.userId));
|
|
249
|
+
const notMembers = userIds.filter((id) => !active.has(id));
|
|
250
|
+
if (notMembers.length > 0) {
|
|
251
|
+
throw new CliError("invalid_arguments", `Not active members (use members add): ${notMembers.join(", ")}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const body = { userIds, role };
|
|
255
|
+
return writeCommand(parsed, {
|
|
256
|
+
action: action === "set-role"
|
|
257
|
+
? `set the channel role of ${userIds.length} member(s) to ${role}`
|
|
258
|
+
: `add ${userIds.length} user(s) to the channel as ${role} (a JOINED notice posts for each new arrival)`,
|
|
259
|
+
target: { channelId, userIds },
|
|
260
|
+
request: body,
|
|
261
|
+
details: { channel },
|
|
262
|
+
}, async () => unwrap(await api.client.POST("/admin/channels/{channelId}/members", {
|
|
263
|
+
params: { path: { channelId } },
|
|
264
|
+
body,
|
|
265
|
+
})));
|
|
266
|
+
}
|
|
267
|
+
throw new CliError("invalid_arguments", "Use channels members list|add|set-role|remove <channel-id> ...");
|
|
268
|
+
}
|
|
269
|
+
throw new CliError("invalid_arguments", "Use channels list|create|set-image|join or channels members list|add|set-role|remove.");
|
|
270
|
+
}
|
|
271
|
+
//# sourceMappingURL=channels.js.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { unwrap } from "../api.js";
|
|
3
|
+
import { flagString } from "../args.js";
|
|
4
|
+
import { CliError } from "../errors.js";
|
|
5
|
+
import { readJsonValue } from "./shared.js";
|
|
6
|
+
export async function runFamiliesCommand({ parsed, api, writeCommand, }) {
|
|
7
|
+
const verb = parsed.positionals[1];
|
|
8
|
+
if (verb === "set-addresses") {
|
|
9
|
+
const filePath = flagString(parsed, "file", { required: true });
|
|
10
|
+
const { absolutePath, raw, parsed: json, } = await readJsonValue(filePath, "Address file");
|
|
11
|
+
if (!Array.isArray(json) || json.length === 0) {
|
|
12
|
+
throw new CliError("invalid_arguments", `Address file must be a non-empty JSON array of {familyId, postalCode?, stateRegion?, city?, streetAddress?, country?, source} (${absolutePath}).`);
|
|
13
|
+
}
|
|
14
|
+
const entries = json;
|
|
15
|
+
const sha256 = createHash("sha256").update(raw).digest("hex");
|
|
16
|
+
// Read-only preflight: the server's dry run validates every entry and
|
|
17
|
+
// returns each family's current and proposed address for the approval.
|
|
18
|
+
const dryRun = unwrap(await api.client.POST("/admin/users/family-addresses/", {
|
|
19
|
+
body: { dryRun: true, entries },
|
|
20
|
+
}));
|
|
21
|
+
const changes = dryRun.results.filter((r) => r.changed);
|
|
22
|
+
return writeCommand(parsed, {
|
|
23
|
+
action: `set household addresses on ${changes.length} of ${entries.length} families (the rest already match)`,
|
|
24
|
+
target: { families: entries.length },
|
|
25
|
+
request: { file: absolutePath, sha256, entries: entries.length },
|
|
26
|
+
details: {
|
|
27
|
+
changes: changes.map((r) => ({
|
|
28
|
+
familyId: r.familyId,
|
|
29
|
+
before: r.before,
|
|
30
|
+
after: r.after,
|
|
31
|
+
})),
|
|
32
|
+
},
|
|
33
|
+
}, async () => unwrap(await api.client.POST("/admin/users/family-addresses/", {
|
|
34
|
+
body: { dryRun: false, entries },
|
|
35
|
+
})));
|
|
36
|
+
}
|
|
37
|
+
throw new CliError("invalid_arguments", "Use families set-addresses.");
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=families.js.map
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { unwrap } from "../api.js";
|
|
2
|
+
import { flagNumber, flagString } from "../args.js";
|
|
3
|
+
import { CliError } from "../errors.js";
|
|
4
|
+
import { positional } from "./shared.js";
|
|
5
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
6
|
+
function dateFlag(parsed, name) {
|
|
7
|
+
const value = flagString(parsed, name);
|
|
8
|
+
if (value !== undefined && !DATE.test(value)) {
|
|
9
|
+
throw new CliError("invalid_arguments", `--${name} must be YYYY-MM-DD.`);
|
|
10
|
+
}
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
function intFlag(parsed, name, min, max) {
|
|
14
|
+
const value = flagNumber(parsed, name);
|
|
15
|
+
if (value !== undefined &&
|
|
16
|
+
(!Number.isInteger(value) || value < min || value > max)) {
|
|
17
|
+
throw new CliError("invalid_arguments", `--${name} must be an integer from ${min} to ${max}.`);
|
|
18
|
+
}
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
export async function runRoomsCommand({ parsed, api, }) {
|
|
22
|
+
const group = parsed.positionals[1];
|
|
23
|
+
const verb = parsed.positionals[2];
|
|
24
|
+
if (group !== "transcripts" || (verb !== "search" && verb !== "get")) {
|
|
25
|
+
throw new CliError("invalid_arguments", "Use `rooms transcripts search` or `rooms transcripts get <meeting-id>`.");
|
|
26
|
+
}
|
|
27
|
+
if (verb === "search") {
|
|
28
|
+
return unwrap(await api.client.GET("/voice/room-transcripts/", {
|
|
29
|
+
params: {
|
|
30
|
+
query: {
|
|
31
|
+
roomName: flagString(parsed, "room"),
|
|
32
|
+
startDate: dateFlag(parsed, "from"),
|
|
33
|
+
endDate: dateFlag(parsed, "to"),
|
|
34
|
+
query: flagString(parsed, "query"),
|
|
35
|
+
limit: intFlag(parsed, "limit", 1, 20),
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
const meetingId = positional(parsed, 3, "meeting ID");
|
|
41
|
+
const breakoutIndex = intFlag(parsed, "breakout", 0, 1000);
|
|
42
|
+
const query = flagString(parsed, "query");
|
|
43
|
+
const read = (offset) => api.client.GET("/voice/room-transcripts/{meetingId}", {
|
|
44
|
+
params: {
|
|
45
|
+
path: { meetingId },
|
|
46
|
+
query: { breakoutIndex, query, offset, maxChars: 30000 },
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
const first = unwrap(await read());
|
|
50
|
+
if (query || first.text === undefined)
|
|
51
|
+
return first;
|
|
52
|
+
let text = first.text;
|
|
53
|
+
let next = first.nextOffset ?? null;
|
|
54
|
+
while (next !== null) {
|
|
55
|
+
const page = unwrap(await read(next));
|
|
56
|
+
if (page.offset !== next || page.text === undefined) {
|
|
57
|
+
throw new CliError("pagination_error", "Transcript pagination did not advance; no complete download was produced.");
|
|
58
|
+
}
|
|
59
|
+
text += page.text;
|
|
60
|
+
next = page.nextOffset ?? null;
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
...first,
|
|
64
|
+
offset: 0,
|
|
65
|
+
nextOffset: null,
|
|
66
|
+
text,
|
|
67
|
+
complete: true,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=rooms.js.map
|
package/dist/help.js
CHANGED
|
@@ -67,6 +67,9 @@ Usage:
|
|
|
67
67
|
recess [--json] chat-logs list --student <user-id> [--limit 100] [--cursor <cursor>]
|
|
68
68
|
recess [--json] chat-logs get <conversation-id>
|
|
69
69
|
recess [--json] chat-logs by-todo <todo-id>
|
|
70
|
+
recess [--json] rooms transcripts search [--room TEXT] [--from YYYY-MM-DD]
|
|
71
|
+
[--to YYYY-MM-DD] [--query TEXT] [--limit 10]
|
|
72
|
+
recess [--json] rooms transcripts get <meeting-id> [--breakout <n>] [--query TEXT]
|
|
70
73
|
recess [--json] students list [--scope mine|family]
|
|
71
74
|
recess [--json] students today --student <kid-id> [--date YYYY-MM-DD]
|
|
72
75
|
recess [--json] students schedule --student <kid-id> [--days 14]
|
|
@@ -160,6 +163,17 @@ Usage:
|
|
|
160
163
|
recess [--json] payout items edit <item-id> [--amount-cents N]
|
|
161
164
|
[--description TEXT] [--date YYYY-MM-DD] [--confirm]
|
|
162
165
|
recess [--json] payout items delete <item-id> [--confirm]
|
|
166
|
+
recess [--json] families set-addresses --file <json> [--confirm]
|
|
167
|
+
recess [--json] channels list [<query>...] [--type <channel-type>] [--audience KID|PARENT]
|
|
168
|
+
[--status ACTIVE|HIDDEN|ARCHIVED] [--limit 1..200]
|
|
169
|
+
recess [--json] channels create --name TEXT [--type <channel-type>]
|
|
170
|
+
[--audience KID|PARENT|NONE] [--description TEXT] [--image <path>] [--no-join] [--confirm]
|
|
171
|
+
recess [--json] channels set-image <channel-id> [path] [--clear] [--confirm]
|
|
172
|
+
recess [--json] channels join <channel-id> [--confirm]
|
|
173
|
+
recess [--json] channels members list <channel-id> [--include-left]
|
|
174
|
+
recess [--json] channels members add <channel-id> <user-id...> [--role MEMBER|MODERATOR|ADMIN] [--confirm]
|
|
175
|
+
recess [--json] channels members set-role <channel-id> <user-id...> --role MEMBER|MODERATOR|ADMIN [--confirm]
|
|
176
|
+
recess [--json] channels members remove <channel-id> <user-id...> [--confirm]
|
|
163
177
|
recess [--json] cohorts get <cohort-id> [--events-tab ACTIVE|ENDED|CANCELED|ARCHIVED]
|
|
164
178
|
recess [--json] cohorts schedule get <cohort-id>
|
|
165
179
|
recess [--json] cohorts schedule set <cohort-id> --days <MO,WE> --time <09:30> [--timezone <zone>] --effective-date <YYYY-MM-DD> --notify none|parents [--confirm] [--approval-token <token>]
|
|
@@ -490,6 +504,8 @@ Usage:
|
|
|
490
504
|
(--goal <goal-id> | --draft <draft-slug>) --source-file <local.pdf>
|
|
491
505
|
[--path uploads/name.pdf] [--message TEXT]
|
|
492
506
|
[--confirm --approval-token TOKEN]
|
|
507
|
+
recess [--json] goals video upload --source-file <local.mp4>
|
|
508
|
+
[--confirm --approval-token TOKEN]
|
|
493
509
|
|
|
494
510
|
Authoring notes: "skills" serves the in-product tutor skills (the private
|
|
495
511
|
packages/skills workspace package) read-only over your admin session — they are never
|
|
@@ -587,6 +603,25 @@ Class-ops notes: "events cancel" notifies families (chat + parent email blast +
|
|
|
587
603
|
credit notes + Slack); "events set-status --status CANCELED" is a silent status
|
|
588
604
|
change. Reschedule times are cohort-local wall-clock (zoneless).
|
|
589
605
|
|
|
606
|
+
Channel notes: channel types are COMMUNITY (default), SYSTEM_GENERATED,
|
|
607
|
+
COHORT_PARENT, DIRECT_MESSAGE, GROUP_MESSAGE, COURSE_REQUEST, RECESS_EVENT,
|
|
608
|
+
LFG_GROUP, CHALLENGE_FINISHERS, RECESS, STREAMING; WORLD channels belong to
|
|
609
|
+
apps/world and can only be listed. Guardians browse PARENT-audience COMMUNITY
|
|
610
|
+
channels and kids browse KID ones; "create" defaults a COMMUNITY channel to KID
|
|
611
|
+
and COHORT_PARENT to PARENT (--audience NONE clears it). Non-community types
|
|
612
|
+
created here stand alone: nothing links them to a cohort, event, challenge, or
|
|
613
|
+
course. "members add" also rejoins past members and applies --role to everyone
|
|
614
|
+
named; kids are refused from PARENT-audience and COHORT_PARENT channels.
|
|
615
|
+
"members remove" is soft (sets leftAt) and posts a LEFT notice. Images
|
|
616
|
+
(--image, set-image) are .png/.jpg/.gif/.webp, 10MB max.
|
|
617
|
+
|
|
618
|
+
Family address notes: "families set-addresses --file" takes a JSON array of
|
|
619
|
+
{familyId, streetAddress?, city?, stateRegion?, postalCode?, country?, source},
|
|
620
|
+
source one of PARENT|STAFF|APPLICATION|LIGHTFIELD|STRIPE. Country is ISO-2 and
|
|
621
|
+
omitted for US; US ZIPs must be 12345 or 12345-6789. Each entry replaces the
|
|
622
|
+
family's whole address. The preview is the server's dry run: before and after
|
|
623
|
+
for every family that changes.
|
|
624
|
+
|
|
590
625
|
Payout notes: amounts are integer cents. "payout items add" without --date
|
|
591
626
|
defaults the item date to the penultimate day of the invoice's cycle (its
|
|
592
627
|
endDate minus one day) and shows the computed date in the preview.
|