@elytracms/cli 0.0.90 → 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 +2 -2
- package/src/cli.js +17 -5
- package/src/config-harness.js +3 -2
- package/src/doctor.js +49 -47
- package/src/engine-version.js +32 -0
- package/src/previews.js +4 -24
- package/src/project.js +2 -1
- package/src/provision.js +8 -6
- package/src/routes-check.js +86 -405
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elytracms/cli",
|
|
3
3
|
"author": "finaldream <hi@finaldream.de>",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.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.
|
|
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
|
|
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
|
|
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) {
|
package/src/config-harness.js
CHANGED
|
@@ -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
|
-
/**
|
|
37
|
-
export const CONFIG_FILE =
|
|
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 {
|
|
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
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
* `
|
|
152
|
-
*
|
|
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
|
|
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,26 +459,34 @@ export function formatReport(report) {
|
|
|
475
459
|
}
|
|
476
460
|
|
|
477
461
|
/**
|
|
478
|
-
*
|
|
479
|
-
*
|
|
480
|
-
*
|
|
481
|
-
*
|
|
482
|
-
*
|
|
483
|
-
*
|
|
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
|
|
469
|
+
export function routeFactsFor({ outcome, configFacts = {}, snapshot }) {
|
|
486
470
|
const project = resolvedProjectOf(outcome)
|
|
487
|
-
if (project
|
|
488
|
-
|
|
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
|
-
|
|
493
|
-
}
|
|
482
|
+
...(project.locales ? { locales: project.locales } : {}),
|
|
483
|
+
},
|
|
484
|
+
...(snapshot?.status === 'answered'
|
|
485
|
+
? { redirects: snapshot.redirects }
|
|
486
|
+
: snapshot
|
|
487
|
+
? { redirectsUnavailable: `Redirects were not read from the deployment — ${snapshot.detail}` }
|
|
488
|
+
: {}),
|
|
494
489
|
}
|
|
495
|
-
const loaded = routeFacts ?? (await loadProjectRoutes(root))
|
|
496
|
-
const enriched = configFacts.collections ? { ...loaded, collections: configFacts.collections } : loaded
|
|
497
|
-
return { ...enriched, source: 'native' }
|
|
498
490
|
}
|
|
499
491
|
|
|
500
492
|
// --- Command -----------------------------------------------------------------
|
|
@@ -553,6 +545,12 @@ export async function routeFactsFor({ outcome, configFacts = {}, routeFacts, roo
|
|
|
553
545
|
* - the route checks run over the RESOLVED config's routes whenever the harness
|
|
554
546
|
* resolved it (the routes the studio and delivery actually serve); the native
|
|
555
547
|
* `cms/index.ts` read is the fallback only when it could not, and the row says which.
|
|
548
|
+
*
|
|
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.
|
|
556
554
|
*/
|
|
557
555
|
export async function runDoctor({
|
|
558
556
|
env = {},
|
|
@@ -621,11 +619,18 @@ export async function runDoctor({
|
|
|
621
619
|
|
|
622
620
|
// EC-606: published documents of undeclared collections — one more request on the
|
|
623
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
|
|
624
628
|
const publishedOrphans = await gatherPublishedOrphans({
|
|
625
629
|
target,
|
|
626
|
-
project
|
|
630
|
+
project,
|
|
627
631
|
configFacts,
|
|
628
632
|
fetchImpl,
|
|
633
|
+
...(snapshot ? { probe: async () => snapshot } : {}),
|
|
629
634
|
})
|
|
630
635
|
if (publishedOrphans) facts.publishedOrphans = publishedOrphans
|
|
631
636
|
|
|
@@ -643,17 +648,14 @@ export async function runDoctor({
|
|
|
643
648
|
...aiRouteDoctorChecks(aiRoute, { studioLabel }),
|
|
644
649
|
])
|
|
645
650
|
|
|
646
|
-
// EC-469: the route
|
|
647
|
-
//
|
|
648
|
-
//
|
|
649
|
-
|
|
650
|
-
// `cms/index.ts` read is the fallback for when it could not (EC-495 had swapped in the
|
|
651
|
-
// resolved collections only, so the routes still came from `cms/index.ts`).
|
|
652
|
-
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) {
|
|
653
655
|
checks = spliceBucketChecks(
|
|
654
656
|
checks,
|
|
655
657
|
'app',
|
|
656
|
-
routesDoctorChecks(
|
|
658
|
+
routesDoctorChecks(routeFacts ?? routeFactsFor({ outcome, configFacts, snapshot })),
|
|
657
659
|
)
|
|
658
660
|
}
|
|
659
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
46
|
-
|
|
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(
|
|
87
|
-
jwks: convexEnvGet(
|
|
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
|
-
|
|
114
|
-
|
|
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
|
}
|
package/src/routes-check.js
CHANGED
|
@@ -1,329 +1,50 @@
|
|
|
1
|
-
import {
|
|
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
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* (
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
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)
|
|
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*")
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
339
|
-
*
|
|
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
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
74
|
+
unavailableCheck('redirect-loops', facts),
|
|
369
75
|
]
|
|
370
76
|
}
|
|
371
|
-
|
|
372
|
-
const
|
|
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 =
|
|
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(
|
|
399
|
-
unreachableRoutesCheck(
|
|
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
|
-
*
|
|
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,
|
|
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
|
|
455
|
-
*
|
|
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 (
|
|
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
|
-
`${
|
|
478
|
-
|
|
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
|
-
|
|
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
|
}
|