@elytracms/cli 0.0.91 → 0.0.92

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/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.91",
4
+ "version": "0.0.92",
5
5
  "description": "The Elytra CLI — run a consuming repo's studio, frontend, and Convex backend under one command.",
6
6
  "license": "MIT",
7
7
  "type": "module",
@@ -17,7 +17,7 @@
17
17
  "node": ">=22.18.0"
18
18
  },
19
19
  "dependencies": {
20
- "@elytracms/core": "^0.0.91"
20
+ "@elytracms/core": "^0.0.92"
21
21
  },
22
22
  "devDependencies": {
23
23
  "vitest": "^4.1.10"
package/src/cli.js CHANGED
@@ -1,5 +1,10 @@
1
1
  import { spawnSync } from 'node:child_process'
2
2
  import { stdout } from 'node:process'
3
+ import {
4
+ DEV_FRONTEND_BASE_PORT,
5
+ DEV_FRONTEND_URL_ENV,
6
+ devFrontendUrl,
7
+ } from '@elytracms/core/export-sync/deployment-ready'
3
8
  import { resolveInstalledEngineVersion, runDoctor } from './doctor.js'
4
9
  import { appDisplay } from './layout.js'
5
10
  import { runPreviews } from './previews.js'
@@ -215,15 +220,22 @@ export function planDev(project) {
215
220
  const studioDir = appDir(project, 'studio')
216
221
  const frontendDir = appDir(project, 'frontend')
217
222
  const offset = Number(project.devPortOffset ?? 0) || 0
218
- // The studio's `vite.config.ts` resolves the offset itself, in JS (`loadEnv`) —
219
- // portable anywhere, so forward the lever as-is.
220
- const studioEnv = { ELYTRA_DEV_PORT_OFFSET: String(offset) }
221
- // The frontend's port is resolved HERE instead, and handed over as `PORT` (which
223
+ // The frontend's port is resolved HERE, once, and handed over as `PORT` (which
222
224
  // Next reads from the process env natively). That keeps the scaffold's `dev`
223
225
  // script a plain `next dev`: computing the port inside the npm script needs POSIX
224
226
  // arithmetic (`$(( ))`), which silently breaks `pnpm dev` under cmd.exe on
225
227
  // Windows — every other template script is shell-agnostic and this one stays so.
226
- const frontendEnv = { ELYTRA_DEV_PORT_OFFSET: String(offset), PORT: String(3000 + offset) }
228
+ const frontendPort = DEV_FRONTEND_BASE_PORT + offset
229
+ // The studio's `vite.config.ts` resolves the offset itself, in JS (`loadEnv`) —
230
+ // portable anywhere, so forward the lever as-is. EC-583 — Q15 RATIFIED (EC-565
231
+ // Round 5, 5b): this process starts the frontend and is the one that KNOWS its
232
+ // port, so it hands the studio the dev frontend URL its live links use; the studio
233
+ // prefers it in a dev runtime only, and `elytra.config.ts` keeps reading no env.
234
+ const studioEnv = {
235
+ ELYTRA_DEV_PORT_OFFSET: String(offset),
236
+ ...(project.apps.frontend ? { [DEV_FRONTEND_URL_ENV]: devFrontendUrl(offset) } : {}),
237
+ }
238
+ const frontendEnv = { ELYTRA_DEV_PORT_OFFSET: String(offset), PORT: String(frontendPort) }
227
239
 
228
240
  if (project.apps.studio && project.mode === 'connected') {
229
241
  if (project.convexProvisioned && !project.isProdDeployment) {
@@ -3,6 +3,7 @@ import { createRequire } from 'node:module'
3
3
  import { createServer as createNetServer } from 'node:net'
4
4
  import { join, relative, resolve, sep } from 'node:path'
5
5
  import { pathToFileURL } from 'node:url'
6
+ import { WORKSPACE_CONFIG_FILE } from '@elytracms/core/config'
6
7
 
7
8
  /**
8
9
  * The consumer-Vite harness (EC-495) — the ONE mechanism by which this CLI
@@ -33,8 +34,8 @@ import { pathToFileURL } from 'node:url'
33
34
 
34
35
  // --- Shared plumbing ---------------------------------------------------------------
35
36
 
36
- /** Conventional config file name at the project root (mirrors `WORKSPACE_CONFIG_FILE`). */
37
- export const CONFIG_FILE = 'elytra.config.ts'
37
+ /** The config file name at the project root — core's one declaration (EC-583, RESIDUE-4d). */
38
+ export const CONFIG_FILE = WORKSPACE_CONFIG_FILE
38
39
 
39
40
  /** Resolve a module specifier from a directory's own node_modules world. */
40
41
  export function resolveFromDir(dir, specifier) {
package/src/doctor.js CHANGED
@@ -1,6 +1,3 @@
1
- import { readFileSync } from 'node:fs'
2
- import { createRequire } from 'node:module'
3
- import { dirname, join } from 'node:path'
4
1
  import { orphanedCollections } from '@elytracms/core/cms-core'
5
2
  import {
6
3
  REVALIDATE_ENV_ENDPOINT,
@@ -19,7 +16,7 @@ import { aiRouteDoctorChecks, executeWorkspaceConfig, readStudioAiRoute } from '
19
16
  import { appDisplay, layoutDoctorChecks } from './layout.js'
20
17
  import { convexEnvGet, defaultConvexExec } from './provision.js'
21
18
  import { gatherPreviewsArtifacts, previewsDoctorChecks } from './previews.js'
22
- import { loadProjectRoutes, routesDoctorChecks } from './routes-check.js'
19
+ import { routesDoctorChecks } from './routes-check.js'
23
20
 
24
21
  /**
25
22
  * `elytra doctor` (EC-259) — the pre-boot counterpart to the studio's boot
@@ -145,26 +142,13 @@ export async function gatherFacts(target, { fetchImpl = fetch, installedEngineVe
145
142
  }
146
143
 
147
144
  /**
148
- * Resolve the engine version of the `@elytracms/studio` package INSTALLED in the
149
- * consumer's studio app (EC-552) — the `expected` half of the version handshake.
150
- * Deliberately resolved through the `convex/delivery` export and its adjacent
151
- * `package.json`: that is the very source file the consumer's `convex deploy`
152
- * bundles, so the version read here is by construction the version a redeploy
153
- * would stamp. (The consumer's own `package.json` only carries a RANGE.)
154
- * `undefined` when the deps aren't installed → the fact is omitted → the check
155
- * reports an honest `unknown`, matching the config checks' opportunistic contract.
145
+ * The engine version of the `@elytracms/studio` package INSTALLED in the consumer's
146
+ * studio app (EC-552) — the `expected` half of the version handshake. `undefined` when
147
+ * the deps aren't installed → the fact is omitted → the check reports an honest
148
+ * `unknown`. The one CLI resolver lives in `engine-version.js` (EC-583, RESIDUE-4e);
149
+ * re-exported here for the doctor's existing importers.
156
150
  */
157
- export function resolveInstalledEngineVersion(studioDir) {
158
- if (!studioDir) return undefined
159
- try {
160
- const req = createRequire(join(studioDir, 'package.json'))
161
- const deliverySource = req.resolve('@elytracms/studio/convex/delivery')
162
- const pkg = JSON.parse(readFileSync(join(dirname(deliverySource), '..', 'package.json'), 'utf8'))
163
- return typeof pkg.version === 'string' ? pkg.version : undefined
164
- } catch {
165
- return undefined
166
- }
167
- }
151
+ export { resolveInstalledEngineVersion } from './engine-version.js'
168
152
 
169
153
  // --- Config facts via the consumer-Vite harness (EC-495) -------------------------------
170
154
 
@@ -475,93 +459,34 @@ export function formatReport(report) {
475
459
  }
476
460
 
477
461
  /**
478
- * The route set the doctor's route checks judge (EC-603, RESIDUE-4): the RESOLVED
479
- * config's routes + collections when the harness resolved it validly — the same records
480
- * the studio mounts and delivery serves, whatever file they were declared in — else the
481
- * native `cms/index.ts` floor ({@link loadProjectRoutes}, or the injected `routeFacts`),
482
- * with the resolved collections swapped in when there are some. `source` says which, so
483
- * the report can. Never throws.
462
+ * What the doctor's route checks judge (EC-590; RESIDUE-4a/4b): the RESOLVED project's
463
+ * routes, collections and locales — the same records the studio mounts and delivery
464
+ * serves — and, when the deployment's public delivery snapshot was read, the enabled
465
+ * editorial redirects it serves. No resolved config → `unavailable`, with the reason:
466
+ * there is no second reading of the routes (the native `cms/index.ts` floor is gone).
467
+ * Pure; never throws.
484
468
  */
485
- export async function routeFactsFor({ outcome, configFacts = {}, routeFacts, root }) {
469
+ export function routeFactsFor({ outcome, configFacts = {}, snapshot }) {
486
470
  const project = resolvedProjectOf(outcome)
487
- if (project && Array.isArray(project.routes)) {
488
- return {
471
+ if (!project || !Array.isArray(project.routes)) {
472
+ const reason =
473
+ configFacts.config !== undefined && !configFacts.config.valid
474
+ ? 'fix the workspace config issues first (see config-valid).'
475
+ : (configFacts.configUnavailableDetail ?? 'elytra.config.ts was not evaluated here.')
476
+ return { unavailable: `Routes were not checked — ${reason}` }
477
+ }
478
+ return {
479
+ project: {
489
480
  routes: project.routes,
490
- routeCount: project.routes.length,
491
481
  ...(Array.isArray(project.collections) ? { collections: project.collections } : {}),
492
- source: 'config',
493
- }
494
- }
495
- const loaded = routeFacts ?? (await loadProjectRoutes(root))
496
- const enriched = configFacts.collections ? { ...loaded, collections: configFacts.collections } : loaded
497
- return { ...enriched, source: 'native' }
498
- }
499
-
500
- // --- Frontend: `cacheComponents` (EC-515) --------------------------------------
501
-
502
- /** The `next.config` file names Next itself resolves, in its order. */
503
- export const NEXT_CONFIG_FILES = ['next.config.js', 'next.config.mjs', 'next.config.ts', 'next.config.mts', 'next.config.cjs']
504
-
505
- /**
506
- * Read the frontend's `next.config` for the `cacheComponents` check — the file name and
507
- * its text, or `{ configFile: null }` when the app has none. `undefined` when there is no
508
- * frontend app to read (a studio-only repo: no row at all). Text only: the CLI runs
509
- * without the consumer's `node_modules`, and a `next.config` imports Next-side modules.
510
- * `readFile` is injectable for tests.
511
- */
512
- export function gatherFrontendCacheComponents(frontendDir, readFile = (p) => readFileSync(p, 'utf8')) {
513
- if (!frontendDir) return undefined
514
- for (const name of NEXT_CONFIG_FILES) {
515
- try {
516
- return { configFile: name, text: readFile(join(frontendDir, name)) }
517
- } catch {
518
- // not this name — try the next
519
- }
520
- }
521
- return { configFile: null, text: '' }
522
- }
523
-
524
- /**
525
- * The `frontend-cache-components` row (`app` bucket). Since EC-515 the delivery kit's
526
- * cache is a `'use cache'` entry, which Next compiles only with `cacheComponents: true`
527
- * — without it `next build` fails under both bundlers (measured: "To use "use cache",
528
- * please enable the feature flag `cacheComponents`"). So a missing flag is a `fail`:
529
- * deterministic in the repo, and the build would fail anyway — so is a literal `false`. A
530
- * `cacheComponents` key that is neither literal (computed from env, say) is a `warn` —
531
- * the doctor reads text and cannot evaluate it. No frontend → no row. Pure.
532
- */
533
- export function cacheComponentsDoctorChecks(facts, { frontendLabel = 'frontend' } = {}) {
534
- if (!facts) return []
535
- const base = { id: 'frontend-cache-components', bucket: 'app' }
536
- const fix = `Set \`cacheComponents: true\` in ${frontendLabel}/next.config.mjs, and export \`generateStaticParams = route.generateStaticParams\` from the canvas catch-all page (see the upgrading notes for EC-515).`
537
- if (facts.configFile === null) {
538
- return [{ ...base, status: 'fail', detail: `${frontendLabel} has no next.config — the delivery kit needs \`cacheComponents: true\`.`, fix }]
539
- }
540
- const where = `${frontendLabel}/${facts.configFile}`
541
- if (/\bcacheComponents\s*:\s*true\b/.test(facts.text)) {
542
- return [{ ...base, status: 'ok', detail: `${where} enables \`cacheComponents\` (the kit's 'use cache' delivery cache).` }]
543
- }
544
- if (/\bcacheComponents\s*:\s*false\b/.test(facts.text)) {
545
- return [{ ...base, status: 'fail', detail: `${where} sets \`cacheComponents: false\` — \`next build\` fails on the delivery kit's 'use cache' cache.`, fix }]
546
- }
547
- if (/\bcacheComponents\b/.test(facts.text)) {
548
- return [
549
- {
550
- ...base,
551
- status: 'warn',
552
- detail: `${where} sets \`cacheComponents\` to something other than the literal \`true\` — the doctor cannot evaluate it. \`next build\` fails if it is not true.`,
553
- fix,
554
- },
555
- ]
556
- }
557
- return [
558
- {
559
- ...base,
560
- status: 'fail',
561
- detail: `${where} does not enable \`cacheComponents\` — \`next build\` fails on the delivery kit's 'use cache' cache.`,
562
- fix,
482
+ ...(project.locales ? { locales: project.locales } : {}),
563
483
  },
564
- ]
484
+ ...(snapshot?.status === 'answered'
485
+ ? { redirects: snapshot.redirects }
486
+ : snapshot
487
+ ? { redirectsUnavailable: `Redirects were not read from the deployment — ${snapshot.detail}` }
488
+ : {}),
489
+ }
565
490
  }
566
491
 
567
492
  // --- Command -----------------------------------------------------------------
@@ -621,11 +546,11 @@ export function cacheComponentsDoctorChecks(facts, { frontendLabel = 'frontend'
621
546
  * resolved it (the routes the studio and delivery actually serve); the native
622
547
  * `cms/index.ts` read is the fallback only when it could not, and the row says which.
623
548
  *
624
- * EC-515: when the layout resolved a frontend app, the `app` bucket reports
625
- * `frontend-cache-components` — whether its `next.config` enables `cacheComponents`,
626
- * which the delivery kit's `'use cache'` cache requires ({@link
627
- * cacheComponentsDoctorChecks}). `frontendCacheComponents` (the gathered facts) is the
628
- * test seam.
549
+ * EC-620: the doctor checks NO Next flag. Caching is the host's — the delivery kit holds
550
+ * no cache scope, so a frontend builds with `cacheComponents`, `experimental.useCache`
551
+ * or neither. (EC-515's `frontend-cache-components` row, which failed a frontend without
552
+ * `cacheComponents`, is deleted.) The engine's part — the invalidation signal — is the
553
+ * revalidate probe above.
629
554
  */
630
555
  export async function runDoctor({
631
556
  env = {},
@@ -642,7 +567,6 @@ export async function runDoctor({
642
567
  routeFacts,
643
568
  configOutcome,
644
569
  installedEngineVersion,
645
- frontendCacheComponents,
646
570
  } = {}) {
647
571
  const studioLabel = appDisplay(layout, 'studio')
648
572
  const target = resolveDoctorTarget(env, { url, envSource: `${studioLabel}/.env` })
@@ -695,11 +619,18 @@ export async function runDoctor({
695
619
 
696
620
  // EC-606: published documents of undeclared collections — one more request on the
697
621
  // same public query API, made only when the config resolved (it needs the declared set).
622
+ // EC-590: the SAME snapshot read carries the deployment's redirects for the route checks.
623
+ const project = resolvedProjectOf(outcome)
624
+ const snapshot =
625
+ target?.kind === 'convex' && project && Array.isArray(project.collections)
626
+ ? await probeDeliverySnapshot(target.url, project.slug, fetchImpl)
627
+ : undefined
698
628
  const publishedOrphans = await gatherPublishedOrphans({
699
629
  target,
700
- project: resolvedProjectOf(outcome),
630
+ project,
701
631
  configFacts,
702
632
  fetchImpl,
633
+ ...(snapshot ? { probe: async () => snapshot } : {}),
703
634
  })
704
635
  if (publishedOrphans) facts.publishedOrphans = publishedOrphans
705
636
 
@@ -712,28 +643,19 @@ export async function runDoctor({
712
643
  let checks = artifacts ? [...report.checks, ...previewsDoctorChecks(artifacts)] : [...report.checks]
713
644
 
714
645
  // EC-603: the layout and AI-route rows, in the `app` bucket (both repo facts).
715
- // EC-515: the frontend's `cacheComponents` flag — a repo fact, any backend mode.
716
- const frontend = layout?.entries?.frontend
717
646
  checks = spliceBucketChecks(checks, 'app', [
718
647
  ...layoutDoctorChecks(layout),
719
648
  ...aiRouteDoctorChecks(aiRoute, { studioLabel }),
720
- ...cacheComponentsDoctorChecks(
721
- frontendCacheComponents ?? gatherFrontendCacheComponents(frontend?.found ? frontend.path : undefined),
722
- { frontendLabel: appDisplay(layout, 'frontend') },
723
- ),
724
649
  ])
725
650
 
726
- // EC-469: the route-conflict check, spliced into the `app` bucket it belongs to.
727
- // Backend-independent (routes are CODE) — it runs in playground mode too.
728
- // EC-603 (RESIDUE-4): when the harness resolved the config, the checks run over ITS
729
- // routes and collections — what the studio and delivery serve; the native
730
- // `cms/index.ts` read is the fallback for when it could not (EC-495 had swapped in the
731
- // resolved collections only, so the routes still came from `cms/index.ts`).
732
- if (root !== undefined || routeFacts !== undefined) {
651
+ // EC-469/EC-590: the route checks — the one router's issue set over the resolved
652
+ // project (and the deployment's redirects when the snapshot was read), spliced into the
653
+ // `app` bucket. Routes are CODE, so the rows run in playground mode too.
654
+ if (root !== undefined || routeFacts !== undefined || configOutcome !== undefined) {
733
655
  checks = spliceBucketChecks(
734
656
  checks,
735
657
  'app',
736
- routesDoctorChecks(await routeFactsFor({ outcome, configFacts, routeFacts, root })),
658
+ routesDoctorChecks(routeFacts ?? routeFactsFor({ outcome, configFacts, snapshot })),
737
659
  )
738
660
  }
739
661
 
@@ -0,0 +1,32 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { createRequire } from 'node:module'
3
+ import { dirname, join } from 'node:path'
4
+
5
+ /**
6
+ * The ONE "installed engine version" resolver of the CLI (EC-583, RESIDUE-4e) — read by
7
+ * `elytra doctor`/`init` (the `expected` half of the EC-552 version handshake) and by
8
+ * `elytra previews` (the version recorded on the manifest). The two used to resolve the
9
+ * same fact two ways: the doctor through the `@elytracms/studio/convex/delivery` export,
10
+ * previews through hand-joined `node_modules` paths with a silent `@elytracms/core`
11
+ * fallback.
12
+ *
13
+ * Resolved through the `convex/delivery` export and its adjacent `package.json`: that is
14
+ * the very source file the consumer's `convex deploy` bundles, so the version read here
15
+ * is by construction the version a redeploy would stamp. (The consumer's own
16
+ * `package.json` only carries a RANGE.) `undefined` when the studio's deps are not
17
+ * installed — callers report an honest `unknown`, never a guessed version.
18
+ *
19
+ * @param {string | undefined | null} studioDir the resolved studio app (EC-603 layout)
20
+ * @returns {string | undefined}
21
+ */
22
+ export function resolveInstalledEngineVersion(studioDir) {
23
+ if (!studioDir) return undefined
24
+ try {
25
+ const req = createRequire(join(studioDir, 'package.json'))
26
+ const deliverySource = req.resolve('@elytracms/studio/convex/delivery')
27
+ const pkg = JSON.parse(readFileSync(join(dirname(deliverySource), '..', 'package.json'), 'utf8'))
28
+ return typeof pkg.version === 'string' ? pkg.version : undefined
29
+ } catch {
30
+ return undefined
31
+ }
32
+ }
package/src/previews.js CHANGED
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync
3
3
  import { dirname, isAbsolute, join, relative, sep } from 'node:path'
4
4
  import { pathToFileURL } from 'node:url'
5
5
  import { CONFIG_FILE, pickPort, resolveFromDir, startConsumerVite, virtualModulesPlugin } from './config-harness.js'
6
+ import { resolveInstalledEngineVersion } from './engine-version.js'
6
7
  import { appDisplay, resolveProjectLayout } from './layout.js'
7
8
 
8
9
  /**
@@ -558,28 +559,6 @@ export function previewsDoctorChecks(artifacts) {
558
559
 
559
560
  // --- Impure plumbing ---------------------------------------------------------------
560
561
 
561
- /**
562
- * The engine version for the input hash — read from the `@elytracms/studio`
563
- * (fallback `@elytracms/core`) package the CONSUMER has installed, never
564
- * hardcoded. Direct node_modules paths (pnpm keeps symlinks for direct deps).
565
- * `studioDir` is the resolved studio app (EC-603 layout).
566
- */
567
- export function resolveEngineVersion(root, studioDir) {
568
- const bases = [studioDir, root]
569
- for (const pkg of ['@elytracms/studio', '@elytracms/core']) {
570
- for (const base of bases) {
571
- const path = join(base, 'node_modules', ...pkg.split('/'), 'package.json')
572
- if (!existsSync(path)) continue
573
- try {
574
- const version = JSON.parse(readFileSync(path, 'utf8')).version
575
- if (typeof version === 'string' && version.length > 0) return version
576
- } catch {
577
- // fall through to the next candidate
578
- }
579
- }
580
- }
581
- return 'unknown'
582
- }
583
562
 
584
563
  /**
585
564
  * The consumer theme entry the harness page imports: the studio's canvas
@@ -671,7 +650,7 @@ async function startHarnessServer({ root, studioDir, layout, port }) {
671
650
  const tailwindEntry = resolveFromDir(studioDir, '@tailwindcss/vite')
672
651
  const tailwind = (await import(pathToFileURL(tailwindEntry).href)).default
673
652
 
674
- const configPath = join(root, 'elytra.config.ts')
653
+ const configPath = join(root, CONFIG_FILE)
675
654
  const themePath = themeStylesheetPath(root, layout)
676
655
  if (!themePath) {
677
656
  const studio = appDisplay(layout, 'studio')
@@ -813,7 +792,8 @@ export async function runPreviews(project, { check = false, port, log = (l) => c
813
792
  const layout = project.layout ?? resolveProjectLayout(root)
814
793
  const studioDir = layout.entries.studio.path
815
794
  const studioLabel = layout.entries.studio.display
816
- const engine = resolveEngineVersion(root, studioDir)
795
+ // EC-583 (RESIDUE-4e): the CLI's one installed-engine-version resolver.
796
+ const engine = resolveInstalledEngineVersion(studioDir) ?? 'unknown'
817
797
  const manifestPath = join(studioDir, PREVIEWS_MANIFEST_FILE)
818
798
  const imagesDir = join(studioDir, PREVIEWS_DIR)
819
799
 
package/src/project.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
2
  import { dirname, join } from 'node:path'
3
+ import { WORKSPACE_CONFIG_FILE } from '@elytracms/core/config'
3
4
  import { parseBackendMode } from '@elytracms/core/export-sync/deployment-ready'
4
5
  import { appDisplay, resolveProjectLayout } from './layout.js'
5
6
 
@@ -34,7 +35,7 @@ import { appDisplay, resolveProjectLayout } from './layout.js'
34
35
  export function findRepoRoot(start) {
35
36
  let dir = start
36
37
  for (;;) {
37
- if (existsSync(join(dir, 'elytra.config.ts'))) return dir
38
+ if (existsSync(join(dir, WORKSPACE_CONFIG_FILE))) return dir
38
39
  const parent = dirname(dir)
39
40
  if (parent === dir) return null
40
41
  dir = parent
package/src/provision.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { spawnSync } from 'node:child_process'
2
2
  import { generateKeyPairSync } from 'node:crypto'
3
+ import { AUTH_SIGNING_KEY_ENV } from '@elytracms/core/export-sync/deployment-ready'
3
4
 
4
5
  /**
5
6
  * First-run auto-provisioning for the Elytra CLI (EC-259). Folds the Convex Auth
@@ -42,8 +43,8 @@ export function generateAuthKeys() {
42
43
  const jwk = publicKey.export({ format: 'jwk' })
43
44
  return {
44
45
  // Match @convex-dev/auth: PKCS#8 PEM, trailing whitespace trimmed, newlines → spaces.
45
- JWT_PRIVATE_KEY: pkcs8.trimEnd().replace(/\n/g, ' '),
46
- JWKS: JSON.stringify({ keys: [{ use: 'sig', ...jwk }] }),
46
+ [AUTH_SIGNING_KEY_ENV.privateKey]: pkcs8.trimEnd().replace(/\n/g, ' '),
47
+ [AUTH_SIGNING_KEY_ENV.publicKeys]: JSON.stringify({ keys: [{ use: 'sig', ...jwk }] }),
47
48
  }
48
49
  }
49
50
 
@@ -83,8 +84,8 @@ export function convexEnvSet(name, value, { cwd, exec = defaultConvexExec } = {}
83
84
  /** Which of the two auth signing-key vars are already present on the deployment. */
84
85
  export function detectKeyState({ cwd, exec } = {}) {
85
86
  return {
86
- jwt: convexEnvGet('JWT_PRIVATE_KEY', { cwd, exec }) !== '',
87
- jwks: convexEnvGet('JWKS', { cwd, exec }) !== '',
87
+ jwt: convexEnvGet(AUTH_SIGNING_KEY_ENV.privateKey, { cwd, exec }) !== '',
88
+ jwks: convexEnvGet(AUTH_SIGNING_KEY_ENV.publicKeys, { cwd, exec }) !== '',
88
89
  }
89
90
  }
90
91
 
@@ -110,8 +111,9 @@ export function ensureAuthKeys({ cwd, exec, log = () => {}, generate = generateA
110
111
  return { action: 'partial', state }
111
112
  }
112
113
  const keys = generate()
113
- convexEnvSet('JWT_PRIVATE_KEY', keys.JWT_PRIVATE_KEY, { cwd, exec })
114
- convexEnvSet('JWKS', keys.JWKS, { cwd, exec })
114
+ for (const name of [AUTH_SIGNING_KEY_ENV.privateKey, AUTH_SIGNING_KEY_ENV.publicKeys]) {
115
+ convexEnvSet(name, keys[name], { cwd, exec })
116
+ }
115
117
  log('✓ Generated and set the Convex Auth signing keys (JWT_PRIVATE_KEY + JWKS).')
116
118
  return { action: 'generated', state }
117
119
  }
@@ -1,329 +1,50 @@
1
- import { existsSync } from 'node:fs'
2
- import { join } from 'node:path'
3
- import { fileURLToPath, pathToFileURL } from 'node:url'
4
- import {
5
- createRouter,
6
- declaredMounts,
7
- hierarchyMountConflicts,
8
- isHierarchyTemplate,
9
- routeRecordSchema,
10
- routeReachabilityIssues,
11
- } from '@elytracms/core/cms-core'
1
+ import { createRouter, declaredMounts } from '@elytracms/core/cms-core'
12
2
 
13
3
  /**
14
- * `elytra doctor`'s route check (EC-469) — the CODE side of route validation.
15
- *
16
- * `createRouter` computes real structural issues and, before this, no surface showed
17
- * them: the studio has had no Routes screen since EC-303 (correctly — routes are
18
- * code), and doctor deferred structure to "the studio boot and `elytra build`",
19
- * neither of which checked routes. A route conflict shipped silently.
20
- *
21
- * **What lives here and why only this.** `createRouter` produces exactly two codes —
22
- * `route-conflict` (from routes alone) and `redirect-loop` (from redirects). Since
23
- * EC-303 redirects are editorial DATA in the backend, so a bundler-less, session-less
24
- * CLI cannot see them; and `unknown-route-target` is not a router issue at all — the
25
- * *resolver* raises it per URL against the documents that exist
26
- * (`core/src/content/client.ts`). So the honest CLI-side answer is **route conflicts**,
27
- * and the other two are the studio's (which holds both the routes and the redirects).
28
- * The `redirect-loops` check below says exactly that, as an `unknown` — the same
29
- * convention `config-valid` already uses for a fact the CLI cannot gather.
30
- *
31
- * EC-482 adds the half the router structurally cannot see: **hierarchy mounts**. They
32
- * are filtered OUT of the router (below, and identically in delivery), so two mounts
33
- * fighting over the same URLs were invisible to every surface. `hierarchyMountConflicts`
34
- * judges them by declaration instead of by pattern shape; the second check row reports
35
- * it. Both checks are code-side and mutually exclusive by construction — a mount never
36
- * reaches the router, an ordinary route never reaches the mount detector.
37
- *
38
- * EC-484 adds the third row, `unreachable-routes`, for the case where nothing is in
39
- * conflict and the declaration simply does not work: a `/:slug`-shaped route that
40
- * matches every URL at a depth the mount serves (so the mount is never reached and
41
- * existing pages 404), a mount whose collection cannot compose nested paths, and a
42
- * catch-all with a dynamic prefix that every router drops and no mount is built from.
43
- * All three were measured to resolve `notFound` with zero issues on every surface.
44
- *
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 ROUTES and collections it resolved
54
- * to these checks in place of the `cms/index.ts` re-export — EC-603; before, only the
55
- * collections were swapped in. This loader stays the floor, and the first row says
56
- * which of the two was judged.)
57
- *
58
- * Two things stand in the way, and both are handled rather than assumed away:
59
- *
60
- * 1. **Node floor.** Native TS needs Node ≥22.18 (measured: 22.13 → `ERR_UNKNOWN_
61
- * FILE_EXTENSION`, 22.21 → loads). Below the floor the check reports `unknown`
62
- * with the version as its reason — the CLI's own `engines` promise (>=20) is not
63
- * quietly broken.
64
- * 2. **Bundler-style specifiers.** Real `cms/` trees write `from './collections'` —
65
- * extensionless, and a directory at that. Plain Node ESM resolves neither, at any
66
- * version. {@link withBundlerResolution} registers a *fallback* resolve hook that
67
- * supplies the extension/index a bundler would, and only after Node's own
68
- * resolution has already failed — so it can never hijack a specifier that already
69
- * resolves.
70
- *
71
- * Executing consumer code in a tool that otherwise only does HTTP is a change in
72
- * kind, so it is bounded: one conventional file, read-only, no writes, and every
73
- * failure mode (missing file, throwing module, unresolvable import, non-erasable TS,
74
- * a `routes` export of the wrong shape, a derive that throws) degrades to a reported
75
- * `unknown` carrying its reason. There is no path here that crashes the doctor and
76
- * none that reports a pass it did not establish.
77
- */
78
-
79
- // --- Loading the project's routes (impure) -----------------------------------
80
-
81
- /** Where the route set is read from, by convention (AD-11: structure is code, in-repo). */
82
- export const CMS_ENTRY = join('cms', 'index.ts')
83
-
84
- /** Relative-specifier suffixes a bundler would try, in the order it tries them. */
85
- const RESOLVE_CANDIDATES = ['.ts', '.tsx', '/index.ts', '/index.tsx', '.js', '.mjs', '/index.js']
86
-
87
- /**
88
- * Run `load` with a FALLBACK module-resolution hook that supplies the extension or
89
- * `/index` a bundler would for a relative specifier — but only for specifiers Node
90
- * itself could not resolve, so an already-valid specifier is never rewritten. The
91
- * hook is deregistered before returning, in success and in failure alike: doctor
92
- * never leaves process-wide resolution patched.
93
- *
94
- * `module.registerHooks` is Node ≥22.15. Where it is absent the load simply runs
95
- * unhooked (a `cms/` tree with explicit extensions still works) — never an error.
96
- */
97
- export async function withBundlerResolution(load) {
98
- let handle
99
- try {
100
- const { registerHooks } = await import('node:module')
101
- if (typeof registerHooks === 'function') {
102
- handle = registerHooks({
103
- resolve(specifier, context, nextResolve) {
104
- try {
105
- return nextResolve(specifier, context)
106
- } catch (error) {
107
- if (!specifier.startsWith('./') && !specifier.startsWith('../')) throw error
108
- if (!context.parentURL) throw error
109
- for (const suffix of RESOLVE_CANDIDATES) {
110
- const url = new URL(specifier + suffix, context.parentURL)
111
- if (url.protocol === 'file:' && existsSync(fileURLToPath(url))) {
112
- return { url: url.href, shortCircuit: true }
113
- }
114
- }
115
- throw error
116
- }
117
- },
118
- })
119
- }
120
- } catch {
121
- // No resolve-hook support in this runtime — load unhooked.
122
- }
123
- try {
124
- return await load()
125
- } finally {
126
- handle?.deregister()
127
- }
128
- }
129
-
130
- /**
131
- * Silence ONE Node warning for the duration of `run`:
132
- * `MODULE_TYPELESS_PACKAGE_JSON`, which fires for every consumer repo whose root
133
- * `package.json` omits `"type": "module"` (both real ones). It is unrelated to route
134
- * validity and would print into the middle of the doctor report. Every other warning
135
- * still prints, and the original listeners are restored unconditionally.
4
+ * `elytra doctor`'s route checks (EC-469, EC-482, EC-484) — READING the one router's
5
+ * issue set (EC-590; EC-565 Round 3, 3h).
6
+ *
7
+ * `createRouter` (cms-core `router.ts`) owns the project's ONE route issue set — exact
8
+ * ties between ordinary routes (`route-conflict`), the one normalised redirect set
9
+ * (`redirect-loop`, `invalid-redirect`), hierarchy-mount ties, and reachability — and
10
+ * the studio and the delivery host read the same set. This module only reports it, in
11
+ * the four rows a reader needs, from the router's `issueGroups`.
12
+ *
13
+ * **The routes are the resolved config's** (EC-583/EC-590, RESIDUE-4a/4b): the routes and
14
+ * collections `elytra.config.ts` resolves to through the consumer studio's own Vite
15
+ * (EC-495) — what the studio mounts and delivery serves. The bundler-free `cms/index.ts`
16
+ * floor (a native import of the scaffold's own convention, with its own copy of the
17
+ * routes materialisation) is gone: when the config cannot be resolved here, the rows say
18
+ * so as `unknown`, never a judgement over a second reading of the routes.
19
+ *
20
+ * **The redirects are the deployment's** (EC-590): the enabled editorial redirects the
21
+ * public delivery snapshot serves, read on the same request `published-orphans` makes.
22
+ * Without a deployment to read (playground, no URL) `redirect-loops` is an honest
23
+ * `unknown`.
136
24
  */
137
- async function withoutTypelessModuleWarning(run) {
138
- const listeners = process.listeners('warning')
139
- process.removeAllListeners('warning')
140
- const passthrough = (warning) => {
141
- if (warning?.code === 'MODULE_TYPELESS_PACKAGE_JSON') return
142
- for (const listener of listeners) listener(warning)
143
- }
144
- process.on('warning', passthrough)
145
- try {
146
- return await run()
147
- } finally {
148
- process.off('warning', passthrough)
149
- for (const listener of listeners) process.on('warning', listener)
150
- }
151
- }
152
-
153
- /** The first line of an error, safe for a one-line report row. */
154
- function reason(error) {
155
- const text = error instanceof Error ? error.message : String(error)
156
- return text.split('\n')[0].trim()
157
- }
158
25
 
159
26
  /**
160
- * Materialize a `routes` config value against the declared collections — the same
161
- * rule `studio-core`'s `materializeRecords` applies (EC-207): a derive function runs
162
- * once, a static array passes through. Both real consumer repos declare the array
163
- * form; the demo and the scaffold declare the derive.
27
+ * @typedef {{ routes: readonly import('@elytracms/core/cms-core').RouteRecord[];
28
+ * collections?: readonly import('@elytracms/core/cms-core').CollectionDef[];
29
+ * locales?: import('@elytracms/core/cms-core').LocaleConfig }} RouteProject
30
+ * @typedef {{ project: RouteProject;
31
+ * redirects?: readonly import('@elytracms/core/cms-core').RedirectRecord[];
32
+ * redirectsUnavailable?: string } | { unavailable: string; fix?: string }} RouteFacts
164
33
  */
165
- function materializeRoutes(routes, collections) {
166
- return typeof routes === 'function' ? routes(collections) : [...routes]
167
- }
168
-
169
- /**
170
- * Load the project's declared route set. Returns either `{ routes, routeCount }` or
171
- * `{ unavailable, fix? }` — an honest reason the routes could not be read. Never
172
- * throws.
173
- *
174
- * `importModule` is injectable for tests (the default is a native dynamic import).
175
- */
176
- export async function loadProjectRoutes(root, { importModule } = {}) {
177
- const entry = join(root, CMS_ENTRY)
178
- if (!existsSync(entry)) {
179
- return {
180
- // EC-533: state what this FALLBACK looked for, not what a project must contain.
181
- // Elytra requires one thing of a repo — an `elytra.config.ts` of the right shape
182
- // — and no blessed module paths (EC-532). `cms/index.ts` is the scaffold's own
183
- // convention, which is all this bundler-free loader can find on its own; where
184
- // the project's deps are installed, EC-495's Vite harness executes the real
185
- // config and this floor is never reached.
186
- 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).`,
187
- }
188
- }
189
-
190
- const load = importModule ?? ((path) => import(pathToFileURL(path).href))
191
- let mod
192
- try {
193
- mod = await withoutTypelessModuleWarning(() => withBundlerResolution(() => load(entry)))
194
- } catch (error) {
195
- if (error?.code === 'ERR_UNKNOWN_FILE_EXTENSION') {
196
- return {
197
- unavailable: `${CMS_ENTRY} could not be read: this Node (${process.version}) does not run TypeScript natively.`,
198
- fix: 'Use Node >=22.18 (the version the engine targets) to have `elytra doctor` check routes.',
199
- }
200
- }
201
- if (error?.code === 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX') {
202
- return {
203
- unavailable: `${CMS_ENTRY} uses TypeScript syntax Node cannot strip (${reason(error)}).`,
204
- fix: 'Keep cms/ to erasable TypeScript (no enum/namespace/parameter properties) so build-time tools can read your structure.',
205
- }
206
- }
207
- return { unavailable: `${CMS_ENTRY} could not be loaded: ${reason(error)}` }
208
- }
209
-
210
- if (mod?.routes === undefined) {
211
- return {
212
- unavailable: `${CMS_ENTRY} exports no \`routes\` — the route set was not checked here.`,
213
- }
214
- }
215
- if (typeof mod.routes !== 'function' && !Array.isArray(mod.routes)) {
216
- return {
217
- unavailable: `${CMS_ENTRY} exports \`routes\` that is neither a RouteRecord[] nor a (collections) => RouteRecord[].`,
218
- }
219
- }
220
- // Distinguish "exports no collections" from "exports an empty array": the
221
- // reachability check must report NOT CHECKED rather than a pass when it cannot see
222
- // the collection set (a mount's target is judged against it).
223
- const collections = Array.isArray(mod.collections) ? mod.collections : undefined
224
-
225
- let records
226
- try {
227
- records = materializeRoutes(mod.routes, collections ?? [])
228
- } catch (error) {
229
- return { unavailable: `The \`routes\` derive in ${CMS_ENTRY} threw: ${reason(error)}` }
230
- }
231
- if (!Array.isArray(records)) {
232
- return { unavailable: `The \`routes\` derive in ${CMS_ENTRY} did not return an array.` }
233
- }
234
-
235
- // Shape-validate before handing records to the router: a malformed record (no
236
- // pattern) would otherwise fault inside pattern compilation. Config validity is
237
- // the studio boot's job, so this reports rather than duplicating that check.
238
- const invalid = records.filter((record) => !routeRecordSchema.safeParse(record).success)
239
- if (invalid.length > 0) {
240
- return {
241
- unavailable: `${invalid.length} of ${records.length} route record(s) are not valid RouteRecords — config validation reports the details.`,
242
- fix: 'Open the studio (or run your build) to see the config validation issues, then re-run `elytra doctor`.',
243
- }
244
- }
245
-
246
- return {
247
- routes: records,
248
- routeCount: records.length,
249
- ...(collections ? { collections } : {}),
250
- }
251
- }
252
-
253
- // --- Detection (pure) --------------------------------------------------------
254
-
255
- /**
256
- * The router issues of a declared route set — route conflicts, since a CLI passes
257
- * no redirects.
258
- *
259
- * Hierarchy-mount templates (`/:path*`, EC-218) are filtered out first, exactly as
260
- * the delivery resolver does (`core/src/content/client.ts:230`): a mount is resolved
261
- * per-request against the parent chain, not by the router, so counting it as a route
262
- * would report conflicts the runtime does not have. Running the same filter is what
263
- * makes doctor unable to disagree with delivery.
264
- *
265
- * The filter is NOT widened to cover EC-482 — {@link mountIssuesOf} answers that
266
- * question about the routes this one removes. Widening the router's signature
267
- * comparison instead would reinstate the `/:slug` + `/:path*` false positive the
268
- * filter exists to prevent (pinned in both this package's and cms-core's tests).
269
- */
270
- export function routeIssuesOf(routes) {
271
- return createRouter(routes.filter((route) => !isHierarchyTemplate(route)), []).issues
272
- }
273
-
274
- /**
275
- * The conflicts BETWEEN hierarchy mounts (EC-482) — the routes {@link routeIssuesOf}
276
- * filters away. `error` for two mounts on the same path (identical URL space, split
277
- * only by declaration order), `warning` for a nested pair (a real shadow, but only
278
- * where the outer collection's page tree actually reaches into the inner prefix — a
279
- * content fact a CLI cannot see).
280
- */
281
- export function mountIssuesOf(routes) {
282
- return hierarchyMountConflicts(routes)
283
- }
284
-
285
- /**
286
- * The declarations that can never serve content (EC-484) — the third question, after
287
- * "do two routes fight" and "do two mounts fight": **does this declaration work at
288
- * all?** An all-dynamic route (`/:slug`) matching every URL at a depth the mount
289
- * serves, a mount whose collection has no `parent` self-relation (or is not declared),
290
- * and a catch-all with a dynamic prefix (`/:lang/:path*`) that every router drops and
291
- * no mount is built from. All three were measured to resolve `notFound` with ZERO
292
- * issues anywhere before this.
293
- *
294
- * `collections` is passed through when the project exported them; without them the
295
- * mount-target half is skipped and {@link routesDoctorChecks} reports `unknown`
296
- * instead of a pass it did not establish.
297
- */
298
- export function reachabilityIssuesOf(routes, collections) {
299
- return routeReachabilityIssues(routes, collections)
300
- }
301
-
302
- // --- Doctor checks (pure) ----------------------------------------------------
303
34
 
304
35
  const CONFLICT_FIX =
305
- 'Two routes match the same URLs — give one a distinct pattern (or delete it) in cms/routes.ts.'
36
+ 'Two routes match the same URLs in the same locale scope — give one a distinct pattern (or delete it) where your routes are declared.'
306
37
 
307
38
  const MOUNT_CONFLICT_FIX =
308
- 'Two collections cannot be served from the same URL space — give one mount a distinct prefix (e.g. "/docs/:path*") in cms/routes.ts, or drop it.'
309
-
310
- const MOUNT_SHADOW_FIX =
311
- 'Mounts resolve in declaration order, not by specificity — declare the more specific mount first, and keep the outer collection\'s page tree clear of that prefix.'
39
+ 'Two collections cannot be served from the same URL space in the same locale scope — give one mount a distinct prefix (e.g. "/docs/:path*"), or drop it.'
312
40
 
313
41
  const UNREACHABLE_FIX =
314
- 'Give an all-dynamic route a static segment (e.g. "/blog/:slug"), mount only collections with a `parent` self-relation, and keep a mount prefix static — in cms/routes.ts.'
42
+ 'Give an all-dynamic route a static segment (e.g. "/blog/:slug"), mount only collections with a parent role, and keep a mount prefix static.'
315
43
 
316
- /**
317
- * Where the judged route set came from (EC-603, RESIDUE-4), appended to the first row so
318
- * the report says which reader it used: the executed config (what the studio and
319
- * delivery serve) or the bundler-free `cms/index.ts` floor. No `source` → no note.
320
- */
321
- const ROUTE_SOURCE_NOTE = {
322
- config: ' (Routes from the executed elytra.config.ts.)',
323
- native: ` (Routes read natively from ${CMS_ENTRY} — elytra.config.ts was not resolved here.)`,
324
- }
44
+ const REDIRECT_FIX =
45
+ 'Edit the redirects in the studio’s Redirects rail: break the loop, or delete one of the duplicate `from`s (`/old` and `/old/` are one URL).'
325
46
 
326
- /** An `unknown` check row carrying the reason the routes could not be read. */
47
+ /** An `unknown` check row carrying the reason the routes could not be judged. */
327
48
  function unavailableCheck(id, facts) {
328
49
  return {
329
50
  id,
@@ -335,90 +56,84 @@ function unavailableCheck(id, facts) {
335
56
  }
336
57
 
337
58
  /**
338
- * The doctor's route checks, for the `app` bucket. Consumes the already-loaded facts
339
- * (the {@link loadProjectRoutes} result) so the reporting shape is pure and directly
340
- * testable, like `formatReport` and `previewsDoctorChecks`.
59
+ * The doctor's route checks, for the `app` bucket — pure over the gathered facts (the
60
+ * resolved project and, when a deployment was read, its redirects), like `formatReport`.
341
61
  *
342
- * Four rows, in the order a reader needs them: the router's own conflicts, the
343
- * mount-vs-mount conflicts the router cannot see (EC-482), the declarations that serve
344
- * nothing at all (EC-484), then the redirects a CLI cannot see at all.
62
+ * `route-conflicts`, `mount-conflicts` and `redirect-loops` can FAIL (deterministic
63
+ * structural faults); `unreachable-routes` fails on a declaration that can never serve
64
+ * and warns on a precedence that depends on content (EC-488).
345
65
  *
346
- * `route-conflicts`, `mount-conflicts` and `unreachable-routes` are the checks here
347
- * that can FAIL — all deterministic in code, so failing the exit code is safe for CI.
348
- * The latter two can also `warn`, in both cases for the same reason: the finding only
349
- * costs content where a collection's tree actually reaches that far, which is CONTENT
350
- * a CLI cannot see, so naming it is honest where failing a build would not be.
351
- * `redirect-loops` is always `unknown`: they are editorial data, and saying so is how
352
- * the report stays honest about what a CLI can and cannot see.
66
+ * @param {RouteFacts | undefined} facts
353
67
  */
354
68
  export function routesDoctorChecks(facts) {
355
- const redirects = {
356
- id: 'redirect-loops',
357
- bucket: 'app',
358
- status: 'unknown',
359
- detail:
360
- 'Redirect loops were not evaluated here — redirects are editorial data in the backend; the studio checks them.',
361
- }
362
-
363
- if (!facts || facts.unavailable) {
69
+ if (!facts || 'unavailable' in facts) {
364
70
  return [
365
71
  unavailableCheck('route-conflicts', facts),
366
72
  unavailableCheck('mount-conflicts', facts),
367
73
  unavailableCheck('unreachable-routes', facts),
368
- redirects,
74
+ unavailableCheck('redirect-loops', facts),
369
75
  ]
370
76
  }
371
-
372
- const count = facts.routeCount ?? facts.routes.length
77
+ const router = createRouter(facts.project, { redirects: facts.redirects ?? [] })
78
+ const routes = facts.project.routes
79
+ const count = routes.length
373
80
  const plural = count === 1 ? '' : 's'
374
- const conflicts = routeIssuesOf(facts.routes).filter((issue) => issue.code === 'route-conflict')
375
- const source = ROUTE_SOURCE_NOTE[facts.source] ?? ''
376
-
81
+ const conflicts = router.issueGroups.routes
377
82
  const routeConflicts =
378
83
  conflicts.length === 0
379
- ? {
380
- id: 'route-conflicts',
381
- bucket: 'app',
382
- status: 'ok',
383
- detail: `${count} declared route${plural}, no URL conflicts.${source}`,
384
- }
84
+ ? { id: 'route-conflicts', bucket: 'app', status: 'ok', detail: `${count} declared route${plural}, no URL conflicts.` }
385
85
  : {
386
86
  id: 'route-conflicts',
387
87
  bucket: 'app',
388
88
  status: 'fail',
389
89
  detail:
390
90
  `${conflicts.length} route conflict(s) among ${count} declared route${plural}. ` +
391
- conflicts.map((issue) => issue.message).join(' ') +
392
- source,
91
+ conflicts.map((issue) => issue.message).join(' '),
393
92
  fix: CONFLICT_FIX,
394
93
  }
395
94
 
396
95
  return [
397
96
  routeConflicts,
398
- mountConflictsCheck(facts.routes),
399
- unreachableRoutesCheck(facts.routes, facts.collections),
400
- redirects,
97
+ mountConflictsCheck(routes, router.issueGroups.mounts),
98
+ unreachableRoutesCheck(routes, router.issueGroups.reachability),
99
+ redirectLoopsCheck(facts, router.issueGroups.redirects),
401
100
  ]
402
101
  }
403
102
 
103
+ /** The `redirect-loops` row: the deployment's redirects through the one normalised set. */
104
+ function redirectLoopsCheck(facts, issues) {
105
+ const base = { id: 'redirect-loops', bucket: 'app' }
106
+ if (facts.redirects === undefined) {
107
+ return {
108
+ ...base,
109
+ status: 'unknown',
110
+ detail:
111
+ facts.redirectsUnavailable ??
112
+ 'Redirects were not evaluated here — they are editorial data on the deployment, and no deployment was read.',
113
+ }
114
+ }
115
+ const n = facts.redirects.length
116
+ const scope = `${n} enabled redirect${n === 1 ? '' : 's'} on the deployment`
117
+ if (issues.length === 0) return { ...base, status: 'ok', detail: `${scope}, no loops or duplicates.` }
118
+ return {
119
+ ...base,
120
+ status: 'fail',
121
+ detail: `${issues.length} redirect issue(s) among ${scope}. ` + issues.map((issue) => issue.message).join(' '),
122
+ fix: REDIRECT_FIX,
123
+ }
124
+ }
125
+
404
126
  /**
405
- * The `unreachable-routes` row (EC-484): declarations that resolve to nothing.
406
- *
407
- * Like `mount-conflicts` it states what it LOOKED at when it passes — a bare "ok"
408
- * reads the same whether the check examined the mounts or skipped them. And when the
409
- * project exported no collections it reports `unknown` rather than `ok`: the
410
- * mount-target half could not run, and a pass it did not establish would be exactly
411
- * the silent failure this ticket exists to remove.
127
+ * The `unreachable-routes` row (EC-484): declarations that resolve to nothing. States what
128
+ * it LOOKED at when it passes — a bare "ok" reads the same whether it examined the mounts
129
+ * or skipped them.
412
130
  */
413
- function unreachableRoutesCheck(routes, collections) {
131
+ function unreachableRoutesCheck(routes, issues) {
414
132
  const mounts = declaredMounts(routes)
415
133
  const scope = `${routes.length} declared route${routes.length === 1 ? '' : 's'}, ${mounts.length} hierarchy mount${mounts.length === 1 ? '' : 's'}`
416
134
  const base = { id: 'unreachable-routes', bucket: 'app' }
417
-
418
- const issues = reachabilityIssuesOf(routes, collections)
419
135
  const errors = issues.filter((issue) => issue.severity === 'error')
420
136
  const warnings = issues.filter((issue) => issue.severity !== 'error')
421
-
422
137
  if (errors.length > 0) {
423
138
  return {
424
139
  ...base,
@@ -433,65 +148,31 @@ function unreachableRoutesCheck(routes, collections) {
433
148
  return {
434
149
  ...base,
435
150
  status: 'warn',
436
- detail:
437
- `${warnings.length} partially shadowed declaration(s), among ${scope}. ` +
438
- warnings.map((issue) => issue.message).join(' '),
151
+ detail: `${warnings.length} partially shadowed declaration(s), among ${scope}. ` + warnings.map((issue) => issue.message).join(' '),
439
152
  fix: UNREACHABLE_FIX,
440
153
  }
441
154
  }
442
- if (mounts.length > 0 && !collections) {
443
- return {
444
- ...base,
445
- status: 'unknown',
446
- detail: `${scope}: no route shadows a mount, but mount targets were not checked — ${CMS_ENTRY} exports no \`collections\`.`,
447
- fix: `Export \`collections\` from ${CMS_ENTRY} (the shape elytra.config.ts re-exports) so mounts can be checked against them.`,
448
- }
449
- }
450
155
  return { ...base, status: 'ok', detail: `${scope} — every declaration can serve content.` }
451
156
  }
452
157
 
453
158
  /**
454
- * The `mount-conflicts` row (EC-482). Reports the mount COUNT even when clean — a row
455
- * that says "1 hierarchy mount, no overlap" proves the check looked at the mounts,
456
- * where a bare "ok" would read the same whether it examined them or skipped them.
159
+ * The `mount-conflicts` row (EC-482, re-derived by EC-590): exact ties between mounts. Reports
160
+ * the mount COUNT even when clean, so a pass proves the check looked at the mounts.
457
161
  */
458
- function mountConflictsCheck(routes) {
162
+ function mountConflictsCheck(routes, issues) {
459
163
  const mounts = declaredMounts(routes)
460
164
  const mountPlural = mounts.length === 1 ? '' : 's'
461
165
  const base = { id: 'mount-conflicts', bucket: 'app' }
462
-
463
- if (mounts.length === 0) {
464
- return { ...base, status: 'ok', detail: 'No hierarchy mounts declared.' }
465
- }
466
-
467
- const issues = mountIssuesOf(routes)
468
- const errors = issues.filter((issue) => issue.severity === 'error')
469
- const warnings = issues.filter((issue) => issue.severity !== 'error')
470
- const among = `among ${mounts.length} hierarchy mount${mountPlural}`
471
-
472
- if (errors.length > 0) {
166
+ if (mounts.length === 0) return { ...base, status: 'ok', detail: 'No hierarchy mounts declared.' }
167
+ if (issues.length > 0) {
473
168
  return {
474
169
  ...base,
475
170
  status: 'fail',
476
171
  detail:
477
- `${errors.length} conflicting mount pair(s) ${among}. ` +
478
- [...errors, ...warnings].map((issue) => issue.message).join(' '),
172
+ `${issues.length} conflicting mount pair(s) among ${mounts.length} hierarchy mount${mountPlural}. ` +
173
+ issues.map((issue) => issue.message).join(' '),
479
174
  fix: MOUNT_CONFLICT_FIX,
480
175
  }
481
176
  }
482
- if (warnings.length > 0) {
483
- return {
484
- ...base,
485
- status: 'warn',
486
- detail:
487
- `${warnings.length} overlapping mount pair(s) ${among}. ` +
488
- warnings.map((issue) => issue.message).join(' '),
489
- fix: MOUNT_SHADOW_FIX,
490
- }
491
- }
492
- return {
493
- ...base,
494
- status: 'ok',
495
- detail: `${mounts.length} hierarchy mount${mountPlural}, no overlapping mounts.`,
496
- }
177
+ return { ...base, status: 'ok', detail: `${mounts.length} hierarchy mount${mountPlural}, no conflicting mounts.` }
497
178
  }