diff --git a/README.md b/README.md
index 95182e1..6b7c191 100644
--- a/README.md
+++ b/README.md
@@ -1,238 +1,308 @@
-
+
***
-
+
- Table des Matières
+ 📋 Table of Contents
- - À propos du projet
- - Technologies Utilisées
- - Pré-requis et Installation
- - Documentation Complémentaire
- - Licence
- - Contact
+ - About The Project
+ - Tech Stack
+ - Prerequisites
+ - Installation
+ - Docker Services
+ - Useful Commands
+ - Development
+ - Additional Documentation
+ - License
+ - Contact
-
-
+***
-# À propos du Projet
+## 🔍 About The Project
-DropIt est une application web et mobile conçue pour optimiser le suivi et la gestion de l'entraînement en haltérophilie. Ce projet de fin d'études vise à fournir une solution intuitive pour les coachs et athlètes, tout en démontrant la maîtrise du cycle complet de développement logiciel.
+DropIt is a web and mobile application designed to optimize training tracking and management for weightlifting.
-**Fonctionnalités principales** : Gestion des athlètes, création de programmes d'entraînement personnalisés, bibliothèque d'exercices, planification des séances, et application mobile pour le suivi des performances.
+**Main Features**: Athlete management, personalized training program creation, exercise library, session planning, and mobile app for performance tracking.
-Pour découvrir l'ensemble des fonctionnalités en détail, consultez la [landing page](https://docs-dropit.pages.dev/) et la [documentation technique](https://docs-dropit.pages.dev/introduction/presentation/).
+To discover all features in detail, check out the [landing page](https://docs-dropit.pages.dev/) and the [technical documentation](https://docs-dropit.pages.dev/introduction/presentation/). (Documentation is in French, as this project started as a school study project)
-(retour en haut)
+(back to top)
***
-
-
-
-## Technologies Utilisées
+## 🛠️ Tech Stack
-- Front-End : React, TypeScript, TanStack (Query + Router), Shadcn + Tailwind
-- Back-End : Nest.js + MikroORM
-- Base de Données : PostgreSQL
-- Recherche: Typesense (à venir)
-- Cache: Redis (à venir)
-- CI/CD : Docker, Docker Compose, GitHub Actions
-- Qualité du Code : Biome
-- Monorepo: Pnpm workspaces
+- **Frontend**: React, TypeScript, TanStack (Query + Router), Shadcn/ui + Tailwind CSS
+- **Backend**: NestJS + MikroORM
+- **Database**: PostgreSQL
+- **Authentication**: Better-auth with organization plugin
+- **CI/CD**: Docker, Docker Compose, GitHub Actions
+- **Code Quality**: Biome
+- **Monorepo**: pnpm workspaces
-(retour en haut)
+(back to top)
***
-
-
-
-## Pré-requis et Installation
+## 📋 Prerequisites
-### Prérequis
+- **Node.js**: Version 22 or higher (required for better-auth and ESM support)
+- **pnpm**: Package manager version 9.7.1+ (install with `npm install -g pnpm@latest`)
+- **Docker** and **Docker Compose**: For running services (PostgreSQL, PgAdmin, MailDev)
+ - **Windows/macOS**: Docker Desktop must be installed and **running** before executing Docker commands
+ - **Linux**: Docker Engine and Docker Compose are sufficient
-Assurez-vous d'avoir installé les éléments suivants avant de commencer :
+## 🚀 Installation
-- **Node.js** : Version 22 ou supérieure (requis pour better-auth et support ESM).
-- **pnpm** : Gestionnaire de paquets version 9.7.1+ (installer avec `npm install -g pnpm@latest`).
-- **Docker** et **Docker Compose** : Pour l'exécution des services (Redis, PostgreSQL, PgAdmin).
- - **Windows/macOS** : Docker Desktop doit être installé et **lancé** avant d'exécuter les commandes Docker.
- - **Linux** : Docker Engine et Docker Compose suffisent.
-
-### Cloner le projet
+### 1. Clone the project
```bash
git clone https://github.com/Netsbump/dropit.git
cd dropit
```
-### Installer les dépendances
+### 2. Install dependencies
```bash
pnpm install
```
-### Build initial
+### 3. Initial build
-Pour permettre aux packages dans packages/ d'être utilisés dans les différents services, vous devez effectuer un build initial :
+To allow packages in `packages/` to be used by the different services, you need to perform an initial build:
```bash
pnpm build
```
-### Configuration des variables d'environnements
+### 4. Environment Setup
+
+#### Automated Setup (Recommended)
-Créer les fichiers de configuration :
+The project includes an automated setup script that will:
+- Detect and copy `.env.example` files to `.env` (if they don't exist)
+- Check for existing `.env` files and only prompt for missing variables
+- Prompt you for database configuration (user, password, name, host, port)
+- Prompt you for application ports (API, MailDev, PgAdmin)
+- Detect your local IP address for the mobile app
+- Generate a secure `BETTER_AUTH_SECRET` automatically
+- Configure all `.env` files with the correct values
+- Optionally start Docker services (PostgreSQL, MailDev, PgAdmin)
+- Optionally run database migrations or set up a fresh database with seed data
```bash
-# Fichier .env à la racine (pour le monorepo)
+pnpm rock
+```
+
+The script will guide you through the configuration process interactively. After completion, you'll see a summary of all configured services and their URLs.
+
+#### Manual Setup (Alternative)
+
+If you prefer to configure everything manually:
+
+1. **Copy environment files:**
+
+```bash
+# Root .env file (for Docker Compose)
cp .env.example .env
-# Fichier .env pour l'API
+# API .env file
cp apps/api/.env.example apps/api/.env
-# Fichier .env pour le frontend web (configuration de l'URL API)
+# Web frontend .env file (API URL configuration)
cp apps/web/.env.example apps/web/.env
-# Fichier .env pour l'application mobile (configuration de l'URL API)
+# Mobile app .env file (API URL configuration with local IP)
cp apps/mobile/.env.example apps/mobile/.env
-# Pour builder l'app mobile en production, créer également :
+# For production mobile build, also create:
cp apps/mobile/.env.example apps/mobile/.env.production
-# Puis éditer .env.production avec l'URL publique de votre API
+# Then edit .env.production with your public API URL
```
-### Lancer le projet (développement)
+2. **Configure environment variables:**
+
+Edit each `.env` file and update the values according to your environment. Key variables to configure:
-Démarrer les services via Docker Compose (PostgreSQL, PgAdmin):
+- **Database**: `DB_USER`, `DB_PASSWORD`, `DB_NAME`, `DB_PORT`, `DB_HOST`
+- **API**: `API_PORT`, `BETTER_AUTH_SECRET`, `TRUSTED_ORIGINS`
+- **Mobile**: `EXPO_PUBLIC_API_URL` (use your local IP, e.g., `http://192.168.1.XXX:3000`)
+
+⚠️ Make sure to update the API URL and port in all `.env` files to match your configuration.
+
+3. **Start Docker services:**
```bash
-docker-compose up -d
+docker compose up -d
```
-Vérifier que les services sont bien démarrés :
+4. **Set up the database:**
+
+Wait a few seconds for PostgreSQL to fully start, then run migrations or create a fresh database:
```bash
-docker-compose ps
+# Option 1: Run migrations
+pnpm --filter api db:migration:up
+
+# Option 2: Fresh database with seed data
+pnpm db:fresh
```
-Attendre quelques secondes que PostgreSQL soit complètement démarré, puis lancer le monorepo (backend + frontends) en mode développement:
+### 5. Start development
```bash
pnpm dev
```
-Les services seront accessibles aux URLs suivantes :
-- **Frontend Web** : http://localhost:5173
-- **API** : http://localhost:3000
-- **Documentation API (Swagger)** : http://localhost:3000/api
-- **PgAdmin** : http://localhost:5050
-- **Application Mobile** : Un QR code s'affichera dans le terminal pour Expo Go
+The services will be available at the following URLs:
+- **Web Frontend**: http://localhost:5173
+- **API**: http://localhost:3000
+- **API Documentation (Swagger)**: http://localhost:3000/api
+- **PgAdmin**: http://localhost:5050
+- **MailDev**: http://localhost:1080
+- **Mobile App**: A QR code will appear in the terminal for Expo Go
-### Migrations de base de données
+## 🐳 Docker Services
-Les migrations sont appliquées automatiquement au démarrage de l'API. Pour plus de détails sur la gestion des migrations (création, application manuelle, etc.), consultez le [README de l'API](apps/api/README.md#database-migrations).
+The project uses Docker Compose to provide the following services:
-### Données de test (Seeds)
+- **PostgreSQL**: Database server
+- **PgAdmin**: PostgreSQL administration tool
+- **MailDev**: SMTP server for development (not for production use!)
-Lors du premier lancement de l'application, des données de test sont automatiquement créées dans la base de données, incluant :
-- Un super admin (Super Admin - super.admin@gmail.com)
-- Un coach pour tester l'interface web (Jean Dupont - coach@example.com)
-- Un club par défaut
-- Des utilisateurs/athlètes générés avec Faker (15-25 athlètes)
+## ⌨️ Useful Commands
-### Connexion à l'interface Web
+### Setup
-Pour tester l'interface web, vous pouvez vous connecter avec le coach :
-- **Email** : `coach@example.com`
-- **Mot de passe** : `Password123!`
+- **Automated setup**: `pnpm rock`
-### Application Mobile (React Native)
+### Docker
-Une application mobile est disponible dans `apps/mobile/`. Elle se lance automatiquement avec `pnpm dev` (qui lance toutes les apps en parallèle). Pour la tester :
+- **Start Docker services**: `docker compose up -d`
+- **Stop Docker services**: `docker compose down`
+- **View Docker logs**: `docker compose logs -f`
-1. Installez Expo Go sur votre téléphone
-2. Scannez le QR code affiché dans le terminal (l'app mobile démarre avec `pnpm dev`, si le QR code ne s'affiche pas en `pnpm dev` global, lancer la commande au niveau dossier `apps/mobile/`)
+### Development
-Pour vous connecter, utilisez l'un des utilisateurs générés par les seeds. Les noms et emails étant générés par Faker, consultez directement la base de données via PgAdmin pour récupérer les identifiants.
+- **Start all apps**: `pnpm dev`
+- **Start web + API only**: `pnpm dev:web-api`
+- **Start mobile app only**: `pnpm dev:mobile`
+- **Build applications**: `pnpm build`
+- **Type checking**: `pnpm typecheck`
+- **Lint code**: `pnpm lint`
+- **Fix linting issues**: `pnpm lint:fix`
+- **Format code**: `pnpm format`
-**Accès PgAdmin** :
-- URL : http://localhost:5050
-- Email : `admin@admin.com`
-- Mot de passe : `admin`
-- Mot de passe universel pour tous les utilisateurs seeds : `Password123!`
+### Database (API)
-(retour en haut)
+- **Fresh database with seeds**: `pnpm db:fresh`
+- **Run seeds only**: `pnpm db:seed`
+- **Create migration**: `pnpm --filter api db:migration:create`
+- **Run migrations**: `pnpm --filter api db:migration:up`
+- **Rollback last migration**: `pnpm --filter api db:migration:down`
+- **Check pending migrations**: `pnpm --filter api db:migration:check`
-***
+### Tests
-
-
+- **Run API unit tests**: `pnpm test:api:unit`
+- **Run API integration tests**: `pnpm test:api:integration`
-## Documentation Complémentaire
+## 💻 Development
-Pour approfondir certains aspects techniques du projet, consultez les guides suivants :
+### Database Migrations
-### Déploiement et Infrastructure
-- **[Guide de Déploiement](docs/deployment.md)** : Configuration complète de l'infrastructure de production (VPS, Dokploy, Traefik, Docker Swarm)
-- **[Plan de Récupération d'Urgence](docs/emergency-recovery.md)** : Procédures de restauration en cas de défaillance majeure
+Migrations are automatically applied when the API starts. For more details on migration management (creation, manual application, etc.), see the [API README](apps/api/README.md#database-migrations).
-### Gestion de Base de Données
-- **[Guide des Migrations en Production](docs/migrations-production.md)** : Stratégies et bonnes pratiques pour gérer les migrations avec de vraies données utilisateur
+### Test Data (Seeds)
-(retour en haut)
+On first launch, test data is automatically created in the database, including:
+- A super admin (Super Admin - super.admin@gmail.com)
+- A coach to test the web interface (Jean Dupont - coach@example.com)
+- A default club
+- Generated users/athletes with Faker (15-25 athletes)
-***
+### Web Interface Login
+
+To test the web interface, you can log in with the coach account:
+- **Email**: `coach@example.com`
+- **Password**: `Password123!`
+
+### Mobile Application (React Native)
+
+A mobile application is available in `apps/mobile/`. It starts automatically with `pnpm dev` (which launches all apps in parallel). To test it:
+
+1. Install Expo Go on your phone
+2. Scan the QR code displayed in the terminal (the mobile app starts with `pnpm dev`; if the QR code doesn't appear, run the command from the `apps/mobile/` folder)
+
+To log in, use one of the users generated by the seeds. Since names and emails are generated by Faker, check the database directly via PgAdmin to retrieve credentials.
+
+**PgAdmin Access**:
+- URL: http://localhost:5050
+- Email: `admin@admin.com`
+- Password: `admin`
+- Universal password for all seeded users: `Password123!`
-
-
+(back to top)
-## Licence
+***
+
+## 📚 Additional Documentation
-Distribué sous la GNU Affero General Public License v3.0 (AGPL-3.0).
+For deeper technical aspects of the project, check out the following guides:
-**Ce logiciel est libre et open source**, mais avec une protection forte contre l'appropriation commerciale :
-- ✅ Vous pouvez librement utiliser, modifier et redistribuer ce logiciel
-- ✅ Tout fork doit rester open source sous AGPL-3.0
-- ✅ Les modifications sur un serveur web doivent être partagées publiquement
+### Deployment and Infrastructure
+- **[Deployment Guide](docs/deployment.md)**: Complete production infrastructure configuration (VPS, Dokploy, Traefik, Docker Swarm) *(in French)*
+- **[Emergency Recovery Plan](docs/emergency-recovery.md)**: Recovery procedures in case of major failure *(in French)*
-Voir le fichier [LICENSE.md](LICENSE.md) pour le texte complet de la licence.
+### Database Management
+- **[Production Migration Guide](docs/migrations-production.md)**: Strategies and best practices for managing migrations with real user data *(in French)*
-(retour en haut)
+(back to top)
***
-
-
+## 📄 License
+
+Distributed under the GNU Affero General Public License v3.0 (AGPL-3.0).
+
+**This software is free and open source**, but with strong protection against commercial appropriation:
+- ✅ You can freely use, modify, and redistribute this software
+- ✅ Any fork must remain open source under AGPL-3.0
+- ✅ Modifications on a web server must be shared publicly
+
+See the [LICENSE.md](LICENSE.md) file for the full license text.
+
+(back to top)
+
+***
-## Contact
+## 📧 Contact
-**LinkedIn** : [Sten Levasseur](https://www.linkedin.com/in/sten-levasseur/)
+**LinkedIn**: [Sten Levasseur](https://www.linkedin.com/in/sten-levasseur/)
-(retour en haut)
+(back to top)
diff --git a/cli/setup.ts b/cli/setup.ts
index 2bf93cd..a7ae768 100644
--- a/cli/setup.ts
+++ b/cli/setup.ts
@@ -1,11 +1,726 @@
-//créer un fichier de setup qui va lancer automatiquement un certain nombre de commande pour preparer le projet
-// Prompt you for database configuration (user, password, name, host, port)
-// Prompt you for application ports
-// Configure SMTP settings (MailDev)
-// Copy and configure all .env files automatically
-// Check for existing .env files and only prompt for missing variables
-// Automatically update all .env files with your configuration
- // Set up proper API URLs and trusted origins across all applications
-// Optionally start Docker services (database, MailDev)
-// Optionally run database migrations or dbfresh // 1. verifier si y a des .env
+import { randomBytes } from 'node:crypto'
+import { spawn } from 'node:child_process'
+import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+import { networkInterfaces } from 'node:os'
+import process from 'node:process'
+import { fileURLToPath } from 'node:url'
+import Enquirer from 'enquirer'
+const { Input, Confirm, Select } = Enquirer as unknown as {
+ Input: new (options: { message: string; initial?: string }) => { run: () => Promise }
+ Confirm: new (options: { name: string; message: string; initial?: boolean }) => {
+ run: () => Promise
+ }
+ Select: new (options: { name: string; message: string; choices: string[]; initial?: number }) => {
+ run: () => Promise
+ }
+}
+
+const __filename = fileURLToPath(import.meta.url)
+const __dirname = dirname(__filename)
+const projectRoot = join(__dirname, '..')
+
+// ANSI color codes for console output
+const colors = {
+ reset: '\x1B[0m',
+ bright: '\x1B[1m',
+ dim: '\x1B[2m',
+ red: '\x1B[31m',
+ green: '\x1B[32m',
+ yellow: '\x1B[33m',
+ blue: '\x1B[34m',
+ cyan: '\x1B[36m',
+} as const
+
+function colorize(text: string, color: keyof typeof colors): string {
+ return `${colors[color]}${text}${colors.reset}`
+}
+
+interface EnvConfig {
+ database: {
+ user: string
+ password: string
+ name: string
+ host: string
+ port: number
+ }
+ ports: {
+ api: number
+ maildevSmtp: number
+ maildevWeb: number
+ pgadmin: number
+ }
+ betterAuth: {
+ secret: string
+ trustedOrigins: string
+ }
+ mobile: {
+ localIp: string
+ }
+}
+
+interface EnvFileInfo {
+ from: string
+ to: string
+ exists: boolean
+ missingVars: string[]
+}
+
+async function prompt(message: string, initial: string): Promise {
+ const input = new Input({
+ message,
+ initial,
+ })
+ return input.run()
+}
+
+async function confirm(message: string, initial = false): Promise {
+ const confirmPrompt = new Confirm({
+ name: 'confirm',
+ message,
+ initial,
+ })
+ return confirmPrompt.run()
+}
+
+async function select(message: string, choices: string[], initial = 0): Promise {
+ const selectPrompt = new Select({
+ name: 'select',
+ message,
+ choices,
+ initial,
+ })
+ return selectPrompt.run()
+}
+
+function runCommand(command: string, args: string[]): Promise {
+ return new Promise((resolve, reject) => {
+ console.log(
+ `\n ${colorize('→', 'cyan')} Running: ${colorize(`${command} ${args.join(' ')}`, 'dim')}\n`,
+ )
+
+ const child = spawn(command, args, {
+ cwd: projectRoot,
+ stdio: 'inherit',
+ shell: true,
+ })
+
+ child.on('close', (code) => {
+ if (code === 0) {
+ resolve()
+ } else {
+ reject(new Error(`Command failed with exit code ${code}`))
+ }
+ })
+
+ child.on('error', (error) => {
+ reject(error)
+ })
+ })
+}
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
+
+async function waitForDatabase(maxRetries = 30, delayMs = 1000): Promise {
+ console.log(` ${colorize('⏳', 'yellow')} Waiting for database to be ready...`)
+
+ for (let i = 0; i < maxRetries; i++) {
+ try {
+ const child = spawn(
+ 'docker',
+ ['compose', 'exec', '-T', 'db', 'pg_isready', '-U', 'postgres'],
+ {
+ cwd: projectRoot,
+ stdio: 'pipe',
+ shell: true,
+ },
+ )
+
+ const exitCode = await new Promise((resolve) => {
+ child.on('close', (code) => resolve(code ?? 1))
+ child.on('error', () => resolve(1))
+ })
+
+ if (exitCode === 0) {
+ console.log(` ${colorize('✓', 'green')} Database is ready!`)
+ return true
+ }
+ } catch {
+ // Ignore errors, retry
+ }
+
+ await sleep(delayMs)
+ process.stdout.write(` ${colorize('⏳', 'yellow')} Waiting... (${i + 1}/${maxRetries})\r`)
+ }
+
+ console.log(`\n ${colorize('⚠', 'yellow')} Database not ready after ${maxRetries} attempts`)
+ return false
+}
+
+function parseEnvFile(filePath: string): Record {
+ if (!existsSync(filePath)) {
+ return {}
+ }
+
+ const content = readFileSync(filePath, 'utf-8')
+ const vars: Record = {}
+
+ for (const line of content.split('\n')) {
+ const trimmed = line.trim()
+ if (trimmed && !trimmed.startsWith('#')) {
+ const match = trimmed.match(/^([^=]+)=(.*)$/)
+ if (match) {
+ const key = match[1].trim()
+ const value = match[2].trim()
+ vars[key] = value
+ }
+ }
+ }
+
+ return vars
+}
+
+function getMissingVariables(examplePath: string, envPath: string): string[] {
+ const exampleVars = parseEnvFile(examplePath)
+ const envVars = parseEnvFile(envPath)
+
+ return Object.keys(exampleVars).filter((key) => !(key in envVars) || !envVars[key])
+}
+
+function getLocalIpAddress(): string | null {
+ const nets = networkInterfaces()
+
+ for (const name of Object.keys(nets)) {
+ const netInterfaces = nets[name]
+ if (!netInterfaces) continue
+
+ for (const net of netInterfaces) {
+ // Skip over non-IPv4 and internal addresses
+ const familyV4Value = typeof net.family === 'string' ? 'IPv4' : 4
+ if (net.family === familyV4Value && !net.internal) {
+ // Prioritize private network ranges
+ if (
+ net.address.startsWith('192.168.') ||
+ net.address.startsWith('10.') ||
+ /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(net.address)
+ ) {
+ return net.address
+ }
+ }
+ }
+ }
+
+ return null
+}
+
+function generateBetterAuthSecret(): string {
+ return randomBytes(32).toString('base64')
+}
+
+function updateEnvFile(
+ filePath: string,
+ replacements: Record,
+ onlyMissing = false,
+): void {
+ if (!existsSync(filePath)) {
+ console.log(` ${colorize('⚠', 'yellow')} File not found: ${colorize(filePath, 'dim')}`)
+ return
+ }
+
+ let content = readFileSync(filePath, 'utf-8')
+ const existingVars = parseEnvFile(filePath)
+ let updated = false
+
+ for (const [key, value] of Object.entries(replacements)) {
+ if (onlyMissing && key in existingVars && existingVars[key]) {
+ continue
+ }
+
+ const regex = new RegExp(`^${key}=.*$`, 'm')
+ if (regex.test(content)) {
+ content = content.replace(regex, `${key}=${value}`)
+ updated = true
+ } else {
+ content += `\n${key}=${value}`
+ updated = true
+ }
+ }
+
+ if (updated) {
+ writeFileSync(filePath, content, 'utf-8')
+ }
+}
+
+function checkEnvFiles(): EnvFileInfo[] {
+ const envFiles: Array<{ from: string; to: string }> = [
+ { from: '.env.example', to: '.env' },
+ { from: 'apps/api/.env.example', to: 'apps/api/.env' },
+ { from: 'apps/web/.env.example', to: 'apps/web/.env' },
+ { from: 'apps/mobile/.env.example', to: 'apps/mobile/.env' },
+ ]
+
+ return envFiles.map(({ from, to }) => {
+ const fromPath = join(projectRoot, from)
+ const toPath = join(projectRoot, to)
+ const exists = existsSync(toPath)
+ const missingVars = exists ? getMissingVariables(fromPath, toPath) : []
+
+ return { from, to, exists, missingVars }
+ })
+}
+
+function copyEnvFiles(envFilesInfo: EnvFileInfo[]): void {
+ console.log(`\n${colorize('📋 Checking .env files', 'cyan')}\n`)
+
+ for (const { from, to, exists, missingVars } of envFilesInfo) {
+ const fromPath = join(projectRoot, from)
+ const toPath = join(projectRoot, to)
+
+ if (exists) {
+ if (missingVars.length > 0) {
+ console.log(
+ ` ${colorize('⚠', 'yellow')} ${colorize(to, 'dim')} exists but missing variables: ${colorize(missingVars.join(', '), 'yellow')}`,
+ )
+ } else {
+ console.log(` ${colorize('✓', 'green')} ${colorize(to, 'dim')} exists and is complete`)
+ }
+ continue
+ }
+
+ if (existsSync(fromPath)) {
+ copyFileSync(fromPath, toPath)
+ console.log(
+ ` ${colorize('✓', 'green')} Copied ${colorize(from, 'dim')} → ${colorize(to, 'dim')}`,
+ )
+ } else {
+ console.log(` ${colorize('⚠', 'yellow')} File not found: ${colorize(from, 'dim')}`)
+ }
+ }
+}
+
+async function promptDatabaseConfig(): Promise {
+ const rootEnvPath = join(projectRoot, '.env')
+ const rootExamplePath = join(projectRoot, '.env.example')
+ const envExists = existsSync(rootEnvPath)
+ const existingVars = envExists ? parseEnvFile(rootEnvPath) : {}
+ const exampleVars = parseEnvFile(rootExamplePath)
+ const missingVars = envExists
+ ? getMissingVariables(rootExamplePath, rootEnvPath)
+ : Object.keys(exampleVars)
+
+ const dbVars = ['DB_USER', 'DB_PASSWORD', 'DB_NAME', 'DB_HOST', 'DB_PORT']
+ const needsDbConfig = dbVars.some((v) => missingVars.includes(v))
+
+ if (!needsDbConfig) {
+ console.log(`\n${colorize('📊 Database Configuration', 'cyan')}`)
+ console.log(` ${colorize('✓', 'green')} Database variables already configured`)
+ return {
+ user: existingVars.DB_USER || 'postgres',
+ password: existingVars.DB_PASSWORD || 'example',
+ name: existingVars.DB_NAME || 'dropit',
+ host: existingVars.DB_HOST || 'localhost',
+ port: Number.parseInt(existingVars.DB_PORT || '5432', 10),
+ }
+ }
+
+ console.log(`\n${colorize('📊 Database Configuration', 'cyan')}\n`)
+
+ const user = missingVars.includes('DB_USER')
+ ? await prompt('Database user', existingVars.DB_USER || exampleVars.DB_USER || 'postgres')
+ : existingVars.DB_USER || 'postgres'
+
+ const password = missingVars.includes('DB_PASSWORD')
+ ? await prompt(
+ 'Database password',
+ existingVars.DB_PASSWORD || exampleVars.DB_PASSWORD || 'example',
+ )
+ : existingVars.DB_PASSWORD || 'example'
+
+ const name = missingVars.includes('DB_NAME')
+ ? await prompt('Database name', existingVars.DB_NAME || exampleVars.DB_NAME || 'dropit')
+ : existingVars.DB_NAME || 'dropit'
+
+ const host = missingVars.includes('DB_HOST')
+ ? await prompt('Database host', existingVars.DB_HOST || exampleVars.DB_HOST || 'localhost')
+ : existingVars.DB_HOST || 'localhost'
+
+ const portStr = missingVars.includes('DB_PORT')
+ ? await prompt('Database port', existingVars.DB_PORT || exampleVars.DB_PORT || '5432')
+ : existingVars.DB_PORT || '5432'
+ const port = Number.parseInt(portStr, 10) || 5432
+
+ return { user, password, name, host, port }
+}
+
+async function promptPortsConfig(): Promise {
+ const apiEnvPath = join(projectRoot, 'apps/api/.env')
+ const apiExamplePath = join(projectRoot, 'apps/api/.env.example')
+ const envExists = existsSync(apiEnvPath)
+ const existingVars = envExists ? parseEnvFile(apiEnvPath) : {}
+ const exampleVars = parseEnvFile(apiExamplePath)
+ const missingVars = envExists
+ ? getMissingVariables(apiExamplePath, apiEnvPath)
+ : Object.keys(exampleVars)
+
+ const portsVars = ['API_PORT', 'MAILDEV_SMTP_PORT', 'MAILDEV_WEB_PORT', 'PGADMIN_PORT']
+ const needsPortsConfig = portsVars.some((v) => missingVars.includes(v))
+
+ if (!needsPortsConfig) {
+ console.log(`\n${colorize('🔌 Ports Configuration', 'cyan')}`)
+ console.log(` ${colorize('✓', 'green')} Ports already configured`)
+ return {
+ api: Number.parseInt(existingVars.API_PORT || '3000', 10),
+ maildevSmtp: Number.parseInt(existingVars.MAILDEV_SMTP_PORT || '1025', 10),
+ maildevWeb: Number.parseInt(existingVars.MAILDEV_WEB_PORT || '1080', 10),
+ pgadmin: Number.parseInt(existingVars.PGADMIN_PORT || '5050', 10),
+ }
+ }
+
+ console.log(`\n${colorize('🔌 Ports Configuration', 'cyan')}\n`)
+
+ const apiPortStr = missingVars.includes('API_PORT')
+ ? await prompt('API port', existingVars.API_PORT || exampleVars.API_PORT || '3000')
+ : existingVars.API_PORT || '3000'
+ const apiPort = Number.parseInt(apiPortStr, 10) || 3000
+
+ const maildevSmtpPortStr = missingVars.includes('MAILDEV_SMTP_PORT')
+ ? await prompt(
+ 'MailDev SMTP port',
+ existingVars.MAILDEV_SMTP_PORT || exampleVars.MAILDEV_SMTP_PORT || '1025',
+ )
+ : existingVars.MAILDEV_SMTP_PORT || '1025'
+ const maildevSmtpPort = Number.parseInt(maildevSmtpPortStr, 10) || 1025
+
+ const maildevWebPortStr = missingVars.includes('MAILDEV_WEB_PORT')
+ ? await prompt(
+ 'MailDev Web port',
+ existingVars.MAILDEV_WEB_PORT || exampleVars.MAILDEV_WEB_PORT || '1080',
+ )
+ : existingVars.MAILDEV_WEB_PORT || '1080'
+ const maildevWebPort = Number.parseInt(maildevWebPortStr, 10) || 1080
+
+ const pgadminPortStr = missingVars.includes('PGADMIN_PORT')
+ ? await prompt(
+ 'PgAdmin port',
+ existingVars.PGADMIN_PORT || exampleVars.PGADMIN_PORT || '5050',
+ )
+ : existingVars.PGADMIN_PORT || '5050'
+ const pgadminPort = Number.parseInt(pgadminPortStr, 10) || 5050
+
+ return {
+ api: apiPort,
+ maildevSmtp: maildevSmtpPort,
+ maildevWeb: maildevWebPort,
+ pgadmin: pgadminPort,
+ }
+}
+
+async function promptLocalIpForMobile(apiPort: number): Promise {
+ const mobileEnvPath = join(projectRoot, 'apps/mobile/.env')
+ const mobileExamplePath = join(projectRoot, 'apps/mobile/.env.example')
+ const envExists = existsSync(mobileEnvPath)
+ const existingVars = envExists ? parseEnvFile(mobileEnvPath) : {}
+
+ // Check if already configured
+ if (
+ existingVars.EXPO_PUBLIC_API_URL &&
+ !existingVars.EXPO_PUBLIC_API_URL.includes('192.168.1.XXX')
+ ) {
+ console.log(`\n${colorize('📱 Mobile Configuration', 'cyan')}`)
+ console.log(` ${colorize('✓', 'green')} Mobile API URL already configured`)
+ // Extract IP from existing URL
+ const match = existingVars.EXPO_PUBLIC_API_URL.match(/http:\/\/([^:]+)/)
+ return match ? match[1] : getLocalIpAddress() || '192.168.1.1'
+ }
+
+ console.log(`\n${colorize('📱 Mobile Configuration', 'cyan')}\n`)
+ console.log(
+ ` ${colorize('ℹ', 'blue')} The mobile app needs your local IP address to connect to the API`,
+ )
+
+ const detectedIp = getLocalIpAddress()
+ if (detectedIp) {
+ console.log(` ${colorize('✓', 'green')} Detected IP: ${colorize(detectedIp, 'bright')}`)
+ const localIp = await prompt('Local IP address for mobile app', detectedIp)
+ return localIp
+ }
+
+ console.log(
+ ` ${colorize('⚠', 'yellow')} Could not detect local IP automatically. Please enter manually.`,
+ )
+ const localIp = await prompt('Local IP address for mobile app', '192.168.1.1')
+ return localIp
+}
+
+function updateAllEnvFiles(config: EnvConfig): void {
+ console.log(`\n${colorize('✏️ Updating .env files', 'cyan')}\n`)
+
+ // Root .env
+ const rootUpdates: Record = {
+ DB_USER: config.database.user,
+ DB_PASSWORD: config.database.password,
+ DB_NAME: config.database.name,
+ DB_PORT: config.database.port.toString(),
+ DB_HOST: config.database.host,
+ DB_USER_TEST: config.database.user,
+ DB_PASSWORD_TEST: config.database.password,
+ DB_NAME_TEST: `${config.database.name}_test`,
+ DB_PORT_TEST: '5433',
+ DB_HOST_TEST: config.database.host,
+ MAILDEV_HOST: 'localhost',
+ MAILDEV_SMTP_PORT: config.ports.maildevSmtp.toString(),
+ MAILDEV_WEB_PORT: config.ports.maildevWeb.toString(),
+ PGADMIN_DEFAULT_EMAIL: 'admin@admin.com',
+ PGADMIN_DEFAULT_PASSWORD: 'admin',
+ PGADMIN_PORT: config.ports.pgadmin.toString(),
+ }
+ updateEnvFile(join(projectRoot, '.env'), rootUpdates, false)
+ console.log(` ${colorize('✓', 'green')} Updated ${colorize('.env', 'dim')}`)
+
+ // API .env
+ const apiUpdates: Record = {
+ DB_USER: config.database.user,
+ DB_PASSWORD: config.database.password,
+ DB_NAME: config.database.name,
+ DB_PORT: config.database.port.toString(),
+ DB_HOST: config.database.host,
+ DB_USER_TEST: config.database.user,
+ DB_PASSWORD_TEST: config.database.password,
+ DB_NAME_TEST: `${config.database.name}_test`,
+ DB_PORT_TEST: '5433',
+ DB_HOST_TEST: config.database.host,
+ NODE_ENV: 'development',
+ API_PORT: config.ports.api.toString(),
+ BETTER_AUTH_SECRET: config.betterAuth.secret,
+ TRUSTED_ORIGINS: config.betterAuth.trustedOrigins,
+ SEED_DB: 'false',
+ VITE_API_URL: `http://localhost:${config.ports.api}`,
+ BREVO_API_KEY: '',
+ BREVO_FROM_EMAIL: '',
+ BREVO_FROM_NAME: '',
+ MAILDEV_HOST: 'localhost',
+ MAILDEV_SMTP_PORT: config.ports.maildevSmtp.toString(),
+ MAILDEV_WEB_PORT: config.ports.maildevWeb.toString(),
+ PGADMIN_DEFAULT_EMAIL: 'admin@admin.com',
+ PGADMIN_DEFAULT_PASSWORD: 'admin',
+ PGADMIN_PORT: config.ports.pgadmin.toString(),
+ }
+ updateEnvFile(join(projectRoot, 'apps/api/.env'), apiUpdates, false)
+ console.log(` ${colorize('✓', 'green')} Updated ${colorize('apps/api/.env', 'dim')}`)
+
+ // Web .env
+ const webUpdates: Record = {
+ VITE_API_URL: `http://localhost:${config.ports.api}`,
+ }
+ updateEnvFile(join(projectRoot, 'apps/web/.env'), webUpdates, false)
+ console.log(` ${colorize('✓', 'green')} Updated ${colorize('apps/web/.env', 'dim')}`)
+
+ // Mobile .env
+ const mobileUpdates: Record = {
+ EXPO_PUBLIC_API_URL: `http://${config.mobile.localIp}:${config.ports.api}`,
+ }
+ updateEnvFile(join(projectRoot, 'apps/mobile/.env'), mobileUpdates, false)
+ console.log(` ${colorize('✓', 'green')} Updated ${colorize('apps/mobile/.env', 'dim')}`)
+}
+
+async function main(): Promise {
+ console.log(`\n${colorize('🚀 DropIt Development Environment Setup', 'bright')}\n`)
+
+ try {
+ // Check .env files
+ const envFilesInfo = checkEnvFiles()
+
+ // Copy .env files if they don't exist
+ copyEnvFiles(envFilesInfo)
+
+ // Prompt for configuration
+ const databaseConfig = await promptDatabaseConfig()
+ const portsConfig = await promptPortsConfig()
+ const localIp = await promptLocalIpForMobile(portsConfig.api)
+
+ // Generate or get existing Better Auth secret
+ const apiEnvPath = join(projectRoot, 'apps/api/.env')
+ const existingApiVars = parseEnvFile(apiEnvPath)
+ const betterAuthSecret =
+ existingApiVars.BETTER_AUTH_SECRET || generateBetterAuthSecret()
+ const trustedOrigins = `http://localhost:${portsConfig.api},http://localhost:5173`
+
+ const config: EnvConfig = {
+ database: databaseConfig,
+ ports: portsConfig,
+ betterAuth: {
+ secret: betterAuthSecret,
+ trustedOrigins,
+ },
+ mobile: {
+ localIp,
+ },
+ }
+
+ // Update all .env files
+ updateAllEnvFiles(config)
+
+ console.log(`\n${colorize('✅ Configuration completed successfully!', 'green')}`)
+ console.log(`\n${colorize('📝 Configuration Summary:', 'cyan')}`)
+ console.log(
+ ` ${colorize('Database:', 'bright')} ${colorize(`${config.database.user}@${config.database.host}:${config.database.port}/${config.database.name}`, 'dim')}`,
+ )
+ console.log(
+ ` ${colorize('API:', 'bright')} ${colorize(`http://localhost:${config.ports.api}`, 'blue')}`,
+ )
+ console.log(
+ ` ${colorize('Web:', 'bright')} ${colorize('http://localhost:5173', 'blue')} ${colorize('(Vite default)', 'dim')}`,
+ )
+ console.log(
+ ` ${colorize('Mobile:', 'bright')} ${colorize(`http://${config.mobile.localIp}:${config.ports.api}`, 'blue')}`,
+ )
+ console.log(
+ ` ${colorize('MailDev:', 'bright')} ${colorize(`http://localhost:${config.ports.maildevWeb}`, 'blue')} ${colorize(`(SMTP: ${config.ports.maildevSmtp})`, 'dim')}`,
+ )
+ console.log(
+ ` ${colorize('PgAdmin:', 'bright')} ${colorize(`http://localhost:${config.ports.pgadmin}`, 'blue')}`,
+ )
+
+ // Ask to start Docker
+ let dockerStarted = false
+ console.log(`\n${colorize('🐳 Docker Services', 'cyan')}`)
+ const shouldStartDocker = await confirm('Start Docker services (database, maildev, pgadmin)?')
+
+ if (shouldStartDocker) {
+ try {
+ await runCommand('docker', ['compose', 'up', '-d'])
+ console.log(`\n ${colorize('✓', 'green')} Docker services started`)
+ dockerStarted = true
+
+ // Wait for database to be ready
+ const dbReady = await waitForDatabase()
+
+ if (dbReady) {
+ console.log(`\n${colorize('🗄️ Database Setup', 'cyan')}`)
+
+ // Check for pending migrations
+ console.log(` ${colorize('→', 'cyan')} Checking for pending migrations...`)
+ let hasPendingMigrations = false
+
+ try {
+ const checkChild = spawn(
+ 'pnpm',
+ ['--filter', 'api', 'db:migration:check'],
+ {
+ cwd: projectRoot,
+ stdio: 'pipe',
+ shell: true,
+ },
+ )
+
+ const output: string[] = []
+ checkChild.stdout?.on('data', (data) => {
+ output.push(data.toString())
+ })
+ checkChild.stderr?.on('data', (data) => {
+ output.push(data.toString())
+ })
+
+ const checkExitCode = await new Promise((resolve) => {
+ checkChild.on('close', (code) => resolve(code ?? 0))
+ checkChild.on('error', () => resolve(1))
+ })
+
+ const outputStr = output.join('')
+ hasPendingMigrations = outputStr.includes('pending') || checkExitCode !== 0
+ } catch {
+ // If check fails, assume migrations might be needed
+ hasPendingMigrations = true
+ }
+
+ if (hasPendingMigrations) {
+ console.log(` ${colorize('ℹ', 'blue')} Pending migrations detected`)
+ const dbChoice = await select(
+ 'How do you want to set up the database?',
+ ['Apply migrations (db:migration:up)', 'Fresh database with seeds (db:fresh)', 'Skip'],
+ 0,
+ )
+
+ if (dbChoice.includes('Apply migrations')) {
+ try {
+ await runCommand('pnpm', ['--filter', 'api', 'db:migration:up'])
+ console.log(`\n ${colorize('✓', 'green')} Migrations applied successfully`)
+ } catch (error) {
+ console.error(`\n ${colorize('⚠', 'yellow')} Migration failed:`, error)
+ console.log(
+ ` ${colorize('You can run migrations manually later with:', 'dim')} ${colorize('pnpm --filter api db:migration:up', 'bright')}`,
+ )
+ }
+ } else if (dbChoice.includes('Fresh database')) {
+ try {
+ await runCommand('pnpm', ['db:fresh'])
+ console.log(
+ `\n ${colorize('✓', 'green')} Fresh database created and seeded successfully`,
+ )
+ } catch (error) {
+ console.error(`\n ${colorize('⚠', 'yellow')} db:fresh failed:`, error)
+ console.log(
+ ` ${colorize('You can run db:fresh manually later with:', 'dim')} ${colorize('pnpm db:fresh', 'bright')}`,
+ )
+ }
+ } else {
+ console.log(
+ ` ${colorize('→', 'cyan')} Skipped database setup. Run manually with: ${colorize('pnpm --filter api db:migration:up', 'bright')} or ${colorize('pnpm db:fresh', 'bright')}`,
+ )
+ }
+ } else {
+ console.log(` ${colorize('✓', 'green')} No pending migrations`)
+ const shouldSeed = await confirm('Run database seeder to add test data?')
+
+ if (shouldSeed) {
+ try {
+ await runCommand('pnpm', ['db:seed'])
+ console.log(`\n ${colorize('✓', 'green')} Database seeded successfully`)
+ } catch (error) {
+ console.error(`\n ${colorize('⚠', 'yellow')} Seeding failed:`, error)
+ }
+ }
+ }
+ } else {
+ console.log(
+ ` ${colorize('→', 'cyan')} Database not ready. Run migrations manually with: ${colorize('pnpm --filter api db:migration:up', 'bright')}`,
+ )
+ }
+ } catch (error) {
+ console.error(`\n ${colorize('⚠', 'yellow')} Failed to start Docker:`, error)
+ console.log(
+ ` ${colorize('You can start Docker manually with:', 'dim')} ${colorize('docker compose up -d', 'bright')}`,
+ )
+ }
+ } else {
+ console.log(
+ ` ${colorize('→', 'cyan')} Skipped Docker. Start manually with: ${colorize('docker compose up -d', 'bright')}`,
+ )
+ }
+
+ // Final summary
+ console.log(`\n${colorize('🎉 Setup complete!', 'green')}`)
+ console.log(`\n${colorize('Next steps:', 'cyan')}`)
+
+ let step = 1
+ if (!dockerStarted) {
+ console.log(
+ ` ${colorize(`${step}.`, 'bright')} Start Docker services: ${colorize('docker compose up -d', 'blue')}`,
+ )
+ step++
+ console.log(
+ ` ${colorize(`${step}.`, 'bright')} Run database setup: ${colorize('pnpm db:fresh', 'blue')} or ${colorize('pnpm --filter api db:migration:up', 'blue')}`,
+ )
+ step++
+ }
+ console.log(` ${colorize(`${step}.`, 'bright')} Start development: ${colorize('pnpm dev', 'blue')}\n`)
+ } catch (error) {
+ console.error(`\n${colorize('❌ Error during setup:', 'red')}`, error)
+ process.exit(1)
+ }
+}
+
+main()
diff --git a/package.json b/package.json
index 9a5aacb..26a1888 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,9 @@
{
"name": "dropit",
+ "type": "module",
"private": true,
"scripts": {
+ "rock": "tsx cli/setup.ts",
"build": "pnpm --recursive --filter '{packages/*}' build && pnpm --filter api build && pnpm --filter web build",
"dev": "pnpm --parallel --filter '{apps/*}' dev",
"dev:web-api": "pnpm --parallel --filter api --filter web dev",
@@ -19,6 +21,8 @@
"devDependencies": {
"@biomejs/biome": "1.5.3",
"dotenv-cli": "^10.0.0",
+ "enquirer": "^2.4.1",
+ "tsx": "^4.19.2",
"typescript": "^5.3.3"
},
"packageManager": "pnpm@9.7.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 06e858c..c7d7ea4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -14,6 +14,12 @@ importers:
dotenv-cli:
specifier: ^10.0.0
version: 10.0.0
+ enquirer:
+ specifier: ^2.4.1
+ version: 2.4.1
+ tsx:
+ specifier: ^4.19.2
+ version: 4.20.6
typescript:
specifier: ^5.3.3
version: 5.9.3
@@ -4467,6 +4473,10 @@ packages:
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
engines: {node: '>=10.13.0'}
+ enquirer@2.4.1:
+ resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==}
+ engines: {node: '>=8.6'}
+
entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
@@ -12722,6 +12732,11 @@ snapshots:
graceful-fs: 4.2.11
tapable: 2.3.0
+ enquirer@2.4.1:
+ dependencies:
+ ansi-colors: 4.1.3
+ strip-ansi: 6.0.1
+
entities@4.5.0: {}
env-editor@0.4.2: {}