@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 CHANGED
@@ -1,6 +1,23 @@
1
1
  #!/usr/bin/env node
2
2
  import { argv, exit } from 'node:process'
3
- import { run } from './src/cli.js'
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.81",
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": ">=20"
17
+ "node": ">=22.18.0"
18
18
  },
19
19
  "dependencies": {
20
- "@elytracms/core": "^0.0.81"
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. Facts it can't gather from a bundler-less CLI
24
- * (config validity, resolved collections, the auth session) are omitted → `unknown`.
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
- * app/user facts are intentionally omitted (the CLI doesn't execute `elytra.config.ts`
82
- * and has no auth session) → the contract renders them `unknown`.
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
- // config/collections/session omitted → the app + user checks surface as `unknown`.
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 facts = routeFacts ?? (await loadProjectRoutes(root))
361
- checks = spliceBucketChecks(checks, 'app', routesDoctorChecks(facts))
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 + engine
23
- * version) — no timestamps, so unchanged inputs re-serialize to the same
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 CLI boots the consumer studio app's own Vite PROGRAMMATICALLY
33
- * (resolved from the studio dir; a minimal inline config — the studio's
34
- * `vite.config.ts`, with its app plugins/env, is deliberately NOT loaded) and
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
- /** Manifest format version (mirrors `@elytracms/studio`'s PREVIEWS_MANIFEST_VERSION). */
57
- export const PREVIEWS_MANIFEST_VERSION = 1
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`) + the installed engine version.
111
- * Implementation and theme drift are deliberately NOT hashed (the ratified
112
- * split: those are caught by regeneration runs, e.g. in the consumer's build).
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, engineVersion) {
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. Every problem names its
169
- * entry; an empty list is green.
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 === 'orphan-file'
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 plugin serving the DATA-FREE harness: two virtual modules
394
- * (the browser render entry; the node-side enumeration) that import exactly
395
- * the consumer config + theme + the studio previews modules — no studio app
396
- * code, no persistence, no auth, no env.
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 harnessPlugin({ configPath, themePath }) {
452
+ function harnessPlugins({ configPath, themePath }) {
399
453
  const configImport = `/@fs/${configPath}`
400
454
  const themeImport = `/@fs/${themePath}`
401
- return {
402
- name: 'elytra-previews-harness',
403
- resolveId(id) {
404
- return id === ENTRY_ID || id === ENUMERATE_ID ? id : null
405
- },
406
- load(id) {
407
- if (id === ENTRY_ID) {
408
- return [
409
- `import '${themeImport}'`,
410
- `import config from '${configImport}'`,
411
- `import { mountPreviewHarness } from '@elytracms/studio/previews/harness'`,
412
- `mountPreviewHarness(config)`,
413
- ].join('\n')
414
- }
415
- if (id === ENUMERATE_ID) {
416
- return [
417
- `import config from '${configImport}'`,
418
- `import { previewEntriesForConfig } from '@elytracms/studio/previews/enumerate'`,
419
- `export const entries = previewEntriesForConfig(config)`,
420
- ].join('\n')
421
- }
422
- return null
423
- },
424
- configureServer(server) {
425
- server.middlewares.use((req, res, next) => {
426
- if (!req.url || req.url.split('?')[0] !== HARNESS_PATH) return next()
427
- server
428
- .transformIndexHtml(req.url, HARNESS_HTML)
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 studio's Vite with the minimal inline harness config. */
487
+ /** Boot the shared consumer-Vite harness with the previews page + modules. */
453
488
  async function startHarnessServer({ root, studioDir, port }) {
454
- const vite = await import(pathToFileURL(resolveFromDir(studioDir, 'vite')).href)
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 vite.createServer({
468
- configFile: false,
469
- root: studioDir,
470
- mode: 'development',
471
- appType: 'custom',
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
- log(`Engine version (hash input): ${engine}`)
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, engine),
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({ specs, manifest: existing, files: artifacts.files })
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`). We deliberately do NOT load the TS config (no TS runtime
10
- * dependency); the paths are conventional and the operative backend signal lives
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. */
@@ -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.** Doctor deliberately executes no `elytra.config.ts` (it
46
- * imports React components). But a project's `cms/` tree is React-free by convention
47
- * — measured across `apps/demo`, the scaffold template, and both real consumer repos:
48
- * every external import is `import type`, erased by Node's own TypeScript support. So
49
- * doctor imports `cms/index.ts` natively, with no bundler and no Vite, and reads the
50
- * same `collections` + `routes` that `elytra.config.ts` passes straight through.
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
- unavailable: `No ${CMS_ENTRY} found — routes are declared in the project's cms/ directory.`,
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