recess-cli 3.3.1 → 3.4.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 +6 -1
- package/dist/args.js +1 -0
- package/dist/cli.js +205 -6
- package/dist/command-schema.js +1 -0
- package/dist/help.js +13 -0
- package/dist/http.js +21 -0
- package/dist/index.js +8 -0
- package/dist/update-notice.js +85 -0
- package/package.json +1 -1
- package/skill/recess-cli/SKILL.md +3 -0
package/dist/api.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
import createClient from "openapi-fetch";
|
|
3
3
|
import { apiError, CliError } from "./errors.js";
|
|
4
|
-
import { cliRequestHeaders, markCliRequest, RECESS_CLIENT_CLI, } from "./http.js";
|
|
4
|
+
import { cliRequestHeaders, markCliRequest, noteServerCliVersions, RECESS_CLIENT_CLI, } from "./http.js";
|
|
5
5
|
const idempotencyContext = new AsyncLocalStorage();
|
|
6
6
|
export function withIdempotencyContext(context, execute) {
|
|
7
7
|
return idempotencyContext.run(context, execute);
|
|
@@ -40,6 +40,10 @@ export class RecessAdminApi {
|
|
|
40
40
|
applyIdempotencyHeaders(request.headers);
|
|
41
41
|
return request;
|
|
42
42
|
},
|
|
43
|
+
onResponse({ response }) {
|
|
44
|
+
noteServerCliVersions(response.headers);
|
|
45
|
+
return response;
|
|
46
|
+
},
|
|
43
47
|
});
|
|
44
48
|
}
|
|
45
49
|
requireAuth() {
|
|
@@ -55,6 +59,7 @@ export class RecessAdminApi {
|
|
|
55
59
|
const response = await fetch(new URL(path, this.config.apiOrigin), {
|
|
56
60
|
headers: cliRequestHeaders({ cookie: this.config.sessionCookie }, this.reason, this.clientTag),
|
|
57
61
|
});
|
|
62
|
+
noteServerCliVersions(response.headers);
|
|
58
63
|
const text = await response.text();
|
|
59
64
|
let body = text;
|
|
60
65
|
try {
|
package/dist/args.js
CHANGED
package/dist/cli.js
CHANGED
|
@@ -21,7 +21,8 @@ import { CliError } from "./errors.js";
|
|
|
21
21
|
import { listFeedback, submitFeedback } from "./feedback.js";
|
|
22
22
|
import { HELP } from "./help.js";
|
|
23
23
|
export { HELP };
|
|
24
|
-
import { cliRequestHeaders, requireCliRequestReason } from "./http.js";
|
|
24
|
+
import { cliRequestHeaders, noteServerCliVersions, requireCliRequestReason, } from "./http.js";
|
|
25
|
+
import { runSelfUpdate, UPGRADE_COMMAND, updateNoticeText, updateStatus, } from "./update-notice.js";
|
|
25
26
|
import { requireConfirmation } from "./safety.js";
|
|
26
27
|
import { installSkill, isEphemeralInstall, readBundledSkillVersion, readCliVersion, } from "./setup.js";
|
|
27
28
|
import { compareVersions, updateSkillFromServer } from "./skill-update.js";
|
|
@@ -174,6 +175,7 @@ async function doctor(config, reason) {
|
|
|
174
175
|
headers: cliRequestHeaders(undefined, reason),
|
|
175
176
|
signal: AbortSignal.timeout(5000),
|
|
176
177
|
});
|
|
178
|
+
noteServerCliVersions(response.headers);
|
|
177
179
|
checks.api = { reachable: response.ok, status: response.status };
|
|
178
180
|
}
|
|
179
181
|
catch (error) {
|
|
@@ -183,6 +185,9 @@ async function doctor(config, reason) {
|
|
|
183
185
|
};
|
|
184
186
|
}
|
|
185
187
|
checks.skill = await skillStatus(config, reason);
|
|
188
|
+
const update = updateStatus(await readCliVersion());
|
|
189
|
+
const updateAction = updateNoticeText(update);
|
|
190
|
+
checks.update = updateAction ? { ...update, action: updateAction } : update;
|
|
186
191
|
return checks;
|
|
187
192
|
}
|
|
188
193
|
/**
|
|
@@ -1438,6 +1443,19 @@ function parseGoalTemplateDocument(doc) {
|
|
|
1438
1443
|
* Resolve a template reference that may be a UUID or a slug. Slugs are what an
|
|
1439
1444
|
* author types and what the skill's prose uses; the routes take a UUID only.
|
|
1440
1445
|
*/
|
|
1446
|
+
/**
|
|
1447
|
+
* `/challenges-v2/{id}/preview` on the web app: the template as the card a
|
|
1448
|
+
* kid would see, with no chat around it. Returned by `goal-templates get`
|
|
1449
|
+
* and `create` so an agent can open it, or hand it to the human, right after
|
|
1450
|
+
* authoring.
|
|
1451
|
+
*/
|
|
1452
|
+
function goalTemplatePreviewUrl(webOrigin, templateId) {
|
|
1453
|
+
return new URL(`/challenges-v2/${encodeURIComponent(templateId)}/preview`, webOrigin).toString();
|
|
1454
|
+
}
|
|
1455
|
+
/** Runs the template with Rocky as a kid would (staff only; ephemeral, never touches real todos). */
|
|
1456
|
+
function goalTemplatePlayTestUrl(webOrigin, templateId) {
|
|
1457
|
+
return new URL(`/challenges-v2/${encodeURIComponent(templateId)}?play=1`, webOrigin).toString();
|
|
1458
|
+
}
|
|
1441
1459
|
async function resolveGoalTemplateId(api, reference) {
|
|
1442
1460
|
if (UUID_RE.test(reference))
|
|
1443
1461
|
return reference;
|
|
@@ -1910,7 +1928,11 @@ export async function executeRecessCommand(argv, options = {}) {
|
|
|
1910
1928
|
}
|
|
1911
1929
|
}
|
|
1912
1930
|
const config = options.config ?? (await resolveConfig(flagString(parsed, "profile")));
|
|
1913
|
-
const requestReason = noun === "auth"
|
|
1931
|
+
const requestReason = noun === "auth"
|
|
1932
|
+
? undefined
|
|
1933
|
+
: noun === "update"
|
|
1934
|
+
? requireCliRequestReason(flagString(parsed, "reason") ?? "check for a recess CLI update")
|
|
1935
|
+
: requiredRequestReason(parsed);
|
|
1914
1936
|
if (noun === "doctor" && isRemote) {
|
|
1915
1937
|
return {
|
|
1916
1938
|
transport: "streamable-http-mcp",
|
|
@@ -1922,6 +1944,31 @@ export async function executeRecessCommand(argv, options = {}) {
|
|
|
1922
1944
|
}
|
|
1923
1945
|
if (noun === "doctor")
|
|
1924
1946
|
return doctor(config, requestReason);
|
|
1947
|
+
if (noun === "update") {
|
|
1948
|
+
if (isRemote) {
|
|
1949
|
+
throw new CliError("remote_command_unavailable", "The update command is not exposed by the hosted Recess CLI.");
|
|
1950
|
+
}
|
|
1951
|
+
const cliVersion = await readCliVersion();
|
|
1952
|
+
const probe = await fetch(new URL("/health", config.apiOrigin), {
|
|
1953
|
+
headers: cliRequestHeaders(undefined, requestReason),
|
|
1954
|
+
signal: AbortSignal.timeout(5000),
|
|
1955
|
+
}).catch(() => null);
|
|
1956
|
+
if (probe)
|
|
1957
|
+
noteServerCliVersions(probe.headers);
|
|
1958
|
+
const status = updateStatus(cliVersion);
|
|
1959
|
+
if (status.state === "current")
|
|
1960
|
+
return { ...status, updated: false };
|
|
1961
|
+
if (isEphemeralInstall()) {
|
|
1962
|
+
throw new CliError("invalid_arguments", "This recess is running from a temporary npx cache. Run `npx recess-cli@latest` or install it with `npm install -g recess-cli@latest`.");
|
|
1963
|
+
}
|
|
1964
|
+
requireConfirmation(hasFlag(parsed, "confirm"), {
|
|
1965
|
+
action: "install the latest recess CLI globally with npm",
|
|
1966
|
+
target: { ...status },
|
|
1967
|
+
request: { command: UPGRADE_COMMAND },
|
|
1968
|
+
});
|
|
1969
|
+
const result = await runSelfUpdate();
|
|
1970
|
+
return { ...status, updated: true, ...result };
|
|
1971
|
+
}
|
|
1925
1972
|
if (noun === "setup") {
|
|
1926
1973
|
// Always lay down the bundled copy first: it is the floor, and it is the
|
|
1927
1974
|
// only copy guaranteed to match this binary. The served upgrade below is
|
|
@@ -2484,6 +2531,145 @@ export async function executeRecessCommand(argv, options = {}) {
|
|
|
2484
2531
|
body,
|
|
2485
2532
|
})));
|
|
2486
2533
|
}
|
|
2534
|
+
if (noun === "users" && verb === "school") {
|
|
2535
|
+
const action = parsed.positionals[2];
|
|
2536
|
+
const userId = positional(parsed, 3, "user ID");
|
|
2537
|
+
if (action === "status")
|
|
2538
|
+
return unwrap(await api.client.GET("/admin/users/{userId}/school-status", {
|
|
2539
|
+
params: { path: { userId } },
|
|
2540
|
+
}));
|
|
2541
|
+
if (action === "history")
|
|
2542
|
+
return unwrap(await api.client.GET("/admin/users/{userId}/school-history", {
|
|
2543
|
+
params: {
|
|
2544
|
+
path: { userId },
|
|
2545
|
+
query: {
|
|
2546
|
+
limit: flagNumber(parsed, "limit"),
|
|
2547
|
+
cursor: flagNumber(parsed, "cursor"),
|
|
2548
|
+
},
|
|
2549
|
+
},
|
|
2550
|
+
}));
|
|
2551
|
+
if (action === "complete-action") {
|
|
2552
|
+
const actionId = positional(parsed, 4, "action ID");
|
|
2553
|
+
const body = { reason: flagString(parsed, "note", { required: true }) };
|
|
2554
|
+
return writeCommand(parsed, {
|
|
2555
|
+
action: "record verified provider cancellation or deactivation; does not contact the provider",
|
|
2556
|
+
target: { userId, actionId },
|
|
2557
|
+
request: body,
|
|
2558
|
+
}, async () => unwrap(await api.client.POST("/admin/users/{userId}/school-actions/{actionId}/complete", { params: { path: { userId, actionId } }, body })));
|
|
2559
|
+
}
|
|
2560
|
+
const status = action === "withdraw" ? "WITHDRAWN" : "ACTIVE";
|
|
2561
|
+
const tierId = action === "restore"
|
|
2562
|
+
? assertChoice(flagString(parsed, "tier", { required: true }), SCHOOL_TIER_IDS, "--tier")
|
|
2563
|
+
: undefined;
|
|
2564
|
+
const body = {
|
|
2565
|
+
status,
|
|
2566
|
+
reason: flagString(parsed, "note", { required: true }),
|
|
2567
|
+
expectedUpdatedAt: flagString(parsed, "expected-updated-at", {
|
|
2568
|
+
required: true,
|
|
2569
|
+
}),
|
|
2570
|
+
...(tierId ? { tierId } : {}),
|
|
2571
|
+
};
|
|
2572
|
+
return writeCommand(parsed, {
|
|
2573
|
+
action: status === "WITHDRAWN"
|
|
2574
|
+
? "withdraw SCHOOL student, cancel subscriptions immediately without refund, and revoke external learning access"
|
|
2575
|
+
: "restore Recess access; billing, classes, and external accounts are not restarted",
|
|
2576
|
+
target: { userId },
|
|
2577
|
+
request: body,
|
|
2578
|
+
details: status === "WITHDRAWN"
|
|
2579
|
+
? {
|
|
2580
|
+
cancelSubscriptionsImmediately: true,
|
|
2581
|
+
refunds: false,
|
|
2582
|
+
revokeExternalAccess: true,
|
|
2583
|
+
ixlRemoval: "nightly roster import",
|
|
2584
|
+
manualProviderFollowUpMayBeRequired: true,
|
|
2585
|
+
}
|
|
2586
|
+
: { restartsBilling: false, restoresExternalAccounts: false },
|
|
2587
|
+
}, async () => unwrap(await api.client.PATCH("/admin/users/{userId}/school-status", {
|
|
2588
|
+
params: { path: { userId } },
|
|
2589
|
+
body,
|
|
2590
|
+
})));
|
|
2591
|
+
}
|
|
2592
|
+
if (noun === "users" && verb === "enrollment") {
|
|
2593
|
+
if (parsed.positionals[2] === "set-partner-school-cc") {
|
|
2594
|
+
const school = positional(parsed, 3, "partner school ID or slug");
|
|
2595
|
+
const email = flagString(parsed, "email");
|
|
2596
|
+
const clear = hasFlag(parsed, "clear");
|
|
2597
|
+
if (!!email === clear) {
|
|
2598
|
+
throw new CliError("invalid_arguments", "Specify either --email <address> or --clear.");
|
|
2599
|
+
}
|
|
2600
|
+
const { schools } = unwrap(await api.client.GET("/admin/users/enrollment-partner-schools"));
|
|
2601
|
+
const partnerSchool = schools.find((entry) => entry.id === school || entry.slug === school);
|
|
2602
|
+
if (!partnerSchool)
|
|
2603
|
+
throw new CliError("invalid_arguments", "Unknown partner school. Use users enrollment list-partner-schools.");
|
|
2604
|
+
const body = {
|
|
2605
|
+
communicationsCcEmail: email?.trim().toLowerCase() ?? null,
|
|
2606
|
+
};
|
|
2607
|
+
return writeCommand(parsed, {
|
|
2608
|
+
action: "set partner school communications CC",
|
|
2609
|
+
target: {
|
|
2610
|
+
id: partnerSchool.id,
|
|
2611
|
+
slug: partnerSchool.slug,
|
|
2612
|
+
name: partnerSchool.name,
|
|
2613
|
+
},
|
|
2614
|
+
request: body,
|
|
2615
|
+
}, async () => unwrap(await api.client.PATCH("/admin/users/enrollment-partner-schools/{schoolId}/communications", {
|
|
2616
|
+
params: { path: { schoolId: partnerSchool.id } },
|
|
2617
|
+
body,
|
|
2618
|
+
})));
|
|
2619
|
+
}
|
|
2620
|
+
if (parsed.positionals[2] === "history") {
|
|
2621
|
+
const userId = positional(parsed, 3, "user ID");
|
|
2622
|
+
return unwrap(await api.client.GET("/admin/users/{userId}/enrollment-history", {
|
|
2623
|
+
params: {
|
|
2624
|
+
path: { userId },
|
|
2625
|
+
query: {
|
|
2626
|
+
limit: flagNumber(parsed, "limit"),
|
|
2627
|
+
cursor: flagNumber(parsed, "cursor"),
|
|
2628
|
+
},
|
|
2629
|
+
},
|
|
2630
|
+
}));
|
|
2631
|
+
}
|
|
2632
|
+
if (parsed.positionals[2] === "list-partner-schools") {
|
|
2633
|
+
return unwrap(await api.client.GET("/admin/users/enrollment-partner-schools"));
|
|
2634
|
+
}
|
|
2635
|
+
const userId = positional(parsed, 3, "user ID");
|
|
2636
|
+
const entities = {
|
|
2637
|
+
"online-learning-program": "ONLINE_LEARNING_PROGRAM",
|
|
2638
|
+
"recess-academy-school": "RECESS_ACADEMY_SCHOOL",
|
|
2639
|
+
"partner-school": "PARTNER_SCHOOL",
|
|
2640
|
+
unset: null,
|
|
2641
|
+
};
|
|
2642
|
+
const entity = assertChoice(flagString(parsed, "entity", { required: true }), [
|
|
2643
|
+
"online-learning-program",
|
|
2644
|
+
"recess-academy-school",
|
|
2645
|
+
"partner-school",
|
|
2646
|
+
"unset",
|
|
2647
|
+
], "--entity");
|
|
2648
|
+
const partner = flagString(parsed, "partner-school");
|
|
2649
|
+
if ((entity === "partner-school") !== !!partner) {
|
|
2650
|
+
throw new CliError("invalid_arguments", "--partner-school is required only with --entity partner-school.");
|
|
2651
|
+
}
|
|
2652
|
+
const schools = partner
|
|
2653
|
+
? unwrap(await api.client.GET("/admin/users/enrollment-partner-schools"))
|
|
2654
|
+
.schools
|
|
2655
|
+
: [];
|
|
2656
|
+
const partnerSchool = schools.find((school) => school.id === partner || school.slug === partner);
|
|
2657
|
+
if (partner && !partnerSchool) {
|
|
2658
|
+
throw new CliError("invalid_arguments", "Unknown partner school. Use users enrollment list-partner-schools to find its ID or slug.");
|
|
2659
|
+
}
|
|
2660
|
+
const body = {
|
|
2661
|
+
enrollmentEntity: entities[entity],
|
|
2662
|
+
enrollmentPartnerSchoolId: partnerSchool?.id ?? null,
|
|
2663
|
+
};
|
|
2664
|
+
return writeCommand(parsed, {
|
|
2665
|
+
action: "set user enrollment entity",
|
|
2666
|
+
target: { userId, partnerSchool: partnerSchool ?? null },
|
|
2667
|
+
request: body,
|
|
2668
|
+
}, async () => unwrap(await api.client.PATCH("/admin/users/{userId}/enrollment-entity", {
|
|
2669
|
+
params: { path: { userId } },
|
|
2670
|
+
body,
|
|
2671
|
+
})));
|
|
2672
|
+
}
|
|
2487
2673
|
if (noun === "users" && verb === "get") {
|
|
2488
2674
|
const userId = positional(parsed, 2, "user ID");
|
|
2489
2675
|
return unwrap(await api.client.GET("/admin/users/{userId}", {
|
|
@@ -3708,7 +3894,13 @@ export async function executeRecessCommand(argv, options = {}) {
|
|
|
3708
3894
|
setupWorkflowSpec: template.setupWorkflowSpec,
|
|
3709
3895
|
};
|
|
3710
3896
|
}
|
|
3711
|
-
|
|
3897
|
+
// The card a kid would see, with no chat around it: the page to open
|
|
3898
|
+
// (or hand to the human) after authoring.
|
|
3899
|
+
return {
|
|
3900
|
+
...template,
|
|
3901
|
+
previewUrl: goalTemplatePreviewUrl(config.webOrigin, template.id),
|
|
3902
|
+
playTestUrl: goalTemplatePlayTestUrl(config.webOrigin, template.id),
|
|
3903
|
+
};
|
|
3712
3904
|
}
|
|
3713
3905
|
if (verb === "versions") {
|
|
3714
3906
|
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
@@ -3800,9 +3992,16 @@ export async function executeRecessCommand(argv, options = {}) {
|
|
|
3800
3992
|
specInventory: validation.totals,
|
|
3801
3993
|
note: "Setup mode is DETERMINISTIC_WORKFLOW and is one-way: this template can never be converted back to a chat-driven setup.",
|
|
3802
3994
|
},
|
|
3803
|
-
}, async () =>
|
|
3804
|
-
|
|
3805
|
-
|
|
3995
|
+
}, async () => {
|
|
3996
|
+
const created = unwrap(await api.client.POST("/ai/goal-templates/", {
|
|
3997
|
+
body: document,
|
|
3998
|
+
}));
|
|
3999
|
+
return {
|
|
4000
|
+
...created,
|
|
4001
|
+
previewUrl: goalTemplatePreviewUrl(config.webOrigin, created.id),
|
|
4002
|
+
playTestUrl: goalTemplatePlayTestUrl(config.webOrigin, created.id),
|
|
4003
|
+
};
|
|
4004
|
+
});
|
|
3806
4005
|
}
|
|
3807
4006
|
if (verb === "policy") {
|
|
3808
4007
|
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
package/dist/command-schema.js
CHANGED
package/dist/help.js
CHANGED
|
@@ -17,6 +17,7 @@ Usage:
|
|
|
17
17
|
recess [--json] agent-context
|
|
18
18
|
recess [--json] setup [--skill-only]
|
|
19
19
|
recess [--json] doctor
|
|
20
|
+
recess [--json] update [--confirm]
|
|
20
21
|
recess [--json] profile list
|
|
21
22
|
recess [--json] profile show <name>
|
|
22
23
|
recess [--json] profile save <name> [--api-origin URL] [--web-origin URL]
|
|
@@ -34,6 +35,18 @@ Usage:
|
|
|
34
35
|
recess [--json] auth status|logout
|
|
35
36
|
recess [--json] users search <name-or-id> [--limit 10]
|
|
36
37
|
recess [--json] users get <user-id>
|
|
38
|
+
recess [--json] users school complete-action <user-id> <action-id> --note <text> [--confirm]
|
|
39
|
+
recess [--json] users school status <user-id>
|
|
40
|
+
recess [--json] users school history <user-id> [--limit 25] [--cursor <id>]
|
|
41
|
+
recess [--json] users school withdraw <user-id> --note <text> --expected-updated-at <iso> [--confirm]
|
|
42
|
+
recess [--json] users school restore <user-id> --tier <tier-id> --note <text> --expected-updated-at <iso> [--confirm]
|
|
43
|
+
recess [--json] users enrollment history <user-id> [--limit 25] [--cursor <id>]
|
|
44
|
+
recess [--json] users enrollment set-partner-school-cc <school-id-or-slug>
|
|
45
|
+
[--email <address> | --clear] [--confirm]
|
|
46
|
+
recess [--json] users enrollment list-partner-schools
|
|
47
|
+
recess [--json] users enrollment set <user-id>
|
|
48
|
+
--entity online-learning-program|recess-academy-school|partner-school|unset
|
|
49
|
+
[--partner-school <school-id-or-slug>] [--confirm]
|
|
37
50
|
recess [--json] users lock <user-id> [--confirm]
|
|
38
51
|
recess [--json] users unlock <user-id> [--confirm]
|
|
39
52
|
recess [--json] users disable <user-id> [--whole-family] [--allow-billing] [--confirm]
|
package/dist/http.js
CHANGED
|
@@ -10,6 +10,25 @@ export const RECESS_REASON_HEADER = "x-recess-reason";
|
|
|
10
10
|
*/
|
|
11
11
|
export const RECESS_REASON_ENCODING_HEADER = "x-recess-reason-encoding";
|
|
12
12
|
export const RECESS_REASON_ENCODING_UTF8 = "utf-8-percent";
|
|
13
|
+
export const RECESS_CLI_VERSION_HEADER = "x-recess-cli-version";
|
|
14
|
+
export const RECESS_CLI_LATEST_HEADER = "x-recess-cli-latest";
|
|
15
|
+
export const RECESS_CLI_MINIMUM_HEADER = "x-recess-cli-minimum";
|
|
16
|
+
let requestCliVersion;
|
|
17
|
+
const observedCliVersions = {};
|
|
18
|
+
export function setRequestCliVersion(version) {
|
|
19
|
+
requestCliVersion = version;
|
|
20
|
+
}
|
|
21
|
+
export function noteServerCliVersions(headers) {
|
|
22
|
+
const latest = headers.get(RECESS_CLI_LATEST_HEADER)?.trim();
|
|
23
|
+
const minimum = headers.get(RECESS_CLI_MINIMUM_HEADER)?.trim();
|
|
24
|
+
if (latest)
|
|
25
|
+
observedCliVersions.latest = latest;
|
|
26
|
+
if (minimum)
|
|
27
|
+
observedCliVersions.minimum = minimum;
|
|
28
|
+
}
|
|
29
|
+
export function serverCliVersions() {
|
|
30
|
+
return { ...observedCliVersions };
|
|
31
|
+
}
|
|
13
32
|
const ASCII_PRINTABLE = /^[\x20-\x7E]*$/;
|
|
14
33
|
/**
|
|
15
34
|
* Make a human-written reason safe to put in an HTTP header.
|
|
@@ -53,6 +72,8 @@ export function requireCliRequestReason(value) {
|
|
|
53
72
|
}
|
|
54
73
|
export function markCliRequest(headers, reason, client = RECESS_CLIENT_CLI) {
|
|
55
74
|
headers.set(RECESS_CLIENT_HEADER, client);
|
|
75
|
+
if (requestCliVersion)
|
|
76
|
+
headers.set(RECESS_CLI_VERSION_HEADER, requestCliVersion);
|
|
56
77
|
if (reason) {
|
|
57
78
|
const encoded = encodeReasonHeader(reason);
|
|
58
79
|
headers.set(RECESS_REASON_HEADER, encoded.value);
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,9 @@ import { parseArgs } from "./args.js";
|
|
|
4
4
|
import { runCommand } from "./cli.js";
|
|
5
5
|
import { deliverJson } from "./delivery.js";
|
|
6
6
|
import { CliError } from "./errors.js";
|
|
7
|
+
import { setRequestCliVersion } from "./http.js";
|
|
8
|
+
import { readCliVersion } from "./setup.js";
|
|
9
|
+
import { printUpdateNotice } from "./update-notice.js";
|
|
7
10
|
async function withInteractiveAdmissionStage(args) {
|
|
8
11
|
const parsed = parseArgs(args);
|
|
9
12
|
if (parsed.flags.has("json") ||
|
|
@@ -41,6 +44,8 @@ async function withInteractiveAdmissionStage(args) {
|
|
|
41
44
|
const argv = await withInteractiveAdmissionStage(process.argv.slice(2));
|
|
42
45
|
const parsedArgv = parseArgs(argv);
|
|
43
46
|
const json = parsedArgv.flags.has("json");
|
|
47
|
+
const cliVersion = await readCliVersion();
|
|
48
|
+
setRequestCliVersion(cliVersion);
|
|
44
49
|
// The interactive console owns the terminal, so it runs before the JSON
|
|
45
50
|
// envelope machinery rather than through it.
|
|
46
51
|
if (argv[0] === "ui") {
|
|
@@ -103,4 +108,7 @@ catch (error) {
|
|
|
103
108
|
}
|
|
104
109
|
process.exitCode = cliError.exitCode;
|
|
105
110
|
}
|
|
111
|
+
if (parsedArgv.positionals[0] !== "update") {
|
|
112
|
+
await printUpdateNotice(cliVersion);
|
|
113
|
+
}
|
|
106
114
|
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { defaultConfigPath } from "./config.js";
|
|
5
|
+
import { CliError } from "./errors.js";
|
|
6
|
+
import { serverCliVersions } from "./http.js";
|
|
7
|
+
import { compareVersions } from "./skill-update.js";
|
|
8
|
+
export const UPGRADE_COMMAND = "npm install -g recess-cli@latest";
|
|
9
|
+
const NOTICE_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
10
|
+
export function updateStatus(cliVersion, versions = serverCliVersions()) {
|
|
11
|
+
const { latest, minimum } = versions;
|
|
12
|
+
if (minimum && compareVersions(cliVersion, minimum) < 0) {
|
|
13
|
+
return { state: "required", cliVersion, latest, minimum };
|
|
14
|
+
}
|
|
15
|
+
if (!latest)
|
|
16
|
+
return { state: "unknown", cliVersion };
|
|
17
|
+
if (compareVersions(cliVersion, latest) < 0) {
|
|
18
|
+
return { state: "available", cliVersion, latest, minimum };
|
|
19
|
+
}
|
|
20
|
+
return { state: "current", cliVersion, latest };
|
|
21
|
+
}
|
|
22
|
+
export function updateNoticeText(status) {
|
|
23
|
+
if (status.state === "required") {
|
|
24
|
+
return `Warning: recess ${status.cliVersion} is older than the minimum supported version ${status.minimum}, so some commands may fail. Update with \`recess update --confirm\` or \`${UPGRADE_COMMAND}\`.`;
|
|
25
|
+
}
|
|
26
|
+
if (status.state === "available") {
|
|
27
|
+
return `recess ${status.latest} is available (you have ${status.cliVersion}). Update with \`recess update --confirm\` or \`${UPGRADE_COMMAND}\`.`;
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
function noticeStampPath() {
|
|
32
|
+
return path.join(path.dirname(defaultConfigPath()), "update-notice.json");
|
|
33
|
+
}
|
|
34
|
+
async function recentlyNotified(latest) {
|
|
35
|
+
try {
|
|
36
|
+
const stamp = JSON.parse(await fs.readFile(noticeStampPath(), "utf8"));
|
|
37
|
+
if (stamp.latest !== latest || !stamp.notifiedAt)
|
|
38
|
+
return false;
|
|
39
|
+
return Date.now() - Date.parse(stamp.notifiedAt) < NOTICE_INTERVAL_MS;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function recordNotice(latest) {
|
|
46
|
+
try {
|
|
47
|
+
await fs.mkdir(path.dirname(noticeStampPath()), { recursive: true });
|
|
48
|
+
await fs.writeFile(noticeStampPath(), JSON.stringify({ latest, notifiedAt: new Date().toISOString() }));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export async function printUpdateNotice(cliVersion, write = (text) => process.stderr.write(text)) {
|
|
55
|
+
if (process.env.RECESS_CLI_NO_UPDATE_NOTICE === "1")
|
|
56
|
+
return;
|
|
57
|
+
const status = updateStatus(cliVersion);
|
|
58
|
+
const text = updateNoticeText(status);
|
|
59
|
+
if (!text)
|
|
60
|
+
return;
|
|
61
|
+
if (status.state === "available") {
|
|
62
|
+
if (await recentlyNotified(status.latest))
|
|
63
|
+
return;
|
|
64
|
+
await recordNotice(status.latest);
|
|
65
|
+
}
|
|
66
|
+
write(`\n${text}\n`);
|
|
67
|
+
}
|
|
68
|
+
export async function runSelfUpdate() {
|
|
69
|
+
const [bin, ...args] = UPGRADE_COMMAND.split(" ");
|
|
70
|
+
const exitCode = await new Promise((resolve, reject) => {
|
|
71
|
+
const child = spawn(bin, args, {
|
|
72
|
+
stdio: ["ignore", process.stderr, process.stderr],
|
|
73
|
+
shell: process.platform === "win32",
|
|
74
|
+
});
|
|
75
|
+
child.on("error", reject);
|
|
76
|
+
child.on("close", (code) => resolve(code ?? 1));
|
|
77
|
+
}).catch((error) => {
|
|
78
|
+
throw new CliError("update_failed", `Could not run \`${UPGRADE_COMMAND}\`: ${error instanceof Error ? error.message : String(error)}`);
|
|
79
|
+
});
|
|
80
|
+
if (exitCode !== 0) {
|
|
81
|
+
throw new CliError("update_failed", `\`${UPGRADE_COMMAND}\` exited with code ${exitCode}. Run it yourself to see the full npm output; a permissions error usually means the global npm directory needs sudo or a Node version manager.`);
|
|
82
|
+
}
|
|
83
|
+
return { command: UPGRADE_COMMAND, exitCode };
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=update-notice.js.map
|
package/package.json
CHANGED
|
@@ -107,6 +107,9 @@ prompts a model for them.
|
|
|
107
107
|
findings — fix them and publish again. Both commands wait for the result (`--no-wait` to return the
|
|
108
108
|
build id and poll with `apps status`).
|
|
109
109
|
- `recess --json apps pull <project-id> [dir]` downloads a project you own to keep editing it.
|
|
110
|
+
- A published `appUrl` is also a valid goal queue entry (`goals queue set`, or a template's
|
|
111
|
+
external practice queue): each app becomes one module. Build interactive content this way rather
|
|
112
|
+
than linking a chat artifact the kid cannot open inside Recess.
|
|
110
113
|
- `recess --json apps standards "<words or notation>"` finds the CCSS standard for `manifest.json`
|
|
111
114
|
(`"grade 4 adding fractions"` → `4.NF.B.3a`…); guides rarely know the codes, look them up.
|
|
112
115
|
|