@elytracms/cli 0.0.91 → 0.0.93

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@elytracms/cli",
3
3
  "author": "finaldream <hi@finaldream.de>",
4
- "version": "0.0.91",
4
+ "version": "0.0.93",
5
5
  "description": "The Elytra CLI — run a consuming repo's studio, frontend, and Convex backend under one command.",
6
6
  "license": "MIT",
7
7
  "type": "module",
@@ -17,7 +17,7 @@
17
17
  "node": ">=22.18.0"
18
18
  },
19
19
  "dependencies": {
20
- "@elytracms/core": "^0.0.91"
20
+ "@elytracms/core": "^0.0.93"
21
21
  },
22
22
  "devDependencies": {
23
23
  "vitest": "^4.1.10"
package/src/cli.js CHANGED
@@ -1,6 +1,12 @@
1
1
  import { spawnSync } from 'node:child_process'
2
2
  import { stdout } from 'node:process'
3
+ import {
4
+ DEV_FRONTEND_BASE_PORT,
5
+ DEV_FRONTEND_URL_ENV,
6
+ devFrontendUrl,
7
+ } from '@elytracms/core/export-sync/deployment-ready'
3
8
  import { resolveInstalledEngineVersion, runDoctor } from './doctor.js'
9
+ import { formatDoctorChecksHelp } from './doctor-checks.js'
4
10
  import { appDisplay } from './layout.js'
5
11
  import { runPreviews } from './previews.js'
6
12
  import { inspectProject, resolveStudioEnv } from './project.js'
@@ -24,16 +30,10 @@ Commands:
24
30
  elytra build frontend frontend build only
25
31
  init First-run setup: provision Convex, generate the auth signing keys,
26
32
  verify readiness. Idempotent — safe to re-run at any point.
27
- doctor Diagnose the active deployment's readiness (URL, reachability, auth
28
- signing keys, expected deployment env vars — set/missing by NAME,
29
- values never shown — config, membership) and print each fix. When the
30
- instant-publish revalidate pair is set, also sends a signed no-op ping
31
- to the endpoint to prove the publish → live chain end to end. Also
32
- checks your routes for URL conflicts, the project layout
33
- (package.json#elytra), the AI route your studio's vite.config.ts
34
- mounts, and reports the generated-previews artifacts (manifest/files
35
- present; hash staleness lives in \`elytra previews --check\`). Exit
36
- non-zero if any check fails. Read-only. Optional: --url <convex url>.
33
+ doctor Diagnose the deployment, its env, your config, routes, project layout
34
+ and generated previews, and print each fix. 'elytra doctor --help'
35
+ lists every check. Exit non-zero if any check fails. Read-only.
36
+ Optional: --url <convex url>.
37
37
  previews Render every canvas-eligible component's fixtures against YOUR theme
38
38
  (headless Chromium via Playwright — an optional install) and write
39
39
  committed preview images (studio/public/elytra-previews/) plus an
@@ -112,27 +112,23 @@ First-run setup: provision Convex, generate the auth signing keys, verify
112
112
  readiness. Idempotent — safe to re-run at any point. No flags.
113
113
  `.trim(),
114
114
 
115
+ // EC-599: the check list is rendered from the one declaration (`doctor-checks.js`) —
116
+ // this text used to restate it by hand, and had drifted from what the doctor does.
115
117
  doctor: `
116
118
  Usage: elytra doctor [--url <convex url>]
117
119
 
118
- Diagnose the active deployment's readiness (URL, reachability, auth signing
119
- keys, expected deployment env vars — set/missing by NAME, values never shown
120
- — config, membership) and print each fix. When the instant-publish
121
- revalidate pair is set, also sends a signed no-op ping to the endpoint to
122
- prove the publish → live chain end to end. Also checks your route set for
123
- URL conflicts (routes are code — redirect loops are editorial data and are
124
- checked in the studio), and reports the generated-previews artifacts
125
- (manifest/files present; hash staleness lives in \`elytra previews --check\`).
126
- With studio/ deps installed, config validity is checked for real: doctor
127
- executes elytra.config.ts through your studio's own Vite (takes a few
128
- seconds), and the route checks judge the routes it resolved; without deps
129
- it stays an honest "unknown" with the reason and the routes are read from
130
- cms/index.ts. With the config resolved, it also reads the public delivery
131
- snapshot and warns about published documents whose collection the config
132
- does not declare. It reports the project layout it resolved
133
- (package.json#elytra — a declared path that does not exist fails) and the AI
134
- route your studio's vite.config.ts mounts (which decides whether an AI
135
- provider key is expected). Exit non-zero if any check fails. Read-only.
120
+ Diagnose the active deployment and this project, and print a copy-pasteable
121
+ fix for every failing or warning check. Read-only: it reads the studio env,
122
+ the deployment's public endpoints and the names of the deployment's env vars;
123
+ no value is ever printed (the revalidate pair's are read in-process, only to
124
+ sign the probe ping). With the studio's dependencies installed it also
125
+ executes elytra.config.ts through your studio's own Vite (a few seconds), so
126
+ the config, collection and route checks get real answers; without them those
127
+ checks say "unknown" with the reason. Exit non-zero if any check fails —
128
+ warnings and unknowns never change the exit code.
129
+
130
+ Checks, in report order:
131
+ ${formatDoctorChecksHelp()}
136
132
 
137
133
  Options:
138
134
  --url <convex url> Diagnose this deployment instead of the one resolved
@@ -215,15 +211,22 @@ export function planDev(project) {
215
211
  const studioDir = appDir(project, 'studio')
216
212
  const frontendDir = appDir(project, 'frontend')
217
213
  const offset = Number(project.devPortOffset ?? 0) || 0
218
- // The studio's `vite.config.ts` resolves the offset itself, in JS (`loadEnv`) —
219
- // portable anywhere, so forward the lever as-is.
220
- const studioEnv = { ELYTRA_DEV_PORT_OFFSET: String(offset) }
221
- // The frontend's port is resolved HERE instead, and handed over as `PORT` (which
214
+ // The frontend's port is resolved HERE, once, and handed over as `PORT` (which
222
215
  // Next reads from the process env natively). That keeps the scaffold's `dev`
223
216
  // script a plain `next dev`: computing the port inside the npm script needs POSIX
224
217
  // arithmetic (`$(( ))`), which silently breaks `pnpm dev` under cmd.exe on
225
218
  // Windows — every other template script is shell-agnostic and this one stays so.
226
- const frontendEnv = { ELYTRA_DEV_PORT_OFFSET: String(offset), PORT: String(3000 + offset) }
219
+ const frontendPort = DEV_FRONTEND_BASE_PORT + offset
220
+ // The studio's `vite.config.ts` resolves the offset itself, in JS (`loadEnv`) —
221
+ // portable anywhere, so forward the lever as-is. EC-583 — Q15 RATIFIED (EC-565
222
+ // Round 5, 5b): this process starts the frontend and is the one that KNOWS its
223
+ // port, so it hands the studio the dev frontend URL its live links use; the studio
224
+ // prefers it in a dev runtime only, and `elytra.config.ts` keeps reading no env.
225
+ const studioEnv = {
226
+ ELYTRA_DEV_PORT_OFFSET: String(offset),
227
+ ...(project.apps.frontend ? { [DEV_FRONTEND_URL_ENV]: devFrontendUrl(offset) } : {}),
228
+ }
229
+ const frontendEnv = { ELYTRA_DEV_PORT_OFFSET: String(offset), PORT: String(frontendPort) }
227
230
 
228
231
  if (project.apps.studio && project.mode === 'connected') {
229
232
  if (project.convexProvisioned && !project.isProdDeployment) {
@@ -3,6 +3,7 @@ import { createRequire } from 'node:module'
3
3
  import { createServer as createNetServer } from 'node:net'
4
4
  import { join, relative, resolve, sep } from 'node:path'
5
5
  import { pathToFileURL } from 'node:url'
6
+ import { WORKSPACE_CONFIG_FILE } from '@elytracms/core/config'
6
7
 
7
8
  /**
8
9
  * The consumer-Vite harness (EC-495) — the ONE mechanism by which this CLI
@@ -33,8 +34,8 @@ import { pathToFileURL } from 'node:url'
33
34
 
34
35
  // --- Shared plumbing ---------------------------------------------------------------
35
36
 
36
- /** Conventional config file name at the project root (mirrors `WORKSPACE_CONFIG_FILE`). */
37
- export const CONFIG_FILE = 'elytra.config.ts'
37
+ /** The config file name at the project root — core's one declaration (EC-583, RESIDUE-4d). */
38
+ export const CONFIG_FILE = WORKSPACE_CONFIG_FILE
38
39
 
39
40
  /** Resolve a module specifier from a directory's own node_modules world. */
40
41
  export function resolveFromDir(dir, specifier) {
@@ -0,0 +1,261 @@
1
+ import { expectedDeploymentEnv } from '@elytracms/core/export-sync/deployment-ready'
2
+
3
+ /**
4
+ * Every check `elytra doctor` can print, declared ONCE (EC-599), in report order.
5
+ *
6
+ * The doctor's capabilities used to be written down three times by hand — the guide's
7
+ * table, `elytra --help` and `elytra doctor --help` — and two of them drifted: the help
8
+ * text still said redirect loops are "checked in the studio" and that the routes are read
9
+ * from `cms/index.ts`, long after EC-590 moved both. Now there is one list and three
10
+ * readers: `elytra doctor --help` ({@link formatDoctorChecksHelp}), the guide's check
11
+ * table (`docs/guide/06-cli-reference.md`, a region `pnpm check:guide` regenerates and
12
+ * fails on when stale — {@link formatDoctorChecksMarkdown}), and the report's bucket
13
+ * headings ({@link DOCTOR_BUCKET_TITLES}). `test/doctor-checks.test.js` runs the doctor
14
+ * and holds the ids it prints equal to this list, both directions, bucket by bucket — a
15
+ * check added without an entry, or an entry left behind by a removed check, fails there.
16
+ *
17
+ * Each entry: `id` (as printed; `env:*` stands for the one row per expected variable),
18
+ * `bucket`, `summary` (what it looks at — one sentence, printed by `--help`), and
19
+ * `outcome` (when it fails / warns / stays unknown — the guide prints it too).
20
+ */
21
+
22
+ /** The report's buckets, in report order, with the heading each prints under. */
23
+ export const DOCTOR_BUCKETS = [
24
+ { bucket: 'deployment', title: 'Deployment' },
25
+ { bucket: 'deployment-env', title: 'Deployment env' },
26
+ { bucket: 'app', title: 'App' },
27
+ { bucket: 'user', title: 'Per-user' },
28
+ { bucket: 'previews', title: 'Previews' },
29
+ ]
30
+
31
+ /** Bucket id → its heading in the printed report. */
32
+ export const DOCTOR_BUCKET_TITLES = Object.fromEntries(DOCTOR_BUCKETS.map(({ bucket, title }) => [bucket, title]))
33
+
34
+ export const DOCTOR_CHECKS = [
35
+ // --- deployment -----------------------------------------------------------------
36
+ {
37
+ id: 'backend-url',
38
+ bucket: 'deployment',
39
+ summary:
40
+ 'A backend is configured: `VITE_CONVEX_URL` (or `CONVEX_URL`) in the studio env, read by the parser the studio runs, or `--url`.',
41
+ outcome:
42
+ 'Fails with neither, and on a `VITE_ELYTRA_MODE` other than `playground`. A configured URL is diagnosed even when the studio itself runs playground.',
43
+ },
44
+ {
45
+ id: 'backend-reachable',
46
+ bucket: 'deployment',
47
+ summary: 'The deployment answers its query API as a Convex deployment — not merely any HTTP response.',
48
+ outcome: 'Fails when it does not (a nonexistent deployment’s empty 404 included).',
49
+ },
50
+ {
51
+ id: 'auth-signing-keys',
52
+ bucket: 'deployment',
53
+ summary:
54
+ 'The deployment’s public JWKS endpoint serves a signing key set — definitive from node (the browser cannot read it: the endpoint sets no CORS).',
55
+ outcome: 'Fails when the deployment publishes no key set (`JWKS` / `JWT_PRIVATE_KEY` unset) — every sign-in fails.',
56
+ },
57
+ {
58
+ id: 'deployment-provisioned',
59
+ bucket: 'deployment',
60
+ summary: 'A deployment is wired for the active project.',
61
+ outcome: 'Passes whenever a backend URL is configured (a URL is a wired deployment); `unknown` with none.',
62
+ },
63
+ {
64
+ id: 'functions-version',
65
+ bucket: 'deployment',
66
+ summary:
67
+ 'The engine version the deployed functions were stamped with, against the `@elytracms/studio` your studio has installed.',
68
+ outcome:
69
+ 'Fails when they differ or the stamp is missing; `unknown` when the installed studio version cannot be read here.',
70
+ },
71
+ // --- deployment env -------------------------------------------------------------
72
+ {
73
+ id: 'env:*',
74
+ bucket: 'deployment-env',
75
+ summary:
76
+ 'One row per expected variable, by name only — the deployment’s (`npx convex env list` on the linked deployment) and, when the studio mounts an AI route, the studio host’s provider key. No value reaches the report.',
77
+ outcome:
78
+ 'A required variable unset fails, a recommended one warns; a half-set group (one of the revalidate pair) fails. `unknown` with no linked deployment, or under `--url`.',
79
+ },
80
+ {
81
+ id: 'revalidate-probe',
82
+ bucket: 'deployment-env',
83
+ summary:
84
+ 'When the revalidate pair is set: a signed no-op `probe` event POSTed to the endpoint, proving the publish → live chain end to end.',
85
+ outcome:
86
+ 'Fails on any answer but the route’s `200 {"revalidated":[]}` — a secret mismatch or an unset frontend secret (401), a frontend on another Elytra version (400), no route (404). Warns when the endpoint cannot be reached from this machine.',
87
+ },
88
+ // --- app ------------------------------------------------------------------------
89
+ {
90
+ id: 'config-valid',
91
+ bucket: 'app',
92
+ summary:
93
+ '`elytra.config.ts` executed through your studio’s own Vite (needs the studio’s dependencies installed) and run through the one resolver the studio boots through.',
94
+ outcome:
95
+ 'Fails with every located issue (`projects.0.slug: …`); `unknown`, with the reason, when it cannot be executed here. The checks below that read the config are `unknown` then too.',
96
+ },
97
+ {
98
+ id: 'asset-collection',
99
+ bucket: 'app',
100
+ summary: 'A project whose collections declare asset fields resolves an asset collection.',
101
+ outcome: 'Fails when none resolves.',
102
+ },
103
+ {
104
+ id: 'presentation-targets',
105
+ bucket: 'app',
106
+ summary: 'Every collection’s `presentation.body` names fields the collection declares.',
107
+ outcome: 'Fails on a name the collection does not have.',
108
+ },
109
+ {
110
+ id: 'published-orphans',
111
+ bucket: 'app',
112
+ summary:
113
+ 'Published documents of collections the config does not declare, read from the deployment’s public delivery snapshot.',
114
+ outcome: 'Warns, naming each collection and count. Printed only against a deployment, with the config resolved.',
115
+ },
116
+ {
117
+ id: 'project-layout',
118
+ bucket: 'app',
119
+ summary:
120
+ 'The studio, frontend and theme locations the CLI resolved from `package.json#elytra`, and whether each was declared or defaulted.',
121
+ outcome: 'Fails on an invalid section or a declared path that does not exist.',
122
+ },
123
+ {
124
+ id: 'ai-route',
125
+ bucket: 'app',
126
+ summary:
127
+ 'The AI route your studio’s `vite.config.ts` mounts (`createAiDevPlugin`, read from the loaded config) — it decides whether an AI provider key is expected.',
128
+ outcome: 'Fails when the mounted `handlerModulePath` does not exist.',
129
+ },
130
+ {
131
+ id: 'route-conflicts',
132
+ bucket: 'app',
133
+ summary:
134
+ 'Two routes tied on specificity, locale scope and shape — the one router’s issue set (the same set the studio and the delivery host read), over the routes your config resolved.',
135
+ outcome: 'Fails on a tie; `unknown` when the config could not be resolved here (as are the next three).',
136
+ },
137
+ {
138
+ id: 'mount-conflicts',
139
+ bucket: 'app',
140
+ summary: 'Two hierarchy mounts on the same path in the same locale scope.',
141
+ outcome: 'Fails on a tie.',
142
+ },
143
+ {
144
+ id: 'unreachable-routes',
145
+ bucket: 'app',
146
+ summary:
147
+ 'A declaration that can never serve content — an all-dynamic route such as `/:slug` shadowing a mount’s whole depth, a mount on a collection with no parent role, a catch-all with a dynamic prefix like `/:lang/:path*`.',
148
+ outcome: 'Fails on such a declaration; warns on a precedence that depends on content.',
149
+ },
150
+ {
151
+ id: 'redirect-loops',
152
+ bucket: 'app',
153
+ summary:
154
+ 'The deployment’s real redirects — the enabled ones its public delivery snapshot serves, read on the same request as `published-orphans`.',
155
+ outcome: 'Fails on a loop or a duplicate `from` (`/old` = `/old/`); `unknown` when no deployment was read.',
156
+ },
157
+ // --- per-user -------------------------------------------------------------------
158
+ {
159
+ id: 'authenticated',
160
+ bucket: 'user',
161
+ summary: 'The signed-in session.',
162
+ outcome: 'Always `unknown` here — the studio boot checks it.',
163
+ },
164
+ {
165
+ id: 'member-role',
166
+ bucket: 'user',
167
+ summary: 'The signed-in user’s membership of the project.',
168
+ outcome: 'Always `unknown` here — the studio boot checks it.',
169
+ },
170
+ // --- previews -------------------------------------------------------------------
171
+ {
172
+ id: 'previews-artifacts',
173
+ bucket: 'previews',
174
+ summary: 'Printed alone when the studio holds no generated previews at all (they are optional).',
175
+ outcome: 'Always ok.',
176
+ },
177
+ {
178
+ id: 'previews-manifest',
179
+ bucket: 'previews',
180
+ summary: 'The generated previews manifest (`<studio>/elytra-previews.json`) is present and parses.',
181
+ outcome: 'Warns otherwise.',
182
+ },
183
+ {
184
+ id: 'previews-files',
185
+ bucket: 'previews',
186
+ summary: 'Every manifest entry’s image file is present.',
187
+ outcome: 'Warns, naming the first missing file.',
188
+ },
189
+ {
190
+ id: 'previews-orphans',
191
+ bucket: 'previews',
192
+ summary: 'Image files no manifest entry references.',
193
+ outcome: 'Warns, naming the first.',
194
+ },
195
+ {
196
+ id: 'previews-staleness',
197
+ bucket: 'previews',
198
+ summary: 'Input-hash staleness needs the component registry, so it is not judged here.',
199
+ outcome: 'Always `unknown` — run `elytra previews --check`.',
200
+ },
201
+ ]
202
+
203
+ /** Is a printed check id the catalogue entry `entry`? (`env:*` covers every `env:<NAME>` row.) */
204
+ export function isCheckOf(entry, id) {
205
+ return entry.id.endsWith(':*') ? id.startsWith(entry.id.slice(0, -1)) : entry.id === id
206
+ }
207
+
208
+ /** The expected deployment variables, read from core's one declaration (every one the doctor may list). */
209
+ function expectedEnvNames() {
210
+ return expectedDeploymentEnv({ aiRouteConfigured: true }).map((expected) =>
211
+ expected.anyOf ? expected.anyOf.join(' / ') : expected.name,
212
+ )
213
+ }
214
+
215
+ /** Wrap prose to `width` columns, every line prefixed with `indent`. */
216
+ function wrap(text, indent, width = 80) {
217
+ const lines = []
218
+ let line = ''
219
+ for (const word of text.split(/\s+/)) {
220
+ if (line && indent.length + line.length + 1 + word.length > width) {
221
+ lines.push(indent + line)
222
+ line = word
223
+ } else {
224
+ line = line ? `${line} ${word}` : word
225
+ }
226
+ }
227
+ if (line) lines.push(indent + line)
228
+ return lines.join('\n')
229
+ }
230
+
231
+ /** The check list `elytra doctor --help` prints, grouped by bucket. Pure. */
232
+ export function formatDoctorChecksHelp() {
233
+ const out = []
234
+ for (const { bucket, title } of DOCTOR_BUCKETS) {
235
+ out.push(` ${title}:`)
236
+ for (const check of DOCTOR_CHECKS.filter((entry) => entry.bucket === bucket)) {
237
+ out.push(` ${check.id}`)
238
+ const summary =
239
+ check.id === 'env:*' ? `${check.summary} Expected: ${expectedEnvNames().join(', ')}.` : check.summary
240
+ out.push(wrap(summary.replace(/`/g, ''), ' '))
241
+ }
242
+ }
243
+ return out.join('\n')
244
+ }
245
+
246
+ /** The guide's check table (`docs/guide/06-cli-reference.md`, a generated region). Pure. */
247
+ export function formatDoctorChecksMarkdown() {
248
+ const out = ['| Bucket | Check | What it looks at | Outcome |', '| --- | --- | --- | --- |']
249
+ for (const { bucket, title } of DOCTOR_BUCKETS) {
250
+ DOCTOR_CHECKS.filter((entry) => entry.bucket === bucket).forEach((check, index) => {
251
+ const summary =
252
+ check.id === 'env:*'
253
+ ? `${check.summary} Expected: ${expectedEnvNames()
254
+ .map((name) => `\`${name}\``)
255
+ .join(', ')}.`
256
+ : check.summary
257
+ out.push(`| ${index === 0 ? `**${title}**` : ''} | \`${check.id}\` | ${summary} | ${check.outcome} |`)
258
+ })
259
+ }
260
+ return out.join('\n')
261
+ }