@elytracms/cli 0.0.88 → 0.0.90

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 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 `.env` (the same backend signal the studio itself
68
- uses):
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`, `STUDIO_ORIGIN`, `DRAFT_PREVIEW_TOKEN` — see the
123
- scaffold's CONNECT.md for what each pairs with.
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 trio
128
- `CANVAS_PROJECT_SCOPE` / `CANVAS_REVALIDATE_ENDPOINT` /
129
- `CANVAS_REVALIDATE_SECRET`, and `DRAFT_PREVIEW_TOKEN` (each of the latter
130
- four must match its frontend-plane counterpart). `elytra doctor` diagnoses
131
- this plane's readiness.
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 trio, the draft-preview token, plus the AI provider key
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 trio** — `CANVAS_PROJECT_SCOPE`,
146
- `CANVAS_REVALIDATE_ENDPOINT`, and `CANVAS_REVALIDATE_SECRET` — gets special
147
- treatment, because the publish path **silently no-ops** while any of the
148
- three is unset: `CANVAS_PROJECT_SCOPE` gates the write-chokepoint trigger
149
- that *schedules* the revalidate webhook (checked FIRST, before the pair is
150
- even consulted), and the pair then gates whether the scheduled webhook
151
- actually *delivers*. Either way, publishes never update the live site until
152
- a redeploy, with no error anywhere. All three missing is a warning carrying
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 (`CANVAS_REVALIDATE_ENDPOINT` +
159
- `CANVAS_REVALIDATE_SECRET`) IS set, the doctor goes one step further and
160
- proves the chain end to end: it POSTs a **signed no-op ping** (an empty tag
161
- list — the route revalidates nothing) to the endpoint. A 200
162
- `{"revalidated":[]}` passes (endpoint reachable, and the frontend's
163
- `REVALIDATE_SECRET` matches); a 401 fails as a secret mismatch between
164
- Convex and the frontend host; a network error only warns (unreachable from
165
- your machine is not the same as broken — Convex may still reach it). The
166
- pair's values are read in-process for the ping only and never printed. The
167
- ping proves only the pair's reachability/secret-match, not scheduling — a
168
- missing `CANVAS_PROJECT_SCOPE` still fails its own row (and the run)
169
- independently, even when the ping passes.
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
- The doctor reads the route set from your project's `cms/index.ts` (the
188
- `collections` + `routes` your `elytra.config.ts` passes through). That tree is
189
- plain, React-free TypeScript by convention, so the CLI imports it natively —
190
- no bundler, no Vite, no dev server. Two consequences worth knowing:
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.88",
4
+ "version": "0.0.90",
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.88"
20
+ "@elytracms/core": "^0.0.90"
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 cms/ routes for URL conflicts, and reports the
33
- generated-previews artifacts (manifest/files present; hash staleness
34
- lives in \`elytra previews --check\`). Exit non-zero if any check
35
- fails. Read-only. Optional: --url <convex url>.
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 the route set in your
115
- cms/ for URL conflicts (routes are code — redirect loops are editorial data
116
- and are checked in the studio), and reports the generated-previews artifacts
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); without deps it stays an honest "unknown" with the reason. With
121
- the config resolved, it also reads the public delivery snapshot and warns
122
- about published documents whose collection the config does not declare.
123
- Exit non-zero if any check fails. Read-only.
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 = join(project.root, 'studio')
195
- const frontendDir = join(project.root, 'frontend')
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 = join(project.root, 'studio')
258
- const frontendDir = join(project.root, 'frontend')
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
- 'function changes, deploy explicitly: `cd studio && npx convex deploy`.'
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
- 'backend — run `cd studio && npx convex dev` once to provision it. See CONNECT.md.'
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
- 'playground or VITE_CONVEX_URL in studio/.env. See CONNECT.md.'
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 = join(project.root, 'studio')
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(' Set VITE_CONVEX_URL, or VITE_ELYTRA_MODE=playground, in studio/.env. See CONNECT.md.')
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(' Provisioning via `convex dev` is skipped against production. Link and deploy explicitly: `cd studio && npx convex deploy`.')
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(' Provisioning did not complete. Run `cd studio && npx convex dev` once, then re-run `elytra init`.')
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.root)
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 ? join(project.root, 'studio') : undefined
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.root, env)
485
- const doctorStudioDir = project.apps.studio ? join(project.root, 'studio') : undefined
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
- // and whether the AI provider-key expectation applies.
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
- aiRouteConfigured: project.hasAiRoute,
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('No studio/ app found — `elytra previews` renders through the studio app’s Vite.')
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
- const project = inspectProject(cwd, env)
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: join(project.root, 'studio'), exec, log: (l) => log(`[setup] ${l}`) })
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('Nothing to run — no studio/ or frontend/ app found.')
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
- : 'Nothing to build — no studio/ or frontend/ app found.',
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
  }
@@ -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)