@elytracms/cli 0.0.1 → 0.0.2
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 +5 -2
- package/src/cli.js +173 -12
- package/src/doctor.js +148 -0
- package/src/project.js +43 -2
- package/src/provision.js +198 -0
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.2",
|
|
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",
|
|
@@ -16,8 +16,11 @@
|
|
|
16
16
|
"engines": {
|
|
17
17
|
"node": ">=20"
|
|
18
18
|
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@elytracms/core": "^0.0.54"
|
|
21
|
+
},
|
|
19
22
|
"devDependencies": {
|
|
20
|
-
"vitest": "^
|
|
23
|
+
"vitest": "^4.1.10"
|
|
21
24
|
},
|
|
22
25
|
"scripts": {
|
|
23
26
|
"start": "node ./index.js",
|
package/src/cli.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
1
2
|
import { join } from 'node:path'
|
|
2
3
|
import { stdout } from 'node:process'
|
|
3
|
-
import {
|
|
4
|
+
import { runDoctor } from './doctor.js'
|
|
5
|
+
import { inspectProject, resolveStudioEnv } from './project.js'
|
|
6
|
+
import { ensureAuthKeys } from './provision.js'
|
|
4
7
|
import { runGroup, runSequential } from './run.js'
|
|
5
8
|
|
|
6
9
|
const HELP = `
|
|
@@ -14,6 +17,11 @@ Commands:
|
|
|
14
17
|
One Ctrl-C stops everything.
|
|
15
18
|
build Build the studio + frontend (and deploy Convex in connected mode),
|
|
16
19
|
in order, failing as a whole if any step fails.
|
|
20
|
+
init First-run setup: provision Convex, generate the auth signing keys,
|
|
21
|
+
verify readiness. Idempotent — safe to re-run at any point.
|
|
22
|
+
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>.
|
|
17
25
|
help Show this help
|
|
18
26
|
version Show version
|
|
19
27
|
|
|
@@ -29,14 +37,20 @@ function reportIssues(issues) {
|
|
|
29
37
|
for (const issue of issues) log(` • ${issue}`)
|
|
30
38
|
}
|
|
31
39
|
|
|
32
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* Build the dev process group from a project inspection. `convex dev` (the continuous
|
|
42
|
+
* dev-sync watcher — it pushes local function code to the target on every save) is only
|
|
43
|
+
* ever added for a provisioned, non-prod deployment: against a `prod:` selector (EC-356)
|
|
44
|
+
* it is never started, prod-safety-critical, regardless of how the studio/frontend are
|
|
45
|
+
* configured. `describeMode` explains why to the developer.
|
|
46
|
+
*/
|
|
33
47
|
export function planDev(project) {
|
|
34
48
|
const procs = []
|
|
35
49
|
const studioDir = join(project.root, 'studio')
|
|
36
50
|
const frontendDir = join(project.root, 'frontend')
|
|
37
51
|
|
|
38
52
|
if (project.apps.studio && project.mode === 'connected') {
|
|
39
|
-
if (project.convexProvisioned) {
|
|
53
|
+
if (project.convexProvisioned && !project.isProdDeployment) {
|
|
40
54
|
procs.push({ name: 'convex', command: 'pnpm', args: ['run', 'convex:dev'], cwd: studioDir })
|
|
41
55
|
}
|
|
42
56
|
}
|
|
@@ -67,23 +81,140 @@ export function planBuild(project) {
|
|
|
67
81
|
return steps
|
|
68
82
|
}
|
|
69
83
|
|
|
70
|
-
|
|
84
|
+
/**
|
|
85
|
+
* The mode note to print before `dev`/`build`, or `null` when the mode needs no comment
|
|
86
|
+
* (a provisioned, non-prod connected deployment — the ordinary happy path). Pure — a
|
|
87
|
+
* direct sibling of {@link formatReport} in `doctor.js` — so the prod-safety message
|
|
88
|
+
* (EC-356) is unit-testable without going through `run()`'s process-spawning commands.
|
|
89
|
+
*/
|
|
90
|
+
export function describeMode(project) {
|
|
71
91
|
if (project.mode === 'playground') {
|
|
72
|
-
|
|
73
|
-
}
|
|
74
|
-
|
|
92
|
+
return 'Mode: playground (in-memory, no backend). Convex is not started.'
|
|
93
|
+
}
|
|
94
|
+
if (project.mode === 'connected' && project.isProdDeployment) {
|
|
95
|
+
return (
|
|
96
|
+
'Mode: connected to a `prod:` Convex deployment. `convex dev` (the continuous dev-sync\n' +
|
|
97
|
+
'watcher, which pushes local function code on every save) is never started against\n' +
|
|
98
|
+
'production. Studio and frontend still start and talk to it over the client SDK. To push\n' +
|
|
99
|
+
'function changes, deploy explicitly: `cd studio && npx convex deploy`.'
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
if (project.mode === 'connected' && !project.convexProvisioned) {
|
|
103
|
+
return (
|
|
75
104
|
'Mode: connected, but no Convex deployment is provisioned yet. Skipping the\n' +
|
|
76
|
-
|
|
105
|
+
'backend — run `cd studio && npx convex dev` once to provision it. See CONNECT.md.'
|
|
77
106
|
)
|
|
78
|
-
}
|
|
79
|
-
|
|
107
|
+
}
|
|
108
|
+
if (project.mode === 'unconfigured') {
|
|
109
|
+
return (
|
|
80
110
|
'Backend not configured. Defaulting to the frontends only. Set VITE_ELYTRA_MODE=\n' +
|
|
81
|
-
|
|
111
|
+
'playground or VITE_CONVEX_URL in studio/.env. See CONNECT.md.'
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
return null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function noteMode(project) {
|
|
118
|
+
const note = describeMode(project)
|
|
119
|
+
if (note) log(note)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Read a `--name value` or `--name=value` flag from argv. */
|
|
123
|
+
function flagValue(argv, name) {
|
|
124
|
+
const i = argv.indexOf(name)
|
|
125
|
+
if (i !== -1 && i + 1 < argv.length) return argv[i + 1]
|
|
126
|
+
const eq = argv.find((a) => a.startsWith(`${name}=`))
|
|
127
|
+
return eq ? eq.slice(name.length + 1) : undefined
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Provision a Convex deployment once (foreground; convex drives its own account UX). */
|
|
131
|
+
function provisionConvexOnce(studioDir) {
|
|
132
|
+
const res = spawnSync('npx', ['convex', 'dev', '--once'], { cwd: studioDir, stdio: 'inherit' })
|
|
133
|
+
return res.status ?? 1
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* `elytra init` — ordered, idempotent first-run setup: provision → ensure auth keys →
|
|
138
|
+
* verify → checklist. Re-runnable safely: every already-satisfied step is a no-op. The
|
|
139
|
+
* decisions mirror {@link planSetup}; `provision`/`exec`/`fetchImpl` are injectable.
|
|
140
|
+
*/
|
|
141
|
+
export async function runInit(project, { log: emit = log, fetchImpl, exec, provision = provisionConvexOnce } = {}) {
|
|
142
|
+
const studioDir = join(project.root, 'studio')
|
|
143
|
+
emit('Elytra first-run setup')
|
|
144
|
+
emit('')
|
|
145
|
+
|
|
146
|
+
// 1. Provision a backend deployment.
|
|
147
|
+
if (project.mode === 'unconfigured') {
|
|
148
|
+
emit('1. Provision — cannot continue: no backend configured.')
|
|
149
|
+
emit(' Set VITE_CONVEX_URL, or VITE_ELYTRA_MODE=playground, in studio/.env. See CONNECT.md.')
|
|
150
|
+
return 1
|
|
151
|
+
}
|
|
152
|
+
if (project.mode === 'playground') {
|
|
153
|
+
emit('1. Provision — skipped (playground mode; in-memory, no backend deployment).')
|
|
154
|
+
} else if (project.convexProvisioned) {
|
|
155
|
+
// EC-356: a `prod:` deployment is still "already provisioned" (skip is correct), but
|
|
156
|
+
// say so explicitly — `convex dev` is never the right next step against production.
|
|
157
|
+
emit(
|
|
158
|
+
project.isProdDeployment
|
|
159
|
+
? '1. Provision — a prod: Convex deployment is already provisioned. `convex dev` is never ' +
|
|
160
|
+
'run against it — deploy function changes explicitly with `npx convex deploy`.'
|
|
161
|
+
: '1. Provision — a Convex deployment is already provisioned.',
|
|
82
162
|
)
|
|
163
|
+
} else if (project.isProdDeployment) {
|
|
164
|
+
// EC-356: no deployment is linked locally (CONVEX_DEPLOYMENT unset), but a prod:
|
|
165
|
+
// selector is present some other way (e.g. CONVEX_DEPLOY_KEY) — provisioning via the
|
|
166
|
+
// dev-sync watcher (`convex dev --once`) is never safe to run against it automatically.
|
|
167
|
+
emit('1. Provision — cannot continue safely: a prod: selector is set, but no deployment is linked here.')
|
|
168
|
+
emit(' Provisioning via `convex dev` is skipped against production. Link and deploy explicitly: `cd studio && npx convex deploy`.')
|
|
169
|
+
return 1
|
|
170
|
+
} else {
|
|
171
|
+
emit('1. Provision — provisioning a Convex deployment (convex dev --once)…')
|
|
172
|
+
const code = provision(studioDir)
|
|
173
|
+
const reinspected = inspectProject(project.root)
|
|
174
|
+
if (code !== 0 || !reinspected.convexProvisioned) {
|
|
175
|
+
emit(' Provisioning did not complete. Run `cd studio && npx convex dev` once, then re-run `elytra init`.')
|
|
176
|
+
return 1
|
|
177
|
+
}
|
|
178
|
+
project = reinspected
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 2. Ensure the Convex Auth signing keys (idempotent; never overwrites).
|
|
182
|
+
if (project.mode !== 'playground' && project.convexProvisioned) {
|
|
183
|
+
emit('')
|
|
184
|
+
emit('2. Auth signing keys —')
|
|
185
|
+
try {
|
|
186
|
+
const result = ensureAuthKeys({ cwd: studioDir, exec, log: (l) => emit(` ${l}`) })
|
|
187
|
+
if (result.action === 'partial') return 1
|
|
188
|
+
} catch (error) {
|
|
189
|
+
emit(` Could not set the keys: ${error.message}`)
|
|
190
|
+
return 1
|
|
191
|
+
}
|
|
192
|
+
} else {
|
|
193
|
+
emit('')
|
|
194
|
+
emit('2. Auth signing keys — skipped (no provisioned Convex deployment).')
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// 3. Verify readiness (the shared elytra doctor check).
|
|
198
|
+
emit('')
|
|
199
|
+
emit('3. Verify —')
|
|
200
|
+
const env = resolveStudioEnv(project.root)
|
|
201
|
+
const code = await runDoctor({ env, fetchImpl, log: (l) => emit(` ${l}`) })
|
|
202
|
+
|
|
203
|
+
// 4. Ready checklist.
|
|
204
|
+
emit('')
|
|
205
|
+
if (project.mode === 'playground') {
|
|
206
|
+
emit('✅ ready — run `elytra dev`, open the studio, and sign in with the seeded dev users.')
|
|
207
|
+
return 0
|
|
83
208
|
}
|
|
209
|
+
if (code === 0) {
|
|
210
|
+
emit('✅ ready — run `elytra dev`, open the studio and sign in; first sign-in is admin.')
|
|
211
|
+
} else {
|
|
212
|
+
emit('Setup incomplete — resolve the failing checks above, then re-run `elytra init`.')
|
|
213
|
+
}
|
|
214
|
+
return code
|
|
84
215
|
}
|
|
85
216
|
|
|
86
|
-
export async function run(argv, { cwd = process.cwd(), env = process.env } = {}) {
|
|
217
|
+
export async function run(argv, { cwd = process.cwd(), env = process.env, emit = log, exec, fetchImpl } = {}) {
|
|
87
218
|
const command = argv[0] ?? 'help'
|
|
88
219
|
|
|
89
220
|
if (command === 'help' || command === '-h' || command === '--help') {
|
|
@@ -98,6 +229,27 @@ export async function run(argv, { cwd = process.cwd(), env = process.env } = {})
|
|
|
98
229
|
return 0
|
|
99
230
|
}
|
|
100
231
|
|
|
232
|
+
// `doctor` is a read-only diagnostic: it needs the repo root + env + network, but not
|
|
233
|
+
// installed deps — so it is not blocked by a missing node_modules.
|
|
234
|
+
if (command === 'doctor') {
|
|
235
|
+
const project = inspectProject(cwd, env)
|
|
236
|
+
if (!project.root) {
|
|
237
|
+
reportIssues(project.issues)
|
|
238
|
+
return 1
|
|
239
|
+
}
|
|
240
|
+
const studioEnv = resolveStudioEnv(project.root, env)
|
|
241
|
+
return runDoctor({ env: studioEnv, fetchImpl, log: emit, url: flagValue(argv, '--url') })
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (command === 'init') {
|
|
245
|
+
const project = inspectProject(cwd, env)
|
|
246
|
+
if (project.issues.length > 0) {
|
|
247
|
+
reportIssues(project.issues)
|
|
248
|
+
return 1
|
|
249
|
+
}
|
|
250
|
+
return runInit(project, { log: emit, fetchImpl, exec })
|
|
251
|
+
}
|
|
252
|
+
|
|
101
253
|
if (command !== 'dev' && command !== 'build') {
|
|
102
254
|
log(`Unknown command: ${command}\n`)
|
|
103
255
|
log(HELP)
|
|
@@ -112,6 +264,15 @@ export async function run(argv, { cwd = process.cwd(), env = process.env } = {})
|
|
|
112
264
|
noteMode(project)
|
|
113
265
|
|
|
114
266
|
if (command === 'dev') {
|
|
267
|
+
// First-run safety net: ensure the auth signing keys exist before starting the
|
|
268
|
+
// backend (idempotent — a no-op when they're already set). Never blocks dev.
|
|
269
|
+
if (project.mode === 'connected' && project.convexProvisioned) {
|
|
270
|
+
try {
|
|
271
|
+
ensureAuthKeys({ cwd: join(project.root, 'studio'), exec, log: (l) => log(`[setup] ${l}`) })
|
|
272
|
+
} catch (error) {
|
|
273
|
+
log(`[setup] auth-keys preflight skipped: ${error.message}`)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
115
276
|
const procs = planDev(project)
|
|
116
277
|
if (procs.length === 0) {
|
|
117
278
|
log('Nothing to run — no studio/ or frontend/ app found.')
|
package/src/doctor.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import {
|
|
2
|
+
backendSiteUrl,
|
|
3
|
+
checkDeploymentReady,
|
|
4
|
+
probeAuthSigningKeys,
|
|
5
|
+
} from '@elytracms/core/export-sync/deployment-ready'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `elytra doctor` (EC-259) — the pre-boot counterpart to the studio's boot
|
|
9
|
+
* `DeploymentSetupGate`. It runs the SAME shared `checkDeploymentReady()` contract
|
|
10
|
+
* (EC-258) against the active deployment from **node**, so a developer can diagnose a
|
|
11
|
+
* half-provisioned env without opening the studio — and, crucially, gets a *definitive*
|
|
12
|
+
* `auth-signing-keys` answer the browser can't produce (the JWKS route sets no CORS, so
|
|
13
|
+
* the studio reports it `unknown`; node has no such limit).
|
|
14
|
+
*
|
|
15
|
+
* Read-only: it only fetches the deployment's public URL and its public JWKS endpoint.
|
|
16
|
+
* It never writes env, never provisions. Facts it can't gather from a bundler-less CLI
|
|
17
|
+
* (config validity, resolved collections, the auth session) are omitted → `unknown`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// --- Target resolution -------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Decide which deployment the doctor diagnoses from the resolved studio env.
|
|
24
|
+
*
|
|
25
|
+
* A configured `VITE_CONVEX_URL` is preferred over the `VITE_ELYTRA_MODE=playground`
|
|
26
|
+
* run-mode flag: that flag only selects the studio's in-memory *adapter*, whereas the
|
|
27
|
+
* doctor's job is to validate the real deployment you configured. So a repo that runs
|
|
28
|
+
* playground locally but points at a Convex deployment (e.g. the demo) is diagnosed
|
|
29
|
+
* against that deployment — with a note that the studio itself runs in-memory.
|
|
30
|
+
*/
|
|
31
|
+
export function resolveDoctorTarget(env = {}, { url } = {}) {
|
|
32
|
+
const explicit = url && url.trim() !== '' ? url.trim() : undefined
|
|
33
|
+
const envUrl = env.VITE_CONVEX_URL || env.CONVEX_URL
|
|
34
|
+
const chosenUrl = explicit || envUrl
|
|
35
|
+
const playgroundFlag = env.VITE_ELYTRA_MODE === 'playground'
|
|
36
|
+
|
|
37
|
+
if (chosenUrl) {
|
|
38
|
+
return {
|
|
39
|
+
kind: 'convex',
|
|
40
|
+
url: chosenUrl,
|
|
41
|
+
siteUrl: env.VITE_CONVEX_SITE_URL || env.CONVEX_SITE_URL,
|
|
42
|
+
playgroundFlagAlsoSet: playgroundFlag && !explicit,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (playgroundFlag) return { kind: 'playground' }
|
|
46
|
+
return {
|
|
47
|
+
kind: 'unconfigured',
|
|
48
|
+
detail: 'No VITE_CONVEX_URL is set, and VITE_ELYTRA_MODE is not "playground", in studio/.env.',
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// --- Probes (node) -----------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Reachability probe: any HTTP response means the deployment answered; a thrown fetch
|
|
56
|
+
* (DNS/connection) means it did not. (The studio uses `adapter.status()`; the CLI has
|
|
57
|
+
* no convex client, so a plain GET to the deployment's client URL is the node analogue.)
|
|
58
|
+
*/
|
|
59
|
+
export async function probeReachable(url, fetchImpl = fetch) {
|
|
60
|
+
try {
|
|
61
|
+
const res = await fetchImpl(url, { method: 'GET' })
|
|
62
|
+
return { available: true, detail: `HTTP ${res.status}` }
|
|
63
|
+
} catch (error) {
|
|
64
|
+
return { available: false, detail: error instanceof Error ? error.message : String(error) }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Gather the readiness facts a node CLI can definitively produce for a target. The
|
|
70
|
+
* app/user facts are intentionally omitted (the CLI doesn't execute `elytra.config.ts`
|
|
71
|
+
* and has no auth session) → the contract renders them `unknown`.
|
|
72
|
+
*/
|
|
73
|
+
export async function gatherFacts(target, { fetchImpl = fetch } = {}) {
|
|
74
|
+
if (target.kind === 'playground') return { backend: { kind: 'playground' } }
|
|
75
|
+
if (target.kind === 'unconfigured') return { backend: { kind: 'unconfigured', detail: target.detail } }
|
|
76
|
+
|
|
77
|
+
const reachable = await probeReachable(target.url, fetchImpl)
|
|
78
|
+
const siteUrl = backendSiteUrl(target.url, target.siteUrl)
|
|
79
|
+
const authKeys = await probeAuthSigningKeys(siteUrl, fetchImpl)
|
|
80
|
+
return {
|
|
81
|
+
backend: { kind: 'convex', url: target.url },
|
|
82
|
+
reachable,
|
|
83
|
+
authKeys,
|
|
84
|
+
// A configured URL means a deployment is wired; leave `provisioned` unset (→ ok).
|
|
85
|
+
// config/collections/session omitted → the app + user checks surface as `unknown`.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// --- Formatting (pure) -------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
const GLYPH = { ok: '✓', fail: '✗', unknown: '?' }
|
|
92
|
+
const BUCKET_TITLE = { deployment: 'Deployment', app: 'App', user: 'Per-user' }
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 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.
|
|
97
|
+
*/
|
|
98
|
+
export function formatReport(report) {
|
|
99
|
+
const lines = []
|
|
100
|
+
let currentBucket = null
|
|
101
|
+
for (const check of report.checks) {
|
|
102
|
+
if (check.bucket !== currentBucket) {
|
|
103
|
+
currentBucket = check.bucket
|
|
104
|
+
lines.push('')
|
|
105
|
+
lines.push(`${BUCKET_TITLE[currentBucket] ?? currentBucket}:`)
|
|
106
|
+
}
|
|
107
|
+
lines.push(` ${GLYPH[check.status] ?? '?'} ${check.id.padEnd(22)} ${check.detail}`)
|
|
108
|
+
if (check.status === 'fail' && check.fix) lines.push(` fix: ${check.fix}`)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const counts = { ok: 0, fail: 0, unknown: 0 }
|
|
112
|
+
for (const c of report.checks) counts[c.status] = (counts[c.status] ?? 0) + 1
|
|
113
|
+
lines.push('')
|
|
114
|
+
if (report.ok) {
|
|
115
|
+
lines.push(`✓ No blocking problems (${counts.ok} ok, ${counts.unknown} not checked here).`)
|
|
116
|
+
} else {
|
|
117
|
+
lines.push(`✗ ${counts.fail} check(s) failed — fix the items above, then re-run \`elytra doctor\`.`)
|
|
118
|
+
}
|
|
119
|
+
return lines.join('\n')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// --- Command -----------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Run the doctor against a project's studio env. Returns the process exit code
|
|
126
|
+
* (0 when the report has no failing check, 1 otherwise). `env`, `fetchImpl`, `log`,
|
|
127
|
+
* and `url` are injectable for tests.
|
|
128
|
+
*/
|
|
129
|
+
export async function runDoctor({ env = {}, fetchImpl = fetch, log = (l) => console.log(l), url } = {}) {
|
|
130
|
+
const target = resolveDoctorTarget(env, { url })
|
|
131
|
+
|
|
132
|
+
log('Elytra deployment doctor')
|
|
133
|
+
if (target.kind === 'convex') {
|
|
134
|
+
log(`Checking Convex deployment: ${target.url}`)
|
|
135
|
+
if (target.playgroundFlagAlsoSet) {
|
|
136
|
+
log('Note: VITE_ELYTRA_MODE=playground is set — the studio runs in-memory; this report checks the configured Convex deployment.')
|
|
137
|
+
}
|
|
138
|
+
} else if (target.kind === 'playground') {
|
|
139
|
+
log('Playground mode (VITE_ELYTRA_MODE=playground) — in-memory, seeded, no backend deployment to check.')
|
|
140
|
+
} else {
|
|
141
|
+
log('No backend configured.')
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const facts = await gatherFacts(target, { fetchImpl })
|
|
145
|
+
const report = checkDeploymentReady(facts)
|
|
146
|
+
log(formatReport(report))
|
|
147
|
+
return report.ok ? 0 : 1
|
|
148
|
+
}
|
package/src/project.js
CHANGED
|
@@ -49,13 +49,52 @@ function resolveEnv(appDir, processEnv) {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Resolve the studio app's effective env (`studio/.env` ← `.env.local` ← process.env) —
|
|
54
|
+
* the same signal the studio itself reads. Used by `elytra doctor` to find the Convex
|
|
55
|
+
* deployment to diagnose. Exported so the doctor and project inspection share one reader.
|
|
56
|
+
*/
|
|
57
|
+
export function resolveStudioEnv(root, processEnv = {}) {
|
|
58
|
+
return resolveEnv(join(root, 'studio'), processEnv)
|
|
59
|
+
}
|
|
60
|
+
|
|
52
61
|
function isApp(root, name) {
|
|
53
62
|
return existsSync(join(root, name, 'package.json'))
|
|
54
63
|
}
|
|
55
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Parse a Convex deployment selector's `<type>:` prefix (`dev:adjective-animal-123`,
|
|
67
|
+
* `prod:…`, `preview:…`) — the shape both `CONVEX_DEPLOYMENT` (written by `npx convex dev`)
|
|
68
|
+
* and `CONVEX_DEPLOY_KEY` (a CI-style deploy key, same prefix convention) use. Pure.
|
|
69
|
+
* Returns `null` for unset/empty values or a value with no recognized `<type>:` prefix (an
|
|
70
|
+
* older/bare deployment name) — never guessed as prod. Lower-cased so a hand-edited env
|
|
71
|
+
* value (`PROD:…`) still classifies correctly.
|
|
72
|
+
*/
|
|
73
|
+
export function deploymentSelectorType(value) {
|
|
74
|
+
if (!value) return null
|
|
75
|
+
const i = value.indexOf(':')
|
|
76
|
+
if (i <= 0) return null
|
|
77
|
+
return value.slice(0, i).toLowerCase()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Detect a `prod:` Convex target from the resolved studio env (EC-356). Checked against
|
|
82
|
+
* BOTH selectors the Convex CLI reads: `CONVEX_DEPLOYMENT` (a deployment linked locally by
|
|
83
|
+
* `npx convex dev`) and `CONVEX_DEPLOY_KEY` (a CI-style deploy key that targets a deployment
|
|
84
|
+
* WITHOUT ever linking one — so a repo can be pointed at prod through this var alone, with
|
|
85
|
+
* `CONVEX_DEPLOYMENT` unset). Used to keep `elytra dev`'s continuous dev-sync watcher, and
|
|
86
|
+
* `elytra init`'s provisioning step, from ever running against production.
|
|
87
|
+
*/
|
|
88
|
+
export function isProdDeploymentSelector(env = {}) {
|
|
89
|
+
return (
|
|
90
|
+
deploymentSelectorType(env.CONVEX_DEPLOYMENT) === 'prod' ||
|
|
91
|
+
deploymentSelectorType(env.CONVEX_DEPLOY_KEY) === 'prod'
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
56
95
|
/**
|
|
57
96
|
* Inspect a consuming repo. Returns:
|
|
58
|
-
* { root, apps: {studio, frontend}, mode, convexProvisioned, issues }
|
|
97
|
+
* { root, apps: {studio, frontend}, mode, convexProvisioned, isProdDeployment, issues }
|
|
59
98
|
* `issues` are actionable, human-readable prerequisite problems (empty = ok).
|
|
60
99
|
*/
|
|
61
100
|
export function inspectProject(cwd, processEnv = {}) {
|
|
@@ -66,6 +105,7 @@ export function inspectProject(cwd, processEnv = {}) {
|
|
|
66
105
|
apps: { studio: false, frontend: false },
|
|
67
106
|
mode: 'unknown',
|
|
68
107
|
convexProvisioned: false,
|
|
108
|
+
isProdDeployment: false,
|
|
69
109
|
issues: [
|
|
70
110
|
'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`).',
|
|
71
111
|
],
|
|
@@ -93,6 +133,7 @@ export function inspectProject(cwd, processEnv = {}) {
|
|
|
93
133
|
// A Convex dev/deploy step only makes sense once a deployment is provisioned
|
|
94
134
|
// (`npx convex dev` writes CONVEX_DEPLOYMENT). Otherwise we skip it and say so.
|
|
95
135
|
const convexProvisioned = Boolean(studioEnv.CONVEX_DEPLOYMENT)
|
|
136
|
+
const isProdDeployment = isProdDeploymentSelector(studioEnv)
|
|
96
137
|
|
|
97
|
-
return { root, apps, mode, convexProvisioned, issues }
|
|
138
|
+
return { root, apps, mode, convexProvisioned, isProdDeployment, issues }
|
|
98
139
|
}
|
package/src/provision.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { generateKeyPairSync } from 'node:crypto'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* First-run auto-provisioning for the Elytra CLI (EC-259). Folds the Convex Auth
|
|
6
|
+
* signing-key generation into `elytra init`/`elytra dev` so a developer never has to
|
|
7
|
+
* discover the `npx @convex-dev/auth` incantation — the historic footgun whose absence
|
|
8
|
+
* silently breaks ALL sign-in (Konditorei, 2026-06-22).
|
|
9
|
+
*
|
|
10
|
+
* ## Why generate keys directly (not `npx @convex-dev/auth`)
|
|
11
|
+
*
|
|
12
|
+
* `@convex-dev/auth`'s wizard is interactive (prompts for SITE_URL + an overwrite
|
|
13
|
+
* confirmation) and does MORE than keys (rewrites `auth.config.ts`, checks git). We want
|
|
14
|
+
* exactly two deployment env vars, set idempotently with zero prompts and NEVER an
|
|
15
|
+
* overwrite. So we generate the keypair here and `convex env set` it directly.
|
|
16
|
+
*
|
|
17
|
+
* The generated values are **byte-compatible** with `@convex-dev/auth`'s own
|
|
18
|
+
* `generateKeys()` (verified against its source): RS256 / 2048-bit / e=65537, the private
|
|
19
|
+
* key as a PKCS#8 PEM with newlines collapsed to spaces, and the public key as a
|
|
20
|
+
* `{ keys: [{ use: 'sig', ...jwk }] }` JWKS. Node's built-in `crypto` produces the same
|
|
21
|
+
* bytes jose does (`exportPKCS8` / `exportJWK` are thin wrappers over the same Node
|
|
22
|
+
* exports), so no `jose` dependency is needed and the Convex Auth runtime imports them
|
|
23
|
+
* unchanged (`importPKCS8(JWT_PRIVATE_KEY, 'RS256')`).
|
|
24
|
+
*
|
|
25
|
+
* The convex invocation defaults to `npx convex …` (what `@convex-dev/auth` itself uses,
|
|
26
|
+
* and what resolves a repo's local convex binary). It is injectable for tests.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
// --- Key generation (pure) ---------------------------------------------------
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Generate a Convex Auth RS256 signing keypair in the exact shape the Convex Auth
|
|
33
|
+
* runtime expects. Returns `{ JWT_PRIVATE_KEY, JWKS }` (both strings, ready for
|
|
34
|
+
* `convex env set`). Pure — no I/O.
|
|
35
|
+
*/
|
|
36
|
+
export function generateAuthKeys() {
|
|
37
|
+
const { publicKey, privateKey } = generateKeyPairSync('rsa', {
|
|
38
|
+
modulusLength: 2048,
|
|
39
|
+
publicExponent: 65537,
|
|
40
|
+
})
|
|
41
|
+
const pkcs8 = String(privateKey.export({ type: 'pkcs8', format: 'pem' }))
|
|
42
|
+
const jwk = publicKey.export({ format: 'jwk' })
|
|
43
|
+
return {
|
|
44
|
+
// Match @convex-dev/auth: PKCS#8 PEM, trailing whitespace trimmed, newlines → spaces.
|
|
45
|
+
JWT_PRIVATE_KEY: pkcs8.trimEnd().replace(/\n/g, ' '),
|
|
46
|
+
JWKS: JSON.stringify({ keys: [{ use: 'sig', ...jwk }] }),
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// --- convex env get/set (I/O, injectable) ------------------------------------
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Default convex invocation: `npx convex <args>`. Returns a normalized result and
|
|
54
|
+
* never throws (spawnSync captures a non-zero exit — e.g. `env get` on a missing var).
|
|
55
|
+
*/
|
|
56
|
+
export function defaultConvexExec(args, { cwd } = {}) {
|
|
57
|
+
const res = spawnSync('npx', ['convex', ...args], { cwd, encoding: 'utf8' })
|
|
58
|
+
return {
|
|
59
|
+
status: res.status ?? 1,
|
|
60
|
+
stdout: res.stdout ?? '',
|
|
61
|
+
stderr: res.stderr ?? '',
|
|
62
|
+
error: res.error,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Read a deployment env var; '' when unset or the command failed. */
|
|
67
|
+
export function convexEnvGet(name, { cwd, exec = defaultConvexExec } = {}) {
|
|
68
|
+
const { status, stdout } = exec(['env', 'get', name], { cwd })
|
|
69
|
+
if (status !== 0) return ''
|
|
70
|
+
return String(stdout).trim()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Set a deployment env var (arg array — no shell quoting of the long PEM value). */
|
|
74
|
+
export function convexEnvSet(name, value, { cwd, exec = defaultConvexExec } = {}) {
|
|
75
|
+
const { status, stderr, error } = exec(['env', 'set', '--', name, value], { cwd })
|
|
76
|
+
if (status !== 0) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`convex env set ${name} failed${error ? ` (${error.message})` : ''}: ${String(stderr).trim()}`,
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Which of the two auth signing-key vars are already present on the deployment. */
|
|
84
|
+
export function detectKeyState({ cwd, exec } = {}) {
|
|
85
|
+
return {
|
|
86
|
+
jwt: convexEnvGet('JWT_PRIVATE_KEY', { cwd, exec }) !== '',
|
|
87
|
+
jwks: convexEnvGet('JWKS', { cwd, exec }) !== '',
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Idempotently ensure the Convex Auth signing keys exist on the target deployment.
|
|
93
|
+
* Never overwrites an existing key.
|
|
94
|
+
*
|
|
95
|
+
* - both present → no-op (`present`)
|
|
96
|
+
* - both absent → generate + set both (`generated`)
|
|
97
|
+
* - exactly one → leave it untouched, report for manual repair (`partial`)
|
|
98
|
+
*
|
|
99
|
+
* `exec` is injectable for tests; `generate` and `log` too.
|
|
100
|
+
*/
|
|
101
|
+
export function ensureAuthKeys({ cwd, exec, log = () => {}, generate = generateAuthKeys } = {}) {
|
|
102
|
+
const state = detectKeyState({ cwd, exec })
|
|
103
|
+
if (state.jwt && state.jwks) {
|
|
104
|
+
log('✓ Auth signing keys already set (JWT_PRIVATE_KEY + JWKS) — no change.')
|
|
105
|
+
return { action: 'present', state }
|
|
106
|
+
}
|
|
107
|
+
if (state.jwt || state.jwks) {
|
|
108
|
+
log('! Only one of JWT_PRIVATE_KEY/JWKS is set — leaving it untouched (setup never overwrites a key).')
|
|
109
|
+
log(' Repair manually: clear both and re-run `elytra init`, or run `npx @convex-dev/auth`.')
|
|
110
|
+
return { action: 'partial', state }
|
|
111
|
+
}
|
|
112
|
+
const keys = generate()
|
|
113
|
+
convexEnvSet('JWT_PRIVATE_KEY', keys.JWT_PRIVATE_KEY, { cwd, exec })
|
|
114
|
+
convexEnvSet('JWKS', keys.JWKS, { cwd, exec })
|
|
115
|
+
log('✓ Generated and set the Convex Auth signing keys (JWT_PRIVATE_KEY + JWKS).')
|
|
116
|
+
return { action: 'generated', state }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// --- Orchestration plan (pure) -----------------------------------------------
|
|
120
|
+
|
|
121
|
+
/** @typedef {'skip'|'run'|'deferred'|'manual'|'blocked'} SetupStepStatus */
|
|
122
|
+
|
|
123
|
+
function step(id, status, detail) {
|
|
124
|
+
return { id, status, detail }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The ordered, idempotent first-run setup plan (pure — the decision core `elytra init`
|
|
129
|
+
* executes and the tests pin). Given the project's resolved mode/provisioning and, when
|
|
130
|
+
* known, the deployment's key state, decide each step: provision → auth-keys → verify.
|
|
131
|
+
*
|
|
132
|
+
* Every decision is re-runnable: an already-satisfied step is `skip`, so re-running
|
|
133
|
+
* `elytra init` is safe at every point.
|
|
134
|
+
*
|
|
135
|
+
* `isProdDeployment` (EC-356) never changes a step's status here, only its message: a
|
|
136
|
+
* `prod:` deployment is provisioned exactly like any other (`skip`), and an unlinked prod
|
|
137
|
+
* selector is still refused exactly like an unconfigured backend (`blocked`) — but the
|
|
138
|
+
* detail text says so, because `convex dev` is never the right next step against
|
|
139
|
+
* production (`elytra init` prints the same distinction; see its `runInit`).
|
|
140
|
+
*
|
|
141
|
+
* @param {{ mode: string, convexProvisioned: boolean, isProdDeployment?: boolean }} project
|
|
142
|
+
* @param {{ jwt: boolean, jwks: boolean } | null} [keyState] deployment key state, or null when not yet knowable
|
|
143
|
+
*/
|
|
144
|
+
export function planSetup({ mode, convexProvisioned, isProdDeployment = false }, keyState = null) {
|
|
145
|
+
const steps = []
|
|
146
|
+
|
|
147
|
+
// 1. Provision a backend deployment.
|
|
148
|
+
if (mode === 'playground') {
|
|
149
|
+
steps.push(step('provision', 'skip', 'Playground mode — in-memory, no backend deployment to provision.'))
|
|
150
|
+
} else if (mode === 'unconfigured') {
|
|
151
|
+
steps.push(
|
|
152
|
+
step('provision', 'blocked', 'No backend configured. Set VITE_CONVEX_URL, or VITE_ELYTRA_MODE=playground, in studio/.env.'),
|
|
153
|
+
)
|
|
154
|
+
} else if (convexProvisioned) {
|
|
155
|
+
steps.push(
|
|
156
|
+
isProdDeployment
|
|
157
|
+
? step(
|
|
158
|
+
'provision',
|
|
159
|
+
'skip',
|
|
160
|
+
'A prod: Convex deployment is already provisioned — `convex dev` is never run against it; deploy explicitly with `npx convex deploy`.',
|
|
161
|
+
)
|
|
162
|
+
: step('provision', 'skip', 'A Convex deployment is already provisioned.'),
|
|
163
|
+
)
|
|
164
|
+
} else if (isProdDeployment) {
|
|
165
|
+
steps.push(
|
|
166
|
+
step(
|
|
167
|
+
'provision',
|
|
168
|
+
'blocked',
|
|
169
|
+
'A prod: selector is set but no deployment is linked here — provisioning via `convex dev` is skipped against production; link and deploy explicitly with `npx convex deploy`.',
|
|
170
|
+
),
|
|
171
|
+
)
|
|
172
|
+
} else {
|
|
173
|
+
steps.push(step('provision', 'run', 'Provision a Convex deployment (convex dev).'))
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// 2. Ensure the Convex Auth signing keys.
|
|
177
|
+
if (mode === 'playground') {
|
|
178
|
+
steps.push(step('auth-keys', 'skip', 'Playground mode — sign-in uses the seeded local adapter (no JWT).'))
|
|
179
|
+
} else if (mode === 'unconfigured') {
|
|
180
|
+
steps.push(step('auth-keys', 'blocked', 'Configure the backend first.'))
|
|
181
|
+
} else if (!convexProvisioned) {
|
|
182
|
+
steps.push(step('auth-keys', 'deferred', 'Auth keys are set once the deployment is provisioned.'))
|
|
183
|
+
} else if (keyState && keyState.jwt && keyState.jwks) {
|
|
184
|
+
steps.push(step('auth-keys', 'skip', 'Auth signing keys (JWT_PRIVATE_KEY + JWKS) are already set.'))
|
|
185
|
+
} else if (keyState && (keyState.jwt || keyState.jwks)) {
|
|
186
|
+
steps.push(step('auth-keys', 'manual', 'Only one of JWT_PRIVATE_KEY/JWKS is set — fix manually; setup never overwrites a key.'))
|
|
187
|
+
} else {
|
|
188
|
+
// keyState null (unknown) or both absent → we will generate.
|
|
189
|
+
steps.push(step('auth-keys', 'run', 'Generate and set JWT_PRIVATE_KEY + JWKS.'))
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// 3. Verify readiness (the elytra doctor check).
|
|
193
|
+
steps.push(
|
|
194
|
+
step('verify', mode === 'unconfigured' ? 'blocked' : 'run', 'Check deployment readiness (elytra doctor).'),
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
return steps
|
|
198
|
+
}
|