@elytracms/cli 0.0.87 → 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 -34
- package/src/config-harness.js +173 -3
- package/src/doctor.js +166 -24
- 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
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs'
|
|
2
2
|
import { createRequire } from 'node:module'
|
|
3
3
|
import { dirname, join } from 'node:path'
|
|
4
|
+
import { orphanedCollections } from '@elytracms/core/cms-core'
|
|
4
5
|
import {
|
|
5
6
|
REVALIDATE_ENV_ENDPOINT,
|
|
6
7
|
REVALIDATE_ENV_SECRET,
|
|
7
8
|
backendSiteUrl,
|
|
9
|
+
backendUrlOf,
|
|
8
10
|
checkDeploymentReady,
|
|
9
11
|
expectedDeploymentEnv,
|
|
12
|
+
parseBackendMode,
|
|
10
13
|
probeAuthSigningKeys,
|
|
14
|
+
probeDeliverySnapshot,
|
|
11
15
|
probeDeployedFunctionsVersion,
|
|
12
16
|
probeRevalidateEndpoint,
|
|
13
17
|
} from '@elytracms/core/export-sync/deployment-ready'
|
|
14
|
-
import { executeWorkspaceConfig } from './config-harness.js'
|
|
18
|
+
import { aiRouteDoctorChecks, executeWorkspaceConfig, readStudioAiRoute } from './config-harness.js'
|
|
19
|
+
import { appDisplay, layoutDoctorChecks } from './layout.js'
|
|
15
20
|
import { convexEnvGet, defaultConvexExec } from './provision.js'
|
|
16
21
|
import { gatherPreviewsArtifacts, previewsDoctorChecks } from './previews.js'
|
|
17
22
|
import { loadProjectRoutes, routesDoctorChecks } from './routes-check.js'
|
|
@@ -38,17 +43,29 @@ import { loadProjectRoutes, routesDoctorChecks } from './routes-check.js'
|
|
|
38
43
|
/**
|
|
39
44
|
* Decide which deployment the doctor diagnoses from the resolved studio env.
|
|
40
45
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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
|
|
44
54
|
* playground locally but points at a Convex deployment (e.g. the demo) is diagnosed
|
|
45
|
-
* 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`).
|
|
46
59
|
*/
|
|
47
|
-
export function resolveDoctorTarget(env = {}, { url } = {}) {
|
|
60
|
+
export function resolveDoctorTarget(env = {}, { url, envSource = 'studio/.env' } = {}) {
|
|
48
61
|
const explicit = url && url.trim() !== '' ? url.trim() : undefined
|
|
49
|
-
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)
|
|
50
67
|
const chosenUrl = explicit || envUrl
|
|
51
|
-
const playgroundFlag =
|
|
68
|
+
const playgroundFlag = backend.kind === 'playground'
|
|
52
69
|
|
|
53
70
|
if (chosenUrl) {
|
|
54
71
|
return {
|
|
@@ -65,7 +82,7 @@ export function resolveDoctorTarget(env = {}, { url } = {}) {
|
|
|
65
82
|
if (playgroundFlag) return { kind: 'playground' }
|
|
66
83
|
return {
|
|
67
84
|
kind: 'unconfigured',
|
|
68
|
-
detail:
|
|
85
|
+
detail: `No VITE_CONVEX_URL is set, and VITE_ELYTRA_MODE is not "playground", in ${envSource}.`,
|
|
69
86
|
}
|
|
70
87
|
}
|
|
71
88
|
|
|
@@ -201,7 +218,7 @@ export function configFactsFromOutcome(outcome) {
|
|
|
201
218
|
issueCount: issues.length,
|
|
202
219
|
...(firstIssue ? { firstIssue: formatConfigIssue(firstIssue) } : {}),
|
|
203
220
|
}
|
|
204
|
-
const project =
|
|
221
|
+
const project = resolvedProjectOf(outcome)
|
|
205
222
|
const collections = project?.collections
|
|
206
223
|
// EC-524: the `componentIds` fact was gathered here for EC-508's presentation
|
|
207
224
|
// check, which resolved a collection's declared `presentation.wrapper` against
|
|
@@ -216,6 +233,60 @@ export function configFactsFromOutcome(outcome) {
|
|
|
216
233
|
}
|
|
217
234
|
}
|
|
218
235
|
|
|
236
|
+
/**
|
|
237
|
+
* The project a resolved config outcome describes — the first one, the same choice
|
|
238
|
+
* {@link configFactsFromOutcome} makes for `collections` — or `undefined` when the
|
|
239
|
+
* config did not resolve validly (or was never evaluated). Pure.
|
|
240
|
+
*/
|
|
241
|
+
export function resolvedProjectOf(outcome) {
|
|
242
|
+
if (!outcome || outcome.kind !== 'resolved' || outcome.result?.ok !== true) return undefined
|
|
243
|
+
return outcome.result.projects?.[0]
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// --- Published orphans (EC-606) -----------------------------------------------------
|
|
247
|
+
//
|
|
248
|
+
// A published document whose collection the config does not declare is still on the
|
|
249
|
+
// public delivery snapshot, but no route or query addresses it and the studio hides it.
|
|
250
|
+
// The doctor reads the snapshot (public, published-only — what every visitor receives)
|
|
251
|
+
// and runs core's ONE counter (`orphanedCollections`, the same one the studio's session
|
|
252
|
+
// hydration runs) against the resolved config's collection ids.
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Gather the `publishedOrphans` fact for a convex target, or `undefined` for any other
|
|
256
|
+
* target (no deployment, no snapshot). Without a declared set — the config did not
|
|
257
|
+
* resolve here — the fact is an honest `unknown` carrying why, and no request is made.
|
|
258
|
+
* `project` is {@link resolvedProjectOf}'s answer; `configFacts` is what
|
|
259
|
+
* {@link configFactsFromOutcome} returned (for the reason text).
|
|
260
|
+
*/
|
|
261
|
+
export async function gatherPublishedOrphans({
|
|
262
|
+
target,
|
|
263
|
+
project,
|
|
264
|
+
configFacts = {},
|
|
265
|
+
fetchImpl = fetch,
|
|
266
|
+
probe = probeDeliverySnapshot,
|
|
267
|
+
} = {}) {
|
|
268
|
+
if (target?.kind !== 'convex') return undefined
|
|
269
|
+
if (!project || !Array.isArray(project.collections)) {
|
|
270
|
+
const reason =
|
|
271
|
+
configFacts.config !== undefined && !configFacts.config.valid
|
|
272
|
+
? 'fix the workspace config issues first (see config-valid).'
|
|
273
|
+
: (configFacts.configUnavailableDetail ?? 'elytra.config.ts was not evaluated here.')
|
|
274
|
+
return {
|
|
275
|
+
status: 'unknown',
|
|
276
|
+
detail: `Published documents were not checked against the declared collections — ${reason}`,
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const snapshot = await probe(target.url, project.slug, fetchImpl)
|
|
280
|
+
if (snapshot.status !== 'answered') return { status: 'unknown', detail: snapshot.detail }
|
|
281
|
+
return {
|
|
282
|
+
status: 'counted',
|
|
283
|
+
orphans: orphanedCollections(
|
|
284
|
+
snapshot.collections.map((collection) => ({ collection, published: true })),
|
|
285
|
+
project.collections.map((collection) => collection.id),
|
|
286
|
+
),
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
219
290
|
// --- Deployment env-var presence (EC-399) --------------------------------------
|
|
220
291
|
//
|
|
221
292
|
// The Convex deployment's own env (`npx convex env set …`) lives in NO file a
|
|
@@ -289,7 +360,7 @@ export function gatherDeploymentEnvFacts({ env = {}, target, studioDir, aiRouteC
|
|
|
289
360
|
facts.unavailableDetail =
|
|
290
361
|
'--url overrides the linked deployment; env names are only read from the deployment this repo is linked to.'
|
|
291
362
|
} else if (!studioDir) {
|
|
292
|
-
facts.unavailableDetail = 'No studio
|
|
363
|
+
facts.unavailableDetail = 'No studio app found — deployment env names not enumerated.'
|
|
293
364
|
} else if (!env.CONVEX_DEPLOYMENT && !env.CONVEX_DEPLOY_KEY) {
|
|
294
365
|
facts.unavailableDetail =
|
|
295
366
|
'No linked deployment (CONVEX_DEPLOYMENT / CONVEX_DEPLOY_KEY unset) — `npx convex env list` needs one.'
|
|
@@ -304,10 +375,10 @@ export function gatherDeploymentEnvFacts({ env = {}, target, studioDir, aiRouteC
|
|
|
304
375
|
// --- Signed revalidate ping (EC-421) -------------------------------------------
|
|
305
376
|
//
|
|
306
377
|
// When the presence checks say the instant-publish pair IS set, the doctor proves the
|
|
307
|
-
// chain end to end: a POST to the endpoint signed with the secret, carrying
|
|
308
|
-
//
|
|
309
|
-
// `{"revalidated":[]}`. Default-on (no flag): the ping is
|
|
310
|
-
// 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
|
|
311
382
|
// forever, no error anywhere) is exactly what the doctor exists for.
|
|
312
383
|
//
|
|
313
384
|
// The ping needs the pair's VALUES — the one sanctioned value read: captured
|
|
@@ -403,6 +474,29 @@ export function formatReport(report) {
|
|
|
403
474
|
return lines.join('\n')
|
|
404
475
|
}
|
|
405
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
|
+
|
|
406
500
|
// --- Command -----------------------------------------------------------------
|
|
407
501
|
|
|
408
502
|
/**
|
|
@@ -441,6 +535,24 @@ export function formatReport(report) {
|
|
|
441
535
|
* version and the `functions-version` check gets a real answer; otherwise the fact is
|
|
442
536
|
* omitted → an honest `unknown`. Resolution stays at the entry point so this function
|
|
443
537
|
* remains pure/injected like every other gatherer here.
|
|
538
|
+
*
|
|
539
|
+
* EC-606: against a convex target the `app` bucket also reports PUBLISHED ORPHANS —
|
|
540
|
+
* published documents whose collection the resolved config does not declare — read
|
|
541
|
+
* from the public delivery snapshot ({@link gatherPublishedOrphans}). A `warn` at most,
|
|
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.
|
|
444
556
|
*/
|
|
445
557
|
export async function runDoctor({
|
|
446
558
|
env = {},
|
|
@@ -449,14 +561,17 @@ export async function runDoctor({
|
|
|
449
561
|
url,
|
|
450
562
|
studioDir,
|
|
451
563
|
root,
|
|
452
|
-
|
|
564
|
+
layout,
|
|
565
|
+
aiRoute: aiRouteOutcome,
|
|
566
|
+
aiRouteConfigured,
|
|
453
567
|
exec,
|
|
454
568
|
previewsArtifacts,
|
|
455
569
|
routeFacts,
|
|
456
570
|
configOutcome,
|
|
457
571
|
installedEngineVersion,
|
|
458
572
|
} = {}) {
|
|
459
|
-
const
|
|
573
|
+
const studioLabel = appDisplay(layout, 'studio')
|
|
574
|
+
const target = resolveDoctorTarget(env, { url, envSource: `${studioLabel}/.env` })
|
|
460
575
|
|
|
461
576
|
log('Elytra deployment doctor')
|
|
462
577
|
if (target.kind === 'convex') {
|
|
@@ -475,8 +590,14 @@ export async function runDoctor({
|
|
|
475
590
|
// entry resolves it via `resolveInstalledEngineVersion`; runDoctor itself never
|
|
476
591
|
// touches the filesystem for it, staying pure/injected like every gatherer here).
|
|
477
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
|
+
|
|
478
599
|
if (target.kind === 'convex') {
|
|
479
|
-
facts.deploymentEnv = gatherDeploymentEnvFacts({ env, target, studioDir, aiRouteConfigured, exec })
|
|
600
|
+
facts.deploymentEnv = gatherDeploymentEnvFacts({ env, target, studioDir, aiRouteConfigured: aiExpected, exec })
|
|
480
601
|
// EC-421: default-on signed no-op ping whenever the revalidate pair is present.
|
|
481
602
|
const revalidateProbe = await gatherRevalidateProbe({
|
|
482
603
|
envFacts: facts.deploymentEnv,
|
|
@@ -491,12 +612,23 @@ export async function runDoctor({
|
|
|
491
612
|
// (announced by the harness itself — the boot costs seconds). Merged BEFORE the
|
|
492
613
|
// contract composes the report so `config-valid`/`asset-collection` are real checks.
|
|
493
614
|
let configFacts = {}
|
|
615
|
+
let outcome
|
|
494
616
|
if (configOutcome !== undefined || root !== undefined) {
|
|
495
|
-
|
|
617
|
+
outcome = configOutcome ?? (await executeWorkspaceConfig({ root, studioDir, log }))
|
|
496
618
|
configFacts = configFactsFromOutcome(outcome)
|
|
497
619
|
Object.assign(facts, configFacts)
|
|
498
620
|
}
|
|
499
621
|
|
|
622
|
+
// EC-606: published documents of undeclared collections — one more request on the
|
|
623
|
+
// same public query API, made only when the config resolved (it needs the declared set).
|
|
624
|
+
const publishedOrphans = await gatherPublishedOrphans({
|
|
625
|
+
target,
|
|
626
|
+
project: resolvedProjectOf(outcome),
|
|
627
|
+
configFacts,
|
|
628
|
+
fetchImpl,
|
|
629
|
+
})
|
|
630
|
+
if (publishedOrphans) facts.publishedOrphans = publishedOrphans
|
|
631
|
+
|
|
500
632
|
const report = checkDeploymentReady(facts)
|
|
501
633
|
|
|
502
634
|
// EC-440: the previews bucket (repo artifacts, backend-independent). Appended to
|
|
@@ -505,14 +637,24 @@ export async function runDoctor({
|
|
|
505
637
|
const artifacts = previewsArtifacts ?? (studioDir ? gatherPreviewsArtifacts(studioDir) : undefined)
|
|
506
638
|
let checks = artifacts ? [...report.checks, ...previewsDoctorChecks(artifacts)] : [...report.checks]
|
|
507
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
|
+
|
|
508
646
|
// EC-469: the route-conflict check, spliced into the `app` bucket it belongs to.
|
|
509
647
|
// Backend-independent (routes are CODE) — it runs in playground mode too.
|
|
510
|
-
// EC-
|
|
511
|
-
//
|
|
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`).
|
|
512
652
|
if (root !== undefined || routeFacts !== undefined) {
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
653
|
+
checks = spliceBucketChecks(
|
|
654
|
+
checks,
|
|
655
|
+
'app',
|
|
656
|
+
routesDoctorChecks(await routeFactsFor({ outcome, configFacts, routeFacts, root })),
|
|
657
|
+
)
|
|
516
658
|
}
|
|
517
659
|
|
|
518
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
|
+
}
|