@elytracms/cli 0.0.88 → 0.0.91

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.`)
package/src/project.js CHANGED
@@ -1,20 +1,36 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
2
  import { dirname, join } from 'node:path'
3
+ import { parseBackendMode } from '@elytracms/core/export-sync/deployment-ready'
4
+ import { appDisplay, resolveProjectLayout } from './layout.js'
3
5
 
4
6
  /**
5
- * Project detection for the Elytra CLI (EC-235). Convention-aware, not config-
6
- * executing: a consuming repo's layout (`studio/`, `frontend/`, root
7
- * `elytra.config.ts`) and its `.env` are the source of truth — the same `.env`
8
- * the studio itself reads to resolve its backend (`VITE_ELYTRA_MODE` /
9
- * `VITE_CONVEX_URL`). Detection deliberately does NOT load the config: it must work
10
- * before `pnpm install`, the paths are conventional, and the operative backend signal
11
- * lives in env regardless. (Commands that need the config's CONTENTS execute it
12
- * through the consumer's own Vite instead — `config-harness.js`, EC-440/EC-495 — and
13
- * degrade honestly when that is unavailable. Node-native TS is not an option: real
14
- * configs import `.tsx` barrels, EC-474.)
7
+ * Project detection for the Elytra CLI (EC-235). Declaration-aware, not config-
8
+ * executing: the repo root is the directory holding `elytra.config.ts`; the LAYOUT —
9
+ * where the studio app, the frontend app and the theme live — is declared in the
10
+ * `package.json` beside it (`"elytra": { studio, frontend, theme }`, EC-603), every key
11
+ * defaulting to the scaffold layout (`./studio`, `./frontend`,
12
+ * `./components/theme.css`) — see `layout.js`. The backend mode comes from the studio
13
+ * app's env files, judged by the ONE parser the studio itself runs (core's
14
+ * `parseBackendMode`, EC-603).
15
+ *
16
+ * Detection deliberately does NOT load the config: it must work before `pnpm install`
17
+ * of the consuming repo (the CLI's own dependency, `@elytracms/core`, is installed WITH
18
+ * the CLI — this module imports exactly two of its built files, the bundler-free
19
+ * `export-sync/deployment-ready` and the parser it re-exports), and the operative
20
+ * backend signal lives in env regardless. (Commands that need the config's CONTENTS
21
+ * execute it through the consumer's own Vite instead — `config-harness.js`,
22
+ * EC-440/EC-495 — and degrade honestly when that is unavailable. Node-native TS is not
23
+ * an option: real configs import `.tsx` barrels, EC-474.)
15
24
  */
16
25
 
17
- /** Walk up from `start` until a directory containing `elytra.config.ts` is found. */
26
+ /**
27
+ * Walk up from `start` until a directory containing `elytra.config.ts` is found.
28
+ *
29
+ * EC-603 kept this key: the config is the one file Elytra requires of a repo and it
30
+ * stays at the root, while the layout section is OPTIONAL (defaults declare nothing) —
31
+ * so a `package.json` can never mark the root on its own, and the studio/frontend apps'
32
+ * own `package.json`s sit on the walk up from inside them.
33
+ */
18
34
  export function findRepoRoot(start) {
19
35
  let dir = start
20
36
  for (;;) {
@@ -25,7 +41,14 @@ export function findRepoRoot(start) {
25
41
  }
26
42
  }
27
43
 
28
- /** Parse a dotenv file into a flat record. Missing file → {}. */
44
+ /**
45
+ * Parse a dotenv file into a flat record. Missing file → {}.
46
+ *
47
+ * Deliberately small, and NOT Vite's dotenv: no variable expansion, and an inline
48
+ * ` #…` is stripped even inside quotes. Values the backend parser reads are trimmed by
49
+ * the parser itself, which covers the measured P21 case (quoted padding); the remaining
50
+ * dotenv-dialect differences are recorded in EC-603's Done section.
51
+ */
29
52
  function readEnvFile(path) {
30
53
  if (!existsSync(path)) return {}
31
54
  const out = {}
@@ -43,26 +66,51 @@ function readEnvFile(path) {
43
66
  return out
44
67
  }
45
68
 
46
- /** Merge a package's `.env` then `.env.local` (local wins), then process.env. */
47
- function resolveEnv(appDir, processEnv) {
48
- return {
49
- ...readEnvFile(join(appDir, '.env')),
50
- ...readEnvFile(join(appDir, '.env.local')),
51
- ...processEnv,
69
+ /**
70
+ * The env files Vite loads for `mode`, lowest precedence first — the studio's own
71
+ * toolchain's file set (`vite dev` = `development`, `vite build` = `production`), so the
72
+ * CLI reads exactly the files the studio it starts will read (EC-603: a mode set only in
73
+ * `.env.development.local` used to be invisible here).
74
+ */
75
+ export function viteEnvFiles(mode) {
76
+ return ['.env', '.env.local', `.env.${mode}`, `.env.${mode}.local`]
77
+ }
78
+
79
+ /** Merge an app's env files (Vite's order: later wins), then process.env over them. */
80
+ function resolveEnv(appDir, processEnv, viteMode) {
81
+ let merged = {}
82
+ if (appDir) {
83
+ for (const file of viteEnvFiles(viteMode)) merged = { ...merged, ...readEnvFile(join(appDir, file)) }
52
84
  }
85
+ return { ...merged, ...processEnv }
53
86
  }
54
87
 
55
88
  /**
56
- * Resolve the studio app's effective env (`studio/.env` ← `.env.local` ← process.env) —
57
- * the same signal the studio itself reads. Used by `elytra doctor` to find the Convex
58
- * deployment to diagnose. Exported so the doctor and project inspection share one reader.
89
+ * Resolve the studio app's effective env (its env files for `viteMode` ← process.env) —
90
+ * the same signal the studio itself reads. Used by `elytra doctor` / `elytra init` to
91
+ * find the Convex deployment to diagnose. `rootOrProject` is the repo root (the layout
92
+ * is resolved from it) or an {@link inspectProject} result (its resolved layout is
93
+ * used). Exported so the doctor and project inspection share one reader.
59
94
  */
60
- export function resolveStudioEnv(root, processEnv = {}) {
61
- return resolveEnv(join(root, 'studio'), processEnv)
95
+ export function resolveStudioEnv(rootOrProject, processEnv = {}, { viteMode = 'development' } = {}) {
96
+ const layout =
97
+ typeof rootOrProject === 'string' ? resolveProjectLayout(rootOrProject) : rootOrProject.layout
98
+ const studio = layout.entries.studio
99
+ return resolveEnv(studio.found ? studio.path : null, processEnv, viteMode)
62
100
  }
63
101
 
64
- function isApp(root, name) {
65
- return existsSync(join(root, name, 'package.json'))
102
+ /**
103
+ * The CLI's vocabulary for the ONE parser's verdict (EC-603) — the single place the two
104
+ * vocabularies meet. The CLI keeps `connected` / `unconfigured` (its user-facing words:
105
+ * "connected mode", "Backend not configured") over the studio's `convex` /
106
+ * `misconfigured`; the mapping is 1:1, and an `unconfigured` project carries the
107
+ * parser's verdict (`project.backend`) so its notes can say WHY — a missing URL, or an
108
+ * unknown mode the studio will refuse to boot with.
109
+ */
110
+ export function cliModeOf(backend) {
111
+ if (backend.kind === 'convex') return 'connected'
112
+ if (backend.kind === 'playground') return 'playground'
113
+ return 'unconfigured'
66
114
  }
67
115
 
68
116
  /**
@@ -113,21 +161,29 @@ export function resolveDevPortOffset(env = {}) {
113
161
 
114
162
  /**
115
163
  * Inspect a consuming repo. Returns:
116
- * { root, apps: {studio, frontend}, mode, convexProvisioned, isProdDeployment,
117
- * hasDeployKey, hasAiRoute, devPortOffset, issues }
118
- * `issues` are actionable, human-readable prerequisite problems (empty = ok).
164
+ * { root, layout, apps: {studio, frontend}, backend, mode, convexProvisioned,
165
+ * isProdDeployment, hasDeployKey, devPortOffset, issues }
166
+ * `layout` is the resolved `package.json#elytra` layout (`layout.js`); `backend` is the
167
+ * shared parser's verdict and `mode` its CLI name ({@link cliModeOf}). `issues` are
168
+ * actionable, human-readable prerequisite problems (empty = ok) — an invalid layout
169
+ * section or a declared path that does not exist is one of them, so no command runs
170
+ * against a layout the project did not mean.
171
+ *
172
+ * `viteMode` selects the studio env files read (Vite's set for that mode): `elytra build`
173
+ * runs `vite build`, so it inspects `production`; everything else `development`.
119
174
  */
120
- export function inspectProject(cwd, processEnv = {}) {
175
+ export function inspectProject(cwd, processEnv = {}, { viteMode = 'development' } = {}) {
121
176
  const root = findRepoRoot(cwd)
122
177
  if (!root) {
123
178
  return {
124
179
  root: null,
180
+ layout: null,
125
181
  apps: { studio: false, frontend: false },
182
+ backend: null,
126
183
  mode: 'unknown',
127
184
  convexProvisioned: false,
128
185
  isProdDeployment: false,
129
186
  hasDeployKey: false,
130
- hasAiRoute: false,
131
187
  devPortOffset: 0,
132
188
  issues: [
133
189
  'No elytra.config.ts found in this directory or any parent. Run `elytra` from inside an Elytra project (or scaffold one with `npm create elytra@latest`).',
@@ -135,23 +191,27 @@ export function inspectProject(cwd, processEnv = {}) {
135
191
  }
136
192
  }
137
193
 
138
- const apps = { studio: isApp(root, 'studio'), frontend: isApp(root, 'frontend') }
139
- const issues = []
140
- if (!apps.studio && !apps.frontend) {
141
- issues.push('No `studio/` or `frontend/` app found next to elytra.config.ts.')
194
+ const layout = resolveProjectLayout(root)
195
+ const apps = { studio: layout.entries.studio.found, frontend: layout.entries.frontend.found }
196
+ const issues = layout.issues.map((i) => i.message)
197
+ if (layout.issues.length === 0 && !apps.studio && !apps.frontend) {
198
+ issues.push(
199
+ `No studio or frontend app found (looked for ${appDisplay(layout, 'studio')}/package.json and ` +
200
+ `${appDisplay(layout, 'frontend')}/package.json` +
201
+ (layout.source === 'defaults' ? ' — the scaffold layout; declare yours in package.json#elytra).' : ').'),
202
+ )
142
203
  }
143
204
  if (!existsSync(join(root, 'node_modules'))) {
144
205
  issues.push('Dependencies are not installed. Run `pnpm install` first.')
145
206
  }
146
207
 
147
- // Backend mode is read from the studio app's env, mirroring the studio's own
148
- // resolveBackendConfig: explicit playground flag wins; otherwise a Convex URL
149
- // means connected; neither means it still needs configuring.
150
- const studioEnv = apps.studio ? resolveEnv(join(root, 'studio'), processEnv) : processEnv
151
- let mode
152
- if (studioEnv.VITE_ELYTRA_MODE === 'playground') mode = 'playground'
153
- else if (studioEnv.VITE_CONVEX_URL) mode = 'connected'
154
- else mode = 'unconfigured'
208
+ // Backend mode is read from the studio app's env through the ONE parser the studio
209
+ // itself runs (EC-603): explicit playground flag wins; an unknown mode is a visible
210
+ // misconfiguration; otherwise a Convex URL means connected; neither means it still
211
+ // needs configuring.
212
+ const studioEnv = resolveEnv(apps.studio ? layout.entries.studio.path : null, processEnv, viteMode)
213
+ const backend = parseBackendMode(studioEnv)
214
+ const mode = cliModeOf(backend)
155
215
 
156
216
  // A Convex dev/deploy step only makes sense once a deployment is provisioned
157
217
  // (`npx convex dev` writes CONVEX_DEPLOYMENT). Otherwise we skip it and say so.
@@ -165,14 +225,11 @@ export function inspectProject(cwd, processEnv = {}) {
165
225
  // so `elytra dev` and the bare `elytra build` are unchanged by its presence.
166
226
  const hasDeployKey = Boolean(studioEnv.CONVEX_DEPLOY_KEY)
167
227
 
168
- // Is the studio's AI route present? (EC-399 — drives the AI provider-key env
169
- // expectation in `elytra doctor`.) Convention-aware like everything here: the
170
- // scaffold mounts the handler at `studio/src/server/ai-handler.ts` (the
171
- // `createAiDevPlugin` default path); we never execute config to find out.
172
- const hasAiRoute = apps.studio && existsSync(join(root, 'studio', 'src', 'server', 'ai-handler.ts'))
228
+ // EC-603: the AI route is no longer guessed from a fixed file path here — `elytra
229
+ // doctor` reads it from the studio's resolved Vite config (`config-harness.js`).
173
230
 
174
231
  // EC-464: the one dev-port lever, read from the same studio env as everything else here.
175
232
  const devPortOffset = resolveDevPortOffset(studioEnv)
176
233
 
177
- return { root, apps, mode, convexProvisioned, isProdDeployment, hasDeployKey, hasAiRoute, devPortOffset, issues }
234
+ return { root, layout, apps, backend, mode, convexProvisioned, isProdDeployment, hasDeployKey, devPortOffset, issues }
178
235
  }
@@ -50,8 +50,10 @@ import {
50
50
  * `elytra.config.ts` passes straight through. (`elytra.config.ts` itself is NOT
51
51
  * loadable this way — it imports `.tsx` component barrels, which no Node version
52
52
  * executes, EC-474. Where the consumer's deps ARE installed, EC-495's Vite harness
53
- * executes the full config and `runDoctor` hands the collections it resolved to these
54
- * checks in place of the `cms/index.ts` re-export; this loader stays the floor.)
53
+ * executes the full config and `runDoctor` hands the ROUTES and collections it resolved
54
+ * to these checks in place of the `cms/index.ts` re-export — EC-603; before, only the
55
+ * collections were swapped in. This loader stays the floor, and the first row says
56
+ * which of the two was judged.)
55
57
  *
56
58
  * Two things stand in the way, and both are handled rather than assumed away:
57
59
  *
@@ -311,6 +313,16 @@ const MOUNT_SHADOW_FIX =
311
313
  const UNREACHABLE_FIX =
312
314
  'Give an all-dynamic route a static segment (e.g. "/blog/:slug"), mount only collections with a `parent` self-relation, and keep a mount prefix static — in cms/routes.ts.'
313
315
 
316
+ /**
317
+ * Where the judged route set came from (EC-603, RESIDUE-4), appended to the first row so
318
+ * the report says which reader it used: the executed config (what the studio and
319
+ * delivery serve) or the bundler-free `cms/index.ts` floor. No `source` → no note.
320
+ */
321
+ const ROUTE_SOURCE_NOTE = {
322
+ config: ' (Routes from the executed elytra.config.ts.)',
323
+ native: ` (Routes read natively from ${CMS_ENTRY} — elytra.config.ts was not resolved here.)`,
324
+ }
325
+
314
326
  /** An `unknown` check row carrying the reason the routes could not be read. */
315
327
  function unavailableCheck(id, facts) {
316
328
  return {
@@ -360,6 +372,7 @@ export function routesDoctorChecks(facts) {
360
372
  const count = facts.routeCount ?? facts.routes.length
361
373
  const plural = count === 1 ? '' : 's'
362
374
  const conflicts = routeIssuesOf(facts.routes).filter((issue) => issue.code === 'route-conflict')
375
+ const source = ROUTE_SOURCE_NOTE[facts.source] ?? ''
363
376
 
364
377
  const routeConflicts =
365
378
  conflicts.length === 0
@@ -367,7 +380,7 @@ export function routesDoctorChecks(facts) {
367
380
  id: 'route-conflicts',
368
381
  bucket: 'app',
369
382
  status: 'ok',
370
- detail: `${count} declared route${plural}, no URL conflicts.`,
383
+ detail: `${count} declared route${plural}, no URL conflicts.${source}`,
371
384
  }
372
385
  : {
373
386
  id: 'route-conflicts',
@@ -375,7 +388,8 @@ export function routesDoctorChecks(facts) {
375
388
  status: 'fail',
376
389
  detail:
377
390
  `${conflicts.length} route conflict(s) among ${count} declared route${plural}. ` +
378
- conflicts.map((issue) => issue.message).join(' '),
391
+ conflicts.map((issue) => issue.message).join(' ') +
392
+ source,
379
393
  fix: CONFLICT_FIX,
380
394
  }
381
395