@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/src/doctor.js CHANGED
@@ -6,14 +6,17 @@ import {
6
6
  REVALIDATE_ENV_ENDPOINT,
7
7
  REVALIDATE_ENV_SECRET,
8
8
  backendSiteUrl,
9
+ backendUrlOf,
9
10
  checkDeploymentReady,
10
11
  expectedDeploymentEnv,
12
+ parseBackendMode,
11
13
  probeAuthSigningKeys,
12
14
  probeDeliverySnapshot,
13
15
  probeDeployedFunctionsVersion,
14
16
  probeRevalidateEndpoint,
15
17
  } from '@elytracms/core/export-sync/deployment-ready'
16
- import { executeWorkspaceConfig } from './config-harness.js'
18
+ import { aiRouteDoctorChecks, executeWorkspaceConfig, readStudioAiRoute } from './config-harness.js'
19
+ import { appDisplay, layoutDoctorChecks } from './layout.js'
17
20
  import { convexEnvGet, defaultConvexExec } from './provision.js'
18
21
  import { gatherPreviewsArtifacts, previewsDoctorChecks } from './previews.js'
19
22
  import { loadProjectRoutes, routesDoctorChecks } from './routes-check.js'
@@ -40,17 +43,29 @@ import { loadProjectRoutes, routesDoctorChecks } from './routes-check.js'
40
43
  /**
41
44
  * Decide which deployment the doctor diagnoses from the resolved studio env.
42
45
  *
43
- * A configured `VITE_CONVEX_URL` is preferred over the `VITE_ELYTRA_MODE=playground`
44
- * run-mode flag: that flag only selects the studio's in-memory *adapter*, whereas the
45
- * doctor's job is to validate the real deployment you configured. So a repo that runs
46
+ * The mode is judged by the ONE parser the studio runs (core's `parseBackendMode`,
47
+ * EC-603), so a padded flag is playground here exactly as it is in the studio, and an
48
+ * unknown mode is the studio's own boot error — a failing `backend-url` naming the
49
+ * value, never silently overridden by a URL beside it.
50
+ *
51
+ * A configured `VITE_CONVEX_URL` is preferred over a VALID `VITE_ELYTRA_MODE=playground`
52
+ * flag: that flag only selects the studio's in-memory *adapter*, whereas the doctor's
53
+ * job is to validate the real deployment you configured. So a repo that runs
46
54
  * playground locally but points at a Convex deployment (e.g. the demo) is diagnosed
47
- * against that deployment — with a note that the studio itself runs in-memory.
55
+ * against that deployment — with a note that the studio itself runs in-memory. An
56
+ * explicit `--url` diagnoses that deployment whatever the env says.
57
+ *
58
+ * `envSource` names where the env was read, for the unconfigured detail (`studio/.env`).
48
59
  */
49
- export function resolveDoctorTarget(env = {}, { url } = {}) {
60
+ export function resolveDoctorTarget(env = {}, { url, envSource = 'studio/.env' } = {}) {
50
61
  const explicit = url && url.trim() !== '' ? url.trim() : undefined
51
- const envUrl = env.VITE_CONVEX_URL || env.CONVEX_URL
62
+ const backend = parseBackendMode(env)
63
+ if (!explicit && backend.kind === 'misconfigured' && backend.reason === 'unknown-mode') {
64
+ return { kind: 'unconfigured', detail: `${backend.detail} (in ${envSource})` }
65
+ }
66
+ const envUrl = backendUrlOf(env)
52
67
  const chosenUrl = explicit || envUrl
53
- const playgroundFlag = env.VITE_ELYTRA_MODE === 'playground'
68
+ const playgroundFlag = backend.kind === 'playground'
54
69
 
55
70
  if (chosenUrl) {
56
71
  return {
@@ -67,7 +82,7 @@ export function resolveDoctorTarget(env = {}, { url } = {}) {
67
82
  if (playgroundFlag) return { kind: 'playground' }
68
83
  return {
69
84
  kind: 'unconfigured',
70
- detail: 'No VITE_CONVEX_URL is set, and VITE_ELYTRA_MODE is not "playground", in studio/.env.',
85
+ detail: `No VITE_CONVEX_URL is set, and VITE_ELYTRA_MODE is not "playground", in ${envSource}.`,
71
86
  }
72
87
  }
73
88
 
@@ -345,7 +360,7 @@ export function gatherDeploymentEnvFacts({ env = {}, target, studioDir, aiRouteC
345
360
  facts.unavailableDetail =
346
361
  '--url overrides the linked deployment; env names are only read from the deployment this repo is linked to.'
347
362
  } else if (!studioDir) {
348
- facts.unavailableDetail = 'No studio/ app found — deployment env names not enumerated.'
363
+ facts.unavailableDetail = 'No studio app found — deployment env names not enumerated.'
349
364
  } else if (!env.CONVEX_DEPLOYMENT && !env.CONVEX_DEPLOY_KEY) {
350
365
  facts.unavailableDetail =
351
366
  'No linked deployment (CONVEX_DEPLOYMENT / CONVEX_DEPLOY_KEY unset) — `npx convex env list` needs one.'
@@ -360,10 +375,10 @@ export function gatherDeploymentEnvFacts({ env = {}, target, studioDir, aiRouteC
360
375
  // --- Signed revalidate ping (EC-421) -------------------------------------------
361
376
  //
362
377
  // When the presence checks say the instant-publish pair IS set, the doctor proves the
363
- // chain end to end: a POST to the endpoint signed with the secret, carrying an EMPTY
364
- // tag list — by the receiving route's own semantics a harmless no-op answering 200
365
- // `{"revalidated":[]}`. Default-on (no flag): the ping is read-only in effect and the
366
- // silent-failure class it catches (secret mismatch — publishes rejected with 401
378
+ // chain end to end: a POST to the endpoint signed with the secret, carrying the `probe`
379
+ // event (EC-579), which derives no cache tags — by the receiving route's own semantics a
380
+ // harmless no-op answering 200 `{"revalidated":[]}`. Default-on (no flag): the ping is
381
+ // read-only in effect and the silent-failure class it catches (secret mismatch — publishes rejected with 401
367
382
  // forever, no error anywhere) is exactly what the doctor exists for.
368
383
  //
369
384
  // The ping needs the pair's VALUES — the one sanctioned value read: captured
@@ -459,6 +474,29 @@ export function formatReport(report) {
459
474
  return lines.join('\n')
460
475
  }
461
476
 
477
+ /**
478
+ * The route set the doctor's route checks judge (EC-603, RESIDUE-4): the RESOLVED
479
+ * config's routes + collections when the harness resolved it validly — the same records
480
+ * the studio mounts and delivery serves, whatever file they were declared in — else the
481
+ * native `cms/index.ts` floor ({@link loadProjectRoutes}, or the injected `routeFacts`),
482
+ * with the resolved collections swapped in when there are some. `source` says which, so
483
+ * the report can. Never throws.
484
+ */
485
+ export async function routeFactsFor({ outcome, configFacts = {}, routeFacts, root }) {
486
+ const project = resolvedProjectOf(outcome)
487
+ if (project && Array.isArray(project.routes)) {
488
+ return {
489
+ routes: project.routes,
490
+ routeCount: project.routes.length,
491
+ ...(Array.isArray(project.collections) ? { collections: project.collections } : {}),
492
+ source: 'config',
493
+ }
494
+ }
495
+ const loaded = routeFacts ?? (await loadProjectRoutes(root))
496
+ const enriched = configFacts.collections ? { ...loaded, collections: configFacts.collections } : loaded
497
+ return { ...enriched, source: 'native' }
498
+ }
499
+
462
500
  // --- Command -----------------------------------------------------------------
463
501
 
464
502
  /**
@@ -502,6 +540,19 @@ export function formatReport(report) {
502
540
  * published documents whose collection the resolved config does not declare — read
503
541
  * from the public delivery snapshot ({@link gatherPublishedOrphans}). A `warn` at most,
504
542
  * so the exit code never changes; without a resolved config it is an honest `unknown`.
543
+ *
544
+ * EC-603: three more facts, all read from DECLARATIONS rather than fixed paths:
545
+ * - `layout` (the CLI entry passes `inspectProject`'s resolved `package.json#elytra`
546
+ * layout) → the `project-layout` row: what was resolved, declared or defaulted, and a
547
+ * `fail` for an invalid section or a declared path that does not exist;
548
+ * - the AI route → read from the studio's own resolved Vite config
549
+ * ({@link readStudioAiRoute}) when a `root` is known — it decides the AI-key
550
+ * expectation and gets its own `ai-route` row. `aiRoute` (an outcome) and
551
+ * `aiRouteConfigured` (a boolean) are test seams; without either and without a root
552
+ * no AI expectation is added;
553
+ * - the route checks run over the RESOLVED config's routes whenever the harness
554
+ * resolved it (the routes the studio and delivery actually serve); the native
555
+ * `cms/index.ts` read is the fallback only when it could not, and the row says which.
505
556
  */
506
557
  export async function runDoctor({
507
558
  env = {},
@@ -510,14 +561,17 @@ export async function runDoctor({
510
561
  url,
511
562
  studioDir,
512
563
  root,
513
- aiRouteConfigured = false,
564
+ layout,
565
+ aiRoute: aiRouteOutcome,
566
+ aiRouteConfigured,
514
567
  exec,
515
568
  previewsArtifacts,
516
569
  routeFacts,
517
570
  configOutcome,
518
571
  installedEngineVersion,
519
572
  } = {}) {
520
- const target = resolveDoctorTarget(env, { url })
573
+ const studioLabel = appDisplay(layout, 'studio')
574
+ const target = resolveDoctorTarget(env, { url, envSource: `${studioLabel}/.env` })
521
575
 
522
576
  log('Elytra deployment doctor')
523
577
  if (target.kind === 'convex') {
@@ -536,8 +590,14 @@ export async function runDoctor({
536
590
  // entry resolves it via `resolveInstalledEngineVersion`; runDoctor itself never
537
591
  // touches the filesystem for it, staying pure/injected like every gatherer here).
538
592
  const facts = await gatherFacts(target, { fetchImpl, installedEngineVersion })
593
+
594
+ // EC-603: the AI route from the studio's resolved Vite config — gathered before the
595
+ // deployment-env facts, whose AI-key expectation it decides.
596
+ const aiRoute = aiRouteOutcome ?? (root !== undefined ? await readStudioAiRoute({ studioDir }) : undefined)
597
+ const aiExpected = aiRouteConfigured ?? aiRoute?.kind === 'mounted'
598
+
539
599
  if (target.kind === 'convex') {
540
- facts.deploymentEnv = gatherDeploymentEnvFacts({ env, target, studioDir, aiRouteConfigured, exec })
600
+ facts.deploymentEnv = gatherDeploymentEnvFacts({ env, target, studioDir, aiRouteConfigured: aiExpected, exec })
541
601
  // EC-421: default-on signed no-op ping whenever the revalidate pair is present.
542
602
  const revalidateProbe = await gatherRevalidateProbe({
543
603
  envFacts: facts.deploymentEnv,
@@ -577,14 +637,24 @@ export async function runDoctor({
577
637
  const artifacts = previewsArtifacts ?? (studioDir ? gatherPreviewsArtifacts(studioDir) : undefined)
578
638
  let checks = artifacts ? [...report.checks, ...previewsDoctorChecks(artifacts)] : [...report.checks]
579
639
 
640
+ // EC-603: the layout and AI-route rows, in the `app` bucket (both repo facts).
641
+ checks = spliceBucketChecks(checks, 'app', [
642
+ ...layoutDoctorChecks(layout),
643
+ ...aiRouteDoctorChecks(aiRoute, { studioLabel }),
644
+ ])
645
+
580
646
  // EC-469: the route-conflict check, spliced into the `app` bucket it belongs to.
581
647
  // Backend-independent (routes are CODE) — it runs in playground mode too.
582
- // EC-495: a resolved config supersedes the `cms/index.ts` collections re-export —
583
- // the route checks judge mounts against the same collections the studio mounts.
648
+ // EC-603 (RESIDUE-4): when the harness resolved the config, the checks run over ITS
649
+ // routes and collections — what the studio and delivery serve; the native
650
+ // `cms/index.ts` read is the fallback for when it could not (EC-495 had swapped in the
651
+ // resolved collections only, so the routes still came from `cms/index.ts`).
584
652
  if (root !== undefined || routeFacts !== undefined) {
585
- const loaded = routeFacts ?? (await loadProjectRoutes(root))
586
- const enriched = configFacts.collections ? { ...loaded, collections: configFacts.collections } : loaded
587
- checks = spliceBucketChecks(checks, 'app', routesDoctorChecks(enriched))
653
+ checks = spliceBucketChecks(
654
+ checks,
655
+ 'app',
656
+ routesDoctorChecks(await routeFactsFor({ outcome, configFacts, routeFacts, root })),
657
+ )
588
658
  }
589
659
 
590
660
  // One rule for the exit code, the same one `checkDeploymentReady` applies: a
package/src/layout.js ADDED
@@ -0,0 +1,288 @@
1
+ import { existsSync, readFileSync, statSync } from 'node:fs'
2
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path'
3
+
4
+ /**
5
+ * The CLI layout contract (EC-603, RATIFIED 2026-10-24) — where a project's studio app,
6
+ * frontend app and theme stylesheet live, declared in the repo-root `package.json`:
7
+ *
8
+ * "elytra": { "studio": "./studio", "frontend": "./frontend", "theme": "./components/theme.css" }
9
+ *
10
+ * `package.json` because it is the one file the CLI can read before ANY toolchain runs —
11
+ * `elytra.config.ts` (the structure) is executed through the studio app's own Vite,
12
+ * which needs the studio's location first (the chicken-and-egg EC-565 Round 5 settled;
13
+ * a plain-TS loader was measured not viable). Every key is optional and DEFAULTS TO THE
14
+ * SCAFFOLD LAYOUT, so a scaffolded project declares nothing and nothing changes for it.
15
+ *
16
+ * The engine supplies capability, never structure (AGENTS.md §4): with this section a
17
+ * project can reorganise its directories freely and still satisfy the CLI.
18
+ *
19
+ * This module is the ONE declaration of the section's shape ({@link LAYOUT_ENTRIES}) and
20
+ * its ONE reader ({@link resolveProjectLayout}); every CLI path that needs a location
21
+ * (`inspectProject`, `dev`/`build`, the config harness, `previews`, `doctor`) takes it
22
+ * from the resolved layout. Validation is explicit — an unknown key, a non-string value,
23
+ * an absolute path, a path escaping the repo, a theme that is not a file, a declared path
24
+ * that does not exist — each is a named issue, never a silent fall-back to the default.
25
+ * Its only reader is the CLI (the studio and the engine never read it), so it lives here,
26
+ * in plain JS with no dependency, rather than as a core schema.
27
+ */
28
+
29
+ /** Where the section lives, as the issues name it. */
30
+ export const LAYOUT_SECTION = 'package.json#elytra'
31
+
32
+ /**
33
+ * The section's keys — THE declaration. `default` is the scaffold layout; `kind` is what
34
+ * the path must be (`app`: a directory with a `package.json`; `file`: a file).
35
+ */
36
+ export const LAYOUT_ENTRIES = {
37
+ studio: { default: './studio', kind: 'app', label: 'studio app' },
38
+ frontend: { default: './frontend', kind: 'app', label: 'frontend app' },
39
+ theme: { default: './components/theme.css', kind: 'file', label: 'theme stylesheet' },
40
+ }
41
+
42
+ const KEYS = Object.keys(LAYOUT_ENTRIES)
43
+
44
+ /** A root-relative POSIX path for display (`apps/editor`, `.` for the root itself). */
45
+ export function displayPath(root, absolute) {
46
+ const rel = relative(root, absolute)
47
+ return rel === '' ? '.' : rel.split(sep).join('/')
48
+ }
49
+
50
+ function issue(key, code, message, fix) {
51
+ return { key, code, message, ...(fix ? { fix } : {}) }
52
+ }
53
+
54
+ /**
55
+ * PURE core: parse the root `package.json` text (or `undefined` when there is none) into
56
+ * the declared values and the SHAPE issues. Existence is checked separately
57
+ * ({@link resolveProjectLayout}) so this stays filesystem-free.
58
+ *
59
+ * Returns `{ declared: { [key]: string }, invalid: Set<key>, issues }` — a key in
60
+ * `invalid` has NO usable value (it is neither the declared one nor the default: an
61
+ * invalid declaration must never quietly turn into the scaffold path).
62
+ */
63
+ export function parseLayoutSection(root, packageJsonText) {
64
+ const declared = {}
65
+ const invalid = new Set()
66
+ const issues = []
67
+ if (packageJsonText === undefined) return { declared, invalid, issues }
68
+
69
+ let pkg
70
+ try {
71
+ pkg = JSON.parse(packageJsonText)
72
+ } catch (error) {
73
+ for (const key of KEYS) invalid.add(key)
74
+ issues.push(
75
+ issue(
76
+ null,
77
+ 'layout-package-json-invalid',
78
+ `The repo-root package.json is not valid JSON (${error.message}) — the project layout (${LAYOUT_SECTION}) cannot be read.`,
79
+ 'Fix the JSON syntax of the repo-root package.json.',
80
+ ),
81
+ )
82
+ return { declared, invalid, issues }
83
+ }
84
+
85
+ const section = pkg?.elytra
86
+ if (section === undefined) return { declared, invalid, issues }
87
+ if (section === null || typeof section !== 'object' || Array.isArray(section)) {
88
+ for (const key of KEYS) invalid.add(key)
89
+ issues.push(
90
+ issue(
91
+ null,
92
+ 'layout-invalid-section',
93
+ `${LAYOUT_SECTION} must be an object of paths ({ "studio": "./studio", … }), not ${section === null ? 'null' : Array.isArray(section) ? 'an array' : typeof section}.`,
94
+ `Make ${LAYOUT_SECTION} an object with any of ${KEYS.join(', ')} — or remove it to use the scaffold layout.`,
95
+ ),
96
+ )
97
+ return { declared, invalid, issues }
98
+ }
99
+
100
+ for (const key of Object.keys(section)) {
101
+ if (!Object.hasOwn(LAYOUT_ENTRIES, key)) {
102
+ issues.push(
103
+ issue(
104
+ key,
105
+ 'layout-unknown-key',
106
+ `${LAYOUT_SECTION} has an unknown key "${key}" — the known keys are ${KEYS.join(', ')}.`,
107
+ `Rename or remove "${key}" in ${LAYOUT_SECTION}.`,
108
+ ),
109
+ )
110
+ }
111
+ }
112
+
113
+ for (const key of KEYS) {
114
+ if (!Object.hasOwn(section, key)) continue
115
+ const value = section[key]
116
+ const entry = LAYOUT_ENTRIES[key]
117
+ const fix = `Set ${LAYOUT_SECTION}.${key} to the ${entry.label}'s path relative to the repo root (default "${entry.default}"), or remove the key to use the default.`
118
+ if (typeof value !== 'string' || value.trim() === '') {
119
+ invalid.add(key)
120
+ issues.push(
121
+ issue(key, 'layout-invalid-path', `${LAYOUT_SECTION}.${key} must be a non-empty path string, not ${JSON.stringify(value)}.`, fix),
122
+ )
123
+ continue
124
+ }
125
+ if (isAbsolute(value)) {
126
+ invalid.add(key)
127
+ issues.push(
128
+ issue(
129
+ key,
130
+ 'layout-invalid-path',
131
+ `${LAYOUT_SECTION}.${key} is the absolute path "${value}" — layout paths are relative to the repo root, so the project works wherever it is checked out.`,
132
+ fix,
133
+ ),
134
+ )
135
+ continue
136
+ }
137
+ const rel = relative(root, resolve(root, value))
138
+ if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
139
+ invalid.add(key)
140
+ issues.push(
141
+ issue(key, 'layout-invalid-path', `${LAYOUT_SECTION}.${key} ("${value}") points outside the repo root.`, fix),
142
+ )
143
+ continue
144
+ }
145
+ if (rel === '' && entry.kind === 'app') {
146
+ // The root package is the one that runs `elytra` — an app there would make
147
+ // `elytra dev` spawn itself.
148
+ invalid.add(key)
149
+ issues.push(
150
+ issue(key, 'layout-invalid-path', `${LAYOUT_SECTION}.${key} ("${value}") is the repo root itself — the ${entry.label} must be its own directory.`, fix),
151
+ )
152
+ continue
153
+ }
154
+ declared[key] = value
155
+ }
156
+ return { declared, invalid, issues }
157
+ }
158
+
159
+ /** Default filesystem probes — injectable for tests. */
160
+ const defaultFs = {
161
+ readFile: (path) => (existsSync(path) ? readFileSync(path, 'utf8') : undefined),
162
+ kindOf: (path) => {
163
+ try {
164
+ const stat = statSync(path)
165
+ return stat.isDirectory() ? 'dir' : stat.isFile() ? 'file' : 'other'
166
+ } catch {
167
+ return 'missing'
168
+ }
169
+ },
170
+ }
171
+
172
+ /**
173
+ * Resolve a project's layout: the declared-or-default path of every entry, whether it
174
+ * exists, and every issue. Never throws.
175
+ *
176
+ * Returns
177
+ * {
178
+ * source: 'declared' | 'defaults', // was anything declared in package.json#elytra?
179
+ * entries: { studio, frontend, theme }, each
180
+ * { key, path: <absolute> | null, display: 'apps/editor' | null,
181
+ * declared: boolean, found: boolean },
182
+ * issues: [{ key, code, message, fix? }],
183
+ * }
184
+ *
185
+ * `path` is `null` only for an INVALID declaration — the entry is then unusable, never
186
+ * the default. A DECLARED path that does not exist (or a theme that is not a file) is an
187
+ * issue; a DEFAULT path that does not exist is not (a frontend-only or studio-only repo
188
+ * is legitimate) — it is simply `found: false`.
189
+ */
190
+ export function resolveProjectLayout(root, fs = defaultFs) {
191
+ const { declared, invalid, issues } = parseLayoutSection(root, fs.readFile(join(root, 'package.json')))
192
+ const entries = {}
193
+ for (const key of KEYS) {
194
+ const entry = LAYOUT_ENTRIES[key]
195
+ if (invalid.has(key)) {
196
+ entries[key] = { key, path: null, display: null, declared: true, found: false }
197
+ continue
198
+ }
199
+ const isDeclared = Object.hasOwn(declared, key)
200
+ const path = resolve(root, isDeclared ? declared[key] : entry.default)
201
+ const display = displayPath(root, path)
202
+ let found
203
+ if (entry.kind === 'app') {
204
+ found = fs.kindOf(join(path, 'package.json')) === 'file'
205
+ if (isDeclared && !found) {
206
+ issues.push(
207
+ issue(
208
+ key,
209
+ 'layout-path-missing',
210
+ fs.kindOf(path) === 'missing'
211
+ ? `${LAYOUT_SECTION}.${key} is "${declared[key]}", but ${display} does not exist.`
212
+ : `${LAYOUT_SECTION}.${key} is "${declared[key]}", but ${display} has no package.json — it is not an app.`,
213
+ `Point ${LAYOUT_SECTION}.${key} at the ${entry.label}'s directory (the one with its package.json), relative to the repo root.`,
214
+ ),
215
+ )
216
+ }
217
+ } else {
218
+ const kind = fs.kindOf(path)
219
+ found = kind === 'file'
220
+ if (isDeclared && kind === 'missing') {
221
+ issues.push(
222
+ issue(
223
+ key,
224
+ 'layout-path-missing',
225
+ `${LAYOUT_SECTION}.${key} is "${declared[key]}", but ${display} does not exist.`,
226
+ `Point ${LAYOUT_SECTION}.${key} at the ${entry.label} file, relative to the repo root.`,
227
+ ),
228
+ )
229
+ } else if (isDeclared && kind !== 'file') {
230
+ issues.push(
231
+ issue(
232
+ key,
233
+ 'layout-not-a-file',
234
+ `${LAYOUT_SECTION}.${key} is "${declared[key]}", but ${display} is ${kind === 'dir' ? 'a directory' : 'not a regular file'} — the theme is one stylesheet.`,
235
+ `Point ${LAYOUT_SECTION}.${key} at the theme's entry stylesheet (e.g. "./components/theme.css").`,
236
+ ),
237
+ )
238
+ }
239
+ }
240
+ entries[key] = { key, path, display, declared: isDeclared, found }
241
+ }
242
+ const source = KEYS.some((key) => entries[key].declared) ? 'declared' : 'defaults'
243
+ return { source, entries, issues }
244
+ }
245
+
246
+ /**
247
+ * The root-relative display path of an entry for prose and fix lines (`apps/editor`,
248
+ * `studio`) — the resolved one, or the scaffold default's when no layout was resolved
249
+ * (an invalid declaration, or a hand-built project object). Display only: no command
250
+ * ever SPAWNS or READS from this fallback.
251
+ */
252
+ export function appDisplay(layout, key) {
253
+ return layout?.entries?.[key]?.display ?? LAYOUT_ENTRIES[key].default.replace(/^\.\//, '')
254
+ }
255
+
256
+ // --- Doctor row (pure) ---------------------------------------------------------------
257
+
258
+ /**
259
+ * The doctor's `project-layout` row for the `app` bucket: what the CLI resolved and where
260
+ * from, so "which studio did it look at?" is never a guess. `fail` on any layout issue
261
+ * (an invalid section or a declared path that does not exist — both deterministic in the
262
+ * repo, so failing the exit code is safe for CI); otherwise `ok`, listing each location
263
+ * and whether it was declared or defaulted.
264
+ */
265
+ export function layoutDoctorChecks(layout) {
266
+ if (!layout) return []
267
+ const base = { id: 'project-layout', bucket: 'app' }
268
+ if (layout.issues.length > 0) {
269
+ return [
270
+ {
271
+ ...base,
272
+ status: 'fail',
273
+ detail: `${layout.issues.length} problem(s) with the project layout. ${layout.issues.map((i) => i.message).join(' ')}`,
274
+ fix: layout.issues.find((i) => i.fix)?.fix ?? `Fix ${LAYOUT_SECTION} in the repo-root package.json.`,
275
+ },
276
+ ]
277
+ }
278
+ const parts = KEYS.map((key) => {
279
+ const entry = layout.entries[key]
280
+ const where = entry.found ? entry.display : `${entry.display} (not present)`
281
+ return `${key} → ${where}`
282
+ })
283
+ const origin =
284
+ layout.source === 'declared'
285
+ ? `declared in ${LAYOUT_SECTION} (undeclared keys use the scaffold defaults)`
286
+ : `the scaffold defaults — nothing declared in ${LAYOUT_SECTION}`
287
+ return [{ ...base, status: 'ok', detail: `${parts.join(', ')} — ${origin}.` }]
288
+ }
package/src/previews.js CHANGED
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync
3
3
  import { dirname, isAbsolute, join, relative, sep } from 'node:path'
4
4
  import { pathToFileURL } from 'node:url'
5
5
  import { CONFIG_FILE, pickPort, resolveFromDir, startConsumerVite, virtualModulesPlugin } from './config-harness.js'
6
+ import { appDisplay, resolveProjectLayout } from './layout.js'
6
7
 
7
8
  /**
8
9
  * `elytra previews` (EC-440) — build-time fixture-preview generation.
@@ -561,9 +562,10 @@ export function previewsDoctorChecks(artifacts) {
561
562
  * The engine version for the input hash — read from the `@elytracms/studio`
562
563
  * (fallback `@elytracms/core`) package the CONSUMER has installed, never
563
564
  * hardcoded. Direct node_modules paths (pnpm keeps symlinks for direct deps).
565
+ * `studioDir` is the resolved studio app (EC-603 layout).
564
566
  */
565
- export function resolveEngineVersion(root) {
566
- const bases = [join(root, 'studio'), root]
567
+ export function resolveEngineVersion(root, studioDir) {
568
+ const bases = [studioDir, root]
567
569
  for (const pkg of ['@elytracms/studio', '@elytracms/core']) {
568
570
  for (const base of bases) {
569
571
  const path = join(base, 'node_modules', ...pkg.split('/'), 'package.json')
@@ -581,16 +583,26 @@ export function resolveEngineVersion(root) {
581
583
 
582
584
  /**
583
585
  * The consumer theme entry the harness page imports: the studio's canvas
584
- * stylesheet (`studio/src/styles/canvas.css` — the EC-414 shape: the site
585
- * theme plus any engine-required `@source` lines) when present, else the
586
- * shared theme entry itself (`components/theme.css`). `null` → no theme, the
587
- * command refuses (an unstyled preview would be a lying artifact).
586
+ * stylesheet (`<studio>/src/styles/canvas.css` — the EC-414 shape: the site
587
+ * theme plus any engine-required `@source` lines; the stylesheet the canvas
588
+ * iframe really links) when present, else the theme itself — `package.json#elytra
589
+ * .theme`, default `components/theme.css` (EC-603: the project's declaration, never
590
+ * a fixed path). `null` → no theme, the command refuses (an unstyled preview would
591
+ * be a lying artifact).
592
+ *
593
+ * The canvas stylesheet stays first so a conventional project's recorded render
594
+ * dependencies (its committed manifest) are byte-for-byte unchanged by EC-603. It is
595
+ * looked up relative to the RESOLVED studio app; a project without one falls back to
596
+ * the declared theme, so it is a preference, never a requirement.
588
597
  */
589
- export function themeStylesheetPath(root) {
590
- for (const candidate of [
591
- join(root, 'studio', 'src', 'styles', 'canvas.css'),
592
- join(root, 'components', 'theme.css'),
593
- ]) {
598
+ export function themeStylesheetPath(root, layout = resolveProjectLayout(root)) {
599
+ const studio = layout.entries.studio
600
+ const theme = layout.entries.theme
601
+ const candidates = [
602
+ ...(studio.path ? [join(studio.path, 'src', 'styles', 'canvas.css')] : []),
603
+ ...(theme.path ? [theme.path] : []),
604
+ ]
605
+ for (const candidate of candidates) {
594
606
  if (existsSync(candidate)) return candidate
595
607
  }
596
608
  return null
@@ -652,7 +664,7 @@ function harnessPlugins({ configPath, themePath }) {
652
664
  }
653
665
 
654
666
  /** Boot the shared consumer-Vite harness with the previews page + modules. */
655
- async function startHarnessServer({ root, studioDir, port }) {
667
+ async function startHarnessServer({ root, studioDir, layout, port }) {
656
668
  // Resolve vite first so a missing toolchain names vite (the same failure
657
669
  // order as before the boot moved to the shared config-harness).
658
670
  resolveFromDir(studioDir, 'vite')
@@ -660,11 +672,13 @@ async function startHarnessServer({ root, studioDir, port }) {
660
672
  const tailwind = (await import(pathToFileURL(tailwindEntry).href)).default
661
673
 
662
674
  const configPath = join(root, 'elytra.config.ts')
663
- const themePath = themeStylesheetPath(root)
675
+ const themePath = themeStylesheetPath(root, layout)
664
676
  if (!themePath) {
677
+ const studio = appDisplay(layout, 'studio')
678
+ const theme = appDisplay(layout, 'theme')
665
679
  throw new Error(
666
- 'No theme stylesheet found (looked for studio/src/styles/canvas.css and components/theme.css) — ' +
667
- 'previews must render against the consumer theme.',
680
+ `No theme stylesheet found (looked for ${studio}/src/styles/canvas.css and ${theme} — ` +
681
+ 'declare yours as package.json#elytra.theme) — previews must render against the consumer theme.',
668
682
  )
669
683
  }
670
684
 
@@ -708,8 +722,8 @@ const PLAYWRIGHT_INSTALL_HELP = [
708
722
  ].join('\n')
709
723
 
710
724
  /** Dynamic, on-demand Playwright load from the consumer repo. `null` = not installed. */
711
- async function loadPlaywright(root) {
712
- for (const base of [join(root, 'studio'), root]) {
725
+ async function loadPlaywright(root, studioDir) {
726
+ for (const base of [studioDir, root]) {
713
727
  let resolved
714
728
  try {
715
729
  resolved = resolveFromDir(base, 'playwright')
@@ -795,8 +809,11 @@ async function captureViewport(page, cdp) {
795
809
  */
796
810
  export async function runPreviews(project, { check = false, port, log = (l) => console.log(l) } = {}) {
797
811
  const root = project.root
798
- const studioDir = join(root, 'studio')
799
- const engine = resolveEngineVersion(root)
812
+ // EC-603: the studio app and theme where the project's layout says they are.
813
+ const layout = project.layout ?? resolveProjectLayout(root)
814
+ const studioDir = layout.entries.studio.path
815
+ const studioLabel = layout.entries.studio.display
816
+ const engine = resolveEngineVersion(root, studioDir)
800
817
  const manifestPath = join(studioDir, PREVIEWS_MANIFEST_FILE)
801
818
  const imagesDir = join(studioDir, PREVIEWS_DIR)
802
819
 
@@ -807,7 +824,7 @@ export async function runPreviews(project, { check = false, port, log = (l) => c
807
824
  const chosenPort = port ? Number(port) : await pickPort()
808
825
  let harness
809
826
  try {
810
- harness = await startHarnessServer({ root, studioDir, port: chosenPort })
827
+ harness = await startHarnessServer({ root, studioDir, layout, port: chosenPort })
811
828
  } catch (error) {
812
829
  log(`Could not start the render harness: ${error.message}`)
813
830
  return 1
@@ -863,7 +880,7 @@ export async function runPreviews(project, { check = false, port, log = (l) => c
863
880
 
864
881
  const failures = []
865
882
  if (toRender.length > 0) {
866
- const playwright = await loadPlaywright(root)
883
+ const playwright = await loadPlaywright(root, studioDir)
867
884
  if (!playwright) {
868
885
  log(PLAYWRIGHT_INSTALL_HELP)
869
886
  return 1
@@ -933,7 +950,7 @@ export async function runPreviews(project, { check = false, port, log = (l) => c
933
950
  log(
934
951
  `Done: ${rendered} rendered, ${jobs.length - toRender.length} unchanged, ` +
935
952
  `${orphans.length} pruned, manifest ${manifestChanged ? 'written' : 'unchanged'} ` +
936
- `(studio/${PREVIEWS_MANIFEST_FILE}).`,
953
+ `(${studioLabel}/${PREVIEWS_MANIFEST_FILE}).`,
937
954
  )
938
955
  if (failures.length > 0) {
939
956
  log(`✗ ${failures.length} fixture(s) failed to render — their entries were left out of the manifest.`)