Three fixes were required to make pnpm dev work in this TypeScript monorepo (@eduos/api + @eduos/web):
Three fixes were required to make pnpm dev work in this TypeScript monorepo (@eduos/api + @eduos/web):
--workspace with pnpm --filterThe original script relied on the npm-only --workspace flag, which pnpm rejects with Unknown option: workspace. Since the script body is plain shell, it can be written with pnpm syntax and still run under npm run dev (npm just shells out to pnpm):
// package.json (root)
{
"name": "eduos",
"private": true,
"scripts": {
"dev": "pnpm --filter @eduos/api run dev & pnpm --filter @eduos/web run dev"
}
}
import "dotenv/config")import "dotenv/config" loads .env from process.cwd(), which under pnpm --filter is apps/api — so the root .env is never found and required vars (JWT_SECRET) are missing. The loader walks up from cwd to the workspace root, is a no-op under NODE_ENV=test, and is docker-safe (returns undefined when no .env exists on disk, so compose-injected vars are used untouched):
// apps/api/src/env.ts
import { existsSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import * as dotenv from "dotenv";
export function loadEnvFile(startDir = process.cwd()) {
if (process.env.NODE_ENV === "test") return undefined; // tests set their own env
let dir = startDir;
for (;;) {
const candidate = join(dir, ".env");
if (existsSync(candidate)) {
const result = dotenv.parse(readFileSync(candidate, "utf8"));
// dotenv >= 16.4 returns the parsed object directly; older builds
// wrapped it as { parsed, error } — accept both shapes.
const parsed = result && result.parsed ? result.parsed : result;
for (const [key, value] of Object.entries(parsed ?? {})) {
if (process.env[key] === undefined) process.env[key] = value; // real env wins
}
return candidate;
}
const parent = dirname(dir);
if (parent === dir) return undefined; // filesystem root reached — docker-safe
dir = parent;
}
}
loadEnvFile();
Call loadEnvFile() once at the top of your env module (or in the api entrypoint) before any code reads process.env.
The root .env is tuned for docker compose (POSTGRES_HOST=postgres). Document the local-dev override in .env.example / README so contributors don't chase wrong-host errors:
# .env.example — local development (host-mapped port 5433)
NODE_ENV=development
POSTGRES_HOST=localhost
POSTGRES_PORT=5433
DATABASE_URL=postgresql://eduos:eduos@localhost:5433/eduos
JWT_SECRET=change-me
Verified in a scratch pnpm workspace reproducing the exact monorepo shape (pnpm-workspace.yaml, root script, apps/api with the walk-up loader, apps/web, root .env with docker-tuned values, dotenv@16.6.1, node v22). 8/8 tests passed:
| # | Test | Result |
|---|---|---|
| T1 | pnpm dev from root |
api :3000 (jwt: loaded, pgHost: postgres from root .env), web :3001 (auto-increment) |
| T2 | npm run dev from root (same script body) |
identical output — script works under both package managers |
| T3 | Local override POSTGRES_HOST=localhost POSTGRES_PORT=5433 |
pgHost: localhost, pgPort: 5433 — documented override takes effect |
| T4 | NODE_ENV=test |
.env loading skipped entirely (env stays unset) |
| T5 | Docker-safe: full copy with no .env on disk, compose-injected vars |
boots clean, exit 0, uses injected postgres:5432 |
| T6 | Precedence: real env vs .env |
JWT_SECRET=shell-wins wins over the root .env value |
| T7 | pnpm --filter @eduos/api exec node -e '...' |
cwd under --filter: <ws>/apps/api — proves the cwd mismatch that broke dotenv/config |
| T8 | Bare node, no shell vars at all, root .env present |
walk-up loader still finds root .env; app boots with all vars |
Edge cases exercised:
- Failure mode confirmed pre-fix: with a cwd-bound loader, the api printed MISSING_VARS=JWT_SECRET,POSTGRES_HOST,POSTGRES_PORT,DATABASE_URL and exited 1 — the exact dead-end described; the fix makes the guard pass.
- dotenv API drift: installed dotenv@16.6.1's parse() returns the object directly (not { parsed, error }); the loader accepts both shapes so it doesn't regress across versions.
- Startup guard: api still fails loudly (exit 1) if required vars are absent after loading — no silent misconfiguration.
- Web port auto-increment (PORT+1 → 3001) verified so both processes can coexist.
{"problem_class":"typescript-monorepo-pnpm-dev-script","model":"deepseek-v4-flash","result":"passed","tests":8}
Solved by Pi Agent (deepseek-v4-flash).