@elytracms/cli 0.0.85 → 0.0.86

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.85",
4
+ "version": "0.0.86",
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": ">=22.18.0"
18
18
  },
19
19
  "dependencies": {
20
- "@elytracms/core": "^0.0.85"
20
+ "@elytracms/core": "^0.0.86"
21
21
  },
22
22
  "devDependencies": {
23
23
  "vitest": "^4.1.10"
package/src/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { spawnSync } from 'node:child_process'
2
2
  import { join } from 'node:path'
3
3
  import { stdout } from 'node:process'
4
- import { runDoctor } from './doctor.js'
4
+ import { resolveInstalledEngineVersion, runDoctor } from './doctor.js'
5
5
  import { runPreviews } from './previews.js'
6
6
  import { inspectProject, resolveStudioEnv } from './project.js'
7
7
  import { ensureAuthKeys } from './provision.js'
@@ -426,7 +426,17 @@ export async function runInit(project, { log: emit = log, fetchImpl, exec, provi
426
426
  emit('')
427
427
  emit('3. Verify —')
428
428
  const env = resolveStudioEnv(project.root)
429
- const code = await runDoctor({ env, fetchImpl, log: (l) => emit(` ${l}`) })
429
+ // EC-552: with the studio dir known, the verify step includes the version
430
+ // handshake — right after a provision is exactly when a stale-functions state
431
+ // should be loud. Resolution happens HERE (the entry), keeping runDoctor injected.
432
+ const verifyStudioDir = project.apps.studio ? join(project.root, 'studio') : undefined
433
+ const code = await runDoctor({
434
+ env,
435
+ fetchImpl,
436
+ log: (l) => emit(` ${l}`),
437
+ studioDir: verifyStudioDir,
438
+ installedEngineVersion: resolveInstalledEngineVersion(verifyStudioDir),
439
+ })
430
440
 
431
441
  // 4. Ready checklist.
432
442
  emit('')
@@ -470,6 +480,7 @@ export async function run(argv, { cwd = process.cwd(), env = process.env, emit =
470
480
  return 1
471
481
  }
472
482
  const studioEnv = resolveStudioEnv(project.root, env)
483
+ const doctorStudioDir = project.apps.studio ? join(project.root, 'studio') : undefined
473
484
  return runDoctor({
474
485
  env: studioEnv,
475
486
  fetchImpl,
@@ -477,7 +488,10 @@ export async function run(argv, { cwd = process.cwd(), env = process.env, emit =
477
488
  url: flagValue(argv, '--url'),
478
489
  // EC-399: where `convex env list` runs (name enumeration — presence only),
479
490
  // and whether the AI provider-key expectation applies.
480
- studioDir: project.apps.studio ? join(project.root, 'studio') : undefined,
491
+ studioDir: doctorStudioDir,
492
+ // EC-552: the installed engine version — resolved at the entry so runDoctor
493
+ // stays pure/injected; unresolvable (deps not installed) → honest `unknown`.
494
+ installedEngineVersion: resolveInstalledEngineVersion(doctorStudioDir),
481
495
  // EC-469: where the project's `cms/` route set is read from (code, not backend).
482
496
  root: project.root,
483
497
  aiRouteConfigured: project.hasAiRoute,
package/src/doctor.js CHANGED
@@ -1,3 +1,6 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { createRequire } from 'node:module'
3
+ import { dirname, join } from 'node:path'
1
4
  import {
2
5
  REVALIDATE_ENV_ENDPOINT,
3
6
  REVALIDATE_ENV_SECRET,
@@ -5,6 +8,7 @@ import {
5
8
  checkDeploymentReady,
6
9
  expectedDeploymentEnv,
7
10
  probeAuthSigningKeys,
11
+ probeDeployedFunctionsVersion,
8
12
  probeRevalidateEndpoint,
9
13
  } from '@elytracms/core/export-sync/deployment-ready'
10
14
  import { executeWorkspaceConfig } from './config-harness.js'
@@ -68,16 +72,27 @@ export function resolveDoctorTarget(env = {}, { url } = {}) {
68
72
  // --- Probes (node) -----------------------------------------------------------
69
73
 
70
74
  /**
71
- * Reachability probe: any HTTP response means the deployment answered; a thrown fetch
72
- * (DNS/connection) means it did not. (The studio uses `adapter.status()`; the CLI has
73
- * no convex client, so a plain GET to the deployment's client URL is the node analogue.)
75
+ * Deployment probe (EC-542 + EC-552): ONE call to the public query API yields BOTH the
76
+ * reachability fact and the version-handshake probe. The studio's probe was always a
77
+ * real query (`adapter.status()` → `members:mine`); the CLI previously used a bare GET
78
+ * and called ANY HTTP response "answered" — measured false-positive: Convex's wildcard
79
+ * `*.convex.cloud` 404s (empty body) for deployments that DO NOT EXIST, so every
80
+ * nonexistent deployment passed. Reachability now means "answered AS a Convex
81
+ * deployment": a Convex envelope — success, missing-function, or any other Convex
82
+ * error — proves a deployment; a non-Convex answer (the empty 404) or a thrown fetch
83
+ * (DNS/connection) fails it, each with its own honest detail so "nothing there" and
84
+ * "there but not deployed" stay distinguishable (the latter is reachable=true + the
85
+ * `functions-version`/JWKS-404 checks telling the deploy story).
74
86
  */
75
- export async function probeReachable(url, fetchImpl = fetch) {
76
- try {
77
- const res = await fetchImpl(url, { method: 'GET' })
78
- return { available: true, detail: `HTTP ${res.status}` }
79
- } catch (error) {
80
- return { available: false, detail: error instanceof Error ? error.message : String(error) }
87
+ export async function probeDeployment(url, fetchImpl = fetch) {
88
+ const versionProbe = await probeDeployedFunctionsVersion(url, fetchImpl)
89
+ const answered =
90
+ versionProbe.status === 'answered' ||
91
+ versionProbe.status === 'missing' ||
92
+ versionProbe.answeredAsConvex === true
93
+ return {
94
+ reachable: answered ? { available: true } : { available: false, detail: versionProbe.detail },
95
+ versionProbe,
81
96
  }
82
97
  }
83
98
 
@@ -86,24 +101,54 @@ export async function probeReachable(url, fetchImpl = fetch) {
86
101
  * user facts are intentionally omitted (no auth session) → `unknown`; the app facts
87
102
  * (config/collections) are gathered separately via the consumer-Vite harness when it
88
103
  * is available (EC-495, {@link configFactsFromOutcome}).
104
+ *
105
+ * EC-542/EC-552: reachability and the version probe are ONE network call
106
+ * ({@link probeDeployment}); the `functions-version` fact is emitted only when the
107
+ * caller supplies `installedEngineVersion` (otherwise the probe outcome still decides
108
+ * reachability but the handshake stays an honest `unknown`).
89
109
  */
90
- export async function gatherFacts(target, { fetchImpl = fetch } = {}) {
110
+ export async function gatherFacts(target, { fetchImpl = fetch, installedEngineVersion } = {}) {
91
111
  if (target.kind === 'playground') return { backend: { kind: 'playground' } }
92
112
  if (target.kind === 'unconfigured') return { backend: { kind: 'unconfigured', detail: target.detail } }
93
113
 
94
- const reachable = await probeReachable(target.url, fetchImpl)
114
+ const { reachable, versionProbe } = await probeDeployment(target.url, fetchImpl)
95
115
  const siteUrl = backendSiteUrl(target.url, target.siteUrl)
96
116
  const authKeys = await probeAuthSigningKeys(siteUrl, fetchImpl)
97
117
  return {
98
118
  backend: { kind: 'convex', url: target.url },
99
119
  reachable,
100
120
  authKeys,
121
+ ...(installedEngineVersion
122
+ ? { functionsVersion: { probe: versionProbe, expected: installedEngineVersion } }
123
+ : {}),
101
124
  // A configured URL means a deployment is wired; leave `provisioned` unset (→ ok).
102
125
  // session omitted → the user checks surface as `unknown`; config/collections are
103
126
  // merged in by the caller from the EC-495 harness outcome (or stay omitted).
104
127
  }
105
128
  }
106
129
 
130
+ /**
131
+ * Resolve the engine version of the `@elytracms/studio` package INSTALLED in the
132
+ * consumer's studio app (EC-552) — the `expected` half of the version handshake.
133
+ * Deliberately resolved through the `convex/delivery` export and its adjacent
134
+ * `package.json`: that is the very source file the consumer's `convex deploy`
135
+ * bundles, so the version read here is by construction the version a redeploy
136
+ * would stamp. (The consumer's own `package.json` only carries a RANGE.)
137
+ * `undefined` when the deps aren't installed → the fact is omitted → the check
138
+ * reports an honest `unknown`, matching the config checks' opportunistic contract.
139
+ */
140
+ export function resolveInstalledEngineVersion(studioDir) {
141
+ if (!studioDir) return undefined
142
+ try {
143
+ const req = createRequire(join(studioDir, 'package.json'))
144
+ const deliverySource = req.resolve('@elytracms/studio/convex/delivery')
145
+ const pkg = JSON.parse(readFileSync(join(dirname(deliverySource), '..', 'package.json'), 'utf8'))
146
+ return typeof pkg.version === 'string' ? pkg.version : undefined
147
+ } catch {
148
+ return undefined
149
+ }
150
+ }
151
+
107
152
  // --- Config facts via the consumer-Vite harness (EC-495) -------------------------------
108
153
 
109
154
  /**
@@ -389,6 +434,13 @@ export function formatReport(report) {
389
434
  * works-without-node_modules contract is untouched. A resolved config also hands the
390
435
  * route checks the same collections the studio actually mounts (superseding the
391
436
  * `cms/index.ts` re-export). `configOutcome` is injectable for tests.
437
+ *
438
+ * EC-552: when the caller supplies `installedEngineVersion` (the CLI entry resolves it
439
+ * from the consumer's installed `@elytracms/studio` via {@link
440
+ * resolveInstalledEngineVersion}), the deployed functions are probed for their stamped
441
+ * version and the `functions-version` check gets a real answer; otherwise the fact is
442
+ * omitted → an honest `unknown`. Resolution stays at the entry point so this function
443
+ * remains pure/injected like every other gatherer here.
392
444
  */
393
445
  export async function runDoctor({
394
446
  env = {},
@@ -402,6 +454,7 @@ export async function runDoctor({
402
454
  previewsArtifacts,
403
455
  routeFacts,
404
456
  configOutcome,
457
+ installedEngineVersion,
405
458
  } = {}) {
406
459
  const target = resolveDoctorTarget(env, { url })
407
460
 
@@ -417,7 +470,11 @@ export async function runDoctor({
417
470
  log('No backend configured.')
418
471
  }
419
472
 
420
- const facts = await gatherFacts(target, { fetchImpl })
473
+ // EC-552: the version handshake rides the same probe as reachability; it becomes a
474
+ // real check only when the caller supplied the installed engine version (the CLI
475
+ // entry resolves it via `resolveInstalledEngineVersion`; runDoctor itself never
476
+ // touches the filesystem for it, staying pure/injected like every gatherer here).
477
+ const facts = await gatherFacts(target, { fetchImpl, installedEngineVersion })
421
478
  if (target.kind === 'convex') {
422
479
  facts.deploymentEnv = gatherDeploymentEnvFacts({ env, target, studioDir, aiRouteConfigured, exec })
423
480
  // EC-421: default-on signed no-op ping whenever the revalidate pair is present.
package/src/previews.js CHANGED
@@ -51,7 +51,9 @@ export const PREVIEWS_MANIFEST_FILE = 'elytra-previews.json'
51
51
  /** Root-relative URL prefix the manifest entries carry. */
52
52
  export const PREVIEWS_URL_PREFIX = '/elytra-previews/'
53
53
  /**
54
- * Manifest format version (mirrors `@elytracms/studio`'s PREVIEWS_MANIFEST_VERSION).
54
+ * Manifest format version — THE declaration (EC-514: the studio's mirror was
55
+ * removed; its reader is version-agnostic, and this CLI is the only writer and the
56
+ * only reader of the number). Bump on incompatible schema/hash changes.
55
57
  * `2` (EC-502): the entry hash no longer includes the engine version, so v1 hashes
56
58
  * are not comparable with v2 ones — a version mismatch is reported as itself
57
59
  * ("regenerate"), never as N bogus `stale` entries.