feat: CLI schema loader — safely import a user's env.ts and expose its declared keys - #1622
feat: CLI schema loader — safely import a user's env.ts and expose its declared keys#1622yamcodes wants to merge 1 commit into
Conversation
Add a CLI schema loader that Jiti-imports env.ts under capture mode so declared keys can be read without validating process.env. Co-authored-by: Cursor <cursoragent@cursor.com>
🦋 Changeset detectedLatest commit: 104e634 The changes in this PR will be included in the next version bump. This PR includes changesets to release 3 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
arkenv
@arkenv/build
@arkenv/bun-plugin
@arkenv/core
@arkenv/fumadocs-ui
@arkenv/nextjs
@arkenv/nuxt
@arkenv/standard
@arkenv/vite-plugin
commit: |
There was a problem hiding this comment.
Important
The loader emits provably wrong hasDefault metadata for two supported validator paths — Valibot defaults and compiled ArkType schemas with defaults — and capture mode returns an empty object that breaks env.ts files consuming the returned env at module scope. None of these break anything today (nothing consumes the loader yet), but this metadata API is the contract check (#962) and sync (#1234) will be built against, so they're worth cleaning up or explicitly scoping before merge.
Reviewed changes — Reviewed the initial commit of #1622: schema capture in @arkenv/core/@arkenv/standard that records arkenv() definitions instead of validating process.env, plus a CLI SchemaLoaderPort and Jiti adapter that load a flat env.ts under capture mode and expose its declared keys.
- Schema-capture primitives — new global-flag bag in
@repo/utils(beginSchemaCapture/endSchemaCapture/isCapturingSchema/recordSchemaCapture) bridged across separately-loaded library copies viaglobalThis; exported as public API from both packages. - Capture short-circuits —
arkenv()in@arkenv/core(before any def handling) and@arkenv/standard(after schema-shape asserts) recordsdefand returns{}. - CLI schema loader —
SchemaLoaderPort+JitiSchemaLoaderAdapterimporting the user'senv.tsunder capture and extracting ordered keys, per-key schema, andhasDefaultviadeclaredKeysFromDefinitions; wired into composition for upcomingsync(#1234) /check(#962). - Fixture coverage — ArkType/Zod flat
env.ts, cross-file re-export, throwing module, and no-arkenv()module; core/standard capture unit tests.
⚠️ Capture depends on the version of @arkenv/core installed in the user's project
The loader sets the capture flag in the CLI's own module graph, but the user's env.ts imports @arkenv/core/@arkenv/standard from their node_modules — the production composition constructs the adapter with no aliases. Capture only fires if that installed copy contains the new isCapturingSchema() check. A project on an older 1.0.0-alpha.x gets a MODULE_LOAD_FAILED/NO_SCHEMA result for a perfectly valid env.ts, with no hint that a library upgrade is what's needed. The Jiti aliases in the test fixtures point at workspace source, so CI can't exercise the skew.
Technical details
# Version-skew story for the capture flag
## Affected sites
- packages/arkenv/src/adapters/jiti-schema-loader/jiti-schema-loader.adapter.ts — beginSchemaCapture/endSchemaCapture come from @repo/utils (CLI's own graph); the imported module resolves @arkenv/core from the user's project.
- packages/arkenv/src/cli/composition.ts:23 — `new JitiSchemaLoaderAdapter()` with no aliases, so user-installed core is what gets imported.
## Required outcome
- A user running the CLI against a pre-feature `@arkenv/core` should get a message pointing at the library version, not a generic module-load/schema failure.
## Suggested approach (optional)
- On the MODULE_LOAD_FAILED/NO_SCHEMA path, when the module imports @arkenv/core|standard, consider hinting that the installed library must support schema capture (upgrade `@arkenv/core`/`@arkenv/standard`). Whether the CLI can detect "module resolved but capture produced nothing" to distinguish this case is an open design question.ℹ️ Nitpicks
packages/arkenv/src/features/schema-loader/declared-keys.test.ts:41-49— the_def: { typeName: "ZodDefault" }and_zod: { def: { type: "default" } }unit mocks match neither real Zod 4 (_def: { type, defaultValue, innerType }, notypeName, no_zod) nor Valibot. The real-Zod path is only anchored by the adapter integration test; pinningschemaHasDefaultwith actual library instances would falsify the branches it claims to cover and expose whether the_zodbranch is reachable at all.packages/arkenv/src/adapters/jiti-schema-loader/jiti-schema-loader.adapter.ts:36—NO_SCHEMAkeys offdefinitions.length === 0, soarkenv({})(empty def) returnsok: truewithkeys: []rather than a "no usable schema" result. Probably fine for the planned commands, just worth deciding explicitly.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
| * @param schema The per-key schema or validator | ||
| * @returns `true` when a default is detectable | ||
| */ | ||
| export function schemaHasDefault(schema: unknown): boolean { |
There was a problem hiding this comment.
schemaHasDefault only recognizes ArkType DSL strings and Zod's _def/_zod internals. Valibot — a supported validator whose own CLI template emits v.optional(x, default) and v.fallback(...) — carries the default as a top-level default/fallback field on a flat schema object, so every Valibot defaulted key reports hasDefault: false. That's the exact signal check/sync will use to decide whether a key needs a value.
Technical details
# Valibot / Standard Schema defaults are invisible to schemaHasDefault
## Evidence (runtime probe against the workspace-dep valibot 1.4.2)
- `v.optional(v.string(), "dev")` → flat object keys `kind, type, reference, expects, async, wrapped, default, ~standard, ~run`; default is a top-level `default` field.
- `v.fallback(v.string(), "x")` → same shape plus a top-level `fallback` field.
Neither matches the `_def`/`_zod` probes, so both return false today.
## Required outcome
- Valibot `v.optional(x, value)` / `v.fallback(x, value)` keys report `hasDefault: true` (a plain-object probe for a top-level string/number `default` or `fallback` field covers it).
- Document the boundary: Standard Schema v1 exposes no default concept, so `hasDefault` can never be authoritative for arbitrary validators — name that in the JSDoc.
## Notes
- The `_zod` branch matches no current Zod shape (Zod 4 uses `_def: { type, defaultValue, innerType }`); the `typeName: "ZodDefault"` probe is the Zod 3 shape. Consider dropping `_zod` or pinning it to a real library in the unit test.| } | ||
| } | ||
| for (const name of compiledKeys) { | ||
| keys.push({ name, schema: definition, hasDefault: false }); |
There was a problem hiding this comment.
This branch hardcodes hasDefault: false and stores the whole compiled definition as every key's schema, diverging from the plain-object path's per-key shape. ArkType's compiled optional entries do expose the default — type({ DATABASE_URL: 'string = "foo"' }).json yields optional: [{ default: "foo", key: "DATABASE_URL", ... }] — so hasDefault is directly derivable here. The branch also has no test coverage.
Technical details
# Compiled-ArkType branch drops default metadata
## Evidence (runtime probe against workspace-dep arktype 2.2.0)
- `type({ DATABASE_URL: 'string = "foo"', PORT: 'number' }).json` → `{ required: [{ key: "PORT", value: "number" }], optional: [{ default: "foo", key: "DATABASE_URL", value: "string" }], domain: "object" }`.
- ArkType itself rejects defaults on `?` keys (`Only required keys may specify default values`), so a defaulted key always lands in `optional` with a `default` field.
## Required outcome
- `hasDefault: "default" in entry` on the compiled path (required/optional split is also derivable from `required` vs `optional`).
- Decide the `schema` value shape: per-key (like the plain path) or the whole definition — the current branch returns the whole definition while the plain path returns the per-key validator, so callers can't rely on one shape.
- Add at least one fixture exercising a compiled schema (`arkenv(someCompiledType)`, reachable via the `CompiledEnvSchema` overload in core/src/arkenv.ts) so the branch is falsifiable.| ): ArkenvOutput<T, D> | SafeArkEnvResult<ArkenvOutput<T, D>> { | ||
| if (isCapturingSchema()) { | ||
| recordSchemaCapture(def); | ||
| return {} as ArkenvOutput<T, D>; |
There was a problem hiding this comment.
Capture returns {}, so any env.ts that derives a value from the returned env at module scope — export const isProd = env.NODE_ENV === "production", or createClient(env.DATABASE_URL) — either throws or silently reads undefined under capture, surfacing as MODULE_LOAD_FAILED with a message that names the schema module rather than the real cause. This is the central tradeoff of the approach; it deserves a documented note (and likely a better diagnostic) before check/sync build on it.
Technical details
# Empty capture return breaks module-scope env consumption
## Affected sites
- packages/core/src/arkenv.ts:163 — `return {} as ArkenvOutput<T, D>`
- packages/standard/src/index.ts:82 — same shape for the standard tier
- packages/arkenv/src/adapters/jiti-schema-loader/jiti-schema-loader.adapter.ts:54 — misleading `MODULE_LOAD_FAILED` for these cases
## Required outcome
- Decide and document the contract: capture mode returns an object with no real values, so any module-scope use of the returned env is out of contract (common real-world env.ts files do `export const isProd = env.NODE_ENV === "production"`).
## Suggested approach (optional)
- Add a fixture pinning current behavior (module that reads the captured env at top level) so the failure mode is explicit rather than incidental.
- On MODULE_LOAD_FAILED, when the thrown cause reads like an "undefined value" error, consider hinting that the schema module must be a declarative flat `arkenv({...})` and not consume the returned env at module scope.
Fixes #1314
Summary
beginSchemaCapture/endSchemaCapturesoarkenv()can record schema definitions without validatingprocess.env(@arkenv/coreand@arkenv/standard).SchemaLoaderPortand Jiti adapter that imports a flatenv.tsand returns declared keys (order, per-key schema,hasDefault) as a structuredok/errorresult.sync(arkenv sync: generate/update.env.examplefrom the env schema #1234) andcheck(arkenv check: validate the environment against the schema #962). Strict layout is out of scope.Test plan
env.ts, re-exports/comments, throwing modules, modules with noarkenv()callpnpm run typecheckcli,arkenv,@arkenv/standard)Made with Cursor