Skip to content

feat: CLI schema loader — safely import a user's env.ts and expose its declared keys - #1622

Open
yamcodes wants to merge 1 commit into
v1from
1314-cli-schema-loader
Open

feat: CLI schema loader — safely import a user's env.ts and expose its declared keys#1622
yamcodes wants to merge 1 commit into
v1from
1314-cli-schema-loader

Conversation

@yamcodes

Copy link
Copy Markdown
Owner

Fixes #1314

Summary

Test plan

  • Core/standard capture tests: missing env does not throw while capturing; normal validation is unchanged afterward
  • CLI adapter fixtures: ArkType and Zod flat env.ts, re-exports/comments, throwing modules, modules with no arkenv() call
  • pnpm run typecheck
  • Related Vitest projects (cli, arkenv, @arkenv/standard)
  • CI on this PR

Made with Cursor

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>
@yamcodes yamcodes added enhancement New feature or improvement @arkenv/cli Issues or Pull Requests involving the ArkEnv CLI labels Aug 25, 2026
@changeset-bot

changeset-bot Bot commented Aug 25, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 104e634

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@arkenv/core Minor
@arkenv/standard Minor
arkenv Minor

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

@github-actions github-actions Bot added docs Adds or changes documentation, or acts as documentation in and of itself arkenv Changes to the `arkenv` npm package. tests This issue or PR is about adding, removing or changing tests and removed @arkenv/cli Issues or Pull Requests involving the ArkEnv CLI labels Aug 25, 2026
@pkg-pr-new

pkg-pr-new Bot commented Aug 25, 2026

Copy link
Copy Markdown

Open in StackBlitz

arkenv

npm i https://pkg.pr.new/arkenv@1622

@arkenv/build

npm i https://pkg.pr.new/@arkenv/build@1622

@arkenv/bun-plugin

npm i https://pkg.pr.new/@arkenv/bun-plugin@1622

@arkenv/core

npm i https://pkg.pr.new/@arkenv/core@1622

@arkenv/fumadocs-ui

npm i https://pkg.pr.new/@arkenv/fumadocs-ui@1622

@arkenv/nextjs

npm i https://pkg.pr.new/@arkenv/nextjs@1622

@arkenv/nuxt

npm i https://pkg.pr.new/@arkenv/nuxt@1622

@arkenv/standard

npm i https://pkg.pr.new/@arkenv/standard@1622

@arkenv/vite-plugin

npm i https://pkg.pr.new/@arkenv/vite-plugin@1622

commit: 104e634

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 via globalThis; exported as public API from both packages.
  • Capture short-circuitsarkenv() in @arkenv/core (before any def handling) and @arkenv/standard (after schema-shape asserts) records def and returns {}.
  • CLI schema loaderSchemaLoaderPort + JitiSchemaLoaderAdapter importing the user's env.ts under capture and extracting ordered keys, per-key schema, and hasDefault via declaredKeysFromDefinitions; wired into composition for upcoming sync (#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 }, no typeName, no _zod) nor Valibot. The real-Zod path is only anchored by the adapter integration test; pinning schemaHasDefault with actual library instances would falsify the branches it claims to cover and expose whether the _zod branch is reachable at all.
  • packages/arkenv/src/adapters/jiti-schema-loader/jiti-schema-loader.adapter.ts:36NO_SCHEMA keys off definitions.length === 0, so arkenv({}) (empty def) returns ok: true with keys: [] rather than a "no usable schema" result. Probably fine for the planned commands, just worth deciding explicitly.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using 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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

arkenv Changes to the `arkenv` npm package. docs Adds or changes documentation, or acts as documentation in and of itself enhancement New feature or improvement tests This issue or PR is about adding, removing or changing tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant