validate-env · diff

git:20261002.7b23599 to git:20261009.0ae007b

56 added, 32 removed. Audit B to B.

---
name: validate-env
- description: Use when a frontend project reads environment variables — validates import.meta.env against a Zod schema once at startup (failing fast with a clear message), exports a typed env object the seams import instead of raw import.meta.env, and augments ImportMetaEnv for autocomplete.
+ description: Use when a frontend project reads import.meta.env - parses it once with a Zod schema at startup, fails fast with a readable message, and exports a typed env object the seams import instead of raw reads.
---
# Validate Env
## 1. Audit current state
```bash
- ls src/libs/env.ts 2>/dev/null
+ ls src/libs/env.ts .env.example .env.test .claude/stack-profile.md 2>/dev/null
grep -rn "import.meta.env.VITE_" src/ 2>/dev/null | head # raw, unvalidated reads to migrate
+ grep -n '"zod"' package.json
```
- A missing/blank `VITE_API_URL` today fails *silently at runtime* (the `fetcher` falls back to `''`). This skill turns that into a startup error. **Prerequisite:** `zod` (already present if `set-up-forms` ran; else install it).
+ Read `.claude/stack-profile.md` if present: `package_manager` replaces `pnpm` below; `frontend.meta` of `nuxt` or `next` means this skill does not apply (their env comes from `runtimeConfig` / `NEXT_PUBLIC_*`, validated in their own config); say so and stop.
+ A missing `VITE_API_URL` today fails *silently at runtime* (the `fetcher` falls back to `''` and every request hits the page origin). This skill turns that into a startup error.
+
## 2. Decide what to do
- No `env.ts` → create it (steps 4–6) and migrate raw reads (step 6).
- `env.ts` exists → confirm the schema covers every `VITE_*` the app reads.
+ - The app reads **no** `VITE_*` variable yet → exit; add the skill with the first one.
## 3. Detect framework
- Framework-agnostic — Vite exposes `import.meta.env` in both React and Vue.
+ None needed: Vite exposes `import.meta.env` the same way to React and Vue.
## 4. Install (if needed)
```bash
pnpm add zod
```
+ Zod 4 (`z.url()`, `z.prettifyError()`). Zod 3 projects: keep their API, do not mix.
- ## 5. Schema + parse once, at startup
+ ## 5. Schema, parsed once at startup
```ts
// src/libs/env.ts
import { z } from 'zod';
+ // A blank `VITE_X=` in .env or CI arrives as '' — treat it as unset.
+ const blankToUndefined = (value: unknown) => (value === '' ? undefined : value);
+ const httpUrl = z.url({ protocol: /^https?$/ });
+
const schema = z.object({
- VITE_API_URL: z.string().url(),
- VITE_SENTRY_DSN: z.string().url().optional(),
- VITE_ANALYTICS_DOMAIN: z.string().min(1).optional(),
+ VITE_API_URL: httpUrl,
+ VITE_SENTRY_DSN: z.preprocess(blankToUndefined, httpUrl.optional()),
+ VITE_ANALYTICS_DOMAIN: z.preprocess(blankToUndefined, z.string().min(1).optional()),
});
const parsed = schema.safeParse(import.meta.env);
if (!parsed.success) {
- console.error('❌ Invalid environment variables:\n', parsed.error.flatten().fieldErrors);
- throw new Error('Invalid environment variables — check your .env / deployment config.');
+ throw new Error(`Invalid environment variables:\n${z.prettifyError(parsed.error)}`);
}
- /** Validated, typed env. Import this — never read `import.meta.env.VITE_*` directly. */
+ /** Validated, typed env. Import this; never read `import.meta.env.VITE_*` directly. */
export const env = parsed.data;
```
- Importing `env.ts` anywhere (the seams do) runs the check before the app renders, so a misconfigured deploy fails loudly at boot instead of with a confusing blank-URL fetch later.
+ Importing `env.ts` anywhere (the seams do) runs the check before the app renders, so a bad deploy fails at boot with the field name, not later with an empty-URL fetch. Keep only the variables the app reads; add a line when a seam needs one (`set-up-realtime` adds `VITE_REALTIME_URL`).
- ## 6. Type `import.meta.env` + migrate the seams
+ `env`'s type comes from the schema, so there is no second declaration to keep in sync. Do **not** add an `ImportMetaEnv` augmentation: raw reads are banned, so it would only duplicate the schema.
- ```ts
- // src/types/env.d.ts — autocomplete + type-safety on import.meta.env itself
- interface ImportMetaEnv {
- readonly VITE_API_URL: string;
- readonly VITE_SENTRY_DSN?: string;
- readonly VITE_ANALYTICS_DOMAIN?: string;
- }
- interface ImportMeta {
- readonly env: ImportMetaEnv;
- }
- ```
+ Commit a `.env.example` listing every key with a harmless value, and a `.env.test` with the values Vitest needs (`VITE_API_URL=http://api.test`); Vitest loads `.env.test` because it runs in Vite's `test` mode.
- Point the seams at `env` instead of raw `import.meta.env`:
+ ## 6. Migrate the seams
+
+ Point each seam at `env`:
```ts
// src/libs/fetcher.ts
import { env } from '@/libs/env';
const BASE_URL = env.VITE_API_URL; // was: import.meta.env.VITE_API_URL ?? ''
```
- Do the same in `sentry.ts` (`VITE_SENTRY_DSN`) and `analytics.ts` (`VITE_ANALYTICS_DOMAIN`). Now env vars are type-checked end-to-end and validated at one chokepoint.
+ Do the same in `sentry.ts` (`env.VITE_SENTRY_DSN`) and `analytics.ts` (`env.VITE_ANALYTICS_DOMAIN`).
## 7. Verify
+
+ Add the unit test; it proves the check can fail:
+ ```ts
+ // tests/unit/env.test.ts (needs configure-test-stack; otherwise run the two checks by hand)
+ import { afterEach, expect, test, vi } from 'vitest';
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.resetModules();
+ });
+
+ test('accepts a valid environment and treats blank optionals as unset', async () => {
+ vi.stubEnv('VITE_API_URL', 'https://api.example.com');
+ vi.stubEnv('VITE_SENTRY_DSN', '');
+ const { env } = await import('@/libs/env');
+ expect(env.VITE_SENTRY_DSN).toBeUndefined();
+ });
+
+ test('throws at import time with the field name when a variable is invalid', async () => {
+ vi.stubEnv('VITE_API_URL', 'javascript:alert(1)');
+ await expect(import('@/libs/env')).rejects.toThrow(/VITE_API_URL/);
+ });
+ ```
```bash
- pnpm tsc --noEmit # ImportMetaEnv types resolve
- pnpm dev # boots with valid env
- # temporarily blank VITE_API_URL → dev should throw at startup with the field error
+ pnpm typecheck # env is typed from the schema
+ pnpm vitest run tests/unit/env.test.ts
+ grep -rn "import.meta.env.VITE_" src/ # only src/libs/env.ts may match
+ # by hand: blank VITE_API_URL in .env, `pnpm dev` → the console shows "Invalid environment variables: ✖ Invalid URL → at VITE_API_URL"
```
## References
- - ./env-patterns.md — fail-fast rationale, one-typed-object, VITE_-prefix client exposure, validate-don't-just-read, secrets stay build-only.
- - ../_shared/fetcher.md — the canonical `fetcher` seam that should import `env`.
- - ../_shared/conventions.md — `libs/` seam location.
+ - ./env-patterns.md — fail-fast, one typed object, the `VITE_` prefix, shape not presence, blank values, build-time secrets.
+ - ../_shared/fetcher.md — the `fetcher` seam that imports `env`.
+ - ../_shared/conventions.md — `libs/` seam location, the `typecheck` rule.