Scaffold generic pnpm monorepo (api + web + shared) (#1)

* Scaffold generic pnpm monorepo (api + web + shared)

Sets up the initial project infrastructure only, no business modules yet:

- apps/api: Express/TypeScript backend skeleton (healthcheck route, zod-validated
  env config, error handling, Prisma initialized with no models yet, Postgres
  as the target DB)
- apps/web: React/Vite/TypeScript frontend skeleton, Capacitor-ready for the
  future mobile app
- packages/shared: empty placeholder for types/schemas shared between api and
  web once the data model is defined
- Tooling: Biome (lint/format), Mocha+Chai+Supertest (api tests), Cypress
  (web e2e smoke test), GitHub Actions CI (lint + test + build + e2e)
- docker-compose.yml for local Postgres
- README documents setup steps, including the Cypress binary caveat (pnpm
  install doesn't always fetch the native binary — needs `cypress install`
  run locally per machine)

Fixes along the way:
- apps/web/cypress.config.ts: disable GPU on browser launch for
  headless/sandboxed environments
- apps/web/tsconfig.*: split into solution/app/node tsconfig files (standard
  Vite pattern) — the previous single-file setup caused `tsc -b` to emit
  compiled .js/.d.ts next to vite.config.ts and cypress.config.ts

* Remove hardcoded credentials from committed env/compose files

.env.example and apps/api/.env.example had a real usable default
credential pair (batchcooking/batchcooking) baked in, and
docker-compose.yml fell back to the same values via ${VAR:-default}
if .env was missing. Neither should ship a working credential:

- .env.example / apps/api/.env.example now use "changeme" placeholders
  that must be edited before use.
- docker-compose.yml uses ${VAR:?...} instead of ${VAR:-default} for
  POSTGRES_USER/PASSWORD/DB, so compose fails loudly if .env isn't set
  up rather than silently falling back to a guessable credential.
  Healthcheck reads the container's own env var ($$POSTGRES_USER)
  instead of duplicating the value in the compose file.
- README updated to say .env.example must be edited, not just copied.

Verified: `docker compose config` fails with a clear message when
.env is absent, and resolves correctly once .env is filled in.

* Fix CI: remove pnpm version conflict with packageManager field

pnpm/action-setup@v4 errored with "Multiple versions of pnpm
specified" because the workflow pinned version: 10 while
package.json's packageManager field pins pnpm@10.12.4. The action
already reads packageManager automatically, so drop the redundant
version input.

* Fix CI: install Cypress binary explicitly before running e2e

Same root cause as the README caveat: pnpm install doesn't reliably
trigger Cypress's postinstall binary download, so `cypress run` failed
in CI with "The cypress npm package is installed, but the Cypress
binary is missing." Add an explicit `cypress install` step, and cache
~/.cache/Cypress keyed on the lockfile so subsequent runs don't
re-download it.

* Fix Cypress config loading: give the solution tsconfig a module system

apps/web/tsconfig.json (the tsc -b "solution" file) had no
compilerOptions, only files/references. Cypress's bundled ts-node
picks the nearest tsconfig.json to transpile cypress.config.ts, and
with no "module" specified it defaulted to CommonJS while
package.json declares "type": "module" — causing:

  ReferenceError: exports is not defined in ES module scope

Adding module/moduleResolution to the solution config (harmless for
tsc -b itself, since it only builds the referenced projects) fixes
the mismatch. This regressed after the earlier fix for the stray
vite.config.js emission and was never re-verified against Cypress
until CI caught it.
This commit is contained in:
kyuno053 2026-08-16 10:55:13 +02:00 committed by GitHub
parent 2f1e73b44a
commit 746100e257
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 4662 additions and 1 deletions

7
.env.example Normal file
View file

@ -0,0 +1,7 @@
# Used by docker-compose.yml to provision the local Postgres container.
# Pick your own values — do not reuse these across environments, and never
# commit the real .env (it's git-ignored).
POSTGRES_USER=changeme
POSTGRES_PASSWORD=changeme
POSTGRES_DB=batchcooking
POSTGRES_PORT=5432

51
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,51 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm --filter api test
- run: pnpm build
e2e:
runs-on: ubuntu-latest
needs: lint-and-test
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Cache Cypress binary
uses: actions/cache@v4
with:
path: ~/.cache/Cypress
key: cypress-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- run: pnpm install --frozen-lockfile
# pnpm install doesn't reliably trigger Cypress's postinstall binary
# download (see apps/web's cypress caveat in the README) — install it
# explicitly so `cypress run` finds it.
- run: pnpm --filter web exec cypress install
- run: pnpm --filter web e2e

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
22

View file

@ -1 +1,78 @@
# batchCooking
# batchCooking
## Structure
Monorepo pnpm workspaces :
- `apps/api` — backend Express/TypeScript (squelette générique : healthcheck, config env, Prisma non modélisé)
- `apps/web` — frontend React/Vite/TypeScript (squelette générique, prêt à être embarqué par Capacitor plus tard)
- `packages/shared` — code partagé entre `api` et `web` (types, schémas de validation, constantes) — vide pour l'instant
Aucun module métier n'est encore implémenté : cette base ne contient que l'outillage générique (lint/format, tests, CI, DB locale).
## Prérequis
- Node.js 22 (voir `.nvmrc`)
- pnpm 10 (`corepack enable` puis `corepack use pnpm@10.12.4`, ou installation manuelle)
- Docker (pour Postgres en local)
## Installation
```bash
pnpm install
cp .env.example .env
cp apps/api/.env.example apps/api/.env
```
Puis **édite ces deux `.env`** pour renseigner de vrais `POSTGRES_USER`/`POSTGRES_PASSWORD`
(et la `DATABASE_URL` correspondante dans `apps/api/.env`) : les fichiers `.env.example`
ne contiennent volontairement aucun identifiant réel (juste `changeme`), et
`docker-compose.yml` refuse de démarrer tant que `POSTGRES_USER`/`PASSWORD`/`DB` ne
sont pas définis dans `.env` — pas de valeur par défaut en dur dans les fichiers commités.
### Cypress : téléchargement du binaire
`pnpm install` installe le package `cypress` mais **pas forcément son binaire** (le
téléchargement du `.exe`/binaire natif peut être ignoré selon l'environnement où
`pnpm install` a été lancé — ex. un environnement sandboxé/CI dont le cache ne
correspond pas à celui de ta machine). Si `pnpm --filter web e2e` échoue avec une
erreur du type :
```
No version of Cypress is installed in: ...\AppData\Local\Cypress\Cache\...
Please reinstall Cypress by running: cypress install
```
lance simplement, depuis ta machine :
```bash
pnpm --filter web exec cypress install
```
(à faire une seule fois par machine ; le binaire est mis en cache localement,
hors du repo).
## Développement
```bash
# Base de données Postgres locale
docker compose up -d
# Backend (http://localhost:3000)
pnpm dev:api
# Frontend (http://localhost:5173)
pnpm dev:web
```
## Qualité / Tests
```bash
pnpm lint # Biome (lint + format check)
pnpm lint:fix # Biome --write
pnpm test # tests unitaires/intégration (Mocha, apps/api)
pnpm --filter web e2e # tests e2e (Cypress, démarre le serveur dev automatiquement)
pnpm build # build de tous les workspaces
```
La CI GitHub Actions (`.github/workflows/ci.yml`) exécute lint + tests + build sur chaque push/PR vers `main`, puis les tests e2e Cypress.

5
apps/api/.env.example Normal file
View file

@ -0,0 +1,5 @@
NODE_ENV=development
PORT=3000
# Match whatever you set in the root .env (POSTGRES_USER/PASSWORD/DB) —
# do not commit the real value.
DATABASE_URL="postgresql://changeme:changeme@localhost:5432/batchcooking?schema=public"

5
apps/api/.mocharc.json Normal file
View file

@ -0,0 +1,5 @@
{
"extension": ["ts"],
"spec": "test/**/*.test.ts",
"node-option": ["import=tsx"]
}

31
apps/api/package.json Normal file
View file

@ -0,0 +1,31 @@
{
"name": "api",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/server.js",
"test": "mocha",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev"
},
"dependencies": {
"@prisma/client": "^5.22.0",
"dotenv": "^16.4.5",
"express": "^4.21.1",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^22.9.0",
"@types/supertest": "^6.0.2",
"chai": "^5.1.2",
"mocha": "^10.8.2",
"prisma": "^5.22.0",
"supertest": "^7.0.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}

View file

@ -0,0 +1,10 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// Models will be added once the data model is specified.

25
apps/api/src/app.ts Normal file
View file

@ -0,0 +1,25 @@
import express, { type NextFunction, type Request, type Response } from "express";
// Application factory — no business modules yet, only generic infrastructure
// (health check, JSON parsing, 404 handler, error handler). Feature modules
// will be added once the data model / specs are defined.
export function createApp() {
const app = express();
app.use(express.json());
app.get("/health", (_req: Request, res: Response) => {
res.status(200).json({ status: "ok" });
});
app.use((_req: Request, res: Response) => {
res.status(404).json({ error: "Not found" });
});
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
console.error(err);
res.status(500).json({ error: "Internal server error" });
});
return app;
}

View file

@ -0,0 +1,10 @@
import "dotenv/config";
import { z } from "zod";
const envSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.coerce.number().int().positive().default(3000),
DATABASE_URL: z.string().url().optional(),
});
export const env = envSchema.parse(process.env);

8
apps/api/src/server.ts Normal file
View file

@ -0,0 +1,8 @@
import { createApp } from "./app.js";
import { env } from "./config/env.js";
const app = createApp();
app.listen(env.PORT, () => {
console.log(`API listening on http://localhost:${env.PORT}`);
});

View file

@ -0,0 +1,13 @@
import { expect } from "chai";
import request from "supertest";
import { createApp } from "../src/app.js";
describe("GET /health", () => {
it("returns 200 with status ok", async () => {
const app = createApp();
const res = await request(app).get("/health");
expect(res.status).to.equal(200);
expect(res.body).to.deep.equal({ status: "ok" });
});
});

11
apps/api/tsconfig.json Normal file
View file

@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src"]
}

View file

@ -0,0 +1,17 @@
import { defineConfig } from "cypress";
export default defineConfig({
e2e: {
baseUrl: "http://localhost:5173",
setupNodeEvents(on) {
// Disable GPU for headless/sandboxed environments (e.g. CI containers)
// where no GPU device is available.
on("before:browser:launch", (browser, launchOptions) => {
if (browser.family === "chromium") {
launchOptions.args.push("--disable-gpu", "--no-sandbox");
}
return launchOptions;
});
},
},
});

View file

@ -0,0 +1,6 @@
describe("smoke test", () => {
it("loads the app shell", () => {
cy.visit("/");
cy.contains("h1", "batchCooking").should("be.visible");
});
});

View file

@ -0,0 +1,3 @@
// Cypress support file — global config and custom commands will go here
// as features (and their e2e specs) get added.
export {};

12
apps/web/index.html Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>batchCooking</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

29
apps/web/package.json Normal file
View file

@ -0,0 +1,29 @@
{
"name": "web",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"test": "echo \"no unit tests yet\" && exit 0",
"cy:open": "cypress open",
"cy:run": "cypress run",
"e2e": "start-server-and-test dev http://localhost:5173 cy:run"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/node": "^22.9.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"cypress": "^13.15.2",
"start-server-and-test": "^2.0.8",
"typescript": "^5.7.2",
"vite": "^5.4.11"
}
}

8
apps/web/src/App.tsx Normal file
View file

@ -0,0 +1,8 @@
export function App() {
return (
<main>
<h1>batchCooking</h1>
<p>Setup initial les features arriveront une fois les specs définies.</p>
</main>
);
}

14
apps/web/src/main.tsx Normal file
View file

@ -0,0 +1,14 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
const rootElement = document.getElementById("root");
if (!rootElement) {
throw new Error("Root element not found");
}
createRoot(rootElement).render(
<StrictMode>
<App />
</StrictMode>,
);

1
apps/web/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"noEmit": true,
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
},
"include": ["src"]
}

8
apps/web/tsconfig.json Normal file
View file

@ -0,0 +1,8 @@
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler"
},
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}

View file

@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"composite": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
}

6
apps/web/vite.config.ts Normal file
View file

@ -0,0 +1,6 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react()],
});

41
biome.json Normal file
View file

@ -0,0 +1,41 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false,
"ignore": [
"dist",
"coverage",
"**/node_modules",
".pnpm-store",
"cypress/videos",
"cypress/screenshots"
]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"semicolons": "always",
"trailingCommas": "all"
}
}
}

23
docker-compose.yml Normal file
View file

@ -0,0 +1,23 @@
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
# No defaults on purpose: POSTGRES_USER/PASSWORD/DB must be set in your
# local, git-ignored .env (see .env.example). Compose fails loudly if
# they're missing instead of falling back to a guessable credential.
POSTGRES_USER: ${POSTGRES_USER:?set POSTGRES_USER in .env}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
POSTGRES_DB: ${POSTGRES_DB:?set POSTGRES_DB in .env}
ports:
- "${POSTGRES_PORT:-5432}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER"]
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres_data:

32
package.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "batch-cooking",
"private": true,
"type": "module",
"engines": {
"node": ">=22"
},
"packageManager": "pnpm@10.12.4",
"scripts": {
"lint": "biome check .",
"lint:fix": "biome check --write .",
"format": "biome format --write .",
"test": "pnpm -r test",
"build": "pnpm -r build",
"dev:api": "pnpm --filter api dev",
"dev:web": "pnpm --filter web dev"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"typescript": "^5.7.2"
},
"pnpm": {
"onlyBuiltDependencies": [
"@biomejs/biome",
"@prisma/client",
"@prisma/engines",
"cypress",
"esbuild",
"prisma"
]
}
}

View file

@ -0,0 +1,18 @@
{
"name": "@batch-cooking/shared",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"test": "echo \"no tests yet\" && exit 0",
"build": "echo \"no build step — consumed as TS source within the workspace\" && exit 0"
},
"devDependencies": {
"typescript": "^5.7.2"
}
}

View file

@ -0,0 +1,5 @@
// Point d'entrée du code partagé entre apps/api et apps/web.
// Types, schémas de validation (zod) et constantes communes seront ajoutés
// ici une fois le modèle de données défini.
export {};

View file

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true
},
"include": ["src"]
}

4133
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

3
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,3 @@
packages:
- "apps/*"
- "packages/*"

14
tsconfig.base.json Normal file
View file

@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noUncheckedIndexedAccess": true
}
}