@elytracms/cli 0.0.88 → 0.0.91
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 +172 -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,96 @@ 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
|
+
|
|
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,
|
|
563
|
+
},
|
|
564
|
+
]
|
|
565
|
+
}
|
|
566
|
+
|
|
462
567
|
// --- Command -----------------------------------------------------------------
|
|
463
568
|
|
|
464
569
|
/**
|
|
@@ -502,6 +607,25 @@ export function formatReport(report) {
|
|
|
502
607
|
* published documents whose collection the resolved config does not declare — read
|
|
503
608
|
* from the public delivery snapshot ({@link gatherPublishedOrphans}). A `warn` at most,
|
|
504
609
|
* so the exit code never changes; without a resolved config it is an honest `unknown`.
|
|
610
|
+
*
|
|
611
|
+
* EC-603: three more facts, all read from DECLARATIONS rather than fixed paths:
|
|
612
|
+
* - `layout` (the CLI entry passes `inspectProject`'s resolved `package.json#elytra`
|
|
613
|
+
* layout) → the `project-layout` row: what was resolved, declared or defaulted, and a
|
|
614
|
+
* `fail` for an invalid section or a declared path that does not exist;
|
|
615
|
+
* - the AI route → read from the studio's own resolved Vite config
|
|
616
|
+
* ({@link readStudioAiRoute}) when a `root` is known — it decides the AI-key
|
|
617
|
+
* expectation and gets its own `ai-route` row. `aiRoute` (an outcome) and
|
|
618
|
+
* `aiRouteConfigured` (a boolean) are test seams; without either and without a root
|
|
619
|
+
* no AI expectation is added;
|
|
620
|
+
* - the route checks run over the RESOLVED config's routes whenever the harness
|
|
621
|
+
* resolved it (the routes the studio and delivery actually serve); the native
|
|
622
|
+
* `cms/index.ts` read is the fallback only when it could not, and the row says which.
|
|
623
|
+
*
|
|
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.
|
|
505
629
|
*/
|
|
506
630
|
export async function runDoctor({
|
|
507
631
|
env = {},
|
|
@@ -510,14 +634,18 @@ export async function runDoctor({
|
|
|
510
634
|
url,
|
|
511
635
|
studioDir,
|
|
512
636
|
root,
|
|
513
|
-
|
|
637
|
+
layout,
|
|
638
|
+
aiRoute: aiRouteOutcome,
|
|
639
|
+
aiRouteConfigured,
|
|
514
640
|
exec,
|
|
515
641
|
previewsArtifacts,
|
|
516
642
|
routeFacts,
|
|
517
643
|
configOutcome,
|
|
518
644
|
installedEngineVersion,
|
|
645
|
+
frontendCacheComponents,
|
|
519
646
|
} = {}) {
|
|
520
|
-
const
|
|
647
|
+
const studioLabel = appDisplay(layout, 'studio')
|
|
648
|
+
const target = resolveDoctorTarget(env, { url, envSource: `${studioLabel}/.env` })
|
|
521
649
|
|
|
522
650
|
log('Elytra deployment doctor')
|
|
523
651
|
if (target.kind === 'convex') {
|
|
@@ -536,8 +664,14 @@ export async function runDoctor({
|
|
|
536
664
|
// entry resolves it via `resolveInstalledEngineVersion`; runDoctor itself never
|
|
537
665
|
// touches the filesystem for it, staying pure/injected like every gatherer here).
|
|
538
666
|
const facts = await gatherFacts(target, { fetchImpl, installedEngineVersion })
|
|
667
|
+
|
|
668
|
+
// EC-603: the AI route from the studio's resolved Vite config — gathered before the
|
|
669
|
+
// deployment-env facts, whose AI-key expectation it decides.
|
|
670
|
+
const aiRoute = aiRouteOutcome ?? (root !== undefined ? await readStudioAiRoute({ studioDir }) : undefined)
|
|
671
|
+
const aiExpected = aiRouteConfigured ?? aiRoute?.kind === 'mounted'
|
|
672
|
+
|
|
539
673
|
if (target.kind === 'convex') {
|
|
540
|
-
facts.deploymentEnv = gatherDeploymentEnvFacts({ env, target, studioDir, aiRouteConfigured, exec })
|
|
674
|
+
facts.deploymentEnv = gatherDeploymentEnvFacts({ env, target, studioDir, aiRouteConfigured: aiExpected, exec })
|
|
541
675
|
// EC-421: default-on signed no-op ping whenever the revalidate pair is present.
|
|
542
676
|
const revalidateProbe = await gatherRevalidateProbe({
|
|
543
677
|
envFacts: facts.deploymentEnv,
|
|
@@ -577,14 +711,30 @@ export async function runDoctor({
|
|
|
577
711
|
const artifacts = previewsArtifacts ?? (studioDir ? gatherPreviewsArtifacts(studioDir) : undefined)
|
|
578
712
|
let checks = artifacts ? [...report.checks, ...previewsDoctorChecks(artifacts)] : [...report.checks]
|
|
579
713
|
|
|
714
|
+
// 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
|
+
checks = spliceBucketChecks(checks, 'app', [
|
|
718
|
+
...layoutDoctorChecks(layout),
|
|
719
|
+
...aiRouteDoctorChecks(aiRoute, { studioLabel }),
|
|
720
|
+
...cacheComponentsDoctorChecks(
|
|
721
|
+
frontendCacheComponents ?? gatherFrontendCacheComponents(frontend?.found ? frontend.path : undefined),
|
|
722
|
+
{ frontendLabel: appDisplay(layout, 'frontend') },
|
|
723
|
+
),
|
|
724
|
+
])
|
|
725
|
+
|
|
580
726
|
// EC-469: the route-conflict check, spliced into the `app` bucket it belongs to.
|
|
581
727
|
// Backend-independent (routes are CODE) — it runs in playground mode too.
|
|
582
|
-
// EC-
|
|
583
|
-
//
|
|
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`).
|
|
584
732
|
if (root !== undefined || routeFacts !== undefined) {
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
733
|
+
checks = spliceBucketChecks(
|
|
734
|
+
checks,
|
|
735
|
+
'app',
|
|
736
|
+
routesDoctorChecks(await routeFactsFor({ outcome, configFacts, routeFacts, root })),
|
|
737
|
+
)
|
|
588
738
|
}
|
|
589
739
|
|
|
590
740
|
// 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
|
+
}
|