A setting the app needs that is missing from .env.example, so someone who downloads the repo cannot start it and has no idea why. Also the reverse, and code that reads process.env directly instead of through the schema.
Add "Environment variable checks" from ntent to this repo.
Fetch https://ntent.app/r/f/check-env as plain text. Write it verbatim to scripts/check-env.mjs. Then make exactly these edits and no others: Point CONFIG.schemaFile at this repo’s env schema, CONFIG.exampleFile at its example file, and CONFIG.sourceDirs at the directories it keeps source in, if they are not src/env.ts, .env.example and src. The pre-commit hook reads those three back out of the file, so moving them keeps the gate wired.
Then check it: Add a variable to the schema, do not add it to .env.example, and the check names it.Reads https://ntent.app/r/f/check-env
Read the code
check-env.mjsscripts/check-env.mjs
282 lines
#!/usr/bin/env node
/**
* Env gate: the validated schema, .env.example, and the deploy config all list
* the same variables.
*
* Why this needs a gate. A missing environment variable in production is one of
* the most common ways a deploy breaks, and it is entirely preventable. The
* usual sequence: someone adds process.env.STRIPE_WEBHOOK_SECRET, it works
* locally because their own .env.local has it, and it fails in production
* because nothing told anyone to set it there. Nothing in the type system knows
* that .env.example and the Vercel project settings exist.
*
* This assumes you validate env at boot with Zod, which you should do anyway:
*
* // src/env.ts
* export const env = z.object({
* DATABASE_URL: z.string().url(),
* STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
* NEXT_PUBLIC_APP_URL: z.string().url(),
* }).parse(process.env)
*
* Four checks:
* 1. IN-SCHEMA-NOT-EXAMPLE a new clone cannot boot and nobody told them why.
* 2. IN-EXAMPLE-NOT-SCHEMA either dead, or being read unvalidated somewhere.
* 3. UNVALIDATED-READ process.env.X in source, with no X in the schema.
* 4. NOT-IN-DEPLOY-LIST the variable is not set where it has to run.
*
* Check 4 is optional and off unless deployListFile is set, because it needs a
* file only someone with deploy access can produce. See the "gate or nag"
* question in chapter 2: if the person committing cannot satisfy it, it does not
* belong in the commit path.
*
* WHAT --check FAILS ON: all of it. Checks 2 and 3 used to print and exit 0,
* which meant the gate this repo advertises passed on the exact defect it
* exists to find: a NEW_SECRET read straight out of process.env, named in the
* output, exit code 0. There is no half-finding here. Either something is worth
* saying and a committer can fix it offline from a fresh clone, in which case it
* gates, or it should not be printed. Anything genuinely unfixable at commit
* time belongs in the session-start nag instead, which is where check 4 lives
* whenever deployListFile is set.
*
* Usage:
* node scripts/check-env.mjs
* node scripts/check-env.mjs --check
* node scripts/check-env.mjs --print-config what the hook needs to wire it
*/
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'
// ---------------------------------------------------------------- CONFIG ---
const CONFIG = {
schemaFile: 'src/env.ts',
exampleFile: '.env.example',
// Where source is scanned for direct process.env reads. This is also what the
// pre-commit hook triggers on, via --print-config: a repo that keeps its code
// somewhere other than src/ would otherwise be scanned by a gate that never
// runs.
sourceDirs: ['src'],
// Optional. A plain text file, one variable name per line, listing what the
// hosting platform has set. Produce it with `vercel env ls` or equivalent and
// commit it. Leave null to skip check 3.
deployListFile: null,
// Variables that are legitimately absent from .env.example: injected by the
// platform, or optional with a working default. Each entry needs a reason.
exempt: {
NODE_ENV: 'set by the runtime',
VERCEL_URL: 'injected by the platform',
PORT: 'injected by the platform',
},
// The files allowed to touch process.env directly. The schema file has to:
// it is the thing doing the validating. Add a bootstrap or an instrumentation
// file here if one genuinely needs raw access, with the reason in a comment.
readsEnvDirectly: ['src/env.ts'],
}
// The schema file always may, wherever it was moved to. Leaving this to the
// list above meant that pointing CONFIG.schemaFile at src/config/env.ts turned
// the schema itself into an offender, and a gate that fails on its own
// correctly-written schema is a gate that gets removed.
if (!CONFIG.readsEnvDirectly.includes(CONFIG.schemaFile)) {
CONFIG.readsEnvDirectly.push(CONFIG.schemaFile)
}
// ----------------------------------------------------------------- PARSE ---
/**
* Pull variable names out of the Zod schema. Deliberately a regex over the
* source rather than an import: importing env.ts runs .parse(process.env),
* which throws in CI where the variables are not set, so the gate would fail
* for the wrong reason. Matching UPPER_SNAKE keys followed by z. is precise
* enough in practice, and a false negative here is caught by check 2.
*/
function schemaVars(file) {
const text = readFileSync(file, 'utf8')
const found = new Set()
const re = /^\s*([A-Z][A-Z0-9_]*)\s*:\s*z\./gm
let m
while ((m = re.exec(text))) found.add(m[1])
return found
}
/** Variable names from a dotenv file. Blank lines and comments ignored. */
function dotenvVars(file) {
const text = readFileSync(file, 'utf8')
const found = new Set()
for (const line of text.split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const m = trimmed.match(/^([A-Z][A-Z0-9_]*)\s*=/)
if (m) found.add(m[1])
}
return found
}
/** Variable names from a plain list, one per line. */
function listVars(file) {
const text = readFileSync(file, 'utf8')
return new Set(
text
.split('\n')
.map((l) => l.trim())
.filter((l) => l && !l.startsWith('#'))
)
}
/**
* Every process.env.X read directly in source, with the file it was read in.
*
* Two different defects live here and the file matters for telling them apart.
* A variable read this way and absent from the schema is UNVALIDATED: it is
* undefined at runtime rather than failing at boot with a clear message. A
* variable read this way and present in the schema is a BYPASS: the validated
* value exists and this line went around it, so the coercion, the default and
* the startsWith('sk_') never ran. The second one was invisible before, because
* checking membership alone made a declared variable look fine however it was
* read.
*/
function directReads(dirs) {
const found = new Map() // NAME -> Set(file)
const walk = (dir) => {
let entries
try {
entries = readdirSync(dir)
} catch {
return
}
for (const entry of entries) {
if (['node_modules', '.next', '.git', 'dist'].includes(entry)) continue
const full = `${dir}/${entry}`
if (statSync(full).isDirectory()) walk(full)
else if (/\.(ts|tsx|js|jsx|mjs)$/.test(entry)) {
const text = readFileSync(full, 'utf8')
const re = /process\.env\.([A-Z][A-Z0-9_]*)/g
let m
while ((m = re.exec(text))) {
if (!found.has(m[1])) found.set(m[1], new Set())
found.get(m[1]).add(full)
}
}
}
}
dirs.forEach(walk)
return found
}
/** Paths that are allowed to read process.env directly. */
const mayReadDirectly = (file) =>
CONFIG.readsEnvDirectly.some((allowed) => file === allowed || file.endsWith(`/${allowed}`))
// ------------------------------------------------------------------ MAIN ---
function main() {
const strict = process.argv.includes('--check')
const errors = []
// The hook asks the checker where its files are instead of assuming the
// defaults. Customising schemaFile is documented and supported, and a hook
// that hardcodes src/ env.ts responds to that by silently skipping itself:
// the check is installed, the commit passes, and nothing says why.
if (process.argv.includes('--print-config')) {
console.log(`schemaFile\t${CONFIG.schemaFile}`)
console.log(`exampleFile\t${CONFIG.exampleFile}`)
for (const dir of CONFIG.sourceDirs) console.log(`sourceDir\t${dir}`)
process.exit(0)
}
if (!existsSync(CONFIG.schemaFile)) {
console.error(`No env schema at ${CONFIG.schemaFile}.`)
console.error(`Create one, or point CONFIG.schemaFile at yours.`)
process.exit(1)
}
if (!existsSync(CONFIG.exampleFile)) {
console.error(`No ${CONFIG.exampleFile}. Create it: it is the only instruction`)
console.error(`a new clone gets about what it needs to boot.`)
process.exit(1)
}
const inSchema = schemaVars(CONFIG.schemaFile)
const inExample = dotenvVars(CONFIG.exampleFile)
// 1. In the schema, missing from the example.
for (const name of inSchema) {
if (!inExample.has(name) && !CONFIG.exempt[name]) {
errors.push(
`IN-SCHEMA-NOT-EXAMPLE ${name}\n` +
` A fresh clone will fail to boot with no hint about this.\n` +
` fix: add "${name}=" to ${CONFIG.exampleFile}, with a comment saying where to get it`
)
}
}
// 2. In the example, missing from the schema.
for (const name of inExample) {
if (!inSchema.has(name) && !CONFIG.exempt[name]) {
errors.push(
`IN-EXAMPLE-NOT-SCHEMA ${name}\n` +
` Either dead, or read somewhere without validation.\n` +
` fix: add it to ${CONFIG.schemaFile}, or delete it from ${CONFIG.exampleFile}`
)
}
}
// 3. Read directly, instead of through the validated env object.
const reads = directReads(CONFIG.sourceDirs)
for (const [name, files] of reads) {
if (CONFIG.exempt[name]) continue
const offenders = [...files].filter((f) => !mayReadDirectly(f))
if (!offenders.length) continue
const where = offenders.slice(0, 3).join(', ') + (offenders.length > 3 ? `, +${offenders.length - 3} more` : '')
if (!inSchema.has(name)) {
errors.push(
`UNVALIDATED-READ ${name}\n` +
` ${where}\n` +
` Read via process.env but not in the schema, so it is undefined at\n` +
` runtime instead of failing at boot with a clear message.\n` +
` fix: add it to ${CONFIG.schemaFile} and import env from there`
)
} else {
errors.push(
`DIRECT-READ ${name}\n` +
` ${where}\n` +
` It IS in the schema, and this line goes around it. The parse, the\n` +
` default and any coercion never run, so a validated variable is read\n` +
` here as a raw string or as undefined.\n` +
` fix: import { env } from '${CONFIG.schemaFile.replace(/\.[tj]sx?$/, '')}' and read env.${name}`
)
}
}
// 4. Missing from the deploy platform.
if (CONFIG.deployListFile && existsSync(CONFIG.deployListFile)) {
const deployed = listVars(CONFIG.deployListFile)
for (const name of inSchema) {
if (!deployed.has(name) && !CONFIG.exempt[name]) {
errors.push(
`NOT-IN-DEPLOY-LIST ${name}\n` +
` In the schema, so the app will not boot in production without it.\n` +
` fix: set it on the platform, then refresh ${CONFIG.deployListFile}`
)
}
}
}
// -------------------------------------------------------------- REPORT ---
if (!errors.length) {
console.log(
`env: ok. ${inSchema.size} variable(s); schema and example agree, and nothing` +
` reads process.env around the schema.`
)
process.exit(0)
}
console.log(`\nenv: ${errors.length} error(s)\n`)
for (const e of errors) console.log(` ${e}\n`)
process.exit(strict ? 1 : 0)
}
main()
Success check: Add a variable to the schema, do not add it to .env.example, and the check names it.