@elytracms/cli 0.0.87 → 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
@@ -1,17 +1,22 @@
1
1
  import { readFileSync } from 'node:fs'
2
2
  import { createRequire } from 'node:module'
3
3
  import { dirname, join } from 'node:path'
4
+ import { orphanedCollections } from '@elytracms/core/cms-core'
4
5
  import {
5
6
  REVALIDATE_ENV_ENDPOINT,
6
7
  REVALIDATE_ENV_SECRET,
7
8
  backendSiteUrl,
9
+ backendUrlOf,
8
10
  checkDeploymentReady,
9
11
  expectedDeploymentEnv,
12
+ parseBackendMode,
10
13
  probeAuthSigningKeys,
14
+ probeDeliverySnapshot,
11
15
  probeDeployedFunctionsVersion,
12
16
  probeRevalidateEndpoint,
13
17
  } from '@elytracms/core/export-sync/deployment-ready'
14
- import { executeWorkspaceConfig } from './config-harness.js'
18
+ import { aiRouteDoctorChecks, executeWorkspaceConfig, readStudioAiRoute } from './config-harness.js'
19
+ import { appDisplay, layoutDoctorChecks } from './layout.js'
15
20
  import { convexEnvGet, defaultConvexExec } from './provision.js'
16
21
  import { gatherPreviewsArtifacts, previewsDoctorChecks } from './previews.js'
17
22
  import { loadProjectRoutes, routesDoctorChecks } from './routes-check.js'
@@ -38,17 +43,29 @@ import { loadProjectRoutes, routesDoctorChecks } from './routes-check.js'
38
43
  /**
39
44
  * Decide which deployment the doctor diagnoses from the resolved studio env.
40
45
  *
41
- * A configured `VITE_CONVEX_URL` is preferred over the `VITE_ELYTRA_MODE=playground`
42
- * run-mode flag: that flag only selects the studio's in-memory *adapter*, whereas the
43
- * 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
44
54
  * playground locally but points at a Convex deployment (e.g. the demo) is diagnosed
45
- * 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`).
46
59
  */
47
- export function resolveDoctorTarget(env = {}, { url } = {}) {
60
+ export function resolveDoctorTarget(env = {}, { url, envSource = 'studio/.env' } = {}) {
48
61
  const explicit = url && url.trim() !== '' ? url.trim() : undefined
49
- 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)
50
67
  const chosenUrl = explicit || envUrl
51
- const playgroundFlag = env.VITE_ELYTRA_MODE === 'playground'
68
+ const playgroundFlag = backend.kind === 'playground'
52
69
 
53
70
  if (chosenUrl) {
54
71
  return {
@@ -65,7 +82,7 @@ export function resolveDoctorTarget(env = {}, { url } = {}) {
65
82
  if (playgroundFlag) return { kind: 'playground' }
66
83
  return {
67
84
  kind: 'unconfigured',
68
- 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}.`,
69
86
  }
70
87
  }
71
88
 
@@ -201,7 +218,7 @@ export function configFactsFromOutcome(outcome) {
201
218
  issueCount: issues.length,
202
219
  ...(firstIssue ? { firstIssue: formatConfigIssue(firstIssue) } : {}),
203
220
  }
204
- const project = valid ? result.projects?.[0] : undefined
221
+ const project = resolvedProjectOf(outcome)
205
222
  const collections = project?.collections
206
223
  // EC-524: the `componentIds` fact was gathered here for EC-508's presentation
207
224
  // check, which resolved a collection's declared `presentation.wrapper` against
@@ -216,6 +233,60 @@ export function configFactsFromOutcome(outcome) {
216
233
  }
217
234
  }
218
235
 
236
+ /**
237
+ * The project a resolved config outcome describes — the first one, the same choice
238
+ * {@link configFactsFromOutcome} makes for `collections` — or `undefined` when the
239
+ * config did not resolve validly (or was never evaluated). Pure.
240
+ */
241
+ export function resolvedProjectOf(outcome) {
242
+ if (!outcome || outcome.kind !== 'resolved' || outcome.result?.ok !== true) return undefined
243
+ return outcome.result.projects?.[0]
244
+ }
245
+
246
+ // --- Published orphans (EC-606) -----------------------------------------------------
247
+ //
248
+ // A published document whose collection the config does not declare is still on the
249
+ // public delivery snapshot, but no route or query addresses it and the studio hides it.
250
+ // The doctor reads the snapshot (public, published-only — what every visitor receives)
251
+ // and runs core's ONE counter (`orphanedCollections`, the same one the studio's session
252
+ // hydration runs) against the resolved config's collection ids.
253
+
254
+ /**
255
+ * Gather the `publishedOrphans` fact for a convex target, or `undefined` for any other
256
+ * target (no deployment, no snapshot). Without a declared set — the config did not
257
+ * resolve here — the fact is an honest `unknown` carrying why, and no request is made.
258
+ * `project` is {@link resolvedProjectOf}'s answer; `configFacts` is what
259
+ * {@link configFactsFromOutcome} returned (for the reason text).
260
+ */
261
+ export async function gatherPublishedOrphans({
262
+ target,
263
+ project,
264
+ configFacts = {},
265
+ fetchImpl = fetch,
266
+ probe = probeDeliverySnapshot,
267
+ } = {}) {
268
+ if (target?.kind !== 'convex') return undefined
269
+ if (!project || !Array.isArray(project.collections)) {
270
+ const reason =
271
+ configFacts.config !== undefined && !configFacts.config.valid
272
+ ? 'fix the workspace config issues first (see config-valid).'
273
+ : (configFacts.configUnavailableDetail ?? 'elytra.config.ts was not evaluated here.')
274
+ return {
275
+ status: 'unknown',
276
+ detail: `Published documents were not checked against the declared collections — ${reason}`,
277
+ }
278
+ }
279
+ const snapshot = await probe(target.url, project.slug, fetchImpl)
280
+ if (snapshot.status !== 'answered') return { status: 'unknown', detail: snapshot.detail }
281
+ return {
282
+ status: 'counted',
283
+ orphans: orphanedCollections(
284
+ snapshot.collections.map((collection) => ({ collection, published: true })),
285
+ project.collections.map((collection) => collection.id),
286
+ ),
287
+ }
288
+ }
289
+
219
290
  // --- Deployment env-var presence (EC-399) --------------------------------------
220
291
  //
221
292
  // The Convex deployment's own env (`npx convex env set …`) lives in NO file a
@@ -289,7 +360,7 @@ export function gatherDeploymentEnvFacts({ env = {}, target, studioDir, aiRouteC
289
360
  facts.unavailableDetail =
290
361
  '--url overrides the linked deployment; env names are only read from the deployment this repo is linked to.'
291
362
  } else if (!studioDir) {
292
- facts.unavailableDetail = 'No studio/ app found — deployment env names not enumerated.'
363
+ facts.unavailableDetail = 'No studio app found — deployment env names not enumerated.'
293
364
  } else if (!env.CONVEX_DEPLOYMENT && !env.CONVEX_DEPLOY_KEY) {
294
365
  facts.unavailableDetail =
295
366
  'No linked deployment (CONVEX_DEPLOYMENT / CONVEX_DEPLOY_KEY unset) — `npx convex env list` needs one.'
@@ -304,10 +375,10 @@ export function gatherDeploymentEnvFacts({ env = {}, target, studioDir, aiRouteC
304
375
  // --- Signed revalidate ping (EC-421) -------------------------------------------
305
376
  //
306
377
  // When the presence checks say the instant-publish pair IS set, the doctor proves the
307
- // chain end to end: a POST to the endpoint signed with the secret, carrying an EMPTY
308
- // tag list — by the receiving route's own semantics a harmless no-op answering 200
309
- // `{"revalidated":[]}`. Default-on (no flag): the ping is read-only in effect and the
310
- // 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
311
382
  // forever, no error anywhere) is exactly what the doctor exists for.
312
383
  //
313
384
  // The ping needs the pair's VALUES — the one sanctioned value read: captured
@@ -403,6 +474,29 @@ export function formatReport(report) {
403
474
  return lines.join('\n')
404
475
  }
405
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
+
406
500
  // --- Command -----------------------------------------------------------------
407
501
 
408
502
  /**
@@ -441,6 +535,24 @@ export function formatReport(report) {
441
535
  * version and the `functions-version` check gets a real answer; otherwise the fact is
442
536
  * omitted → an honest `unknown`. Resolution stays at the entry point so this function
443
537
  * remains pure/injected like every other gatherer here.
538
+ *
539
+ * EC-606: against a convex target the `app` bucket also reports PUBLISHED ORPHANS —
540
+ * published documents whose collection the resolved config does not declare — read
541
+ * from the public delivery snapshot ({@link gatherPublishedOrphans}). A `warn` at most,
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.
444
556
  */
445
557
  export async function runDoctor({
446
558
  env = {},
@@ -449,14 +561,17 @@ export async function runDoctor({
449
561
  url,
450
562
  studioDir,
451
563
  root,
452
- aiRouteConfigured = false,
564
+ layout,
565
+ aiRoute: aiRouteOutcome,
566
+ aiRouteConfigured,
453
567
  exec,
454
568
  previewsArtifacts,
455
569
  routeFacts,
456
570
  configOutcome,
457
571
  installedEngineVersion,
458
572
  } = {}) {
459
- const target = resolveDoctorTarget(env, { url })
573
+ const studioLabel = appDisplay(layout, 'studio')
574
+ const target = resolveDoctorTarget(env, { url, envSource: `${studioLabel}/.env` })
460
575
 
461
576
  log('Elytra deployment doctor')
462
577
  if (target.kind === 'convex') {
@@ -475,8 +590,14 @@ export async function runDoctor({
475
590
  // entry resolves it via `resolveInstalledEngineVersion`; runDoctor itself never
476
591
  // touches the filesystem for it, staying pure/injected like every gatherer here).
477
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
+
478
599
  if (target.kind === 'convex') {
479
- facts.deploymentEnv = gatherDeploymentEnvFacts({ env, target, studioDir, aiRouteConfigured, exec })
600
+ facts.deploymentEnv = gatherDeploymentEnvFacts({ env, target, studioDir, aiRouteConfigured: aiExpected, exec })
480
601
  // EC-421: default-on signed no-op ping whenever the revalidate pair is present.
481
602
  const revalidateProbe = await gatherRevalidateProbe({
482
603
  envFacts: facts.deploymentEnv,
@@ -491,12 +612,23 @@ export async function runDoctor({
491
612
  // (announced by the harness itself — the boot costs seconds). Merged BEFORE the
492
613
  // contract composes the report so `config-valid`/`asset-collection` are real checks.
493
614
  let configFacts = {}
615
+ let outcome
494
616
  if (configOutcome !== undefined || root !== undefined) {
495
- const outcome = configOutcome ?? (await executeWorkspaceConfig({ root, studioDir, log }))
617
+ outcome = configOutcome ?? (await executeWorkspaceConfig({ root, studioDir, log }))
496
618
  configFacts = configFactsFromOutcome(outcome)
497
619
  Object.assign(facts, configFacts)
498
620
  }
499
621
 
622
+ // EC-606: published documents of undeclared collections — one more request on the
623
+ // same public query API, made only when the config resolved (it needs the declared set).
624
+ const publishedOrphans = await gatherPublishedOrphans({
625
+ target,
626
+ project: resolvedProjectOf(outcome),
627
+ configFacts,
628
+ fetchImpl,
629
+ })
630
+ if (publishedOrphans) facts.publishedOrphans = publishedOrphans
631
+
500
632
  const report = checkDeploymentReady(facts)
501
633
 
502
634
  // EC-440: the previews bucket (repo artifacts, backend-independent). Appended to
@@ -505,14 +637,24 @@ export async function runDoctor({
505
637
  const artifacts = previewsArtifacts ?? (studioDir ? gatherPreviewsArtifacts(studioDir) : undefined)
506
638
  let checks = artifacts ? [...report.checks, ...previewsDoctorChecks(artifacts)] : [...report.checks]
507
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
+
508
646
  // EC-469: the route-conflict check, spliced into the `app` bucket it belongs to.
509
647
  // Backend-independent (routes are CODE) — it runs in playground mode too.
510
- // EC-495: a resolved config supersedes the `cms/index.ts` collections re-export —
511
- // 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`).
512
652
  if (root !== undefined || routeFacts !== undefined) {
513
- const loaded = routeFacts ?? (await loadProjectRoutes(root))
514
- const enriched = configFacts.collections ? { ...loaded, collections: configFacts.collections } : loaded
515
- checks = spliceBucketChecks(checks, 'app', routesDoctorChecks(enriched))
653
+ checks = spliceBucketChecks(
654
+ checks,
655
+ 'app',
656
+ routesDoctorChecks(await routeFactsFor({ outcome, configFacts, routeFacts, root })),
657
+ )
516
658
  }
517
659
 
518
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
+ }