@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/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