@elytracms/cli 0.0.80 → 0.0.82
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +89 -1
- package/index.js +18 -1
- package/package.json +2 -2
- package/src/cli.js +17 -7
- package/src/config-harness.js +256 -0
- package/src/doctor.js +142 -10
- package/src/engine-build-failure.js +88 -0
- package/src/previews.js +141 -105
- package/src/project.js +6 -3
- package/src/routes-check.js +483 -0
|
@@ -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. */
|