recess-cli 2.7.0 → 2.9.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 +13 -8
- package/dist/args.js +0 -1
- package/dist/cli.js +246 -37
- package/dist/command-schema.js +196 -34
- package/dist/commands/apps.js +282 -23
- package/dist/commands/mastery.js +254 -0
- package/dist/commands/onboarding.js +1 -7
- package/dist/commands/school.js +1 -1
- package/dist/engine.js +6 -0
- package/dist/errors.js +13 -4
- package/dist/help.js +20 -6
- package/dist/upload-names.js +19 -0
- package/package.json +8 -1
- package/skill/recess-cli/SKILL.md +31 -1
- package/skill/recess-cli/agents/version.json +2 -2
package/dist/api.js
CHANGED
|
@@ -19,16 +19,19 @@ export class RecessAdminApi {
|
|
|
19
19
|
clientTag;
|
|
20
20
|
client;
|
|
21
21
|
villageSessionPromise;
|
|
22
|
-
constructor(config, reason, clientTag = RECESS_CLIENT_CLI) {
|
|
22
|
+
constructor(config, reason, clientTag = RECESS_CLIENT_CLI, clientOverride, fetchOverride) {
|
|
23
23
|
this.config = config;
|
|
24
24
|
this.reason = reason;
|
|
25
25
|
this.clientTag = clientTag;
|
|
26
|
-
this.client =
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
26
|
+
this.client =
|
|
27
|
+
clientOverride ??
|
|
28
|
+
createClient({
|
|
29
|
+
baseUrl: config.apiOrigin,
|
|
30
|
+
headers: config.sessionCookie
|
|
31
|
+
? { cookie: config.sessionCookie }
|
|
32
|
+
: undefined,
|
|
33
|
+
...(fetchOverride ? { fetch: fetchOverride } : {}),
|
|
34
|
+
});
|
|
32
35
|
const requestReason = this.reason;
|
|
33
36
|
const requestClientTag = this.clientTag;
|
|
34
37
|
this.client.use({
|
|
@@ -162,7 +165,9 @@ export class RecessAdminApi {
|
|
|
162
165
|
async uploadMapTestScores(studentId, pdf, fileName) {
|
|
163
166
|
this.requireAuth();
|
|
164
167
|
const formData = new FormData();
|
|
165
|
-
|
|
168
|
+
const pdfBody = new Uint8Array(pdf.byteLength);
|
|
169
|
+
pdfBody.set(pdf);
|
|
170
|
+
formData.append("file", new Blob([pdfBody.buffer], { type: "application/pdf" }), fileName);
|
|
166
171
|
const headers = cliRequestHeaders({ cookie: this.config.sessionCookie }, this.reason, this.clientTag);
|
|
167
172
|
applyIdempotencyHeaders(headers);
|
|
168
173
|
const response = await fetch(new URL(`/tutor/students/${encodeURIComponent(studentId)}/map-test-scores/upload`, this.config.apiOrigin), {
|
package/dist/args.js
CHANGED
package/dist/cli.js
CHANGED
|
@@ -6,9 +6,10 @@ import { RecessAdminApi, unwrap, withIdempotencyContext } from "./api.js";
|
|
|
6
6
|
import { flagNumber, flagString, hasFlag, parseArgs, } from "./args.js";
|
|
7
7
|
import { login, pollDeviceAuth, requestDeviceAuth } from "./auth.js";
|
|
8
8
|
import { clearStoredSession, deleteProfile, listProfiles, resolveConfig, saveProfile, useProfile, } from "./config.js";
|
|
9
|
-
import { agentContext, buildCommandSchema, scopedHelp, validateInvocation, } from "./command-schema.js";
|
|
9
|
+
import { agentContext, buildCommandSchema, findCommandSchema, remoteCommands, scopedHelp, validateInvocation, } from "./command-schema.js";
|
|
10
10
|
import { runApplicationsCommand } from "./commands/applications.js";
|
|
11
11
|
import { runAppsCommand } from "./commands/apps.js";
|
|
12
|
+
import { runMasteryCommand } from "./commands/mastery.js";
|
|
12
13
|
import { runOnboardingCommand } from "./commands/onboarding.js";
|
|
13
14
|
import { runSchoolCommand } from "./commands/school.js";
|
|
14
15
|
import { runVillageEventsCommand } from "./commands/village-events.js";
|
|
@@ -22,6 +23,7 @@ import { requireConfirmation } from "./safety.js";
|
|
|
22
23
|
import { installSkill, isEphemeralInstall, readBundledSkillVersion, readCliVersion, } from "./setup.js";
|
|
23
24
|
import { compareVersions, updateSkillFromServer } from "./skill-update.js";
|
|
24
25
|
import { readSkillCache, writeSkillCache } from "./skills-cache.js";
|
|
26
|
+
import { sanitizeWorkspaceUploadName } from "./upload-names.js";
|
|
25
27
|
import { appendJobEvent, getJob, listJobs, pruneJobs } from "./jobs.js";
|
|
26
28
|
function requiredRequestReason(parsed) {
|
|
27
29
|
return requireCliRequestReason(flagString(parsed, "reason", { required: true }));
|
|
@@ -229,7 +231,7 @@ async function skillStatus(config, reason) {
|
|
|
229
231
|
};
|
|
230
232
|
}
|
|
231
233
|
}
|
|
232
|
-
async function
|
|
234
|
+
async function localWriteCommand(parsed, preview, execute) {
|
|
233
235
|
const fingerprint = approvalTokenFor(preview);
|
|
234
236
|
const suppliedOperationKey = flagString(parsed, "operation-key");
|
|
235
237
|
if (!hasFlag(parsed, "confirm")) {
|
|
@@ -249,6 +251,7 @@ async function writeCommand(parsed, preview, execute) {
|
|
|
249
251
|
action: preview.action,
|
|
250
252
|
fingerprint,
|
|
251
253
|
target: preview.target,
|
|
254
|
+
...(preview.action.startsWith("mastery.") ? { preview } : {}),
|
|
252
255
|
});
|
|
253
256
|
requireConfirmation(false, boundPreview);
|
|
254
257
|
}
|
|
@@ -696,9 +699,6 @@ async function cleanupPlannerUpload(api, conversationId, signed, includeDelete)
|
|
|
696
699
|
.catch(() => undefined);
|
|
697
700
|
}
|
|
698
701
|
}
|
|
699
|
-
function safeGoalUploadName(fileName) {
|
|
700
|
-
return fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
701
|
-
}
|
|
702
702
|
async function sha256File(absolutePath) {
|
|
703
703
|
const handle = await fs.open(absolutePath, "r");
|
|
704
704
|
const hash = createHash("sha256");
|
|
@@ -914,6 +914,8 @@ const CONTENT_LIBRARY_DISCOVERY_LANES = [
|
|
|
914
914
|
"puzzles",
|
|
915
915
|
"wonder",
|
|
916
916
|
"idea-games",
|
|
917
|
+
"makers",
|
|
918
|
+
"drills",
|
|
917
919
|
];
|
|
918
920
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
919
921
|
function parseGoalQueueEntries(value, label) {
|
|
@@ -1551,13 +1553,86 @@ function tierSlots(parsed) {
|
|
|
1551
1553
|
}
|
|
1552
1554
|
return slots;
|
|
1553
1555
|
}
|
|
1554
|
-
|
|
1556
|
+
function cliErrorShape(error) {
|
|
1557
|
+
const cliError = error instanceof CliError
|
|
1558
|
+
? error
|
|
1559
|
+
: new CliError("unexpected_error", error instanceof Error ? error.message : String(error));
|
|
1560
|
+
return {
|
|
1561
|
+
code: cliError.code,
|
|
1562
|
+
message: cliError.message,
|
|
1563
|
+
...(cliError.details === undefined ? {} : { details: cliError.details }),
|
|
1564
|
+
};
|
|
1565
|
+
}
|
|
1566
|
+
function remoteWriteCommand(argv, structuredInput, store) {
|
|
1567
|
+
return async (parsed, preview, execute) => {
|
|
1568
|
+
const fingerprint = approvalTokenFor({
|
|
1569
|
+
...preview,
|
|
1570
|
+
details: {
|
|
1571
|
+
...preview.details,
|
|
1572
|
+
command: parsed.positionals.join(" "),
|
|
1573
|
+
},
|
|
1574
|
+
});
|
|
1575
|
+
const suppliedOperationKey = flagString(parsed, "operation-key");
|
|
1576
|
+
if (!hasFlag(parsed, "confirm")) {
|
|
1577
|
+
const operationKey = await store.stage({
|
|
1578
|
+
argv,
|
|
1579
|
+
structuredInput,
|
|
1580
|
+
preview,
|
|
1581
|
+
fingerprint,
|
|
1582
|
+
});
|
|
1583
|
+
requireConfirmation(false, {
|
|
1584
|
+
...preview,
|
|
1585
|
+
details: {
|
|
1586
|
+
...preview.details,
|
|
1587
|
+
operationKey,
|
|
1588
|
+
retry: "Confirm with the same command path plus --confirm --operation-key <operationKey>. The hosted service will execute the stored exact payload; do not retransmit structured input.",
|
|
1589
|
+
},
|
|
1590
|
+
});
|
|
1591
|
+
}
|
|
1592
|
+
if (!suppliedOperationKey) {
|
|
1593
|
+
throw new CliError("confirmation_required", "A confirmed hosted write requires --operation-key from the preview.", 2, { preview, requiredFlag: "--operation-key" });
|
|
1594
|
+
}
|
|
1595
|
+
const begun = await store.begin(suppliedOperationKey, fingerprint);
|
|
1596
|
+
if (begun.state === "completed")
|
|
1597
|
+
return begun.result;
|
|
1598
|
+
if (begun.state === "failed") {
|
|
1599
|
+
throw new CliError(begun.error.code, begun.error.message, 1, begun.error.details);
|
|
1600
|
+
}
|
|
1601
|
+
try {
|
|
1602
|
+
const result = await withIdempotencyContext({ operationKey: suppliedOperationKey, fingerprint }, execute);
|
|
1603
|
+
await store.complete(suppliedOperationKey, result);
|
|
1604
|
+
return result;
|
|
1605
|
+
}
|
|
1606
|
+
catch (error) {
|
|
1607
|
+
await store.fail(suppliedOperationKey, cliErrorShape(error));
|
|
1608
|
+
throw error;
|
|
1609
|
+
}
|
|
1610
|
+
};
|
|
1611
|
+
}
|
|
1612
|
+
export async function executeRecessCommand(argv, options = {}) {
|
|
1555
1613
|
const parsed = parseArgs(argv);
|
|
1556
1614
|
const [noun, verb] = parsed.positionals;
|
|
1557
1615
|
const commands = buildCommandSchema(HELP);
|
|
1616
|
+
const isRemote = options.transport === "remote";
|
|
1617
|
+
if (isRemote &&
|
|
1618
|
+
(!options.api || !options.config || !options.operationStore)) {
|
|
1619
|
+
throw new CliError("remote_context_required", "Hosted execution requires injected API, configuration, and operation-store adapters.");
|
|
1620
|
+
}
|
|
1621
|
+
if (isRemote) {
|
|
1622
|
+
const forbiddenTransportFlag = ["deliver", "profile", "json"].find((name) => hasFlag(parsed, name));
|
|
1623
|
+
if (forbiddenTransportFlag) {
|
|
1624
|
+
throw new CliError("remote_flag_unavailable", `--${forbiddenTransportFlag} is not available over hosted MCP.`);
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
const writeCommand = options.operationStore
|
|
1628
|
+
? remoteWriteCommand(argv, options.structuredInput, options.operationStore)
|
|
1629
|
+
: localWriteCommand;
|
|
1558
1630
|
// Before the help branch: `--version` parses as a FLAG, so `noun` is
|
|
1559
1631
|
// undefined and `!noun` would return help instead. (Found by running it.)
|
|
1560
|
-
if (noun === "version" || hasFlag(parsed, "version")) {
|
|
1632
|
+
if (noun === "version" || (!noun && hasFlag(parsed, "version"))) {
|
|
1633
|
+
if (isRemote) {
|
|
1634
|
+
throw new CliError("remote_command_unavailable", "The version command is not exposed by the hosted Recess CLI.");
|
|
1635
|
+
}
|
|
1561
1636
|
const unknown = Array.from(parsed.flags.keys()).filter((name) => !["deliver", "json", "version"].includes(name));
|
|
1562
1637
|
if (unknown.length > 0) {
|
|
1563
1638
|
throw new CliError("unknown_flag", `Unknown flag${unknown.length === 1 ? "" : "s"}: ${unknown
|
|
@@ -1576,28 +1651,70 @@ export async function runCommand(argv) {
|
|
|
1576
1651
|
.map((name) => `--${name}`)
|
|
1577
1652
|
.join(", ")}.`, 1, { validFlags: ["--deliver", "--help", "--json", "--profile"] });
|
|
1578
1653
|
}
|
|
1579
|
-
const config = await resolveConfig(flagString(parsed, "profile"));
|
|
1580
|
-
const discovery = resolveCommandDiscovery(config);
|
|
1581
|
-
|
|
1654
|
+
const config = options.config ?? (await resolveConfig(flagString(parsed, "profile")));
|
|
1655
|
+
const discovery = options.discovery ?? resolveCommandDiscovery(config);
|
|
1656
|
+
const visibleCommands = isRemote ? remoteCommands(commands) : commands;
|
|
1657
|
+
return {
|
|
1658
|
+
help: scopedHelp(HELP, visibleCommands, [], discovery, {
|
|
1659
|
+
remoteOnly: isRemote,
|
|
1660
|
+
}),
|
|
1661
|
+
};
|
|
1582
1662
|
}
|
|
1583
1663
|
if (noun === "help" || hasFlag(parsed, "help")) {
|
|
1584
1664
|
const scope = noun === "help" ? parsed.positionals.slice(1) : parsed.positionals;
|
|
1585
|
-
const config = await resolveConfig(flagString(parsed, "profile"));
|
|
1586
|
-
const discovery = resolveCommandDiscovery(config);
|
|
1587
|
-
|
|
1665
|
+
const config = options.config ?? (await resolveConfig(flagString(parsed, "profile")));
|
|
1666
|
+
const discovery = options.discovery ?? resolveCommandDiscovery(config);
|
|
1667
|
+
const visibleCommands = isRemote ? remoteCommands(commands) : commands;
|
|
1668
|
+
return {
|
|
1669
|
+
help: scopedHelp(HELP, visibleCommands, scope, discovery, {
|
|
1670
|
+
remoteOnly: isRemote,
|
|
1671
|
+
}),
|
|
1672
|
+
};
|
|
1673
|
+
}
|
|
1674
|
+
validateInvocation(parsed, commands, { remote: isRemote });
|
|
1675
|
+
if (isRemote) {
|
|
1676
|
+
const command = findCommandSchema(commands, parsed.positionals);
|
|
1677
|
+
if (!command?.remoteCapable) {
|
|
1678
|
+
throw new CliError("remote_command_unavailable", `\`${parsed.positionals.join(" ")}\` is not exposed by the hosted Recess CLI. Use help or agent-context to discover the remote catalog.`);
|
|
1679
|
+
}
|
|
1680
|
+
if (options.structuredInput !== undefined &&
|
|
1681
|
+
![
|
|
1682
|
+
"apps validate",
|
|
1683
|
+
"goal-templates validate-spec",
|
|
1684
|
+
"goal-templates create",
|
|
1685
|
+
].includes(command.path.join(" "))) {
|
|
1686
|
+
throw new CliError("invalid_arguments", "Structured input is accepted only by hosted apps validate and goal-templates validate-spec/create.");
|
|
1687
|
+
}
|
|
1688
|
+
if ((noun === "goal-templates" || noun === "skills") &&
|
|
1689
|
+
options.session?.user.role !== "GUIDE" &&
|
|
1690
|
+
options.session?.user.role !== "ADMIN") {
|
|
1691
|
+
throw new CliError("forbidden", "Hosted goal-template authoring requires a guide or admin.");
|
|
1692
|
+
}
|
|
1693
|
+
if (noun === "skills" &&
|
|
1694
|
+
parsed.positionals[3] !== "recess-goal-authoring") {
|
|
1695
|
+
throw new CliError("remote_command_unavailable", "Hosted skills only exposes recess-goal-authoring.");
|
|
1696
|
+
}
|
|
1697
|
+
const granted = options.grantedScopes ?? new Set();
|
|
1698
|
+
const missing = command.oauthScopes.filter((scope) => !granted.has(scope));
|
|
1699
|
+
if (missing.length > 0) {
|
|
1700
|
+
throw new CliError("insufficient_scope", `This command requires OAuth scope${missing.length === 1 ? "" : "s"}: ${missing.join(", ")}.`, 1, { requiredScopes: command.oauthScopes });
|
|
1701
|
+
}
|
|
1588
1702
|
}
|
|
1589
|
-
validateInvocation(parsed, commands);
|
|
1590
1703
|
if (noun === "agent-context") {
|
|
1591
|
-
const [profiles, config] =
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1704
|
+
const [profiles, config] = isRemote
|
|
1705
|
+
? [{ profiles: [] }, options.config ?? (await resolveConfig(undefined))]
|
|
1706
|
+
: await Promise.all([
|
|
1707
|
+
listProfiles(),
|
|
1708
|
+
options.config ?? resolveConfig(flagString(parsed, "profile")),
|
|
1709
|
+
]);
|
|
1710
|
+
const discovery = options.discovery ?? resolveCommandDiscovery(config);
|
|
1596
1711
|
return agentContext(commands, {
|
|
1597
|
-
cliVersion:
|
|
1712
|
+
cliVersion: options.versions?.cliVersion ??
|
|
1713
|
+
(isRemote ? "hosted" : await readCliVersion()),
|
|
1598
1714
|
availableProfiles: profiles.profiles.map((profile) => profile.name),
|
|
1599
1715
|
feedbackUpstreamConfigured: Boolean(process.env.RECESS_CLI_FEEDBACK_ENDPOINT),
|
|
1600
1716
|
discovery,
|
|
1717
|
+
remoteOnly: isRemote,
|
|
1601
1718
|
});
|
|
1602
1719
|
}
|
|
1603
1720
|
if (noun === "profile") {
|
|
@@ -1701,8 +1818,17 @@ export async function runCommand(argv) {
|
|
|
1701
1818
|
}));
|
|
1702
1819
|
}
|
|
1703
1820
|
}
|
|
1704
|
-
const config = await resolveConfig(flagString(parsed, "profile"));
|
|
1821
|
+
const config = options.config ?? (await resolveConfig(flagString(parsed, "profile")));
|
|
1705
1822
|
const requestReason = noun === "auth" ? undefined : requiredRequestReason(parsed);
|
|
1823
|
+
if (noun === "doctor" && isRemote) {
|
|
1824
|
+
return {
|
|
1825
|
+
transport: "streamable-http-mcp",
|
|
1826
|
+
resource: options.remoteResource ?? config.apiOrigin.replace(/\/$/, "") + "/mcp",
|
|
1827
|
+
authenticated: true,
|
|
1828
|
+
session: options.session ?? null,
|
|
1829
|
+
scopes: Array.from(options.grantedScopes ?? []).sort(),
|
|
1830
|
+
};
|
|
1831
|
+
}
|
|
1706
1832
|
if (noun === "doctor")
|
|
1707
1833
|
return doctor(config, requestReason);
|
|
1708
1834
|
if (noun === "setup") {
|
|
@@ -1772,7 +1898,7 @@ export async function runCommand(argv) {
|
|
|
1772
1898
|
}
|
|
1773
1899
|
throw new CliError("invalid_arguments", "Use auth login, request, poll, status, or logout.");
|
|
1774
1900
|
}
|
|
1775
|
-
const api = new RecessAdminApi(config, requestReason);
|
|
1901
|
+
const api = options.api ?? new RecessAdminApi(config, requestReason);
|
|
1776
1902
|
api.requireAuth();
|
|
1777
1903
|
if (noun === "village") {
|
|
1778
1904
|
const targetWorldId = flagString(parsed, "world") ?? "village-1";
|
|
@@ -2536,7 +2662,16 @@ export async function runCommand(argv) {
|
|
|
2536
2662
|
return runApplicationsCommand({ parsed, api, writeCommand });
|
|
2537
2663
|
}
|
|
2538
2664
|
if (noun === "apps") {
|
|
2539
|
-
return runAppsCommand({
|
|
2665
|
+
return runAppsCommand({
|
|
2666
|
+
parsed,
|
|
2667
|
+
api,
|
|
2668
|
+
writeCommand,
|
|
2669
|
+
transport: options.transport,
|
|
2670
|
+
structuredInput: options.structuredInput,
|
|
2671
|
+
});
|
|
2672
|
+
}
|
|
2673
|
+
if (noun === "mastery") {
|
|
2674
|
+
return runMasteryCommand({ parsed, api, writeCommand });
|
|
2540
2675
|
}
|
|
2541
2676
|
if (noun === "school") {
|
|
2542
2677
|
return runSchoolCommand({ parsed, api, writeCommand });
|
|
@@ -3127,7 +3262,7 @@ export async function runCommand(argv) {
|
|
|
3127
3262
|
// infers whether `get` means guardian guidance or staff-only operations.
|
|
3128
3263
|
const audience = assertChoice(verb, ["admin", "guardian"], "skill audience");
|
|
3129
3264
|
const action = positional(parsed, 2, "skills action");
|
|
3130
|
-
const refresh = hasFlag(parsed, "refresh");
|
|
3265
|
+
const refresh = isRemote || hasFlag(parsed, "refresh");
|
|
3131
3266
|
if (action === "list") {
|
|
3132
3267
|
const query = flagString(parsed, "query");
|
|
3133
3268
|
const category = flagString(parsed, "category");
|
|
@@ -3146,7 +3281,8 @@ export async function runCommand(argv) {
|
|
|
3146
3281
|
},
|
|
3147
3282
|
},
|
|
3148
3283
|
}));
|
|
3149
|
-
|
|
3284
|
+
if (!isRemote)
|
|
3285
|
+
await writeSkillCache(config.apiOrigin, cacheKey, data);
|
|
3150
3286
|
return { ...data, cached: false };
|
|
3151
3287
|
}
|
|
3152
3288
|
if (action === "get") {
|
|
@@ -3171,7 +3307,8 @@ export async function runCommand(argv) {
|
|
|
3171
3307
|
},
|
|
3172
3308
|
},
|
|
3173
3309
|
}));
|
|
3174
|
-
|
|
3310
|
+
if (!isRemote)
|
|
3311
|
+
await writeSkillCache(config.apiOrigin, cacheKey, data);
|
|
3175
3312
|
return { ...data, cached: false };
|
|
3176
3313
|
}
|
|
3177
3314
|
throw new CliError("invalid_arguments", "Use skills guardian list|get or skills admin list|get.");
|
|
@@ -3258,7 +3395,12 @@ export async function runCommand(argv) {
|
|
|
3258
3395
|
timeoutSeconds > 7200) {
|
|
3259
3396
|
throw new CliError("invalid_arguments", "--timeout must be an integer number of seconds from 10 through 7200.");
|
|
3260
3397
|
}
|
|
3261
|
-
const
|
|
3398
|
+
const allowPossibleDuplicate = hasFlag(parsed, "allow-possible-duplicate");
|
|
3399
|
+
const body = {
|
|
3400
|
+
stage,
|
|
3401
|
+
items: input.items,
|
|
3402
|
+
...(allowPossibleDuplicate ? { allowPossibleDuplicate: true } : {}),
|
|
3403
|
+
};
|
|
3262
3404
|
const preview = {
|
|
3263
3405
|
action: "content-library.submit",
|
|
3264
3406
|
target: { stage, resourceCount: input.items.length },
|
|
@@ -3267,7 +3409,9 @@ export async function runCommand(argv) {
|
|
|
3267
3409
|
admission: stage === "polish"
|
|
3268
3410
|
? "Starts automatic decoration; the island promotes each successful resource to LIVE after the polish and deterministic tail finish."
|
|
3269
3411
|
: "Holds each new resource in REVIEW until an admin approves it.",
|
|
3270
|
-
duplicateBehavior:
|
|
3412
|
+
duplicateBehavior: allowPossibleDuplicate
|
|
3413
|
+
? "Exact duplicates and previously rejected URLs are still returned as duplicates; fuzzy possible-duplicate candidates are admitted to REVIEW for a curator to settle."
|
|
3414
|
+
: "Existing URLs are returned as duplicates and are not overwritten.",
|
|
3271
3415
|
deployOrderFence: "The server verifies the island's review/polish lifecycle capability before its first write.",
|
|
3272
3416
|
waitForLive: wait,
|
|
3273
3417
|
...(wait ? { timeoutSeconds } : {}),
|
|
@@ -3289,6 +3433,21 @@ export async function runCommand(argv) {
|
|
|
3289
3433
|
throw new CliError("invalid_arguments", "Use content-library search, status, set-stage, or submit.");
|
|
3290
3434
|
}
|
|
3291
3435
|
if (noun === "goal-templates") {
|
|
3436
|
+
const readTemplateDocument = async () => {
|
|
3437
|
+
if (!isRemote) {
|
|
3438
|
+
return readJsonFile(flagString(parsed, "file", { required: true }), "Template file");
|
|
3439
|
+
}
|
|
3440
|
+
const input = options.structuredInput;
|
|
3441
|
+
const template = input?.template;
|
|
3442
|
+
if (!input ||
|
|
3443
|
+
Object.keys(input).some((key) => key !== "template") ||
|
|
3444
|
+
!template ||
|
|
3445
|
+
typeof template !== "object" ||
|
|
3446
|
+
Array.isArray(template)) {
|
|
3447
|
+
throw new CliError("invalid_arguments", "Supply the complete template document as input.template.");
|
|
3448
|
+
}
|
|
3449
|
+
return template;
|
|
3450
|
+
};
|
|
3292
3451
|
if (verb === "list") {
|
|
3293
3452
|
const query = flagString(parsed, "query");
|
|
3294
3453
|
const kind = flagString(parsed, "kind");
|
|
@@ -3362,7 +3521,7 @@ export async function runCommand(argv) {
|
|
|
3362
3521
|
return version;
|
|
3363
3522
|
}
|
|
3364
3523
|
if (verb === "validate-spec") {
|
|
3365
|
-
const document = parseGoalTemplateDocument(await
|
|
3524
|
+
const document = parseGoalTemplateDocument(await readTemplateDocument());
|
|
3366
3525
|
// A read-only validation: no gate, and iterating on it is the whole point.
|
|
3367
3526
|
return {
|
|
3368
3527
|
slug: document.slug,
|
|
@@ -3376,8 +3535,10 @@ export async function runCommand(argv) {
|
|
|
3376
3535
|
};
|
|
3377
3536
|
}
|
|
3378
3537
|
if (verb === "create") {
|
|
3379
|
-
const filePath =
|
|
3380
|
-
|
|
3538
|
+
const filePath = isRemote
|
|
3539
|
+
? undefined
|
|
3540
|
+
: flagString(parsed, "file", { required: true });
|
|
3541
|
+
const authoredDocument = parseGoalTemplateDocument(await readTemplateDocument());
|
|
3381
3542
|
const coinAmountOverride = flagNumber(parsed, "coin-amount");
|
|
3382
3543
|
if (coinAmountOverride !== undefined &&
|
|
3383
3544
|
(!Number.isInteger(coinAmountOverride) || coinAmountOverride < 1)) {
|
|
@@ -3402,13 +3563,15 @@ export async function runCommand(argv) {
|
|
|
3402
3563
|
},
|
|
3403
3564
|
}));
|
|
3404
3565
|
if (!validation.valid) {
|
|
3405
|
-
throw new CliError("invalid_spec", `The setupWorkflowSpec is invalid, so nothing was created: ${validation.error}`, 1, { file: path.resolve(filePath) });
|
|
3566
|
+
throw new CliError("invalid_spec", `The setupWorkflowSpec is invalid, so nothing was created: ${validation.error}`, 1, filePath ? { file: path.resolve(filePath) } : { input: "template" });
|
|
3406
3567
|
}
|
|
3407
3568
|
return writeCommand(parsed, {
|
|
3408
3569
|
action: "create a NEW global goal template (DETERMINISTIC_WORKFLOW — this cannot be converted back)",
|
|
3409
3570
|
target: { slug: document.slug, title: document.title },
|
|
3410
3571
|
request: {
|
|
3411
|
-
|
|
3572
|
+
...(filePath
|
|
3573
|
+
? { file: path.resolve(filePath) }
|
|
3574
|
+
: { template: document }),
|
|
3412
3575
|
kind: document.kind,
|
|
3413
3576
|
setupAudience: document.setupAudience,
|
|
3414
3577
|
category: document.category ?? null,
|
|
@@ -4239,8 +4402,25 @@ export async function runCommand(argv) {
|
|
|
4239
4402
|
}
|
|
4240
4403
|
if (noun === "students") {
|
|
4241
4404
|
if (verb === "list") {
|
|
4242
|
-
|
|
4243
|
-
|
|
4405
|
+
const requestedScope = flagString(parsed, "scope");
|
|
4406
|
+
const scope = requestedScope
|
|
4407
|
+
? assertChoice(requestedScope, ["mine", "family"], "--scope")
|
|
4408
|
+
: isRemote
|
|
4409
|
+
? "mine"
|
|
4410
|
+
: undefined;
|
|
4411
|
+
if (scope === "mine") {
|
|
4412
|
+
return unwrap(await api.client.GET("/tutor/students", {
|
|
4413
|
+
params: { query: { scope: "mine" } },
|
|
4414
|
+
}));
|
|
4415
|
+
}
|
|
4416
|
+
if (scope === "family") {
|
|
4417
|
+
return unwrap(await api.client.GET("/family/kids", {
|
|
4418
|
+
params: { query: { includeSelf: "false" } },
|
|
4419
|
+
}));
|
|
4420
|
+
}
|
|
4421
|
+
// A guide has no family — their roster is the tutor surface's, which the
|
|
4422
|
+
// server scopes per surface (`STUDENT_SCOPE_POLICY_BY_SURFACE`: manual
|
|
4423
|
+
// assignments plus live cohorts, never the institution roster). The
|
|
4244
4424
|
// stored identity is absent on an env-cookie session, so fall back to
|
|
4245
4425
|
// asking the server who this session belongs to.
|
|
4246
4426
|
// The stored identity only describes a `auth login` session; an
|
|
@@ -4278,13 +4458,38 @@ export async function runCommand(argv) {
|
|
|
4278
4458
|
}));
|
|
4279
4459
|
}
|
|
4280
4460
|
if (verb === "todos") {
|
|
4281
|
-
const
|
|
4461
|
+
const student = flagString(parsed, "student", { required: true });
|
|
4462
|
+
const date = flagString(parsed, "date");
|
|
4463
|
+
if (date !== undefined &&
|
|
4464
|
+
date !== "today" &&
|
|
4465
|
+
(!/^\d{4}-\d{2}-\d{2}$/.test(date) ||
|
|
4466
|
+
!Number.isFinite(Date.parse(`${date}T00:00:00.000Z`)) ||
|
|
4467
|
+
new Date(`${date}T00:00:00.000Z`).toISOString().slice(0, 10) !== date)) {
|
|
4468
|
+
throw new CliError("invalid_arguments", "--date must be today or a valid YYYY-MM-DD calendar date.");
|
|
4469
|
+
}
|
|
4470
|
+
const cursor = flagString(parsed, "cursor");
|
|
4282
4471
|
const limit = flagNumber(parsed, "limit");
|
|
4472
|
+
let studentId = student;
|
|
4473
|
+
if (!/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(student)) {
|
|
4474
|
+
const roster = unwrap(await api.client.GET("/tutor/students", {
|
|
4475
|
+
params: { query: { scope: "mine" } },
|
|
4476
|
+
}));
|
|
4477
|
+
const normalize = (name) => name.trim().replace(/\s+/g, " ").toLowerCase();
|
|
4478
|
+
const matches = roster.items.filter((item) => normalize(item.name) === normalize(student));
|
|
4479
|
+
if (matches.length !== 1) {
|
|
4480
|
+
throw new CliError(matches.length ? "ambiguous_student" : "student_not_found", matches.length
|
|
4481
|
+
? "Multiple students have that name. Ask which student, then retry with their ID."
|
|
4482
|
+
: "No exact name match in your roster. Use students list to find the student's ID.", 1, { matches: matches.map(({ userId, name }) => ({ userId, name })) });
|
|
4483
|
+
}
|
|
4484
|
+
studentId = matches[0].userId;
|
|
4485
|
+
}
|
|
4283
4486
|
return unwrap(await api.client.GET("/studio/students/{studentId}/todos", {
|
|
4284
4487
|
params: {
|
|
4285
4488
|
path: { studentId },
|
|
4286
4489
|
query: {
|
|
4287
4490
|
...(limit === undefined ? {} : { limit }),
|
|
4491
|
+
...(date === undefined ? {} : { date }),
|
|
4492
|
+
...(cursor === undefined ? {} : { cursor }),
|
|
4288
4493
|
...(hasFlag(parsed, "analyzed") ? { analyzed: true } : {}),
|
|
4289
4494
|
},
|
|
4290
4495
|
},
|
|
@@ -4641,7 +4846,8 @@ export async function runCommand(argv) {
|
|
|
4641
4846
|
throw new CliError("invalid_arguments", `PDF size must be between 1 byte and ${GOAL_PDF_UPLOAD_MAX_BYTES} bytes.`);
|
|
4642
4847
|
}
|
|
4643
4848
|
const fileName = path.basename(absolutePath);
|
|
4644
|
-
const workspacePath = flagString(parsed, "path") ??
|
|
4849
|
+
const workspacePath = flagString(parsed, "path") ??
|
|
4850
|
+
`uploads/${sanitizeWorkspaceUploadName(fileName)}`;
|
|
4645
4851
|
if (!workspacePath.startsWith("uploads/")) {
|
|
4646
4852
|
throw new CliError("invalid_arguments", "Textbook PDFs must be attached under uploads/.");
|
|
4647
4853
|
}
|
|
@@ -4970,4 +5176,7 @@ export async function runCommand(argv) {
|
|
|
4970
5176
|
}
|
|
4971
5177
|
throw new CliError("unknown_command", `Unknown command: ${parsed.positionals.join(" ")}\n\n${HELP}`);
|
|
4972
5178
|
}
|
|
5179
|
+
export async function runCommand(argv) {
|
|
5180
|
+
return executeRecessCommand(argv);
|
|
5181
|
+
}
|
|
4973
5182
|
//# sourceMappingURL=cli.js.map
|