recess-cli 3.4.0 → 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 +104 -5
- package/dist/command-schema.js +7 -0
- package/dist/commands/apps.js +5 -1
- 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 +39 -1
- 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";
|
|
@@ -4259,6 +4263,7 @@ export async function executeRecessCommand(argv, options = {}) {
|
|
|
4259
4263
|
(!Number.isInteger(coinAmount) || coinAmount < 1)) {
|
|
4260
4264
|
throw new CliError("invalid_arguments", "--coin-amount must be a positive integer.");
|
|
4261
4265
|
}
|
|
4266
|
+
const visibility = flagString(parsed, "visibility");
|
|
4262
4267
|
const isStarterRaw = flagString(parsed, "is-starter");
|
|
4263
4268
|
const setupAudienceRaw = flagString(parsed, "setup-audience");
|
|
4264
4269
|
const isStarter = isStarterRaw === undefined
|
|
@@ -4300,6 +4305,11 @@ export async function executeRecessCommand(argv, options = {}) {
|
|
|
4300
4305
|
...(kind
|
|
4301
4306
|
? { kind: assertChoice(kind, GOAL_TEMPLATE_KINDS, "--kind") }
|
|
4302
4307
|
: {}),
|
|
4308
|
+
...(visibility
|
|
4309
|
+
? {
|
|
4310
|
+
visibility: assertChoice(visibility, ["PUBLIC", "PRIVATE", "STAFF_ONLY"], "--visibility"),
|
|
4311
|
+
}
|
|
4312
|
+
: {}),
|
|
4303
4313
|
...(agentInstructionsFile
|
|
4304
4314
|
? {
|
|
4305
4315
|
agentInstructions: await fs.readFile(path.resolve(agentInstructionsFile), "utf8"),
|
|
@@ -4316,7 +4326,7 @@ export async function executeRecessCommand(argv, options = {}) {
|
|
|
4316
4326
|
// shape that caused the template incident; editing an existing spec goes
|
|
4317
4327
|
// through the guarded /ai patch path with its destructive-change token.
|
|
4318
4328
|
if (Object.keys(body).length === 1) {
|
|
4319
|
-
throw new CliError("invalid_arguments", "Pass at least one field to change (--title, --description, --emoji, --image-url, --coin-amount, --category, --tags, --sort-order, --is-starter, --setup-audience, --kind, --agent-instructions-file, --output-template-file).");
|
|
4329
|
+
throw new CliError("invalid_arguments", "Pass at least one field to change (--title, --description, --emoji, --image-url, --coin-amount, --category, --tags, --sort-order, --is-starter, --setup-audience, --kind, --visibility, --agent-instructions-file, --output-template-file).");
|
|
4320
4330
|
}
|
|
4321
4331
|
return writeCommand(parsed, {
|
|
4322
4332
|
action: "update goal template metadata (never its setupWorkflowSpec)",
|
|
@@ -4334,7 +4344,7 @@ export async function executeRecessCommand(argv, options = {}) {
|
|
|
4334
4344
|
}));
|
|
4335
4345
|
const prompt = flagString(parsed, "prompt");
|
|
4336
4346
|
return writeCommand(parsed, {
|
|
4337
|
-
action: "queue paid
|
|
4347
|
+
action: "queue paid challenge-art regeneration",
|
|
4338
4348
|
target: {
|
|
4339
4349
|
templateId: id,
|
|
4340
4350
|
slug: current.slug,
|
|
@@ -4342,7 +4352,7 @@ export async function executeRecessCommand(argv, options = {}) {
|
|
|
4342
4352
|
},
|
|
4343
4353
|
request: { prompt: prompt ?? null },
|
|
4344
4354
|
details: {
|
|
4345
|
-
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.",
|
|
4346
4356
|
},
|
|
4347
4357
|
}, async () => unwrap(await api.client.POST("/ai/goal-templates/{id}/generate-image", {
|
|
4348
4358
|
params: { path: { id } },
|
|
@@ -4557,7 +4567,29 @@ export async function executeRecessCommand(argv, options = {}) {
|
|
|
4557
4567
|
}
|
|
4558
4568
|
throw new CliError("invalid_arguments", "Use goal-templates list|get|versions|validate-spec|create|patch-spec|set-metadata|generate-image|delete|snapshot-files|capture-snapshot|apply|apply-starter.");
|
|
4559
4569
|
}
|
|
4560
|
-
if (noun === "goals" && verb
|
|
4570
|
+
if (noun === "goals" && verb === "adaptations") {
|
|
4571
|
+
const action = positional(parsed, 2, "goals adaptations action");
|
|
4572
|
+
const studentId = flagString(parsed, "student", { required: true });
|
|
4573
|
+
const goalId = flagString(parsed, "goal", { required: true });
|
|
4574
|
+
if (action === "list") {
|
|
4575
|
+
return unwrap(await api.client.GET("/tutor/students/{studentId}/goals/{goalId}/adaptations", { params: { path: { studentId, goalId } } }));
|
|
4576
|
+
}
|
|
4577
|
+
if (action === "diff") {
|
|
4578
|
+
const runId = positional(parsed, 3, "adaptation run ID");
|
|
4579
|
+
const path = flagString(parsed, "path");
|
|
4580
|
+
return unwrap(await api.client.GET("/tutor/students/{studentId}/goals/{goalId}/adaptations/{runId}/diff", {
|
|
4581
|
+
params: {
|
|
4582
|
+
path: { studentId, goalId, runId },
|
|
4583
|
+
query: path ? { path } : {},
|
|
4584
|
+
},
|
|
4585
|
+
}));
|
|
4586
|
+
}
|
|
4587
|
+
throw new CliError("invalid_arguments", "Use goals adaptations list|diff.");
|
|
4588
|
+
}
|
|
4589
|
+
if (noun === "goals" &&
|
|
4590
|
+
verb !== "files" &&
|
|
4591
|
+
verb !== "pdf" &&
|
|
4592
|
+
verb !== "video") {
|
|
4561
4593
|
if (verb === "complete" || verb === "undo-completion") {
|
|
4562
4594
|
const goalId = positional(parsed, 2, "goal ID");
|
|
4563
4595
|
const undo = verb === "undo-completion";
|
|
@@ -5049,11 +5081,20 @@ export async function executeRecessCommand(argv, options = {}) {
|
|
|
5049
5081
|
}
|
|
5050
5082
|
throw new CliError("invalid_arguments", "Use goals queue get|set.");
|
|
5051
5083
|
}
|
|
5052
|
-
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 });
|
|
5053
5091
|
}
|
|
5054
5092
|
if (noun === "chat-logs") {
|
|
5055
5093
|
return runChatLogsCommand({ parsed, api, writeCommand });
|
|
5056
5094
|
}
|
|
5095
|
+
if (noun === "rooms") {
|
|
5096
|
+
return runRoomsCommand({ parsed, api, writeCommand });
|
|
5097
|
+
}
|
|
5057
5098
|
if (noun === "students") {
|
|
5058
5099
|
if (verb === "list") {
|
|
5059
5100
|
const requestedScope = flagString(parsed, "scope");
|
|
@@ -5626,6 +5667,64 @@ export async function executeRecessCommand(argv, options = {}) {
|
|
|
5626
5667
|
}
|
|
5627
5668
|
throw new CliError("invalid_arguments", "Use tutor-templates list|get|create|update|delete|assign|unassign.");
|
|
5628
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
|
+
}
|
|
5629
5728
|
if (noun === "goals" && verb === "pdf") {
|
|
5630
5729
|
const action = positional(parsed, 2, "goals pdf action");
|
|
5631
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",
|
|
@@ -165,6 +167,8 @@ const FAMILY_AI_COMMANDS = new Set([
|
|
|
165
167
|
"goal-templates list",
|
|
166
168
|
"goal-templates snapshot-files",
|
|
167
169
|
"goal-templates validate-spec",
|
|
170
|
+
"goals adaptations diff",
|
|
171
|
+
"goals adaptations list",
|
|
168
172
|
"goals archive",
|
|
169
173
|
"goals create",
|
|
170
174
|
"goals edit",
|
|
@@ -177,6 +181,7 @@ const FAMILY_AI_COMMANDS = new Set([
|
|
|
177
181
|
"goals list",
|
|
178
182
|
"goals move-module",
|
|
179
183
|
"goals pdf upload",
|
|
184
|
+
"goals video upload",
|
|
180
185
|
"goals queue get",
|
|
181
186
|
"goals queue set",
|
|
182
187
|
"goals unarchive",
|
|
@@ -200,6 +205,8 @@ const FAMILY_AI_COMMANDS = new Set([
|
|
|
200
205
|
"tutor-templates unassign",
|
|
201
206
|
]);
|
|
202
207
|
const STAFF_COMMANDS = new Set([
|
|
208
|
+
"rooms transcripts get",
|
|
209
|
+
"rooms transcripts search",
|
|
203
210
|
"cohorts schedule get",
|
|
204
211
|
"cohorts schedule set",
|
|
205
212
|
"cohorts schedule changes",
|
package/dist/commands/apps.js
CHANGED
|
@@ -212,7 +212,7 @@ function agentsGuide(docs) {
|
|
|
212
212
|
"widgets.js, engine.js, key.js — a modified locked file fails validation.",
|
|
213
213
|
"",
|
|
214
214
|
"Before publishing, write two files at the root:",
|
|
215
|
-
"- contract.json — the BuildContract
|
|
215
|
+
"- contract.json — the BuildContract: copy the example in the contract.json section below.",
|
|
216
216
|
'- manifest.json — { "title": "...", "description": "...", "template": { "ccss": "5.NF.A.1", "modes": ["prepare","remediate"], "misconceptions": ["..."] } }',
|
|
217
217
|
"",
|
|
218
218
|
'Then: `recess apps validate --reason "..."` (reports without publishing) and',
|
|
@@ -228,6 +228,10 @@ function agentsGuide(docs) {
|
|
|
228
228
|
"",
|
|
229
229
|
"---",
|
|
230
230
|
"",
|
|
231
|
+
docs.contract ?? "",
|
|
232
|
+
"",
|
|
233
|
+
"---",
|
|
234
|
+
"",
|
|
231
235
|
docs.pedagogy ? `# Pedagogy\n\n${docs.pedagogy}` : "",
|
|
232
236
|
"",
|
|
233
237
|
"---",
|
|
@@ -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>]
|
|
@@ -391,7 +405,7 @@ Usage:
|
|
|
391
405
|
[--title TEXT] [--description TEXT] [--emoji X] [--category TEXT] [--tags A,B]
|
|
392
406
|
[--image-url URL] [--coin-amount N] [--sort-order N] [--is-starter true|false]
|
|
393
407
|
[--setup-audience KID_FRIENDLY|PARENT_SETUP] [--kind SIMPLE|BLUEPRINT]
|
|
394
|
-
[--agent-instructions-file <path>]
|
|
408
|
+
[--visibility PUBLIC|PRIVATE|STAFF_ONLY] [--agent-instructions-file <path>]
|
|
395
409
|
[--output-template-file <path>] [--confirm]
|
|
396
410
|
recess [--json] goal-templates delete <template-id> --expected-version N [--confirm]
|
|
397
411
|
recess [--json] goal-templates generate-image <template-id|slug>
|
|
@@ -468,6 +482,9 @@ Usage:
|
|
|
468
482
|
[--lessons-dir <dir>] [--emoji TEXT] [--grade-min N] [--grade-max N]
|
|
469
483
|
[--visibility PUBLIC|STAFF] [--confirm]
|
|
470
484
|
recess [--json] tutor-templates delete <slug> [--confirm]
|
|
485
|
+
recess [--json] goals adaptations list --student <goal-owner-id> --goal <goal-id>
|
|
486
|
+
recess [--json] goals adaptations diff <run-id> --student <goal-owner-id>
|
|
487
|
+
--goal <goal-id> [--path P]
|
|
471
488
|
recess [--json] goals files list --student <goal-owner-id> --goal <goal-id>
|
|
472
489
|
recess [--json] goals files read --student <goal-owner-id> --goal <goal-id> --path P
|
|
473
490
|
recess [--json] goals files init --student <goal-owner-id> --draft <draft-slug>
|
|
@@ -487,6 +504,8 @@ Usage:
|
|
|
487
504
|
(--goal <goal-id> | --draft <draft-slug>) --source-file <local.pdf>
|
|
488
505
|
[--path uploads/name.pdf] [--message TEXT]
|
|
489
506
|
[--confirm --approval-token TOKEN]
|
|
507
|
+
recess [--json] goals video upload --source-file <local.mp4>
|
|
508
|
+
[--confirm --approval-token TOKEN]
|
|
490
509
|
|
|
491
510
|
Authoring notes: "skills" serves the in-product tutor skills (the private
|
|
492
511
|
packages/skills workspace package) read-only over your admin session — they are never
|
|
@@ -584,6 +603,25 @@ Class-ops notes: "events cancel" notifies families (chat + parent email blast +
|
|
|
584
603
|
credit notes + Slack); "events set-status --status CANCELED" is a silent status
|
|
585
604
|
change. Reschedule times are cohort-local wall-clock (zoneless).
|
|
586
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
|
+
|
|
587
625
|
Payout notes: amounts are integer cents. "payout items add" without --date
|
|
588
626
|
defaults the item date to the penultimate day of the invoice's cycle (its
|
|
589
627
|
endDate minus one day) and shows the computed date in the preview.
|