recess-cli 1.9.2 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +94 -56
- package/dist/api.js +122 -10
- package/dist/args.js +12 -1
- package/dist/auth.js +11 -10
- package/dist/cli.js +1320 -859
- package/dist/command-schema.js +261 -0
- package/dist/commands/applications.js +336 -0
- package/dist/commands/onboarding.js +623 -0
- package/dist/commands/shared.js +100 -0
- package/dist/config.js +116 -19
- package/dist/delivery.js +26 -0
- package/dist/feedback.js +64 -0
- package/dist/help.js +376 -0
- package/dist/http.js +25 -0
- package/dist/index.js +15 -3
- package/dist/jobs.js +80 -0
- package/dist/skill-update.js +2 -1
- package/dist/ui/index.js +2 -1
- package/package.json +3 -3
- package/skill/recess-cli/SKILL.md +20 -8
- package/skill/recess-cli/agents/version.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -1,274 +1,27 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
2
3
|
import fs from "node:fs/promises";
|
|
3
4
|
import path from "node:path";
|
|
4
|
-
import { RecessAdminApi, unwrap } from "./api.js";
|
|
5
|
-
import {
|
|
5
|
+
import { RecessAdminApi, unwrap, withIdempotencyContext } from "./api.js";
|
|
6
|
+
import { flagNumber, flagString, hasFlag, parseArgs, } from "./args.js";
|
|
6
7
|
import { login, pollDeviceAuth, requestDeviceAuth } from "./auth.js";
|
|
7
|
-
import { clearStoredSession, resolveConfig, } from "./config.js";
|
|
8
|
+
import { clearStoredSession, deleteProfile, listProfiles, resolveConfig, saveProfile, useProfile, } from "./config.js";
|
|
9
|
+
import { agentContext, buildCommandSchema, scopedHelp, validateInvocation, } from "./command-schema.js";
|
|
10
|
+
import { runApplicationsCommand } from "./commands/applications.js";
|
|
11
|
+
import { runOnboardingCommand } from "./commands/onboarding.js";
|
|
12
|
+
import { assertChoice, flagIdList, positional, readJsonFile, readJsonValue, } from "./commands/shared.js";
|
|
8
13
|
import { CliError } from "./errors.js";
|
|
14
|
+
import { listFeedback, submitFeedback } from "./feedback.js";
|
|
15
|
+
import { HELP } from "./help.js";
|
|
16
|
+
export { HELP };
|
|
17
|
+
import { cliRequestHeaders, requireCliRequestReason } from "./http.js";
|
|
9
18
|
import { requireConfirmation } from "./safety.js";
|
|
10
19
|
import { installSkill, isEphemeralInstall, readBundledSkillVersion, readCliVersion, } from "./setup.js";
|
|
11
20
|
import { compareVersions, updateSkillFromServer } from "./skill-update.js";
|
|
12
21
|
import { readSkillCache, writeSkillCache } from "./skills-cache.js";
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
recess [--json] --version
|
|
17
|
-
recess [--json] setup [--skill-only]
|
|
18
|
-
recess [--json] doctor
|
|
19
|
-
recess [--json] auth login [--client-id ID] [--callback-port 8765]
|
|
20
|
-
recess [--json] auth request [--label TEXT]
|
|
21
|
-
recess [--json] auth poll [--timeout 300]
|
|
22
|
-
recess [--json] auth status|logout
|
|
23
|
-
recess [--json] users search <name-or-id> [--limit 10]
|
|
24
|
-
recess [--json] users get <user-id>
|
|
25
|
-
recess [--json] users tier list-tiers
|
|
26
|
-
recess [--json] users tier get <kid-id>
|
|
27
|
-
recess [--json] users tier preview <kid-id> --tier social|academics|lite|complete|platform
|
|
28
|
-
[--slots N]
|
|
29
|
-
recess [--json] users tier set <kid-id> --tier social|academics|lite|complete|platform
|
|
30
|
-
[--slots N] --expected-updated-at <iso> [--allow-strand] [--confirm]
|
|
31
|
-
recess [--json] guides create --email <email> --first-name TEXT
|
|
32
|
-
[--last-name TEXT] [--no-invite] [--confirm]
|
|
33
|
-
recess [--json] guides invite --user <user-id> [--confirm]
|
|
34
|
-
recess [--json] guardians invite --family <family-id> --email <email>
|
|
35
|
-
--first-name TEXT [--last-name TEXT] [--no-invite] [--confirm]
|
|
36
|
-
recess [--json] students upload-map-scores --student <kid-id>
|
|
37
|
-
--file </path/to/map-report.pdf> [--confirm]
|
|
38
|
-
recess [--json] students list
|
|
39
|
-
recess [--json] students today --student <kid-id> [--date YYYY-MM-DD]
|
|
40
|
-
recess [--json] students schedule --student <kid-id> [--days 14]
|
|
41
|
-
recess [--json] students xp-history --student <kid-id>
|
|
42
|
-
[--range week|month|quarter|year]
|
|
43
|
-
recess [--json] enrollments list --user <user-id>
|
|
44
|
-
recess [--json] subscriptions list --family <family-id> [--kid <kid-id>]
|
|
45
|
-
recess [--json] invoices list --subscription <subscription-id>
|
|
46
|
-
recess [--json] billing pause --subscription <id> [--until ISO_DATE] [--confirm]
|
|
47
|
-
recess [--json] billing resume --subscription <id> [--confirm]
|
|
48
|
-
recess [--json] invoices refund --invoice <id> --line-item <id>
|
|
49
|
-
--method refund|credit|tokens [--full | --amount-cents N]
|
|
50
|
-
[--who-pays guide|recess] [--reason TEXT] [--confirm]
|
|
51
|
-
recess [--json] applications enroll <application-id> --quote <quote-id>
|
|
52
|
-
--school <institution-slug> --kid "<quoteLineId>:<firstName>[:<age>]" (repeat per line)
|
|
53
|
-
[--family <family-id>] [--unassign <kid-id>] (repeat) [--note TEXT] [--confirm]
|
|
54
|
-
recess [--json] cohorts search <query>
|
|
55
|
-
recess [--json] enrollments create --user <kid-id> --cohort <id>
|
|
56
|
-
[--first-charge-at ISO_DATETIME] [--send-email] [--force] [--confirm]
|
|
57
|
-
recess [--json] enrollments register-cohort --enrollment <id>
|
|
58
|
-
--user <id> --cohort <id> [--confirm]
|
|
59
|
-
recess [--json] enrollments unregister-cohort --user <id>
|
|
60
|
-
--cohort <id> [--confirm]
|
|
61
|
-
recess [--json] enrollments get-for-subscription --subscription <id>
|
|
62
|
-
recess [--json] billing extend-trial --subscription <id>
|
|
63
|
-
--trial-end ISO_DATE [--confirm]
|
|
64
|
-
recess [--json] billing cancel-subscription --subscription <id>
|
|
65
|
-
[--immediate] [--reason TEXT] [--restore] [--confirm]
|
|
66
|
-
recess [--json] payout payruns list [--status A,B] [--schedule <id>]
|
|
67
|
-
recess [--json] payout recipients list [--search <name>] [--user <id>] [--id <id>]
|
|
68
|
-
recess [--json] payout invoices list [--payrun <id>] [--recipient <account-id>]
|
|
69
|
-
[--user <id>] [--status A,B] (at least one filter)
|
|
70
|
-
recess [--json] payout invoices get <invoice-id>
|
|
71
|
-
recess [--json] payout invoices set-status <invoice-id>
|
|
72
|
-
--status IN_REVIEW|OPEN|PAID|CANCELED [--send-email] [--confirm]
|
|
73
|
-
recess [--json] payout items add --invoice <id> --amount-cents N
|
|
74
|
-
--description TEXT [--date YYYY-MM-DD] [--confirm]
|
|
75
|
-
recess [--json] payout items edit <item-id> [--amount-cents N]
|
|
76
|
-
[--description TEXT] [--date YYYY-MM-DD] [--confirm]
|
|
77
|
-
recess [--json] payout items delete <item-id> [--confirm]
|
|
78
|
-
recess [--json] cohorts get <cohort-id> [--events-tab ACTIVE|ENDED|CANCELED|ARCHIVED]
|
|
79
|
-
recess [--json] cohorts parent-emails <cohort-id>
|
|
80
|
-
recess [--json] cohorts end <cohort-id> [--cancel-subscriptions] [--confirm]
|
|
81
|
-
recess [--json] cohorts pause-billing <cohort-id> --weeks 1..6 [--confirm]
|
|
82
|
-
recess [--json] cohorts resume-billing <cohort-id> [--confirm]
|
|
83
|
-
recess [--json] cohorts email <cohort-id> --target ALL_PARENTS|ALL_PARENTS_GUIDES
|
|
84
|
-
--content TEXT [--confirm]
|
|
85
|
-
recess [--json] events get <event-id>
|
|
86
|
-
recess [--json] events take-attendance <event-id> --attended <id,id,...>
|
|
87
|
-
[--absent <id,id,...>] [--excused <id,id,...>] [--confirm]
|
|
88
|
-
recess [--json] events cancel <event-id> --reason TEXT [--confirm]
|
|
89
|
-
recess [--json] events set-status <event-id> --status ACTIVE|ENDED|CANCELED [--confirm]
|
|
90
|
-
recess [--json] events reschedule --cohort <id> --event <event-id>
|
|
91
|
-
--starts-at "YYYY-MM-DDTHH:MM" [--timezone <iana>] [--length-mins N] [--confirm]
|
|
92
|
-
recess [--json] events add --cohort <id> --starts-at "YYYY-MM-DDTHH:MM"
|
|
93
|
-
[--timezone <iana>] [--length-mins N] [--confirm]
|
|
94
|
-
recess [--json] registrations approve --registration <id> [--confirm]
|
|
95
|
-
recess [--json] registrations deny --cohort <id> --user <id> [--confirm]
|
|
96
|
-
recess [--json] request get </path?query=value>
|
|
97
|
-
recess [--json] onboarding status <family-id>
|
|
98
|
-
recess [--json] onboarding kids [--time-period-days N] [--cohort <id>]
|
|
99
|
-
[--limit N] [--stage-filter all|scheduled|oriented|course|converted|lost]
|
|
100
|
-
recess [--json] onboarding intake-session <family-id>
|
|
101
|
-
recess [--json] onboarding intake-session-create <family-id> [--confirm]
|
|
102
|
-
recess [--json] onboarding set-stage <family-id>
|
|
103
|
-
--stage LEGACY|PROVISIONED|PARENT_CONFIRMED|CLEARED_FOR_COHORT|COMPLETE [--confirm]
|
|
104
|
-
recess [--json] onboarding set-account-state <family-id>
|
|
105
|
-
--state ACTIVE|PENDING_PAYMENT|PAUSED|BOOTED [--note TEXT] [--confirm]
|
|
106
|
-
recess [--json] onboarding attest <family-id>
|
|
107
|
-
--condition app_downloaded|tutor_met|goals_loaded|ma_diagnostic [--revoke]
|
|
108
|
-
[--note TEXT] [--confirm]
|
|
109
|
-
recess [--json] onboarding set-intake <family-id> --session <id>
|
|
110
|
-
--data <json> [--expected-updated-at <iso>] [--confirm]
|
|
111
|
-
recess [--json] onboarding extract <family-id> --session <id>
|
|
112
|
-
(--transcript-file <path> | --granola <ref>) [--confirm]
|
|
113
|
-
recess [--json] village models list [--world village-1] [--query TEXT] [--archived]
|
|
114
|
-
recess [--json] village models upload --file </path/model.glb>
|
|
115
|
-
[--world village-1] [--name TEXT] [--id ID] [--description TEXT] [--tags A,B]
|
|
116
|
-
[--visual-only] [--confirm]
|
|
117
|
-
recess [--json] village models publish|archive <model-id> [--world village-1] [--confirm]
|
|
118
|
-
recess [--json] village models place <model-id> --x N --z N
|
|
119
|
-
[--y N] [--rotation 0..3] [--mirrored] [--no-collision] [--batch ID]
|
|
120
|
-
[--world village-1] [--confirm]
|
|
121
|
-
recess [--json] village models move <placement-id> --x N --z N
|
|
122
|
-
[--y N] [--rotation 0..3] [--mirrored] [--world village-1] [--confirm]
|
|
123
|
-
recess [--json] village models remove <placement-id> [--world village-1] [--confirm]
|
|
124
|
-
recess [--json] village render --min-x N --min-z N --max-x N --max-z N
|
|
125
|
-
[--world village-1]
|
|
126
|
-
recess [--json] store-items list [--search TEXT]
|
|
127
|
-
[--status ACTIVE|INACTIVE|COMING_SOON] [--item-type TYPE]
|
|
128
|
-
[--page 0] [--limit 20] [--sort-by name|price|createdAt|updatedAt|order]
|
|
129
|
-
[--sort-order asc|desc]
|
|
130
|
-
recess [--json] store-items set-status <village-store-item-id>
|
|
131
|
-
--status ACTIVE|INACTIVE|COMING_SOON [--confirm]
|
|
132
|
-
recess [--json] content-library search <query> [--limit 8]
|
|
133
|
-
recess [--json] content-library status <gem-id-or-url>
|
|
134
|
-
recess [--json] content-library set-stage <gem-id-or-url...> [--file <path>]
|
|
135
|
-
--stage review|polishing|live|archived [--confirm]
|
|
136
|
-
recess [--json] content-library submit <url...> [--file <path>]
|
|
137
|
-
[--stage review|polish] [--title TEXT] [--summary TEXT]
|
|
138
|
-
[--lane web-toys|mechanics|explorables|data-stories|sims|maps-scale|sound-art|puzzles|wonder|idea-games]
|
|
139
|
-
[--confirm]
|
|
140
|
-
recess [--json] skills guardian list [--query TEXT] [--category TEXT]
|
|
141
|
-
recess [--json] skills guardian get <skill-name>
|
|
142
|
-
[--reference NAME | --all-references] [--refresh]
|
|
143
|
-
recess [--json] skills admin list [--query TEXT] [--category TEXT]
|
|
144
|
-
recess [--json] skills admin get <skill-name>
|
|
145
|
-
[--reference NAME | --all-references]
|
|
146
|
-
[--refresh]
|
|
147
|
-
recess [--json] goal-templates list [--query TEXT] [--category TEXT]
|
|
148
|
-
[--kind SIMPLE|BLUEPRINT] [--starter-only] [--include-deleted]
|
|
149
|
-
recess [--json] goal-templates get <template-id|slug> [--spec-only]
|
|
150
|
-
recess [--json] goal-templates versions <template-id> [--version N]
|
|
151
|
-
recess [--json] goal-templates validate-spec --file <path/template.json>
|
|
152
|
-
recess [--json] goal-templates create --file <path/template.json> [--confirm]
|
|
153
|
-
recess [--json] goal-templates patch-spec <template-id|slug> --expected-version N
|
|
154
|
-
--patches-file <path/patches.json> [--confirm]
|
|
155
|
-
[--confirm-destructive-changes --destructive-change-token TOKEN]
|
|
156
|
-
recess [--json] goal-templates set-metadata <template-id> --expected-version N
|
|
157
|
-
[--title TEXT] [--description TEXT] [--emoji X] [--category TEXT] [--tags A,B]
|
|
158
|
-
[--sort-order N] [--is-starter true|false]
|
|
159
|
-
[--setup-audience KID_FRIENDLY|PARENT_SETUP] [--kind SIMPLE|BLUEPRINT]
|
|
160
|
-
[--agent-instructions-file <path>]
|
|
161
|
-
[--output-template-file <path>] [--confirm]
|
|
162
|
-
recess [--json] goal-templates delete <template-id> --expected-version N [--confirm]
|
|
163
|
-
recess [--json] goal-templates snapshot-files <template-id> [--path P]
|
|
164
|
-
recess [--json] goal-templates capture-snapshot <template-id|slug>
|
|
165
|
-
(--source-goal <goal-id> | --source-draft <draft-slug> --student <goal-owner-id>)
|
|
166
|
-
[--dry-run] [--confirm]
|
|
167
|
-
recess [--json] goal-templates apply <template-id> --answers-file <path>
|
|
168
|
-
[--dry-run] [--confirm --approval-token TOKEN]
|
|
169
|
-
recess [--json] goal-templates apply-starter <template-id> --student <kid-id>
|
|
170
|
-
[--answers-file <path>] [--confirm]
|
|
171
|
-
recess [--json] goals list --student <kid-id>
|
|
172
|
-
recess [--json] goals create --student <kid-id> --title TEXT
|
|
173
|
-
(--description TEXT | --description-file <path>) [--target-date <iso>]
|
|
174
|
-
[--schedule TEXT] [--draft <draft-slug>
|
|
175
|
-
(--enable-applet-follow-ups | --disable-applet-follow-ups)]
|
|
176
|
-
[--confirm --approval-token TOKEN]
|
|
177
|
-
recess [--json] goals edit <goal-id> --student <kid-id> --patch-file <path/patch.json>
|
|
178
|
-
--delta TEXT [--confirm --approval-token TOKEN]
|
|
179
|
-
recess [--json] goals queue get <goal-id> --student <kid-id>
|
|
180
|
-
recess [--json] goals queue set <goal-id> --student <kid-id>
|
|
181
|
-
--entries-file <path.json> --delta TEXT [--replace-description-pointer]
|
|
182
|
-
[--confirm --approval-token TOKEN]
|
|
183
|
-
recess [--json] todos create --student <kid-id> --title TEXT
|
|
184
|
-
[--due-date YYYY-MM-DD] [--estimated-minutes N] [--url URL] [--confirm]
|
|
185
|
-
recess [--json] todos edit <todo-id> --patch-file <path/patch.json>
|
|
186
|
-
[--confirm --approval-token TOKEN]
|
|
187
|
-
recess [--json] todos generate-applet <todo-id> --student <kid-id>
|
|
188
|
-
[--due-date YYYY-MM-DD] [--confirm --approval-token TOKEN]
|
|
189
|
-
recess [--json] memories context --student <kid-id>
|
|
190
|
-
recess [--json] memories log --student <kid-id> --date YYYY-MM-DD
|
|
191
|
-
recess [--json] rocky get --student <kid-id>
|
|
192
|
-
recess [--json] rocky set --student <kid-id> --patch-file <path/patch.json>
|
|
193
|
-
--expected-updated-at ISO|none [--confirm]
|
|
194
|
-
recess [--json] goals files list --student <goal-owner-id> --goal <goal-id>
|
|
195
|
-
recess [--json] goals files read --student <goal-owner-id> --goal <goal-id> --path P
|
|
196
|
-
recess [--json] goals files write --student <goal-owner-id>
|
|
197
|
-
(--goal <goal-id> | --draft <draft-slug>)
|
|
198
|
-
(--source-dir <local-dir> | --source-file <local-file> --path P)
|
|
199
|
-
[--message TEXT] [--confirm --approval-token TOKEN]
|
|
200
|
-
recess [--json] goals pdf upload --student <goal-owner-id>
|
|
201
|
-
(--goal <goal-id> | --draft <draft-slug>) --source-file <local.pdf>
|
|
202
|
-
[--path uploads/name.pdf] [--message TEXT]
|
|
203
|
-
[--confirm --approval-token TOKEN]
|
|
204
|
-
|
|
205
|
-
Authoring notes: "skills" serves the in-product tutor skills (the private
|
|
206
|
-
packages/skills workspace package) read-only over your admin session — they are never
|
|
207
|
-
bundled into this npm package. Load os-v2-goal-template-builder and its
|
|
208
|
-
references/deterministic-workflow-setup.md BEFORE authoring a template; that is
|
|
209
|
-
the same guidance the recess.gg/ai agent follows, so there is exactly one
|
|
210
|
-
standard. Responses cache under ~/.recess-cli/skills-cache/ (--refresh re-fetches).
|
|
211
|
-
For goal workspace and PDF commands, --student names the goal owner. A
|
|
212
|
-
full_admin session may use a KID or ADMIN user id, including its own ADMIN id;
|
|
213
|
-
family_ai sessions remain limited to managed KID profiles.
|
|
214
|
-
"goals create --draft" materializes that validated draft directly as a personal
|
|
215
|
-
module-backed goal; it does not create or apply a reusable template. Its preview
|
|
216
|
-
is bound to the exact workspace revision, hash, module inventory, and applet
|
|
217
|
-
follow-up choice.
|
|
218
|
-
Every template created here is setupMode DETERMINISTIC_WORKFLOW and CANNOT be
|
|
219
|
-
converted back, so "validate-spec" against the same file until it passes, then
|
|
220
|
-
"create". "create" runs a real server-side validation before its gate, so the
|
|
221
|
-
preview shows the handler/goalShape/step keys the SERVER resolved. "apply" runs
|
|
222
|
-
the backend's own dryRun before the gate and previews the per-student outcome.
|
|
223
|
-
"set-metadata" and "delete" require --expected-version (from "get"); a stale one
|
|
224
|
-
409s STALE_WRITE and writes nothing. The spec is unreachable from "set-metadata"
|
|
225
|
-
by design — an existing spec is edited only through the guarded /ai patch path.
|
|
226
|
-
|
|
227
|
-
Onboarding notes: "status" and "intake-session" are reads — "intake-session"
|
|
228
|
-
looks up the current IN_PROGRESS session without creating one (prints a "none
|
|
229
|
-
yet" result when absent). "intake-session-create" is the explicit write that
|
|
230
|
-
mints a blank session, so it is gated behind --confirm. "set-stage" reads the
|
|
231
|
-
current stage first and warns when a move re-locks progress; CLEARED_FOR_COHORT
|
|
232
|
-
unlocks cohort registration. "set-account-state" PAUSED/BOOTED lock the family out of paid
|
|
233
|
-
capabilities (--note is analytics-only). "set-intake --data" takes JSON (agents
|
|
234
|
-
drive it with --json; humans rarely will); the preview is offline. Its
|
|
235
|
-
optimistic-concurrency token resolves only on --confirm: pass
|
|
236
|
-
--expected-updated-at <iso> (from an intake-session read's updatedAt) for strict
|
|
237
|
-
CAS that 409s if a parent autosaved since; omit it and the CLI fetches the
|
|
238
|
-
current token at confirm time and warns that post-preview edits are unprotected.
|
|
239
|
-
"extract" is LLM-bound and can take ~30s; a 503 means Granola is not configured
|
|
240
|
-
server-side.
|
|
241
|
-
|
|
242
|
-
Class-ops notes: "events cancel" notifies families (chat + parent email blast +
|
|
243
|
-
credit notes + Slack); "events set-status --status CANCELED" is a silent status
|
|
244
|
-
change. Reschedule times are cohort-local wall-clock (zoneless).
|
|
245
|
-
|
|
246
|
-
Payout notes: amounts are integer cents. "payout items add" without --date
|
|
247
|
-
defaults the item date to the penultimate day of the invoice's cycle (its
|
|
248
|
-
endDate minus one day) and shows the computed date in the preview.
|
|
249
|
-
|
|
250
|
-
Auth notes: "auth login" runs the browser loopback flow for ADMIN or a GUARDIAN with
|
|
251
|
-
access:ai. Guardian sessions are family-scoped and cannot call /admin. For a headless
|
|
252
|
-
cloud agent, the ADMIN-only "auth request" prints an approval URL to hand a Recess admin; after they
|
|
253
|
-
approve it in a browser, "auth poll" collects the 12h session. When it lapses, run
|
|
254
|
-
"auth request" again for a fresh link. Both paths yield the same session.
|
|
255
|
-
|
|
256
|
-
Skill notes: this CLI's own agent skill ships inside the npm package AND is served
|
|
257
|
-
by the server, so wording/Gotcha updates arrive without an npm release. "setup"
|
|
258
|
-
(or "setup --skill-only") installs the bundled copy, then upgrades it from the
|
|
259
|
-
server when the served bundle's minCliVersion allows — an older binary keeps the
|
|
260
|
-
bundled copy, and an unreachable server is not an error. "doctor" reports whether
|
|
261
|
-
a newer skill exists and names the command; it never writes.
|
|
262
|
-
|
|
263
|
-
Writes preview and exit 2 unless --confirm is supplied after explicit human approval.
|
|
264
|
-
Environment overrides: RECESS_CLI_API_ORIGIN, RECESS_CLI_WEB_ORIGIN,
|
|
265
|
-
RECESS_CLI_OAUTH_CLIENT_ID, RECESS_CLI_COOKIE, RECESS_CLI_CONFIG.`;
|
|
266
|
-
function positional(parsed, index, label) {
|
|
267
|
-
const value = parsed.positionals[index];
|
|
268
|
-
if (!value) {
|
|
269
|
-
throw new CliError("invalid_arguments", `Missing ${label}.`);
|
|
270
|
-
}
|
|
271
|
-
return value;
|
|
22
|
+
import { appendJobEvent, getJob, listJobs, pruneJobs } from "./jobs.js";
|
|
23
|
+
function requiredRequestReason(parsed) {
|
|
24
|
+
return requireCliRequestReason(flagString(parsed, "reason", { required: true }));
|
|
272
25
|
}
|
|
273
26
|
async function sessionStatus(config) {
|
|
274
27
|
if (!config.sessionCookie) {
|
|
@@ -291,18 +44,20 @@ async function sessionStatus(config) {
|
|
|
291
44
|
}
|
|
292
45
|
return { ...result.data, authSource: config.authSource };
|
|
293
46
|
}
|
|
294
|
-
async function doctor(config) {
|
|
47
|
+
async function doctor(config, reason) {
|
|
295
48
|
const checks = {
|
|
296
49
|
config: {
|
|
297
50
|
path: config.configPath,
|
|
298
51
|
apiOrigin: config.apiOrigin,
|
|
299
52
|
webOrigin: config.webOrigin,
|
|
300
53
|
oauthClientIdConfigured: Boolean(config.oauthClientId),
|
|
54
|
+
profile: config.profileName ?? null,
|
|
301
55
|
},
|
|
302
56
|
auth: await sessionStatus(config),
|
|
303
57
|
};
|
|
304
58
|
try {
|
|
305
59
|
const response = await fetch(new URL("/health", config.apiOrigin), {
|
|
60
|
+
headers: cliRequestHeaders(undefined, reason),
|
|
306
61
|
signal: AbortSignal.timeout(5000),
|
|
307
62
|
});
|
|
308
63
|
checks.api = { reachable: response.ok, status: response.status };
|
|
@@ -313,7 +68,7 @@ async function doctor(config) {
|
|
|
313
68
|
message: error instanceof Error ? error.message : String(error),
|
|
314
69
|
};
|
|
315
70
|
}
|
|
316
|
-
checks.skill = await skillStatus(config);
|
|
71
|
+
checks.skill = await skillStatus(config, reason);
|
|
317
72
|
return checks;
|
|
318
73
|
}
|
|
319
74
|
/**
|
|
@@ -322,7 +77,7 @@ async function doctor(config) {
|
|
|
322
77
|
* silently rewrote `~/.claude/skills` mid-session would change what the agent is
|
|
323
78
|
* reading out from under it. It names the command instead.
|
|
324
79
|
*/
|
|
325
|
-
async function skillStatus(config) {
|
|
80
|
+
async function skillStatus(config, reason) {
|
|
326
81
|
const cliVersion = await readCliVersion();
|
|
327
82
|
const installedVersion = await readBundledSkillVersion();
|
|
328
83
|
const base = { cliVersion, bundledSkillVersion: installedVersion };
|
|
@@ -331,7 +86,7 @@ async function skillStatus(config) {
|
|
|
331
86
|
}
|
|
332
87
|
try {
|
|
333
88
|
const response = await fetch(new URL("/auth/admin-cli/skill/", config.apiOrigin), {
|
|
334
|
-
headers: { cookie: config.sessionCookie },
|
|
89
|
+
headers: cliRequestHeaders({ cookie: config.sessionCookie }, reason),
|
|
335
90
|
signal: AbortSignal.timeout(5000),
|
|
336
91
|
});
|
|
337
92
|
if (!response.ok) {
|
|
@@ -366,30 +121,433 @@ async function skillStatus(config) {
|
|
|
366
121
|
}
|
|
367
122
|
}
|
|
368
123
|
async function writeCommand(parsed, preview, execute) {
|
|
369
|
-
|
|
370
|
-
|
|
124
|
+
const fingerprint = approvalTokenFor(preview);
|
|
125
|
+
const suppliedOperationKey = flagString(parsed, "operation-key");
|
|
126
|
+
if (!hasFlag(parsed, "confirm")) {
|
|
127
|
+
const operationKey = operationKeyFor(fingerprint);
|
|
128
|
+
const boundPreview = {
|
|
129
|
+
...preview,
|
|
130
|
+
details: {
|
|
131
|
+
...preview.details,
|
|
132
|
+
operationKey,
|
|
133
|
+
retry: "Rerun the unchanged command with --confirm --operation-key <operationKey>. Reuse that same key after an interrupted invocation.",
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
await appendJobEvent({
|
|
137
|
+
jobId: operationKey,
|
|
138
|
+
status: "awaiting_confirmation",
|
|
139
|
+
timestamp: new Date().toISOString(),
|
|
140
|
+
action: preview.action,
|
|
141
|
+
fingerprint,
|
|
142
|
+
target: preview.target,
|
|
143
|
+
});
|
|
144
|
+
requireConfirmation(false, boundPreview);
|
|
145
|
+
}
|
|
146
|
+
if (!suppliedOperationKey) {
|
|
147
|
+
throw new CliError("confirmation_required", "A confirmed write requires the operation key from its approved preview.", 2, { preview, requiredFlag: "--operation-key" });
|
|
148
|
+
}
|
|
149
|
+
assertOperationKeyMatchesPreview(suppliedOperationKey, fingerprint, preview);
|
|
150
|
+
await appendJobEvent({
|
|
151
|
+
jobId: suppliedOperationKey,
|
|
152
|
+
status: "running",
|
|
153
|
+
timestamp: new Date().toISOString(),
|
|
154
|
+
action: preview.action,
|
|
155
|
+
fingerprint,
|
|
156
|
+
target: preview.target,
|
|
157
|
+
});
|
|
158
|
+
try {
|
|
159
|
+
const result = await withIdempotencyContext({ operationKey: suppliedOperationKey, fingerprint }, execute);
|
|
160
|
+
await appendJobEvent({
|
|
161
|
+
jobId: suppliedOperationKey,
|
|
162
|
+
status: "completed",
|
|
163
|
+
timestamp: new Date().toISOString(),
|
|
164
|
+
action: preview.action,
|
|
165
|
+
fingerprint,
|
|
166
|
+
target: preview.target,
|
|
167
|
+
result,
|
|
168
|
+
});
|
|
169
|
+
return result;
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
await appendJobEvent({
|
|
173
|
+
jobId: suppliedOperationKey,
|
|
174
|
+
status: "unknown",
|
|
175
|
+
timestamp: new Date().toISOString(),
|
|
176
|
+
action: preview.action,
|
|
177
|
+
fingerprint,
|
|
178
|
+
target: preview.target,
|
|
179
|
+
message: error instanceof Error ? error.message : String(error),
|
|
180
|
+
});
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
371
183
|
}
|
|
372
184
|
const GOAL_WORKSPACE_WRITE_MAX_FILES = 1_000;
|
|
373
185
|
const GOAL_WORKSPACE_WRITE_MAX_TOTAL_BYTES = 20 * 1024 * 1024;
|
|
374
186
|
const GOAL_PDF_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024;
|
|
375
187
|
const GOAL_QUEUE_MAX_ENTRIES = 500;
|
|
376
188
|
const URL_QUEUE_DESCRIPTION_POINTER = "Skill queue managed by the system.";
|
|
189
|
+
const RECESS_GIT_METADATA = "recess-workspace.json";
|
|
190
|
+
async function runGit(cwd, args, options = {}) {
|
|
191
|
+
return new Promise((resolve, reject) => {
|
|
192
|
+
execFile("git", ["-C", cwd, ...args], {
|
|
193
|
+
encoding: "utf8",
|
|
194
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
195
|
+
env: options.identity
|
|
196
|
+
? {
|
|
197
|
+
...process.env,
|
|
198
|
+
GIT_AUTHOR_NAME: "Recess CLI",
|
|
199
|
+
GIT_AUTHOR_EMAIL: "cli@recess.gg",
|
|
200
|
+
GIT_COMMITTER_NAME: "Recess CLI",
|
|
201
|
+
GIT_COMMITTER_EMAIL: "cli@recess.gg",
|
|
202
|
+
}
|
|
203
|
+
: process.env,
|
|
204
|
+
}, (error, stdout, stderr) => {
|
|
205
|
+
if (error) {
|
|
206
|
+
reject(new CliError("git_failed", `Git command failed: git ${args.join(" ")} (${stderr.trim() || error.message})`));
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
resolve(stdout);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
async function ensureLocalGitIdentity(root) {
|
|
214
|
+
for (const [key, fallback] of [
|
|
215
|
+
["user.name", "Recess CLI"],
|
|
216
|
+
["user.email", "cli@recess.gg"],
|
|
217
|
+
]) {
|
|
218
|
+
let configured = "";
|
|
219
|
+
try {
|
|
220
|
+
configured = (await runGit(root, ["config", "--get", key])).trim();
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
// A missing inherited value exits 1. Install a repo-local fallback so
|
|
224
|
+
// the checkout can be committed without changing the user's global Git.
|
|
225
|
+
}
|
|
226
|
+
if (!configured)
|
|
227
|
+
await runGit(root, ["config", key, fallback]);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function resolveWorkspaceCheckoutPath(root, workspacePath) {
|
|
231
|
+
const normalized = workspacePath.replaceAll("\\", "/");
|
|
232
|
+
const segments = normalized.split("/");
|
|
233
|
+
const isGitMetadataSegment = (segment) => {
|
|
234
|
+
// NTFS canonicalizes trailing dots/spaces, supports alternate data streams,
|
|
235
|
+
// and may expose .git through an 8.3 alias such as GIT~1. Reject those
|
|
236
|
+
// spellings too so a checkout cannot write Git metadata before `git init`.
|
|
237
|
+
const ntfsName = segment
|
|
238
|
+
.split(":", 1)[0]
|
|
239
|
+
.replace(/[ .]+$/g, "")
|
|
240
|
+
.toLowerCase();
|
|
241
|
+
return ntfsName === ".git" || /^git~\d+$/.test(ntfsName);
|
|
242
|
+
};
|
|
243
|
+
if (!normalized ||
|
|
244
|
+
path.posix.isAbsolute(normalized) ||
|
|
245
|
+
segments.some((segment) => !segment ||
|
|
246
|
+
segment === "." ||
|
|
247
|
+
segment === ".." ||
|
|
248
|
+
isGitMetadataSegment(segment))) {
|
|
249
|
+
throw new CliError("invalid_workspace", `Refusing unsafe workspace path from Mesa: ${workspacePath}`);
|
|
250
|
+
}
|
|
251
|
+
const target = path.resolve(root, ...segments);
|
|
252
|
+
if (!target.startsWith(`${root}${path.sep}`)) {
|
|
253
|
+
throw new CliError("invalid_workspace", `Refusing workspace path outside checkout: ${workspacePath}`);
|
|
254
|
+
}
|
|
255
|
+
return target;
|
|
256
|
+
}
|
|
257
|
+
function recessGitMetadataPath(root) {
|
|
258
|
+
return path.join(root, ".git", RECESS_GIT_METADATA);
|
|
259
|
+
}
|
|
260
|
+
async function writeRecessGitMetadata(root, metadata) {
|
|
261
|
+
const destination = recessGitMetadataPath(root);
|
|
262
|
+
const temporary = `${destination}.${process.pid}.tmp`;
|
|
263
|
+
await fs.writeFile(temporary, `${JSON.stringify(metadata, null, 2)}\n`, {
|
|
264
|
+
encoding: "utf8",
|
|
265
|
+
mode: 0o600,
|
|
266
|
+
});
|
|
267
|
+
await fs.rename(temporary, destination);
|
|
268
|
+
}
|
|
269
|
+
async function readRecessGitMetadata(sourceDirectory) {
|
|
270
|
+
const requestedRoot = path.resolve(sourceDirectory);
|
|
271
|
+
const requestedRealRoot = await fs.realpath(requestedRoot).catch(() => null);
|
|
272
|
+
if (!requestedRealRoot) {
|
|
273
|
+
throw new CliError("invalid_checkout", `Recess checkout does not exist: ${requestedRoot}`);
|
|
274
|
+
}
|
|
275
|
+
const root = (await runGit(requestedRoot, ["rev-parse", "--show-toplevel"])).trim();
|
|
276
|
+
const realRoot = await fs.realpath(root);
|
|
277
|
+
if (realRoot !== requestedRealRoot) {
|
|
278
|
+
throw new CliError("invalid_checkout", `--source-dir must be the Recess checkout root: ${root}`);
|
|
279
|
+
}
|
|
280
|
+
let parsed;
|
|
281
|
+
try {
|
|
282
|
+
parsed = JSON.parse(await fs.readFile(recessGitMetadataPath(root), "utf8"));
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
throw new CliError("invalid_checkout", `${root} is not a Recess goal workspace checkout.`);
|
|
286
|
+
}
|
|
287
|
+
const value = parsed;
|
|
288
|
+
const candidateTarget = value.target;
|
|
289
|
+
const target = value.schemaVersion === 1 && typeof value.goalId === "string"
|
|
290
|
+
? { kind: "goal", goalId: value.goalId }
|
|
291
|
+
: candidateTarget;
|
|
292
|
+
if (![1, 2].includes(typeof value.schemaVersion === "number" ? value.schemaVersion : 0) ||
|
|
293
|
+
typeof value.studentId !== "string" ||
|
|
294
|
+
!target ||
|
|
295
|
+
(target.kind !== "goal" && target.kind !== "draft") ||
|
|
296
|
+
(target.kind === "goal" && typeof target.goalId !== "string") ||
|
|
297
|
+
(target.kind === "draft" && typeof target.draftSlug !== "string") ||
|
|
298
|
+
typeof value.changeId !== "string" ||
|
|
299
|
+
typeof value.upstreamCommit !== "string") {
|
|
300
|
+
throw new CliError("invalid_checkout", `${root} has invalid Recess workspace metadata.`);
|
|
301
|
+
}
|
|
302
|
+
return {
|
|
303
|
+
root: realRoot,
|
|
304
|
+
metadata: {
|
|
305
|
+
schemaVersion: 2,
|
|
306
|
+
studentId: value.studentId,
|
|
307
|
+
target: target,
|
|
308
|
+
changeId: value.changeId,
|
|
309
|
+
upstreamCommit: value.upstreamCommit,
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
async function checkoutGoalWorkspace(input) {
|
|
314
|
+
const outputDirectory = path.resolve(input.outputDirectory);
|
|
315
|
+
if (await fs.lstat(outputDirectory).catch(() => null)) {
|
|
316
|
+
throw new CliError("destination_exists", `Checkout destination already exists: ${outputDirectory}`);
|
|
317
|
+
}
|
|
318
|
+
if (input.snapshot.files.length > GOAL_WORKSPACE_WRITE_MAX_FILES) {
|
|
319
|
+
throw new CliError("workspace_too_large", `Workspace has more than ${GOAL_WORKSPACE_WRITE_MAX_FILES} files.`);
|
|
320
|
+
}
|
|
321
|
+
const sizeBytes = input.snapshot.files.reduce((total, file) => total + file.sizeBytes, 0);
|
|
322
|
+
if (sizeBytes > GOAL_WORKSPACE_WRITE_MAX_TOTAL_BYTES) {
|
|
323
|
+
throw new CliError("workspace_too_large", `Workspace exceeds ${GOAL_WORKSPACE_WRITE_MAX_TOTAL_BYTES} decoded bytes.`);
|
|
324
|
+
}
|
|
325
|
+
const parent = path.dirname(outputDirectory);
|
|
326
|
+
await fs.mkdir(parent, { recursive: true });
|
|
327
|
+
const temporary = await fs.mkdtemp(path.join(parent, ".recess-checkout-"));
|
|
328
|
+
let moved = false;
|
|
329
|
+
try {
|
|
330
|
+
for (const file of input.snapshot.files) {
|
|
331
|
+
const destination = resolveWorkspaceCheckoutPath(temporary, file.path);
|
|
332
|
+
const bytes = Buffer.from(file.content, file.contentEncoding === "base64" ? "base64" : "utf8");
|
|
333
|
+
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
334
|
+
if (bytes.byteLength !== file.sizeBytes || sha256 !== file.sha256) {
|
|
335
|
+
throw new CliError("invalid_workspace", `Mesa snapshot integrity check failed for ${file.path}.`);
|
|
336
|
+
}
|
|
337
|
+
await fs.mkdir(path.dirname(destination), { recursive: true });
|
|
338
|
+
await fs.writeFile(destination, bytes);
|
|
339
|
+
}
|
|
340
|
+
await runGit(temporary, ["init", "--quiet", "--initial-branch=main"]);
|
|
341
|
+
await ensureLocalGitIdentity(temporary);
|
|
342
|
+
await runGit(temporary, ["add", "-A"]);
|
|
343
|
+
await runGit(temporary, [
|
|
344
|
+
"commit",
|
|
345
|
+
"--quiet",
|
|
346
|
+
"--allow-empty",
|
|
347
|
+
"-m",
|
|
348
|
+
`Checkout Recess ${input.target.kind} workspace at ${input.snapshot.changeId}`,
|
|
349
|
+
], { identity: true });
|
|
350
|
+
const upstreamCommit = (await runGit(temporary, ["rev-parse", "HEAD"])).trim();
|
|
351
|
+
await writeRecessGitMetadata(temporary, {
|
|
352
|
+
schemaVersion: 2,
|
|
353
|
+
studentId: input.studentId,
|
|
354
|
+
target: input.target,
|
|
355
|
+
changeId: input.snapshot.changeId,
|
|
356
|
+
upstreamCommit,
|
|
357
|
+
});
|
|
358
|
+
await fs.rename(temporary, outputDirectory);
|
|
359
|
+
moved = true;
|
|
360
|
+
return {
|
|
361
|
+
directory: outputDirectory,
|
|
362
|
+
studentId: input.studentId,
|
|
363
|
+
target: input.target,
|
|
364
|
+
changeId: input.snapshot.changeId,
|
|
365
|
+
upstreamCommit,
|
|
366
|
+
fileCount: input.snapshot.files.length,
|
|
367
|
+
sizeBytes,
|
|
368
|
+
next: `Edit with normal file tools, inspect with git -C ${JSON.stringify(outputDirectory)} diff, commit locally, then run recess --json goals files push --source-dir ${JSON.stringify(outputDirectory)} --reason <purpose>.`,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
finally {
|
|
372
|
+
if (!moved)
|
|
373
|
+
await fs.rm(temporary, { recursive: true, force: true });
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
async function readRemoteWorkspaceSnapshot(api, studentId, target) {
|
|
377
|
+
return unwrap(await api.client.GET("/tutor/browser/mesa/workspace-files", {
|
|
378
|
+
params: {
|
|
379
|
+
query: {
|
|
380
|
+
studentUserId: studentId,
|
|
381
|
+
...(target.kind === "goal"
|
|
382
|
+
? { goalId: target.goalId }
|
|
383
|
+
: { draftSlug: target.draftSlug }),
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
}));
|
|
387
|
+
}
|
|
388
|
+
async function assertCheckoutReadyForMaterialization(checkout) {
|
|
389
|
+
const status = await runGit(checkout.root, ["status", "--porcelain=v1"]);
|
|
390
|
+
if (status.trim()) {
|
|
391
|
+
throw new CliError("uncommitted_changes", "Commit or discard all local workspace changes before creating or capturing.");
|
|
392
|
+
}
|
|
393
|
+
const head = (await runGit(checkout.root, ["rev-parse", "HEAD"])).trim();
|
|
394
|
+
if (head !== checkout.metadata.upstreamCommit) {
|
|
395
|
+
throw new CliError("unpushed_changes", "Push every local workspace commit before creating a goal or capturing a template.");
|
|
396
|
+
}
|
|
397
|
+
return head;
|
|
398
|
+
}
|
|
399
|
+
async function readGoalWorkspaceGitChanges(root, base) {
|
|
400
|
+
const status = await runGit(root, ["status", "--porcelain=v1"]);
|
|
401
|
+
if (status.trim()) {
|
|
402
|
+
throw new CliError("uncommitted_changes", "Commit or discard all local workspace changes before pushing.");
|
|
403
|
+
}
|
|
404
|
+
const head = (await runGit(root, ["rev-parse", "HEAD"])).trim();
|
|
405
|
+
if (head === base) {
|
|
406
|
+
throw new CliError("no_changes", "There are no local commits to push.");
|
|
407
|
+
}
|
|
408
|
+
await runGit(root, ["merge-base", "--is-ancestor", base, head]);
|
|
409
|
+
const commitLines = (await runGit(root, [
|
|
410
|
+
"log",
|
|
411
|
+
"--reverse",
|
|
412
|
+
"--format=%H%x09%s",
|
|
413
|
+
`${base}..${head}`,
|
|
414
|
+
]))
|
|
415
|
+
.trim()
|
|
416
|
+
.split("\n")
|
|
417
|
+
.filter(Boolean);
|
|
418
|
+
const commits = commitLines.map((line) => {
|
|
419
|
+
const tab = line.indexOf("\t");
|
|
420
|
+
return { id: line.slice(0, tab), subject: line.slice(tab + 1) };
|
|
421
|
+
});
|
|
422
|
+
const raw = await runGit(root, [
|
|
423
|
+
"diff",
|
|
424
|
+
"--name-status",
|
|
425
|
+
"--no-renames",
|
|
426
|
+
"-z",
|
|
427
|
+
`${base}..${head}`,
|
|
428
|
+
]);
|
|
429
|
+
const fields = raw.split("\0").filter((field) => field.length > 0);
|
|
430
|
+
if (fields.length % 2 !== 0) {
|
|
431
|
+
throw new CliError("git_failed", "Git returned an invalid workspace diff.");
|
|
432
|
+
}
|
|
433
|
+
const changes = [];
|
|
434
|
+
let sizeBytes = 0;
|
|
435
|
+
for (let index = 0; index < fields.length; index += 2) {
|
|
436
|
+
const statusCode = fields[index][0];
|
|
437
|
+
const workspacePath = fields[index + 1];
|
|
438
|
+
resolveWorkspaceCheckoutPath(root, workspacePath);
|
|
439
|
+
if (statusCode === "D") {
|
|
440
|
+
changes.push({ path: workspacePath, action: "delete" });
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
if (!["A", "M", "T"].includes(statusCode)) {
|
|
444
|
+
throw new CliError("unsupported_git_change", `Unsupported Git change ${fields[index]} for ${workspacePath}.`);
|
|
445
|
+
}
|
|
446
|
+
const absolutePath = resolveWorkspaceCheckoutPath(root, workspacePath);
|
|
447
|
+
const stat = await fs.lstat(absolutePath).catch(() => null);
|
|
448
|
+
if (!stat?.isFile() || stat.isSymbolicLink()) {
|
|
449
|
+
throw new CliError("invalid_workspace", `Refusing non-file workspace entry: ${workspacePath}`);
|
|
450
|
+
}
|
|
451
|
+
const bytes = await fs.readFile(absolutePath);
|
|
452
|
+
sizeBytes += bytes.byteLength;
|
|
453
|
+
changes.push({
|
|
454
|
+
path: workspacePath,
|
|
455
|
+
action: "upsert",
|
|
456
|
+
content: bytes.toString("base64"),
|
|
457
|
+
contentEncoding: "base64",
|
|
458
|
+
sizeBytes: bytes.byteLength,
|
|
459
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
if (changes.length === 0) {
|
|
463
|
+
throw new CliError("no_changes", "Local commits do not change workspace file contents.");
|
|
464
|
+
}
|
|
465
|
+
if (changes.length > GOAL_WORKSPACE_WRITE_MAX_FILES) {
|
|
466
|
+
throw new CliError("workspace_too_large", `Push changes more than ${GOAL_WORKSPACE_WRITE_MAX_FILES} files.`);
|
|
467
|
+
}
|
|
468
|
+
if (sizeBytes > GOAL_WORKSPACE_WRITE_MAX_TOTAL_BYTES) {
|
|
469
|
+
throw new CliError("workspace_too_large", `Push exceeds ${GOAL_WORKSPACE_WRITE_MAX_TOTAL_BYTES} decoded bytes.`);
|
|
470
|
+
}
|
|
471
|
+
return { head, commits, changes, sizeBytes };
|
|
472
|
+
}
|
|
377
473
|
function approvalTokenFor(preview) {
|
|
378
474
|
return createHash("sha256").update(JSON.stringify(preview)).digest("hex");
|
|
379
475
|
}
|
|
380
|
-
function
|
|
476
|
+
function operationKeyFor(fingerprint) {
|
|
477
|
+
return `op_${randomUUID().replaceAll("-", "")}_${fingerprint}`;
|
|
478
|
+
}
|
|
479
|
+
function assertOperationKeyMatchesPreview(operationKey, fingerprint, preview) {
|
|
480
|
+
if (!operationKey.endsWith(`_${fingerprint}`)) {
|
|
481
|
+
throw new CliError("approval_mismatch", "The operation key does not belong to this preview. Start from a new preview and obtain approval again.", 2, { preview, requiredFlag: "--operation-key" });
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
async function requirePreviewBoundConfirmation(parsed, preview) {
|
|
381
485
|
const approvalToken = approvalTokenFor(preview);
|
|
486
|
+
const suppliedOperationKey = flagString(parsed, "operation-key");
|
|
382
487
|
const boundPreview = {
|
|
383
488
|
...preview,
|
|
384
|
-
details: {
|
|
489
|
+
details: {
|
|
490
|
+
...preview.details,
|
|
491
|
+
approvalToken,
|
|
492
|
+
operationKey: suppliedOperationKey ?? operationKeyFor(approvalToken),
|
|
493
|
+
},
|
|
385
494
|
};
|
|
386
495
|
if (!hasFlag(parsed, "confirm")) {
|
|
496
|
+
await appendJobEvent({
|
|
497
|
+
jobId: String(boundPreview.details.operationKey),
|
|
498
|
+
status: "awaiting_confirmation",
|
|
499
|
+
timestamp: new Date().toISOString(),
|
|
500
|
+
action: preview.action,
|
|
501
|
+
fingerprint: approvalToken,
|
|
502
|
+
target: preview.target,
|
|
503
|
+
});
|
|
387
504
|
requireConfirmation(false, boundPreview);
|
|
388
505
|
}
|
|
389
506
|
const supplied = flagString(parsed, "approval-token");
|
|
390
507
|
if (supplied !== approvalToken) {
|
|
391
508
|
throw new CliError("approval_mismatch", "The approved preview no longer matches this request. Review the current preview and rerun with --confirm --approval-token <token>.", 2, { preview: boundPreview, requiredFlag: "--approval-token" });
|
|
392
509
|
}
|
|
510
|
+
if (!suppliedOperationKey) {
|
|
511
|
+
throw new CliError("confirmation_required", "A confirmed write requires the operation key from its approved preview.", 2, { preview: boundPreview, requiredFlag: "--operation-key" });
|
|
512
|
+
}
|
|
513
|
+
assertOperationKeyMatchesPreview(suppliedOperationKey, approvalToken, boundPreview);
|
|
514
|
+
await appendJobEvent({
|
|
515
|
+
jobId: suppliedOperationKey,
|
|
516
|
+
status: "running",
|
|
517
|
+
timestamp: new Date().toISOString(),
|
|
518
|
+
action: preview.action,
|
|
519
|
+
fingerprint: approvalToken,
|
|
520
|
+
target: preview.target,
|
|
521
|
+
});
|
|
522
|
+
return { operationKey: suppliedOperationKey, fingerprint: approvalToken };
|
|
523
|
+
}
|
|
524
|
+
async function previewBoundWrite(parsed, preview, execute) {
|
|
525
|
+
const context = await requirePreviewBoundConfirmation(parsed, preview);
|
|
526
|
+
try {
|
|
527
|
+
const result = await withIdempotencyContext(context, execute);
|
|
528
|
+
await appendJobEvent({
|
|
529
|
+
jobId: context.operationKey,
|
|
530
|
+
status: "completed",
|
|
531
|
+
timestamp: new Date().toISOString(),
|
|
532
|
+
action: preview.action,
|
|
533
|
+
fingerprint: context.fingerprint,
|
|
534
|
+
target: preview.target,
|
|
535
|
+
result,
|
|
536
|
+
});
|
|
537
|
+
return result;
|
|
538
|
+
}
|
|
539
|
+
catch (error) {
|
|
540
|
+
await appendJobEvent({
|
|
541
|
+
jobId: context.operationKey,
|
|
542
|
+
status: "unknown",
|
|
543
|
+
timestamp: new Date().toISOString(),
|
|
544
|
+
action: preview.action,
|
|
545
|
+
fingerprint: context.fingerprint,
|
|
546
|
+
target: preview.target,
|
|
547
|
+
message: error instanceof Error ? error.message : String(error),
|
|
548
|
+
});
|
|
549
|
+
throw error;
|
|
550
|
+
}
|
|
393
551
|
}
|
|
394
552
|
async function cleanupPlannerUpload(api, conversationId, signed, includeDelete) {
|
|
395
553
|
if (signed.kind === "multipart") {
|
|
@@ -553,12 +711,6 @@ async function readGoalWorkspaceWriteSource(parsed) {
|
|
|
553
711
|
}
|
|
554
712
|
return { files, source, sizeBytes, sha256: hash.digest("hex") };
|
|
555
713
|
}
|
|
556
|
-
function assertChoice(value, choices, label) {
|
|
557
|
-
if (!choices.includes(value)) {
|
|
558
|
-
throw new CliError("invalid_arguments", `${label} must be one of: ${choices.join(", ")}.`);
|
|
559
|
-
}
|
|
560
|
-
return value;
|
|
561
|
-
}
|
|
562
714
|
const PAYRUN_STATUSES = [
|
|
563
715
|
"UPCOMING",
|
|
564
716
|
"DRAFT",
|
|
@@ -582,36 +734,6 @@ const PAYOUT_STATUS_SIDE_EFFECTS = {
|
|
|
582
734
|
PAID: "marks the invoice paid and deducts its total from the recipient's account balance",
|
|
583
735
|
CANCELED: "cancels the invoice and reverses any balance items carried on it",
|
|
584
736
|
};
|
|
585
|
-
// Ascending onboarding stage order (mirrors STAGE_ORDER in
|
|
586
|
-
// apps/web-server/src/libs/onboarding-stage.ts); a lower index is earlier
|
|
587
|
-
// progress, so a target below the current stage is a backward move.
|
|
588
|
-
const ONBOARDING_STAGES = [
|
|
589
|
-
"LEGACY",
|
|
590
|
-
"PROVISIONED",
|
|
591
|
-
"PARENT_CONFIRMED",
|
|
592
|
-
"CLEARED_FOR_COHORT",
|
|
593
|
-
"COMPLETE",
|
|
594
|
-
];
|
|
595
|
-
// What each stage unlocks/implies, surfaced in the set-stage preview.
|
|
596
|
-
const ONBOARDING_STAGE_SIDE_EFFECTS = {
|
|
597
|
-
LEGACY: "the pre-onboarding baseline",
|
|
598
|
-
PROVISIONED: "kid accounts provisioned, awaiting parent confirmation",
|
|
599
|
-
PARENT_CONFIRMED: "parent has confirmed setup",
|
|
600
|
-
CLEARED_FOR_COHORT: "unlocks cohort registration for school families",
|
|
601
|
-
COMPLETE: "onboarding finished",
|
|
602
|
-
};
|
|
603
|
-
const ONBOARDING_ACCOUNT_STATES = [
|
|
604
|
-
"ACTIVE",
|
|
605
|
-
"PENDING_PAYMENT",
|
|
606
|
-
"PAUSED",
|
|
607
|
-
"BOOTED",
|
|
608
|
-
];
|
|
609
|
-
const ONBOARDING_CONDITIONS = [
|
|
610
|
-
"app_downloaded",
|
|
611
|
-
"tutor_met",
|
|
612
|
-
"goals_loaded",
|
|
613
|
-
"ma_diagnostic",
|
|
614
|
-
];
|
|
615
737
|
const SCHOOL_TIER_OPTIONS = [
|
|
616
738
|
{ id: "social", name: "Social", defaultClassSlots: 2 },
|
|
617
739
|
{ id: "academics", name: "Academics", defaultClassSlots: 0 },
|
|
@@ -664,41 +786,6 @@ const CONTENT_LIBRARY_DISCOVERY_LANES = [
|
|
|
664
786
|
"idea-games",
|
|
665
787
|
];
|
|
666
788
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
667
|
-
/**
|
|
668
|
-
* Read and parse a JSON file supplied by an authoring agent. Large payloads —
|
|
669
|
-
* a setupWorkflowSpec, an answers map, a goal description — go through files
|
|
670
|
-
* rather than argv: a shell mangles embedded quotes and newlines, and a spec
|
|
671
|
-
* that survived a round trip through `--json '<...>'` is not the spec that was
|
|
672
|
-
* reviewed.
|
|
673
|
-
*/
|
|
674
|
-
async function readJsonValue(filePath, label) {
|
|
675
|
-
const absolutePath = path.resolve(filePath);
|
|
676
|
-
let raw;
|
|
677
|
-
try {
|
|
678
|
-
raw = await fs.readFile(absolutePath, "utf8");
|
|
679
|
-
}
|
|
680
|
-
catch (error) {
|
|
681
|
-
if (error.code === "ENOENT") {
|
|
682
|
-
throw new CliError("invalid_arguments", `${label} does not exist: ${absolutePath}`);
|
|
683
|
-
}
|
|
684
|
-
throw error;
|
|
685
|
-
}
|
|
686
|
-
let parsed;
|
|
687
|
-
try {
|
|
688
|
-
parsed = JSON.parse(raw);
|
|
689
|
-
}
|
|
690
|
-
catch (error) {
|
|
691
|
-
throw new CliError("invalid_arguments", `${label} is not valid JSON (${absolutePath}): ${error instanceof Error ? error.message : String(error)}`);
|
|
692
|
-
}
|
|
693
|
-
return { absolutePath, raw, parsed };
|
|
694
|
-
}
|
|
695
|
-
async function readJsonFile(filePath, label) {
|
|
696
|
-
const { absolutePath, parsed } = await readJsonValue(filePath, label);
|
|
697
|
-
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
698
|
-
throw new CliError("invalid_arguments", `${label} must be a JSON object (${absolutePath}).`);
|
|
699
|
-
}
|
|
700
|
-
return parsed;
|
|
701
|
-
}
|
|
702
789
|
function parseGoalQueueEntries(value, label) {
|
|
703
790
|
if (!Array.isArray(value)) {
|
|
704
791
|
throw new CliError("invalid_arguments", `${label} must be a JSON array of queue entries.`);
|
|
@@ -1025,7 +1112,7 @@ function optionalDocString(doc, key) {
|
|
|
1025
1112
|
return value;
|
|
1026
1113
|
}
|
|
1027
1114
|
/**
|
|
1028
|
-
* Coerce an authored template file into the exact `POST /
|
|
1115
|
+
* Coerce an authored template file into the exact `POST /ai/goal-templates/`
|
|
1029
1116
|
* body. Every unknown key is dropped rather than forwarded, so a stale field
|
|
1030
1117
|
* copied from an old export cannot ride along into a create, and the shape
|
|
1031
1118
|
* failures an agent actually makes (missing slug, spec as a string, tags as a
|
|
@@ -1184,34 +1271,6 @@ function flagStatusList(parsed, name, choices) {
|
|
|
1184
1271
|
}
|
|
1185
1272
|
return values.map((value) => assertChoice(value, choices, `--${name}`));
|
|
1186
1273
|
}
|
|
1187
|
-
/**
|
|
1188
|
-
* Parse one `--kid` spec into an enroll roster entry.
|
|
1189
|
-
*
|
|
1190
|
-
* Shape: `<quoteLineId>:<firstName>[:<age>]` — the quote line FIRST, because
|
|
1191
|
-
* the line id is the identity the enrollment matches on and the name is only a
|
|
1192
|
-
* label. A staffer typing these reads them off the quote, in order.
|
|
1193
|
-
*
|
|
1194
|
-
* ⚠️ THE NAME MAY CONTAIN NOTHING SURPRISING, but an age must be a real number:
|
|
1195
|
-
* age decides `birthdate` at creation AND rides in the frozen payment snapshot
|
|
1196
|
-
* that a later takeover is compared against, so a silently-dropped or
|
|
1197
|
-
* mistyped age is a money-adjacent error, not a cosmetic one. Absent is fine
|
|
1198
|
-
* (the server treats it as "not stated"); unparseable is refused here.
|
|
1199
|
-
*/
|
|
1200
|
-
function parseEnrollKidSpec(raw) {
|
|
1201
|
-
const parts = raw.split(":").map((part) => part.trim());
|
|
1202
|
-
const [quoteLineId, firstName, ageRaw, ...extra] = parts;
|
|
1203
|
-
if (!quoteLineId || !firstName || extra.length > 0) {
|
|
1204
|
-
throw new CliError("invalid_arguments", `--kid must be "<quoteLineId>:<firstName>[:<age>]"; got "${raw}".`);
|
|
1205
|
-
}
|
|
1206
|
-
if (ageRaw === undefined || ageRaw === "") {
|
|
1207
|
-
return { quoteLineId, firstName };
|
|
1208
|
-
}
|
|
1209
|
-
const age = Number(ageRaw);
|
|
1210
|
-
if (!Number.isInteger(age) || age < 1 || age > 25) {
|
|
1211
|
-
throw new CliError("invalid_arguments", `--kid age must be a whole number from 1 to 25; got "${ageRaw}".`);
|
|
1212
|
-
}
|
|
1213
|
-
return { quoteLineId, firstName, age };
|
|
1214
|
-
}
|
|
1215
1274
|
function flagCents(parsed, name, options = {}) {
|
|
1216
1275
|
const value = flagNumber(parsed, name);
|
|
1217
1276
|
if (value === undefined) {
|
|
@@ -1253,6 +1312,48 @@ function flagDateOnly(parsed, name) {
|
|
|
1253
1312
|
return raw;
|
|
1254
1313
|
}
|
|
1255
1314
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
1315
|
+
async function waitForContentLibraryLive(api, gems, timeoutSeconds) {
|
|
1316
|
+
const pending = new Set(gems);
|
|
1317
|
+
const latest = new Map();
|
|
1318
|
+
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
1319
|
+
let delayMs = 2_000;
|
|
1320
|
+
while (pending.size > 0) {
|
|
1321
|
+
const targets = Array.from(pending);
|
|
1322
|
+
for (let index = 0; index < targets.length; index += 3) {
|
|
1323
|
+
const batch = targets.slice(index, index + 3);
|
|
1324
|
+
await Promise.all(batch.map(async (gem) => {
|
|
1325
|
+
const status = unwrap(await api.client.GET("/admin/content-library/status", {
|
|
1326
|
+
params: { query: { gem } },
|
|
1327
|
+
}));
|
|
1328
|
+
latest.set(gem, status);
|
|
1329
|
+
if (status.stage === "live") {
|
|
1330
|
+
pending.delete(gem);
|
|
1331
|
+
return;
|
|
1332
|
+
}
|
|
1333
|
+
const errors = [
|
|
1334
|
+
status.progress.metadata,
|
|
1335
|
+
status.progress.cover,
|
|
1336
|
+
status.progress.searchIndex,
|
|
1337
|
+
].filter((step) => step.state === "error");
|
|
1338
|
+
if (errors.length > 0) {
|
|
1339
|
+
throw new CliError("async_job_failed", `Content Library polishing failed for ${gem}.`, 1, { status });
|
|
1340
|
+
}
|
|
1341
|
+
}));
|
|
1342
|
+
}
|
|
1343
|
+
if (pending.size === 0)
|
|
1344
|
+
break;
|
|
1345
|
+
if (Date.now() + delayMs >= deadline) {
|
|
1346
|
+
throw new CliError("async_timeout", `Timed out waiting for ${pending.size} Content Library resource(s) to become live. Retry the unchanged confirmed command with the same operation key to resume polling.`, 1, {
|
|
1347
|
+
pending: Array.from(pending),
|
|
1348
|
+
latest: Object.fromEntries(latest),
|
|
1349
|
+
});
|
|
1350
|
+
}
|
|
1351
|
+
const jitter = Math.floor(Math.random() * Math.max(1, delayMs / 4));
|
|
1352
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs + jitter));
|
|
1353
|
+
delayMs = Math.min(15_000, Math.round(delayMs * 1.7));
|
|
1354
|
+
}
|
|
1355
|
+
return { completed: true, resources: Array.from(latest.values()) };
|
|
1356
|
+
}
|
|
1256
1357
|
// The cohort event routes take zoneless local wall-clock datetimes
|
|
1257
1358
|
// (interpreted in the cohort's timezone server-side).
|
|
1258
1359
|
function flagLocalDateTime(parsed, name, options = {}) {
|
|
@@ -1265,19 +1366,6 @@ function flagLocalDateTime(parsed, name, options = {}) {
|
|
|
1265
1366
|
}
|
|
1266
1367
|
return match[2] ? raw : `${raw}:00`;
|
|
1267
1368
|
}
|
|
1268
|
-
function flagIdList(parsed, name) {
|
|
1269
|
-
const raw = flagString(parsed, name);
|
|
1270
|
-
if (raw === undefined)
|
|
1271
|
-
return [];
|
|
1272
|
-
const ids = raw
|
|
1273
|
-
.split(",")
|
|
1274
|
-
.map((value) => value.trim())
|
|
1275
|
-
.filter(Boolean);
|
|
1276
|
-
if (ids.length === 0) {
|
|
1277
|
-
throw new CliError("invalid_arguments", `--${name} requires a value.`);
|
|
1278
|
-
}
|
|
1279
|
-
return ids;
|
|
1280
|
-
}
|
|
1281
1369
|
export function penultimateCycleDayMs(cycleEndDate) {
|
|
1282
1370
|
const endMs = Date.parse(cycleEndDate);
|
|
1283
1371
|
if (!Number.isFinite(endMs)) {
|
|
@@ -1325,20 +1413,148 @@ function tierSlots(parsed) {
|
|
|
1325
1413
|
export async function runCommand(argv) {
|
|
1326
1414
|
const parsed = parseArgs(argv);
|
|
1327
1415
|
const [noun, verb] = parsed.positionals;
|
|
1416
|
+
const commands = buildCommandSchema(HELP);
|
|
1328
1417
|
// Before the help branch: `--version` parses as a FLAG, so `noun` is
|
|
1329
1418
|
// undefined and `!noun` would return help instead. (Found by running it.)
|
|
1330
1419
|
if (noun === "version" || hasFlag(parsed, "version")) {
|
|
1420
|
+
const unknown = Array.from(parsed.flags.keys()).filter((name) => !["deliver", "json", "version"].includes(name));
|
|
1421
|
+
if (unknown.length > 0) {
|
|
1422
|
+
throw new CliError("unknown_flag", `Unknown flag${unknown.length === 1 ? "" : "s"}: ${unknown
|
|
1423
|
+
.map((name) => `--${name}`)
|
|
1424
|
+
.join(", ")}.`, 1, { validFlags: ["--deliver", "--json", "--version"] });
|
|
1425
|
+
}
|
|
1331
1426
|
return {
|
|
1332
1427
|
cliVersion: await readCliVersion(),
|
|
1333
1428
|
skillVersion: await readBundledSkillVersion(),
|
|
1334
1429
|
};
|
|
1335
1430
|
}
|
|
1336
|
-
if (!noun
|
|
1337
|
-
|
|
1431
|
+
if (!noun) {
|
|
1432
|
+
const unknown = Array.from(parsed.flags.keys()).filter((name) => !["deliver", "help", "json", "profile"].includes(name));
|
|
1433
|
+
if (unknown.length > 0) {
|
|
1434
|
+
throw new CliError("unknown_flag", `Unknown flag${unknown.length === 1 ? "" : "s"}: ${unknown
|
|
1435
|
+
.map((name) => `--${name}`)
|
|
1436
|
+
.join(", ")}.`, 1, { validFlags: ["--deliver", "--help", "--json", "--profile"] });
|
|
1437
|
+
}
|
|
1438
|
+
return { help: scopedHelp(HELP, commands, []) };
|
|
1439
|
+
}
|
|
1440
|
+
if (noun === "help" || hasFlag(parsed, "help")) {
|
|
1441
|
+
const scope = noun === "help" ? parsed.positionals.slice(1) : parsed.positionals;
|
|
1442
|
+
return { help: scopedHelp(HELP, commands, scope) };
|
|
1443
|
+
}
|
|
1444
|
+
validateInvocation(parsed, commands);
|
|
1445
|
+
if (noun === "agent-context") {
|
|
1446
|
+
const profiles = await listProfiles();
|
|
1447
|
+
return agentContext(commands, {
|
|
1448
|
+
cliVersion: await readCliVersion(),
|
|
1449
|
+
availableProfiles: profiles.profiles.map((profile) => profile.name),
|
|
1450
|
+
feedbackUpstreamConfigured: Boolean(process.env.RECESS_CLI_FEEDBACK_ENDPOINT),
|
|
1451
|
+
});
|
|
1452
|
+
}
|
|
1453
|
+
if (noun === "profile") {
|
|
1454
|
+
const name = parsed.positionals[2];
|
|
1455
|
+
if (verb === "list")
|
|
1456
|
+
return listProfiles();
|
|
1457
|
+
if (verb === "show") {
|
|
1458
|
+
if (!name)
|
|
1459
|
+
throw new CliError("invalid_arguments", "Missing profile name.");
|
|
1460
|
+
const profiles = await listProfiles();
|
|
1461
|
+
const profile = profiles.profiles.find((entry) => entry.name === name);
|
|
1462
|
+
if (!profile)
|
|
1463
|
+
throw new CliError("not_found", `Unknown profile ${name}.`);
|
|
1464
|
+
return { ...profile, active: profiles.activeProfile === name };
|
|
1465
|
+
}
|
|
1466
|
+
if (verb === "save") {
|
|
1467
|
+
if (!name)
|
|
1468
|
+
throw new CliError("invalid_arguments", "Missing profile name.");
|
|
1469
|
+
const current = await resolveConfig(flagString(parsed, "profile"));
|
|
1470
|
+
const apiOrigin = flagString(parsed, "api-origin") ?? current.apiOrigin;
|
|
1471
|
+
const webOrigin = flagString(parsed, "web-origin") ?? current.webOrigin;
|
|
1472
|
+
const oauthClientId = flagString(parsed, "oauth-client-id") ?? current.oauthClientId;
|
|
1473
|
+
for (const [label, value] of [
|
|
1474
|
+
["--api-origin", apiOrigin],
|
|
1475
|
+
["--web-origin", webOrigin],
|
|
1476
|
+
]) {
|
|
1477
|
+
try {
|
|
1478
|
+
new URL(value);
|
|
1479
|
+
}
|
|
1480
|
+
catch {
|
|
1481
|
+
throw new CliError("invalid_arguments", `${label} must be a URL.`);
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
await saveProfile(name, { apiOrigin, webOrigin, oauthClientId });
|
|
1485
|
+
return { saved: name, apiOrigin, webOrigin, oauthClientId };
|
|
1486
|
+
}
|
|
1487
|
+
if (verb === "use") {
|
|
1488
|
+
if (!name)
|
|
1489
|
+
throw new CliError("invalid_arguments", "Missing profile name.");
|
|
1490
|
+
await useProfile(name);
|
|
1491
|
+
return { activeProfile: name };
|
|
1492
|
+
}
|
|
1493
|
+
if (verb === "delete") {
|
|
1494
|
+
if (!name)
|
|
1495
|
+
throw new CliError("invalid_arguments", "Missing profile name.");
|
|
1496
|
+
await deleteProfile(name);
|
|
1497
|
+
return { deleted: name };
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
if (noun === "jobs") {
|
|
1501
|
+
if (verb === "list") {
|
|
1502
|
+
const limit = flagNumber(parsed, "limit") ?? 20;
|
|
1503
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
|
|
1504
|
+
throw new CliError("invalid_arguments", "--limit must be an integer from 1 through 100.");
|
|
1505
|
+
}
|
|
1506
|
+
return listJobs(limit);
|
|
1507
|
+
}
|
|
1508
|
+
if (verb === "get") {
|
|
1509
|
+
const jobId = positional(parsed, 2, "operation key");
|
|
1510
|
+
const result = await getJob(jobId);
|
|
1511
|
+
if (!result.job)
|
|
1512
|
+
throw new CliError("not_found", `Unknown job ${jobId}.`);
|
|
1513
|
+
return result;
|
|
1514
|
+
}
|
|
1515
|
+
if (verb === "prune") {
|
|
1516
|
+
const olderThanDays = flagNumber(parsed, "older-than-days") ?? 30;
|
|
1517
|
+
if (!Number.isInteger(olderThanDays) ||
|
|
1518
|
+
olderThanDays < 1 ||
|
|
1519
|
+
olderThanDays > 3650) {
|
|
1520
|
+
throw new CliError("invalid_arguments", "--older-than-days must be an integer from 1 through 3650.");
|
|
1521
|
+
}
|
|
1522
|
+
return pruneJobs(olderThanDays);
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
if (noun === "feedback") {
|
|
1526
|
+
if (verb === "list") {
|
|
1527
|
+
const limit = flagNumber(parsed, "limit") ?? 20;
|
|
1528
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
|
|
1529
|
+
throw new CliError("invalid_arguments", "--limit must be an integer from 1 through 100.");
|
|
1530
|
+
}
|
|
1531
|
+
return listFeedback(limit);
|
|
1532
|
+
}
|
|
1533
|
+
if (verb === "submit") {
|
|
1534
|
+
const feedbackText = parsed.positionals.slice(2).join(" ").trim();
|
|
1535
|
+
if (!feedbackText) {
|
|
1536
|
+
throw new CliError("invalid_arguments", "Feedback text must not be empty.");
|
|
1537
|
+
}
|
|
1538
|
+
if (feedbackText.length > 4_000) {
|
|
1539
|
+
throw new CliError("invalid_arguments", "Feedback text must be 4,000 characters or fewer.");
|
|
1540
|
+
}
|
|
1541
|
+
return writeCommand(parsed, {
|
|
1542
|
+
action: "record Recess CLI feedback locally and send it upstream when configured",
|
|
1543
|
+
target: {
|
|
1544
|
+
upstreamConfigured: Boolean(process.env.RECESS_CLI_FEEDBACK_ENDPOINT),
|
|
1545
|
+
},
|
|
1546
|
+
request: { text: feedbackText },
|
|
1547
|
+
}, () => submitFeedback({
|
|
1548
|
+
id: flagString(parsed, "operation-key", { required: true }),
|
|
1549
|
+
text: feedbackText,
|
|
1550
|
+
createdAt: new Date().toISOString(),
|
|
1551
|
+
}));
|
|
1552
|
+
}
|
|
1338
1553
|
}
|
|
1339
|
-
const config = await resolveConfig();
|
|
1554
|
+
const config = await resolveConfig(flagString(parsed, "profile"));
|
|
1555
|
+
const requestReason = noun === "auth" ? undefined : requiredRequestReason(parsed);
|
|
1340
1556
|
if (noun === "doctor")
|
|
1341
|
-
return doctor(config);
|
|
1557
|
+
return doctor(config, requestReason);
|
|
1342
1558
|
if (noun === "setup") {
|
|
1343
1559
|
// Always lay down the bundled copy first: it is the floor, and it is the
|
|
1344
1560
|
// only copy guaranteed to match this binary. The served upgrade below is
|
|
@@ -1348,6 +1564,7 @@ export async function runCommand(argv) {
|
|
|
1348
1564
|
apiOrigin: config.apiOrigin,
|
|
1349
1565
|
sessionCookie: config.sessionCookie,
|
|
1350
1566
|
cliVersion: await readCliVersion(),
|
|
1567
|
+
reason: requestReason,
|
|
1351
1568
|
});
|
|
1352
1569
|
const ephemeral = isEphemeralInstall();
|
|
1353
1570
|
// Reuse a session that is still live; a missing or already-expired one is
|
|
@@ -1400,12 +1617,12 @@ export async function runCommand(argv) {
|
|
|
1400
1617
|
if (verb === "status")
|
|
1401
1618
|
return sessionStatus(config);
|
|
1402
1619
|
if (verb === "logout") {
|
|
1403
|
-
await clearStoredSession();
|
|
1620
|
+
await clearStoredSession(config.profileName);
|
|
1404
1621
|
return { loggedOut: true };
|
|
1405
1622
|
}
|
|
1406
1623
|
throw new CliError("invalid_arguments", "Use auth login, request, poll, status, or logout.");
|
|
1407
1624
|
}
|
|
1408
|
-
const api = new RecessAdminApi(config);
|
|
1625
|
+
const api = new RecessAdminApi(config, requestReason);
|
|
1409
1626
|
api.requireAuth();
|
|
1410
1627
|
if (noun === "village") {
|
|
1411
1628
|
const targetWorldId = flagString(parsed, "world") ?? "village-1";
|
|
@@ -1424,17 +1641,180 @@ export async function runCommand(argv) {
|
|
|
1424
1641
|
return value;
|
|
1425
1642
|
};
|
|
1426
1643
|
if (verb === "render") {
|
|
1644
|
+
const renderWorldId = flagString(parsed, "world") ??
|
|
1645
|
+
(config.user && config.user.role !== "ADMIN"
|
|
1646
|
+
? `home-${config.user.id}`
|
|
1647
|
+
: targetWorldId);
|
|
1427
1648
|
const params = new URLSearchParams({
|
|
1428
|
-
worldId: targetWorldId,
|
|
1429
1649
|
minX: String(numberFlag("min-x")),
|
|
1430
1650
|
minZ: String(numberFlag("min-z")),
|
|
1431
1651
|
maxX: String(numberFlag("max-x")),
|
|
1432
1652
|
maxZ: String(numberFlag("max-z")),
|
|
1433
1653
|
});
|
|
1434
|
-
return api.
|
|
1654
|
+
return api.villageRead(`/api/cli/worlds/${encodeURIComponent(renderWorldId)}/render?${params}`);
|
|
1655
|
+
}
|
|
1656
|
+
const directWorldId = flagString(parsed, "world") ??
|
|
1657
|
+
(config.user?.id ? `home-${config.user.id}` : targetWorldId);
|
|
1658
|
+
if (verb === "library") {
|
|
1659
|
+
const action = positional(parsed, 2, "Village library action");
|
|
1660
|
+
const base = `/api/cli/worlds/${encodeURIComponent(directWorldId)}/library`;
|
|
1661
|
+
if (action === "search") {
|
|
1662
|
+
const limit = flagNumber(parsed, "limit") ?? 20;
|
|
1663
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 50) {
|
|
1664
|
+
throw new CliError("invalid_arguments", "--limit must be an integer from 1 through 50.");
|
|
1665
|
+
}
|
|
1666
|
+
const query = flagString(parsed, "query") ??
|
|
1667
|
+
parsed.positionals.slice(3).join(" ").trim();
|
|
1668
|
+
const params = new URLSearchParams({ limit: String(limit) });
|
|
1669
|
+
if (query)
|
|
1670
|
+
params.set("q", query);
|
|
1671
|
+
return api.villageRead(`${base}?${params}`);
|
|
1672
|
+
}
|
|
1673
|
+
if (action === "get") {
|
|
1674
|
+
const modelId = positional(parsed, 3, "model ID");
|
|
1675
|
+
return api.villageRead(`${base}/${encodeURIComponent(modelId)}`);
|
|
1676
|
+
}
|
|
1677
|
+
throw new CliError("invalid_arguments", "Use village library search|get.");
|
|
1678
|
+
}
|
|
1679
|
+
if (verb === "objects") {
|
|
1680
|
+
const action = positional(parsed, 2, "Village objects action");
|
|
1681
|
+
const base = `/api/cli/worlds/${encodeURIComponent(directWorldId)}/objects`;
|
|
1682
|
+
if (action === "list") {
|
|
1683
|
+
const region = {
|
|
1684
|
+
minX: numberFlag("min-x", -20),
|
|
1685
|
+
minZ: numberFlag("min-z", -20),
|
|
1686
|
+
maxX: numberFlag("max-x", 20),
|
|
1687
|
+
maxZ: numberFlag("max-z", 20),
|
|
1688
|
+
};
|
|
1689
|
+
if (region.minX > region.maxX ||
|
|
1690
|
+
region.minZ > region.maxZ ||
|
|
1691
|
+
region.maxX - region.minX > 128 ||
|
|
1692
|
+
region.maxZ - region.minZ > 128) {
|
|
1693
|
+
throw new CliError("invalid_arguments", "Object bounds must be ordered and no wider than 128m per side.");
|
|
1694
|
+
}
|
|
1695
|
+
const params = new URLSearchParams(Object.fromEntries(Object.entries(region).map(([key, value]) => [key, String(value)])));
|
|
1696
|
+
return api.villageRead(`${base}?${params}`);
|
|
1697
|
+
}
|
|
1698
|
+
if (action === "get") {
|
|
1699
|
+
const objectId = positional(parsed, 3, "object ID");
|
|
1700
|
+
return api.villageRead(`${base}/${encodeURIComponent(objectId)}`);
|
|
1701
|
+
}
|
|
1702
|
+
throw new CliError("invalid_arguments", "Use village objects list|get.");
|
|
1703
|
+
}
|
|
1704
|
+
if (verb === "build") {
|
|
1705
|
+
const action = positional(parsed, 2, "Village build action");
|
|
1706
|
+
if (action !== "cmd") {
|
|
1707
|
+
throw new CliError("invalid_arguments", "Use village build cmd '<command-json>' or --file <command.json>.");
|
|
1708
|
+
}
|
|
1709
|
+
const commandFile = flagString(parsed, "file");
|
|
1710
|
+
const inlineCommand = parsed.positionals[3];
|
|
1711
|
+
if (Boolean(commandFile) === Boolean(inlineCommand)) {
|
|
1712
|
+
throw new CliError("invalid_arguments", "Pass exactly one command JSON argument or --file <command.json>.");
|
|
1713
|
+
}
|
|
1714
|
+
let command;
|
|
1715
|
+
let source;
|
|
1716
|
+
if (commandFile) {
|
|
1717
|
+
const value = await readJsonValue(commandFile, "Village command file");
|
|
1718
|
+
if (value.parsed === null ||
|
|
1719
|
+
typeof value.parsed !== "object" ||
|
|
1720
|
+
Array.isArray(value.parsed)) {
|
|
1721
|
+
throw new CliError("invalid_arguments", "Village command file must contain one JSON object.");
|
|
1722
|
+
}
|
|
1723
|
+
command = value.parsed;
|
|
1724
|
+
source = {
|
|
1725
|
+
file: value.absolutePath,
|
|
1726
|
+
sha256: createHash("sha256").update(value.raw).digest("hex"),
|
|
1727
|
+
};
|
|
1728
|
+
}
|
|
1729
|
+
else {
|
|
1730
|
+
try {
|
|
1731
|
+
const value = JSON.parse(inlineCommand);
|
|
1732
|
+
if (value === null ||
|
|
1733
|
+
typeof value !== "object" ||
|
|
1734
|
+
Array.isArray(value)) {
|
|
1735
|
+
throw new Error("command must be an object");
|
|
1736
|
+
}
|
|
1737
|
+
command = value;
|
|
1738
|
+
}
|
|
1739
|
+
catch (error) {
|
|
1740
|
+
throw new CliError("invalid_arguments", `Village command is not a JSON object: ${error instanceof Error ? error.message : String(error)}`);
|
|
1741
|
+
}
|
|
1742
|
+
source = { inline: true };
|
|
1743
|
+
}
|
|
1744
|
+
const buildWorldId = flagString(parsed, "world") ??
|
|
1745
|
+
(config.user?.id ? `home-${config.user.id}` : undefined);
|
|
1746
|
+
if (!buildWorldId) {
|
|
1747
|
+
throw new CliError("invalid_arguments", "Missing --world. Login normally to default to your home world, or pass the exact world ID.");
|
|
1748
|
+
}
|
|
1749
|
+
return writeCommand(parsed, {
|
|
1750
|
+
action: "run one authenticated Village build command",
|
|
1751
|
+
target: { worldId: buildWorldId },
|
|
1752
|
+
request: { command, ...source },
|
|
1753
|
+
details: {
|
|
1754
|
+
consequence: "The Village server applies the same ownership, block-limit, and broadcast rules as an in-world wrench edit.",
|
|
1755
|
+
},
|
|
1756
|
+
}, () => api.villageCommand(buildWorldId, command));
|
|
1757
|
+
}
|
|
1758
|
+
if (verb === "worlds") {
|
|
1759
|
+
const action = positional(parsed, 2, "Village world action");
|
|
1760
|
+
const worldId = positional(parsed, 3, "world ID");
|
|
1761
|
+
const bridgePath = `/admin/village/worlds/${encodeURIComponent(worldId)}`;
|
|
1762
|
+
if (action === "export") {
|
|
1763
|
+
const output = flagString(parsed, "out");
|
|
1764
|
+
if (!output) {
|
|
1765
|
+
return api.villageRequest(`${bridgePath}/export`);
|
|
1766
|
+
}
|
|
1767
|
+
const absolutePath = path.resolve(output);
|
|
1768
|
+
return writeCommand(parsed, {
|
|
1769
|
+
action: "export a Village world bundle to a local file",
|
|
1770
|
+
target: { worldId, file: absolutePath },
|
|
1771
|
+
request: { overwrite: true },
|
|
1772
|
+
}, async () => {
|
|
1773
|
+
const bundle = await api.villageRequest(`${bridgePath}/export`);
|
|
1774
|
+
const contents = `${JSON.stringify(bundle, null, 2)}\n`;
|
|
1775
|
+
await fs.writeFile(absolutePath, contents, "utf8");
|
|
1776
|
+
return {
|
|
1777
|
+
worldId,
|
|
1778
|
+
file: absolutePath,
|
|
1779
|
+
sizeBytes: Buffer.byteLength(contents),
|
|
1780
|
+
};
|
|
1781
|
+
});
|
|
1782
|
+
}
|
|
1783
|
+
if (action === "import") {
|
|
1784
|
+
const value = await readJsonValue(flagString(parsed, "file", { required: true }), "Village world bundle");
|
|
1785
|
+
return writeCommand(parsed, {
|
|
1786
|
+
action: "replace a Village world from an imported bundle",
|
|
1787
|
+
target: { worldId },
|
|
1788
|
+
request: {
|
|
1789
|
+
file: value.absolutePath,
|
|
1790
|
+
sizeBytes: Buffer.byteLength(value.raw),
|
|
1791
|
+
sha256: createHash("sha256").update(value.raw).digest("hex"),
|
|
1792
|
+
},
|
|
1793
|
+
details: {
|
|
1794
|
+
consequence: "The imported rows replace this world's current data and broadcast a live world reset.",
|
|
1795
|
+
},
|
|
1796
|
+
}, () => api.villageRequest(`${bridgePath}/import`, {
|
|
1797
|
+
method: "POST",
|
|
1798
|
+
body: { bundle: value.parsed, confirm: "import" },
|
|
1799
|
+
}));
|
|
1800
|
+
}
|
|
1801
|
+
if (action === "promote") {
|
|
1802
|
+
return writeCommand(parsed, {
|
|
1803
|
+
action: "promote a Village mirror or archive into the live hub",
|
|
1804
|
+
target: { worldId },
|
|
1805
|
+
request: { confirm: "promote" },
|
|
1806
|
+
details: {
|
|
1807
|
+
consequence: "The current hub is archived, this world replaces it atomically, and connected players receive a live world reset.",
|
|
1808
|
+
},
|
|
1809
|
+
}, () => api.villageRequest(`${bridgePath}/promote`, {
|
|
1810
|
+
method: "POST",
|
|
1811
|
+
body: { confirm: "promote" },
|
|
1812
|
+
}));
|
|
1813
|
+
}
|
|
1814
|
+
throw new CliError("invalid_arguments", "Use village worlds export|import|promote.");
|
|
1435
1815
|
}
|
|
1436
1816
|
if (verb !== "models") {
|
|
1437
|
-
throw new CliError("invalid_arguments", "Use village models
|
|
1817
|
+
throw new CliError("invalid_arguments", "Use village build …, village library …, village objects …, village worlds …, village models …, or village render …");
|
|
1438
1818
|
}
|
|
1439
1819
|
const action = positional(parsed, 2, "Village model action");
|
|
1440
1820
|
if (action === "list") {
|
|
@@ -1865,47 +2245,8 @@ export async function runCommand(argv) {
|
|
|
1865
2245
|
params: { query: { subscriptionId } },
|
|
1866
2246
|
}));
|
|
1867
2247
|
}
|
|
1868
|
-
if (noun === "applications"
|
|
1869
|
-
|
|
1870
|
-
const quoteId = flagString(parsed, "quote", { required: true });
|
|
1871
|
-
const institutionSlug = flagString(parsed, "school", { required: true });
|
|
1872
|
-
const familyId = flagString(parsed, "family");
|
|
1873
|
-
const note = flagString(parsed, "note");
|
|
1874
|
-
// Repeatable --kid, one per PRICED LINE on the quote. The server refuses a
|
|
1875
|
-
// partial roster (every priced student must be enrolled), so this is
|
|
1876
|
-
// deliberately not a convenience list — it is the whole quote, echoed back.
|
|
1877
|
-
const kidSpecs = flagList(parsed, "kid");
|
|
1878
|
-
if (kidSpecs.length === 0) {
|
|
1879
|
-
throw new CliError("invalid_arguments", 'Missing --kid. Pass one per quote line: --kid "<quoteLineId>:<firstName>[:<age>]".');
|
|
1880
|
-
}
|
|
1881
|
-
const kids = kidSpecs.map(parseEnrollKidSpec);
|
|
1882
|
-
// Every OTHER live child in the family must be named explicitly. The server
|
|
1883
|
-
// refuses the whole enrollment otherwise, listing who was unlisted — so the
|
|
1884
|
-
// failure is legible either way, but naming them here is how a staffer says
|
|
1885
|
-
// "yes, I know, they are not enrolling".
|
|
1886
|
-
const dispositions = flagList(parsed, "unassign").map((kidUserId) => ({
|
|
1887
|
-
kidUserId,
|
|
1888
|
-
action: "unassigned",
|
|
1889
|
-
}));
|
|
1890
|
-
return writeCommand(parsed, {
|
|
1891
|
-
// One short clause, like every other preview in this file. The
|
|
1892
|
-
// consequences are enumerated in `request` below, which is what the
|
|
1893
|
-
// confirmation prompt prints in full — restating them here would make
|
|
1894
|
-
// this the only preview a staffer has to read twice.
|
|
1895
|
-
action: "enroll an application from its accepted quote (creates children, charges the first month, cancels marketplace subscriptions)",
|
|
1896
|
-
target: { applicationId, quoteId, institutionSlug, familyId },
|
|
1897
|
-
request: { kids, dispositions, note },
|
|
1898
|
-
}, async () => unwrap(await api.client.POST("/admin/applications/{applicationId}/enroll", {
|
|
1899
|
-
params: { path: { applicationId } },
|
|
1900
|
-
body: {
|
|
1901
|
-
quoteId,
|
|
1902
|
-
institutionSlug,
|
|
1903
|
-
kids,
|
|
1904
|
-
...(familyId ? { familyId } : {}),
|
|
1905
|
-
...(dispositions.length > 0 ? { dispositions } : {}),
|
|
1906
|
-
...(note ? { note } : {}),
|
|
1907
|
-
},
|
|
1908
|
-
})));
|
|
2248
|
+
if (noun === "applications" || noun === "quotes") {
|
|
2249
|
+
return runApplicationsCommand({ parsed, api, writeCommand });
|
|
1909
2250
|
}
|
|
1910
2251
|
if (noun === "cohorts" && verb === "search") {
|
|
1911
2252
|
const search = parsed.positionals.slice(2).join(" ").trim();
|
|
@@ -2001,52 +2342,47 @@ export async function runCommand(argv) {
|
|
|
2001
2342
|
? { firstChargeAt: new Date(firstChargeAt).toISOString() }
|
|
2002
2343
|
: {}),
|
|
2003
2344
|
};
|
|
2004
|
-
//
|
|
2005
|
-
//
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
// the human before the confirmation gate.
|
|
2009
|
-
if (!hasFlag(parsed, "confirm")) {
|
|
2010
|
-
const preview = unwrap(await api.client.POST("/admin/cohorts/enroll/", {
|
|
2011
|
-
body: { ...body, dryRun: true },
|
|
2012
|
-
}));
|
|
2013
|
-
const b = preview.plan.billing;
|
|
2014
|
-
const usd = (cents) => `${(cents / 100).toFixed(2)} USD`;
|
|
2015
|
-
const recurrence = b.isRecurring ? `, recurring per ${b.interval}` : "";
|
|
2016
|
-
// effectivePriceCents already has the course discount applied; credits are
|
|
2017
|
-
// deducted later as an invoice line, so this is an upper bound.
|
|
2018
|
-
const amount = b.discountKind || b.creditBalanceCents > 0
|
|
2019
|
-
? `${usd(b.effectivePriceCents)} (list ${usd(b.listPriceCents)}${b.discountKind ? `, ${b.discountKind} discount applied` : ""}${b.creditBalanceCents > 0 ? `; up to ${usd(b.creditBalanceCents)} of Recess credits may reduce this further` : ""})`
|
|
2020
|
-
: usd(b.effectivePriceCents);
|
|
2021
|
-
requireConfirmation(false, {
|
|
2022
|
-
action: preview.plan.reusedEnrollmentId
|
|
2023
|
-
? "enroll kid into cohort by reusing an existing enrollment (no charge)"
|
|
2024
|
-
: b.chargesImmediately
|
|
2025
|
-
? `enroll kid into cohort and CHARGE ${amount} NOW${recurrence}`
|
|
2026
|
-
: `enroll kid into cohort with first charge of ${amount} on ${b.firstChargeAt}${recurrence}`,
|
|
2027
|
-
target: {
|
|
2028
|
-
kid: `${preview.plan.kid.firstName ?? ""} ${preview.plan.kid.lastName ?? ""}`.trim(),
|
|
2029
|
-
userId,
|
|
2030
|
-
cohort: preview.plan.cohort.name,
|
|
2031
|
-
cohortId,
|
|
2032
|
-
course: preview.plan.cohort.courseName,
|
|
2033
|
-
},
|
|
2034
|
-
request: { ...body, dryRun: false },
|
|
2035
|
-
details: {
|
|
2036
|
-
billing: b,
|
|
2037
|
-
reusedEnrollmentId: preview.plan.reusedEnrollmentId,
|
|
2038
|
-
warnings: preview.warnings,
|
|
2039
|
-
...(preview.warnings.length > 0 && !body.force
|
|
2040
|
-
? {
|
|
2041
|
-
blocked: "These warnings will reject the write unless you also pass --force.",
|
|
2042
|
-
}
|
|
2043
|
-
: {}),
|
|
2044
|
-
},
|
|
2045
|
-
});
|
|
2046
|
-
}
|
|
2047
|
-
return unwrap(await api.client.POST("/admin/cohorts/enroll/", {
|
|
2048
|
-
body: { ...body, dryRun: false },
|
|
2345
|
+
// The backend owns the price/capacity plan. Recompute it on every run and
|
|
2346
|
+
// bind the confirmation token to that exact plan before the mutation.
|
|
2347
|
+
const preflight = unwrap(await api.client.POST("/admin/cohorts/enroll/", {
|
|
2348
|
+
body: { ...body, dryRun: true },
|
|
2049
2349
|
}));
|
|
2350
|
+
const billing = preflight.plan.billing;
|
|
2351
|
+
const usd = (cents) => `${(cents / 100).toFixed(2)} USD`;
|
|
2352
|
+
const recurrence = billing.isRecurring
|
|
2353
|
+
? `, recurring per ${billing.interval}`
|
|
2354
|
+
: "";
|
|
2355
|
+
const amount = billing.discountKind || billing.creditBalanceCents > 0
|
|
2356
|
+
? `${usd(billing.effectivePriceCents)} (list ${usd(billing.listPriceCents)}${billing.discountKind ? `, ${billing.discountKind} discount applied` : ""}${billing.creditBalanceCents > 0 ? `; up to ${usd(billing.creditBalanceCents)} of Recess credits may reduce this further` : ""})`
|
|
2357
|
+
: usd(billing.effectivePriceCents);
|
|
2358
|
+
const preview = {
|
|
2359
|
+
action: preflight.plan.reusedEnrollmentId
|
|
2360
|
+
? "enroll kid into cohort by reusing an existing enrollment (no charge)"
|
|
2361
|
+
: billing.chargesImmediately
|
|
2362
|
+
? `enroll kid into cohort and CHARGE ${amount} NOW${recurrence}`
|
|
2363
|
+
: `enroll kid into cohort with first charge of ${amount} on ${billing.firstChargeAt}${recurrence}`,
|
|
2364
|
+
target: {
|
|
2365
|
+
kid: `${preflight.plan.kid.firstName ?? ""} ${preflight.plan.kid.lastName ?? ""}`.trim(),
|
|
2366
|
+
userId,
|
|
2367
|
+
cohort: preflight.plan.cohort.name,
|
|
2368
|
+
cohortId,
|
|
2369
|
+
course: preflight.plan.cohort.courseName,
|
|
2370
|
+
},
|
|
2371
|
+
request: { ...body, dryRun: false },
|
|
2372
|
+
details: {
|
|
2373
|
+
billing,
|
|
2374
|
+
reusedEnrollmentId: preflight.plan.reusedEnrollmentId,
|
|
2375
|
+
warnings: preflight.warnings,
|
|
2376
|
+
...(preflight.warnings.length > 0 && !body.force
|
|
2377
|
+
? {
|
|
2378
|
+
blocked: "These warnings will reject the write unless you also pass --force.",
|
|
2379
|
+
}
|
|
2380
|
+
: {}),
|
|
2381
|
+
},
|
|
2382
|
+
};
|
|
2383
|
+
return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.POST("/admin/cohorts/enroll/", {
|
|
2384
|
+
body: { ...body, dryRun: false },
|
|
2385
|
+
})));
|
|
2050
2386
|
}
|
|
2051
2387
|
if (noun === "enrollments" && verb === "register-cohort") {
|
|
2052
2388
|
const enrollmentId = flagString(parsed, "enrollment", { required: true });
|
|
@@ -2141,12 +2477,20 @@ export async function runCommand(argv) {
|
|
|
2141
2477
|
const search = flagString(parsed, "search");
|
|
2142
2478
|
const userId = flagString(parsed, "user");
|
|
2143
2479
|
const id = flagString(parsed, "id");
|
|
2144
|
-
|
|
2145
|
-
|
|
2480
|
+
const limit = flagNumber(parsed, "limit") ?? 20;
|
|
2481
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
|
|
2482
|
+
throw new CliError("invalid_arguments", "--limit must be an integer between 1 and 100.");
|
|
2483
|
+
}
|
|
2484
|
+
return unwrap(await api.client.GET("/admin/payout/recipient/", {
|
|
2485
|
+
params: {
|
|
2146
2486
|
query: {
|
|
2147
2487
|
...(search ? { search } : {}),
|
|
2148
2488
|
...(userId ? { userId } : {}),
|
|
2149
2489
|
...(id ? { id } : {}),
|
|
2490
|
+
limit,
|
|
2491
|
+
...(flagString(parsed, "cursor")
|
|
2492
|
+
? { cursor: flagString(parsed, "cursor") }
|
|
2493
|
+
: {}),
|
|
2150
2494
|
},
|
|
2151
2495
|
},
|
|
2152
2496
|
}));
|
|
@@ -2483,215 +2827,7 @@ export async function runCommand(argv) {
|
|
|
2483
2827
|
}, async () => unwrap(await api.client.POST("/admin/cohorts/unregister/", { body })));
|
|
2484
2828
|
}
|
|
2485
2829
|
if (noun === "onboarding") {
|
|
2486
|
-
|
|
2487
|
-
const familyId = positional(parsed, 2, "family ID");
|
|
2488
|
-
return unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/status", {
|
|
2489
|
-
params: { path: { familyId } },
|
|
2490
|
-
}));
|
|
2491
|
-
}
|
|
2492
|
-
if (verb === "kids") {
|
|
2493
|
-
const timePeriodDays = flagNumber(parsed, "time-period-days");
|
|
2494
|
-
const cohortId = flagString(parsed, "cohort");
|
|
2495
|
-
const limit = flagNumber(parsed, "limit");
|
|
2496
|
-
const stageFilterRaw = flagString(parsed, "stage-filter");
|
|
2497
|
-
const stageFilter = stageFilterRaw
|
|
2498
|
-
? assertChoice(stageFilterRaw, ["all", "scheduled", "oriented", "course", "converted", "lost"], "--stage-filter")
|
|
2499
|
-
: undefined;
|
|
2500
|
-
return unwrap(await api.client.GET("/admin/onboarding/kids/", {
|
|
2501
|
-
params: {
|
|
2502
|
-
query: {
|
|
2503
|
-
...(timePeriodDays !== undefined ? { timePeriodDays } : {}),
|
|
2504
|
-
...(cohortId ? { cohortId } : {}),
|
|
2505
|
-
...(limit !== undefined ? { limit } : {}),
|
|
2506
|
-
...(stageFilter ? { stageFilter } : {}),
|
|
2507
|
-
},
|
|
2508
|
-
},
|
|
2509
|
-
}));
|
|
2510
|
-
}
|
|
2511
|
-
if (verb === "intake-session") {
|
|
2512
|
-
const familyId = positional(parsed, 2, "family ID");
|
|
2513
|
-
// A true read: look up the family's current IN_PROGRESS intake session
|
|
2514
|
-
// WITHOUT minting one (get.family-intake-session.ts). Merely viewing a
|
|
2515
|
-
// family must not create a blank session — use intake-session-create to
|
|
2516
|
-
// mint one. A 404 means "none yet" and is a clean result, not an error.
|
|
2517
|
-
try {
|
|
2518
|
-
return await api.rawGet(`/admin/onboarding/families/${encodeURIComponent(familyId)}/intake-session`);
|
|
2519
|
-
}
|
|
2520
|
-
catch (error) {
|
|
2521
|
-
if (error instanceof CliError &&
|
|
2522
|
-
typeof error.details === "object" &&
|
|
2523
|
-
error.details !== null &&
|
|
2524
|
-
error.details.status === 404) {
|
|
2525
|
-
return {
|
|
2526
|
-
intakeSession: null,
|
|
2527
|
-
familyId,
|
|
2528
|
-
message: "No intake session for this family yet.",
|
|
2529
|
-
};
|
|
2530
|
-
}
|
|
2531
|
-
throw error;
|
|
2532
|
-
}
|
|
2533
|
-
}
|
|
2534
|
-
if (verb === "intake-session-create") {
|
|
2535
|
-
const familyId = positional(parsed, 2, "family ID");
|
|
2536
|
-
// The explicit create: get-or-create the intake session. Mints a blank
|
|
2537
|
-
// IN_PROGRESS session when none exists (post.family-intake-session.ts,
|
|
2538
|
-
// freshIfFinished), so it is a write and goes through the confirmation
|
|
2539
|
-
// gate like every other mutating command.
|
|
2540
|
-
return writeCommand(parsed, {
|
|
2541
|
-
action: "create the family's parent intake session (mints a blank IN_PROGRESS session if none exists)",
|
|
2542
|
-
target: { familyId },
|
|
2543
|
-
request: {},
|
|
2544
|
-
}, async () => unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/intake-session", { params: { path: { familyId } } })));
|
|
2545
|
-
}
|
|
2546
|
-
if (verb === "set-stage") {
|
|
2547
|
-
const familyId = positional(parsed, 2, "family ID");
|
|
2548
|
-
const stage = assertChoice(flagString(parsed, "stage", { required: true }), ONBOARDING_STAGES, "--stage");
|
|
2549
|
-
// Read the current stage first so the preview can name the transition and
|
|
2550
|
-
// warn when it moves progress backward (re-locks capabilities).
|
|
2551
|
-
const current = unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/status", {
|
|
2552
|
-
params: { path: { familyId } },
|
|
2553
|
-
}));
|
|
2554
|
-
const from = current.onboardingStage;
|
|
2555
|
-
const backward = ONBOARDING_STAGES.indexOf(stage) < ONBOARDING_STAGES.indexOf(from);
|
|
2556
|
-
const body = { stage };
|
|
2557
|
-
return writeCommand(parsed, {
|
|
2558
|
-
action: `${backward ? "MOVE BACKWARD — re-locks progress: " : ""}set onboarding stage to ${stage} — ${ONBOARDING_STAGE_SIDE_EFFECTS[stage]}`,
|
|
2559
|
-
target: { familyId, from },
|
|
2560
|
-
request: body,
|
|
2561
|
-
}, async () => unwrap(await api.client.PATCH("/admin/onboarding/families/{familyId}/stage", { params: { path: { familyId } }, body })));
|
|
2562
|
-
}
|
|
2563
|
-
if (verb === "set-account-state") {
|
|
2564
|
-
const familyId = positional(parsed, 2, "family ID");
|
|
2565
|
-
const state = assertChoice(flagString(parsed, "state", { required: true }), ONBOARDING_ACCOUNT_STATES, "--state");
|
|
2566
|
-
const note = flagString(parsed, "note");
|
|
2567
|
-
const lockout = state === "PAUSED" || state === "BOOTED"
|
|
2568
|
-
? " — locks the family out of paid capabilities"
|
|
2569
|
-
: "";
|
|
2570
|
-
const body = { state, ...(note !== undefined ? { note } : {}) };
|
|
2571
|
-
return writeCommand(parsed, {
|
|
2572
|
-
action: `set account state to ${state}${lockout}`,
|
|
2573
|
-
target: { familyId },
|
|
2574
|
-
request: body,
|
|
2575
|
-
}, async () => unwrap(await api.client.PATCH("/admin/onboarding/families/{familyId}/account-state", { params: { path: { familyId } }, body })));
|
|
2576
|
-
}
|
|
2577
|
-
if (verb === "attest") {
|
|
2578
|
-
const familyId = positional(parsed, 2, "family ID");
|
|
2579
|
-
const condition = assertChoice(flagString(parsed, "condition", { required: true }), ONBOARDING_CONDITIONS, "--condition");
|
|
2580
|
-
const revoke = hasFlag(parsed, "revoke");
|
|
2581
|
-
const note = flagString(parsed, "note");
|
|
2582
|
-
const body = {
|
|
2583
|
-
condition,
|
|
2584
|
-
attested: !revoke,
|
|
2585
|
-
...(note !== undefined ? { note } : {}),
|
|
2586
|
-
};
|
|
2587
|
-
return writeCommand(parsed, {
|
|
2588
|
-
action: revoke
|
|
2589
|
-
? `REVOKE attestation ${condition}`
|
|
2590
|
-
: `attest ${condition}`,
|
|
2591
|
-
target: { familyId },
|
|
2592
|
-
request: body,
|
|
2593
|
-
}, async () => unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/attest", { params: { path: { familyId } }, body })));
|
|
2594
|
-
}
|
|
2595
|
-
if (verb === "set-intake") {
|
|
2596
|
-
const familyId = positional(parsed, 2, "family ID");
|
|
2597
|
-
const sessionId = flagString(parsed, "session", { required: true });
|
|
2598
|
-
const dataRaw = flagString(parsed, "data", { required: true });
|
|
2599
|
-
const expectedUpdatedAt = flagString(parsed, "expected-updated-at");
|
|
2600
|
-
let collectedData;
|
|
2601
|
-
try {
|
|
2602
|
-
collectedData = JSON.parse(dataRaw);
|
|
2603
|
-
}
|
|
2604
|
-
catch {
|
|
2605
|
-
throw new CliError("invalid_arguments", "--data must be valid JSON for the intake collectedData object.");
|
|
2606
|
-
}
|
|
2607
|
-
if (expectedUpdatedAt !== undefined &&
|
|
2608
|
-
Number.isNaN(Date.parse(expectedUpdatedAt))) {
|
|
2609
|
-
throw new CliError("invalid_arguments", "--expected-updated-at must be an ISO-8601 timestamp (the intake-session read's updatedAt).");
|
|
2610
|
-
}
|
|
2611
|
-
const collected = collectedData;
|
|
2612
|
-
// The preview must do ZERO network so an offline preview stays honest
|
|
2613
|
-
// (admin-cli offline-preview contract). The optimistic-concurrency token
|
|
2614
|
-
// is resolved only on --confirm, inside the execute closure below — so a
|
|
2615
|
-
// plain preview never reaches out. Strict mode (--expected-updated-at)
|
|
2616
|
-
// shows the pinned token in the preview; the default fetches it at confirm.
|
|
2617
|
-
const previewRequest = {
|
|
2618
|
-
sessionId,
|
|
2619
|
-
collectedData: collected,
|
|
2620
|
-
...(expectedUpdatedAt !== undefined ? { expectedUpdatedAt } : {}),
|
|
2621
|
-
};
|
|
2622
|
-
return writeCommand(parsed, {
|
|
2623
|
-
action: "write structured parent intake data for the family",
|
|
2624
|
-
target: { familyId, sessionId },
|
|
2625
|
-
request: previewRequest,
|
|
2626
|
-
}, async () => {
|
|
2627
|
-
// Resolve the CAS token now (only reached on --confirm):
|
|
2628
|
-
// - strict mode: pin the operator-supplied token. A stale one 409s
|
|
2629
|
-
// (unwrap → CliError → non-zero exit), protecting a parent's
|
|
2630
|
-
// concurrent confirm-flow autosave.
|
|
2631
|
-
// - default: fetch the CURRENT token and warn that edits made since
|
|
2632
|
-
// the preview are unprotected. The CLI is a last-resort admin tool;
|
|
2633
|
-
// pragmatic default + honest warning + strict opt-in.
|
|
2634
|
-
let token = expectedUpdatedAt;
|
|
2635
|
-
if (token === undefined) {
|
|
2636
|
-
const current = unwrap(await api.client.GET("/admin/onboarding/families/{familyId}/intake-session", { params: { path: { familyId } } }));
|
|
2637
|
-
token = current.updatedAt;
|
|
2638
|
-
process.stderr.write("Warning: using current session token; edits made since your preview are not protected — pass --expected-updated-at for strict CAS.\n");
|
|
2639
|
-
}
|
|
2640
|
-
const body = {
|
|
2641
|
-
sessionId,
|
|
2642
|
-
collectedData: collected,
|
|
2643
|
-
expectedUpdatedAt: token,
|
|
2644
|
-
};
|
|
2645
|
-
return unwrap(await api.client.PUT("/admin/onboarding/families/{familyId}/intake", { params: { path: { familyId } }, body }));
|
|
2646
|
-
});
|
|
2647
|
-
}
|
|
2648
|
-
if (verb === "extract") {
|
|
2649
|
-
const familyId = positional(parsed, 2, "family ID");
|
|
2650
|
-
const sessionId = flagString(parsed, "session", { required: true });
|
|
2651
|
-
const transcriptFile = flagString(parsed, "transcript-file");
|
|
2652
|
-
const granolaRef = flagString(parsed, "granola");
|
|
2653
|
-
if ((transcriptFile && granolaRef) || (!transcriptFile && !granolaRef)) {
|
|
2654
|
-
throw new CliError("invalid_arguments", "Pass exactly one source: --transcript-file <path> or --granola <ref>.");
|
|
2655
|
-
}
|
|
2656
|
-
let transcript;
|
|
2657
|
-
if (transcriptFile) {
|
|
2658
|
-
try {
|
|
2659
|
-
transcript = await fs.readFile(path.resolve(transcriptFile), "utf8");
|
|
2660
|
-
}
|
|
2661
|
-
catch (error) {
|
|
2662
|
-
throw new CliError("invalid_arguments", `Could not read transcript file ${transcriptFile}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2663
|
-
}
|
|
2664
|
-
}
|
|
2665
|
-
const body = {
|
|
2666
|
-
sessionId,
|
|
2667
|
-
...(transcript !== undefined ? { transcript } : {}),
|
|
2668
|
-
...(granolaRef ? { granolaRef } : {}),
|
|
2669
|
-
};
|
|
2670
|
-
// Keep raw transcript PII out of the confirmation preview (it prints to
|
|
2671
|
-
// stderr, agent logs, and approval records); the full body still goes to
|
|
2672
|
-
// the API on --confirm.
|
|
2673
|
-
const previewRequest = transcript !== undefined && transcriptFile
|
|
2674
|
-
? {
|
|
2675
|
-
sessionId,
|
|
2676
|
-
path: path.resolve(transcriptFile),
|
|
2677
|
-
byteCount: Buffer.byteLength(transcript, "utf8"),
|
|
2678
|
-
sha256Prefix: createHash("sha256")
|
|
2679
|
-
.update(transcript)
|
|
2680
|
-
.digest("hex")
|
|
2681
|
-
.slice(0, 12),
|
|
2682
|
-
}
|
|
2683
|
-
: { sessionId, ...(granolaRef ? { granolaRef } : {}) };
|
|
2684
|
-
return writeCommand(parsed, {
|
|
2685
|
-
action: "extract intake from transcript via Claude and merge into session",
|
|
2686
|
-
target: {
|
|
2687
|
-
familyId,
|
|
2688
|
-
sessionId,
|
|
2689
|
-
source: transcriptFile ? "transcript" : "granola",
|
|
2690
|
-
},
|
|
2691
|
-
request: previewRequest,
|
|
2692
|
-
}, async () => unwrap(await api.client.POST("/admin/onboarding/families/{familyId}/intake-extract", { params: { path: { familyId } }, body })));
|
|
2693
|
-
}
|
|
2694
|
-
throw new CliError("invalid_arguments", "Use onboarding status|kids|intake-session|intake-session-create|set-stage|set-account-state|attest|set-intake|extract.");
|
|
2830
|
+
return runOnboardingCommand({ parsed, api, writeCommand });
|
|
2695
2831
|
}
|
|
2696
2832
|
if (noun === "skills") {
|
|
2697
2833
|
// Reads only — no confirmation gate. Audience is explicit so an agent never
|
|
@@ -2767,6 +2903,19 @@ export async function runCommand(argv) {
|
|
|
2767
2903
|
if (verb === "set-stage") {
|
|
2768
2904
|
const stage = assertChoice(flagString(parsed, "stage", { required: true }), CONTENT_LIBRARY_RESOURCE_STAGES, "--stage");
|
|
2769
2905
|
const input = await readContentLibraryGemTargets(parsed);
|
|
2906
|
+
const wait = hasFlag(parsed, "wait");
|
|
2907
|
+
const timeoutSeconds = flagNumber(parsed, "timeout") ?? 900;
|
|
2908
|
+
if (hasFlag(parsed, "timeout") && !wait) {
|
|
2909
|
+
throw new CliError("invalid_arguments", "--timeout requires --wait.");
|
|
2910
|
+
}
|
|
2911
|
+
if (wait && stage !== "live") {
|
|
2912
|
+
throw new CliError("invalid_arguments", "--wait is supported for content-library set-stage only when --stage live.");
|
|
2913
|
+
}
|
|
2914
|
+
if (!Number.isInteger(timeoutSeconds) ||
|
|
2915
|
+
timeoutSeconds < 10 ||
|
|
2916
|
+
timeoutSeconds > 7200) {
|
|
2917
|
+
throw new CliError("invalid_arguments", "--timeout must be an integer number of seconds from 10 through 7200.");
|
|
2918
|
+
}
|
|
2770
2919
|
const preflightBody = {
|
|
2771
2920
|
gems: input.gems,
|
|
2772
2921
|
stage,
|
|
@@ -2783,16 +2932,39 @@ export async function runCommand(argv) {
|
|
|
2783
2932
|
resources: preflight.results,
|
|
2784
2933
|
lifecycle: "Uses the same lifecycle as Manage. Direct-to-Live never bypasses unfinished polishing; leaving Polishing cancels its bound run before moving.",
|
|
2785
2934
|
concurrency: 3,
|
|
2935
|
+
waitForLive: wait,
|
|
2936
|
+
...(wait ? { timeoutSeconds } : {}),
|
|
2786
2937
|
...(input.file ? { inputFile: input.file } : {}),
|
|
2787
2938
|
},
|
|
2788
2939
|
};
|
|
2789
|
-
return writeCommand(parsed, preview, async () =>
|
|
2790
|
-
|
|
2791
|
-
|
|
2940
|
+
return writeCommand(parsed, preview, async () => {
|
|
2941
|
+
const result = unwrap(await api.client.POST("/admin/content-library/transition", {
|
|
2942
|
+
body: { gems: input.gems, stage, dryRun: false },
|
|
2943
|
+
}));
|
|
2944
|
+
if (!wait)
|
|
2945
|
+
return result;
|
|
2946
|
+
return {
|
|
2947
|
+
...result,
|
|
2948
|
+
wait: await waitForContentLibraryLive(api, input.gems, timeoutSeconds),
|
|
2949
|
+
};
|
|
2950
|
+
});
|
|
2792
2951
|
}
|
|
2793
2952
|
if (verb === "submit") {
|
|
2794
2953
|
const stage = assertChoice(flagString(parsed, "stage") ?? "review", CONTENT_LIBRARY_STAGES, "--stage");
|
|
2795
2954
|
const input = await readContentLibrarySubmitItems(parsed);
|
|
2955
|
+
const wait = hasFlag(parsed, "wait");
|
|
2956
|
+
const timeoutSeconds = flagNumber(parsed, "timeout") ?? 900;
|
|
2957
|
+
if (hasFlag(parsed, "timeout") && !wait) {
|
|
2958
|
+
throw new CliError("invalid_arguments", "--timeout requires --wait.");
|
|
2959
|
+
}
|
|
2960
|
+
if (wait && stage !== "polish") {
|
|
2961
|
+
throw new CliError("invalid_arguments", "--wait is supported for content-library submit only when --stage polish.");
|
|
2962
|
+
}
|
|
2963
|
+
if (!Number.isInteger(timeoutSeconds) ||
|
|
2964
|
+
timeoutSeconds < 10 ||
|
|
2965
|
+
timeoutSeconds > 7200) {
|
|
2966
|
+
throw new CliError("invalid_arguments", "--timeout must be an integer number of seconds from 10 through 7200.");
|
|
2967
|
+
}
|
|
2796
2968
|
const body = { stage, items: input.items };
|
|
2797
2969
|
const preview = {
|
|
2798
2970
|
action: "content-library.submit",
|
|
@@ -2804,51 +2976,57 @@ export async function runCommand(argv) {
|
|
|
2804
2976
|
: "Holds each new resource in REVIEW until an admin approves it.",
|
|
2805
2977
|
duplicateBehavior: "Existing URLs are returned as duplicates and are not overwritten.",
|
|
2806
2978
|
deployOrderFence: "The server verifies the island's review/polish lifecycle capability before its first write.",
|
|
2979
|
+
waitForLive: wait,
|
|
2980
|
+
...(wait ? { timeoutSeconds } : {}),
|
|
2807
2981
|
...(input.file ? { inputFile: input.file } : {}),
|
|
2808
2982
|
},
|
|
2809
2983
|
};
|
|
2810
|
-
return writeCommand(parsed, preview, async () =>
|
|
2811
|
-
|
|
2812
|
-
|
|
2984
|
+
return writeCommand(parsed, preview, async () => {
|
|
2985
|
+
const result = unwrap(await api.client.POST("/admin/content-library/submit", {
|
|
2986
|
+
body,
|
|
2987
|
+
}));
|
|
2988
|
+
if (!wait)
|
|
2989
|
+
return result;
|
|
2990
|
+
return {
|
|
2991
|
+
...result,
|
|
2992
|
+
wait: await waitForContentLibraryLive(api, input.items.map((item) => item.url), timeoutSeconds),
|
|
2993
|
+
};
|
|
2994
|
+
});
|
|
2813
2995
|
}
|
|
2814
2996
|
throw new CliError("invalid_arguments", "Use content-library search, status, set-stage, or submit.");
|
|
2815
2997
|
}
|
|
2816
2998
|
if (noun === "goal-templates") {
|
|
2817
2999
|
if (verb === "list") {
|
|
2818
|
-
const query = flagString(parsed, "query")
|
|
3000
|
+
const query = flagString(parsed, "query");
|
|
2819
3001
|
const kind = flagString(parsed, "kind");
|
|
2820
|
-
const
|
|
3002
|
+
const wantedKind = kind
|
|
3003
|
+
? assertChoice(kind, GOAL_TEMPLATE_KINDS, "--kind")
|
|
3004
|
+
: undefined;
|
|
3005
|
+
const limit = flagNumber(parsed, "limit") ?? 20;
|
|
3006
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
|
|
3007
|
+
throw new CliError("invalid_arguments", "--limit must be an integer between 1 and 100.");
|
|
3008
|
+
}
|
|
3009
|
+
return unwrap(await api.client.GET("/ai/goal-templates/", {
|
|
2821
3010
|
params: {
|
|
2822
3011
|
query: {
|
|
2823
3012
|
...(flagString(parsed, "category")
|
|
2824
3013
|
? { category: flagString(parsed, "category") }
|
|
2825
3014
|
: {}),
|
|
3015
|
+
...(query ? { query } : {}),
|
|
3016
|
+
...(wantedKind ? { kind: wantedKind } : {}),
|
|
2826
3017
|
includeDeleted: hasFlag(parsed, "include-deleted")
|
|
2827
3018
|
? "true"
|
|
2828
3019
|
: "false",
|
|
2829
3020
|
starterOnly: hasFlag(parsed, "starter-only")
|
|
2830
3021
|
? "true"
|
|
2831
3022
|
: "false",
|
|
3023
|
+
limit,
|
|
3024
|
+
...(flagString(parsed, "cursor")
|
|
3025
|
+
? { cursor: flagString(parsed, "cursor") }
|
|
3026
|
+
: {}),
|
|
2832
3027
|
},
|
|
2833
3028
|
},
|
|
2834
3029
|
}));
|
|
2835
|
-
// The route filters by category/starter/deleted only; `--query` and
|
|
2836
|
-
// `--kind` narrow the returned page here rather than pretending the
|
|
2837
|
-
// backend supports them.
|
|
2838
|
-
const wantedKind = kind
|
|
2839
|
-
? assertChoice(kind, GOAL_TEMPLATE_KINDS, "--kind")
|
|
2840
|
-
: undefined;
|
|
2841
|
-
const items = data.items.filter((item) => {
|
|
2842
|
-
if (wantedKind && item.kind !== wantedKind)
|
|
2843
|
-
return false;
|
|
2844
|
-
if (!query)
|
|
2845
|
-
return true;
|
|
2846
|
-
return [item.title, item.slug, item.description, item.category ?? ""]
|
|
2847
|
-
.join("\n")
|
|
2848
|
-
.toLowerCase()
|
|
2849
|
-
.includes(query);
|
|
2850
|
-
});
|
|
2851
|
-
return { items, totalBeforeFilter: data.items.length };
|
|
2852
3030
|
}
|
|
2853
3031
|
if (verb === "get") {
|
|
2854
3032
|
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
@@ -2953,7 +3131,7 @@ export async function runCommand(argv) {
|
|
|
2953
3131
|
// protected-inventory counting, and the token binding the approved loss
|
|
2954
3132
|
// to this exact version + result hash. Always ask it for a fresh preview,
|
|
2955
3133
|
// including on a confirmed run, before permitting the write request.
|
|
2956
|
-
const preflight = unwrap(await api.client.POST("/
|
|
3134
|
+
const preflight = unwrap(await api.client.POST("/ai/goal-templates/{id}/setup-workflow-spec/patch", {
|
|
2957
3135
|
params: { path: { id } },
|
|
2958
3136
|
body: {
|
|
2959
3137
|
expectedVersion,
|
|
@@ -2991,7 +3169,9 @@ export async function runCommand(argv) {
|
|
|
2991
3169
|
: {}),
|
|
2992
3170
|
},
|
|
2993
3171
|
};
|
|
2994
|
-
|
|
3172
|
+
if (!hasFlag(parsed, "confirm")) {
|
|
3173
|
+
await requirePreviewBoundConfirmation(parsed, preview);
|
|
3174
|
+
}
|
|
2995
3175
|
const destructiveChangeToken = flagString(parsed, "destructive-change-token");
|
|
2996
3176
|
if (preflight.preview.destructiveChanges &&
|
|
2997
3177
|
(!hasFlag(parsed, "confirm-destructive-changes") ||
|
|
@@ -3007,7 +3187,7 @@ export async function runCommand(argv) {
|
|
|
3007
3187
|
expectedDestructiveChangeToken: preflight.preview.destructiveChangeToken,
|
|
3008
3188
|
});
|
|
3009
3189
|
}
|
|
3010
|
-
return unwrap(await api.client.POST("/
|
|
3190
|
+
return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.POST("/ai/goal-templates/{id}/setup-workflow-spec/patch", {
|
|
3011
3191
|
params: { path: { id } },
|
|
3012
3192
|
body: {
|
|
3013
3193
|
expectedVersion,
|
|
@@ -3016,7 +3196,7 @@ export async function runCommand(argv) {
|
|
|
3016
3196
|
confirmDestructiveChanges: preflight.preview.destructiveChanges,
|
|
3017
3197
|
...(destructiveChangeToken ? { destructiveChangeToken } : {}),
|
|
3018
3198
|
},
|
|
3019
|
-
}));
|
|
3199
|
+
})));
|
|
3020
3200
|
}
|
|
3021
3201
|
if (verb === "set-metadata") {
|
|
3022
3202
|
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
@@ -3085,7 +3265,7 @@ export async function runCommand(argv) {
|
|
|
3085
3265
|
action: "update goal template metadata (never its setupWorkflowSpec)",
|
|
3086
3266
|
target: { templateId: id, expectedVersion },
|
|
3087
3267
|
request: body,
|
|
3088
|
-
}, async () => unwrap(await api.client.PUT("/
|
|
3268
|
+
}, async () => unwrap(await api.client.PUT("/ai/goal-templates/{id}", {
|
|
3089
3269
|
params: { path: { id } },
|
|
3090
3270
|
body,
|
|
3091
3271
|
})));
|
|
@@ -3096,7 +3276,7 @@ export async function runCommand(argv) {
|
|
|
3096
3276
|
// Read the current version so the gate refuses a stale delete locally,
|
|
3097
3277
|
// and so the preview names the template a human is being asked to approve
|
|
3098
3278
|
// rather than only its UUID.
|
|
3099
|
-
const current = unwrap(await api.client.GET("/
|
|
3279
|
+
const current = unwrap(await api.client.GET("/ai/goal-templates/{id}", {
|
|
3100
3280
|
params: { path: { id } },
|
|
3101
3281
|
}));
|
|
3102
3282
|
if (current.version !== expectedVersion) {
|
|
@@ -3114,7 +3294,7 @@ export async function runCommand(argv) {
|
|
|
3114
3294
|
details: {
|
|
3115
3295
|
note: "Soft delete: the row keeps its deletedAt and drops out of every list. Existing goals already applied from it are unaffected.",
|
|
3116
3296
|
},
|
|
3117
|
-
}, async () => unwrap(await api.client.DELETE("/
|
|
3297
|
+
}, async () => unwrap(await api.client.DELETE("/ai/goal-templates/{id}", {
|
|
3118
3298
|
params: { path: { id } },
|
|
3119
3299
|
})));
|
|
3120
3300
|
}
|
|
@@ -3134,18 +3314,43 @@ export async function runCommand(argv) {
|
|
|
3134
3314
|
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
3135
3315
|
const sourceGoalId = flagString(parsed, "source-goal");
|
|
3136
3316
|
const sourceDraft = flagString(parsed, "source-draft");
|
|
3137
|
-
|
|
3138
|
-
|
|
3317
|
+
const sourceDirectory = flagString(parsed, "source-dir");
|
|
3318
|
+
if ([sourceGoalId, sourceDraft, sourceDirectory].filter(Boolean).length !==
|
|
3319
|
+
1) {
|
|
3320
|
+
throw new CliError("invalid_arguments", "Pass exactly one of --source-goal, --source-draft, or --source-dir.");
|
|
3321
|
+
}
|
|
3322
|
+
const checkout = sourceDirectory
|
|
3323
|
+
? await readRecessGitMetadata(sourceDirectory)
|
|
3324
|
+
: null;
|
|
3325
|
+
const checkoutHead = checkout
|
|
3326
|
+
? await assertCheckoutReadyForMaterialization(checkout)
|
|
3327
|
+
: null;
|
|
3328
|
+
const requestedSourceStudentId = flagString(parsed, "student");
|
|
3329
|
+
if (checkout &&
|
|
3330
|
+
requestedSourceStudentId &&
|
|
3331
|
+
requestedSourceStudentId !== checkout.metadata.studentId) {
|
|
3332
|
+
throw new CliError("invalid_arguments", `--student ${requestedSourceStudentId} does not match checkout owner ${checkout.metadata.studentId}.`);
|
|
3139
3333
|
}
|
|
3140
3334
|
const sourceStudentUserId = sourceDraft
|
|
3141
3335
|
? flagString(parsed, "student", { required: true })
|
|
3142
|
-
:
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3336
|
+
: checkout?.metadata.studentId;
|
|
3337
|
+
let source;
|
|
3338
|
+
if (sourceGoalId) {
|
|
3339
|
+
source = { sourceGoalId };
|
|
3340
|
+
}
|
|
3341
|
+
else if (checkout?.metadata.target.kind === "goal") {
|
|
3342
|
+
source = { sourceGoalId: checkout.metadata.target.goalId };
|
|
3343
|
+
}
|
|
3344
|
+
else {
|
|
3345
|
+
const draftSlug = sourceDraft ??
|
|
3346
|
+
(checkout?.metadata.target.kind === "draft"
|
|
3347
|
+
? checkout.metadata.target.draftSlug
|
|
3348
|
+
: undefined);
|
|
3349
|
+
source = {
|
|
3350
|
+
sourceWorkspacePath: `drafts/${draftSlug}/workspace`,
|
|
3351
|
+
sourceStudentUserId: sourceStudentUserId,
|
|
3148
3352
|
};
|
|
3353
|
+
}
|
|
3149
3354
|
const preflight = unwrap(await api.client.POST("/ai/goal-templates/{id}/snapshot/capture", {
|
|
3150
3355
|
params: { path: { id } },
|
|
3151
3356
|
body: { ...source, dryRun: true },
|
|
@@ -3153,6 +3358,10 @@ export async function runCommand(argv) {
|
|
|
3153
3358
|
if (preflight.action !== "preview_capture_snapshot") {
|
|
3154
3359
|
throw new CliError("unexpected_response", "Snapshot capture did not return a preview; nothing was captured.");
|
|
3155
3360
|
}
|
|
3361
|
+
if (checkout &&
|
|
3362
|
+
preflight.source.changeId !== checkout.metadata.changeId) {
|
|
3363
|
+
throw new CliError("stale_workspace", `Workspace changed from ${checkout.metadata.changeId} to ${preflight.source.changeId}. Check it out again before capturing; nothing was captured.`);
|
|
3364
|
+
}
|
|
3156
3365
|
if (hasFlag(parsed, "dry-run"))
|
|
3157
3366
|
return preflight;
|
|
3158
3367
|
const preview = {
|
|
@@ -3169,6 +3378,13 @@ export async function runCommand(argv) {
|
|
|
3169
3378
|
fileCount: preflight.snapshot.fileCount,
|
|
3170
3379
|
sizeBytes: preflight.snapshot.sizeBytes,
|
|
3171
3380
|
sha256: preflight.snapshot.sha256,
|
|
3381
|
+
...(checkout
|
|
3382
|
+
? {
|
|
3383
|
+
sourceDirectory: checkout.root,
|
|
3384
|
+
localHeadCommit: checkoutHead,
|
|
3385
|
+
mesaChangeId: checkout.metadata.changeId,
|
|
3386
|
+
}
|
|
3387
|
+
: {}),
|
|
3172
3388
|
},
|
|
3173
3389
|
details: {
|
|
3174
3390
|
modules: preflight.snapshot.modules,
|
|
@@ -3177,8 +3393,7 @@ export async function runCommand(argv) {
|
|
|
3177
3393
|
note: "The confirmed request is fenced to both this template version and this exact source revision. Capturing bumps the template version unless the snapshot is unchanged.",
|
|
3178
3394
|
},
|
|
3179
3395
|
};
|
|
3180
|
-
|
|
3181
|
-
return unwrap(await api.client.POST("/ai/goal-templates/{id}/snapshot/capture", {
|
|
3396
|
+
return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.POST("/ai/goal-templates/{id}/snapshot/capture", {
|
|
3182
3397
|
params: { path: { id } },
|
|
3183
3398
|
body: {
|
|
3184
3399
|
...source,
|
|
@@ -3186,7 +3401,7 @@ export async function runCommand(argv) {
|
|
|
3186
3401
|
expectedTemplateVersion: preflight.template.version,
|
|
3187
3402
|
expectedSourceChangeId: preflight.source.changeId,
|
|
3188
3403
|
},
|
|
3189
|
-
}));
|
|
3404
|
+
})));
|
|
3190
3405
|
}
|
|
3191
3406
|
if (verb === "apply") {
|
|
3192
3407
|
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
@@ -3230,15 +3445,14 @@ export async function runCommand(argv) {
|
|
|
3230
3445
|
note: "Counts come from the backend's own dry run. `skipped_existing` items are idempotent — re-applying does not duplicate them.",
|
|
3231
3446
|
},
|
|
3232
3447
|
};
|
|
3233
|
-
|
|
3234
|
-
return unwrap(await api.client.POST("/ai/goal-templates/{id}/apply-workflow", {
|
|
3448
|
+
return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.POST("/ai/goal-templates/{id}/apply-workflow", {
|
|
3235
3449
|
params: { path: { id } },
|
|
3236
3450
|
body: {
|
|
3237
3451
|
answers,
|
|
3238
3452
|
dryRun: false,
|
|
3239
3453
|
expectedTemplateVersion: preflight.templateVersion,
|
|
3240
3454
|
},
|
|
3241
|
-
}));
|
|
3455
|
+
})));
|
|
3242
3456
|
}
|
|
3243
3457
|
if (verb === "apply-starter") {
|
|
3244
3458
|
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
@@ -3272,7 +3486,30 @@ export async function runCommand(argv) {
|
|
|
3272
3486
|
}));
|
|
3273
3487
|
}
|
|
3274
3488
|
if (verb === "create") {
|
|
3275
|
-
const
|
|
3489
|
+
const requestedStudentId = flagString(parsed, "student");
|
|
3490
|
+
const sourceDirectory = flagString(parsed, "source-dir");
|
|
3491
|
+
const explicitDraftSlug = flagString(parsed, "draft");
|
|
3492
|
+
if (sourceDirectory && explicitDraftSlug) {
|
|
3493
|
+
throw new CliError("invalid_arguments", "Pass either --source-dir or --draft, not both.");
|
|
3494
|
+
}
|
|
3495
|
+
const checkout = sourceDirectory
|
|
3496
|
+
? await readRecessGitMetadata(sourceDirectory)
|
|
3497
|
+
: null;
|
|
3498
|
+
if (checkout && checkout.metadata.target.kind !== "draft") {
|
|
3499
|
+
throw new CliError("invalid_checkout", "A fresh module goal must be created from a draft checkout, not an existing goal checkout.");
|
|
3500
|
+
}
|
|
3501
|
+
const checkoutHead = checkout
|
|
3502
|
+
? await assertCheckoutReadyForMaterialization(checkout)
|
|
3503
|
+
: null;
|
|
3504
|
+
if (checkout &&
|
|
3505
|
+
requestedStudentId &&
|
|
3506
|
+
requestedStudentId !== checkout.metadata.studentId) {
|
|
3507
|
+
throw new CliError("invalid_arguments", `--student ${requestedStudentId} does not match checkout owner ${checkout.metadata.studentId}.`);
|
|
3508
|
+
}
|
|
3509
|
+
const userId = requestedStudentId ?? checkout?.metadata.studentId;
|
|
3510
|
+
if (!userId) {
|
|
3511
|
+
throw new CliError("invalid_arguments", "Pass --student for a direct goal create, or use a Recess draft --source-dir.");
|
|
3512
|
+
}
|
|
3276
3513
|
const title = flagString(parsed, "title", { required: true });
|
|
3277
3514
|
const descriptionFile = flagString(parsed, "description-file");
|
|
3278
3515
|
const descriptionFlag = flagString(parsed, "description");
|
|
@@ -3288,14 +3525,17 @@ export async function runCommand(argv) {
|
|
|
3288
3525
|
throw new CliError("invalid_arguments", "--target-date must be an ISO 8601 datetime.");
|
|
3289
3526
|
}
|
|
3290
3527
|
const schedule = flagString(parsed, "schedule");
|
|
3291
|
-
const draftSlug =
|
|
3528
|
+
const draftSlug = explicitDraftSlug ??
|
|
3529
|
+
(checkout?.metadata.target.kind === "draft"
|
|
3530
|
+
? checkout.metadata.target.draftSlug
|
|
3531
|
+
: undefined);
|
|
3292
3532
|
const enablesAppletFollowUps = hasFlag(parsed, "enable-applet-follow-ups");
|
|
3293
3533
|
const disablesAppletFollowUps = hasFlag(parsed, "disable-applet-follow-ups");
|
|
3294
3534
|
if (draftSlug && enablesAppletFollowUps === disablesAppletFollowUps) {
|
|
3295
3535
|
throw new CliError("invalid_arguments", "A module-backed goal requires exactly one of --enable-applet-follow-ups or --disable-applet-follow-ups.");
|
|
3296
3536
|
}
|
|
3297
3537
|
if (!draftSlug && (enablesAppletFollowUps || disablesAppletFollowUps)) {
|
|
3298
|
-
throw new CliError("invalid_arguments", "Applet follow-up flags are used with --draft for
|
|
3538
|
+
throw new CliError("invalid_arguments", "Applet follow-up flags are used with --draft or --source-dir for module-backed goals.");
|
|
3299
3539
|
}
|
|
3300
3540
|
if (draftSlug) {
|
|
3301
3541
|
const body = {
|
|
@@ -3313,6 +3553,10 @@ export async function runCommand(argv) {
|
|
|
3313
3553
|
if (preflight.action !== "preview_module_goal_create") {
|
|
3314
3554
|
throw new CliError("unexpected_response", "Direct module goal creation did not return a preview; no goal was created.");
|
|
3315
3555
|
}
|
|
3556
|
+
if (checkout &&
|
|
3557
|
+
preflight.currentChangeId !== checkout.metadata.changeId) {
|
|
3558
|
+
throw new CliError("stale_workspace", `Draft changed from ${checkout.metadata.changeId} to ${preflight.currentChangeId}. Check it out again before creating the goal; nothing was created.`);
|
|
3559
|
+
}
|
|
3316
3560
|
const preview = {
|
|
3317
3561
|
action: "create a module-backed goal directly from a validated draft workspace",
|
|
3318
3562
|
target: preflight.target,
|
|
@@ -3325,6 +3569,13 @@ export async function runCommand(argv) {
|
|
|
3325
3569
|
targetDate: targetDate ?? null,
|
|
3326
3570
|
schedule: schedule ?? null,
|
|
3327
3571
|
enableAppletFollowUps: enablesAppletFollowUps,
|
|
3572
|
+
...(checkout
|
|
3573
|
+
? {
|
|
3574
|
+
sourceDirectory: checkout.root,
|
|
3575
|
+
localHeadCommit: checkoutHead,
|
|
3576
|
+
mesaChangeId: checkout.metadata.changeId,
|
|
3577
|
+
}
|
|
3578
|
+
: {}),
|
|
3328
3579
|
},
|
|
3329
3580
|
details: {
|
|
3330
3581
|
currentChangeId: preflight.currentChangeId,
|
|
@@ -3338,14 +3589,35 @@ export async function runCommand(argv) {
|
|
|
3338
3589
|
note: "Creates a personal module-backed goal directly. It does not create, capture, or apply a reusable template. After success, Recess removes the draft only when no newer Mesa edit has landed; otherwise it preserves the draft.",
|
|
3339
3590
|
},
|
|
3340
3591
|
};
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3592
|
+
return previewBoundWrite(parsed, preview, async () => {
|
|
3593
|
+
const result = unwrap(await api.client.POST("/tutor/browser/mesa/goals", {
|
|
3594
|
+
body: {
|
|
3595
|
+
...body,
|
|
3596
|
+
dryRun: false,
|
|
3597
|
+
expectedWorkspaceChangeId: preflight.currentChangeId,
|
|
3598
|
+
},
|
|
3599
|
+
}));
|
|
3600
|
+
if (result.action !== "create_module_goal") {
|
|
3601
|
+
throw new CliError("unexpected_response", "Module goal creation returned a preview instead of creating the goal.");
|
|
3602
|
+
}
|
|
3603
|
+
if (checkout && result.mesaChangeId) {
|
|
3604
|
+
await writeRecessGitMetadata(checkout.root, {
|
|
3605
|
+
...checkout.metadata,
|
|
3606
|
+
target: { kind: "goal", goalId: result.goalId },
|
|
3607
|
+
changeId: result.mesaChangeId,
|
|
3608
|
+
upstreamCommit: checkoutHead,
|
|
3609
|
+
});
|
|
3610
|
+
return {
|
|
3611
|
+
...result,
|
|
3612
|
+
local: {
|
|
3613
|
+
directory: checkout.root,
|
|
3614
|
+
target: { kind: "goal", goalId: result.goalId },
|
|
3615
|
+
upstreamCommit: checkoutHead,
|
|
3616
|
+
},
|
|
3617
|
+
};
|
|
3618
|
+
}
|
|
3619
|
+
return result;
|
|
3620
|
+
});
|
|
3349
3621
|
}
|
|
3350
3622
|
const body = {
|
|
3351
3623
|
title,
|
|
@@ -3363,7 +3635,7 @@ export async function runCommand(argv) {
|
|
|
3363
3635
|
schedule: schedule ?? null,
|
|
3364
3636
|
},
|
|
3365
3637
|
details: {
|
|
3366
|
-
note: "Creates a description-only goal with no modules or course workspace. Pass
|
|
3638
|
+
note: "Creates a description-only goal with no modules or course workspace. Pass a draft checkout with --source-dir to create a personal module-backed goal. A 409 GOAL_LIMIT_REACHED means the kid is at capacity and nothing was created.",
|
|
3367
3639
|
},
|
|
3368
3640
|
}, async () => unwrap(await api.client.POST("/tutor/browser/students/{userId}/goals/", {
|
|
3369
3641
|
params: { path: { userId } },
|
|
@@ -3392,11 +3664,39 @@ export async function runCommand(argv) {
|
|
|
3392
3664
|
patch,
|
|
3393
3665
|
},
|
|
3394
3666
|
};
|
|
3395
|
-
|
|
3396
|
-
return unwrap(await api.client.PATCH("/tutor/browser/students/goals/{goalId}/", {
|
|
3667
|
+
return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.PATCH("/tutor/browser/students/goals/{goalId}/", {
|
|
3397
3668
|
params: { path: { goalId } },
|
|
3398
3669
|
body: patch,
|
|
3399
|
-
}));
|
|
3670
|
+
})));
|
|
3671
|
+
}
|
|
3672
|
+
if (verb === "delete") {
|
|
3673
|
+
const goalId = positional(parsed, 2, "goal ID");
|
|
3674
|
+
const studentId = flagString(parsed, "student", { required: true });
|
|
3675
|
+
const current = unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
|
|
3676
|
+
params: { path: { userId: studentId } },
|
|
3677
|
+
})).goals.find((goal) => goal.id === goalId);
|
|
3678
|
+
if (!current) {
|
|
3679
|
+
throw new CliError("not_found", `Goal ${goalId} was not found for student ${studentId}.`);
|
|
3680
|
+
}
|
|
3681
|
+
const preview = {
|
|
3682
|
+
action: "soft-delete a goal for a managed student",
|
|
3683
|
+
target: {
|
|
3684
|
+
goalId,
|
|
3685
|
+
studentUserId: studentId,
|
|
3686
|
+
title: current.title,
|
|
3687
|
+
status: current.status,
|
|
3688
|
+
},
|
|
3689
|
+
request: { expectedUpdatedAt: current.updatedAt },
|
|
3690
|
+
details: {
|
|
3691
|
+
note: "Soft-deletes the goal and its current/future dated todos. Historical todos and the deletion audit remain available.",
|
|
3692
|
+
},
|
|
3693
|
+
};
|
|
3694
|
+
return previewBoundWrite(parsed, preview, async () => {
|
|
3695
|
+
unwrap(await api.client.DELETE("/admin/browser/students/goals/{goalId}/", {
|
|
3696
|
+
params: { path: { goalId } },
|
|
3697
|
+
}));
|
|
3698
|
+
return { deleted: true, goalId, studentUserId: studentId };
|
|
3699
|
+
});
|
|
3400
3700
|
}
|
|
3401
3701
|
if (verb === "queue") {
|
|
3402
3702
|
const subverb = positional(parsed, 2, "queue action (get|set)");
|
|
@@ -3411,7 +3711,7 @@ export async function runCommand(argv) {
|
|
|
3411
3711
|
});
|
|
3412
3712
|
const delta = flagString(parsed, "delta", { required: true });
|
|
3413
3713
|
const replaceDescriptionPointer = hasFlag(parsed, "replace-description-pointer");
|
|
3414
|
-
const { absolutePath, parsed: rawEntries } = await readJsonValue(entriesFile, "Queue entries file");
|
|
3714
|
+
const { absolutePath, raw: rawEntriesText, parsed: rawEntries, } = await readJsonValue(entriesFile, "Queue entries file");
|
|
3415
3715
|
const entries = parseGoalQueueEntries(rawEntries, "Queue entries file");
|
|
3416
3716
|
const goal = unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
|
|
3417
3717
|
params: { path: { userId: studentId } },
|
|
@@ -3422,15 +3722,36 @@ export async function runCommand(argv) {
|
|
|
3422
3722
|
const current = unwrap(await api.client.GET("/tutor/browser/students/goals/{goalId}/queue/", { params: { path: { goalId } } }));
|
|
3423
3723
|
const currentUrls = new Set(current.queue.map((item) => item.url));
|
|
3424
3724
|
const proposedUrls = new Set(entries.map((entry) => entry.url));
|
|
3725
|
+
const currentByUrl = new Map(current.queue.map((item) => [item.url, item]));
|
|
3726
|
+
const completionChanges = entries.flatMap((entry) => {
|
|
3727
|
+
if (entry.completed === undefined)
|
|
3728
|
+
return [];
|
|
3729
|
+
const existing = currentByUrl.get(entry.url);
|
|
3730
|
+
const currentlyCompleted = existing?.completedAt != null;
|
|
3731
|
+
if (currentlyCompleted === entry.completed)
|
|
3732
|
+
return [];
|
|
3733
|
+
return [
|
|
3734
|
+
{
|
|
3735
|
+
moduleRef: existing?.moduleRef ?? null,
|
|
3736
|
+
url: entry.url,
|
|
3737
|
+
fromCompletedAt: existing?.completedAt ?? null,
|
|
3738
|
+
toCompleted: entry.completed,
|
|
3739
|
+
},
|
|
3740
|
+
];
|
|
3741
|
+
});
|
|
3425
3742
|
const preview = {
|
|
3426
3743
|
action: "replace a goal's URL skill queue for a managed student",
|
|
3427
3744
|
target: { goalId, studentUserId: studentId },
|
|
3428
3745
|
request: {
|
|
3429
3746
|
entriesFile: absolutePath,
|
|
3747
|
+
entriesSha256: createHash("sha256")
|
|
3748
|
+
.update(rawEntriesText)
|
|
3749
|
+
.digest("hex"),
|
|
3430
3750
|
entryCount: entries.length,
|
|
3431
3751
|
delta,
|
|
3432
3752
|
replaceDescriptionPointer,
|
|
3433
3753
|
expectedUpdatedAt: goal.updatedAt,
|
|
3754
|
+
unchangedTitleForConcurrencyFence: goal.title,
|
|
3434
3755
|
},
|
|
3435
3756
|
details: {
|
|
3436
3757
|
currentQueue: current.queue.map((item) => ({
|
|
@@ -3444,26 +3765,31 @@ export async function runCommand(argv) {
|
|
|
3444
3765
|
removed: current.queue
|
|
3445
3766
|
.filter((item) => !proposedUrls.has(item.url))
|
|
3446
3767
|
.map((item) => item.url),
|
|
3768
|
+
completionChanges,
|
|
3447
3769
|
note: "Replaces the entire EXTERNAL_URL queue. Completion is preserved for unchanged URLs; WORKSPACE modules are untouched. Daily generation mints the head entry for a flag-on kid.",
|
|
3448
3770
|
},
|
|
3449
3771
|
};
|
|
3450
|
-
requirePreviewBoundConfirmation(parsed, preview);
|
|
3451
3772
|
const body = {
|
|
3452
3773
|
delta,
|
|
3453
3774
|
expectedUpdatedAt: goal.updatedAt,
|
|
3775
|
+
// The deployed route's queue-only CAS reaches Prisma with an empty
|
|
3776
|
+
// scalar update, which reports zero rows and falsely trips the stale
|
|
3777
|
+
// write fence. Echoing the already-previewed title gives that CAS a
|
|
3778
|
+
// real update field without changing the title.
|
|
3779
|
+
title: goal.title,
|
|
3454
3780
|
queue: entries,
|
|
3455
3781
|
...(replaceDescriptionPointer
|
|
3456
3782
|
? { description: URL_QUEUE_DESCRIPTION_POINTER }
|
|
3457
3783
|
: {}),
|
|
3458
3784
|
};
|
|
3459
|
-
return unwrap(await api.client.PATCH("/tutor/browser/students/goals/{goalId}/", {
|
|
3785
|
+
return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.PATCH("/tutor/browser/students/goals/{goalId}/", {
|
|
3460
3786
|
params: { path: { goalId } },
|
|
3461
3787
|
body,
|
|
3462
|
-
}));
|
|
3788
|
+
})));
|
|
3463
3789
|
}
|
|
3464
3790
|
throw new CliError("invalid_arguments", "Use goals queue get|set.");
|
|
3465
3791
|
}
|
|
3466
|
-
throw new CliError("invalid_arguments", "Use goals list|create|edit|queue|files|pdf.");
|
|
3792
|
+
throw new CliError("invalid_arguments", "Use goals list|create|edit|delete|queue|files|pdf.");
|
|
3467
3793
|
}
|
|
3468
3794
|
if (noun === "students") {
|
|
3469
3795
|
if (verb === "list") {
|
|
@@ -3559,11 +3885,37 @@ export async function runCommand(argv) {
|
|
|
3559
3885
|
patch: body,
|
|
3560
3886
|
},
|
|
3561
3887
|
};
|
|
3562
|
-
|
|
3563
|
-
return unwrap(await api.client.PATCH("/tutor/browser/todos/{id}", {
|
|
3888
|
+
return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.PATCH("/tutor/browser/todos/{id}", {
|
|
3564
3889
|
params: { path: { id: todoId } },
|
|
3565
3890
|
body,
|
|
3891
|
+
})));
|
|
3892
|
+
}
|
|
3893
|
+
if (verb === "delete") {
|
|
3894
|
+
const todoId = positional(parsed, 2, "todo ID");
|
|
3895
|
+
const current = unwrap(await api.client.GET("/tutor/browser/todos/{id}/", {
|
|
3896
|
+
params: { path: { id: todoId } },
|
|
3566
3897
|
}));
|
|
3898
|
+
const preview = {
|
|
3899
|
+
action: "permanently delete a todo for a managed student",
|
|
3900
|
+
target: {
|
|
3901
|
+
todoId,
|
|
3902
|
+
studentUserId: current.userId,
|
|
3903
|
+
title: current.title,
|
|
3904
|
+
status: current.status,
|
|
3905
|
+
goalId: current.goal?.id ?? null,
|
|
3906
|
+
},
|
|
3907
|
+
request: { expectedUpdatedAt: current.updatedAt },
|
|
3908
|
+
details: {
|
|
3909
|
+
dueDate: current.dueDate,
|
|
3910
|
+
creationSource: current.creationSource,
|
|
3911
|
+
creationSourceId: current.creationSourceId,
|
|
3912
|
+
url: "url" in current ? current.url : null,
|
|
3913
|
+
note: "Hard-deletes this todo without awarding XP or running todo-completion side effects. This is permanent; use it only when the preview identifies a disposable todo with no work to preserve.",
|
|
3914
|
+
},
|
|
3915
|
+
};
|
|
3916
|
+
return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.DELETE("/admin/todos/{id}/", {
|
|
3917
|
+
params: { path: { id: todoId } },
|
|
3918
|
+
})));
|
|
3567
3919
|
}
|
|
3568
3920
|
if (verb === "generate-applet") {
|
|
3569
3921
|
const todoId = positional(parsed, 2, "todo ID");
|
|
@@ -3601,13 +3953,12 @@ export async function runCommand(argv) {
|
|
|
3601
3953
|
versionRouting: "The server evaluates applet-gen-v2 for this student and uses v1 when the flag is off or its evaluation fails.",
|
|
3602
3954
|
},
|
|
3603
3955
|
};
|
|
3604
|
-
|
|
3605
|
-
return unwrap(await api.client.POST("/admin/learning-pipeline/{analysisId}/generate-applet/", {
|
|
3956
|
+
return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.POST("/admin/learning-pipeline/{analysisId}/generate-applet/", {
|
|
3606
3957
|
params: { path: { analysisId: analysis.id } },
|
|
3607
3958
|
body: targetDueDateISO ? { targetDueDateISO } : {},
|
|
3608
|
-
}));
|
|
3959
|
+
})));
|
|
3609
3960
|
}
|
|
3610
|
-
throw new CliError("invalid_arguments", "Use todos create|edit|generate-applet.");
|
|
3961
|
+
throw new CliError("invalid_arguments", "Use todos create|edit|delete|generate-applet.");
|
|
3611
3962
|
}
|
|
3612
3963
|
if (noun === "memories") {
|
|
3613
3964
|
const studentId = flagString(parsed, "student", { required: true });
|
|
@@ -3715,7 +4066,7 @@ export async function runCommand(argv) {
|
|
|
3715
4066
|
if (preflight.action !== "preview_workspace_files_write") {
|
|
3716
4067
|
throw new CliError("unexpected_response", "Goal PDF upload did not return a preview; nothing was uploaded.");
|
|
3717
4068
|
}
|
|
3718
|
-
|
|
4069
|
+
const preview = {
|
|
3719
4070
|
action: "upload a textbook PDF and attach it to a student's goal",
|
|
3720
4071
|
target: preflight.target,
|
|
3721
4072
|
request: {
|
|
@@ -3729,111 +4080,114 @@ export async function runCommand(argv) {
|
|
|
3729
4080
|
currentChangeId: preflight.currentChangeId,
|
|
3730
4081
|
note: "The PDF is stored separately and the goal workspace receives a small verified reference, matching planner-authored textbook goals.",
|
|
3731
4082
|
},
|
|
3732
|
-
}
|
|
3733
|
-
|
|
3734
|
-
|
|
3735
|
-
|
|
3736
|
-
conversationId,
|
|
3737
|
-
studentUserId: studentId,
|
|
3738
|
-
fileName,
|
|
3739
|
-
contentType: "application/pdf",
|
|
3740
|
-
sizeBytes: fileStat.size,
|
|
3741
|
-
},
|
|
3742
|
-
}));
|
|
3743
|
-
let uploaded;
|
|
3744
|
-
try {
|
|
3745
|
-
const uploadedSource = await uploadPdfToSignedUrls(absolutePath, "application/pdf", signed);
|
|
3746
|
-
if (uploadedSource.sha256 !== sourceSha256 ||
|
|
3747
|
-
uploadedSource.sizeBytes !== fileStat.size) {
|
|
3748
|
-
throw new CliError("source_changed", "The source PDF changed after approval. The upload was discarded; preview the current file again.");
|
|
3749
|
-
}
|
|
3750
|
-
uploaded =
|
|
3751
|
-
signed.kind === "multipart"
|
|
3752
|
-
? unwrap(await api.client.POST("/os-v2-mesa/planner/uploads/complete", {
|
|
3753
|
-
body: {
|
|
3754
|
-
conversationId,
|
|
3755
|
-
fileName,
|
|
3756
|
-
contentType: "application/pdf",
|
|
3757
|
-
key: signed.key,
|
|
3758
|
-
uploadId: signed.uploadId,
|
|
3759
|
-
sizeBytes: fileStat.size,
|
|
3760
|
-
},
|
|
3761
|
-
}))
|
|
3762
|
-
: unwrap(await api.client.POST("/os-v2-mesa/planner/uploads/verify", {
|
|
3763
|
-
body: {
|
|
3764
|
-
conversationId,
|
|
3765
|
-
fileName,
|
|
3766
|
-
contentType: "application/pdf",
|
|
3767
|
-
key: signed.key,
|
|
3768
|
-
sizeBytes: fileStat.size,
|
|
3769
|
-
},
|
|
3770
|
-
}));
|
|
3771
|
-
}
|
|
3772
|
-
catch (error) {
|
|
3773
|
-
// A failed multipart complete may already have materialized the object,
|
|
3774
|
-
// so attempt both abort and delete. These are compensation attempts; the
|
|
3775
|
-
// original upload error remains authoritative.
|
|
3776
|
-
await cleanupPlannerUpload(api, conversationId, signed, true);
|
|
3777
|
-
throw error;
|
|
3778
|
-
}
|
|
3779
|
-
const manifest = JSON.stringify({
|
|
3780
|
-
kind: "r2-pdf",
|
|
3781
|
-
version: 2,
|
|
3782
|
-
r2Key: uploaded.key,
|
|
3783
|
-
signature: uploaded.manifestSignature,
|
|
3784
|
-
fileName,
|
|
3785
|
-
contentType: uploaded.contentType,
|
|
3786
|
-
sizeBytes: uploaded.sizeBytes,
|
|
3787
|
-
});
|
|
3788
|
-
let workspace;
|
|
3789
|
-
try {
|
|
3790
|
-
workspace = unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
|
|
4083
|
+
};
|
|
4084
|
+
return previewBoundWrite(parsed, preview, async () => {
|
|
4085
|
+
const conversationId = randomUUID();
|
|
4086
|
+
const signed = unwrap(await api.client.POST("/os-v2-mesa/planner/uploads/sign", {
|
|
3791
4087
|
body: {
|
|
3792
|
-
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
contentEncoding: "utf8",
|
|
3798
|
-
},
|
|
3799
|
-
],
|
|
3800
|
-
dryRun: false,
|
|
3801
|
-
expectedChangeId: preflight.currentChangeId,
|
|
4088
|
+
conversationId,
|
|
4089
|
+
studentUserId: studentId,
|
|
4090
|
+
fileName,
|
|
4091
|
+
contentType: "application/pdf",
|
|
4092
|
+
sizeBytes: fileStat.size,
|
|
3802
4093
|
},
|
|
3803
4094
|
}));
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
4095
|
+
let uploaded;
|
|
4096
|
+
try {
|
|
4097
|
+
const uploadedSource = await uploadPdfToSignedUrls(absolutePath, "application/pdf", signed);
|
|
4098
|
+
if (uploadedSource.sha256 !== sourceSha256 ||
|
|
4099
|
+
uploadedSource.sizeBytes !== fileStat.size) {
|
|
4100
|
+
throw new CliError("source_changed", "The source PDF changed after approval. The upload was discarded; preview the current file again.");
|
|
4101
|
+
}
|
|
4102
|
+
uploaded =
|
|
4103
|
+
signed.kind === "multipart"
|
|
4104
|
+
? unwrap(await api.client.POST("/os-v2-mesa/planner/uploads/complete", {
|
|
4105
|
+
body: {
|
|
4106
|
+
conversationId,
|
|
4107
|
+
fileName,
|
|
4108
|
+
contentType: "application/pdf",
|
|
4109
|
+
key: signed.key,
|
|
4110
|
+
uploadId: signed.uploadId,
|
|
4111
|
+
sizeBytes: fileStat.size,
|
|
4112
|
+
},
|
|
4113
|
+
}))
|
|
4114
|
+
: unwrap(await api.client.POST("/os-v2-mesa/planner/uploads/verify", {
|
|
4115
|
+
body: {
|
|
4116
|
+
conversationId,
|
|
4117
|
+
fileName,
|
|
4118
|
+
contentType: "application/pdf",
|
|
4119
|
+
key: signed.key,
|
|
4120
|
+
sizeBytes: fileStat.size,
|
|
4121
|
+
},
|
|
4122
|
+
}));
|
|
4123
|
+
}
|
|
4124
|
+
catch (error) {
|
|
4125
|
+
// A failed multipart complete may already have materialized the object,
|
|
4126
|
+
// so attempt both abort and delete. These are compensation attempts; the
|
|
4127
|
+
// original upload error remains authoritative.
|
|
3816
4128
|
await cleanupPlannerUpload(api, conversationId, signed, true);
|
|
4129
|
+
throw error;
|
|
3817
4130
|
}
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
3827
|
-
|
|
4131
|
+
const manifest = JSON.stringify({
|
|
4132
|
+
kind: "r2-pdf",
|
|
4133
|
+
version: 2,
|
|
4134
|
+
r2Key: uploaded.key,
|
|
4135
|
+
signature: uploaded.manifestSignature,
|
|
4136
|
+
fileName,
|
|
4137
|
+
contentType: uploaded.contentType,
|
|
4138
|
+
sizeBytes: uploaded.sizeBytes,
|
|
4139
|
+
});
|
|
4140
|
+
let workspace;
|
|
4141
|
+
try {
|
|
4142
|
+
workspace = unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
|
|
4143
|
+
body: {
|
|
4144
|
+
...body,
|
|
4145
|
+
files: [
|
|
4146
|
+
{
|
|
4147
|
+
path: workspacePath,
|
|
4148
|
+
content: manifest,
|
|
4149
|
+
contentEncoding: "utf8",
|
|
4150
|
+
},
|
|
4151
|
+
],
|
|
4152
|
+
dryRun: false,
|
|
4153
|
+
expectedChangeId: preflight.currentChangeId,
|
|
4154
|
+
},
|
|
4155
|
+
}));
|
|
4156
|
+
}
|
|
4157
|
+
catch (error) {
|
|
4158
|
+
// Only a returned 4xx from this route is a definitive no-write response.
|
|
4159
|
+
// A transport/5xx failure is ambiguous: the workspace commit may have landed,
|
|
4160
|
+
// so keep the object rather than creating a dangling manifest.
|
|
4161
|
+
const status = error instanceof CliError &&
|
|
4162
|
+
error.details &&
|
|
4163
|
+
typeof error.details === "object" &&
|
|
4164
|
+
"status" in error.details
|
|
4165
|
+
? Number(error.details.status)
|
|
4166
|
+
: 0;
|
|
4167
|
+
if (status >= 400 && status < 500) {
|
|
4168
|
+
await cleanupPlannerUpload(api, conversationId, signed, true);
|
|
4169
|
+
}
|
|
4170
|
+
throw error;
|
|
4171
|
+
}
|
|
4172
|
+
return {
|
|
4173
|
+
action: "upload_textbook_pdf",
|
|
4174
|
+
sourceSha256,
|
|
4175
|
+
fileName,
|
|
4176
|
+
sizeBytes: uploaded.sizeBytes,
|
|
4177
|
+
workspacePath,
|
|
4178
|
+
workspace,
|
|
4179
|
+
};
|
|
4180
|
+
});
|
|
3828
4181
|
}
|
|
3829
4182
|
if (noun === "goals" && verb === "files") {
|
|
3830
4183
|
const action = positional(parsed, 2, "goals files action");
|
|
3831
|
-
const studentId = flagString(parsed, "student", { required: true });
|
|
3832
4184
|
if (action === "list") {
|
|
4185
|
+
const studentId = flagString(parsed, "student", { required: true });
|
|
3833
4186
|
const goalId = flagString(parsed, "goal", { required: true });
|
|
3834
4187
|
return unwrap(await api.client.GET("/tutor/students/{studentId}/goals/{goalId}/workspace/files", { params: { path: { studentId, goalId } } }));
|
|
3835
4188
|
}
|
|
3836
4189
|
if (action === "read") {
|
|
4190
|
+
const studentId = flagString(parsed, "student", { required: true });
|
|
3837
4191
|
const goalId = flagString(parsed, "goal", { required: true });
|
|
3838
4192
|
return unwrap(await api.client.GET("/tutor/students/{studentId}/goals/{goalId}/workspace/file", {
|
|
3839
4193
|
params: {
|
|
@@ -3842,7 +4196,115 @@ export async function runCommand(argv) {
|
|
|
3842
4196
|
},
|
|
3843
4197
|
}));
|
|
3844
4198
|
}
|
|
4199
|
+
if (action === "init" || action === "checkout") {
|
|
4200
|
+
const studentId = flagString(parsed, "student", { required: true });
|
|
4201
|
+
const goalId = flagString(parsed, "goal");
|
|
4202
|
+
const draftSlug = flagString(parsed, "draft");
|
|
4203
|
+
if (action === "init" && (goalId || !draftSlug)) {
|
|
4204
|
+
throw new CliError("invalid_arguments", "goals files init requires --draft and does not accept --goal.");
|
|
4205
|
+
}
|
|
4206
|
+
if (Boolean(goalId) === Boolean(draftSlug)) {
|
|
4207
|
+
throw new CliError("invalid_arguments", "Pass exactly one of --goal or --draft for goals files checkout.");
|
|
4208
|
+
}
|
|
4209
|
+
const outputDirectory = flagString(parsed, "output-dir", {
|
|
4210
|
+
required: true,
|
|
4211
|
+
});
|
|
4212
|
+
const target = goalId
|
|
4213
|
+
? { kind: "goal", goalId }
|
|
4214
|
+
: { kind: "draft", draftSlug: draftSlug };
|
|
4215
|
+
const response = await readRemoteWorkspaceSnapshot(api, studentId, target);
|
|
4216
|
+
if (action === "init" && response.snapshot.fileCount > 0) {
|
|
4217
|
+
throw new CliError("draft_exists", `Draft ${draftSlug} already has files. Use goals files checkout --draft to preserve them.`);
|
|
4218
|
+
}
|
|
4219
|
+
return checkoutGoalWorkspace({
|
|
4220
|
+
outputDirectory,
|
|
4221
|
+
studentId,
|
|
4222
|
+
target,
|
|
4223
|
+
snapshot: response.snapshot,
|
|
4224
|
+
});
|
|
4225
|
+
}
|
|
4226
|
+
if (action === "push") {
|
|
4227
|
+
const sourceDirectory = flagString(parsed, "source-dir", {
|
|
4228
|
+
required: true,
|
|
4229
|
+
});
|
|
4230
|
+
const checkout = await readRecessGitMetadata(sourceDirectory);
|
|
4231
|
+
const local = await readGoalWorkspaceGitChanges(checkout.root, checkout.metadata.upstreamCommit);
|
|
4232
|
+
const defaultMessage = local.commits.length === 1
|
|
4233
|
+
? local.commits[0].subject
|
|
4234
|
+
: `recess-cli: push ${local.commits.length} commits (${local.commits.at(-1).subject})`;
|
|
4235
|
+
const message = (flagString(parsed, "message") ?? defaultMessage).trim();
|
|
4236
|
+
if (!message || message.length > 200) {
|
|
4237
|
+
throw new CliError("invalid_arguments", "--message (or the derived local commit message) must be 1 to 200 characters.");
|
|
4238
|
+
}
|
|
4239
|
+
const files = local.changes.map((change) => change.action === "delete"
|
|
4240
|
+
? { path: change.path, action: "delete" }
|
|
4241
|
+
: {
|
|
4242
|
+
path: change.path,
|
|
4243
|
+
action: "upsert",
|
|
4244
|
+
content: change.content,
|
|
4245
|
+
contentEncoding: change.contentEncoding,
|
|
4246
|
+
});
|
|
4247
|
+
const body = {
|
|
4248
|
+
studentUserId: checkout.metadata.studentId,
|
|
4249
|
+
target: checkout.metadata.target,
|
|
4250
|
+
message,
|
|
4251
|
+
files,
|
|
4252
|
+
expectedChangeId: checkout.metadata.changeId,
|
|
4253
|
+
};
|
|
4254
|
+
const preflight = unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
|
|
4255
|
+
body: { ...body, dryRun: true },
|
|
4256
|
+
}));
|
|
4257
|
+
if (preflight.action !== "preview_workspace_files_write") {
|
|
4258
|
+
throw new CliError("unexpected_response", "Goal workspace push did not return a preview; nothing was written.");
|
|
4259
|
+
}
|
|
4260
|
+
const preview = {
|
|
4261
|
+
action: `push committed Git changes to a student's Mesa ${checkout.metadata.target.kind} workspace`,
|
|
4262
|
+
target: preflight.target,
|
|
4263
|
+
request: {
|
|
4264
|
+
sourceDirectory: checkout.root,
|
|
4265
|
+
mesaBaseChangeId: checkout.metadata.changeId,
|
|
4266
|
+
localBaseCommit: checkout.metadata.upstreamCommit,
|
|
4267
|
+
localHeadCommit: local.head,
|
|
4268
|
+
commits: local.commits,
|
|
4269
|
+
message,
|
|
4270
|
+
changes: local.changes.map((change) => change.action === "delete"
|
|
4271
|
+
? change
|
|
4272
|
+
: {
|
|
4273
|
+
path: change.path,
|
|
4274
|
+
action: change.action,
|
|
4275
|
+
sizeBytes: change.sizeBytes,
|
|
4276
|
+
sha256: change.sha256,
|
|
4277
|
+
}),
|
|
4278
|
+
sizeBytes: local.sizeBytes,
|
|
4279
|
+
},
|
|
4280
|
+
details: {
|
|
4281
|
+
currentChangeId: preflight.currentChangeId,
|
|
4282
|
+
note: "The committed local diff is published as one atomic Mesa change. Deletes and renames follow Git's diff. A changed Mesa tip refuses the push.",
|
|
4283
|
+
},
|
|
4284
|
+
};
|
|
4285
|
+
return previewBoundWrite(parsed, preview, async () => {
|
|
4286
|
+
const result = unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
|
|
4287
|
+
body: { ...body, dryRun: false },
|
|
4288
|
+
}));
|
|
4289
|
+
if (result.action !== "write_workspace_files") {
|
|
4290
|
+
throw new CliError("unexpected_response", "Goal workspace push returned an unexpected response.");
|
|
4291
|
+
}
|
|
4292
|
+
await writeRecessGitMetadata(checkout.root, {
|
|
4293
|
+
...checkout.metadata,
|
|
4294
|
+
changeId: result.changeId,
|
|
4295
|
+
upstreamCommit: local.head,
|
|
4296
|
+
});
|
|
4297
|
+
return {
|
|
4298
|
+
...result,
|
|
4299
|
+
local: {
|
|
4300
|
+
directory: checkout.root,
|
|
4301
|
+
upstreamCommit: local.head,
|
|
4302
|
+
},
|
|
4303
|
+
};
|
|
4304
|
+
});
|
|
4305
|
+
}
|
|
3845
4306
|
if (action === "write") {
|
|
4307
|
+
const studentId = flagString(parsed, "student", { required: true });
|
|
3846
4308
|
const goalId = flagString(parsed, "goal");
|
|
3847
4309
|
const draftSlug = flagString(parsed, "draft");
|
|
3848
4310
|
if (Boolean(goalId) === Boolean(draftSlug)) {
|
|
@@ -3881,19 +4343,18 @@ export async function runCommand(argv) {
|
|
|
3881
4343
|
files: preflight.files,
|
|
3882
4344
|
note: target.kind === "draft"
|
|
3883
4345
|
? "This writes a complete authoring tree under drafts/<slug>/workspace. Capture it only after the server's goal-workspace validation passes."
|
|
3884
|
-
: "
|
|
4346
|
+
: "This directly edits the live goal workspace, including modules/ and state/. The write remains bound to the previewed workspace revision.",
|
|
3885
4347
|
},
|
|
3886
4348
|
};
|
|
3887
|
-
|
|
3888
|
-
return unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
|
|
4349
|
+
return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
|
|
3889
4350
|
body: {
|
|
3890
4351
|
...body,
|
|
3891
4352
|
dryRun: false,
|
|
3892
4353
|
expectedChangeId: preflight.currentChangeId,
|
|
3893
4354
|
},
|
|
3894
|
-
}));
|
|
4355
|
+
})));
|
|
3895
4356
|
}
|
|
3896
|
-
throw new CliError("invalid_arguments", "Use goals files list|read|write.");
|
|
4357
|
+
throw new CliError("invalid_arguments", "Use goals files list|read|init|checkout|push|write.");
|
|
3897
4358
|
}
|
|
3898
4359
|
if (noun === "request" && verb === "get") {
|
|
3899
4360
|
return api.rawGet(positional(parsed, 2, "request path"));
|