@elytracms/cli 0.0.10 → 0.0.11
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 +67 -0
- package/package.json +2 -2
- package/src/cli.js +117 -13
- package/src/doctor.js +121 -8
- package/src/project.js +18 -2
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.
|
|
4
|
+
"version": "0.0.11",
|
|
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.
|
|
20
|
+
"@elytracms/core": "^0.0.63"
|
|
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,
|
|
24
|
-
|
|
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
|
-
/**
|
|
67
|
-
export
|
|
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(
|
|
137
|
+
steps.push(studioBuild)
|
|
77
138
|
}
|
|
78
139
|
if (project.apps.frontend) {
|
|
79
|
-
steps.push(
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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({
|
|
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
|
-
|
|
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(
|
|
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 = {
|
|
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
|
|
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
|
-
|
|
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 `
|
|
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({
|
|
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,
|
|
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
|
-
|
|
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
|
}
|