@elytracms/cli 0.0.88 → 0.0.91
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 +56 -37
- package/package.json +2 -2
- package/src/cli.js +81 -36
- package/src/config-harness.js +173 -3
- package/src/doctor.js +172 -22
- package/src/layout.js +288 -0
- package/src/previews.js +39 -22
- package/src/project.js +105 -48
- package/src/routes-check.js +18 -4
package/README.md
CHANGED
|
@@ -62,15 +62,33 @@ project inspection, no process spawned, no network call, no write. `elytra dev
|
|
|
62
62
|
--help` never starts a server; `elytra doctor --help` never runs the network
|
|
63
63
|
diagnostic; `elytra build --help` never validates a target.
|
|
64
64
|
|
|
65
|
+
## Project layout
|
|
66
|
+
|
|
67
|
+
The studio app, the frontend app and the theme are found at `studio/`,
|
|
68
|
+
`frontend/` and `components/theme.css` — the scaffold layout — unless the
|
|
69
|
+
repo-root `package.json` (beside `elytra.config.ts`) declares otherwise:
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{ "elytra": { "studio": "./apps/editor", "frontend": "./apps/site", "theme": "./styles/site.css" } }
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Every key is optional. An invalid section or a declared path that does not
|
|
76
|
+
exist is reported (`elytra doctor` fails `project-layout`; the other commands
|
|
77
|
+
refuse to start) — never silently replaced by the default. Full rules:
|
|
78
|
+
`docs/guide/06-cli-reference.md`.
|
|
79
|
+
|
|
65
80
|
## Mode-aware
|
|
66
81
|
|
|
67
|
-
The CLI reads the studio's
|
|
68
|
-
|
|
82
|
+
The CLI reads the studio app's env files — the ones Vite loads for it
|
|
83
|
+
(`.env`, `.env.local`, `.env.<mode>`, `.env.<mode>.local`) — through the SAME
|
|
84
|
+
backend-mode parser the studio runs, so the two never disagree:
|
|
69
85
|
|
|
70
86
|
- **Playground** (`VITE_ELYTRA_MODE=playground`) — runs the studio + frontend
|
|
71
87
|
only; no Convex.
|
|
72
88
|
- **Connected** (`VITE_CONVEX_URL` set) — also runs `convex dev` (or deploys on
|
|
73
89
|
build) once a deployment is provisioned (`CONVEX_DEPLOYMENT` present).
|
|
90
|
+
- Any other `VITE_ELYTRA_MODE` value is the studio's boot error, and the CLI
|
|
91
|
+
names it the same way.
|
|
74
92
|
|
|
75
93
|
It orchestrates and owns the process lifecycle; it does not reimplement Vite,
|
|
76
94
|
Next, or Convex — each step shells out to the underlying tool and inherits its
|
|
@@ -119,20 +137,20 @@ set on the wrong plane:
|
|
|
119
137
|
the AI provider key (`ANTHROPIC_API_KEY`, or the Vercel AI Gateway) for
|
|
120
138
|
the studio's AI route — a host/server env, never `VITE_`-prefixed.
|
|
121
139
|
- _Frontend project:_ `ELYTRA_CONVEX_URL`, `ELYTRA_PROJECT_ID`,
|
|
122
|
-
`REVALIDATE_SECRET`, `
|
|
123
|
-
|
|
140
|
+
`REVALIDATE_SECRET`, `DRAFT_PREVIEW_TOKEN` — see the scaffold's
|
|
141
|
+
CONNECT.md for what each pairs with.
|
|
124
142
|
3. **Convex deployment env** — set with `npx convex env set` (from `studio/`);
|
|
125
143
|
lives on the deployment itself, visible in the Convex dashboard but in no
|
|
126
144
|
file: the auth signing keys (`JWT_PRIVATE_KEY`, `JWKS` — set via
|
|
127
|
-
`npx @convex-dev/auth`), `SITE_URL`, the instant-publish
|
|
128
|
-
`
|
|
129
|
-
`
|
|
130
|
-
|
|
131
|
-
|
|
145
|
+
`npx @convex-dev/auth`), `SITE_URL`, the instant-publish pair
|
|
146
|
+
`CANVAS_REVALIDATE_ENDPOINT` / `CANVAS_REVALIDATE_SECRET`, and
|
|
147
|
+
`DRAFT_PREVIEW_TOKEN` (the secret and the token must match their
|
|
148
|
+
frontend-plane counterparts). `elytra doctor` diagnoses this plane's
|
|
149
|
+
readiness.
|
|
132
150
|
|
|
133
151
|
Because plane 3 lives in no file, it is the easiest to overlook — `elytra
|
|
134
152
|
doctor` therefore enumerates the expected vars there (auth keys, `SITE_URL`,
|
|
135
|
-
the instant-publish
|
|
153
|
+
the instant-publish pair, the draft-preview token, plus the AI provider key
|
|
136
154
|
on the studio-host plane when the AI route is present) and reports each one
|
|
137
155
|
**set or missing, by name only**: the doctor parses names out of `npx convex
|
|
138
156
|
env list` and discards every value on the spot — values are never stored,
|
|
@@ -142,31 +160,28 @@ the draft-preview token) is a named warning that doesn't change the exit
|
|
|
142
160
|
code. The studio's boot-time "Deployment setup" screen lists the same
|
|
143
161
|
expected vars, so the CLI and the studio tell one story.
|
|
144
162
|
|
|
145
|
-
The **instant-publish
|
|
146
|
-
`
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
exactly that consequence (a project without a delivery frontend may
|
|
154
|
-
legitimately leave them unset); **any partial subset — one or two of the
|
|
155
|
-
three set — fails** the run, because a half-configured trio has no
|
|
163
|
+
The **instant-publish pair** — `CANVAS_REVALIDATE_ENDPOINT` and
|
|
164
|
+
`CANVAS_REVALIDATE_SECRET` — gets special treatment, because the publish path
|
|
165
|
+
**silently no-ops** while either is unset: the write-chokepoint trigger
|
|
166
|
+
schedules the revalidate webhook, and the action delivers it, only while both
|
|
167
|
+
are set. Publishes then never update the live site until a redeploy, with no
|
|
168
|
+
error anywhere. Both missing is a warning carrying exactly that consequence (a
|
|
169
|
+
project without a delivery frontend may legitimately leave them unset); **one
|
|
170
|
+
of the two set fails** the run, because a half-configured pair has no
|
|
156
171
|
legitimate reading (documented together in the scaffold's CONNECT.md, set in
|
|
157
172
|
one shell block, so a partial state is far more likely an interrupted setup
|
|
158
|
-
than a deliberate choice). When the pair
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
`
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
your machine is not the same as
|
|
166
|
-
pair's values are read in-process
|
|
167
|
-
ping
|
|
168
|
-
|
|
169
|
-
|
|
173
|
+
than a deliberate choice). When the pair IS set, the doctor goes one step
|
|
174
|
+
further and proves the chain end to end: it POSTs a **signed no-op ping** (the
|
|
175
|
+
`probe` event — the one event that derives no cache tags) to the endpoint. A
|
|
176
|
+
200 `{"revalidated":[]}` passes (endpoint reachable, the frontend's
|
|
177
|
+
`REVALIDATE_SECRET` matches, and the frontend speaks this engine's event
|
|
178
|
+
wire); a 401 fails as a secret mismatch between Convex and the frontend host;
|
|
179
|
+
a 400 fails as a version skew (the frontend runs a different Elytra version);
|
|
180
|
+
a network error only warns (unreachable from your machine is not the same as
|
|
181
|
+
broken — Convex may still reach it). The pair's values are read in-process
|
|
182
|
+
for the ping only and never printed. (There is no third, scope var any more:
|
|
183
|
+
the webhook carries the event, and the frontend derives its cache tags with
|
|
184
|
+
its own `ELYTRA_PROJECT_ID`.)
|
|
170
185
|
|
|
171
186
|
The **draft-preview token** — `DRAFT_PREVIEW_TOKEN` — gates the deployment's
|
|
172
187
|
`draftSnapshot` query and must equal the frontend host's own
|
|
@@ -184,10 +199,14 @@ reports it: two routes that would match the exact same URLs fail the run with
|
|
|
184
199
|
the offending ids and patterns named. Nothing about this needs a deployment —
|
|
185
200
|
the check runs in playground mode too.
|
|
186
201
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
202
|
+
When the studio's deps are installed, the doctor executes `elytra.config.ts`
|
|
203
|
+
through the studio's own Vite and judges the routes it resolved — exactly what
|
|
204
|
+
the studio and delivery serve. Without them it falls back to reading your
|
|
205
|
+
project's `cms/index.ts` natively (the `collections` + `routes` the scaffold's
|
|
206
|
+
`elytra.config.ts` passes through), and the `route-conflicts` row says which
|
|
207
|
+
of the two it judged. That tree is plain, React-free TypeScript by convention,
|
|
208
|
+
so the CLI imports it natively — no bundler, no Vite, no dev server. Two
|
|
209
|
+
consequences of the fallback worth knowing:
|
|
191
210
|
|
|
192
211
|
- It needs a Node that runs TypeScript natively (**≥22.18**). On an older Node
|
|
193
212
|
the check reports "not checked here" and names the version — never a false
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elytracms/cli",
|
|
3
3
|
"author": "finaldream <hi@finaldream.de>",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.91",
|
|
5
5
|
"description": "The Elytra CLI — run a consuming repo's studio, frontend, and Convex backend under one command.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"type": "module",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"node": ">=22.18.0"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@elytracms/core": "^0.0.
|
|
20
|
+
"@elytracms/core": "^0.0.91"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
23
|
"vitest": "^4.1.10"
|
package/src/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process'
|
|
2
|
-
import { join } from 'node:path'
|
|
3
2
|
import { stdout } from 'node:process'
|
|
4
3
|
import { resolveInstalledEngineVersion, runDoctor } from './doctor.js'
|
|
4
|
+
import { appDisplay } from './layout.js'
|
|
5
5
|
import { runPreviews } from './previews.js'
|
|
6
6
|
import { inspectProject, resolveStudioEnv } from './project.js'
|
|
7
7
|
import { ensureAuthKeys } from './provision.js'
|
|
@@ -29,10 +29,11 @@ Commands:
|
|
|
29
29
|
values never shown — config, membership) and print each fix. When the
|
|
30
30
|
instant-publish revalidate pair is set, also sends a signed no-op ping
|
|
31
31
|
to the endpoint to prove the publish → live chain end to end. Also
|
|
32
|
-
checks your
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
checks your routes for URL conflicts, the project layout
|
|
33
|
+
(package.json#elytra), the AI route your studio's vite.config.ts
|
|
34
|
+
mounts, and reports the generated-previews artifacts (manifest/files
|
|
35
|
+
present; hash staleness lives in \`elytra previews --check\`). Exit
|
|
36
|
+
non-zero if any check fails. Read-only. Optional: --url <convex url>.
|
|
36
37
|
previews Render every canvas-eligible component's fixtures against YOUR theme
|
|
37
38
|
(headless Chromium via Playwright — an optional install) and write
|
|
38
39
|
committed preview images (studio/public/elytra-previews/) plus an
|
|
@@ -47,6 +48,13 @@ Commands:
|
|
|
47
48
|
Run from anywhere inside an Elytra project (a directory with elytra.config.ts).
|
|
48
49
|
Any subcommand accepts --help/-h for its own usage (never does the command's work).
|
|
49
50
|
|
|
51
|
+
Layout: the studio app, frontend app and theme are found at studio/, frontend/
|
|
52
|
+
and components/theme.css by default. A project laid out differently declares
|
|
53
|
+
its paths in the repo-root package.json:
|
|
54
|
+
"elytra": { "studio": "./apps/editor", "frontend": "./apps/site",
|
|
55
|
+
"theme": "./styles/site.css" }
|
|
56
|
+
Every key is optional; a declared path that does not exist is reported.
|
|
57
|
+
|
|
50
58
|
Dev ports: studio 5180, frontend 3000 by default. Set ELYTRA_DEV_PORT_OFFSET in
|
|
51
59
|
studio/.env to shift BOTH together (e.g. to run a second Elytra project alongside
|
|
52
60
|
this one without a clash) — 'elytra dev' forwards it to both processes.
|
|
@@ -111,16 +119,20 @@ Diagnose the active deployment's readiness (URL, reachability, auth signing
|
|
|
111
119
|
keys, expected deployment env vars — set/missing by NAME, values never shown
|
|
112
120
|
— config, membership) and print each fix. When the instant-publish
|
|
113
121
|
revalidate pair is set, also sends a signed no-op ping to the endpoint to
|
|
114
|
-
prove the publish → live chain end to end. Also checks
|
|
115
|
-
|
|
116
|
-
|
|
122
|
+
prove the publish → live chain end to end. Also checks your route set for
|
|
123
|
+
URL conflicts (routes are code — redirect loops are editorial data and are
|
|
124
|
+
checked in the studio), and reports the generated-previews artifacts
|
|
117
125
|
(manifest/files present; hash staleness lives in \`elytra previews --check\`).
|
|
118
126
|
With studio/ deps installed, config validity is checked for real: doctor
|
|
119
127
|
executes elytra.config.ts through your studio's own Vite (takes a few
|
|
120
|
-
seconds)
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
128
|
+
seconds), and the route checks judge the routes it resolved; without deps
|
|
129
|
+
it stays an honest "unknown" with the reason and the routes are read from
|
|
130
|
+
cms/index.ts. With the config resolved, it also reads the public delivery
|
|
131
|
+
snapshot and warns about published documents whose collection the config
|
|
132
|
+
does not declare. It reports the project layout it resolved
|
|
133
|
+
(package.json#elytra — a declared path that does not exist fails) and the AI
|
|
134
|
+
route your studio's vite.config.ts mounts (which decides whether an AI
|
|
135
|
+
provider key is expected). Exit non-zero if any check fails. Read-only.
|
|
124
136
|
|
|
125
137
|
Options:
|
|
126
138
|
--url <convex url> Diagnose this deployment instead of the one resolved
|
|
@@ -175,6 +187,15 @@ function reportIssues(issues) {
|
|
|
175
187
|
for (const issue of issues) log(` • ${issue}`)
|
|
176
188
|
}
|
|
177
189
|
|
|
190
|
+
/**
|
|
191
|
+
* The resolved directory of an app (EC-603) — from `package.json#elytra`, else the
|
|
192
|
+
* scaffold default, as `inspectProject` resolved it. The ONE place a command learns
|
|
193
|
+
* where to spawn; no command joins a conventional directory name itself.
|
|
194
|
+
*/
|
|
195
|
+
function appDir(project, key) {
|
|
196
|
+
return project.layout.entries[key].path
|
|
197
|
+
}
|
|
198
|
+
|
|
178
199
|
/**
|
|
179
200
|
* Build the dev process group from a project inspection. `convex dev` (the continuous
|
|
180
201
|
* dev-sync watcher — it pushes local function code to the target on every save) is only
|
|
@@ -191,8 +212,8 @@ function reportIssues(issues) {
|
|
|
191
212
|
*/
|
|
192
213
|
export function planDev(project) {
|
|
193
214
|
const procs = []
|
|
194
|
-
const studioDir =
|
|
195
|
-
const frontendDir =
|
|
215
|
+
const studioDir = appDir(project, 'studio')
|
|
216
|
+
const frontendDir = appDir(project, 'frontend')
|
|
196
217
|
const offset = Number(project.devPortOffset ?? 0) || 0
|
|
197
218
|
// The studio's `vite.config.ts` resolves the offset itself, in JS (`loadEnv`) —
|
|
198
219
|
// portable anywhere, so forward the lever as-is.
|
|
@@ -254,8 +275,8 @@ function couplesConvexDeploy(project) {
|
|
|
254
275
|
*/
|
|
255
276
|
export function planBuild(project, target) {
|
|
256
277
|
const steps = []
|
|
257
|
-
const studioDir =
|
|
258
|
-
const frontendDir =
|
|
278
|
+
const studioDir = appDir(project, 'studio')
|
|
279
|
+
const frontendDir = appDir(project, 'frontend')
|
|
259
280
|
const studioBuild = { name: 'studio build', command: 'pnpm', args: ['run', 'build'], cwd: studioDir }
|
|
260
281
|
const frontendBuild = { name: 'frontend build', command: 'pnpm', args: ['run', 'build'], cwd: frontendDir }
|
|
261
282
|
|
|
@@ -299,6 +320,8 @@ export function planBuild(project, target) {
|
|
|
299
320
|
* (EC-356) is unit-testable without going through `run()`'s process-spawning commands.
|
|
300
321
|
*/
|
|
301
322
|
export function describeMode(project) {
|
|
323
|
+
// EC-603: the studio's location as the project laid it out (`studio/` by default).
|
|
324
|
+
const studio = appDisplay(project.layout, 'studio')
|
|
302
325
|
if (project.mode === 'playground') {
|
|
303
326
|
return 'Mode: playground (in-memory, no backend). Convex is not started.'
|
|
304
327
|
}
|
|
@@ -307,19 +330,24 @@ export function describeMode(project) {
|
|
|
307
330
|
'Mode: connected to a `prod:` Convex deployment. `convex dev` (the continuous dev-sync\n' +
|
|
308
331
|
'watcher, which pushes local function code on every save) is never started against\n' +
|
|
309
332
|
'production. Studio and frontend still start and talk to it over the client SDK. To push\n' +
|
|
310
|
-
|
|
333
|
+
`function changes, deploy explicitly: \`cd ${studio} && npx convex deploy\`.`
|
|
311
334
|
)
|
|
312
335
|
}
|
|
313
336
|
if (project.mode === 'connected' && !project.convexProvisioned) {
|
|
314
337
|
return (
|
|
315
338
|
'Mode: connected, but no Convex deployment is provisioned yet. Skipping the\n' +
|
|
316
|
-
|
|
339
|
+
`backend — run \`cd ${studio} && npx convex dev\` once to provision it. See CONNECT.md.`
|
|
317
340
|
)
|
|
318
341
|
}
|
|
319
342
|
if (project.mode === 'unconfigured') {
|
|
343
|
+
// EC-603: an unknown mode is the studio's own boot error — say it in its words, so
|
|
344
|
+
// the CLI and the studio never disagree about what is wrong.
|
|
345
|
+
if (project.backend?.kind === 'misconfigured' && project.backend.reason === 'unknown-mode') {
|
|
346
|
+
return `Backend misconfigured: ${project.backend.detail} (read from ${studio}/.env*). The studio will not boot until it is fixed.`
|
|
347
|
+
}
|
|
320
348
|
return (
|
|
321
349
|
'Backend not configured. Defaulting to the frontends only. Set VITE_ELYTRA_MODE=\n' +
|
|
322
|
-
|
|
350
|
+
`playground or VITE_CONVEX_URL in ${studio}/.env. See CONNECT.md.`
|
|
323
351
|
)
|
|
324
352
|
}
|
|
325
353
|
return null
|
|
@@ -335,6 +363,11 @@ export function describeMode(project) {
|
|
|
335
363
|
*/
|
|
336
364
|
export function describeBuildMode(project, target) {
|
|
337
365
|
if (target === 'frontend') return null
|
|
366
|
+
// EC-603: an unknown mode is named before anything else — a deploy key does not make
|
|
367
|
+
// a studio that will refuse to boot any less broken.
|
|
368
|
+
if (project.backend?.kind === 'misconfigured' && project.backend.reason === 'unknown-mode') {
|
|
369
|
+
return describeMode(project)
|
|
370
|
+
}
|
|
338
371
|
if (
|
|
339
372
|
target === 'studio' &&
|
|
340
373
|
project.mode !== 'playground' &&
|
|
@@ -369,14 +402,21 @@ function provisionConvexOnce(studioDir) {
|
|
|
369
402
|
* decisions mirror {@link planSetup}; `provision`/`exec`/`fetchImpl` are injectable.
|
|
370
403
|
*/
|
|
371
404
|
export async function runInit(project, { log: emit = log, fetchImpl, exec, provision = provisionConvexOnce } = {}) {
|
|
372
|
-
const studioDir =
|
|
405
|
+
const studioDir = appDir(project, 'studio')
|
|
406
|
+
const studio = appDisplay(project.layout, 'studio')
|
|
373
407
|
emit('Elytra first-run setup')
|
|
374
408
|
emit('')
|
|
375
409
|
|
|
376
410
|
// 1. Provision a backend deployment.
|
|
377
411
|
if (project.mode === 'unconfigured') {
|
|
412
|
+
// EC-603: an unknown mode is named in the studio's own words, never as "not configured".
|
|
413
|
+
if (project.backend?.kind === 'misconfigured' && project.backend.reason === 'unknown-mode') {
|
|
414
|
+
emit(`1. Provision — cannot continue: ${project.backend.detail}`)
|
|
415
|
+
emit(` Fix it in ${studio}/.env (or .env.local). See CONNECT.md.`)
|
|
416
|
+
return 1
|
|
417
|
+
}
|
|
378
418
|
emit('1. Provision — cannot continue: no backend configured.')
|
|
379
|
-
emit(
|
|
419
|
+
emit(` Set VITE_CONVEX_URL, or VITE_ELYTRA_MODE=playground, in ${studio}/.env. See CONNECT.md.`)
|
|
380
420
|
return 1
|
|
381
421
|
}
|
|
382
422
|
if (project.mode === 'playground') {
|
|
@@ -395,14 +435,14 @@ export async function runInit(project, { log: emit = log, fetchImpl, exec, provi
|
|
|
395
435
|
// selector is present some other way (e.g. CONVEX_DEPLOY_KEY) — provisioning via the
|
|
396
436
|
// dev-sync watcher (`convex dev --once`) is never safe to run against it automatically.
|
|
397
437
|
emit('1. Provision — cannot continue safely: a prod: selector is set, but no deployment is linked here.')
|
|
398
|
-
emit(
|
|
438
|
+
emit(` Provisioning via \`convex dev\` is skipped against production. Link and deploy explicitly: \`cd ${studio} && npx convex deploy\`.`)
|
|
399
439
|
return 1
|
|
400
440
|
} else {
|
|
401
441
|
emit('1. Provision — provisioning a Convex deployment (convex dev --once)…')
|
|
402
442
|
const code = provision(studioDir)
|
|
403
443
|
const reinspected = inspectProject(project.root)
|
|
404
444
|
if (code !== 0 || !reinspected.convexProvisioned) {
|
|
405
|
-
emit(
|
|
445
|
+
emit(` Provisioning did not complete. Run \`cd ${studio} && npx convex dev\` once, then re-run \`elytra init\`.`)
|
|
406
446
|
return 1
|
|
407
447
|
}
|
|
408
448
|
project = reinspected
|
|
@@ -427,11 +467,11 @@ export async function runInit(project, { log: emit = log, fetchImpl, exec, provi
|
|
|
427
467
|
// 3. Verify readiness (the shared elytra doctor check).
|
|
428
468
|
emit('')
|
|
429
469
|
emit('3. Verify —')
|
|
430
|
-
const env = resolveStudioEnv(project
|
|
470
|
+
const env = resolveStudioEnv(project)
|
|
431
471
|
// EC-552: with the studio dir known, the verify step includes the version
|
|
432
472
|
// handshake — right after a provision is exactly when a stale-functions state
|
|
433
473
|
// should be loud. Resolution happens HERE (the entry), keeping runDoctor injected.
|
|
434
|
-
const verifyStudioDir = project.apps.studio ?
|
|
474
|
+
const verifyStudioDir = project.apps.studio ? appDir(project, 'studio') : undefined
|
|
435
475
|
const code = await runDoctor({
|
|
436
476
|
env,
|
|
437
477
|
fetchImpl,
|
|
@@ -481,22 +521,23 @@ export async function run(argv, { cwd = process.cwd(), env = process.env, emit =
|
|
|
481
521
|
reportIssues(project.issues)
|
|
482
522
|
return 1
|
|
483
523
|
}
|
|
484
|
-
const studioEnv = resolveStudioEnv(project
|
|
485
|
-
const doctorStudioDir = project.apps.studio ?
|
|
524
|
+
const studioEnv = resolveStudioEnv(project, env)
|
|
525
|
+
const doctorStudioDir = project.apps.studio ? appDir(project, 'studio') : undefined
|
|
486
526
|
return runDoctor({
|
|
487
527
|
env: studioEnv,
|
|
488
528
|
fetchImpl,
|
|
489
529
|
log: emit,
|
|
490
530
|
url: flagValue(argv, '--url'),
|
|
491
|
-
// EC-399: where `convex env list` runs (name enumeration — presence only)
|
|
492
|
-
//
|
|
531
|
+
// EC-399: where `convex env list` runs (name enumeration — presence only).
|
|
532
|
+
// EC-603: also where the AI route is read from — the studio's own Vite config.
|
|
493
533
|
studioDir: doctorStudioDir,
|
|
494
534
|
// EC-552: the installed engine version — resolved at the entry so runDoctor
|
|
495
535
|
// stays pure/injected; unresolvable (deps not installed) → honest `unknown`.
|
|
496
536
|
installedEngineVersion: resolveInstalledEngineVersion(doctorStudioDir),
|
|
497
537
|
// EC-469: where the project's `cms/` route set is read from (code, not backend).
|
|
498
538
|
root: project.root,
|
|
499
|
-
|
|
539
|
+
// EC-603: the resolved package.json#elytra layout — reported as `project-layout`.
|
|
540
|
+
layout: project.layout,
|
|
500
541
|
exec,
|
|
501
542
|
})
|
|
502
543
|
}
|
|
@@ -519,7 +560,9 @@ export async function run(argv, { cwd = process.cwd(), env = process.env, emit =
|
|
|
519
560
|
return 1
|
|
520
561
|
}
|
|
521
562
|
if (!project.apps.studio) {
|
|
522
|
-
log(
|
|
563
|
+
log(
|
|
564
|
+
`No studio app found at ${appDisplay(project.layout, 'studio')}/ — \`elytra previews\` renders through the studio app’s Vite.`,
|
|
565
|
+
)
|
|
523
566
|
return 1
|
|
524
567
|
}
|
|
525
568
|
return runPreviews(project, {
|
|
@@ -544,7 +587,9 @@ export async function run(argv, { cwd = process.cwd(), env = process.env, emit =
|
|
|
544
587
|
return 1
|
|
545
588
|
}
|
|
546
589
|
|
|
547
|
-
|
|
590
|
+
// EC-603: read the studio env files of the Vite mode the spawned studio runs in —
|
|
591
|
+
// `vite build` reads `.env.production*`, `vite dev` `.env.development*`.
|
|
592
|
+
const project = inspectProject(cwd, env, { viteMode: command === 'build' ? 'production' : 'development' })
|
|
548
593
|
if (project.issues.length > 0) {
|
|
549
594
|
reportIssues(project.issues)
|
|
550
595
|
return 1
|
|
@@ -557,14 +602,14 @@ export async function run(argv, { cwd = process.cwd(), env = process.env, emit =
|
|
|
557
602
|
// backend (idempotent — a no-op when they're already set). Never blocks dev.
|
|
558
603
|
if (project.mode === 'connected' && project.convexProvisioned) {
|
|
559
604
|
try {
|
|
560
|
-
ensureAuthKeys({ cwd:
|
|
605
|
+
ensureAuthKeys({ cwd: appDir(project, 'studio'), exec, log: (l) => log(`[setup] ${l}`) })
|
|
561
606
|
} catch (error) {
|
|
562
607
|
log(`[setup] auth-keys preflight skipped: ${error.message}`)
|
|
563
608
|
}
|
|
564
609
|
}
|
|
565
610
|
const procs = planDev(project)
|
|
566
611
|
if (procs.length === 0) {
|
|
567
|
-
log(
|
|
612
|
+
log(`Nothing to run — no ${appDisplay(project.layout, 'studio')}/ or ${appDisplay(project.layout, 'frontend')}/ app found.`)
|
|
568
613
|
return 1
|
|
569
614
|
}
|
|
570
615
|
log('Starting (Ctrl-C to stop everything)…\n')
|
|
@@ -576,8 +621,8 @@ export async function run(argv, { cwd = process.cwd(), env = process.env, emit =
|
|
|
576
621
|
if (steps.length === 0) {
|
|
577
622
|
log(
|
|
578
623
|
target
|
|
579
|
-
? `Nothing to build — no ${target}/ app found.`
|
|
580
|
-
:
|
|
624
|
+
? `Nothing to build — no ${appDisplay(project.layout, target)}/ app found.`
|
|
625
|
+
: `Nothing to build — no ${appDisplay(project.layout, 'studio')}/ or ${appDisplay(project.layout, 'frontend')}/ app found.`,
|
|
581
626
|
)
|
|
582
627
|
return 1
|
|
583
628
|
}
|
package/src/config-harness.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { createRequire } from 'node:module'
|
|
3
3
|
import { createServer as createNetServer } from 'node:net'
|
|
4
|
-
import { join } from 'node:path'
|
|
4
|
+
import { join, relative, resolve, sep } from 'node:path'
|
|
5
5
|
import { pathToFileURL } from 'node:url'
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -21,6 +21,11 @@ import { pathToFileURL } from 'node:url'
|
|
|
21
21
|
* config by `/@fs/` path, and `ssrLoadModule`s them node-side. The consumer's
|
|
22
22
|
* own toolchain does the executing; the CLI never grows a bundler dependency.
|
|
23
23
|
*
|
|
24
|
+
* EC-603 adds one READ of the studio's own `vite.config.ts` — evaluated through
|
|
25
|
+
* Vite's `loadConfigFromFile`, never used to boot anything — solely to learn the
|
|
26
|
+
* AI route it mounts ({@link readStudioAiRoute}); the harness boot above still
|
|
27
|
+
* never loads it.
|
|
28
|
+
*
|
|
24
29
|
* Everything here is opportunistic by contract: the CLI works without
|
|
25
30
|
* installed deps (`cli.js` — doctor is not blocked by a missing
|
|
26
31
|
* node_modules), so every "can't" degrades to a reason string, never a crash.
|
|
@@ -227,7 +232,8 @@ export function configHarnessPrecondition({ configPath, studioDir, exists, resol
|
|
|
227
232
|
kind: 'unavailable',
|
|
228
233
|
reason:
|
|
229
234
|
`No studio/ app found — doctor executes ${CONFIG_FILE} through the studio app's own Vite; ` +
|
|
230
|
-
'the studio boot and `elytra build` validate it too.'
|
|
235
|
+
'the studio boot and `elytra build` validate it too. (A studio laid out elsewhere is declared in ' +
|
|
236
|
+
'package.json#elytra.studio.)',
|
|
231
237
|
}
|
|
232
238
|
}
|
|
233
239
|
for (const dep of REQUIRED_CONSUMER_DEPS) {
|
|
@@ -237,7 +243,7 @@ export function configHarnessPrecondition({ configPath, studioDir, exists, resol
|
|
|
237
243
|
return {
|
|
238
244
|
kind: 'unavailable',
|
|
239
245
|
reason:
|
|
240
|
-
`The studio's dependencies are not installed (${dep} could not be resolved from studio
|
|
246
|
+
`The studio's dependencies are not installed (${dep} could not be resolved from the studio app) — ` +
|
|
241
247
|
'run `pnpm install`, then re-run `elytra doctor` for a real config check.',
|
|
242
248
|
}
|
|
243
249
|
}
|
|
@@ -245,6 +251,170 @@ export function configHarnessPrecondition({ configPath, studioDir, exists, resol
|
|
|
245
251
|
return null
|
|
246
252
|
}
|
|
247
253
|
|
|
254
|
+
// --- The AI route, from the studio's resolved Vite config (EC-603) -------------------
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The AI dev plugin's `name` (`@elytracms/studio/lib/ai/vite`'s `AI_ROUTE_PLUGIN_NAME`).
|
|
258
|
+
* Mirrored as a literal because the CLI must not import the studio package; the
|
|
259
|
+
* plugin's own test pins the value, and this module's test pins the mirror against it.
|
|
260
|
+
*/
|
|
261
|
+
export const AI_ROUTE_PLUGIN_NAME = 'elytra-ai-route'
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Load the studio app's OWN Vite config the way Vite itself does — Vite's discovery of
|
|
265
|
+
* `vite.config.*` in the studio root, via the consumer's installed `vite`
|
|
266
|
+
* (`loadConfigFromFile`, the documented API). That evaluates the config module and its
|
|
267
|
+
* plugin factories only: no server, no `configResolved`/`buildStart` hooks, no route
|
|
268
|
+
* generation. Measured 2026-10-25 on `apps/demo/studio` and a scaffold-shaped studio:
|
|
269
|
+
* no file written outside Vite's own `node_modules/.vite-temp` bundle, 0.2–0.9 s.
|
|
270
|
+
* `null` when the studio has no Vite config file.
|
|
271
|
+
*/
|
|
272
|
+
export async function loadStudioViteConfig(studioDir) {
|
|
273
|
+
const vite = await import(pathToFileURL(resolveFromDir(studioDir, 'vite')).href)
|
|
274
|
+
return vite.loadConfigFromFile(
|
|
275
|
+
{ command: 'serve', mode: 'development', isSsrBuild: false, isPreview: false },
|
|
276
|
+
undefined,
|
|
277
|
+
studioDir,
|
|
278
|
+
'silent',
|
|
279
|
+
)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Flatten a Vite `plugins` option (nested arrays, promises, falsy entries) into plugin objects. */
|
|
283
|
+
export async function flattenPlugins(option) {
|
|
284
|
+
const out = []
|
|
285
|
+
const walk = async (value) => {
|
|
286
|
+
const resolved = await value
|
|
287
|
+
if (!resolved) return
|
|
288
|
+
if (Array.isArray(resolved)) {
|
|
289
|
+
for (const item of resolved) await walk(item)
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
out.push(resolved)
|
|
293
|
+
}
|
|
294
|
+
await walk(option)
|
|
295
|
+
return out
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* PURE decision core: the AI-route fact from a loaded Vite config (`loaded` is
|
|
300
|
+
* `loadConfigFromFile`'s result, or `null` for no config file).
|
|
301
|
+
*
|
|
302
|
+
* The route is read from the declaration that MOUNTS it — `createAiDevPlugin({
|
|
303
|
+
* handlerModulePath, route })` in the studio's Vite config — never guessed from a fixed
|
|
304
|
+
* file path (the old `studio/src/server/ai-handler.ts` probe, RATIFIED away by EC-603).
|
|
305
|
+
* Its presence means this project serves its own AI route, whose handler is what reads
|
|
306
|
+
* the provider key — so it is what decides the doctor's AI-key expectation. The studio
|
|
307
|
+
* config's `ai` block was measured too and rejected as the source: nothing declares
|
|
308
|
+
* where that file lives (the studio entry imports it — Konditorei keeps it at
|
|
309
|
+
* `studio/src/studio.config.tsx`, the scaffold at `studio/studio.config.tsx`), so
|
|
310
|
+
* reading it would need exactly the fixed path the ruling forbids.
|
|
311
|
+
*
|
|
312
|
+
* Returns one of
|
|
313
|
+
* - `{ kind: 'mounted', source, route?, handlerModulePath?, handlerPath?, handlerExists? }`
|
|
314
|
+
* — the plugin is configured; the route details are present when the installed
|
|
315
|
+
* engine exposes them on the plugin's `api` (EC-603+), and `handlerExists` says
|
|
316
|
+
* whether the module it will `ssrLoadModule` exists;
|
|
317
|
+
* - `{ kind: 'none', source }` — a Vite config without the plugin (`source: null`
|
|
318
|
+
* when the studio has no Vite config file at all).
|
|
319
|
+
*/
|
|
320
|
+
export function aiRouteFromViteConfig({ studioDir, loaded, plugins, exists = existsSync }) {
|
|
321
|
+
const source = loaded?.path ? relative(studioDir, loaded.path).split(sep).join('/') : null
|
|
322
|
+
const plugin = plugins.find((p) => p && p.name === AI_ROUTE_PLUGIN_NAME)
|
|
323
|
+
if (!plugin) return { kind: 'none', source }
|
|
324
|
+
const declared = plugin.api?.elytraAiRoute
|
|
325
|
+
if (!declared || typeof declared.handlerModulePath !== 'string') return { kind: 'mounted', source }
|
|
326
|
+
const { route, handlerModulePath } = declared
|
|
327
|
+
// Vite resolves a `/`-prefixed id against its root first, then as an absolute fs path.
|
|
328
|
+
const candidates = handlerModulePath.startsWith('/')
|
|
329
|
+
? [join(studioDir, handlerModulePath), handlerModulePath]
|
|
330
|
+
: [resolve(studioDir, handlerModulePath)]
|
|
331
|
+
const handlerPath = candidates.find((candidate) => exists(candidate)) ?? candidates[0]
|
|
332
|
+
return {
|
|
333
|
+
kind: 'mounted',
|
|
334
|
+
source,
|
|
335
|
+
route,
|
|
336
|
+
handlerModulePath,
|
|
337
|
+
handlerPath,
|
|
338
|
+
handlerExists: candidates.some((candidate) => exists(candidate)),
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Read the studio's AI route from its resolved Vite config (EC-603). Never throws:
|
|
344
|
+
* every "can't" is `{ kind: 'unavailable', reason }` → the doctor's `ai-route` row is an
|
|
345
|
+
* honest `unknown` and no AI-key expectation is added (a fact not established is not
|
|
346
|
+
* assumed — and never re-guessed from a file path).
|
|
347
|
+
*/
|
|
348
|
+
export async function readStudioAiRoute({ studioDir, exists = existsSync, loadConfig = loadStudioViteConfig } = {}) {
|
|
349
|
+
if (!studioDir || !exists(join(studioDir, 'package.json'))) {
|
|
350
|
+
return { kind: 'unavailable', reason: 'No studio app found — the AI route was not determined here.' }
|
|
351
|
+
}
|
|
352
|
+
let loaded
|
|
353
|
+
try {
|
|
354
|
+
loaded = await loadConfig(studioDir)
|
|
355
|
+
} catch (error) {
|
|
356
|
+
return {
|
|
357
|
+
kind: 'unavailable',
|
|
358
|
+
reason: `The studio's Vite config could not be loaded (${errorSummary(error)}) — the AI route was not determined here.`,
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
try {
|
|
362
|
+
const plugins = await flattenPlugins(loaded?.config?.plugins)
|
|
363
|
+
return aiRouteFromViteConfig({ studioDir, loaded, plugins, exists })
|
|
364
|
+
} catch (error) {
|
|
365
|
+
return {
|
|
366
|
+
kind: 'unavailable',
|
|
367
|
+
reason: `The studio's Vite plugins could not be read (${errorSummary(error)}) — the AI route was not determined here.`,
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* The doctor's `ai-route` row (pure, `app` bucket): which AI route the studio mounts, read
|
|
374
|
+
* from where — so the AI-key expectation in the deployment-env bucket is never a mystery.
|
|
375
|
+
* `fail` only when the config mounts a handler module that does not exist (every request
|
|
376
|
+
* to the route answers 500 — deterministic in the repo); otherwise `ok` or `unknown`.
|
|
377
|
+
*/
|
|
378
|
+
export function aiRouteDoctorChecks(aiRoute, { studioLabel = 'studio' } = {}) {
|
|
379
|
+
if (!aiRoute) return []
|
|
380
|
+
const base = { id: 'ai-route', bucket: 'app' }
|
|
381
|
+
if (aiRoute.kind === 'unavailable') return [{ ...base, status: 'unknown', detail: aiRoute.reason }]
|
|
382
|
+
const where = aiRoute.source ? `${studioLabel}/${aiRoute.source}` : `${studioLabel}/ (no Vite config file)`
|
|
383
|
+
if (aiRoute.kind === 'none') {
|
|
384
|
+
return [
|
|
385
|
+
{
|
|
386
|
+
...base,
|
|
387
|
+
status: 'ok',
|
|
388
|
+
detail: `No AI route is mounted (${where} has no createAiDevPlugin) — no AI provider key is expected.`,
|
|
389
|
+
},
|
|
390
|
+
]
|
|
391
|
+
}
|
|
392
|
+
if (aiRoute.handlerExists === false) {
|
|
393
|
+
return [
|
|
394
|
+
{
|
|
395
|
+
...base,
|
|
396
|
+
status: 'fail',
|
|
397
|
+
detail:
|
|
398
|
+
`${where} mounts the AI route${aiRoute.route ? ` at ${aiRoute.route}` : ''} with handlerModulePath ` +
|
|
399
|
+
`"${aiRoute.handlerModulePath}", but that module does not exist — every request to the route answers 500.`,
|
|
400
|
+
fix:
|
|
401
|
+
'Point createAiDevPlugin({ handlerModulePath }) at your handler module (Vite-root-relative, ' +
|
|
402
|
+
"e.g. '/src/server/ai-handler.ts'), or remove the plugin to ship without AI.",
|
|
403
|
+
},
|
|
404
|
+
]
|
|
405
|
+
}
|
|
406
|
+
const details = aiRoute.handlerModulePath
|
|
407
|
+
? ` (${aiRoute.route ?? 'its route'} → ${aiRoute.handlerModulePath})`
|
|
408
|
+
: ''
|
|
409
|
+
return [
|
|
410
|
+
{
|
|
411
|
+
...base,
|
|
412
|
+
status: 'ok',
|
|
413
|
+
detail: `The studio mounts its AI route${details}, read from ${where} — the AI provider-key expectation applies.`,
|
|
414
|
+
},
|
|
415
|
+
]
|
|
416
|
+
}
|
|
417
|
+
|
|
248
418
|
/** An error's message, newline-collapsed and capped — legible in a one-line report row, never a stack dump. */
|
|
249
419
|
function errorSummary(error) {
|
|
250
420
|
const message = error instanceof Error ? error.message : String(error)
|