diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index bdbbc65..3763c18 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -42,8 +42,9 @@ async function bootstrap() { // Configuration Swagger SwaggerModule.setup('api', app, openApiDocument); - await app.listen(PORT); + await app.listen(PORT, '0.0.0.0'); console.log(`Application is running on: http://localhost:${PORT}`); + console.log(`Also accessible on network: http://192.168.1.147:${PORT}`); } bootstrap(); diff --git a/apps/mobile/App.tsx b/apps/mobile/App.tsx index 4519659..00889fe 100644 --- a/apps/mobile/App.tsx +++ b/apps/mobile/App.tsx @@ -1,41 +1,11 @@ -import { StatusBar } from 'expo-status-bar'; -import { StyleSheet, Text, View } from 'react-native'; -import { athleteSchema } from '@dropit/schemas'; -import { z } from 'zod'; +import React from 'react'; +import AuthProvider from './src/components/AuthProvider'; +import DashboardScreen from './src/components/DashboardScreen'; export default function App() { - - - return ( - - Welcome to DropIt Mobile! - ✅ Shared packages working Ouais! - Test athlete: Sten Levasseur - - - ); + return ( + + + + ); } - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#fff', - alignItems: 'center', - justifyContent: 'center', - }, - successText: { - color: 'green', - fontSize: 16, - marginTop: 10, - }, - errorText: { - color: 'red', - fontSize: 16, - marginTop: 10, - }, - smallText: { - fontSize: 12, - marginTop: 5, - color: '#666', - }, -}); diff --git a/apps/mobile/README.md b/apps/mobile/README.md new file mode 100644 index 0000000..bfd8391 --- /dev/null +++ b/apps/mobile/README.md @@ -0,0 +1,213 @@ +# DropIt Mobile App + +Application mobile React Native avec Expo pour la gestion d'entraînements de musculation. + +## 🚀 Fonctionnalités + +- ✅ **Authentification Better Auth** - Connexion sécurisée avec email/mot de passe +- ✅ **Support Bearer Token** - Gestion automatique des tokens pour les API calls +- ✅ **AsyncStorage** - Persistance de session locale +- ✅ **Packages partagés** - Utilisation des schémas et contrats du monorepo +- ✅ **TypeScript** - Typage complet avec validation Zod +- ✅ **Design responsive** - Interface optimisée mobile + +## 🛠 Architecture + +``` +src/ +├── components/ +│ ├── AuthProvider.tsx # Gestion globale de l'authentification +│ ├── LoginScreen.tsx # Écran de connexion +│ └── DashboardScreen.tsx # Écran principal après connexion +├── lib/ +│ ├── auth-client.ts # Configuration Better Auth mobile +│ └── api.ts # Client API avec Bearer token +``` + +## 📱 Configuration Better Auth + +### Client d'authentification (`src/lib/auth-client.ts`) + +Le client Better Auth est configuré spécialement pour React Native : + +```typescript +export const authClient = createAuthClient({ + baseURL: 'http://localhost:3000', + plugins: [organizationClient({ ac, roles: { owner, admin, member } })], + storage: { + // Configuration AsyncStorage pour React Native + get: async (key: string) => AsyncStorage.getItem(key), + set: async (key: string, value: any) => AsyncStorage.setItem(key, JSON.stringify(value)), + remove: async (key: string) => AsyncStorage.removeItem(key), + }, +}); +``` + +### API Client avec Bearer Token (`src/lib/api.ts`) + +```typescript +export const api = initClient(apiContract, { + baseUrl: 'http://localhost:3000/api', + api: async (args: any) => { + // Récupération automatique du token depuis AsyncStorage + const authData = await AsyncStorage.getItem('better-auth.session-token'); + const token = authData ? JSON.parse(authData) : null; + + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + // Requête avec Bearer token + return fetch(args.path, { + method: args.method, + headers, + body: args.body ? JSON.stringify(args.body) : undefined, + }); + }, +}); +``` + +## 🔐 Flux d'authentification + +### 1. Connexion +```typescript +const { data, error } = await authClient.signIn.email({ + email: 'user@example.com', + password: 'password123' +}); +``` + +### 2. Gestion de session +```typescript +const sessionData = await authClient.getSession(); +if (sessionData.data) { + // Utilisateur connecté + setSession(sessionData.data); +} +``` + +### 3. Déconnexion +```typescript +await authClient.signOut(); +setSession(null); +``` + +## 🧩 Intégration des packages partagés + +L'app mobile utilise tous les packages partagés du monorepo : + +- **@dropit/contract** - Contrats API typés +- **@dropit/schemas** - Validation Zod +- **@dropit/permissions** - Système de rôles et permissions +- **@dropit/i18n** - Internationalisation + +### Exemple d'utilisation des schémas : + +```typescript +import { athleteSchema } from '@dropit/schemas'; + +const testAthlete = athleteSchema.parse({ + id: '1', + firstName: 'John', + lastName: 'Doe', + email: 'john.doe@example.com', + organizationId: 'org-1' +}); +``` + +## 📋 Configuration du serveur + +Le serveur API doit être configuré avec support Bearer token : + +```typescript +// better-auth.config.ts +bearerToken: { + enabled: true, // ✅ Activé pour mobile +}, +``` + +## 🚀 Démarrage + +### Prérequis +- API backend démarrée sur `localhost:3000` +- Base de données configurée +- Expo CLI installé + +### Commandes + +```bash +# Installation des dépendances +pnpm install + +# Démarrage de l'app mobile +pnpm dev:mobile + +# Vérification TypeScript +pnpm --filter mobile typecheck + +# Dans un autre terminal, démarrer l'API +pnpm --filter api dev +``` + +### Test sur appareil + +1. Installer **Expo Go** sur votre smartphone +2. Scanner le QR code affiché dans le terminal +3. L'app se charge avec l'écran de connexion + +## 🔧 Configuration réseau + +Pour tester sur un appareil physique, assurez-vous que : + +1. **L'API est accessible** depuis votre réseau local +2. **Les CORS sont configurés** pour accepter les requêtes mobiles +3. **L'URL de l'API** correspond à votre IP locale si nécessaire + +```typescript +// Remplacer localhost par votre IP locale si nécessaire +baseURL: 'http://192.168.1.100:3000', // Exemple +``` + +## 🎨 Interface utilisateur + +### Écran de connexion +- Design moderne et responsive +- Validation des champs +- Gestion d'erreurs +- État de chargement + +### Dashboard +- Navigation intuitive +- Boutons d'action +- Test des packages partagés +- Déconnexion sécurisée + +## 🐛 Débogage + +### Logs utiles +```typescript +// Connexion réussie +console.log('Login successful:', data.user.email); + +// Session trouvée +console.log('Session found:', sessionData.data.user.email); + +// Erreur d'authentification +console.error('Login error:', error); +``` + +### Problèmes courants + +1. **Token non envoyé** - Vérifier AsyncStorage et Bearer token +2. **CORS errors** - Configurer `trustedOrigins` dans better-auth +3. **Session expirée** - Implémenter refresh token automatique + +## 📚 Ressources + +- [Better Auth Documentation](https://www.better-auth.com/docs) +- [Expo Documentation](https://docs.expo.dev/) +- [React Native Documentation](https://reactnative.dev/docs/getting-started) \ No newline at end of file diff --git a/apps/mobile/package.json b/apps/mobile/package.json index c61d94c..cb570b5 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -22,7 +22,10 @@ "react-dom": "19.0.0", "react-native": "0.79.5", "react-native-web": "~0.20.0", - "zod": "^3.24.1" + "zod": "^3.24.1", + "better-auth": "^1.2.7", + "@react-native-async-storage/async-storage": "^2.1.0", + "@ts-rest/core": "^3.51.0" }, "devDependencies": { "@babel/core": "^7.25.2", diff --git a/apps/mobile/src/components/AuthProvider.tsx b/apps/mobile/src/components/AuthProvider.tsx new file mode 100644 index 0000000..9255f48 --- /dev/null +++ b/apps/mobile/src/components/AuthProvider.tsx @@ -0,0 +1,115 @@ +import React, { useEffect, useState } from 'react'; +import { View, Text, ActivityIndicator, StyleSheet } from 'react-native'; +import { authClient } from '../lib/auth-client'; +import LoginScreen from './LoginScreen'; + +interface AuthProviderProps { + children: React.ReactNode; +} + +interface User { + id: string; + email: string; + name: string; + emailVerified: boolean; +} + +interface Session { + user: User; + session: { + id: string; + userId: string; + activeOrganizationId?: string | null; + }; +} + +export default function AuthProvider({ children }: AuthProviderProps) { + const [session, setSession] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isInitialized, setIsInitialized] = useState(false); + + useEffect(() => { + initializeAuth(); + }, []); + + const initializeAuth = async () => { + try { + setIsLoading(true); + + // Attendre un peu pour éviter les erreurs d'initialisation + await new Promise(resolve => setTimeout(resolve, 100)); + + // Récupérer la session actuelle + const sessionData = await authClient.getSession(); + + if (sessionData.data) { + console.log('Session found:', sessionData.data.user.email); + setSession(sessionData.data); + } else { + console.log('No session found'); + setSession(null); + } + } catch (error) { + console.error('Auth initialization error:', error); + setSession(null); + } finally { + setIsLoading(false); + setIsInitialized(true); + } + }; + + const handleLoginSuccess = async () => { + // Rafraîchir la session après connexion réussie + await initializeAuth(); + }; + + const handleLogout = async () => { + try { + await authClient.signOut(); + setSession(null); + console.log('Logged out successfully'); + } catch (error) { + console.error('Logout error:', error); + } + }; + + // Écran de chargement initial + if (!isInitialized || isLoading) { + return ( + + + Initialisation... + + ); + } + + // Si pas de session, afficher l'écran de connexion + if (!session) { + return ; + } + + // Session active, afficher l'app + return ( + + {children} + {/* Vous pouvez ajouter un bouton de déconnexion ici pour les tests */} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: '#FFFFFF', + }, + loadingText: { + marginTop: 16, + fontSize: 16, + color: '#6B7280', + }, +}); \ No newline at end of file diff --git a/apps/mobile/src/components/DashboardScreen.tsx b/apps/mobile/src/components/DashboardScreen.tsx new file mode 100644 index 0000000..55a32f0 --- /dev/null +++ b/apps/mobile/src/components/DashboardScreen.tsx @@ -0,0 +1,179 @@ +import React from 'react'; +import { + View, + Text, + TouchableOpacity, + StyleSheet, + Alert, +} from 'react-native'; +import { StatusBar } from 'expo-status-bar'; +import { authClient } from '../lib/auth-client'; +import { athleteSchema } from '@dropit/schemas'; + +export default function DashboardScreen() { + const handleLogout = async () => { + Alert.alert( + 'Déconnexion', + 'Êtes-vous sûr de vouloir vous déconnecter ?', + [ + { text: 'Annuler', style: 'cancel' }, + { + text: 'Déconnexion', + style: 'destructive', + onPress: async () => { + try { + await authClient.signOut(); + // L'AuthProvider détectera automatiquement la déconnexion + } catch (error) { + console.error('Logout error:', error); + Alert.alert('Erreur', 'Erreur lors de la déconnexion'); + } + }, + }, + ] + ); + }; + + const testSchemaValidation = () => { + try { + const testAthlete = athleteSchema.parse({ + id: '1', + firstName: 'John', + lastName: 'Doe', + email: 'john.doe@example.com', + organizationId: 'org-1' + }); + + Alert.alert( + 'Test Schema', + `✅ Validation réussie!\nAthlete: ${testAthlete.firstName} ${testAthlete.lastName}` + ); + } catch (error) { + Alert.alert('Test Schema', '❌ Erreur de validation'); + } + }; + + return ( + + + + {/* Header */} + + Tableau de bord + Bienvenue sur DropIt Mobile! + + + {/* Content */} + + + 🏋️ Entraînements + + Gérez vos séances d'entraînement + + + + + 👤 Athlètes + + Suivez vos performances et progrès + + + + + 📊 Statistiques + + Analysez vos résultats d'entraînement + + + + {/* Test button pour vérifier les packages partagés */} + + Tester Schema Partagé + + + + {/* Footer avec bouton de déconnexion */} + + + Se déconnecter + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#F9FAFB', + }, + header: { + paddingHorizontal: 24, + paddingTop: 60, + paddingBottom: 32, + backgroundColor: '#FFFFFF', + }, + title: { + fontSize: 28, + fontWeight: 'bold', + color: '#1F2937', + marginBottom: 8, + }, + subtitle: { + fontSize: 16, + color: '#6B7280', + }, + content: { + flex: 1, + paddingHorizontal: 24, + paddingTop: 24, + }, + card: { + backgroundColor: '#FFFFFF', + borderRadius: 12, + padding: 20, + marginBottom: 16, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.1, + shadowRadius: 3, + elevation: 2, + }, + cardTitle: { + fontSize: 18, + fontWeight: '600', + color: '#1F2937', + marginBottom: 8, + }, + cardDescription: { + fontSize: 14, + color: '#6B7280', + lineHeight: 20, + }, + testButton: { + backgroundColor: '#10B981', + borderRadius: 8, + padding: 16, + alignItems: 'center', + marginTop: 16, + }, + testButtonText: { + color: '#FFFFFF', + fontSize: 16, + fontWeight: '600', + }, + footer: { + padding: 24, + }, + logoutButton: { + backgroundColor: '#EF4444', + borderRadius: 8, + padding: 16, + alignItems: 'center', + }, + logoutButtonText: { + color: '#FFFFFF', + fontSize: 16, + fontWeight: '600', + }, +}); \ No newline at end of file diff --git a/apps/mobile/src/components/LoginScreen.tsx b/apps/mobile/src/components/LoginScreen.tsx new file mode 100644 index 0000000..2f79f2b --- /dev/null +++ b/apps/mobile/src/components/LoginScreen.tsx @@ -0,0 +1,234 @@ +import React, { useState } from 'react'; +import { + View, + Text, + TextInput, + TouchableOpacity, + StyleSheet, + Alert, + KeyboardAvoidingView, + Platform, + ScrollView, + ActivityIndicator, +} from 'react-native'; +import { StatusBar } from 'expo-status-bar'; +import { authClient } from '../lib/auth-client'; + +interface LoginScreenProps { + onLoginSuccess?: () => void; +} + +export default function LoginScreen({ onLoginSuccess }: LoginScreenProps) { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const handleLogin = async () => { + if (!email.trim() || !password.trim()) { + Alert.alert('Erreur', 'Veuillez remplir tous les champs'); + return; + } + + setIsLoading(true); + + try { + const { data, error } = await authClient.signIn.email({ + email: email.trim().toLowerCase(), + password, + }); + + if (error) { + console.error('Login error:', error); + Alert.alert( + 'Erreur de connexion', + error.message || 'Email ou mot de passe incorrect' + ); + return; + } + + if (data) { + console.log('Login successful:', data.user.email); + Alert.alert('Succès', 'Connexion réussie !', [ + { text: 'OK', onPress: onLoginSuccess } + ]); + } + } catch (error) { + console.error('Unexpected login error:', error); + Alert.alert('Erreur', 'Une erreur inattendue est survenue'); + } finally { + setIsLoading(false); + } + }; + + const handleSignUp = () => { + Alert.alert('Inscription', 'Fonctionnalité d\'inscription à venir'); + }; + + return ( + + + + {/* Header */} + + DropIt + Votre assistant d'entraînement + + + {/* Login Form */} + + + Email + + + + + Mot de passe + + + + + {isLoading ? ( + + ) : ( + Se connecter + )} + + + + + Pas encore de compte ? S'inscrire + + + + + {/* Footer */} + + + En vous connectant, vous acceptez nos conditions d'utilisation + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#FFFFFF', + }, + scrollContainer: { + flexGrow: 1, + paddingHorizontal: 24, + paddingTop: 60, + paddingBottom: 40, + }, + header: { + alignItems: 'center', + marginBottom: 48, + }, + title: { + fontSize: 32, + fontWeight: 'bold', + color: '#1F2937', + marginBottom: 8, + }, + subtitle: { + fontSize: 16, + color: '#6B7280', + textAlign: 'center', + }, + form: { + flex: 1, + justifyContent: 'center', + }, + inputContainer: { + marginBottom: 20, + }, + inputLabel: { + fontSize: 14, + fontWeight: '600', + color: '#374151', + marginBottom: 8, + }, + input: { + borderWidth: 1, + borderColor: '#D1D5DB', + borderRadius: 8, + paddingHorizontal: 16, + paddingVertical: 12, + fontSize: 16, + backgroundColor: '#FFFFFF', + color: '#1F2937', + }, + loginButton: { + backgroundColor: '#3B82F6', + borderRadius: 8, + paddingVertical: 16, + alignItems: 'center', + marginTop: 12, + marginBottom: 16, + }, + loginButtonDisabled: { + backgroundColor: '#9CA3AF', + }, + loginButtonText: { + color: '#FFFFFF', + fontSize: 16, + fontWeight: '600', + }, + signUpButton: { + alignItems: 'center', + paddingVertical: 12, + }, + signUpButtonText: { + fontSize: 14, + color: '#6B7280', + }, + signUpButtonTextBold: { + fontWeight: '600', + color: '#3B82F6', + }, + footer: { + marginTop: 32, + alignItems: 'center', + }, + footerText: { + fontSize: 12, + color: '#9CA3AF', + textAlign: 'center', + lineHeight: 16, + }, +}); \ No newline at end of file diff --git a/apps/mobile/src/lib/api.ts b/apps/mobile/src/lib/api.ts new file mode 100644 index 0000000..12dbf0f --- /dev/null +++ b/apps/mobile/src/lib/api.ts @@ -0,0 +1,46 @@ +import { apiContract } from '@dropit/contract'; +import { initClient } from '@ts-rest/core'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +// Client API pour React Native avec gestion du Bearer token +export const api = initClient(apiContract, { + baseUrl: 'http://192.168.1.147:3000/api', + // Configuration pour React Native + // biome-ignore lint/suspicious/noExplicitAny: Better Auth type compatibility + api: async (args: any) => { + try { + // Récupération du token depuis AsyncStorage + const authData = await AsyncStorage.getItem('better-auth.session-token'); + const token = authData ? JSON.parse(authData) : null; + + // Configuration des headers avec Bearer token + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + // Effectuer la requête + const response = await fetch(args.path, { + method: args.method, + headers, + body: args.body ? JSON.stringify(args.body) : undefined, + }); + + return { + status: response.status, + body: await response.text(), + headers: response.headers, + }; + } catch (error) { + console.error('API Request Error:', error); + return { + status: 500, + body: JSON.stringify({ error: 'Network error' }), + headers: new Headers(), + }; + } + }, +}); \ No newline at end of file diff --git a/apps/mobile/src/lib/auth-client.ts b/apps/mobile/src/lib/auth-client.ts new file mode 100644 index 0000000..eaeda61 --- /dev/null +++ b/apps/mobile/src/lib/auth-client.ts @@ -0,0 +1,50 @@ +import { createAuthClient } from 'better-auth/react'; +import { organizationClient } from 'better-auth/client/plugins'; +import { ac, owner, admin, member } from '@dropit/permissions'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +// Configuration du client d'authentification pour React Native +export const authClient = createAuthClient({ + baseURL: 'http://192.168.1.147:3000', // IP locale pour mobile + plugins: [ + organizationClient({ + // biome-ignore lint/suspicious/noExplicitAny: Better Auth type compatibility + ac: ac as any, + roles: { + owner, + admin, + member, + }, + }), + ], + // Configuration pour React Native avec AsyncStorage + storage: { + get: async (key: string) => { + try { + const value = await AsyncStorage.getItem(key); + return value ? JSON.parse(value) : null; + } catch { + return null; + } + }, + // biome-ignore lint/suspicious/noExplicitAny: Better Auth type compatibility + set: async (key: string, value: any) => { + try { + await AsyncStorage.setItem(key, JSON.stringify(value)); + } catch { + // Ignore storage errors + } + }, + remove: async (key: string) => { + try { + await AsyncStorage.removeItem(key); + } catch { + // Ignore storage errors + } + }, + }, + // Support du Bearer token pour mobile + fetchOptions: { + credentials: 'include', + }, +}); \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2e355c..dff7de5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -168,6 +168,15 @@ importers: '@dropit/schemas': specifier: workspace:* version: link:../../packages/schemas + '@react-native-async-storage/async-storage': + specifier: ^2.1.0 + version: 2.2.0(react-native@0.79.5) + '@ts-rest/core': + specifier: ^3.51.0 + version: 3.52.1(@types/node@20.17.30)(zod@3.24.2) + better-auth: + specifier: ^1.2.7 + version: 1.2.7 expo: specifier: ~53.0.20 version: 53.0.20(@babel/core@7.26.10)(react-native@0.79.5)(react@19.0.0) @@ -4345,6 +4354,15 @@ packages: resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} dev: false + /@react-native-async-storage/async-storage@2.2.0(react-native@0.79.5): + resolution: {integrity: sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==} + peerDependencies: + react-native: ^0.0.0-0 || >=0.65 <1.0 + dependencies: + merge-options: 3.0.4 + react-native: 0.79.5(@babel/core@7.26.10)(@types/react@19.1.10)(react@19.0.0) + dev: false + /@react-native/assets-registry@0.79.5: resolution: {integrity: sha512-N4Kt1cKxO5zgM/BLiyzuuDNquZPiIgfktEQ6TqJ/4nKA8zr4e8KJgU6Tb2eleihDO4E24HmkvGc73naybKRz/w==} engines: {node: '>=18'} @@ -8093,6 +8111,11 @@ packages: engines: {node: '>=8'} dev: false + /is-plain-obj@2.1.0: + resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} + engines: {node: '>=8'} + dev: false + /is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} @@ -9125,6 +9148,13 @@ packages: /merge-descriptors@1.0.3: resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + /merge-options@3.0.4: + resolution: {integrity: sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==} + engines: {node: '>=10'} + dependencies: + is-plain-obj: 2.1.0 + dev: false + /merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}