@elytracms/cli 0.0.10 → 0.0.12

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
@@ -16,6 +16,18 @@ elytra dev Start the studio + frontend (and Convex in connected mode)
16
16
 
17
17
  elytra build Build the studio + frontend (and deploy Convex in connected
18
18
  mode), in order — failing as a whole if any step fails.
19
+ Optional target (the per-app Vercel build commands):
20
+ elytra build studio Convex deploy coupled with the studio
21
+ build (functions + client in lockstep)
22
+ elytra build frontend frontend build only
23
+
24
+ elytra init First-run setup: provision Convex, generate the auth signing
25
+ keys, verify readiness. Idempotent — safe to re-run.
26
+
27
+ elytra doctor Diagnose the active deployment's readiness and print each fix.
28
+ Includes the expected deployment env vars, reported set/missing
29
+ by NAME only — values are never read into the report or printed.
30
+ Read-only; exits non-zero if any check fails.
19
31
 
20
32
  elytra help Show help
21
33
  elytra version Show version
@@ -36,3 +48,58 @@ uses):
36
48
  It orchestrates and owns the process lifecycle; it does not reimplement Vite,
37
49
  Next, or Convex — each step shells out to the underlying tool and inherits its
38
50
  watch/HMR/build behaviour.
51
+
52
+ ## Deploying (Vercel)
53
+
54
+ Deploys are git-push-driven — there is no `elytra deploy`. A consuming repo
55
+ deploys as **two Vercel projects** on the same git repo, plus the Convex
56
+ deployment that the studio build pushes functions to:
57
+
58
+ | Vercel project | Root Directory | Build command |
59
+ | -------------- | -------------- | --------------------------------- |
60
+ | studio | `studio` | `pnpm exec elytra build studio` |
61
+ | frontend | `frontend` | `pnpm exec elytra build frontend` |
62
+
63
+ `elytra build studio` wraps Convex's recommended Vercel integration: it runs
64
+ `convex deploy --cmd 'pnpm run build'`, which pushes your Convex functions and
65
+ then runs the studio build with the deployment URL injected into its env
66
+ (`VITE_CONVEX_URL`). The built studio always matches the functions it was
67
+ deployed with, and there is no hand-set URL to drift out of sync with the
68
+ deploy key's target. `elytra build frontend` runs the Next.js build only — the
69
+ frontend reads its Convex URL at runtime, not at build time.
70
+
71
+ ### Which env lives on which plane
72
+
73
+ Env lives on three planes. Keep them apart — most misconfiguration is a value
74
+ set on the wrong plane:
75
+
76
+ 1. **Local `.env` files** (`studio/.env`, `frontend/.env`) — local development
77
+ only. Gitignored; they never reach Vercel or the Convex deployment.
78
+ 2. **Vercel project env** — build- and runtime-env per Vercel project:
79
+ - _Studio project:_ `CONVEX_DEPLOY_KEY` (a production deploy key generated
80
+ in the Convex dashboard — it selects **and** authorizes the deployment).
81
+ Do not hand-set `VITE_CONVEX_URL`; the build injects it (above). Optional:
82
+ the AI provider key (`ANTHROPIC_API_KEY`, or the Vercel AI Gateway) for
83
+ the studio's AI route — a host/server env, never `VITE_`-prefixed.
84
+ - _Frontend project:_ `ELYTRA_CONVEX_URL`, `ELYTRA_PROJECT_ID`,
85
+ `REVALIDATE_SECRET`, `STUDIO_ORIGIN`, `DRAFT_PREVIEW_TOKEN` — see the
86
+ scaffold's CONNECT.md for what each pairs with.
87
+ 3. **Convex deployment env** — set with `npx convex env set` (from `studio/`);
88
+ lives on the deployment itself, visible in the Convex dashboard but in no
89
+ file: the auth signing keys (`JWT_PRIVATE_KEY`, `JWKS` — set via
90
+ `npx @convex-dev/auth`), `SITE_URL`, and the publish/draft pair
91
+ `CANVAS_PROJECT_SCOPE` / `CANVAS_REVALIDATE_ENDPOINT` /
92
+ `CANVAS_REVALIDATE_SECRET` and `DRAFT_PREVIEW_TOKEN` (each must match its
93
+ frontend-plane counterpart). `elytra doctor` diagnoses this plane's
94
+ readiness.
95
+
96
+ Because plane 3 lives in no file, it is the easiest to overlook — `elytra
97
+ doctor` therefore enumerates the expected vars there (auth keys, `SITE_URL`,
98
+ plus the AI provider key on the studio-host plane when the AI route is
99
+ present) and reports each one **set or missing, by name only**: the doctor
100
+ parses names out of `npx convex env list` and discards every value on the
101
+ spot — values are never stored, printed, or logged. A missing required var
102
+ (the auth keys) fails the run with its exact fix line; a missing recommended
103
+ var (`SITE_URL`, the AI key) is a named warning that doesn't change the exit
104
+ code. The studio's boot-time "Deployment setup" screen lists the same
105
+ expected vars, so the CLI and the studio tell one story.
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.10",
4
+ "version": "0.0.12",
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.62"
20
+ "@elytracms/core": "^0.0.64"
21
21
  },
22
22
  "devDependencies": {
23
23
  "vitest": "^4.1.10"
package/src/cli.js CHANGED
@@ -17,11 +17,16 @@ Commands:
17
17
  One Ctrl-C stops everything.
18
18
  build Build the studio + frontend (and deploy Convex in connected mode),
19
19
  in order, failing as a whole if any step fails.
20
+ Optional target — the per-app Vercel build commands (EC-410):
21
+ elytra build studio Convex deploy coupled with the studio build
22
+ (functions + client stay in lockstep)
23
+ elytra build frontend frontend build only
20
24
  init First-run setup: provision Convex, generate the auth signing keys,
21
25
  verify readiness. Idempotent — safe to re-run at any point.
22
26
  doctor Diagnose the active deployment's readiness (URL, reachability, auth
23
- signing keys, config, membership) and print each fix. Exit non-zero
24
- if any check fails. Read-only. Optional: --url <convex url>.
27
+ signing keys, expected deployment env vars — set/missing by NAME,
28
+ values never shown — config, membership) and print each fix. Exit
29
+ non-zero if any check fails. Read-only. Optional: --url <convex url>.
25
30
  help Show this help
26
31
  version Show version
27
32
 
@@ -63,20 +68,76 @@ export function planDev(project) {
63
68
  return procs
64
69
  }
65
70
 
66
- /** Build the ordered build steps from a project inspection. */
67
- export function planBuild(project) {
71
+ /** The valid `elytra build` targets (EC-410). No target = the whole repo. */
72
+ export const BUILD_TARGETS = ['studio', 'frontend']
73
+
74
+ /**
75
+ * Should a studio build run the Convex deploy coupling? Same conditions as the bare
76
+ * build (connected + provisioned) — including against a `prod:` deployment: EC-356
77
+ * only forbids the continuous dev-sync watcher (`convex dev`) against production; an
78
+ * explicit `convex deploy` IS the correct prod path. Plus the CI/Vercel form of
79
+ * "connected" (EC-410): a CONVEX_DEPLOY_KEY selects and authorizes a deployment
80
+ * without a local link (`CONVEX_DEPLOYMENT`) or a hand-set `VITE_CONVEX_URL` — on
81
+ * Vercel the studio's gitignored `.env` doesn't exist, so the key is the whole
82
+ * signal. An explicit playground mode always wins: it never deploys anything.
83
+ */
84
+ function couplesConvexDeploy(project) {
85
+ if (project.mode === 'playground') return false
86
+ return (project.mode === 'connected' && project.convexProvisioned) || Boolean(project.hasDeployKey)
87
+ }
88
+
89
+ /**
90
+ * Build the ordered build steps from a project inspection, optionally scoped to one
91
+ * target (EC-410). Bare `elytra build` (no target) keeps the whole-repo sequence
92
+ * unchanged: convex deploy (connected + provisioned) → studio build → frontend build.
93
+ *
94
+ * `elytra build studio` is shaped to be the studio Vercel project's build command:
95
+ * when the Convex coupling applies it runs Convex's recommended single-command
96
+ * integration — `convex deploy --cmd 'pnpm run build'` — which pushes the functions
97
+ * and then runs the studio build with the deployment URL injected into its env
98
+ * (`--cmd-url-env-var-name VITE_CONVEX_URL`). The built client therefore always
99
+ * matches the functions it was deployed with; there is no hand-set duplicate URL to
100
+ * drift (the exact hazard EC-410 names).
101
+ *
102
+ * `elytra build frontend` builds the frontend only: its Convex URL
103
+ * (`ELYTRA_CONVEX_URL`) is runtime env, not a build-time coupling.
104
+ */
105
+ export function planBuild(project, target) {
68
106
  const steps = []
69
107
  const studioDir = join(project.root, 'studio')
70
108
  const frontendDir = join(project.root, 'frontend')
109
+ const studioBuild = { name: 'studio build', command: 'pnpm', args: ['run', 'build'], cwd: studioDir }
110
+ const frontendBuild = { name: 'frontend build', command: 'pnpm', args: ['run', 'build'], cwd: frontendDir }
111
+
112
+ if (target === 'frontend') {
113
+ if (project.apps.frontend) steps.push(frontendBuild)
114
+ return steps
115
+ }
116
+
117
+ if (target === 'studio') {
118
+ if (!project.apps.studio) return steps
119
+ if (couplesConvexDeploy(project)) {
120
+ steps.push({
121
+ name: 'convex deploy + studio build',
122
+ command: 'pnpm',
123
+ args: ['exec', 'convex', 'deploy', '--cmd-url-env-var-name', 'VITE_CONVEX_URL', '--cmd', 'pnpm run build'],
124
+ cwd: studioDir,
125
+ })
126
+ } else {
127
+ steps.push(studioBuild)
128
+ }
129
+ return steps
130
+ }
71
131
 
132
+ // Bare `elytra build` — the whole-repo sequence, byte-for-byte as before EC-410.
72
133
  if (project.mode === 'connected' && project.convexProvisioned) {
73
134
  steps.push({ name: 'convex deploy', command: 'pnpm', args: ['exec', 'convex', 'deploy'], cwd: studioDir })
74
135
  }
75
136
  if (project.apps.studio) {
76
- steps.push({ name: 'studio build', command: 'pnpm', args: ['run', 'build'], cwd: studioDir })
137
+ steps.push(studioBuild)
77
138
  }
78
139
  if (project.apps.frontend) {
79
- steps.push({ name: 'frontend build', command: 'pnpm', args: ['run', 'build'], cwd: frontendDir })
140
+ steps.push(frontendBuild)
80
141
  }
81
142
  return steps
82
143
  }
@@ -114,9 +175,28 @@ export function describeMode(project) {
114
175
  return null
115
176
  }
116
177
 
117
- function noteMode(project) {
118
- const note = describeMode(project)
119
- if (note) log(note)
178
+ /**
179
+ * The mode note for `elytra build [target]` (EC-410) — pure, like {@link describeMode}.
180
+ * The frontend target makes no backend decision (its Convex URL is runtime env), so it
181
+ * gets no note. The studio target adds the deploy-key case — a CONVEX_DEPLOY_KEY is how
182
+ * a CI/Vercel build is "connected" without a locally linked deployment, so the generic
183
+ * "not provisioned"/"not configured" notes would be wrong — and otherwise falls back to
184
+ * {@link describeMode}, keeping the bare build's output unchanged.
185
+ */
186
+ export function describeBuildMode(project, target) {
187
+ if (target === 'frontend') return null
188
+ if (
189
+ target === 'studio' &&
190
+ project.mode !== 'playground' &&
191
+ project.hasDeployKey &&
192
+ !(project.mode === 'connected' && project.convexProvisioned)
193
+ ) {
194
+ return (
195
+ 'Mode: connected via CONVEX_DEPLOY_KEY (CI/Vercel). `convex deploy` pushes the Convex\n' +
196
+ 'functions and injects the deployment URL into the studio build (VITE_CONVEX_URL).'
197
+ )
198
+ }
199
+ return describeMode(project)
120
200
  }
121
201
 
122
202
  /** Read a `--name value` or `--name=value` flag from argv. */
@@ -238,7 +318,17 @@ export async function run(argv, { cwd = process.cwd(), env = process.env, emit =
238
318
  return 1
239
319
  }
240
320
  const studioEnv = resolveStudioEnv(project.root, env)
241
- return runDoctor({ env: studioEnv, fetchImpl, log: emit, url: flagValue(argv, '--url') })
321
+ return runDoctor({
322
+ env: studioEnv,
323
+ fetchImpl,
324
+ log: emit,
325
+ url: flagValue(argv, '--url'),
326
+ // EC-399: where `convex env list` runs (name enumeration — presence only),
327
+ // and whether the AI provider-key expectation applies.
328
+ studioDir: project.apps.studio ? join(project.root, 'studio') : undefined,
329
+ aiRouteConfigured: project.hasAiRoute,
330
+ exec,
331
+ })
242
332
  }
243
333
 
244
334
  if (command === 'init') {
@@ -256,12 +346,22 @@ export async function run(argv, { cwd = process.cwd(), env = process.env, emit =
256
346
  return 1
257
347
  }
258
348
 
349
+ // `elytra build [studio|frontend]` (EC-410). Validated before project inspection so
350
+ // a typo'd target fails the same clear way everywhere (CI included).
351
+ const target = command === 'build' ? argv[1] : undefined
352
+ if (target !== undefined && !BUILD_TARGETS.includes(target)) {
353
+ log(`Unknown build target: ${target}`)
354
+ log(`Valid targets: ${BUILD_TARGETS.join(', ')} — or no target to build the whole repo.`)
355
+ return 1
356
+ }
357
+
259
358
  const project = inspectProject(cwd, env)
260
359
  if (project.issues.length > 0) {
261
360
  reportIssues(project.issues)
262
361
  return 1
263
362
  }
264
- noteMode(project)
363
+ const note = command === 'build' ? describeBuildMode(project, target) : describeMode(project)
364
+ if (note) log(note)
265
365
 
266
366
  if (command === 'dev') {
267
367
  // First-run safety net: ensure the auth signing keys exist before starting the
@@ -283,9 +383,13 @@ export async function run(argv, { cwd = process.cwd(), env = process.env, emit =
283
383
  }
284
384
 
285
385
  // build
286
- const steps = planBuild(project)
386
+ const steps = planBuild(project, target)
287
387
  if (steps.length === 0) {
288
- log('Nothing to build — no studio/ or frontend/ app found.')
388
+ log(
389
+ target
390
+ ? `Nothing to build — no ${target}/ app found.`
391
+ : 'Nothing to build — no studio/ or frontend/ app found.',
392
+ )
289
393
  return 1
290
394
  }
291
395
  const code = await runSequential(steps)
package/src/doctor.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import {
2
2
  backendSiteUrl,
3
3
  checkDeploymentReady,
4
+ expectedDeploymentEnv,
4
5
  probeAuthSigningKeys,
5
6
  } from '@elytracms/core/export-sync/deployment-ready'
7
+ import { defaultConvexExec } from './provision.js'
6
8
 
7
9
  /**
8
10
  * `elytra doctor` (EC-259) — the pre-boot counterpart to the studio's boot
@@ -40,6 +42,10 @@ export function resolveDoctorTarget(env = {}, { url } = {}) {
40
42
  url: chosenUrl,
41
43
  siteUrl: env.VITE_CONVEX_SITE_URL || env.CONVEX_SITE_URL,
42
44
  playgroundFlagAlsoSet: playgroundFlag && !explicit,
45
+ // EC-399: an explicit --url that differs from the env-resolved deployment means
46
+ // `convex env list` (which reads the LINKED deployment) would enumerate the
47
+ // wrong deployment's env — the env checks then stay `unknown`, never wrong.
48
+ urlOverridesEnv: Boolean(explicit) && explicit !== envUrl,
43
49
  }
44
50
  }
45
51
  if (playgroundFlag) return { kind: 'playground' }
@@ -86,14 +92,106 @@ export async function gatherFacts(target, { fetchImpl = fetch } = {}) {
86
92
  }
87
93
  }
88
94
 
95
+ // --- Deployment env-var presence (EC-399) --------------------------------------
96
+ //
97
+ // The Convex deployment's own env (`npx convex env set …`) lives in NO file a
98
+ // developer can see — the invisible plane the owner named. The doctor enumerates
99
+ // which of the EXPECTED vars are set. PRESENCE ONLY, by hard rule: names are parsed
100
+ // out and every value is discarded in the same pass; no value is ever stored on a
101
+ // fact, printed, or logged.
102
+
103
+ /**
104
+ * Parse `npx convex env list` output into the NAMES of set vars — values are
105
+ * discarded at the parse boundary and never leave this function. Non-matching
106
+ * lines (headers, notices) are ignored.
107
+ */
108
+ export function parseEnvListNames(stdout) {
109
+ const names = []
110
+ for (const line of String(stdout).split('\n')) {
111
+ const match = /^([A-Za-z_][A-Za-z0-9_]*)=/.exec(line)
112
+ if (match) names.push(match[1])
113
+ }
114
+ return names
115
+ }
116
+
117
+ /** Strip everything after `=` per line — safe error text that can never carry a value. */
118
+ function stripValues(text) {
119
+ return String(text)
120
+ .split('\n')
121
+ .map((line) => line.replace(/=.*$/, '=…'))
122
+ .join(' ')
123
+ .replace(/\s+/g, ' ')
124
+ .trim()
125
+ }
126
+
127
+ /**
128
+ * Enumerate the linked deployment's set env-var NAMES via `npx convex env list`
129
+ * (read-only; the convex CLI resolves the deployment from `studio/.env.local` /
130
+ * `CONVEX_DEPLOY_KEY` itself). Returns `{ names }` on success, `{ detail }` (safe,
131
+ * value-stripped) when the command failed — the checks then surface `unknown`.
132
+ */
133
+ export function gatherDeploymentEnvNames({ cwd, exec = defaultConvexExec } = {}) {
134
+ const { status, stdout, stderr, error } = exec(['env', 'list'], { cwd })
135
+ if (status !== 0) {
136
+ const reason = stripValues(error ? error.message : stderr).slice(0, 200)
137
+ return { detail: `\`npx convex env list\` failed${reason ? ` (${reason})` : ''} — deployment env names not enumerated.` }
138
+ }
139
+ return { names: parseEnvListNames(stdout) }
140
+ }
141
+
142
+ /**
143
+ * Build the expected-env facts for a convex target (EC-399): the engine contract
144
+ * (auth always; AI when the studio ships the AI route), the studio-host presence
145
+ * booleans from the resolved studio env, and — when the target IS the linked
146
+ * deployment — the deployment's set names. Every ungathered source carries an
147
+ * honest reason and surfaces as `unknown`, never a wrong answer.
148
+ */
149
+ export function gatherDeploymentEnvFacts({ env = {}, target, studioDir, aiRouteConfigured = false, exec } = {}) {
150
+ const expected = expectedDeploymentEnv({ aiRouteConfigured })
151
+
152
+ // Studio-host plane: reduce the resolved studio env to presence booleans
153
+ // (the EC-410 `hasDeployKey` house style — values never reach a variable that lives on).
154
+ const hostPresence = {}
155
+ for (const expectation of expected) {
156
+ if (expectation.plane !== 'studio-host') continue
157
+ for (const name of expectation.anyOf ?? [expectation.name]) {
158
+ hostPresence[name] = Boolean(env[name] && String(env[name]).trim() !== '')
159
+ }
160
+ }
161
+ const facts = { expected, hostPresence }
162
+
163
+ // Deployment plane: only for the linked deployment, and only when there is one.
164
+ if (target?.urlOverridesEnv) {
165
+ facts.unavailableDetail =
166
+ '--url overrides the linked deployment; env names are only read from the deployment this repo is linked to.'
167
+ } else if (!studioDir) {
168
+ facts.unavailableDetail = 'No studio/ app found — deployment env names not enumerated.'
169
+ } else if (!env.CONVEX_DEPLOYMENT && !env.CONVEX_DEPLOY_KEY) {
170
+ facts.unavailableDetail =
171
+ 'No linked deployment (CONVEX_DEPLOYMENT / CONVEX_DEPLOY_KEY unset) — `npx convex env list` needs one.'
172
+ } else {
173
+ const { names, detail } = gatherDeploymentEnvNames({ cwd: studioDir, exec })
174
+ if (names) facts.deploymentSetNames = names
175
+ else facts.unavailableDetail = detail
176
+ }
177
+ return facts
178
+ }
179
+
89
180
  // --- Formatting (pure) -------------------------------------------------------
90
181
 
91
- const GLYPH = { ok: '✓', fail: '✗', unknown: '?' }
92
- const BUCKET_TITLE = { deployment: 'Deployment', app: 'App', user: 'Per-user' }
182
+ const GLYPH = { ok: '✓', warn: '!', fail: '✗', unknown: '?' }
183
+ const BUCKET_TITLE = {
184
+ deployment: 'Deployment',
185
+ 'deployment-env': 'Deployment env',
186
+ app: 'App',
187
+ user: 'Per-user',
188
+ }
93
189
 
94
190
  /**
95
191
  * Render a `DeploymentReadinessReport` as the bucketed doctor report (pure — no I/O,
96
- * no color, so it's directly testable). Failing checks print their copy-pasteable fix.
192
+ * no color, so it's directly testable). Failing AND warning checks print their
193
+ * copy-pasteable fix (EC-399: a `warn` is a named, remediated finding that never
194
+ * fails the exit code).
97
195
  */
98
196
  export function formatReport(report) {
99
197
  const lines = []
@@ -105,14 +203,15 @@ export function formatReport(report) {
105
203
  lines.push(`${BUCKET_TITLE[currentBucket] ?? currentBucket}:`)
106
204
  }
107
205
  lines.push(` ${GLYPH[check.status] ?? '?'} ${check.id.padEnd(22)} ${check.detail}`)
108
- if (check.status === 'fail' && check.fix) lines.push(` fix: ${check.fix}`)
206
+ if ((check.status === 'fail' || check.status === 'warn') && check.fix) lines.push(` fix: ${check.fix}`)
109
207
  }
110
208
 
111
- const counts = { ok: 0, fail: 0, unknown: 0 }
209
+ const counts = { ok: 0, warn: 0, fail: 0, unknown: 0 }
112
210
  for (const c of report.checks) counts[c.status] = (counts[c.status] ?? 0) + 1
113
211
  lines.push('')
114
212
  if (report.ok) {
115
- lines.push(`✓ No blocking problems (${counts.ok} ok, ${counts.unknown} not checked here).`)
213
+ const warnPart = counts.warn > 0 ? `, ${counts.warn} warning(s)` : ''
214
+ lines.push(`✓ No blocking problems (${counts.ok} ok${warnPart}, ${counts.unknown} not checked here).`)
116
215
  } else {
117
216
  lines.push(`✗ ${counts.fail} check(s) failed — fix the items above, then re-run \`elytra doctor\`.`)
118
217
  }
@@ -124,9 +223,20 @@ export function formatReport(report) {
124
223
  /**
125
224
  * Run the doctor against a project's studio env. Returns the process exit code
126
225
  * (0 when the report has no failing check, 1 otherwise). `env`, `fetchImpl`, `log`,
127
- * and `url` are injectable for tests.
226
+ * `url`, and `exec` are injectable for tests. `studioDir` (where `convex env list`
227
+ * runs) and `aiRouteConfigured` enable the deployment-env bucket (EC-399); without a
228
+ * studio dir the env checks stay honest `unknown`s. Playground stays untouched — no
229
+ * deployment, no env bucket.
128
230
  */
129
- export async function runDoctor({ env = {}, fetchImpl = fetch, log = (l) => console.log(l), url } = {}) {
231
+ export async function runDoctor({
232
+ env = {},
233
+ fetchImpl = fetch,
234
+ log = (l) => console.log(l),
235
+ url,
236
+ studioDir,
237
+ aiRouteConfigured = false,
238
+ exec,
239
+ } = {}) {
130
240
  const target = resolveDoctorTarget(env, { url })
131
241
 
132
242
  log('Elytra deployment doctor')
@@ -142,6 +252,9 @@ export async function runDoctor({ env = {}, fetchImpl = fetch, log = (l) => cons
142
252
  }
143
253
 
144
254
  const facts = await gatherFacts(target, { fetchImpl })
255
+ if (target.kind === 'convex') {
256
+ facts.deploymentEnv = gatherDeploymentEnvFacts({ env, target, studioDir, aiRouteConfigured, exec })
257
+ }
145
258
  const report = checkDeploymentReady(facts)
146
259
  log(formatReport(report))
147
260
  return report.ok ? 0 : 1
package/src/project.js CHANGED
@@ -94,7 +94,8 @@ export function isProdDeploymentSelector(env = {}) {
94
94
 
95
95
  /**
96
96
  * Inspect a consuming repo. Returns:
97
- * { root, apps: {studio, frontend}, mode, convexProvisioned, isProdDeployment, issues }
97
+ * { root, apps: {studio, frontend}, mode, convexProvisioned, isProdDeployment,
98
+ * hasDeployKey, hasAiRoute, issues }
98
99
  * `issues` are actionable, human-readable prerequisite problems (empty = ok).
99
100
  */
100
101
  export function inspectProject(cwd, processEnv = {}) {
@@ -106,6 +107,8 @@ export function inspectProject(cwd, processEnv = {}) {
106
107
  mode: 'unknown',
107
108
  convexProvisioned: false,
108
109
  isProdDeployment: false,
110
+ hasDeployKey: false,
111
+ hasAiRoute: false,
109
112
  issues: [
110
113
  '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`).',
111
114
  ],
@@ -135,5 +138,18 @@ export function inspectProject(cwd, processEnv = {}) {
135
138
  const convexProvisioned = Boolean(studioEnv.CONVEX_DEPLOYMENT)
136
139
  const isProdDeployment = isProdDeploymentSelector(studioEnv)
137
140
 
138
- return { root, apps, mode, convexProvisioned, isProdDeployment, issues }
141
+ // A CI-style deploy key (CONVEX_DEPLOY_KEY) selects AND authorizes a deployment
142
+ // without a local link — it is how a Vercel/CI build is "connected" (EC-410; the
143
+ // studio's `.env` is gitignored, so the key is often the ONLY backend signal
144
+ // there). Surfaced as its own fact: it never flips `mode`/`convexProvisioned`,
145
+ // so `elytra dev` and the bare `elytra build` are unchanged by its presence.
146
+ const hasDeployKey = Boolean(studioEnv.CONVEX_DEPLOY_KEY)
147
+
148
+ // Is the studio's AI route present? (EC-399 — drives the AI provider-key env
149
+ // expectation in `elytra doctor`.) Convention-aware like everything here: the
150
+ // scaffold mounts the handler at `studio/src/server/ai-handler.ts` (the
151
+ // `createAiDevPlugin` default path); we never execute config to find out.
152
+ const hasAiRoute = apps.studio && existsSync(join(root, 'studio', 'src', 'server', 'ai-handler.ts'))
153
+
154
+ return { root, apps, mode, convexProvisioned, isProdDeployment, hasDeployKey, hasAiRoute, issues }
139
155
  }