@elytracms/cli 0.0.88 → 0.0.90
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -37
- package/package.json +2 -2
- package/src/cli.js +81 -36
- package/src/config-harness.js +173 -3
- package/src/doctor.js +92 -22
- package/src/layout.js +288 -0
- package/src/previews.js +39 -22
- package/src/project.js +105 -48
- package/src/routes-check.js +18 -4
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).
|
|
6
|
-
* executing:
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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 (
|
|
57
|
-
* the same signal the studio itself reads. Used by `elytra doctor`
|
|
58
|
-
* deployment to diagnose.
|
|
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(
|
|
61
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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,
|
|
117
|
-
*
|
|
118
|
-
* `
|
|
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
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
148
|
-
//
|
|
149
|
-
// means connected; neither means it still
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
//
|
|
169
|
-
//
|
|
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,
|
|
234
|
+
return { root, layout, apps, backend, mode, convexProvisioned, isProdDeployment, hasDeployKey, devPortOffset, issues }
|
|
178
235
|
}
|
package/src/routes-check.js
CHANGED
|
@@ -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
|
|
54
|
-
* checks in place of the `cms/index.ts` re-export;
|
|
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
|
|