@elytracms/cli 0.0.77 → 0.0.79

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 CHANGED
@@ -56,6 +56,11 @@ elytra version Show version
56
56
 
57
57
  Run from anywhere inside an Elytra project (a directory with `elytra.config.ts`).
58
58
 
59
+ Every subcommand accepts `--help`/`-h` and prints its own usage — inertly: no
60
+ project inspection, no process spawned, no network call, no write. `elytra dev
61
+ --help` never starts a server; `elytra doctor --help` never runs the network
62
+ diagnostic; `elytra build --help` never validates a target.
63
+
59
64
  ## Mode-aware
60
65
 
61
66
  The CLI reads the studio's `.env` (the same backend signal the studio itself
@@ -70,6 +75,16 @@ It orchestrates and owns the process lifecycle; it does not reimplement Vite,
70
75
  Next, or Convex — each step shells out to the underlying tool and inherits its
71
76
  watch/HMR/build behaviour.
72
77
 
78
+ ## Dev ports
79
+
80
+ The studio defaults to `:5180`, the frontend to `:3000`. One lever moves both
81
+ together — set `ELYTRA_DEV_PORT_OFFSET` in `studio/.env` (e.g. `100` → studio
82
+ `:5280`, frontend `:3100`) to run a second Elytra project alongside this one
83
+ without a clash. `elytra dev` reads it from `studio/.env` and forwards it to
84
+ both child processes; the studio's own `vite.config.ts` also reads it directly
85
+ (so `vite dev` run standalone honors it too). Unset (the default) leaves both
86
+ ports exactly as they are today.
87
+
73
88
  ## Deploying (Vercel)
74
89
 
75
90
  Deploys are git-push-driven — there is no `elytra deploy`. A consuming repo
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.77",
4
+ "version": "0.0.79",
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": ">=20"
18
18
  },
19
19
  "dependencies": {
20
- "@elytracms/core": "^0.0.77"
20
+ "@elytracms/core": "^0.0.79"
21
21
  },
22
22
  "devDependencies": {
23
23
  "vitest": "^4.1.10"
package/src/cli.js CHANGED
@@ -44,12 +44,124 @@ Commands:
44
44
  version Show version
45
45
 
46
46
  Run from anywhere inside an Elytra project (a directory with elytra.config.ts).
47
+ Any subcommand accepts --help/-h for its own usage (never does the command's work).
48
+
49
+ Dev ports: studio 5180, frontend 3000 by default. Set ELYTRA_DEV_PORT_OFFSET in
50
+ studio/.env to shift BOTH together (e.g. to run a second Elytra project alongside
51
+ this one without a clash) — 'elytra dev' forwards it to both processes.
47
52
  `.trimStart()
48
53
 
49
54
  function log(line = '') {
50
55
  stdout.write(`${line}\n`)
51
56
  }
52
57
 
58
+ // --- Uniform inert `--help`/`-h` (EC-464) -------------------------------------
59
+ //
60
+ // Two builders (one external) independently hit `elytra <subcommand> --help`
61
+ // DOING the subcommand's work instead of just describing it — `dev --help`
62
+ // started real dev servers (attempting the shared default ports, 5180/3000),
63
+ // `doctor --help` ran the full network diagnostic, `build --help` rejected as
64
+ // an unknown build target. The fix is one central gate in `run()`, checked
65
+ // BEFORE any subcommand branch does anything: every entry in `SUBCOMMAND_HELP`
66
+ // is inert under `--help`/`-h` by construction — no project inspection, no
67
+ // process spawned, no network call, no write. A future subcommand inherits
68
+ // this for free by adding its usage text here; nothing per-command to
69
+ // remember.
70
+
71
+ /** Per-subcommand usage text — each documents that subcommand's real flags. */
72
+ export const SUBCOMMAND_HELP = {
73
+ dev: `
74
+ Usage: elytra dev
75
+
76
+ Start the studio + frontend (and Convex in connected mode) together as one
77
+ supervised process group. One Ctrl-C stops everything; if any process exits
78
+ on its own, the rest are torn down.
79
+
80
+ No flags. The dev ports (studio 5180, frontend 3000) move together via the
81
+ ELYTRA_DEV_PORT_OFFSET lever — see studio/.env.
82
+ `.trim(),
83
+
84
+ build: `
85
+ Usage: elytra build [target]
86
+
87
+ Build the studio + frontend (and deploy Convex in connected mode), in order,
88
+ failing as a whole if any step fails.
89
+
90
+ Optional target — the per-app Vercel build commands (EC-410):
91
+ elytra build studio Convex deploy coupled with the studio build
92
+ (functions + client stay in lockstep)
93
+ elytra build frontend frontend build only
94
+
95
+ No target builds the whole repo in order: convex deploy (connected mode) →
96
+ studio build → frontend build.
97
+ `.trim(),
98
+
99
+ init: `
100
+ Usage: elytra init
101
+
102
+ First-run setup: provision Convex, generate the auth signing keys, verify
103
+ readiness. Idempotent — safe to re-run at any point. No flags.
104
+ `.trim(),
105
+
106
+ doctor: `
107
+ Usage: elytra doctor [--url <convex url>]
108
+
109
+ Diagnose the active deployment's readiness (URL, reachability, auth signing
110
+ keys, expected deployment env vars — set/missing by NAME, values never shown
111
+ — config, membership) and print each fix. When the instant-publish
112
+ revalidate pair is set, also sends a signed no-op ping to the endpoint to
113
+ prove the publish → live chain end to end. Also reports the
114
+ generated-previews artifacts (manifest/files present; hash staleness lives
115
+ in \`elytra previews --check\`). Exit non-zero if any check fails. Read-only.
116
+
117
+ Options:
118
+ --url <convex url> Diagnose this deployment instead of the one resolved
119
+ from studio/.env.
120
+ `.trim(),
121
+
122
+ previews: `
123
+ Usage: elytra previews [--check] [--port <n>]
124
+
125
+ Render every canvas-eligible component's fixtures against YOUR theme
126
+ (headless Chromium via Playwright — an optional install) and write
127
+ committed preview images (studio/public/elytra-previews/) plus an
128
+ importable manifest (studio/elytra-previews.json) for the insert menu.
129
+ Re-running with unchanged inputs rewrites nothing.
130
+
131
+ Options:
132
+ --check Verify freshness (input hashes) without rendering; exit
133
+ non-zero when previews are missing/stale.
134
+ --port <n> Harness dev-server port (default: first free 5440+).
135
+ `.trim(),
136
+ }
137
+
138
+ /** Does this subcommand's own argv (everything after argv[0]) ask for help? */
139
+ function hasHelpFlag(subArgs) {
140
+ return subArgs.includes('--help') || subArgs.includes('-h')
141
+ }
142
+
143
+ /**
144
+ * Pure dispatch decision (EC-464): does this invocation want top-level help,
145
+ * a subcommand's inert help, the version, or should the subcommand actually
146
+ * run? Centralizes the `--help`/`-h` gate so every subcommand is inert under
147
+ * help BY CONSTRUCTION — table-testable directly, independent of `run()`'s
148
+ * process-spawning/network/fs side effects. The regression this pins is
149
+ * "`--help` did real work".
150
+ */
151
+ export function decideAction(argv) {
152
+ const command = argv[0] ?? 'help'
153
+ if (command === 'help' || command === '-h' || command === '--help') {
154
+ return { action: 'help', subcommand: 'help' }
155
+ }
156
+ if (command === 'version' || command === '--version') {
157
+ return { action: 'version', subcommand: 'version' }
158
+ }
159
+ if (Object.hasOwn(SUBCOMMAND_HELP, command) && hasHelpFlag(argv.slice(1))) {
160
+ return { action: 'help', subcommand: command }
161
+ }
162
+ return { action: 'run', subcommand: command }
163
+ }
164
+
53
165
  function reportIssues(issues) {
54
166
  log('Cannot continue:')
55
167
  for (const issue of issues) log(` • ${issue}`)
@@ -61,11 +173,28 @@ function reportIssues(issues) {
61
173
  * ever added for a provisioned, non-prod deployment: against a `prod:` selector (EC-356)
62
174
  * it is never started, prod-safety-critical, regardless of how the studio/frontend are
63
175
  * configured. `describeMode` explains why to the developer.
176
+ *
177
+ * EC-464 dev-port lever: `project.devPortOffset` (resolved by `inspectProject` from
178
+ * studio/.env's `ELYTRA_DEV_PORT_OFFSET`) is forwarded as an env var to BOTH the
179
+ * studio and frontend child processes — the studio's own `vite.config.ts` reads it
180
+ * directly (so it also applies when running `vite dev` standalone), and the
181
+ * frontend's `dev` script reads it from its process env (which only `elytra dev`
182
+ * can supply, since dotenv files aren't shell-sourced) — one lever, moves both.
64
183
  */
65
184
  export function planDev(project) {
66
185
  const procs = []
67
186
  const studioDir = join(project.root, 'studio')
68
187
  const frontendDir = join(project.root, 'frontend')
188
+ const offset = Number(project.devPortOffset ?? 0) || 0
189
+ // The studio's `vite.config.ts` resolves the offset itself, in JS (`loadEnv`) —
190
+ // portable anywhere, so forward the lever as-is.
191
+ const studioEnv = { ELYTRA_DEV_PORT_OFFSET: String(offset) }
192
+ // The frontend's port is resolved HERE instead, and handed over as `PORT` (which
193
+ // Next reads from the process env natively). That keeps the scaffold's `dev`
194
+ // script a plain `next dev`: computing the port inside the npm script needs POSIX
195
+ // arithmetic (`$(( ))`), which silently breaks `pnpm dev` under cmd.exe on
196
+ // Windows — every other template script is shell-agnostic and this one stays so.
197
+ const frontendEnv = { ELYTRA_DEV_PORT_OFFSET: String(offset), PORT: String(3000 + offset) }
69
198
 
70
199
  if (project.apps.studio && project.mode === 'connected') {
71
200
  if (project.convexProvisioned && !project.isProdDeployment) {
@@ -73,10 +202,10 @@ export function planDev(project) {
73
202
  }
74
203
  }
75
204
  if (project.apps.studio) {
76
- procs.push({ name: 'studio', command: 'pnpm', args: ['run', 'dev'], cwd: studioDir })
205
+ procs.push({ name: 'studio', command: 'pnpm', args: ['run', 'dev'], cwd: studioDir, env: studioEnv })
77
206
  }
78
207
  if (project.apps.frontend) {
79
- procs.push({ name: 'frontend', command: 'pnpm', args: ['run', 'dev'], cwd: frontendDir })
208
+ procs.push({ name: 'frontend', command: 'pnpm', args: ['run', 'dev'], cwd: frontendDir, env: frontendEnv })
80
209
  }
81
210
  return procs
82
211
  }
@@ -308,19 +437,21 @@ export async function runInit(project, { log: emit = log, fetchImpl, exec, provi
308
437
  }
309
438
 
310
439
  export async function run(argv, { cwd = process.cwd(), env = process.env, emit = log, exec, fetchImpl } = {}) {
311
- const command = argv[0] ?? 'help'
312
-
313
- if (command === 'help' || command === '-h' || command === '--help') {
314
- log(HELP)
440
+ // EC-464: the ONE gate — decide before touching fs/network/processes. `help` and
441
+ // every per-subcommand `--help`/`-h` short-circuit here, inertly, by construction.
442
+ const decision = decideAction(argv)
443
+ if (decision.action === 'help') {
444
+ log(decision.subcommand === 'help' ? HELP : SUBCOMMAND_HELP[decision.subcommand])
315
445
  return 0
316
446
  }
317
- if (command === 'version' || command === '--version') {
447
+ if (decision.action === 'version') {
318
448
  const { readFileSync } = await import('node:fs')
319
449
  const { fileURLToPath } = await import('node:url')
320
450
  const pkgPath = fileURLToPath(new URL('../package.json', import.meta.url))
321
451
  log(JSON.parse(readFileSync(pkgPath, 'utf8')).version)
322
452
  return 0
323
453
  }
454
+ const command = decision.subcommand
324
455
 
325
456
  // `doctor` is a read-only diagnostic: it needs the repo root + env + network, but not
326
457
  // installed deps — so it is not blocked by a missing node_modules.
package/src/project.js CHANGED
@@ -92,10 +92,26 @@ export function isProdDeploymentSelector(env = {}) {
92
92
  )
93
93
  }
94
94
 
95
+ /**
96
+ * Parse the EC-464 dev-port-offset lever from the resolved studio env: a single
97
+ * integer that shifts BOTH the studio's and the frontend's dev port together (the
98
+ * studio's own `vite.config.ts` reads it directly; `elytra dev` forwards it to the
99
+ * frontend child too, since a dotenv file isn't shell-sourced for its `next dev`
100
+ * script) — one lever, so a second Elytra project can run alongside this one
101
+ * without a port clash. Unset/blank/non-numeric → 0, the current 5180/3000
102
+ * defaults, byte-for-byte unchanged for every existing scaffold.
103
+ */
104
+ export function resolveDevPortOffset(env = {}) {
105
+ const raw = env.ELYTRA_DEV_PORT_OFFSET
106
+ if (raw === undefined || String(raw).trim() === '') return 0
107
+ const n = Number(raw)
108
+ return Number.isFinite(n) ? Math.trunc(n) : 0
109
+ }
110
+
95
111
  /**
96
112
  * Inspect a consuming repo. Returns:
97
113
  * { root, apps: {studio, frontend}, mode, convexProvisioned, isProdDeployment,
98
- * hasDeployKey, hasAiRoute, issues }
114
+ * hasDeployKey, hasAiRoute, devPortOffset, issues }
99
115
  * `issues` are actionable, human-readable prerequisite problems (empty = ok).
100
116
  */
101
117
  export function inspectProject(cwd, processEnv = {}) {
@@ -109,6 +125,7 @@ export function inspectProject(cwd, processEnv = {}) {
109
125
  isProdDeployment: false,
110
126
  hasDeployKey: false,
111
127
  hasAiRoute: false,
128
+ devPortOffset: 0,
112
129
  issues: [
113
130
  'No elytra.config.ts found in this directory or any parent. Run `elytra` from inside an Elytra project (or scaffold one with `npm create elytra@latest`).',
114
131
  ],
@@ -151,5 +168,8 @@ export function inspectProject(cwd, processEnv = {}) {
151
168
  // `createAiDevPlugin` default path); we never execute config to find out.
152
169
  const hasAiRoute = apps.studio && existsSync(join(root, 'studio', 'src', 'server', 'ai-handler.ts'))
153
170
 
154
- return { root, apps, mode, convexProvisioned, isProdDeployment, hasDeployKey, hasAiRoute, issues }
171
+ // EC-464: the one dev-port lever, read from the same studio env as everything else here.
172
+ const devPortOffset = resolveDevPortOffset(studioEnv)
173
+
174
+ return { root, apps, mode, convexProvisioned, isProdDeployment, hasDeployKey, hasAiRoute, devPortOffset, issues }
155
175
  }
package/src/run.js CHANGED
@@ -34,7 +34,10 @@ function pipePrefixed(stream, label, color) {
34
34
  * Ctrl-C (SIGINT/SIGTERM) tears all of them down; if any child exits on its own,
35
35
  * the rest are killed and the group resolves with that child's exit code.
36
36
  *
37
- * `procs`: [{ name, command, args, cwd }]. Returns the group's exit code.
37
+ * `procs`: [{ name, command, args, cwd, env? }] — an optional `env` is merged
38
+ * OVER `process.env` for that one child only (EC-464: how `planDev` forwards the
39
+ * dev-port-offset lever to the studio/frontend children without changing every
40
+ * other process's environment). Returns the group's exit code.
38
41
  */
39
42
  export function runGroup(procs) {
40
43
  return new Promise((resolve) => {
@@ -69,7 +72,7 @@ export function runGroup(procs) {
69
72
  stdout.write(`${paint(`[${proc.name}]`, color)} ${proc.command} ${proc.args.join(' ')}\n`)
70
73
  const child = spawn(proc.command, proc.args, {
71
74
  cwd: proc.cwd,
72
- env: process.env,
75
+ env: proc.env ? { ...process.env, ...proc.env } : process.env,
73
76
  stdio: ['ignore', 'pipe', 'pipe'],
74
77
  })
75
78
  children.push(child)