@elytracms/cli 0.0.80 → 0.0.82
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 +89 -1
- package/index.js +18 -1
- package/package.json +2 -2
- package/src/cli.js +17 -7
- package/src/config-harness.js +256 -0
- package/src/doctor.js +142 -10
- package/src/engine-build-failure.js +88 -0
- package/src/previews.js +141 -105
- package/src/project.js +6 -3
- package/src/routes-check.js +483 -0
package/README.md
CHANGED
|
@@ -29,7 +29,8 @@ elytra doctor Diagnose the active deployment's readiness and print each fix.
|
|
|
29
29
|
by NAME only — values are never read into the report or printed.
|
|
30
30
|
When the publish revalidate pair is set, also sends a signed
|
|
31
31
|
no-op ping to the endpoint to prove the publish → live chain
|
|
32
|
-
end to end. Also
|
|
32
|
+
end to end. Also checks your cms/ route set for URL conflicts
|
|
33
|
+
(see below), and reports the generated-previews artifacts
|
|
33
34
|
(manifest present, image files present, no strays — warnings
|
|
34
35
|
only; previews are optional). Read-only; exits non-zero if any
|
|
35
36
|
check fails.
|
|
@@ -175,3 +176,90 @@ has no `pairWith` escalation because its real partner lives on a plane this
|
|
|
175
176
|
doctor cannot read. Missing it is a warning: draft preview is closed
|
|
176
177
|
everywhere (401 on both sides) until it's set — a project that never uses
|
|
177
178
|
draft preview may legitimately leave it unset.
|
|
179
|
+
|
|
180
|
+
## Route conflicts (`elytra doctor`)
|
|
181
|
+
|
|
182
|
+
Routes are code, so a route conflict is a build-time bug, and `elytra doctor`
|
|
183
|
+
reports it: two routes that would match the exact same URLs fail the run with
|
|
184
|
+
the offending ids and patterns named. Nothing about this needs a deployment —
|
|
185
|
+
the check runs in playground mode too.
|
|
186
|
+
|
|
187
|
+
The doctor reads the route set from your project's `cms/index.ts` (the
|
|
188
|
+
`collections` + `routes` your `elytra.config.ts` passes through). That tree is
|
|
189
|
+
plain, React-free TypeScript by convention, so the CLI imports it natively —
|
|
190
|
+
no bundler, no Vite, no dev server. Two consequences worth knowing:
|
|
191
|
+
|
|
192
|
+
- It needs a Node that runs TypeScript natively (**≥22.18**). On an older Node
|
|
193
|
+
the check reports "not checked here" and names the version — never a false
|
|
194
|
+
pass.
|
|
195
|
+
- If the route set cannot be read for any reason (no `cms/index.ts`, a module
|
|
196
|
+
that throws, an import the CLI cannot resolve such as a `tsconfig` path
|
|
197
|
+
alias, TypeScript syntax Node cannot strip, or a `routes` export of the
|
|
198
|
+
wrong shape), the check reports **why**, as "not checked here". It never
|
|
199
|
+
crashes the doctor and never claims your routes are fine.
|
|
200
|
+
|
|
201
|
+
### Conflicting hierarchy mounts
|
|
202
|
+
|
|
203
|
+
A hierarchy mount (`/:path*`, `/docs/:path*`) is not a router route: it is pulled
|
|
204
|
+
out of the router and resolved per request against a collection's composed page
|
|
205
|
+
tree. So the route-conflict check above cannot see mounts at all, and a second
|
|
206
|
+
row — `mount-conflicts` — judges them by what they declare instead:
|
|
207
|
+
|
|
208
|
+
- **Two collections mounted at the same path fail the run.** They claim an
|
|
209
|
+
identical URL space, and the only thing separating them is declaration order.
|
|
210
|
+
- **A mount nested inside another's URL space is a warning.** It is a real
|
|
211
|
+
shadow, but only where the outer collection's tree actually composes a path
|
|
212
|
+
into the inner mount's prefix — that is content, which a CLI cannot see, so
|
|
213
|
+
naming the precedence is honest and failing your build on it would not be.
|
|
214
|
+
|
|
215
|
+
The precedence being named is worth stating plainly, because nothing else does:
|
|
216
|
+
**mounts are tried in declaration order, not by specificity**, and the first
|
|
217
|
+
whose collection resolves a document for that URL wins. A root mount declared
|
|
218
|
+
first therefore outranks a deeper one — the opposite of how ordinary routes
|
|
219
|
+
behave, where more static segments always win. Declare the more specific mount
|
|
220
|
+
first.
|
|
221
|
+
|
|
222
|
+
### Declarations that serve nothing
|
|
223
|
+
|
|
224
|
+
A third row — `unreachable-routes` — asks a different question from the two
|
|
225
|
+
above: not "do two declarations fight?" but "can this declaration ever serve
|
|
226
|
+
anything at all?" Three shapes trip it, all of which used to 404 silently with
|
|
227
|
+
no issue raised anywhere:
|
|
228
|
+
|
|
229
|
+
- **An all-dynamic route over a mount's URL space** — a **warning**. `/:slug`
|
|
230
|
+
matches *every* one-segment URL, so the `/:path*` mount is consulted there
|
|
231
|
+
only where the route resolves no document. Nothing is unconditionally
|
|
232
|
+
unreachable (the resolver falls through — see *Precedence* below), but
|
|
233
|
+
precedence at that depth is then decided by your **content**: a page whose
|
|
234
|
+
composed path collides with a post slug is silently shadowed, and neither
|
|
235
|
+
side is told. `/blog/:slug` is fine and always was: a static segment claims
|
|
236
|
+
only the namespace you carved out. It is the *all*-dynamic pattern that takes
|
|
237
|
+
a whole depth.
|
|
238
|
+
- **A mount on a collection that cannot back it** — no `parent` self-relation,
|
|
239
|
+
so there are no nested paths to compose, or the collection is not declared at
|
|
240
|
+
all. The resolver skips such a mount silently; every URL under it 404s. This
|
|
241
|
+
one **fails** the run: no content and no ordering can make it work.
|
|
242
|
+
- **A catch-all with a dynamic prefix** (`/:lang/:path*`) — also a **failure**.
|
|
243
|
+
It is dead in both directions: every router drops it as a catch-all, and no
|
|
244
|
+
mount is built from it because a mount prefix must be static. For per-locale
|
|
245
|
+
mounts, declare one mount per locale instead:
|
|
246
|
+
`{ pattern: '/de/:path*', locale: 'de' }`.
|
|
247
|
+
|
|
248
|
+
### Precedence: a match is not a result
|
|
249
|
+
|
|
250
|
+
A route always wins over a mount **for the URLs it can actually serve**. When a
|
|
251
|
+
route matches a URL but resolves no document there — no post with that slug, or
|
|
252
|
+
none published in this perspective — the resolver falls through to the hierarchy
|
|
253
|
+
mounts instead of returning 404. So declaring `/blog/:slug` does not take the
|
|
254
|
+
`/blog/*` namespace away from your page tree: a page at `blog/kid` is served,
|
|
255
|
+
and it stops being served the moment a *post* with slug `kid` exists.
|
|
256
|
+
|
|
257
|
+
The consequence worth knowing: which collection serves such a URL depends on
|
|
258
|
+
what exists, not on the routes alone, and it is evaluated per perspective — a
|
|
259
|
+
draft-only post wins in preview while the published page still serves live.
|
|
260
|
+
|
|
261
|
+
**Redirect loops are not checked here.** Since EC-303 redirects are editorial
|
|
262
|
+
data in the backend, not code — a CLI has no editorial data. The studio checks
|
|
263
|
+
them (and route conflicts, and the route-side half of `unreachable-routes`)
|
|
264
|
+
live, at the section-rail foot, because it holds both the config routes and the
|
|
265
|
+
redirects rail.
|
package/index.js
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { argv, exit } from 'node:process'
|
|
3
|
-
import {
|
|
3
|
+
import { engineBuildFailureMessage } from './src/engine-build-failure.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* EC-503: `src/cli.js`'s static graph reaches `@elytracms/core`'s COMPILED dist
|
|
7
|
+
* (doctor + routes-check), so an absent or half-written engine build kills every
|
|
8
|
+
* command — including `--help` — before a line of this file's code would run.
|
|
9
|
+
* Loading that graph dynamically is what makes the failure catchable at all; the
|
|
10
|
+
* leaf import above has no engine dependency and therefore always loads.
|
|
11
|
+
*/
|
|
12
|
+
let run
|
|
13
|
+
try {
|
|
14
|
+
;({ run } = await import('./src/cli.js'))
|
|
15
|
+
} catch (error) {
|
|
16
|
+
const attributed = engineBuildFailureMessage(error)
|
|
17
|
+
if (attributed === null) throw error
|
|
18
|
+
console.error(attributed)
|
|
19
|
+
exit(1)
|
|
20
|
+
}
|
|
4
21
|
|
|
5
22
|
run(argv.slice(2))
|
|
6
23
|
.then((code) => exit(code))
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elytracms/cli",
|
|
3
3
|
"author": "finaldream <hi@finaldream.de>",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.82",
|
|
5
5
|
"description": "The Elytra CLI — run a consuming repo's studio, frontend, and Convex backend under one command.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"type": "module",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"node": ">=20"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@elytracms/core": "^0.0.
|
|
20
|
+
"@elytracms/core": "^0.0.82"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
23
|
"vitest": "^4.1.10"
|
package/src/cli.js
CHANGED
|
@@ -29,9 +29,10 @@ Commands:
|
|
|
29
29
|
values never shown — config, membership) and print each fix. When the
|
|
30
30
|
instant-publish revalidate pair is set, also sends a signed no-op ping
|
|
31
31
|
to the endpoint to prove the publish → live chain end to end. Also
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
checks your cms/ routes for URL conflicts, and reports the
|
|
33
|
+
generated-previews artifacts (manifest/files present; hash staleness
|
|
34
|
+
lives in \`elytra previews --check\`). Exit non-zero if any check
|
|
35
|
+
fails. Read-only. Optional: --url <convex url>.
|
|
35
36
|
previews Render every canvas-eligible component's fixtures against YOUR theme
|
|
36
37
|
(headless Chromium via Playwright — an optional install) and write
|
|
37
38
|
committed preview images (studio/public/elytra-previews/) plus an
|
|
@@ -110,9 +111,14 @@ Diagnose the active deployment's readiness (URL, reachability, auth signing
|
|
|
110
111
|
keys, expected deployment env vars — set/missing by NAME, values never shown
|
|
111
112
|
— config, membership) and print each fix. When the instant-publish
|
|
112
113
|
revalidate pair is set, also sends a signed no-op ping to the endpoint to
|
|
113
|
-
prove the publish → live chain end to end. Also
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
prove the publish → live chain end to end. Also checks the route set in your
|
|
115
|
+
cms/ for URL conflicts (routes are code — redirect loops are editorial data
|
|
116
|
+
and are checked in the studio), and reports the generated-previews artifacts
|
|
117
|
+
(manifest/files present; hash staleness lives in \`elytra previews --check\`).
|
|
118
|
+
With studio/ deps installed, config validity is checked for real: doctor
|
|
119
|
+
executes elytra.config.ts through your studio's own Vite (takes a few
|
|
120
|
+
seconds); without deps it stays an honest "unknown" with the reason.
|
|
121
|
+
Exit non-zero if any check fails. Read-only.
|
|
116
122
|
|
|
117
123
|
Options:
|
|
118
124
|
--url <convex url> Diagnose this deployment instead of the one resolved
|
|
@@ -454,7 +460,9 @@ export async function run(argv, { cwd = process.cwd(), env = process.env, emit =
|
|
|
454
460
|
const command = decision.subcommand
|
|
455
461
|
|
|
456
462
|
// `doctor` is a read-only diagnostic: it needs the repo root + env + network, but not
|
|
457
|
-
// installed deps — so it is not blocked by a missing node_modules.
|
|
463
|
+
// installed deps — so it is not blocked by a missing node_modules. (EC-495: WITH
|
|
464
|
+
// installed studio deps it additionally executes elytra.config.ts through the
|
|
465
|
+
// consumer's own Vite for a real config-valid answer — opportunistic, never required.)
|
|
458
466
|
if (command === 'doctor') {
|
|
459
467
|
const project = inspectProject(cwd, env)
|
|
460
468
|
if (!project.root) {
|
|
@@ -470,6 +478,8 @@ export async function run(argv, { cwd = process.cwd(), env = process.env, emit =
|
|
|
470
478
|
// EC-399: where `convex env list` runs (name enumeration — presence only),
|
|
471
479
|
// and whether the AI provider-key expectation applies.
|
|
472
480
|
studioDir: project.apps.studio ? join(project.root, 'studio') : undefined,
|
|
481
|
+
// EC-469: where the project's `cms/` route set is read from (code, not backend).
|
|
482
|
+
root: project.root,
|
|
473
483
|
aiRouteConfigured: project.hasAiRoute,
|
|
474
484
|
exec,
|
|
475
485
|
})
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
3
|
+
import { createServer as createNetServer } from 'node:net'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { pathToFileURL } from 'node:url'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The consumer-Vite harness (EC-495) — the ONE mechanism by which this CLI
|
|
9
|
+
* executes a consumer's `elytra.config.ts`, shared by `elytra previews`
|
|
10
|
+
* (EC-440, the original) and `elytra doctor`'s `config-valid` check.
|
|
11
|
+
*
|
|
12
|
+
* Node-native TypeScript is deliberately NOT the mechanism: every real config
|
|
13
|
+
* transitively imports `.tsx` component barrels via `./components`, which no
|
|
14
|
+
* Node version or flag executes (EC-474, measured on node 24.18 and 26.3 —
|
|
15
|
+
* `ERR_UNKNOWN_FILE_EXTENSION`; the React-free `cms/` tree in
|
|
16
|
+
* `routes-check.js` is the only place native stripping works). Instead the
|
|
17
|
+
* CLI boots the CONSUMER STUDIO APP'S OWN Vite programmatically — resolved
|
|
18
|
+
* from `studio/`'s node_modules, with a minimal inline config (the studio's
|
|
19
|
+
* `vite.config.ts`, with its app plugins/ports/env wiring, is deliberately
|
|
20
|
+
* NOT loaded: `configFile: false`) — serves virtual modules that import the
|
|
21
|
+
* config by `/@fs/` path, and `ssrLoadModule`s them node-side. The consumer's
|
|
22
|
+
* own toolchain does the executing; the CLI never grows a bundler dependency.
|
|
23
|
+
*
|
|
24
|
+
* Everything here is opportunistic by contract: the CLI works without
|
|
25
|
+
* installed deps (`cli.js` — doctor is not blocked by a missing
|
|
26
|
+
* node_modules), so every "can't" degrades to a reason string, never a crash.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
// --- Shared plumbing ---------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
/** Conventional config file name at the project root (mirrors `WORKSPACE_CONFIG_FILE`). */
|
|
32
|
+
export const CONFIG_FILE = 'elytra.config.ts'
|
|
33
|
+
|
|
34
|
+
/** Resolve a module specifier from a directory's own node_modules world. */
|
|
35
|
+
export function resolveFromDir(dir, specifier) {
|
|
36
|
+
return createRequire(join(dir, 'package.json')).resolve(specifier)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** First free port in [start, start+span) — never a hardcoded single port. */
|
|
40
|
+
export async function pickPort(start = 5440, span = 30) {
|
|
41
|
+
for (let port = start; port < start + span; port += 1) {
|
|
42
|
+
const free = await new Promise((resolve) => {
|
|
43
|
+
const probe = createNetServer()
|
|
44
|
+
probe.once('error', () => resolve(false))
|
|
45
|
+
probe.listen(port, '127.0.0.1', () => probe.close(() => resolve(true)))
|
|
46
|
+
})
|
|
47
|
+
if (free) return port
|
|
48
|
+
}
|
|
49
|
+
throw new Error(`No free port in ${start}–${start + span - 1}.`)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* An inline Vite plugin serving virtual modules from an id → source map. The
|
|
54
|
+
* shared half of both harness plugins: previews adds its HTML middleware on
|
|
55
|
+
* top; doctor needs nothing else.
|
|
56
|
+
*/
|
|
57
|
+
export function virtualModulesPlugin(name, modules) {
|
|
58
|
+
return {
|
|
59
|
+
name,
|
|
60
|
+
resolveId(id) {
|
|
61
|
+
return Object.hasOwn(modules, id) ? id : null
|
|
62
|
+
},
|
|
63
|
+
load(id) {
|
|
64
|
+
return Object.hasOwn(modules, id) ? modules[id] : null
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Boot the consumer studio's Vite with the minimal inline harness config —
|
|
71
|
+
* the exact boot `elytra previews` has always used (EC-440): the studio dir
|
|
72
|
+
* as root (so bare imports resolve from the CONSUMER's node_modules), an
|
|
73
|
+
* ephemeral loopback port with `strictPort` (failing loudly beats silently
|
|
74
|
+
* walking onto a dev server's port — the studio's own 5180 is never touched
|
|
75
|
+
* because the studio `vite.config.ts` is never loaded), and automatic JSX so
|
|
76
|
+
* `.tsx` component barrels transform without the app's React plugin.
|
|
77
|
+
*/
|
|
78
|
+
export async function startConsumerVite({ root, studioDir, port, plugins }) {
|
|
79
|
+
const vite = await import(pathToFileURL(resolveFromDir(studioDir, 'vite')).href)
|
|
80
|
+
const server = await vite.createServer({
|
|
81
|
+
configFile: false,
|
|
82
|
+
root: studioDir,
|
|
83
|
+
mode: 'development',
|
|
84
|
+
appType: 'custom',
|
|
85
|
+
logLevel: 'warn',
|
|
86
|
+
plugins,
|
|
87
|
+
server: {
|
|
88
|
+
port,
|
|
89
|
+
strictPort: true,
|
|
90
|
+
host: '127.0.0.1',
|
|
91
|
+
fs: { allow: [vite.searchForWorkspaceRoot(studioDir), root] },
|
|
92
|
+
},
|
|
93
|
+
esbuild: { jsx: 'automatic' },
|
|
94
|
+
})
|
|
95
|
+
await server.listen()
|
|
96
|
+
return server
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// --- Doctor's config check (EC-495) --------------------------------------------------
|
|
100
|
+
|
|
101
|
+
/** The doctor's virtual check module id. */
|
|
102
|
+
const CONFIG_CHECK_ID = 'virtual:elytra-config-check'
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The check module's source: import the engine's resolver from the CONSUMER's
|
|
106
|
+
* installed core, then dynamically import the config INSIDE the check so a
|
|
107
|
+
* throwing config is attributed to the config (`config-error`) and never
|
|
108
|
+
* conflated with a broken harness (`harness-error` — e.g. an installed core
|
|
109
|
+
* too old to export these functions). The result crosses `ssrLoadModule`
|
|
110
|
+
* in-process, functions intact — nothing here is serialized.
|
|
111
|
+
*/
|
|
112
|
+
function configCheckModuleSource(configPath) {
|
|
113
|
+
const configImport = JSON.stringify(`/@fs/${configPath}`)
|
|
114
|
+
const file = JSON.stringify(CONFIG_FILE)
|
|
115
|
+
return [
|
|
116
|
+
`import { extractWorkspaceConfigExport, resolveWorkspaceConfig } from '@elytracms/core/studio-core'`,
|
|
117
|
+
`export async function runCheck() {`,
|
|
118
|
+
` let mod`,
|
|
119
|
+
` try {`,
|
|
120
|
+
` mod = await import(${configImport})`,
|
|
121
|
+
` } catch (error) {`,
|
|
122
|
+
` return { kind: 'config-error', error }`,
|
|
123
|
+
` }`,
|
|
124
|
+
` try {`,
|
|
125
|
+
` const extracted = extractWorkspaceConfigExport(mod, ${file})`,
|
|
126
|
+
` if ('issue' in extracted) {`,
|
|
127
|
+
` return { kind: 'resolved', result: { ok: false, projects: [], issues: [extracted.issue] } }`,
|
|
128
|
+
` }`,
|
|
129
|
+
` return { kind: 'resolved', result: resolveWorkspaceConfig(extracted.value, { file: ${file} }) }`,
|
|
130
|
+
` } catch (error) {`,
|
|
131
|
+
` return { kind: 'harness-error', error }`,
|
|
132
|
+
` }`,
|
|
133
|
+
`}`,
|
|
134
|
+
].join('\n')
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The modules the check needs from the CONSUMER's own installed world: `vite`
|
|
139
|
+
* (the executor) and the exact `@elytracms/core` SUBPATH the virtual check
|
|
140
|
+
* module imports. Named separately so a half-installed repo says which is
|
|
141
|
+
* missing. The subpath is load-bearing, not cosmetic: `@elytracms/core` is a
|
|
142
|
+
* subpath-only package with no `"."` export, so probing the bare specifier
|
|
143
|
+
* reports `ERR_PACKAGE_PATH_NOT_EXPORTED` on a FULLY installed repo (measured
|
|
144
|
+
* against `apps/demo` 2026-08-01, where it produced a false "deps not
|
|
145
|
+
* installed"). Probe what you are going to import.
|
|
146
|
+
*/
|
|
147
|
+
const REQUIRED_CONSUMER_DEPS = ['vite', '@elytracms/core/studio-core']
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* PURE precondition core: can the harness run at all, and if not, why exactly?
|
|
151
|
+
* Everything impure is injected (`exists` for the two conventional paths,
|
|
152
|
+
* `resolveDep` for the consumer's module world), so every degrade branch is
|
|
153
|
+
* deterministically testable without a filesystem or a real node_modules.
|
|
154
|
+
* Returns `null` when the harness may proceed, else the `unavailable` outcome
|
|
155
|
+
* with ITS OWN reason — the three cases the contract distinguishes.
|
|
156
|
+
*/
|
|
157
|
+
export function configHarnessPrecondition({ configPath, studioDir, exists, resolveDep }) {
|
|
158
|
+
if (!exists(configPath)) {
|
|
159
|
+
return { kind: 'unavailable', reason: `No ${CONFIG_FILE} found at the project root — config validity was not evaluated here.` }
|
|
160
|
+
}
|
|
161
|
+
if (!studioDir || !exists(join(studioDir, 'package.json'))) {
|
|
162
|
+
return {
|
|
163
|
+
kind: 'unavailable',
|
|
164
|
+
reason:
|
|
165
|
+
`No studio/ app found — doctor executes ${CONFIG_FILE} through the studio app's own Vite; ` +
|
|
166
|
+
'the studio boot and `elytra build` validate it too.',
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
for (const dep of REQUIRED_CONSUMER_DEPS) {
|
|
170
|
+
try {
|
|
171
|
+
resolveDep(studioDir, dep)
|
|
172
|
+
} catch {
|
|
173
|
+
return {
|
|
174
|
+
kind: 'unavailable',
|
|
175
|
+
reason:
|
|
176
|
+
`The studio's dependencies are not installed (${dep} could not be resolved from studio/) — ` +
|
|
177
|
+
'run `pnpm install`, then re-run `elytra doctor` for a real config check.',
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return null
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** An error's message, newline-collapsed and capped — legible in a one-line report row, never a stack dump. */
|
|
185
|
+
function errorSummary(error) {
|
|
186
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
187
|
+
const collapsed = message
|
|
188
|
+
.split('\n')
|
|
189
|
+
.map((line) => line.trim())
|
|
190
|
+
.filter(Boolean)
|
|
191
|
+
.join(' ')
|
|
192
|
+
return collapsed.length > 300 ? `${collapsed.slice(0, 300)}…` : collapsed || 'unknown error'
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Execute a project's `elytra.config.ts` through the consumer studio's own
|
|
197
|
+
* Vite and resolve it with the engine's `resolveWorkspaceConfig`. Never
|
|
198
|
+
* throws; read-only toward the consumer project (no installs, no writes).
|
|
199
|
+
* Returns exactly one of:
|
|
200
|
+
*
|
|
201
|
+
* - `{ kind: 'resolved', result, file }` — the config EXECUTED; `result` is
|
|
202
|
+
* the engine's `WorkspaceConfigResult` (`ok` may still be false: that is a
|
|
203
|
+
* definitive INVALID, not an unavailability).
|
|
204
|
+
* - `{ kind: 'config-error', error, file }` — the config file itself threw
|
|
205
|
+
* on import (syntax error, a throwing top-level statement, a broken
|
|
206
|
+
* import). A definitive FAIL with the error surfaced.
|
|
207
|
+
* - `{ kind: 'unavailable', reason }` — the harness could not run at all
|
|
208
|
+
* (no studio app, deps not installed, Vite failed to boot, or the check
|
|
209
|
+
* itself broke). An honest `unknown`, with the specific reason.
|
|
210
|
+
*
|
|
211
|
+
* The boot costs seconds, not milliseconds — `log` announces it so a quiet
|
|
212
|
+
* pause in the doctor output is explained (`elytra doctor` is a diagnostic;
|
|
213
|
+
* the price is right).
|
|
214
|
+
*/
|
|
215
|
+
export async function executeWorkspaceConfig({
|
|
216
|
+
root,
|
|
217
|
+
studioDir,
|
|
218
|
+
port,
|
|
219
|
+
log = () => {},
|
|
220
|
+
exists = existsSync,
|
|
221
|
+
resolveDep = resolveFromDir,
|
|
222
|
+
} = {}) {
|
|
223
|
+
const configPath = join(root, CONFIG_FILE)
|
|
224
|
+
const blocked = configHarnessPrecondition({ configPath, studioDir, exists, resolveDep })
|
|
225
|
+
if (blocked) return blocked
|
|
226
|
+
|
|
227
|
+
log(`Executing ${CONFIG_FILE} through the studio's own Vite for the config check (this takes a few seconds)…`)
|
|
228
|
+
|
|
229
|
+
let server
|
|
230
|
+
try {
|
|
231
|
+
const chosenPort = port ?? (await pickPort())
|
|
232
|
+
server = await startConsumerVite({
|
|
233
|
+
root,
|
|
234
|
+
studioDir,
|
|
235
|
+
port: chosenPort,
|
|
236
|
+
plugins: [virtualModulesPlugin('elytra-config-check', { [CONFIG_CHECK_ID]: configCheckModuleSource(configPath) })],
|
|
237
|
+
})
|
|
238
|
+
} catch (error) {
|
|
239
|
+
return { kind: 'unavailable', reason: `The studio's Vite could not start (${errorSummary(error)}) — config validity was not evaluated here.` }
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
const { runCheck } = await server.ssrLoadModule(CONFIG_CHECK_ID)
|
|
243
|
+
const outcome = await runCheck()
|
|
244
|
+
if (outcome.kind === 'config-error') {
|
|
245
|
+
return { kind: 'config-error', error: errorSummary(outcome.error), file: CONFIG_FILE }
|
|
246
|
+
}
|
|
247
|
+
if (outcome.kind === 'harness-error') {
|
|
248
|
+
return { kind: 'unavailable', reason: `The config check could not run (${errorSummary(outcome.error)}) — config validity was not evaluated here.` }
|
|
249
|
+
}
|
|
250
|
+
return { kind: 'resolved', result: outcome.result, file: CONFIG_FILE }
|
|
251
|
+
} catch (error) {
|
|
252
|
+
return { kind: 'unavailable', reason: `The config check could not run (${errorSummary(error)}) — config validity was not evaluated here.` }
|
|
253
|
+
} finally {
|
|
254
|
+
await server.close()
|
|
255
|
+
}
|
|
256
|
+
}
|
package/src/doctor.js
CHANGED
|
@@ -7,8 +7,10 @@ import {
|
|
|
7
7
|
probeAuthSigningKeys,
|
|
8
8
|
probeRevalidateEndpoint,
|
|
9
9
|
} from '@elytracms/core/export-sync/deployment-ready'
|
|
10
|
+
import { executeWorkspaceConfig } from './config-harness.js'
|
|
10
11
|
import { convexEnvGet, defaultConvexExec } from './provision.js'
|
|
11
12
|
import { gatherPreviewsArtifacts, previewsDoctorChecks } from './previews.js'
|
|
13
|
+
import { loadProjectRoutes, routesDoctorChecks } from './routes-check.js'
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
16
|
* `elytra doctor` (EC-259) — the pre-boot counterpart to the studio's boot
|
|
@@ -19,8 +21,12 @@ import { gatherPreviewsArtifacts, previewsDoctorChecks } from './previews.js'
|
|
|
19
21
|
* the studio reports it `unknown`; node has no such limit).
|
|
20
22
|
*
|
|
21
23
|
* Read-only: it only fetches the deployment's public URL and its public JWKS endpoint.
|
|
22
|
-
* It never writes env, never provisions.
|
|
23
|
-
*
|
|
24
|
+
* It never writes env, never provisions. The auth `session` is never gathered here →
|
|
25
|
+
* the user checks stay `unknown`. Config validity + resolved collections are gathered
|
|
26
|
+
* OPPORTUNISTICALLY (EC-495): when `studio/` and its installed deps exist, the config
|
|
27
|
+
* is executed through the consumer studio's own Vite (the `elytra previews` harness —
|
|
28
|
+
* see `config-harness.js`) and resolved with the engine's `resolveWorkspaceConfig`;
|
|
29
|
+
* otherwise those facts are omitted with the specific reason → an honest `unknown`.
|
|
24
30
|
*/
|
|
25
31
|
|
|
26
32
|
// --- Target resolution -------------------------------------------------------
|
|
@@ -77,8 +83,9 @@ export async function probeReachable(url, fetchImpl = fetch) {
|
|
|
77
83
|
|
|
78
84
|
/**
|
|
79
85
|
* Gather the readiness facts a node CLI can definitively produce for a target. The
|
|
80
|
-
*
|
|
81
|
-
*
|
|
86
|
+
* user facts are intentionally omitted (no auth session) → `unknown`; the app facts
|
|
87
|
+
* (config/collections) are gathered separately via the consumer-Vite harness when it
|
|
88
|
+
* is available (EC-495, {@link configFactsFromOutcome}).
|
|
82
89
|
*/
|
|
83
90
|
export async function gatherFacts(target, { fetchImpl = fetch } = {}) {
|
|
84
91
|
if (target.kind === 'playground') return { backend: { kind: 'playground' } }
|
|
@@ -92,7 +99,75 @@ export async function gatherFacts(target, { fetchImpl = fetch } = {}) {
|
|
|
92
99
|
reachable,
|
|
93
100
|
authKeys,
|
|
94
101
|
// A configured URL means a deployment is wired; leave `provisioned` unset (→ ok).
|
|
95
|
-
//
|
|
102
|
+
// session omitted → the user checks surface as `unknown`; config/collections are
|
|
103
|
+
// merged in by the caller from the EC-495 harness outcome (or stay omitted).
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// --- Config facts via the consumer-Vite harness (EC-495) -------------------------------
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* One located line for a workspace-config issue: `projects.0.slug: message`.
|
|
111
|
+
* The path grammar matches the issue's own `path` (config-object addressing).
|
|
112
|
+
*/
|
|
113
|
+
export function formatConfigIssue(issue) {
|
|
114
|
+
const path = (issue.path ?? []).join('.')
|
|
115
|
+
return path.length > 0 ? `${path}: ${issue.message}` : issue.message
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* PURE decision core (EC-495): map a config-harness outcome
|
|
120
|
+
* ({@link executeWorkspaceConfig}'s union) onto the `DeploymentReadinessFacts`
|
|
121
|
+
* fields the shared contract consumes. The three honest shapes, and never a
|
|
122
|
+
* fourth:
|
|
123
|
+
*
|
|
124
|
+
* - `resolved` → `config` with a real `valid`; on a valid config also the
|
|
125
|
+
* first project's `collections` (what the studio mounts — consumers are
|
|
126
|
+
* single-project; a multi-project workspace is judged by its first, the
|
|
127
|
+
* studio's own default active project).
|
|
128
|
+
* - `config-error` → `config.valid: false` with the surfaced `error` (the
|
|
129
|
+
* config threw — a definitive fail, not an unavailability).
|
|
130
|
+
* - `unavailable` (or no outcome) → NO `config` fact, reason in
|
|
131
|
+
* `configUnavailableDetail` → the contract renders `unknown` + why.
|
|
132
|
+
*
|
|
133
|
+
* EC-490: `firstIssue` is carried on a VALID config too. A config can resolve
|
|
134
|
+
* (`ok: true`) and still return warning-severity issues — the resolver's first one
|
|
135
|
+
* is EC-490's "this project declares no locales, `['en']` was applied". Withholding
|
|
136
|
+
* the message there is what made the whole warning channel invisible: the contract
|
|
137
|
+
* renders it as `warn` (see `appChecks`), and a `warn` without its message is a
|
|
138
|
+
* count nobody can act on.
|
|
139
|
+
*/
|
|
140
|
+
export function configFactsFromOutcome(outcome) {
|
|
141
|
+
if (!outcome || outcome.kind === 'unavailable') {
|
|
142
|
+
return outcome?.reason !== undefined ? { configUnavailableDetail: outcome.reason } : {}
|
|
143
|
+
}
|
|
144
|
+
if (outcome.kind === 'config-error') {
|
|
145
|
+
return { config: { valid: false, source: outcome.file, error: outcome.error } }
|
|
146
|
+
}
|
|
147
|
+
const { result, file } = outcome
|
|
148
|
+
const issues = Array.isArray(result?.issues) ? result.issues : []
|
|
149
|
+
const valid = result?.ok === true
|
|
150
|
+
// The first ERROR names what failed; with none (a valid config, or an
|
|
151
|
+
// error-less list) the first issue stands in.
|
|
152
|
+
const firstIssue = issues.find((issue) => issue.severity === 'error') ?? issues[0]
|
|
153
|
+
const config = {
|
|
154
|
+
valid,
|
|
155
|
+
source: file,
|
|
156
|
+
issueCount: issues.length,
|
|
157
|
+
...(firstIssue ? { firstIssue: formatConfigIssue(firstIssue) } : {}),
|
|
158
|
+
}
|
|
159
|
+
const project = valid ? result.projects?.[0] : undefined
|
|
160
|
+
const collections = project?.collections
|
|
161
|
+
// EC-524: the `componentIds` fact was gathered here for EC-508's presentation
|
|
162
|
+
// check, which resolved a collection's declared `presentation.wrapper` against
|
|
163
|
+
// the registry. The wrapper left config entirely (a detail wrapper is a page
|
|
164
|
+
// template and templates are code), so the check now reads collection FIELDS
|
|
165
|
+
// alone and nothing consumes the registry ids. Gathering them was removed
|
|
166
|
+
// rather than left dangling — neither the fact nor the harness reduction ever
|
|
167
|
+
// shipped (both landed after the 0.0.81 staging commit).
|
|
168
|
+
return {
|
|
169
|
+
config,
|
|
170
|
+
...(collections ? { collections } : {}),
|
|
96
171
|
}
|
|
97
172
|
}
|
|
98
173
|
|
|
@@ -236,6 +311,22 @@ const BUCKET_TITLE = {
|
|
|
236
311
|
previews: 'Previews',
|
|
237
312
|
}
|
|
238
313
|
|
|
314
|
+
/**
|
|
315
|
+
* Splice extra checks into an existing bucket's run, right after that bucket's last
|
|
316
|
+
* check (EC-469). {@link formatReport} opens a bucket heading whenever `check.bucket`
|
|
317
|
+
* changes, so appending an `app` check after the `user`/`previews` ones would print
|
|
318
|
+
* "App:" twice. Bucket absent from the report → the extras go at the end. Pure.
|
|
319
|
+
*/
|
|
320
|
+
export function spliceBucketChecks(checks, bucket, extra) {
|
|
321
|
+
if (extra.length === 0) return [...checks]
|
|
322
|
+
let last = -1
|
|
323
|
+
for (let i = 0; i < checks.length; i++) {
|
|
324
|
+
if (checks[i].bucket === bucket) last = i
|
|
325
|
+
}
|
|
326
|
+
if (last === -1) return [...checks, ...extra]
|
|
327
|
+
return [...checks.slice(0, last + 1), ...extra, ...checks.slice(last + 1)]
|
|
328
|
+
}
|
|
329
|
+
|
|
239
330
|
/**
|
|
240
331
|
* Render a `DeploymentReadinessReport` as the bucketed doctor report (pure — no I/O,
|
|
241
332
|
* no color, so it's directly testable). Failing AND warning checks print their
|
|
@@ -284,6 +375,20 @@ export function formatReport(report) {
|
|
|
284
375
|
* findings there are warnings (previews are optional); input-hash STALENESS needs
|
|
285
376
|
* the component registry and lives in `elytra previews --check` (the ratified
|
|
286
377
|
* split). `previewsArtifacts` is injectable for tests.
|
|
378
|
+
*
|
|
379
|
+
* EC-469: when a project `root` is known, the `app` bucket also reports ROUTE
|
|
380
|
+
* CONFLICTS, read from the project's React-free `cms/` tree (see `routes-check.js`).
|
|
381
|
+
* That check CAN fail — a conflict is deterministic in code — so it participates in
|
|
382
|
+
* the exit code; a route set that cannot be read reports `unknown` with its reason and
|
|
383
|
+
* never fails. `routeFacts` is injectable for tests.
|
|
384
|
+
*
|
|
385
|
+
* EC-495: when a project `root` is known, `config-valid` + `asset-collection` get a
|
|
386
|
+
* REAL answer by executing `elytra.config.ts` through the consumer studio's own Vite
|
|
387
|
+
* (see `config-harness.js`) — opportunistic and read-only: without `studio/` or its
|
|
388
|
+
* installed deps the checks stay honest `unknown`s carrying the reason, and the CLI's
|
|
389
|
+
* works-without-node_modules contract is untouched. A resolved config also hands the
|
|
390
|
+
* route checks the same collections the studio actually mounts (superseding the
|
|
391
|
+
* `cms/index.ts` re-export). `configOutcome` is injectable for tests.
|
|
287
392
|
*/
|
|
288
393
|
export async function runDoctor({
|
|
289
394
|
env = {},
|
|
@@ -291,9 +396,12 @@ export async function runDoctor({
|
|
|
291
396
|
log = (l) => console.log(l),
|
|
292
397
|
url,
|
|
293
398
|
studioDir,
|
|
399
|
+
root,
|
|
294
400
|
aiRouteConfigured = false,
|
|
295
401
|
exec,
|
|
296
402
|
previewsArtifacts,
|
|
403
|
+
routeFacts,
|
|
404
|
+
configOutcome,
|
|
297
405
|
} = {}) {
|
|
298
406
|
const target = resolveDoctorTarget(env, { url })
|
|
299
407
|
|
|
@@ -321,16 +429,40 @@ export async function runDoctor({
|
|
|
321
429
|
})
|
|
322
430
|
if (revalidateProbe) facts.revalidateProbe = revalidateProbe
|
|
323
431
|
}
|
|
432
|
+
|
|
433
|
+
// EC-495: execute the config through the consumer studio's own Vite when possible
|
|
434
|
+
// (announced by the harness itself — the boot costs seconds). Merged BEFORE the
|
|
435
|
+
// contract composes the report so `config-valid`/`asset-collection` are real checks.
|
|
436
|
+
let configFacts = {}
|
|
437
|
+
if (configOutcome !== undefined || root !== undefined) {
|
|
438
|
+
const outcome = configOutcome ?? (await executeWorkspaceConfig({ root, studioDir, log }))
|
|
439
|
+
configFacts = configFactsFromOutcome(outcome)
|
|
440
|
+
Object.assign(facts, configFacts)
|
|
441
|
+
}
|
|
442
|
+
|
|
324
443
|
const report = checkDeploymentReady(facts)
|
|
325
444
|
|
|
326
445
|
// EC-440: the previews bucket (repo artifacts, backend-independent). Appended to
|
|
327
446
|
// the shared report before formatting so it wears the same glyph/fix conventions;
|
|
328
|
-
// its checks are ok/warn/unknown only, so
|
|
447
|
+
// its checks are ok/warn/unknown only, so the exit code is unchanged by them.
|
|
329
448
|
const artifacts = previewsArtifacts ?? (studioDir ? gatherPreviewsArtifacts(studioDir) : undefined)
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
449
|
+
let checks = artifacts ? [...report.checks, ...previewsDoctorChecks(artifacts)] : [...report.checks]
|
|
450
|
+
|
|
451
|
+
// EC-469: the route-conflict check, spliced into the `app` bucket it belongs to.
|
|
452
|
+
// Backend-independent (routes are CODE) — it runs in playground mode too.
|
|
453
|
+
// EC-495: a resolved config supersedes the `cms/index.ts` collections re-export —
|
|
454
|
+
// the route checks judge mounts against the same collections the studio mounts.
|
|
455
|
+
if (root !== undefined || routeFacts !== undefined) {
|
|
456
|
+
const loaded = routeFacts ?? (await loadProjectRoutes(root))
|
|
457
|
+
const enriched = configFacts.collections ? { ...loaded, collections: configFacts.collections } : loaded
|
|
458
|
+
checks = spliceBucketChecks(checks, 'app', routesDoctorChecks(enriched))
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// One rule for the exit code, the same one `checkDeploymentReady` applies: a
|
|
462
|
+
// report is ok when nothing FAILED. Recomputed over the merged set so an appended
|
|
463
|
+
// bucket's failure can never be silently dropped from the exit code.
|
|
464
|
+
const merged = { ok: !checks.some((c) => c.status === 'fail'), checks }
|
|
333
465
|
|
|
334
466
|
log(formatReport(merged))
|
|
335
|
-
return
|
|
467
|
+
return merged.ok ? 0 : 1
|
|
336
468
|
}
|