Skip to content

Commit 302f753

Browse files
Runtime environment failfast (#666)
* Fail fast on missing runtime env Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Require Fly and port env at runtime Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Use zod-validated getEnv for runtime env access Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Load .env.example in vitest setup Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Quiet dotenv output in tests Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Fix env refactor typecheck issues Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Fix import order warnings Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Use build-time env check for RedBox Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Fix CI env requirements for prisma generate and playwright Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Default NODE_ENV to development Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Tighten getEnv typing and error logging Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Tidy env example and document Kit api_secret usage Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Require NODE_ENV to be set Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Revise AGENTS.md to streamline editing policy and documentation structure. Emphasize concise project-specific guidance and link to detailed agent documentation in `docs/agents/`. * Refactor YouTube playlist configuration to use only playlist ID. Update environment variables and documentation accordingly. --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 662a8dd commit 302f753

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

51 files changed

+726
-422
lines changed

.env.example

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,20 +26,23 @@ BOT_GITHUB_TOKEN=1a2b3c4d5e6f7g8g9i0j
2626

2727
# Feature: authentication
2828
# Mocked: Unnecessary (any value can be used)
29-
# Technically we have a fallback in development so this doesn't even need to be set
29+
# Any value can be used locally, but it must be set (we fail fast when missing)
3030

3131
SESSION_SECRET=anything_works_here
3232
MAGIC_LINK_SECRET=whatever_stuff
3333

3434
# Feature: basically everything
3535
# Mocked: No, must run sqlite locally
3636
DATABASE_FILENAME="sqlite.db"
37+
DATABASE_PATH=./prisma/sqlite.db
3738
DATABASE_URL="file:./prisma/sqlite.db"
3839
CACHE_DATABASE_PATH="other/cache.db"
3940
LITEFS_DIR="./prisma"
41+
PORT=3000
4042

4143
# Feature: Fly
4244
# Mocked: No
45+
FLY_APP_NAME=kcd
4346
FLY_REGION="dfw"
4447
FLY_INSTANCE="123456"
4548
INTERNAL_COMMAND_TOKEN="something_random"
@@ -121,8 +124,7 @@ R2_SECRET_ACCESS_KEY=MOCK_R2_SECRET_ACCESS_KEY
121124
SEMANTIC_SEARCH_IGNORE_LIST_KEY=manifests/ignore-list.json
122125

123126
# Feature: YouTube playlist semantic indexing + /youtube route
124-
# Optional: set either playlist URL or playlist ID.
125-
YOUTUBE_PLAYLIST_URL=https://www.youtube.com/watch?v=wSEUlS8WcQs&list=PLV5CVI1eNcJgNqzNwcs4UKrlJdhfDjshf
127+
# Optional
126128
YOUTUBE_PLAYLIST_ID=PLV5CVI1eNcJgNqzNwcs4UKrlJdhfDjshf
127129
# Optional: helps bypass YouTube anti-bot checks for captions in some envs.
128130
YOUTUBE_COOKIE=MOCK_YOUTUBE_COOKIE

.github/workflows/index-semantic-youtube.yml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,8 @@ jobs:
4747
${{ secrets.CLOUDFLARE_AI_EMBEDDING_MODEL }}
4848
R2_BUCKET: ${{ secrets.R2_BUCKET }}
4949

50-
# Prefer setting one of these as repository variables.
51-
# If neither is set, the script uses a built-in default playlist ID.
52-
YOUTUBE_PLAYLIST_URL: ${{ vars.YOUTUBE_PLAYLIST_URL }}
50+
# Prefer setting this as a repository variable.
51+
# If it is not set, the script uses a built-in default playlist ID.
5352
YOUTUBE_PLAYLIST_ID: ${{ vars.YOUTUBE_PLAYLIST_ID }}
5453

5554
# Optional: force-index specific videos. Can be set either as a

AGENTS.md

Lines changed: 16 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,23 @@
11
# AGENTS.md
22

3-
## Cloud-specific instructions
3+
This file is included in full context for every agent conversation. Keep it
4+
tiny and stable.
45

5-
If you're fixing a bug, always attempt to reproduce it with an automated test or
6-
manually first. This is the only way to be sure you've actually fixed the bug.
6+
## Editing policy
77

8-
### Overview
8+
- Avoid adding operational details to `AGENTS.md`.
9+
- Only update `AGENTS.md` to reference other docs/files.
10+
- Put project callouts and workflow details in `docs/agents/` and link them from
11+
here.
912

10-
This is Kent C. Dodds' personal website (kentcdodds.com) — a React Router v7 app
11-
with Express, SQLite (via Prisma), and extensive MSW mocks for all external
12-
APIs.
13+
## Agent docs (source of truth)
1314

14-
### Prerequisites
15+
- `docs/agents/project-context.md` (setup, commands, project-specific caveats)
16+
- `docs/agents/code-style.md`
17+
- `docs/agents/bugfix-workflow.md`
18+
- `docs/agents/testing-principles.md`
1519

16-
- **Node.js 24** is required (`engines` field in `package.json`). Install via
17-
`nvm install 24 && nvm alias default 24`.
18-
- The `.npmrc` sets `legacy-peer-deps=true`; `npm install` respects this
19-
automatically.
20-
21-
### Key commands
22-
23-
Standard dev commands are documented in `README.md` and `CONTRIBUTING.md`. Quick
24-
reference:
25-
26-
| Task | Command |
27-
| --------------- | --------------------------------------------------------------------------------- |
28-
| Dev server | `npm run dev` (starts on port 3000 with `MOCKS=true`) |
29-
| Lint | `npm run lint` |
30-
| Typecheck | `npm run typecheck` |
31-
| Unit tests | `npm run test -- --watch=false` |
32-
| E2E tests | `npm run test:e2e:dev` (requires Playwright browsers: `npm run test:e2e:install`) |
33-
| DB reset + seed | `npx prisma@7 migrate reset --force` then `npx tsx other/runfile prisma/seed.ts` |
34-
35-
### Non-obvious caveats
36-
37-
- **Dev server is not a TTY**: `server/dev-server.js` detects non-TTY and
38-
disables keyboard shortcuts. This is expected in cloud agent terminals.
39-
- **All external APIs are mocked** via MSW when `MOCKS=true` (the default in
40-
dev). No real API keys are needed for local development — the `.env.example`
41-
values are sufficient.
42-
- **SQLite is file-based**: The database file lives at `prisma/sqlite.db`. No
43-
external database server is required.
44-
- **Cache database**: A separate SQLite cache DB is created at `other/cache.db`.
45-
It's populated on first request or via `npm run prime-cache:mocks`.
46-
- **Patch-package**: Three Remix packages are patched during `postinstall`
47-
(patches in `other/patches/`). If you see patch errors after dependency
48-
changes, check those patches.
49-
- **Content is filesystem-based**: Blog posts are MDX files in `content/blog/`.
50-
Changes to content files are auto-detected by the dev server's file watcher.
20+
If you discover a new sharp edge, workflow, or non-obvious project behavior,
21+
update the relevant doc(s) in `docs/agents/` so future agent runs are faster and
22+
more correct. Keep callouts organized under clear headings and prefer concise,
23+
project-specific guidance over generic advice.

app/components/errors.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ function ErrorPage({
8484
</div>
8585
</noscript>
8686
<main className="relative">
87-
{error && process.env.NODE_ENV === 'development' ? (
87+
{error && import.meta.env.MODE === 'development' ? (
8888
<RedBox error={error} />
8989
) : null}
9090
<HeroSection {...heroProps} />

app/entry.server.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@ import { ServerRouter,
1212
type HandleDocumentRequestFunction } from 'react-router';
1313
import { ensurePrimary } from '#app/utils/litefs-js.server.ts'
1414
import { routes as otherRoutes } from './other-routes.server.ts'
15-
import { getEnv, init } from './utils/env.server.ts'
15+
import { getEnv, getPublicEnv, init } from './utils/env.server.ts'
1616
import { NonceProvider } from './utils/nonce-provider.ts'
1717

1818
init()
19-
global.ENV = getEnv()
19+
global.ENV = getPublicEnv()
2020

2121
const ABORT_DELAY = 5000
2222

@@ -30,6 +30,7 @@ export default async function handleDocumentRequest(...args: DocRequestArgs) {
3030
reactRouterContext,
3131
loadContext,
3232
] = args
33+
const env = getEnv()
3334
if (responseStatusCode >= 500) {
3435
// if we had an error, let's just send this over to the primary and see
3536
// if it can handle it.
@@ -41,11 +42,11 @@ export default async function handleDocumentRequest(...args: DocRequestArgs) {
4142
if (otherRouteResponse) return otherRouteResponse
4243
}
4344

44-
if (process.env.NODE_ENV !== 'production') {
45+
if (env.NODE_ENV !== 'production') {
4546
responseHeaders.set('Cache-Control', 'no-store')
4647
}
4748

48-
if (process.env.NODE_ENV === 'production' && process.env.SENTRY_DSN) {
49+
if (env.NODE_ENV === 'production' && env.SENTRY_DSN) {
4950
responseHeaders.append('Document-Policy', 'js-profiling')
5051
}
5152

app/kit/kit.server.ts

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
import { getRequiredServerEnvVar } from '../utils/misc.ts'
1+
import { getEnv } from '../utils/env.server.ts'
22

3-
const KIT_API_SECRET = getRequiredServerEnvVar('KIT_API_SECRET')
4-
const KIT_API_KEY = getRequiredServerEnvVar('KIT_API_KEY')
3+
function getKitAuth() {
4+
const env = getEnv()
5+
return { apiSecret: env.KIT_API_SECRET, apiKey: env.KIT_API_KEY }
6+
}
57

68
type KitSubscriber = {
79
id: number
@@ -19,8 +21,11 @@ type KitTag = {
1921
}
2022

2123
async function getKitSubscriber(email: string) {
24+
const { apiSecret } = getKitAuth()
2225
const url = new URL('https://api.kit.com/v3/subscribers')
23-
url.searchParams.set('api_secret', KIT_API_SECRET)
26+
// Kit API v3 expects `api_secret` as a query param for these endpoints.
27+
// Avoid logging this URL to prevent leaking credentials.
28+
url.searchParams.set('api_secret', apiSecret)
2429
url.searchParams.set('email_address', email)
2530

2631
const resp = await fetch(url.toString())
@@ -33,8 +38,11 @@ async function getKitSubscriber(email: string) {
3338
}
3439

3540
async function getKitSubscriberTags(subscriberId: KitSubscriber['id']) {
41+
const { apiSecret } = getKitAuth()
3642
const url = new URL(`https://api.kit.com/v3/subscribers/${subscriberId}/tags`)
37-
url.searchParams.set('api_secret', KIT_API_SECRET)
43+
// Kit API v3 expects `api_secret` as a query param for these endpoints.
44+
// Avoid logging this URL to prevent leaking credentials.
45+
url.searchParams.set('api_secret', apiSecret)
3846

3947
const resp = await fetch(url.toString())
4048
const json = (await resp.json()) as {
@@ -73,9 +81,10 @@ async function addSubscriberToForm({
7381
firstName: string
7482
kitFormId: string
7583
}) {
84+
const { apiKey, apiSecret } = getKitAuth()
7685
const subscriberData = {
77-
api_key: KIT_API_KEY,
78-
api_secret: KIT_API_SECRET,
86+
api_key: apiKey,
87+
api_secret: apiSecret,
7988
first_name: firstName,
8089
email,
8190
}
@@ -106,9 +115,10 @@ async function addTagToSubscriber({
106115
kitTagId: string
107116
}) {
108117
await ensureSubscriber({ email, firstName })
118+
const { apiKey, apiSecret } = getKitAuth()
109119
const subscriberData = {
110-
api_key: KIT_API_KEY,
111-
api_secret: KIT_API_SECRET,
120+
api_key: apiKey,
121+
api_secret: apiSecret,
112122
first_name: firstName,
113123
email,
114124
}
@@ -139,9 +149,10 @@ async function tagKCDSiteSubscriber({
139149
const subscriber = await getKitSubscriber(email)
140150
const kcdTagId = '2466369'
141151
const kcdSiteForm = '2393887'
152+
const { apiKey, apiSecret } = getKitAuth()
142153
const subscriberData = {
143-
api_key: KIT_API_KEY,
144-
api_secret: KIT_API_SECRET,
154+
api_key: apiKey,
155+
api_secret: apiSecret,
145156
first_name: firstName,
146157
email,
147158
fields,

app/root.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import tailwindStyles from './styles/tailwind.css?url'
3434
import vendorStyles from './styles/vendors.css?url'
3535
import { ClientHintCheck, getHints } from './utils/client-hints.tsx'
3636
import { getClientSession } from './utils/client.server.ts'
37-
import { getEnv } from './utils/env.server.ts'
37+
import { getPublicEnv } from './utils/env.server.ts'
3838
import { getLoginInfoSession } from './utils/login.server.ts'
3939
import { useNonce } from './utils/nonce-provider.ts'
4040
import { getLatestPodcastSeasonLinks } from './utils/podcast-latest-season.server.ts'
@@ -134,7 +134,7 @@ export async function loader({ request }: Route.LoaderArgs) {
134134
user,
135135
userInfo: user ? await getUserInfo(user, { request, timings }) : null,
136136
latestPodcastSeasonLinks,
137-
ENV: getEnv(),
137+
ENV: getPublicEnv(),
138138
randomFooterImageKey,
139139
requestInfo: {
140140
hints: getHints(request),

app/routes/action/refresh-cache.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@ import path from 'path'
22
import { data as json, redirect } from 'react-router';
33
import { cache } from '#app/utils/cache.server.ts'
44
import { getPeople } from '#app/utils/credits.server.ts'
5+
import { getEnv } from '#app/utils/env.server.ts'
56
import { ensurePrimary } from '#app/utils/litefs-js.server.ts'
67
import {
78
getBlogMdxListItems,
89
getMdxDirList,
910
getMdxPage,
1011
} from '#app/utils/mdx.server.ts'
11-
import { getRequiredServerEnvVar } from '#app/utils/misc.ts'
1212
import { getResumeData } from '#app/utils/resume.server.ts'
1313
import { getTalksAndTags } from '#app/utils/talks.server.ts'
1414
import { getTestimonials } from '#app/utils/testimonials.server.ts'
@@ -38,10 +38,7 @@ export const commitShaKey = 'meta:last-refresh-commit-sha'
3838

3939
export async function action({ request }: Route.ActionArgs) {
4040
await ensurePrimary()
41-
if (
42-
request.headers.get('auth') !==
43-
getRequiredServerEnvVar('REFRESH_CACHE_SECRET')
44-
) {
41+
if (request.headers.get('auth') !== getEnv().REFRESH_CACHE_SECRET) {
4542
return redirect('https://www.youtube.com/watch?v=dQw4w9WgXcQ')
4643
}
4744

app/routes/calls_.record/new.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { RecordingForm, type RecordingFormData } from '#app/routes/resources/cal
1010
import { CallKentTextToSpeech } from '#app/routes/resources/calls/text-to-speech.tsx'
1111
import { type KCDHandle } from '#app/types.ts'
1212
import { AI_VOICE_DISCLOSURE_PREFIX } from '#app/utils/call-kent-text-to-speech.ts'
13+
import { getEnv } from '#app/utils/env.server.ts'
1314
import { type SerializeFrom } from '#app/utils/serialize-from.ts'
1415
import { type Route } from './+types/new'
1516

@@ -20,7 +21,8 @@ export const handle: KCDHandle = {
2021
export async function loader({ request }: Route.LoaderArgs) {
2122
const url = new URL(request.url)
2223
const shouldUseSampleAudio =
23-
process.env.NODE_ENV === 'development' && url.searchParams.get('sampleAudio') === '1'
24+
getEnv().NODE_ENV === 'development' &&
25+
url.searchParams.get('sampleAudio') === '1'
2426
return json({ shouldUseSampleAudio } as const)
2527
}
2628

app/routes/oauth.authorize.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { data as json, redirect, Form } from 'react-router';
22
import { Button } from '#app/components/button.tsx'
3+
import { getEnv } from '#app/utils/env.server.ts'
34
import { requireUser } from '#app/utils/session.server.ts'
45
import { type Route } from './+types/oauth.authorize'
56

@@ -40,7 +41,7 @@ export async function action({ request }: Route.ActionArgs) {
4041
method: 'POST',
4142
headers: {
4243
'Content-Type': 'application/json',
43-
authorization: `Bearer ${process.env.CF_INTERNAL_SECRET}`,
44+
authorization: `Bearer ${getEnv().CF_INTERNAL_SECRET}`,
4445
},
4546
body: JSON.stringify({
4647
requestParams: {

0 commit comments

Comments
 (0)