Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -26,20 +26,23 @@ BOT_GITHUB_TOKEN=1a2b3c4d5e6f7g8g9i0j

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

SESSION_SECRET=anything_works_here
MAGIC_LINK_SECRET=whatever_stuff

# Feature: basically everything
# Mocked: No, must run sqlite locally
DATABASE_FILENAME="sqlite.db"
DATABASE_PATH=./prisma/sqlite.db
DATABASE_URL="file:./prisma/sqlite.db"
CACHE_DATABASE_PATH="other/cache.db"
LITEFS_DIR="./prisma"
PORT=3000

# Feature: Fly
# Mocked: No
FLY_APP_NAME=kcd
FLY_REGION="dfw"
FLY_INSTANCE="123456"
INTERNAL_COMMAND_TOKEN="something_random"
Expand Down Expand Up @@ -121,8 +124,7 @@ R2_SECRET_ACCESS_KEY=MOCK_R2_SECRET_ACCESS_KEY
SEMANTIC_SEARCH_IGNORE_LIST_KEY=manifests/ignore-list.json

# Feature: YouTube playlist semantic indexing + /youtube route
# Optional: set either playlist URL or playlist ID.
YOUTUBE_PLAYLIST_URL=https://www.youtube.com/watch?v=wSEUlS8WcQs&list=PLV5CVI1eNcJgNqzNwcs4UKrlJdhfDjshf
# Optional
YOUTUBE_PLAYLIST_ID=PLV5CVI1eNcJgNqzNwcs4UKrlJdhfDjshf
# Optional: helps bypass YouTube anti-bot checks for captions in some envs.
YOUTUBE_COOKIE=MOCK_YOUTUBE_COOKIE
Expand Down
5 changes: 2 additions & 3 deletions .github/workflows/index-semantic-youtube.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,8 @@ jobs:
${{ secrets.CLOUDFLARE_AI_EMBEDDING_MODEL }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}

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

# Optional: force-index specific videos. Can be set either as a
Expand Down
59 changes: 16 additions & 43 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,50 +1,23 @@
# AGENTS.md

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

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

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

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

### Prerequisites
- `docs/agents/project-context.md` (setup, commands, project-specific caveats)
- `docs/agents/code-style.md`
- `docs/agents/bugfix-workflow.md`
- `docs/agents/testing-principles.md`

- **Node.js 24** is required (`engines` field in `package.json`). Install via
`nvm install 24 && nvm alias default 24`.
- The `.npmrc` sets `legacy-peer-deps=true`; `npm install` respects this
automatically.

### Key commands

Standard dev commands are documented in `README.md` and `CONTRIBUTING.md`. Quick
reference:

| Task | Command |
| --------------- | --------------------------------------------------------------------------------- |
| Dev server | `npm run dev` (starts on port 3000 with `MOCKS=true`) |
| Lint | `npm run lint` |
| Typecheck | `npm run typecheck` |
| Unit tests | `npm run test -- --watch=false` |
| E2E tests | `npm run test:e2e:dev` (requires Playwright browsers: `npm run test:e2e:install`) |
| DB reset + seed | `npx prisma@7 migrate reset --force` then `npx tsx other/runfile prisma/seed.ts` |

### Non-obvious caveats

- **Dev server is not a TTY**: `server/dev-server.js` detects non-TTY and
disables keyboard shortcuts. This is expected in cloud agent terminals.
- **All external APIs are mocked** via MSW when `MOCKS=true` (the default in
dev). No real API keys are needed for local development — the `.env.example`
values are sufficient.
- **SQLite is file-based**: The database file lives at `prisma/sqlite.db`. No
external database server is required.
- **Cache database**: A separate SQLite cache DB is created at `other/cache.db`.
It's populated on first request or via `npm run prime-cache:mocks`.
- **Patch-package**: Three Remix packages are patched during `postinstall`
(patches in `other/patches/`). If you see patch errors after dependency
changes, check those patches.
- **Content is filesystem-based**: Blog posts are MDX files in `content/blog/`.
Changes to content files are auto-detected by the dev server's file watcher.
If you discover a new sharp edge, workflow, or non-obvious project behavior,
update the relevant doc(s) in `docs/agents/` so future agent runs are faster and
more correct. Keep callouts organized under clear headings and prefer concise,
project-specific guidance over generic advice.
2 changes: 1 addition & 1 deletion app/components/errors.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ function ErrorPage({
</div>
</noscript>
<main className="relative">
{error && process.env.NODE_ENV === 'development' ? (
{error && import.meta.env.MODE === 'development' ? (
<RedBox error={error} />
) : null}
<HeroSection {...heroProps} />
Expand Down
9 changes: 5 additions & 4 deletions app/entry.server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ import { ServerRouter,
type HandleDocumentRequestFunction } from 'react-router';
import { ensurePrimary } from '#app/utils/litefs-js.server.ts'
import { routes as otherRoutes } from './other-routes.server.ts'
import { getEnv, init } from './utils/env.server.ts'
import { getEnv, getPublicEnv, init } from './utils/env.server.ts'
import { NonceProvider } from './utils/nonce-provider.ts'

init()
global.ENV = getEnv()
global.ENV = getPublicEnv()

const ABORT_DELAY = 5000

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

if (process.env.NODE_ENV !== 'production') {
if (env.NODE_ENV !== 'production') {
responseHeaders.set('Cache-Control', 'no-store')
}

if (process.env.NODE_ENV === 'production' && process.env.SENTRY_DSN) {
if (env.NODE_ENV === 'production' && env.SENTRY_DSN) {
responseHeaders.append('Document-Policy', 'js-profiling')
}

Expand Down
33 changes: 22 additions & 11 deletions app/kit/kit.server.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { getRequiredServerEnvVar } from '../utils/misc.ts'
import { getEnv } from '../utils/env.server.ts'

const KIT_API_SECRET = getRequiredServerEnvVar('KIT_API_SECRET')
const KIT_API_KEY = getRequiredServerEnvVar('KIT_API_KEY')
function getKitAuth() {
const env = getEnv()
return { apiSecret: env.KIT_API_SECRET, apiKey: env.KIT_API_KEY }
}

type KitSubscriber = {
id: number
Expand All @@ -19,8 +21,11 @@ type KitTag = {
}

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

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

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

const resp = await fetch(url.toString())
const json = (await resp.json()) as {
Expand Down Expand Up @@ -73,9 +81,10 @@ async function addSubscriberToForm({
firstName: string
kitFormId: string
}) {
const { apiKey, apiSecret } = getKitAuth()
const subscriberData = {
api_key: KIT_API_KEY,
api_secret: KIT_API_SECRET,
api_key: apiKey,
api_secret: apiSecret,
first_name: firstName,
email,
}
Expand Down Expand Up @@ -106,9 +115,10 @@ async function addTagToSubscriber({
kitTagId: string
}) {
await ensureSubscriber({ email, firstName })
const { apiKey, apiSecret } = getKitAuth()
const subscriberData = {
api_key: KIT_API_KEY,
api_secret: KIT_API_SECRET,
api_key: apiKey,
api_secret: apiSecret,
first_name: firstName,
email,
}
Expand Down Expand Up @@ -139,9 +149,10 @@ async function tagKCDSiteSubscriber({
const subscriber = await getKitSubscriber(email)
const kcdTagId = '2466369'
const kcdSiteForm = '2393887'
const { apiKey, apiSecret } = getKitAuth()
const subscriberData = {
api_key: KIT_API_KEY,
api_secret: KIT_API_SECRET,
api_key: apiKey,
api_secret: apiSecret,
first_name: firstName,
email,
fields,
Expand Down
4 changes: 2 additions & 2 deletions app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import tailwindStyles from './styles/tailwind.css?url'
import vendorStyles from './styles/vendors.css?url'
import { ClientHintCheck, getHints } from './utils/client-hints.tsx'
import { getClientSession } from './utils/client.server.ts'
import { getEnv } from './utils/env.server.ts'
import { getPublicEnv } from './utils/env.server.ts'
import { getLoginInfoSession } from './utils/login.server.ts'
import { useNonce } from './utils/nonce-provider.ts'
import { getLatestPodcastSeasonLinks } from './utils/podcast-latest-season.server.ts'
Expand Down Expand Up @@ -134,7 +134,7 @@ export async function loader({ request }: Route.LoaderArgs) {
user,
userInfo: user ? await getUserInfo(user, { request, timings }) : null,
latestPodcastSeasonLinks,
ENV: getEnv(),
ENV: getPublicEnv(),
randomFooterImageKey,
requestInfo: {
hints: getHints(request),
Expand Down
7 changes: 2 additions & 5 deletions app/routes/action/refresh-cache.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ import path from 'path'
import { data as json, redirect } from 'react-router';
import { cache } from '#app/utils/cache.server.ts'
import { getPeople } from '#app/utils/credits.server.ts'
import { getEnv } from '#app/utils/env.server.ts'
import { ensurePrimary } from '#app/utils/litefs-js.server.ts'
import {
getBlogMdxListItems,
getMdxDirList,
getMdxPage,
} from '#app/utils/mdx.server.ts'
import { getRequiredServerEnvVar } from '#app/utils/misc.ts'
import { getResumeData } from '#app/utils/resume.server.ts'
import { getTalksAndTags } from '#app/utils/talks.server.ts'
import { getTestimonials } from '#app/utils/testimonials.server.ts'
Expand Down Expand Up @@ -38,10 +38,7 @@ export const commitShaKey = 'meta:last-refresh-commit-sha'

export async function action({ request }: Route.ActionArgs) {
await ensurePrimary()
if (
request.headers.get('auth') !==
getRequiredServerEnvVar('REFRESH_CACHE_SECRET')
) {
if (request.headers.get('auth') !== getEnv().REFRESH_CACHE_SECRET) {
return redirect('https://www.youtube.com/watch?v=dQw4w9WgXcQ')
}

Expand Down
4 changes: 3 additions & 1 deletion app/routes/calls_.record/new.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { RecordingForm, type RecordingFormData } from '#app/routes/resources/cal
import { CallKentTextToSpeech } from '#app/routes/resources/calls/text-to-speech.tsx'
import { type KCDHandle } from '#app/types.ts'
import { AI_VOICE_DISCLOSURE_PREFIX } from '#app/utils/call-kent-text-to-speech.ts'
import { getEnv } from '#app/utils/env.server.ts'
import { type SerializeFrom } from '#app/utils/serialize-from.ts'
import { type Route } from './+types/new'

Expand All @@ -20,7 +21,8 @@ export const handle: KCDHandle = {
export async function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url)
const shouldUseSampleAudio =
process.env.NODE_ENV === 'development' && url.searchParams.get('sampleAudio') === '1'
getEnv().NODE_ENV === 'development' &&
url.searchParams.get('sampleAudio') === '1'
return json({ shouldUseSampleAudio } as const)
}

Expand Down
3 changes: 2 additions & 1 deletion app/routes/oauth.authorize.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { data as json, redirect, Form } from 'react-router';
import { Button } from '#app/components/button.tsx'
import { getEnv } from '#app/utils/env.server.ts'
import { requireUser } from '#app/utils/session.server.ts'
import { type Route } from './+types/oauth.authorize'

Expand Down Expand Up @@ -40,7 +41,7 @@ export async function action({ request }: Route.ActionArgs) {
method: 'POST',
headers: {
'Content-Type': 'application/json',
authorization: `Bearer ${process.env.CF_INTERNAL_SECRET}`,
authorization: `Bearer ${getEnv().CF_INTERNAL_SECRET}`,
},
body: JSON.stringify({
requestParams: {
Expand Down
6 changes: 3 additions & 3 deletions app/routes/resources/cache.sqlite.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { data as json, redirect } from 'react-router';
import { serverOnly$ } from 'vite-env-only/macros'
import { cache } from '#app/utils/cache.server.ts'
import { getEnv } from '#app/utils/env.server.ts'
import {
getInstanceInfo,
getInternalInstanceDomain,
} from '#app/utils/litefs-js.server.ts'
import { getRequiredServerEnvVar } from '#app/utils/misc.ts'
import { type Route } from './+types/cache.sqlite'

export async function action({ request }: Route.ActionArgs) {
Expand All @@ -15,7 +15,7 @@ export async function action({ request }: Route.ActionArgs) {
`${request.url} should only be called on the primary instance (${primaryInstance})}`,
)
}
const token = getRequiredServerEnvVar('INTERNAL_COMMAND_TOKEN')
const token = getEnv().INTERNAL_COMMAND_TOKEN
const isAuthorized =
request.headers.get('Authorization') === `Bearer ${token}`
if (!isAuthorized) {
Expand Down Expand Up @@ -45,7 +45,7 @@ export const updatePrimaryCacheValue = serverOnly$(
)
}
const domain = getInternalInstanceDomain(primaryInstance)
const token = getRequiredServerEnvVar('INTERNAL_COMMAND_TOKEN')
const token = getEnv().INTERNAL_COMMAND_TOKEN
return fetch(`${domain}/resources/cache/sqlite`, {
method: 'POST',
headers: {
Expand Down
9 changes: 6 additions & 3 deletions app/routes/resources/calls/save.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -496,13 +496,15 @@ async function createCall({
try {
const [
{ sendMessageFromDiscordBot },
{ getDomainUrl, getOptionalTeam, getRequiredServerEnvVar },
{ getDomainUrl, getOptionalTeam },
{ getEnv },
{ prisma },
{ requireUser },
{ teamEmoji },
] = await Promise.all([
import('#app/utils/discord.server.ts'),
import('#app/utils/misc.ts'),
import('#app/utils/env.server.ts'),
import('#app/utils/prisma.server.ts'),
import('#app/utils/session.server.ts'),
import('#app/utils/team-provider.tsx'),
Expand All @@ -527,8 +529,9 @@ async function createCall({
})

try {
const channelId = getRequiredServerEnvVar('DISCORD_PRIVATE_BOT_CHANNEL')
const adminUserId = getRequiredServerEnvVar('DISCORD_ADMIN_USER_ID')
const env = getEnv()
const channelId = env.DISCORD_PRIVATE_BOT_CHANNEL
const adminUserId = env.DISCORD_ADMIN_USER_ID
const { firstName, team, discordId } = user
const userMention = discordId ? `<@!${discordId}>` : firstName
const emoji = teamEmoji[getOptionalTeam(team)]
Expand Down
Loading