@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@elytracms/cli",
3
3
  "author": "finaldream <hi@finaldream.de>",
4
- "version": "0.0.1",
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": "^3.0.0"
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 { inspectProject } from './project.js'
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
- /** Build the dev process group from a project inspection. */
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
- function noteMode(project) {
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
- log('Mode: playground (in-memory, no backend). Convex is not started.')
73
- } else if (project.mode === 'connected' && !project.convexProvisioned) {
74
- log(
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
- 'backend — run `cd studio && npx convex dev` once to provision it. See CONNECT.md.',
105
+ 'backend — run `cd studio && npx convex dev` once to provision it. See CONNECT.md.'
77
106
  )
78
- } else if (project.mode === 'unconfigured') {
79
- log(
107
+ }
108
+ if (project.mode === 'unconfigured') {
109
+ return (
80
110
  'Backend not configured. Defaulting to the frontends only. Set VITE_ELYTRA_MODE=\n' +
81
- 'playground or VITE_CONVEX_URL in studio/.env. See CONNECT.md.',
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
  }
@@ -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
+ }