@elytracms/cli 0.0.88 → 0.0.90
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -37
- package/package.json +2 -2
- package/src/cli.js +81 -36
- package/src/config-harness.js +173 -3
- package/src/doctor.js +92 -22
- package/src/layout.js +288 -0
- package/src/previews.js +39 -22
- package/src/project.js +105 -48
- package/src/routes-check.js +18 -4
package/src/doctor.js
CHANGED
|
@@ -6,14 +6,17 @@ import {
|
|
|
6
6
|
REVALIDATE_ENV_ENDPOINT,
|
|
7
7
|
REVALIDATE_ENV_SECRET,
|
|
8
8
|
backendSiteUrl,
|
|
9
|
+
backendUrlOf,
|
|
9
10
|
checkDeploymentReady,
|
|
10
11
|
expectedDeploymentEnv,
|
|
12
|
+
parseBackendMode,
|
|
11
13
|
probeAuthSigningKeys,
|
|
12
14
|
probeDeliverySnapshot,
|
|
13
15
|
probeDeployedFunctionsVersion,
|
|
14
16
|
probeRevalidateEndpoint,
|
|
15
17
|
} from '@elytracms/core/export-sync/deployment-ready'
|
|
16
|
-
import { executeWorkspaceConfig } from './config-harness.js'
|
|
18
|
+
import { aiRouteDoctorChecks, executeWorkspaceConfig, readStudioAiRoute } from './config-harness.js'
|
|
19
|
+
import { appDisplay, layoutDoctorChecks } from './layout.js'
|
|
17
20
|
import { convexEnvGet, defaultConvexExec } from './provision.js'
|
|
18
21
|
import { gatherPreviewsArtifacts, previewsDoctorChecks } from './previews.js'
|
|
19
22
|
import { loadProjectRoutes, routesDoctorChecks } from './routes-check.js'
|
|
@@ -40,17 +43,29 @@ import { loadProjectRoutes, routesDoctorChecks } from './routes-check.js'
|
|
|
40
43
|
/**
|
|
41
44
|
* Decide which deployment the doctor diagnoses from the resolved studio env.
|
|
42
45
|
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
+
* The mode is judged by the ONE parser the studio runs (core's `parseBackendMode`,
|
|
47
|
+
* EC-603), so a padded flag is playground here exactly as it is in the studio, and an
|
|
48
|
+
* unknown mode is the studio's own boot error — a failing `backend-url` naming the
|
|
49
|
+
* value, never silently overridden by a URL beside it.
|
|
50
|
+
*
|
|
51
|
+
* A configured `VITE_CONVEX_URL` is preferred over a VALID `VITE_ELYTRA_MODE=playground`
|
|
52
|
+
* flag: that flag only selects the studio's in-memory *adapter*, whereas the doctor's
|
|
53
|
+
* job is to validate the real deployment you configured. So a repo that runs
|
|
46
54
|
* playground locally but points at a Convex deployment (e.g. the demo) is diagnosed
|
|
47
|
-
* against that deployment — with a note that the studio itself runs in-memory.
|
|
55
|
+
* against that deployment — with a note that the studio itself runs in-memory. An
|
|
56
|
+
* explicit `--url` diagnoses that deployment whatever the env says.
|
|
57
|
+
*
|
|
58
|
+
* `envSource` names where the env was read, for the unconfigured detail (`studio/.env`).
|
|
48
59
|
*/
|
|
49
|
-
export function resolveDoctorTarget(env = {}, { url } = {}) {
|
|
60
|
+
export function resolveDoctorTarget(env = {}, { url, envSource = 'studio/.env' } = {}) {
|
|
50
61
|
const explicit = url && url.trim() !== '' ? url.trim() : undefined
|
|
51
|
-
const
|
|
62
|
+
const backend = parseBackendMode(env)
|
|
63
|
+
if (!explicit && backend.kind === 'misconfigured' && backend.reason === 'unknown-mode') {
|
|
64
|
+
return { kind: 'unconfigured', detail: `${backend.detail} (in ${envSource})` }
|
|
65
|
+
}
|
|
66
|
+
const envUrl = backendUrlOf(env)
|
|
52
67
|
const chosenUrl = explicit || envUrl
|
|
53
|
-
const playgroundFlag =
|
|
68
|
+
const playgroundFlag = backend.kind === 'playground'
|
|
54
69
|
|
|
55
70
|
if (chosenUrl) {
|
|
56
71
|
return {
|
|
@@ -67,7 +82,7 @@ export function resolveDoctorTarget(env = {}, { url } = {}) {
|
|
|
67
82
|
if (playgroundFlag) return { kind: 'playground' }
|
|
68
83
|
return {
|
|
69
84
|
kind: 'unconfigured',
|
|
70
|
-
detail:
|
|
85
|
+
detail: `No VITE_CONVEX_URL is set, and VITE_ELYTRA_MODE is not "playground", in ${envSource}.`,
|
|
71
86
|
}
|
|
72
87
|
}
|
|
73
88
|
|
|
@@ -345,7 +360,7 @@ export function gatherDeploymentEnvFacts({ env = {}, target, studioDir, aiRouteC
|
|
|
345
360
|
facts.unavailableDetail =
|
|
346
361
|
'--url overrides the linked deployment; env names are only read from the deployment this repo is linked to.'
|
|
347
362
|
} else if (!studioDir) {
|
|
348
|
-
facts.unavailableDetail = 'No studio
|
|
363
|
+
facts.unavailableDetail = 'No studio app found — deployment env names not enumerated.'
|
|
349
364
|
} else if (!env.CONVEX_DEPLOYMENT && !env.CONVEX_DEPLOY_KEY) {
|
|
350
365
|
facts.unavailableDetail =
|
|
351
366
|
'No linked deployment (CONVEX_DEPLOYMENT / CONVEX_DEPLOY_KEY unset) — `npx convex env list` needs one.'
|
|
@@ -360,10 +375,10 @@ export function gatherDeploymentEnvFacts({ env = {}, target, studioDir, aiRouteC
|
|
|
360
375
|
// --- Signed revalidate ping (EC-421) -------------------------------------------
|
|
361
376
|
//
|
|
362
377
|
// When the presence checks say the instant-publish pair IS set, the doctor proves the
|
|
363
|
-
// chain end to end: a POST to the endpoint signed with the secret, carrying
|
|
364
|
-
//
|
|
365
|
-
// `{"revalidated":[]}`. Default-on (no flag): the ping is
|
|
366
|
-
// silent-failure class it catches (secret mismatch — publishes rejected with 401
|
|
378
|
+
// chain end to end: a POST to the endpoint signed with the secret, carrying the `probe`
|
|
379
|
+
// event (EC-579), which derives no cache tags — by the receiving route's own semantics a
|
|
380
|
+
// harmless no-op answering 200 `{"revalidated":[]}`. Default-on (no flag): the ping is
|
|
381
|
+
// read-only in effect and the silent-failure class it catches (secret mismatch — publishes rejected with 401
|
|
367
382
|
// forever, no error anywhere) is exactly what the doctor exists for.
|
|
368
383
|
//
|
|
369
384
|
// The ping needs the pair's VALUES — the one sanctioned value read: captured
|
|
@@ -459,6 +474,29 @@ export function formatReport(report) {
|
|
|
459
474
|
return lines.join('\n')
|
|
460
475
|
}
|
|
461
476
|
|
|
477
|
+
/**
|
|
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.
|
|
484
|
+
*/
|
|
485
|
+
export async function routeFactsFor({ outcome, configFacts = {}, routeFacts, root }) {
|
|
486
|
+
const project = resolvedProjectOf(outcome)
|
|
487
|
+
if (project && Array.isArray(project.routes)) {
|
|
488
|
+
return {
|
|
489
|
+
routes: project.routes,
|
|
490
|
+
routeCount: project.routes.length,
|
|
491
|
+
...(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
|
+
|
|
462
500
|
// --- Command -----------------------------------------------------------------
|
|
463
501
|
|
|
464
502
|
/**
|
|
@@ -502,6 +540,19 @@ export function formatReport(report) {
|
|
|
502
540
|
* published documents whose collection the resolved config does not declare — read
|
|
503
541
|
* from the public delivery snapshot ({@link gatherPublishedOrphans}). A `warn` at most,
|
|
504
542
|
* so the exit code never changes; without a resolved config it is an honest `unknown`.
|
|
543
|
+
*
|
|
544
|
+
* EC-603: three more facts, all read from DECLARATIONS rather than fixed paths:
|
|
545
|
+
* - `layout` (the CLI entry passes `inspectProject`'s resolved `package.json#elytra`
|
|
546
|
+
* layout) → the `project-layout` row: what was resolved, declared or defaulted, and a
|
|
547
|
+
* `fail` for an invalid section or a declared path that does not exist;
|
|
548
|
+
* - the AI route → read from the studio's own resolved Vite config
|
|
549
|
+
* ({@link readStudioAiRoute}) when a `root` is known — it decides the AI-key
|
|
550
|
+
* expectation and gets its own `ai-route` row. `aiRoute` (an outcome) and
|
|
551
|
+
* `aiRouteConfigured` (a boolean) are test seams; without either and without a root
|
|
552
|
+
* no AI expectation is added;
|
|
553
|
+
* - the route checks run over the RESOLVED config's routes whenever the harness
|
|
554
|
+
* resolved it (the routes the studio and delivery actually serve); the native
|
|
555
|
+
* `cms/index.ts` read is the fallback only when it could not, and the row says which.
|
|
505
556
|
*/
|
|
506
557
|
export async function runDoctor({
|
|
507
558
|
env = {},
|
|
@@ -510,14 +561,17 @@ export async function runDoctor({
|
|
|
510
561
|
url,
|
|
511
562
|
studioDir,
|
|
512
563
|
root,
|
|
513
|
-
|
|
564
|
+
layout,
|
|
565
|
+
aiRoute: aiRouteOutcome,
|
|
566
|
+
aiRouteConfigured,
|
|
514
567
|
exec,
|
|
515
568
|
previewsArtifacts,
|
|
516
569
|
routeFacts,
|
|
517
570
|
configOutcome,
|
|
518
571
|
installedEngineVersion,
|
|
519
572
|
} = {}) {
|
|
520
|
-
const
|
|
573
|
+
const studioLabel = appDisplay(layout, 'studio')
|
|
574
|
+
const target = resolveDoctorTarget(env, { url, envSource: `${studioLabel}/.env` })
|
|
521
575
|
|
|
522
576
|
log('Elytra deployment doctor')
|
|
523
577
|
if (target.kind === 'convex') {
|
|
@@ -536,8 +590,14 @@ export async function runDoctor({
|
|
|
536
590
|
// entry resolves it via `resolveInstalledEngineVersion`; runDoctor itself never
|
|
537
591
|
// touches the filesystem for it, staying pure/injected like every gatherer here).
|
|
538
592
|
const facts = await gatherFacts(target, { fetchImpl, installedEngineVersion })
|
|
593
|
+
|
|
594
|
+
// EC-603: the AI route from the studio's resolved Vite config — gathered before the
|
|
595
|
+
// deployment-env facts, whose AI-key expectation it decides.
|
|
596
|
+
const aiRoute = aiRouteOutcome ?? (root !== undefined ? await readStudioAiRoute({ studioDir }) : undefined)
|
|
597
|
+
const aiExpected = aiRouteConfigured ?? aiRoute?.kind === 'mounted'
|
|
598
|
+
|
|
539
599
|
if (target.kind === 'convex') {
|
|
540
|
-
facts.deploymentEnv = gatherDeploymentEnvFacts({ env, target, studioDir, aiRouteConfigured, exec })
|
|
600
|
+
facts.deploymentEnv = gatherDeploymentEnvFacts({ env, target, studioDir, aiRouteConfigured: aiExpected, exec })
|
|
541
601
|
// EC-421: default-on signed no-op ping whenever the revalidate pair is present.
|
|
542
602
|
const revalidateProbe = await gatherRevalidateProbe({
|
|
543
603
|
envFacts: facts.deploymentEnv,
|
|
@@ -577,14 +637,24 @@ export async function runDoctor({
|
|
|
577
637
|
const artifacts = previewsArtifacts ?? (studioDir ? gatherPreviewsArtifacts(studioDir) : undefined)
|
|
578
638
|
let checks = artifacts ? [...report.checks, ...previewsDoctorChecks(artifacts)] : [...report.checks]
|
|
579
639
|
|
|
640
|
+
// EC-603: the layout and AI-route rows, in the `app` bucket (both repo facts).
|
|
641
|
+
checks = spliceBucketChecks(checks, 'app', [
|
|
642
|
+
...layoutDoctorChecks(layout),
|
|
643
|
+
...aiRouteDoctorChecks(aiRoute, { studioLabel }),
|
|
644
|
+
])
|
|
645
|
+
|
|
580
646
|
// EC-469: the route-conflict check, spliced into the `app` bucket it belongs to.
|
|
581
647
|
// Backend-independent (routes are CODE) — it runs in playground mode too.
|
|
582
|
-
// EC-
|
|
583
|
-
//
|
|
648
|
+
// EC-603 (RESIDUE-4): when the harness resolved the config, the checks run over ITS
|
|
649
|
+
// routes and collections — what the studio and delivery serve; the native
|
|
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`).
|
|
584
652
|
if (root !== undefined || routeFacts !== undefined) {
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
653
|
+
checks = spliceBucketChecks(
|
|
654
|
+
checks,
|
|
655
|
+
'app',
|
|
656
|
+
routesDoctorChecks(await routeFactsFor({ outcome, configFacts, routeFacts, root })),
|
|
657
|
+
)
|
|
588
658
|
}
|
|
589
659
|
|
|
590
660
|
// One rule for the exit code, the same one `checkDeploymentReady` applies: a
|
package/src/layout.js
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
2
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The CLI layout contract (EC-603, RATIFIED 2026-10-24) — where a project's studio app,
|
|
6
|
+
* frontend app and theme stylesheet live, declared in the repo-root `package.json`:
|
|
7
|
+
*
|
|
8
|
+
* "elytra": { "studio": "./studio", "frontend": "./frontend", "theme": "./components/theme.css" }
|
|
9
|
+
*
|
|
10
|
+
* `package.json` because it is the one file the CLI can read before ANY toolchain runs —
|
|
11
|
+
* `elytra.config.ts` (the structure) is executed through the studio app's own Vite,
|
|
12
|
+
* which needs the studio's location first (the chicken-and-egg EC-565 Round 5 settled;
|
|
13
|
+
* a plain-TS loader was measured not viable). Every key is optional and DEFAULTS TO THE
|
|
14
|
+
* SCAFFOLD LAYOUT, so a scaffolded project declares nothing and nothing changes for it.
|
|
15
|
+
*
|
|
16
|
+
* The engine supplies capability, never structure (AGENTS.md §4): with this section a
|
|
17
|
+
* project can reorganise its directories freely and still satisfy the CLI.
|
|
18
|
+
*
|
|
19
|
+
* This module is the ONE declaration of the section's shape ({@link LAYOUT_ENTRIES}) and
|
|
20
|
+
* its ONE reader ({@link resolveProjectLayout}); every CLI path that needs a location
|
|
21
|
+
* (`inspectProject`, `dev`/`build`, the config harness, `previews`, `doctor`) takes it
|
|
22
|
+
* from the resolved layout. Validation is explicit — an unknown key, a non-string value,
|
|
23
|
+
* an absolute path, a path escaping the repo, a theme that is not a file, a declared path
|
|
24
|
+
* that does not exist — each is a named issue, never a silent fall-back to the default.
|
|
25
|
+
* Its only reader is the CLI (the studio and the engine never read it), so it lives here,
|
|
26
|
+
* in plain JS with no dependency, rather than as a core schema.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** Where the section lives, as the issues name it. */
|
|
30
|
+
export const LAYOUT_SECTION = 'package.json#elytra'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The section's keys — THE declaration. `default` is the scaffold layout; `kind` is what
|
|
34
|
+
* the path must be (`app`: a directory with a `package.json`; `file`: a file).
|
|
35
|
+
*/
|
|
36
|
+
export const LAYOUT_ENTRIES = {
|
|
37
|
+
studio: { default: './studio', kind: 'app', label: 'studio app' },
|
|
38
|
+
frontend: { default: './frontend', kind: 'app', label: 'frontend app' },
|
|
39
|
+
theme: { default: './components/theme.css', kind: 'file', label: 'theme stylesheet' },
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const KEYS = Object.keys(LAYOUT_ENTRIES)
|
|
43
|
+
|
|
44
|
+
/** A root-relative POSIX path for display (`apps/editor`, `.` for the root itself). */
|
|
45
|
+
export function displayPath(root, absolute) {
|
|
46
|
+
const rel = relative(root, absolute)
|
|
47
|
+
return rel === '' ? '.' : rel.split(sep).join('/')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function issue(key, code, message, fix) {
|
|
51
|
+
return { key, code, message, ...(fix ? { fix } : {}) }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* PURE core: parse the root `package.json` text (or `undefined` when there is none) into
|
|
56
|
+
* the declared values and the SHAPE issues. Existence is checked separately
|
|
57
|
+
* ({@link resolveProjectLayout}) so this stays filesystem-free.
|
|
58
|
+
*
|
|
59
|
+
* Returns `{ declared: { [key]: string }, invalid: Set<key>, issues }` — a key in
|
|
60
|
+
* `invalid` has NO usable value (it is neither the declared one nor the default: an
|
|
61
|
+
* invalid declaration must never quietly turn into the scaffold path).
|
|
62
|
+
*/
|
|
63
|
+
export function parseLayoutSection(root, packageJsonText) {
|
|
64
|
+
const declared = {}
|
|
65
|
+
const invalid = new Set()
|
|
66
|
+
const issues = []
|
|
67
|
+
if (packageJsonText === undefined) return { declared, invalid, issues }
|
|
68
|
+
|
|
69
|
+
let pkg
|
|
70
|
+
try {
|
|
71
|
+
pkg = JSON.parse(packageJsonText)
|
|
72
|
+
} catch (error) {
|
|
73
|
+
for (const key of KEYS) invalid.add(key)
|
|
74
|
+
issues.push(
|
|
75
|
+
issue(
|
|
76
|
+
null,
|
|
77
|
+
'layout-package-json-invalid',
|
|
78
|
+
`The repo-root package.json is not valid JSON (${error.message}) — the project layout (${LAYOUT_SECTION}) cannot be read.`,
|
|
79
|
+
'Fix the JSON syntax of the repo-root package.json.',
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
return { declared, invalid, issues }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const section = pkg?.elytra
|
|
86
|
+
if (section === undefined) return { declared, invalid, issues }
|
|
87
|
+
if (section === null || typeof section !== 'object' || Array.isArray(section)) {
|
|
88
|
+
for (const key of KEYS) invalid.add(key)
|
|
89
|
+
issues.push(
|
|
90
|
+
issue(
|
|
91
|
+
null,
|
|
92
|
+
'layout-invalid-section',
|
|
93
|
+
`${LAYOUT_SECTION} must be an object of paths ({ "studio": "./studio", … }), not ${section === null ? 'null' : Array.isArray(section) ? 'an array' : typeof section}.`,
|
|
94
|
+
`Make ${LAYOUT_SECTION} an object with any of ${KEYS.join(', ')} — or remove it to use the scaffold layout.`,
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
return { declared, invalid, issues }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for (const key of Object.keys(section)) {
|
|
101
|
+
if (!Object.hasOwn(LAYOUT_ENTRIES, key)) {
|
|
102
|
+
issues.push(
|
|
103
|
+
issue(
|
|
104
|
+
key,
|
|
105
|
+
'layout-unknown-key',
|
|
106
|
+
`${LAYOUT_SECTION} has an unknown key "${key}" — the known keys are ${KEYS.join(', ')}.`,
|
|
107
|
+
`Rename or remove "${key}" in ${LAYOUT_SECTION}.`,
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (const key of KEYS) {
|
|
114
|
+
if (!Object.hasOwn(section, key)) continue
|
|
115
|
+
const value = section[key]
|
|
116
|
+
const entry = LAYOUT_ENTRIES[key]
|
|
117
|
+
const fix = `Set ${LAYOUT_SECTION}.${key} to the ${entry.label}'s path relative to the repo root (default "${entry.default}"), or remove the key to use the default.`
|
|
118
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
119
|
+
invalid.add(key)
|
|
120
|
+
issues.push(
|
|
121
|
+
issue(key, 'layout-invalid-path', `${LAYOUT_SECTION}.${key} must be a non-empty path string, not ${JSON.stringify(value)}.`, fix),
|
|
122
|
+
)
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
if (isAbsolute(value)) {
|
|
126
|
+
invalid.add(key)
|
|
127
|
+
issues.push(
|
|
128
|
+
issue(
|
|
129
|
+
key,
|
|
130
|
+
'layout-invalid-path',
|
|
131
|
+
`${LAYOUT_SECTION}.${key} is the absolute path "${value}" — layout paths are relative to the repo root, so the project works wherever it is checked out.`,
|
|
132
|
+
fix,
|
|
133
|
+
),
|
|
134
|
+
)
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
const rel = relative(root, resolve(root, value))
|
|
138
|
+
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
139
|
+
invalid.add(key)
|
|
140
|
+
issues.push(
|
|
141
|
+
issue(key, 'layout-invalid-path', `${LAYOUT_SECTION}.${key} ("${value}") points outside the repo root.`, fix),
|
|
142
|
+
)
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
if (rel === '' && entry.kind === 'app') {
|
|
146
|
+
// The root package is the one that runs `elytra` — an app there would make
|
|
147
|
+
// `elytra dev` spawn itself.
|
|
148
|
+
invalid.add(key)
|
|
149
|
+
issues.push(
|
|
150
|
+
issue(key, 'layout-invalid-path', `${LAYOUT_SECTION}.${key} ("${value}") is the repo root itself — the ${entry.label} must be its own directory.`, fix),
|
|
151
|
+
)
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
declared[key] = value
|
|
155
|
+
}
|
|
156
|
+
return { declared, invalid, issues }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Default filesystem probes — injectable for tests. */
|
|
160
|
+
const defaultFs = {
|
|
161
|
+
readFile: (path) => (existsSync(path) ? readFileSync(path, 'utf8') : undefined),
|
|
162
|
+
kindOf: (path) => {
|
|
163
|
+
try {
|
|
164
|
+
const stat = statSync(path)
|
|
165
|
+
return stat.isDirectory() ? 'dir' : stat.isFile() ? 'file' : 'other'
|
|
166
|
+
} catch {
|
|
167
|
+
return 'missing'
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Resolve a project's layout: the declared-or-default path of every entry, whether it
|
|
174
|
+
* exists, and every issue. Never throws.
|
|
175
|
+
*
|
|
176
|
+
* Returns
|
|
177
|
+
* {
|
|
178
|
+
* source: 'declared' | 'defaults', // was anything declared in package.json#elytra?
|
|
179
|
+
* entries: { studio, frontend, theme }, each
|
|
180
|
+
* { key, path: <absolute> | null, display: 'apps/editor' | null,
|
|
181
|
+
* declared: boolean, found: boolean },
|
|
182
|
+
* issues: [{ key, code, message, fix? }],
|
|
183
|
+
* }
|
|
184
|
+
*
|
|
185
|
+
* `path` is `null` only for an INVALID declaration — the entry is then unusable, never
|
|
186
|
+
* the default. A DECLARED path that does not exist (or a theme that is not a file) is an
|
|
187
|
+
* issue; a DEFAULT path that does not exist is not (a frontend-only or studio-only repo
|
|
188
|
+
* is legitimate) — it is simply `found: false`.
|
|
189
|
+
*/
|
|
190
|
+
export function resolveProjectLayout(root, fs = defaultFs) {
|
|
191
|
+
const { declared, invalid, issues } = parseLayoutSection(root, fs.readFile(join(root, 'package.json')))
|
|
192
|
+
const entries = {}
|
|
193
|
+
for (const key of KEYS) {
|
|
194
|
+
const entry = LAYOUT_ENTRIES[key]
|
|
195
|
+
if (invalid.has(key)) {
|
|
196
|
+
entries[key] = { key, path: null, display: null, declared: true, found: false }
|
|
197
|
+
continue
|
|
198
|
+
}
|
|
199
|
+
const isDeclared = Object.hasOwn(declared, key)
|
|
200
|
+
const path = resolve(root, isDeclared ? declared[key] : entry.default)
|
|
201
|
+
const display = displayPath(root, path)
|
|
202
|
+
let found
|
|
203
|
+
if (entry.kind === 'app') {
|
|
204
|
+
found = fs.kindOf(join(path, 'package.json')) === 'file'
|
|
205
|
+
if (isDeclared && !found) {
|
|
206
|
+
issues.push(
|
|
207
|
+
issue(
|
|
208
|
+
key,
|
|
209
|
+
'layout-path-missing',
|
|
210
|
+
fs.kindOf(path) === 'missing'
|
|
211
|
+
? `${LAYOUT_SECTION}.${key} is "${declared[key]}", but ${display} does not exist.`
|
|
212
|
+
: `${LAYOUT_SECTION}.${key} is "${declared[key]}", but ${display} has no package.json — it is not an app.`,
|
|
213
|
+
`Point ${LAYOUT_SECTION}.${key} at the ${entry.label}'s directory (the one with its package.json), relative to the repo root.`,
|
|
214
|
+
),
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
const kind = fs.kindOf(path)
|
|
219
|
+
found = kind === 'file'
|
|
220
|
+
if (isDeclared && kind === 'missing') {
|
|
221
|
+
issues.push(
|
|
222
|
+
issue(
|
|
223
|
+
key,
|
|
224
|
+
'layout-path-missing',
|
|
225
|
+
`${LAYOUT_SECTION}.${key} is "${declared[key]}", but ${display} does not exist.`,
|
|
226
|
+
`Point ${LAYOUT_SECTION}.${key} at the ${entry.label} file, relative to the repo root.`,
|
|
227
|
+
),
|
|
228
|
+
)
|
|
229
|
+
} else if (isDeclared && kind !== 'file') {
|
|
230
|
+
issues.push(
|
|
231
|
+
issue(
|
|
232
|
+
key,
|
|
233
|
+
'layout-not-a-file',
|
|
234
|
+
`${LAYOUT_SECTION}.${key} is "${declared[key]}", but ${display} is ${kind === 'dir' ? 'a directory' : 'not a regular file'} — the theme is one stylesheet.`,
|
|
235
|
+
`Point ${LAYOUT_SECTION}.${key} at the theme's entry stylesheet (e.g. "./components/theme.css").`,
|
|
236
|
+
),
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
entries[key] = { key, path, display, declared: isDeclared, found }
|
|
241
|
+
}
|
|
242
|
+
const source = KEYS.some((key) => entries[key].declared) ? 'declared' : 'defaults'
|
|
243
|
+
return { source, entries, issues }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The root-relative display path of an entry for prose and fix lines (`apps/editor`,
|
|
248
|
+
* `studio`) — the resolved one, or the scaffold default's when no layout was resolved
|
|
249
|
+
* (an invalid declaration, or a hand-built project object). Display only: no command
|
|
250
|
+
* ever SPAWNS or READS from this fallback.
|
|
251
|
+
*/
|
|
252
|
+
export function appDisplay(layout, key) {
|
|
253
|
+
return layout?.entries?.[key]?.display ?? LAYOUT_ENTRIES[key].default.replace(/^\.\//, '')
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// --- Doctor row (pure) ---------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The doctor's `project-layout` row for the `app` bucket: what the CLI resolved and where
|
|
260
|
+
* from, so "which studio did it look at?" is never a guess. `fail` on any layout issue
|
|
261
|
+
* (an invalid section or a declared path that does not exist — both deterministic in the
|
|
262
|
+
* repo, so failing the exit code is safe for CI); otherwise `ok`, listing each location
|
|
263
|
+
* and whether it was declared or defaulted.
|
|
264
|
+
*/
|
|
265
|
+
export function layoutDoctorChecks(layout) {
|
|
266
|
+
if (!layout) return []
|
|
267
|
+
const base = { id: 'project-layout', bucket: 'app' }
|
|
268
|
+
if (layout.issues.length > 0) {
|
|
269
|
+
return [
|
|
270
|
+
{
|
|
271
|
+
...base,
|
|
272
|
+
status: 'fail',
|
|
273
|
+
detail: `${layout.issues.length} problem(s) with the project layout. ${layout.issues.map((i) => i.message).join(' ')}`,
|
|
274
|
+
fix: layout.issues.find((i) => i.fix)?.fix ?? `Fix ${LAYOUT_SECTION} in the repo-root package.json.`,
|
|
275
|
+
},
|
|
276
|
+
]
|
|
277
|
+
}
|
|
278
|
+
const parts = KEYS.map((key) => {
|
|
279
|
+
const entry = layout.entries[key]
|
|
280
|
+
const where = entry.found ? entry.display : `${entry.display} (not present)`
|
|
281
|
+
return `${key} → ${where}`
|
|
282
|
+
})
|
|
283
|
+
const origin =
|
|
284
|
+
layout.source === 'declared'
|
|
285
|
+
? `declared in ${LAYOUT_SECTION} (undeclared keys use the scaffold defaults)`
|
|
286
|
+
: `the scaffold defaults — nothing declared in ${LAYOUT_SECTION}`
|
|
287
|
+
return [{ ...base, status: 'ok', detail: `${parts.join(', ')} — ${origin}.` }]
|
|
288
|
+
}
|
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 { appDisplay, resolveProjectLayout } from './layout.js'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* `elytra previews` (EC-440) — build-time fixture-preview generation.
|
|
@@ -561,9 +562,10 @@ export function previewsDoctorChecks(artifacts) {
|
|
|
561
562
|
* The engine version for the input hash — read from the `@elytracms/studio`
|
|
562
563
|
* (fallback `@elytracms/core`) package the CONSUMER has installed, never
|
|
563
564
|
* hardcoded. Direct node_modules paths (pnpm keeps symlinks for direct deps).
|
|
565
|
+
* `studioDir` is the resolved studio app (EC-603 layout).
|
|
564
566
|
*/
|
|
565
|
-
export function resolveEngineVersion(root) {
|
|
566
|
-
const bases = [
|
|
567
|
+
export function resolveEngineVersion(root, studioDir) {
|
|
568
|
+
const bases = [studioDir, root]
|
|
567
569
|
for (const pkg of ['@elytracms/studio', '@elytracms/core']) {
|
|
568
570
|
for (const base of bases) {
|
|
569
571
|
const path = join(base, 'node_modules', ...pkg.split('/'), 'package.json')
|
|
@@ -581,16 +583,26 @@ export function resolveEngineVersion(root) {
|
|
|
581
583
|
|
|
582
584
|
/**
|
|
583
585
|
* The consumer theme entry the harness page imports: the studio's canvas
|
|
584
|
-
* stylesheet (
|
|
585
|
-
* theme plus any engine-required `@source` lines
|
|
586
|
-
*
|
|
587
|
-
*
|
|
586
|
+
* stylesheet (`<studio>/src/styles/canvas.css` — the EC-414 shape: the site
|
|
587
|
+
* theme plus any engine-required `@source` lines; the stylesheet the canvas
|
|
588
|
+
* iframe really links) when present, else the theme itself — `package.json#elytra
|
|
589
|
+
* .theme`, default `components/theme.css` (EC-603: the project's declaration, never
|
|
590
|
+
* a fixed path). `null` → no theme, the command refuses (an unstyled preview would
|
|
591
|
+
* be a lying artifact).
|
|
592
|
+
*
|
|
593
|
+
* The canvas stylesheet stays first so a conventional project's recorded render
|
|
594
|
+
* dependencies (its committed manifest) are byte-for-byte unchanged by EC-603. It is
|
|
595
|
+
* looked up relative to the RESOLVED studio app; a project without one falls back to
|
|
596
|
+
* the declared theme, so it is a preference, never a requirement.
|
|
588
597
|
*/
|
|
589
|
-
export function themeStylesheetPath(root) {
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
598
|
+
export function themeStylesheetPath(root, layout = resolveProjectLayout(root)) {
|
|
599
|
+
const studio = layout.entries.studio
|
|
600
|
+
const theme = layout.entries.theme
|
|
601
|
+
const candidates = [
|
|
602
|
+
...(studio.path ? [join(studio.path, 'src', 'styles', 'canvas.css')] : []),
|
|
603
|
+
...(theme.path ? [theme.path] : []),
|
|
604
|
+
]
|
|
605
|
+
for (const candidate of candidates) {
|
|
594
606
|
if (existsSync(candidate)) return candidate
|
|
595
607
|
}
|
|
596
608
|
return null
|
|
@@ -652,7 +664,7 @@ function harnessPlugins({ configPath, themePath }) {
|
|
|
652
664
|
}
|
|
653
665
|
|
|
654
666
|
/** Boot the shared consumer-Vite harness with the previews page + modules. */
|
|
655
|
-
async function startHarnessServer({ root, studioDir, port }) {
|
|
667
|
+
async function startHarnessServer({ root, studioDir, layout, port }) {
|
|
656
668
|
// Resolve vite first so a missing toolchain names vite (the same failure
|
|
657
669
|
// order as before the boot moved to the shared config-harness).
|
|
658
670
|
resolveFromDir(studioDir, 'vite')
|
|
@@ -660,11 +672,13 @@ async function startHarnessServer({ root, studioDir, port }) {
|
|
|
660
672
|
const tailwind = (await import(pathToFileURL(tailwindEntry).href)).default
|
|
661
673
|
|
|
662
674
|
const configPath = join(root, 'elytra.config.ts')
|
|
663
|
-
const themePath = themeStylesheetPath(root)
|
|
675
|
+
const themePath = themeStylesheetPath(root, layout)
|
|
664
676
|
if (!themePath) {
|
|
677
|
+
const studio = appDisplay(layout, 'studio')
|
|
678
|
+
const theme = appDisplay(layout, 'theme')
|
|
665
679
|
throw new Error(
|
|
666
|
-
|
|
667
|
-
'previews must render against the consumer theme.',
|
|
680
|
+
`No theme stylesheet found (looked for ${studio}/src/styles/canvas.css and ${theme} — ` +
|
|
681
|
+
'declare yours as package.json#elytra.theme) — previews must render against the consumer theme.',
|
|
668
682
|
)
|
|
669
683
|
}
|
|
670
684
|
|
|
@@ -708,8 +722,8 @@ const PLAYWRIGHT_INSTALL_HELP = [
|
|
|
708
722
|
].join('\n')
|
|
709
723
|
|
|
710
724
|
/** Dynamic, on-demand Playwright load from the consumer repo. `null` = not installed. */
|
|
711
|
-
async function loadPlaywright(root) {
|
|
712
|
-
for (const base of [
|
|
725
|
+
async function loadPlaywright(root, studioDir) {
|
|
726
|
+
for (const base of [studioDir, root]) {
|
|
713
727
|
let resolved
|
|
714
728
|
try {
|
|
715
729
|
resolved = resolveFromDir(base, 'playwright')
|
|
@@ -795,8 +809,11 @@ async function captureViewport(page, cdp) {
|
|
|
795
809
|
*/
|
|
796
810
|
export async function runPreviews(project, { check = false, port, log = (l) => console.log(l) } = {}) {
|
|
797
811
|
const root = project.root
|
|
798
|
-
|
|
799
|
-
const
|
|
812
|
+
// EC-603: the studio app and theme where the project's layout says they are.
|
|
813
|
+
const layout = project.layout ?? resolveProjectLayout(root)
|
|
814
|
+
const studioDir = layout.entries.studio.path
|
|
815
|
+
const studioLabel = layout.entries.studio.display
|
|
816
|
+
const engine = resolveEngineVersion(root, studioDir)
|
|
800
817
|
const manifestPath = join(studioDir, PREVIEWS_MANIFEST_FILE)
|
|
801
818
|
const imagesDir = join(studioDir, PREVIEWS_DIR)
|
|
802
819
|
|
|
@@ -807,7 +824,7 @@ export async function runPreviews(project, { check = false, port, log = (l) => c
|
|
|
807
824
|
const chosenPort = port ? Number(port) : await pickPort()
|
|
808
825
|
let harness
|
|
809
826
|
try {
|
|
810
|
-
harness = await startHarnessServer({ root, studioDir, port: chosenPort })
|
|
827
|
+
harness = await startHarnessServer({ root, studioDir, layout, port: chosenPort })
|
|
811
828
|
} catch (error) {
|
|
812
829
|
log(`Could not start the render harness: ${error.message}`)
|
|
813
830
|
return 1
|
|
@@ -863,7 +880,7 @@ export async function runPreviews(project, { check = false, port, log = (l) => c
|
|
|
863
880
|
|
|
864
881
|
const failures = []
|
|
865
882
|
if (toRender.length > 0) {
|
|
866
|
-
const playwright = await loadPlaywright(root)
|
|
883
|
+
const playwright = await loadPlaywright(root, studioDir)
|
|
867
884
|
if (!playwright) {
|
|
868
885
|
log(PLAYWRIGHT_INSTALL_HELP)
|
|
869
886
|
return 1
|
|
@@ -933,7 +950,7 @@ export async function runPreviews(project, { check = false, port, log = (l) => c
|
|
|
933
950
|
log(
|
|
934
951
|
`Done: ${rendered} rendered, ${jobs.length - toRender.length} unchanged, ` +
|
|
935
952
|
`${orphans.length} pruned, manifest ${manifestChanged ? 'written' : 'unchanged'} ` +
|
|
936
|
-
`(
|
|
953
|
+
`(${studioLabel}/${PREVIEWS_MANIFEST_FILE}).`,
|
|
937
954
|
)
|
|
938
955
|
if (failures.length > 0) {
|
|
939
956
|
log(`✗ ${failures.length} fixture(s) failed to render — their entries were left out of the manifest.`)
|