@elytracms/cli 0.0.81 → 0.0.83
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/index.js +18 -1
- package/package.json +3 -3
- package/src/cli.js +6 -1
- package/src/config-harness.js +320 -0
- package/src/doctor.js +104 -7
- 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 +17 -7
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.83",
|
|
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",
|
|
@@ -14,10 +14,10 @@
|
|
|
14
14
|
"README.md"
|
|
15
15
|
],
|
|
16
16
|
"engines": {
|
|
17
|
-
"node": ">=
|
|
17
|
+
"node": ">=22.18.0"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@elytracms/core": "^0.0.
|
|
20
|
+
"@elytracms/core": "^0.0.83"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
23
|
"vitest": "^4.1.10"
|
package/src/cli.js
CHANGED
|
@@ -115,6 +115,9 @@ prove the publish → live chain end to end. Also checks the route set in your
|
|
|
115
115
|
cms/ for URL conflicts (routes are code — redirect loops are editorial data
|
|
116
116
|
and are checked in the studio), and reports the generated-previews artifacts
|
|
117
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.
|
|
118
121
|
Exit non-zero if any check fails. Read-only.
|
|
119
122
|
|
|
120
123
|
Options:
|
|
@@ -457,7 +460,9 @@ export async function run(argv, { cwd = process.cwd(), env = process.env, emit =
|
|
|
457
460
|
const command = decision.subcommand
|
|
458
461
|
|
|
459
462
|
// `doctor` is a read-only diagnostic: it needs the repo root + env + network, but not
|
|
460
|
-
// 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.)
|
|
461
466
|
if (command === 'doctor') {
|
|
462
467
|
const project = inspectProject(cwd, env)
|
|
463
468
|
if (!project.root) {
|
|
@@ -0,0 +1,320 @@
|
|
|
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
|
+
/**
|
|
79
|
+
* React specifiers the automatic-JSX transform injects, aliased to the STUDIO's
|
|
80
|
+
* own copies (EC-537).
|
|
81
|
+
*
|
|
82
|
+
* `jsx: 'automatic'` below rewrites every transformed `.tsx` to import
|
|
83
|
+
* `react/jsx-dev-runtime`, and Vite resolves a bare specifier from the
|
|
84
|
+
* IMPORTER's directory. A consumer's page-builder components live at their repo
|
|
85
|
+
* root, outside `studioDir` — so on a repo whose root `package.json` does not
|
|
86
|
+
* declare react, every one of them fails to resolve and `config-valid` reports
|
|
87
|
+
* a config that "could not be executed", taking `asset-collection` and
|
|
88
|
+
* `presentation-targets` down with it.
|
|
89
|
+
*
|
|
90
|
+
* Measured 2026-08-02 on a real `create-elytra@0.0.82` scaffold, both
|
|
91
|
+
* directions: with react at the root `✓ config-valid` (11 ok); with react
|
|
92
|
+
* removed, `✗ Cannot find module 'react/jsx-dev-runtime' imported from
|
|
93
|
+
* components/marketing/hero.tsx` and three checks lost. Both production
|
|
94
|
+
* consumers hit exactly this on upgrading and both added react to their root.
|
|
95
|
+
*
|
|
96
|
+
* That was a workaround for a requirement the HARNESS invented: automatic JSX is
|
|
97
|
+
* our transform setting, chosen for our convenience, in a boot whose only job is
|
|
98
|
+
* to *evaluate* a config — nothing here mounts or renders. So the package that
|
|
99
|
+
* mandated React supplies it. The studio always has react (it is a React app),
|
|
100
|
+
* and resolving from `studioDir` is what this harness already does for `vite`
|
|
101
|
+
* itself, one line above.
|
|
102
|
+
*
|
|
103
|
+
* Opportunistic by contract, like everything else in this file: a specifier that
|
|
104
|
+
* cannot be resolved is simply left unaliased rather than throwing, so a partial
|
|
105
|
+
* install degrades to the previous behaviour instead of crashing the CLI.
|
|
106
|
+
*/
|
|
107
|
+
export function reactRuntimeFromStudioPlugin(studioDir) {
|
|
108
|
+
// Resolve THROUGH VITE, from a studio-side importer — never to a raw path.
|
|
109
|
+
//
|
|
110
|
+
// The obvious implementation is `resolve.alias` to
|
|
111
|
+
// `createRequire(studioDir).resolve('react/jsx-dev-runtime')`. That was tried
|
|
112
|
+
// first and is wrong twice over (both measured 2026-08-02): `createRequire`
|
|
113
|
+
// returns the **CJS** entry, and an absolute-path alias bypasses Vite's
|
|
114
|
+
// exports-map/condition/interop handling entirely, so its SSR runner
|
|
115
|
+
// evaluates CommonJS as ESM — `elytra previews` died with
|
|
116
|
+
// `ReferenceError: module is not defined` at `react/index.js:9`, then at
|
|
117
|
+
// `react/jsx-dev-runtime.js:9` when the alias was narrowed.
|
|
118
|
+
//
|
|
119
|
+
// Handing Vite a studio-side *importer* instead lets its own resolver do the
|
|
120
|
+
// work — right conditions, right ESM build — while only changing WHERE the
|
|
121
|
+
// lookup starts. That is the whole intent: not a different React, just one
|
|
122
|
+
// that can be found.
|
|
123
|
+
const importer = join(studioDir, '__elytra-harness__.js')
|
|
124
|
+
return {
|
|
125
|
+
name: 'elytra:react-runtime-from-studio',
|
|
126
|
+
async resolveId(id, from) {
|
|
127
|
+
if (id !== 'react/jsx-runtime' && id !== 'react/jsx-dev-runtime') return null
|
|
128
|
+
// Only rescue importers OUTSIDE the studio (the consumer's root-level
|
|
129
|
+
// components). Anything already inside studio/ resolves on its own, and
|
|
130
|
+
// intercepting it would be a silent no-op at best.
|
|
131
|
+
if (from === undefined || from.startsWith(studioDir)) return null
|
|
132
|
+
const resolved = await this.resolve(id, importer, { skipSelf: true })
|
|
133
|
+
return resolved ?? null
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function startConsumerVite({ root, studioDir, port, plugins }) {
|
|
139
|
+
const vite = await import(pathToFileURL(resolveFromDir(studioDir, 'vite')).href)
|
|
140
|
+
const server = await vite.createServer({
|
|
141
|
+
configFile: false,
|
|
142
|
+
root: studioDir,
|
|
143
|
+
mode: 'development',
|
|
144
|
+
appType: 'custom',
|
|
145
|
+
logLevel: 'warn',
|
|
146
|
+
// EC-537 — the rescue runs BEFORE the caller's plugins so nothing downstream
|
|
147
|
+
// has to know about it. `dedupe` rides along: once the consumer's components
|
|
148
|
+
// and the studio can both resolve React, they must agree on ONE copy.
|
|
149
|
+
plugins: [reactRuntimeFromStudioPlugin(studioDir), ...(plugins ?? [])],
|
|
150
|
+
resolve: { dedupe: ['react', 'react-dom'] },
|
|
151
|
+
server: {
|
|
152
|
+
port,
|
|
153
|
+
strictPort: true,
|
|
154
|
+
host: '127.0.0.1',
|
|
155
|
+
fs: { allow: [vite.searchForWorkspaceRoot(studioDir), root] },
|
|
156
|
+
},
|
|
157
|
+
esbuild: { jsx: 'automatic' },
|
|
158
|
+
})
|
|
159
|
+
await server.listen()
|
|
160
|
+
return server
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// --- Doctor's config check (EC-495) --------------------------------------------------
|
|
164
|
+
|
|
165
|
+
/** The doctor's virtual check module id. */
|
|
166
|
+
const CONFIG_CHECK_ID = 'virtual:elytra-config-check'
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The check module's source: import the engine's resolver from the CONSUMER's
|
|
170
|
+
* installed core, then dynamically import the config INSIDE the check so a
|
|
171
|
+
* throwing config is attributed to the config (`config-error`) and never
|
|
172
|
+
* conflated with a broken harness (`harness-error` — e.g. an installed core
|
|
173
|
+
* too old to export these functions). The result crosses `ssrLoadModule`
|
|
174
|
+
* in-process, functions intact — nothing here is serialized.
|
|
175
|
+
*/
|
|
176
|
+
function configCheckModuleSource(configPath) {
|
|
177
|
+
const configImport = JSON.stringify(`/@fs/${configPath}`)
|
|
178
|
+
const file = JSON.stringify(CONFIG_FILE)
|
|
179
|
+
return [
|
|
180
|
+
`import { extractWorkspaceConfigExport, resolveWorkspaceConfig } from '@elytracms/core/studio-core'`,
|
|
181
|
+
`export async function runCheck() {`,
|
|
182
|
+
` let mod`,
|
|
183
|
+
` try {`,
|
|
184
|
+
` mod = await import(${configImport})`,
|
|
185
|
+
` } catch (error) {`,
|
|
186
|
+
` return { kind: 'config-error', error }`,
|
|
187
|
+
` }`,
|
|
188
|
+
` try {`,
|
|
189
|
+
` const extracted = extractWorkspaceConfigExport(mod, ${file})`,
|
|
190
|
+
` if ('issue' in extracted) {`,
|
|
191
|
+
` return { kind: 'resolved', result: { ok: false, projects: [], issues: [extracted.issue] } }`,
|
|
192
|
+
` }`,
|
|
193
|
+
` return { kind: 'resolved', result: resolveWorkspaceConfig(extracted.value, { file: ${file} }) }`,
|
|
194
|
+
` } catch (error) {`,
|
|
195
|
+
` return { kind: 'harness-error', error }`,
|
|
196
|
+
` }`,
|
|
197
|
+
`}`,
|
|
198
|
+
].join('\n')
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The modules the check needs from the CONSUMER's own installed world: `vite`
|
|
203
|
+
* (the executor) and the exact `@elytracms/core` SUBPATH the virtual check
|
|
204
|
+
* module imports. Named separately so a half-installed repo says which is
|
|
205
|
+
* missing. The subpath is load-bearing, not cosmetic: `@elytracms/core` is a
|
|
206
|
+
* subpath-only package with no `"."` export, so probing the bare specifier
|
|
207
|
+
* reports `ERR_PACKAGE_PATH_NOT_EXPORTED` on a FULLY installed repo (measured
|
|
208
|
+
* against `apps/demo` 2026-08-01, where it produced a false "deps not
|
|
209
|
+
* installed"). Probe what you are going to import.
|
|
210
|
+
*/
|
|
211
|
+
const REQUIRED_CONSUMER_DEPS = ['vite', '@elytracms/core/studio-core']
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* PURE precondition core: can the harness run at all, and if not, why exactly?
|
|
215
|
+
* Everything impure is injected (`exists` for the two conventional paths,
|
|
216
|
+
* `resolveDep` for the consumer's module world), so every degrade branch is
|
|
217
|
+
* deterministically testable without a filesystem or a real node_modules.
|
|
218
|
+
* Returns `null` when the harness may proceed, else the `unavailable` outcome
|
|
219
|
+
* with ITS OWN reason — the three cases the contract distinguishes.
|
|
220
|
+
*/
|
|
221
|
+
export function configHarnessPrecondition({ configPath, studioDir, exists, resolveDep }) {
|
|
222
|
+
if (!exists(configPath)) {
|
|
223
|
+
return { kind: 'unavailable', reason: `No ${CONFIG_FILE} found at the project root — config validity was not evaluated here.` }
|
|
224
|
+
}
|
|
225
|
+
if (!studioDir || !exists(join(studioDir, 'package.json'))) {
|
|
226
|
+
return {
|
|
227
|
+
kind: 'unavailable',
|
|
228
|
+
reason:
|
|
229
|
+
`No studio/ app found — doctor executes ${CONFIG_FILE} through the studio app's own Vite; ` +
|
|
230
|
+
'the studio boot and `elytra build` validate it too.',
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
for (const dep of REQUIRED_CONSUMER_DEPS) {
|
|
234
|
+
try {
|
|
235
|
+
resolveDep(studioDir, dep)
|
|
236
|
+
} catch {
|
|
237
|
+
return {
|
|
238
|
+
kind: 'unavailable',
|
|
239
|
+
reason:
|
|
240
|
+
`The studio's dependencies are not installed (${dep} could not be resolved from studio/) — ` +
|
|
241
|
+
'run `pnpm install`, then re-run `elytra doctor` for a real config check.',
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return null
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** An error's message, newline-collapsed and capped — legible in a one-line report row, never a stack dump. */
|
|
249
|
+
function errorSummary(error) {
|
|
250
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
251
|
+
const collapsed = message
|
|
252
|
+
.split('\n')
|
|
253
|
+
.map((line) => line.trim())
|
|
254
|
+
.filter(Boolean)
|
|
255
|
+
.join(' ')
|
|
256
|
+
return collapsed.length > 300 ? `${collapsed.slice(0, 300)}…` : collapsed || 'unknown error'
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Execute a project's `elytra.config.ts` through the consumer studio's own
|
|
261
|
+
* Vite and resolve it with the engine's `resolveWorkspaceConfig`. Never
|
|
262
|
+
* throws; read-only toward the consumer project (no installs, no writes).
|
|
263
|
+
* Returns exactly one of:
|
|
264
|
+
*
|
|
265
|
+
* - `{ kind: 'resolved', result, file }` — the config EXECUTED; `result` is
|
|
266
|
+
* the engine's `WorkspaceConfigResult` (`ok` may still be false: that is a
|
|
267
|
+
* definitive INVALID, not an unavailability).
|
|
268
|
+
* - `{ kind: 'config-error', error, file }` — the config file itself threw
|
|
269
|
+
* on import (syntax error, a throwing top-level statement, a broken
|
|
270
|
+
* import). A definitive FAIL with the error surfaced.
|
|
271
|
+
* - `{ kind: 'unavailable', reason }` — the harness could not run at all
|
|
272
|
+
* (no studio app, deps not installed, Vite failed to boot, or the check
|
|
273
|
+
* itself broke). An honest `unknown`, with the specific reason.
|
|
274
|
+
*
|
|
275
|
+
* The boot costs seconds, not milliseconds — `log` announces it so a quiet
|
|
276
|
+
* pause in the doctor output is explained (`elytra doctor` is a diagnostic;
|
|
277
|
+
* the price is right).
|
|
278
|
+
*/
|
|
279
|
+
export async function executeWorkspaceConfig({
|
|
280
|
+
root,
|
|
281
|
+
studioDir,
|
|
282
|
+
port,
|
|
283
|
+
log = () => {},
|
|
284
|
+
exists = existsSync,
|
|
285
|
+
resolveDep = resolveFromDir,
|
|
286
|
+
} = {}) {
|
|
287
|
+
const configPath = join(root, CONFIG_FILE)
|
|
288
|
+
const blocked = configHarnessPrecondition({ configPath, studioDir, exists, resolveDep })
|
|
289
|
+
if (blocked) return blocked
|
|
290
|
+
|
|
291
|
+
log(`Executing ${CONFIG_FILE} through the studio's own Vite for the config check (this takes a few seconds)…`)
|
|
292
|
+
|
|
293
|
+
let server
|
|
294
|
+
try {
|
|
295
|
+
const chosenPort = port ?? (await pickPort())
|
|
296
|
+
server = await startConsumerVite({
|
|
297
|
+
root,
|
|
298
|
+
studioDir,
|
|
299
|
+
port: chosenPort,
|
|
300
|
+
plugins: [virtualModulesPlugin('elytra-config-check', { [CONFIG_CHECK_ID]: configCheckModuleSource(configPath) })],
|
|
301
|
+
})
|
|
302
|
+
} catch (error) {
|
|
303
|
+
return { kind: 'unavailable', reason: `The studio's Vite could not start (${errorSummary(error)}) — config validity was not evaluated here.` }
|
|
304
|
+
}
|
|
305
|
+
try {
|
|
306
|
+
const { runCheck } = await server.ssrLoadModule(CONFIG_CHECK_ID)
|
|
307
|
+
const outcome = await runCheck()
|
|
308
|
+
if (outcome.kind === 'config-error') {
|
|
309
|
+
return { kind: 'config-error', error: errorSummary(outcome.error), file: CONFIG_FILE }
|
|
310
|
+
}
|
|
311
|
+
if (outcome.kind === 'harness-error') {
|
|
312
|
+
return { kind: 'unavailable', reason: `The config check could not run (${errorSummary(outcome.error)}) — config validity was not evaluated here.` }
|
|
313
|
+
}
|
|
314
|
+
return { kind: 'resolved', result: outcome.result, file: CONFIG_FILE }
|
|
315
|
+
} catch (error) {
|
|
316
|
+
return { kind: 'unavailable', reason: `The config check could not run (${errorSummary(error)}) — config validity was not evaluated here.` }
|
|
317
|
+
} finally {
|
|
318
|
+
await server.close()
|
|
319
|
+
}
|
|
320
|
+
}
|
package/src/doctor.js
CHANGED
|
@@ -7,6 +7,7 @@ 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'
|
|
12
13
|
import { loadProjectRoutes, routesDoctorChecks } from './routes-check.js'
|
|
@@ -20,8 +21,12 @@ import { loadProjectRoutes, routesDoctorChecks } from './routes-check.js'
|
|
|
20
21
|
* the studio reports it `unknown`; node has no such limit).
|
|
21
22
|
*
|
|
22
23
|
* Read-only: it only fetches the deployment's public URL and its public JWKS endpoint.
|
|
23
|
-
* It never writes env, never provisions.
|
|
24
|
-
*
|
|
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`.
|
|
25
30
|
*/
|
|
26
31
|
|
|
27
32
|
// --- Target resolution -------------------------------------------------------
|
|
@@ -78,8 +83,9 @@ export async function probeReachable(url, fetchImpl = fetch) {
|
|
|
78
83
|
|
|
79
84
|
/**
|
|
80
85
|
* Gather the readiness facts a node CLI can definitively produce for a target. The
|
|
81
|
-
*
|
|
82
|
-
*
|
|
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}).
|
|
83
89
|
*/
|
|
84
90
|
export async function gatherFacts(target, { fetchImpl = fetch } = {}) {
|
|
85
91
|
if (target.kind === 'playground') return { backend: { kind: 'playground' } }
|
|
@@ -93,7 +99,75 @@ export async function gatherFacts(target, { fetchImpl = fetch } = {}) {
|
|
|
93
99
|
reachable,
|
|
94
100
|
authKeys,
|
|
95
101
|
// A configured URL means a deployment is wired; leave `provisioned` unset (→ ok).
|
|
96
|
-
//
|
|
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 } : {}),
|
|
97
171
|
}
|
|
98
172
|
}
|
|
99
173
|
|
|
@@ -307,6 +381,14 @@ export function formatReport(report) {
|
|
|
307
381
|
* That check CAN fail — a conflict is deterministic in code — so it participates in
|
|
308
382
|
* the exit code; a route set that cannot be read reports `unknown` with its reason and
|
|
309
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.
|
|
310
392
|
*/
|
|
311
393
|
export async function runDoctor({
|
|
312
394
|
env = {},
|
|
@@ -319,6 +401,7 @@ export async function runDoctor({
|
|
|
319
401
|
exec,
|
|
320
402
|
previewsArtifacts,
|
|
321
403
|
routeFacts,
|
|
404
|
+
configOutcome,
|
|
322
405
|
} = {}) {
|
|
323
406
|
const target = resolveDoctorTarget(env, { url })
|
|
324
407
|
|
|
@@ -346,6 +429,17 @@ export async function runDoctor({
|
|
|
346
429
|
})
|
|
347
430
|
if (revalidateProbe) facts.revalidateProbe = revalidateProbe
|
|
348
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
|
+
|
|
349
443
|
const report = checkDeploymentReady(facts)
|
|
350
444
|
|
|
351
445
|
// EC-440: the previews bucket (repo artifacts, backend-independent). Appended to
|
|
@@ -356,9 +450,12 @@ export async function runDoctor({
|
|
|
356
450
|
|
|
357
451
|
// EC-469: the route-conflict check, spliced into the `app` bucket it belongs to.
|
|
358
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.
|
|
359
455
|
if (root !== undefined || routeFacts !== undefined) {
|
|
360
|
-
const
|
|
361
|
-
|
|
456
|
+
const loaded = routeFacts ?? (await loadProjectRoutes(root))
|
|
457
|
+
const enriched = configFacts.collections ? { ...loaded, collections: configFacts.collections } : loaded
|
|
458
|
+
checks = spliceBucketChecks(checks, 'app', routesDoctorChecks(enriched))
|
|
362
459
|
}
|
|
363
460
|
|
|
364
461
|
// One rule for the exit code, the same one `checkDeploymentReady` applies: a
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* EC-503 — attribute "the engine has no build output" to its real cause.
|
|
5
|
+
*
|
|
6
|
+
* This CLI loads the engine's COMPILED output under plain Node: `doctor.js`
|
|
7
|
+
* imports `@elytracms/core/export-sync/deployment-ready` and `routes-check.js`
|
|
8
|
+
* imports `@elytracms/core/cms-core`, both of which resolve to
|
|
9
|
+
* `@elytracms/core/dist/...` (no bundler, no `development` export condition).
|
|
10
|
+
* Those are static imports at the top of `src/cli.js`'s graph, so when that dist
|
|
11
|
+
* is absent or half-written NOTHING runs — `elytra --help` itself dies with a raw
|
|
12
|
+
* `ERR_MODULE_NOT_FOUND` stack naming a compiled file the developer never wrote
|
|
13
|
+
* (measured 2026-08-01: moved `dist/export-sync/deployment-ready.js` aside, ran
|
|
14
|
+
* `elytra --help`, got the stack and exit 1).
|
|
15
|
+
*
|
|
16
|
+
* The state is easy to reach and has nothing to do with the person who hits it:
|
|
17
|
+
* every package build starts by DELETING dist (`rmSync('dist')` then `tsc`), so
|
|
18
|
+
* any `pnpm build` / `pnpm verify` / `pnpm typecheck` that dies partway — for a
|
|
19
|
+
* reason anywhere in the workspace — leaves the tree in it. The tool you would
|
|
20
|
+
* reach for to find out what is wrong is the tool that stops working, and its
|
|
21
|
+
* error names the wrong thing.
|
|
22
|
+
*
|
|
23
|
+
* So the CLI's job here is not to recover — it genuinely cannot run without the
|
|
24
|
+
* engine — but to FAIL WITH THE TRUE CAUSE AND ONE COMMAND. This module is the
|
|
25
|
+
* pure decision core; `index.js` is the impure shell that catches and prints it.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** `ERR_MODULE_NOT_FOUND` carries the unresolvable file on `url`; older shapes only in the message. */
|
|
29
|
+
function missingUrlOf(error) {
|
|
30
|
+
if (typeof error?.url === 'string') return error.url
|
|
31
|
+
const match = /Cannot find module '([^']+)'/.exec(String(error?.message ?? ''))
|
|
32
|
+
return match ? match[1] : undefined
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Split an `@elytracms/<pkg>/…/dist/…` path into the package name and the
|
|
37
|
+
* package root. Returns `undefined` for anything that is not an engine dist
|
|
38
|
+
* path — a missing module elsewhere is somebody else's bug and must keep its
|
|
39
|
+
* own error verbatim.
|
|
40
|
+
*/
|
|
41
|
+
export function parseEngineDistPath(path) {
|
|
42
|
+
if (typeof path !== 'string') return undefined
|
|
43
|
+
const match = /(.*\/@elytracms\/([^/]+))\/dist\//.exec(path.replace(/^file:\/\//, ''))
|
|
44
|
+
if (!match) return undefined
|
|
45
|
+
return { packageName: `@elytracms/${match[2]}`, packageRoot: decodeURIComponent(match[1]) }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Is this package a LINKED workspace checkout rather than an installed tarball?
|
|
50
|
+
* Discriminated by what the published artifact contains: `@elytracms/core` ships
|
|
51
|
+
* `files: ["dist"]` and `@elytracms/studio` ships `["dist","convex","src/styles"]`
|
|
52
|
+
* — neither publishes `src/lib`, so its presence means a source checkout. Path
|
|
53
|
+
* shape is NOT usable here: pnpm's workspace link makes a linked package resolve
|
|
54
|
+
* through `node_modules/@elytracms/…` exactly like an installed one (measured —
|
|
55
|
+
* the failing url was `packages/cli/node_modules/@elytracms/core/dist/…`).
|
|
56
|
+
*/
|
|
57
|
+
export function isLinkedWorkspacePackage(packageRoot, exists = existsSync) {
|
|
58
|
+
return exists(`${packageRoot}/src/lib`) || exists(`${packageRoot}/src/index.ts`) || exists(`${packageRoot}/tsconfig.build.json`)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The attributed message for an engine-dist load failure, or `null` when the
|
|
63
|
+
* error is anything else (which must then propagate untouched — swallowing a
|
|
64
|
+
* real bug behind a friendly line is the failure mode this whole file exists to
|
|
65
|
+
* avoid at one level up).
|
|
66
|
+
*/
|
|
67
|
+
export function engineBuildFailureMessage(error, { exists = existsSync } = {}) {
|
|
68
|
+
if (error?.code !== 'ERR_MODULE_NOT_FOUND') return null
|
|
69
|
+
const parsed = parseEngineDistPath(missingUrlOf(error))
|
|
70
|
+
if (!parsed) return null
|
|
71
|
+
const { packageName, packageRoot } = parsed
|
|
72
|
+
const fix = isLinkedWorkspacePackage(packageRoot, exists)
|
|
73
|
+
? ` pnpm --filter ${packageName} build`
|
|
74
|
+
: ' pnpm install --force (the installed package is incomplete)'
|
|
75
|
+
return [
|
|
76
|
+
`elytra: ${packageName} has no usable build output, so no elytra command can run.`,
|
|
77
|
+
'',
|
|
78
|
+
` missing: ${missingUrlOf(error)}`,
|
|
79
|
+
'',
|
|
80
|
+
'The CLI loads the engine’s compiled output under plain Node. A package build',
|
|
81
|
+
'DELETES its dist before writing it, so any interrupted `pnpm build` / `pnpm verify`',
|
|
82
|
+
'— for any reason, anywhere in the workspace — leaves it missing or half-written.',
|
|
83
|
+
'',
|
|
84
|
+
'Fix:',
|
|
85
|
+
fix,
|
|
86
|
+
'',
|
|
87
|
+
].join('\n')
|
|
88
|
+
}
|
package/src/previews.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
2
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
-
import { createRequire } from 'node:module'
|
|
4
|
-
import { createServer as createNetServer } from 'node:net'
|
|
5
3
|
import { join } from 'node:path'
|
|
6
4
|
import { pathToFileURL } from 'node:url'
|
|
5
|
+
import { pickPort, resolveFromDir, startConsumerVite, virtualModulesPlugin } from './config-harness.js'
|
|
7
6
|
|
|
8
7
|
/**
|
|
9
8
|
* `elytra previews` (EC-440) — build-time fixture-preview generation.
|
|
@@ -19,9 +18,8 @@ import { pathToFileURL } from 'node:url'
|
|
|
19
18
|
* the blocks-panel tile);
|
|
20
19
|
* - a previews manifest to `studio/elytra-previews.json` (importable next to
|
|
21
20
|
* `studio.config.tsx`) carrying per entry: componentId, fixture, URL, and
|
|
22
|
-
* an input hash (manifest prop/slot shape + fixture props
|
|
23
|
-
*
|
|
24
|
-
* bytes.
|
|
21
|
+
* an input hash (manifest prop/slot shape + fixture props) — no
|
|
22
|
+
* timestamps, so unchanged inputs re-serialize to the same bytes.
|
|
25
23
|
*
|
|
26
24
|
* Idempotence: an entry whose input hash matches the existing manifest AND
|
|
27
25
|
* whose image file exists is skipped entirely (screenshots are not guaranteed
|
|
@@ -29,10 +27,9 @@ import { pathToFileURL } from 'node:url'
|
|
|
29
27
|
* compare); the manifest is only written when its bytes change. A re-run with
|
|
30
28
|
* unchanged inputs therefore rewrites nothing.
|
|
31
29
|
*
|
|
32
|
-
* Harness: the
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* serves a DATA-FREE render page that imports exactly the consumer registry
|
|
30
|
+
* Harness: the shared consumer-Vite boot (`config-harness.js`, EC-495 — one
|
|
31
|
+
* mechanism for this command and `elytra doctor`'s config check) plus a
|
|
32
|
+
* DATA-FREE render page that imports exactly the consumer registry
|
|
36
33
|
* (`elytra.config.ts`) and the consumer theme. Enumeration + hashing run
|
|
37
34
|
* node-side through the same Vite (`ssrLoadModule`), so `--check` needs no
|
|
38
35
|
* browser; rendering drives headless Chromium via Playwright — an ON-DEMAND
|
|
@@ -53,8 +50,13 @@ export const PREVIEWS_DIR = join('public', 'elytra-previews')
|
|
|
53
50
|
export const PREVIEWS_MANIFEST_FILE = 'elytra-previews.json'
|
|
54
51
|
/** Root-relative URL prefix the manifest entries carry. */
|
|
55
52
|
export const PREVIEWS_URL_PREFIX = '/elytra-previews/'
|
|
56
|
-
/**
|
|
57
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Manifest format version (mirrors `@elytracms/studio`'s PREVIEWS_MANIFEST_VERSION).
|
|
55
|
+
* `2` (EC-502): the entry hash no longer includes the engine version, so v1 hashes
|
|
56
|
+
* are not comparable with v2 ones — a version mismatch is reported as itself
|
|
57
|
+
* ("regenerate"), never as N bogus `stale` entries.
|
|
58
|
+
*/
|
|
59
|
+
export const PREVIEWS_MANIFEST_VERSION = 2
|
|
58
60
|
|
|
59
61
|
/** Capture geometry: 1280×800 CSS pixels (a desktop render), shot at 0.5 → 640×400 (16/10). */
|
|
60
62
|
export const CAPTURE = { width: 1280, height: 800, scale: 0.5 }
|
|
@@ -107,14 +109,28 @@ function sortValue(value) {
|
|
|
107
109
|
/**
|
|
108
110
|
* The staleness input hash: sha-256 over the stable-serialized render inputs —
|
|
109
111
|
* the manifest's prop/slot shape + the fixture's own props/slotChildren (from
|
|
110
|
-
* the studio's `previewEntriesForConfig`)
|
|
111
|
-
*
|
|
112
|
-
*
|
|
112
|
+
* the studio's `previewEntriesForConfig`). Implementation and theme drift are
|
|
113
|
+
* deliberately NOT hashed (the ratified EC-440 split: those are caught by
|
|
114
|
+
* regeneration runs, e.g. in the consumer's build).
|
|
115
|
+
*
|
|
116
|
+
* **EC-502 — the engine version was removed from this hash.** It made every
|
|
117
|
+
* release invalidate every committed preview in every repo, so `elytra previews
|
|
118
|
+
* --check` was red on a healthy tree for a reason that had nothing to do with any
|
|
119
|
+
* entry. Measured on `apps/demo` 2026-08-01: all 23 committed hashes reproduced
|
|
120
|
+
* byte-identically at the manifest's recorded engine (0.0.70) against today's
|
|
121
|
+
* inputs — 0 entries had drifted, yet all 23 reported `stale`, burying the ONE
|
|
122
|
+
* real problem (a new fixture with no image) at line 12 of 24.
|
|
123
|
+
*
|
|
124
|
+
* The engine version is engine IMPLEMENTATION, and implementation is the exact
|
|
125
|
+
* category the ratified split already puts outside `--check`'s remit; treating the
|
|
126
|
+
* engine's as an ERROR while a consumer component's is not even a warning was
|
|
127
|
+
* incoherent, and the incoherence is what produced the permanent red. It is still
|
|
128
|
+
* recorded in the manifest (which engine rendered these images) and still reported
|
|
129
|
+
* on every `--check` run — as a WARNING that does not fail the gate. See
|
|
130
|
+
* {@link checkPreviews}.
|
|
113
131
|
*/
|
|
114
|
-
export function previewEntryHash(hashInput
|
|
115
|
-
return createHash('sha256')
|
|
116
|
-
.update(stableStringify({ engine: engineVersion, input: hashInput }), 'utf8')
|
|
117
|
-
.digest('hex')
|
|
132
|
+
export function previewEntryHash(hashInput) {
|
|
133
|
+
return createHash('sha256').update(stableStringify({ input: hashInput }), 'utf8').digest('hex')
|
|
118
134
|
}
|
|
119
135
|
|
|
120
136
|
/** Serialize the previews manifest deterministically (sorted entry keys, LF, trailing newline). */
|
|
@@ -165,17 +181,45 @@ export function planPreviewRun({ specs, existing, files }) {
|
|
|
165
181
|
|
|
166
182
|
/**
|
|
167
183
|
* `--check` verdicts: compare the enumerated entry set (+ fresh hashes)
|
|
168
|
-
* against the committed manifest + image files.
|
|
169
|
-
*
|
|
184
|
+
* against the committed manifest + image files.
|
|
185
|
+
*
|
|
186
|
+
* Two channels, and the distinction is the whole point (EC-502):
|
|
187
|
+
*
|
|
188
|
+
* - **problems** fail the gate (exit 1). Each one means an artifact does not
|
|
189
|
+
* match what the config declares: a fixture with no preview, a preview whose
|
|
190
|
+
* declared inputs changed, a manifest entry whose image is gone, an entry or
|
|
191
|
+
* file nothing enumerates any more.
|
|
192
|
+
* - **warnings** are reported and do NOT fail. Today that is exactly one:
|
|
193
|
+
* `engine-drift` — these images were rendered by a different engine version.
|
|
194
|
+
* True and worth saying, but it is implementation drift, the same category
|
|
195
|
+
* the ratified EC-440 split already leaves to regeneration runs; as a failure
|
|
196
|
+
* it fired on every entry of every repo on every release and taught people
|
|
197
|
+
* that red means nothing.
|
|
198
|
+
*
|
|
199
|
+
* A manifest written by an older FORMAT version is its own problem: its hashes were
|
|
200
|
+
* computed under different semantics, so comparing them would print one meaningless
|
|
201
|
+
* `stale` line per entry. Say the real thing once, and skip the hash comparison
|
|
202
|
+
* (entry-set and file checks still run — they are version-independent).
|
|
170
203
|
*/
|
|
171
|
-
export function checkPreviews({ specs, manifest, files }) {
|
|
204
|
+
export function checkPreviews({ specs, manifest, files, engine }) {
|
|
172
205
|
const problems = []
|
|
206
|
+
const warnings = []
|
|
173
207
|
if (!manifest) {
|
|
174
208
|
for (const spec of specs) {
|
|
175
209
|
problems.push({ kind: 'missing', componentId: spec.componentId, fixture: spec.fixture })
|
|
176
210
|
}
|
|
177
|
-
return { ok: specs.length === 0, problems }
|
|
211
|
+
return { ok: specs.length === 0, problems, warnings }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const version = typeof manifest.version === 'number' ? manifest.version : 0
|
|
215
|
+
const comparableHashes = version === PREVIEWS_MANIFEST_VERSION
|
|
216
|
+
if (!comparableHashes) {
|
|
217
|
+
problems.push({ kind: 'manifest-version', from: version, to: PREVIEWS_MANIFEST_VERSION })
|
|
218
|
+
}
|
|
219
|
+
if (engine !== undefined && manifest.engine !== undefined && manifest.engine !== engine) {
|
|
220
|
+
warnings.push({ kind: 'engine-drift', from: manifest.engine, to: engine })
|
|
178
221
|
}
|
|
222
|
+
|
|
179
223
|
const entryByKey = new Map(manifest.entries.map((e) => [key(e), e]))
|
|
180
224
|
const specKeys = new Set(specs.map((s) => key(s)))
|
|
181
225
|
|
|
@@ -186,7 +230,7 @@ export function checkPreviews({ specs, manifest, files }) {
|
|
|
186
230
|
continue
|
|
187
231
|
}
|
|
188
232
|
const file = fileOfUrl(entry.url)
|
|
189
|
-
if (entry.hash !== spec.hash) {
|
|
233
|
+
if (comparableHashes && entry.hash !== spec.hash) {
|
|
190
234
|
problems.push({ kind: 'stale', componentId: spec.componentId, fixture: spec.fixture })
|
|
191
235
|
} else if (!file || !files.includes(file)) {
|
|
192
236
|
problems.push({ kind: 'missing-file', componentId: spec.componentId, fixture: spec.fixture, file })
|
|
@@ -203,7 +247,7 @@ export function checkPreviews({ specs, manifest, files }) {
|
|
|
203
247
|
problems.push({ kind: 'orphan-file', file })
|
|
204
248
|
}
|
|
205
249
|
}
|
|
206
|
-
return { ok: problems.length === 0, problems }
|
|
250
|
+
return { ok: problems.length === 0, problems, warnings }
|
|
207
251
|
}
|
|
208
252
|
|
|
209
253
|
/** One line per problem, stable order, for `--check` output. */
|
|
@@ -215,10 +259,24 @@ export function formatCheckProblems(problems) {
|
|
|
215
259
|
'orphan-entry': 'orphaned ',
|
|
216
260
|
'orphan-file': 'stray file',
|
|
217
261
|
}
|
|
218
|
-
return problems.map((p) =>
|
|
219
|
-
p.kind === '
|
|
262
|
+
return problems.map((p) => {
|
|
263
|
+
if (p.kind === 'manifest-version') {
|
|
264
|
+
return ` ✗ manifest written in format v${p.from}, this CLI writes v${p.to} — hashes are not comparable; regenerate`
|
|
265
|
+
}
|
|
266
|
+
return p.kind === 'orphan-file'
|
|
220
267
|
? ` ✗ ${label[p.kind]} ${p.file}`
|
|
221
|
-
: ` ✗ ${label[p.kind]} ${p.componentId} · ${p.fixture}${p.file ? ` (${p.file})` : ''}
|
|
268
|
+
: ` ✗ ${label[p.kind]} ${p.componentId} · ${p.fixture}${p.file ? ` (${p.file})` : ''}`
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** One line per warning — reported, never fatal (EC-502). */
|
|
273
|
+
export function formatCheckWarnings(warnings) {
|
|
274
|
+
return warnings.map((w) =>
|
|
275
|
+
w.kind === 'engine-drift'
|
|
276
|
+
? ` ! engine these images were rendered by engine ${w.from}; installed is ${w.to}. ` +
|
|
277
|
+
'Declared inputs are unchanged, so this does not fail the check — ' +
|
|
278
|
+
'run `elytra previews` when you want the images re-rendered.'
|
|
279
|
+
: ` ! ${w.kind}`,
|
|
222
280
|
)
|
|
223
281
|
}
|
|
224
282
|
|
|
@@ -332,11 +390,6 @@ export function previewsDoctorChecks(artifacts) {
|
|
|
332
390
|
|
|
333
391
|
// --- Impure plumbing ---------------------------------------------------------------
|
|
334
392
|
|
|
335
|
-
/** Resolve a module specifier from a directory's own node_modules world. */
|
|
336
|
-
function resolveFromDir(dir, specifier) {
|
|
337
|
-
return createRequire(join(dir, 'package.json')).resolve(specifier)
|
|
338
|
-
}
|
|
339
|
-
|
|
340
393
|
/**
|
|
341
394
|
* The engine version for the input hash — read from the `@elytracms/studio`
|
|
342
395
|
* (fallback `@elytracms/core`) package the CONSUMER has installed, never
|
|
@@ -390,68 +443,52 @@ const HARNESS_HTML = `<!doctype html>
|
|
|
390
443
|
`
|
|
391
444
|
|
|
392
445
|
/**
|
|
393
|
-
* The inline Vite
|
|
394
|
-
* (the browser render entry; the node-side enumeration
|
|
395
|
-
*
|
|
396
|
-
*
|
|
446
|
+
* The inline Vite plugins serving the DATA-FREE harness: two virtual modules
|
|
447
|
+
* (the browser render entry; the node-side enumeration — via the shared
|
|
448
|
+
* {@link virtualModulesPlugin}) that import exactly the consumer config +
|
|
449
|
+
* theme + the studio previews modules — no studio app code, no persistence,
|
|
450
|
+
* no auth, no env — plus the middleware serving the render page itself.
|
|
397
451
|
*/
|
|
398
|
-
function
|
|
452
|
+
function harnessPlugins({ configPath, themePath }) {
|
|
399
453
|
const configImport = `/@fs/${configPath}`
|
|
400
454
|
const themeImport = `/@fs/${themePath}`
|
|
401
|
-
return
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
.then((html) => {
|
|
430
|
-
res.setHeader('content-type', 'text/html')
|
|
431
|
-
res.end(html)
|
|
432
|
-
})
|
|
433
|
-
.catch(next)
|
|
434
|
-
})
|
|
455
|
+
return [
|
|
456
|
+
virtualModulesPlugin('elytra-previews-harness', {
|
|
457
|
+
[ENTRY_ID]: [
|
|
458
|
+
`import '${themeImport}'`,
|
|
459
|
+
`import config from '${configImport}'`,
|
|
460
|
+
`import { mountPreviewHarness } from '@elytracms/studio/previews/harness'`,
|
|
461
|
+
`mountPreviewHarness(config)`,
|
|
462
|
+
].join('\n'),
|
|
463
|
+
[ENUMERATE_ID]: [
|
|
464
|
+
`import config from '${configImport}'`,
|
|
465
|
+
`import { previewEntriesForConfig } from '@elytracms/studio/previews/enumerate'`,
|
|
466
|
+
`export const entries = previewEntriesForConfig(config)`,
|
|
467
|
+
].join('\n'),
|
|
468
|
+
}),
|
|
469
|
+
{
|
|
470
|
+
name: 'elytra-previews-harness-page',
|
|
471
|
+
configureServer(server) {
|
|
472
|
+
server.middlewares.use((req, res, next) => {
|
|
473
|
+
if (!req.url || req.url.split('?')[0] !== HARNESS_PATH) return next()
|
|
474
|
+
server
|
|
475
|
+
.transformIndexHtml(req.url, HARNESS_HTML)
|
|
476
|
+
.then((html) => {
|
|
477
|
+
res.setHeader('content-type', 'text/html')
|
|
478
|
+
res.end(html)
|
|
479
|
+
})
|
|
480
|
+
.catch(next)
|
|
481
|
+
})
|
|
482
|
+
},
|
|
435
483
|
},
|
|
436
|
-
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
/** First free port in [start, start+span) — never a hardcoded single port. */
|
|
440
|
-
async function pickPort(start = 5440, span = 30) {
|
|
441
|
-
for (let port = start; port < start + span; port += 1) {
|
|
442
|
-
const free = await new Promise((resolve) => {
|
|
443
|
-
const probe = createNetServer()
|
|
444
|
-
probe.once('error', () => resolve(false))
|
|
445
|
-
probe.listen(port, '127.0.0.1', () => probe.close(() => resolve(true)))
|
|
446
|
-
})
|
|
447
|
-
if (free) return port
|
|
448
|
-
}
|
|
449
|
-
throw new Error(`No free port in ${start}–${start + span - 1}.`)
|
|
484
|
+
]
|
|
450
485
|
}
|
|
451
486
|
|
|
452
|
-
/** Boot the consumer
|
|
487
|
+
/** Boot the shared consumer-Vite harness with the previews page + modules. */
|
|
453
488
|
async function startHarnessServer({ root, studioDir, port }) {
|
|
454
|
-
|
|
489
|
+
// Resolve vite first so a missing toolchain names vite (the same failure
|
|
490
|
+
// order as before the boot moved to the shared config-harness).
|
|
491
|
+
resolveFromDir(studioDir, 'vite')
|
|
455
492
|
const tailwindEntry = resolveFromDir(studioDir, '@tailwindcss/vite')
|
|
456
493
|
const tailwind = (await import(pathToFileURL(tailwindEntry).href)).default
|
|
457
494
|
|
|
@@ -464,22 +501,12 @@ async function startHarnessServer({ root, studioDir, port }) {
|
|
|
464
501
|
)
|
|
465
502
|
}
|
|
466
503
|
|
|
467
|
-
const server = await
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
logLevel: 'warn',
|
|
473
|
-
plugins: [tailwind(), harnessPlugin({ configPath, themePath })],
|
|
474
|
-
server: {
|
|
475
|
-
port,
|
|
476
|
-
strictPort: true,
|
|
477
|
-
host: '127.0.0.1',
|
|
478
|
-
fs: { allow: [vite.searchForWorkspaceRoot(studioDir), root] },
|
|
479
|
-
},
|
|
480
|
-
esbuild: { jsx: 'automatic' },
|
|
504
|
+
const server = await startConsumerVite({
|
|
505
|
+
root,
|
|
506
|
+
studioDir,
|
|
507
|
+
port,
|
|
508
|
+
plugins: [tailwind(), ...harnessPlugins({ configPath, themePath })],
|
|
481
509
|
})
|
|
482
|
-
await server.listen()
|
|
483
510
|
return { server, url: `http://127.0.0.1:${port}${HARNESS_PATH}` }
|
|
484
511
|
}
|
|
485
512
|
|
|
@@ -587,7 +614,8 @@ export async function runPreviews(project, { check = false, port, log = (l) => c
|
|
|
587
614
|
const imagesDir = join(studioDir, PREVIEWS_DIR)
|
|
588
615
|
|
|
589
616
|
log(`Elytra previews — fixture preview ${check ? 'staleness check' : 'generation'}`)
|
|
590
|
-
|
|
617
|
+
// EC-502: recorded on the manifest, NOT hashed into the entries.
|
|
618
|
+
log(`Engine version (recorded on the manifest): ${engine}`)
|
|
591
619
|
|
|
592
620
|
const chosenPort = port ? Number(port) : await pickPort()
|
|
593
621
|
let harness
|
|
@@ -604,7 +632,7 @@ export async function runPreviews(project, { check = false, port, log = (l) => c
|
|
|
604
632
|
const specs = entries.map((entry) => ({
|
|
605
633
|
componentId: entry.componentId,
|
|
606
634
|
fixture: entry.fixture,
|
|
607
|
-
hash: previewEntryHash(entry.hashInput
|
|
635
|
+
hash: previewEntryHash(entry.hashInput),
|
|
608
636
|
}))
|
|
609
637
|
log(`Enumerated ${specs.length} fixture preview(s) from elytra.config.ts.`)
|
|
610
638
|
|
|
@@ -612,7 +640,15 @@ export async function runPreviews(project, { check = false, port, log = (l) => c
|
|
|
612
640
|
const existing = parsePreviewsManifest(artifacts.manifestText)
|
|
613
641
|
|
|
614
642
|
if (check) {
|
|
615
|
-
const { ok, problems } = checkPreviews({
|
|
643
|
+
const { ok, problems, warnings } = checkPreviews({
|
|
644
|
+
specs,
|
|
645
|
+
manifest: existing,
|
|
646
|
+
files: artifacts.files,
|
|
647
|
+
engine,
|
|
648
|
+
})
|
|
649
|
+
// Warnings print on BOTH verdicts — a green run that still has something to
|
|
650
|
+
// say says it, and a red run does not swallow it.
|
|
651
|
+
for (const line of formatCheckWarnings(warnings)) log(line)
|
|
616
652
|
if (ok) {
|
|
617
653
|
log(`✓ ${specs.length} preview(s) up to date (hashes match, all files present).`)
|
|
618
654
|
return 0
|
package/src/project.js
CHANGED
|
@@ -6,9 +6,12 @@ import { dirname, join } from 'node:path'
|
|
|
6
6
|
* executing: a consuming repo's layout (`studio/`, `frontend/`, root
|
|
7
7
|
* `elytra.config.ts`) and its `.env` are the source of truth — the same `.env`
|
|
8
8
|
* the studio itself reads to resolve its backend (`VITE_ELYTRA_MODE` /
|
|
9
|
-
* `VITE_CONVEX_URL`).
|
|
10
|
-
*
|
|
11
|
-
* in env regardless.
|
|
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.)
|
|
12
15
|
*/
|
|
13
16
|
|
|
14
17
|
/** Walk up from `start` until a directory containing `elytra.config.ts` is found. */
|
package/src/routes-check.js
CHANGED
|
@@ -42,12 +42,16 @@ import {
|
|
|
42
42
|
* catch-all with a dynamic prefix that every router drops and no mount is built from.
|
|
43
43
|
* All three were measured to resolve `notFound` with zero issues on every surface.
|
|
44
44
|
*
|
|
45
|
-
* **Loading the routes.**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
45
|
+
* **Loading the routes.** This check runs with NO bundler and NO installed deps: a
|
|
46
|
+
* project's `cms/` tree is React-free by convention — measured across `apps/demo`, the
|
|
47
|
+
* scaffold template, and both real consumer repos: every external import is
|
|
48
|
+
* `import type`, erased by Node's own TypeScript support. So doctor imports
|
|
49
|
+
* `cms/index.ts` natively and reads the same `collections` + `routes` that
|
|
50
|
+
* `elytra.config.ts` passes straight through. (`elytra.config.ts` itself is NOT
|
|
51
|
+
* loadable this way — it imports `.tsx` component barrels, which no Node version
|
|
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.)
|
|
51
55
|
*
|
|
52
56
|
* Two things stand in the way, and both are handled rather than assumed away:
|
|
53
57
|
*
|
|
@@ -171,7 +175,13 @@ export async function loadProjectRoutes(root, { importModule } = {}) {
|
|
|
171
175
|
const entry = join(root, CMS_ENTRY)
|
|
172
176
|
if (!existsSync(entry)) {
|
|
173
177
|
return {
|
|
174
|
-
|
|
178
|
+
// EC-533: state what this FALLBACK looked for, not what a project must contain.
|
|
179
|
+
// Elytra requires one thing of a repo — an `elytra.config.ts` of the right shape
|
|
180
|
+
// — and no blessed module paths (EC-532). `cms/index.ts` is the scaffold's own
|
|
181
|
+
// convention, which is all this bundler-free loader can find on its own; where
|
|
182
|
+
// the project's deps are installed, EC-495's Vite harness executes the real
|
|
183
|
+
// config and this floor is never reached.
|
|
184
|
+
unavailable: `Routes not checked: no ${CMS_ENTRY} to read without a bundler (this is the scaffold's convention, not a requirement — your routes may be declared elsewhere).`,
|
|
175
185
|
}
|
|
176
186
|
}
|
|
177
187
|
|