@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.
@@ -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. */