diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c7cd35..9e27c48 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Typecheck + run: pnpm run typecheck + - name: Lint run: pnpm run lint diff --git a/.gitignore b/.gitignore index c0df505..8bf9c59 100644 --- a/.gitignore +++ b/.gitignore @@ -141,4 +141,6 @@ vite.config.ts.timestamp-* .pnpm-store/ # CLI credentials (per-user, not versioned) -.dev-auth-fetcher/ \ No newline at end of file +.dev-auth-fetcher/ +# Ancien fichier de config local (désormais dans ~/.dev-auth-fetcher/config.json) +config/app.config.json \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ae97911 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,57 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Projet + +`dev-auth-fetcher` — CLI Node/TypeScript qui s'authentifie sur un environnement de recette Edifice (ENT) et injecte les cookies de session (`VITE_XSRF_TOKEN`, `VITE_ONE_SESSION_ID`, `VITE_RECETTE`) dans les fichiers `.env` des frontends Vite locaux. Permet de pointer un front local sur un backend de recette sans relogin manuel. + +## Stack + +- **Node >= 24**, projet **ESM** (`"type": "module"`) — tous les imports internes portent l'extension `.js` (résolution NodeNext), même depuis des sources `.ts`. +- **pnpm** (gestionnaire de paquets). +- **commander** (CLI), **inquirer** (prompts interactifs), **ora** (spinners), **chalk** (couleurs). +- **tsup** pour le build, **tsx** pour l'exécution en dev, **vitest** pour les tests, **eslint** + **prettier**. + +## Commandes + +```bash +pnpm install +pnpm run dev # exécute via tsx sans build (ex: pnpm run dev connect) +pnpm run build # bundle ESM dans dist/ via tsup +pnpm start # node dist/index.js (après build) +pnpm run typecheck # tsc --noEmit sur src + tests (tsconfig.typecheck.json) +pnpm test # vitest run (une passe). Un seul fichier : pnpm test tests/utils/envFile.test.ts +pnpm run test:watch # vitest en mode watch +pnpm run lint # eslint sur src/ et tests/ +pnpm run format # prettier --write +pnpm run format:check +``` + +> Le build (tsup) type via `tsconfig.json` (src uniquement). `pnpm run typecheck` couvre **aussi** `tests/` via `tsconfig.typecheck.json` — c'est là que les erreurs de type des tests sont détectées (CI : typecheck → lint → format:check → test). + +Le binaire (`bin/dev-auth-fetcher`) charge `dist/index.js` : il faut **builder avant** de lancer via `node bin/...`. Commandes CLI : `onboard`, `connect`, `list-apps`, `reconnect-last` (voir README pour les options). + +## Architecture + +Flux en couches, du plus haut au plus bas niveau : + +- **`src/cli/`** — entrée commander (`index.ts`) et `commands/` : chaque commande délègue à un service. +- **`src/services/`** — orchestrateurs. `EnvSyncService` pilote `connect` (choix env → apps → identifiant → auth → écriture .env) ; `OnboardingService` pilote `onboard`. +- **`src/steps/`** — étapes interactives réutilisables (prompts inquirer), regroupées par parcours (`connect/`, `onboarding/`). Les services composent ces steps. +- **`src/core/`** — logique métier sans I/O interactif : + - `auth/` — `IAuthClient` + `FetchAuthClient` : POST `x-www-form-urlencoded` sur `/auth/login` avec `redirect: 'manual'` et timeout (`AbortController`), parse les `Set-Cookie`. Valide `authenticated=true` puis extrait `XSRF-TOKEN` (URL-décodé) et `oneSessionId`. Injecté dans `EnvSyncService` (constructeur) → service testable sans réseau. + - `env/EnvManager` — applique le patch de cookies aux `.env`, **en préservant les variables existantes**. + - `apps/AppDiscovery` — scanne `appsRoot` ; détecte **4 schémas** : `/frontend`, `/` directe (`.env` + `package.json` sans sous-dossier `frontend`), `entcore//frontend`, et `entcore//src/main/ts` (id = `entcore/`). +- **`src/config/`** — `appConfig` (`~/.dev-auth-fetcher/config.json` : `appsRoot`, `defaultEnvironment`), `envConfigs` (environnements dans `~/.dev-auth-fetcher/environments/`, seedés depuis `DEFAULT_ENVIRONMENTS` — source partagée versionnée dans le code), `credentialsStore` (profils + historique des connexions récentes, **par utilisateur**, dans `~/.dev-auth-fetcher/credentials/`), `config.types.ts` (types + `VITE_ENV_KEYS`). +- **`src/utils/`** — `envFile` (parse/merge/write `.env`), `paths` (helpers cross-platform), `logger`, classes d'erreurs (`errors.ts`). + +## Conventions & points de vigilance + +- **Cross-platform** (macOS / Linux / Windows) : toujours passer par `path.join`/`path.resolve` (helpers dans `utils/paths.ts`), jamais de `/` concaténé en dur. +- **Données utilisateur centralisées hors du repo** : config + credentials vivent dans `~/.dev-auth-fetcher/` (`config.json` + `credentials/.json`), surchargeable via `DEV_AUTH_FETCHER_HOME` (`getUserDataDir` dans `utils/paths.ts`). `` = `DEV_AUTH_USER` ?? `os.userInfo().username`. `loadAppConfig`/`loadUserCredentialsStore` migrent une fois les anciens emplacements relatifs au cwd (`config/app.config.json`, `./.dev-auth-fetcher/`). Ne jamais logger de mot de passe. +- **Préserver les `.env` cibles** : `updateAppEnv` re-merge l'existant ; seules les clés `VITE_*` (cf. `VITE_ENV_KEYS`) sont écrasées, avec des commentaires d'en-tête (login + date). +- **Erreurs typées** : lever les classes de `utils/errors.ts` (`AuthError`, `AppConfigError`, `EnvFileError`, `AppDiscoveryError`) plutôt que des `Error` nus. +- **Imports** : règle eslint `import/order` (groupes triés, `newlines-between: always`). Prettier : `singleQuote: true`, `semi: true`, `printWidth: 100`. +- **Tests** dans `tests/`, miroir de `src/`, alias `@` → `src` (cf. `vitest.config.ts`). `tsconfig` exclut `tests` du build. +- **Tout l'état runtime vit dans `~/.dev-auth-fetcher/`** (config + credentials + environments) → la CLI est **indépendante du `process.cwd()`** et fonctionne en install global, pas seulement depuis la racine du repo. La liste partagée d'environnements est versionnée **dans le code** (`DEFAULT_ENVIRONMENTS`) et seedée vers le dossier utilisateur ; `listEnvironments` seede au premier usage (migration unique de l'ancien `config/environments/` si présent, sinon défauts du code). diff --git a/README.md b/README.md index 7fb589d..db82a5d 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ pnpm run dev onboard node bin/dev-auth-fetcher onboard ``` -Vous serez invité à saisir le chemin du répertoire contenant vos applications : soit des dossiers `/frontend` à la racine, soit (ou en plus) un sous-dossier `entcore` avec des apps `entcore//frontend`. Les fichiers dans `config/environments/` (recette-ode1, recette-ode2, recette-release, local) sont créés par défaut. +Vous serez invité à saisir le chemin du répertoire contenant vos applications : soit des dossiers `/frontend` à la racine, soit (ou en plus) un sous-dossier `entcore` avec des apps `entcore//frontend`. Les environnements par défaut (recette-ode1…ode4, recette-release, local) sont seedés dans `~/.dev-auth-fetcher/environments/`. ### Connexion et injection des cookies @@ -60,8 +60,14 @@ Options de la commande `connect` : - `-a, --app ` : nom ou id d'application (ex. `mon-app` ou `entcore/mediacentre` pour une app sous entcore) - `--all` : cibler toutes les applications détectées - `-l, --login ` : login utilisateur (sinon demandé en interactif) +- `--watch` : maintenir la session vivante par keep-alive (voir [Mode watch](#mode-watch--watch)) +- `--watch-interval ` : intervalle des pings keep-alive (défaut 2) -Lors du premier `connect` pour un environnement, vous saisissez **login**, **mot de passe** et éventuellement un **rôle** (ex. Enseignant, Élève) pour identifier le compte. Après une connexion réussie, ces informations sont enregistrées. Aux connexions suivantes pour le même environnement, vous pouvez choisir un identifiant déjà enregistré (affiché avec le rôle entre parenthèses) ou « Nouvel identifiant ». Les credentials sont stockés par environnement et par utilisateur dans un fichier **non versionné** (voir [Structure des fichiers](#structure-des-fichiers)). +Lors du premier `connect` pour un environnement, vous saisissez **login**, **mot de passe** et éventuellement un **rôle** (ex. Enseignant, Élève) pour identifier le compte. Après une connexion réussie, ces informations sont enregistrées. Aux connexions suivantes pour le même environnement, vous pouvez choisir un identifiant déjà enregistré (affiché avec le rôle entre parenthèses) ou « Nouvel identifiant ». Les identifiants sont **triés en faisant remonter les derniers utilisés**. Les credentials sont stockés par environnement et par utilisateur dans un fichier **non versionné** (voir [Structure des fichiers](#structure-des-fichiers)). + +#### Reconnexions rapides + +En mode interactif (sans `-e`), `connect` propose en tête de liste les **dernières connexions** (jusqu'à 3 : combo *environnement / login / apps*), chacune annotée de sa fraîcheur (« connecté il y a 3h12 », avec ⚠️ si la session est probablement expirée). Sélectionnez-en une pour la rejouer directement. Pratique quand on jongle entre plusieurs sujets avec des comptes différents. Exemples : @@ -81,6 +87,22 @@ dev-auth-fetcher reconnect-last À utiliser après avoir fait au moins une fois `connect` (avec sélection d'apps). Si aucune dernière connexion n'est enregistrée, un message vous invitera à lancer d'abord `connect`. +### Mode watch (`--watch`) + +Les sessions de recette expirent après une période d'inactivité : votre front local se met alors à recevoir des 401. Le mode `--watch` **maintient la session vivante** par un **ping keep-alive régulier** (premier plan, `Ctrl+C` pour arrêter) : + +```bash +dev-auth-fetcher connect -e recette-ode1 -a mon-app --watch +dev-auth-fetcher connect -e recette-ode1 -a mon-app --watch --watch-interval 3 # ping toutes les 3 min +``` + +À chaque intervalle (défaut **2 min**, réglable via `--watch-interval `), l'outil sonde la session. L'intervalle doit rester **inférieur au timeout d'inactivité du serveur** (observé ≈ 5 min sur les recettes) pour que le ping réarme la session avant son expiration : + +- **session vivante** → rien n'est touché. Le ping lui-même réarme le timeout d'inactivité côté serveur, donc la session reste active **sans réécrire le `.env`** et **sans reload Vite**. +- **session tombée** → l'outil **ré-authentifie et réinjecte** les `.env`. C'est le **seul** moment où le `.env` change. + +> ⚠️ **À savoir** : quand le `.env` est réécrit (uniquement à la ré-authentification), Vite **surveille les `.env`**, **redémarre le dev-server** et **recharge la page** (perte de l'état HMR). En keep-alive nominal, ça n'arrive pas — le reload n'a lieu que si la session a réellement expiré. + ### Lister les applications Affiche les applications détectées (avec dossier `frontend`) dans le répertoire configuré : @@ -91,23 +113,26 @@ dev-auth-fetcher list-apps ## Structure des fichiers -- **Configuration globale** : `config/app.config.json` - - `appsRoot` : chemin racine des applications - - `defaultEnvironment` : environnement par défaut +Les données **non versionnées** (propres à chaque dev) sont centralisées dans **un seul dossier**, hors du repo : `~/.dev-auth-fetcher/` (surchargeable via la variable d'environnement `DEV_AUTH_FETCHER_HOME`). + +- **Configuration utilisateur** : `~/.dev-auth-fetcher/config.json` + - `appsRoot` : chemin racine des applications + - `defaultEnvironment` : environnement par défaut -- **Environnements** : un fichier par environnement dans `config/environments/` - - Ex. `recette-ode1.json` : `{ "id", "label", "url" }` +- **Identifiants enregistrés** : `~/.dev-auth-fetcher/credentials/.json` + - `userId` = nom d'utilisateur système par défaut ; surchargeable via `DEV_AUTH_USER`. + - Contenu : profils par environnement (login, mot de passe, rôle optionnel) et **historique des dernières connexions** (jusqu'à 3 : env, login, apps, horodatage et expiration estimée) pour les reconnexions rapides et `reconnect-last`. + + > Migration automatique : si d'anciens fichiers existent (`config/app.config.json` et `./.dev-auth-fetcher/credentials/` à la racine du repo), ils sont repris une fois vers `~/.dev-auth-fetcher/` au premier lancement. + +- **Environnements** : un fichier par environnement dans `~/.dev-auth-fetcher/environments/` (ex. `recette-ode1.json` : `{ "id", "label", "url" }`). + - La **liste partagée par défaut** est définie dans le code (`DEFAULT_ENVIRONMENTS`, versionné) et seedée automatiquement au premier usage. Pour ajouter/modifier un environnement partagé, éditer ce tableau ; pour un environnement perso, déposer un `.json` dans le dossier ci-dessus. - **Fichier .env cible** (dans chaque `application/frontend/.env` ou `entcore/application/frontend/.env`) : - `VITE_XSRF_TOKEN=...` - `VITE_ONE_SESSION_ID=...` - `VITE_RECETTE=` -- **Identifiants enregistrés** (par utilisateur, **non versionnés**, répertoire dans `.gitignore`) : - - Répertoire : `.dev-auth-fetcher/credentials/` (à la racine du répertoire depuis lequel vous lancez la CLI). - - Fichier : `.json` (par défaut `userId` = nom d'utilisateur système ; peut être surchargé avec la variable d'environnement `DEV_AUTH_USER`). - - Contenu : profils par environnement (login, mot de passe, rôle optionnel) et dernière connexion (env, login, apps) pour `reconnect-last`. - ## Scripts | Commande | Description | diff --git a/config/app.config.json b/config/app.config.json deleted file mode 100644 index 2e586b1..0000000 --- a/config/app.config.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "appsRoot": "/Volumes/Work", - "defaultEnvironment": "recette-ode1", - "profiles": [] -} \ No newline at end of file diff --git a/config/environments/local.json b/config/environments/local.json deleted file mode 100644 index cdf2675..0000000 --- a/config/environments/local.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "id": "local", - "label": "Local", - "url": "http://localhost:8090/" -} \ No newline at end of file diff --git a/config/environments/recette-ode1.json b/config/environments/recette-ode1.json deleted file mode 100644 index 3ca8c1b..0000000 --- a/config/environments/recette-ode1.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "id": "recette-ode1", - "label": "Recette ODE 1", - "url": "https://recette-ode1.opendigitaleducation.com/" -} \ No newline at end of file diff --git a/config/environments/recette-ode2.json b/config/environments/recette-ode2.json deleted file mode 100644 index 19d50af..0000000 --- a/config/environments/recette-ode2.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "id": "recette-ode2", - "label": "Recette ODE 2", - "url": "https://recette-ode2.opendigitaleducation.com/" -} \ No newline at end of file diff --git a/config/environments/recette-ode3.json b/config/environments/recette-ode3.json deleted file mode 100644 index 9fd944b..0000000 --- a/config/environments/recette-ode3.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "id": "recette-ode3", - "label": "Recette ODE 3", - "url": "https://recette-ode3.opendigitaleducation.com/" -} \ No newline at end of file diff --git a/config/environments/recette-ode4.json b/config/environments/recette-ode4.json deleted file mode 100644 index 2fddcad..0000000 --- a/config/environments/recette-ode4.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "id": "recette-ode4", - "label": "Recette ODE 4", - "url": "https://recette-ode4.opendigitaleducation.com/" -} diff --git a/config/environments/recette-release.json b/config/environments/recette-release.json deleted file mode 100644 index b1554bc..0000000 --- a/config/environments/recette-release.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "id": "recette-release", - "label": "Recette Release", - "url": "https://recette-release.opendigitaleducation.com/" -} \ No newline at end of file diff --git a/old/config.json b/old/config.json deleted file mode 100644 index cef3d50..0000000 --- a/old/config.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "recettes": [ - "https://recette-ode1.opendigitaleducation.com/", - "http://localhost:8090/", - "https://recette-release.opendigitaleducation.com/" - ], - "profils": [ - { - "login": "stephane.loison", - "password": "EasyDemo2011", - "pi": "Enseignant" - }, - { - "login": "isabelle.fricot", - "password": "EasyDemo2011", - "pi": "Enseignant - Postman" - }, - { - "login": "isabelle.polonio", - "password": "EasyDemo2011" - }, - { - "login": "olympe", - "password": "EasyDemo2011", - "pi": "Eleve sans aucun fil" - }, - { - "login": "romuald.coulaud", - "password": "EasyDemo2011", - "pi": "ADML avec fil" - }, - { - "login": "albus", - "password": "EasyDemo2011" - }, - { - "login": "hadda.karamoko", - "password": "EasyDemo2011", - "pi": "ADML sans aucun fil" - }, - { - "login": "peggy.barreau", - "password": "EasyDemo2011" - }, - { - "login": "rogue", - "password": "EasyDemo2011" - }, - { - "login": "luc.ien", - "password": "EasyDemo2011" - }, - { - "login": "claire.lemarquis", - "password": "password" - } - ] -} \ No newline at end of file diff --git a/old/config.template.json b/old/config.template.json deleted file mode 100644 index 26c4dff..0000000 --- a/old/config.template.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "recettes": [ - "https://recette-url1.com/", - "https://recette-url2.com/" - ], - "profils": [ - { - "login": "login.1", - "password": "password.1" - }, - { - "login": "login.2", - "password": "password.2" - } - ] - } \ No newline at end of file diff --git a/old/index.cjs b/old/index.cjs deleted file mode 100644 index 93bb320..0000000 --- a/old/index.cjs +++ /dev/null @@ -1,173 +0,0 @@ -const fs = require('fs'); -const { chromium } = require('playwright'); -const inquirer = require('inquirer'); -const configPath = `${__dirname}/config.json`; - -const isAutoMode = process.argv.includes('--auto'); -let browser; - -process.on('SIGINT', async () => { - console.log('🚨 Signal SIGINT détecté. Fermeture de Playwright...'); - if (browser) { - await browser.close(); - console.log('✅ Playwright fermé.'); - } - process.exit(0); -}); - -if (!fs.existsSync(configPath)) { - console.log('⚠️ Aucun fichier config.json trouvé. Création en cours...'); - fs.writeFileSync( - configPath, - JSON.stringify({ recettes: [], profils: [] }, null, 2), - ); - console.log('✅ Fichier config.json créé.'); -} - -let config = require(configPath); - -async function initialiserConfig() { - if (!config.recettes || config.recettes.length === 0) { - if (isAutoMode) { - console.error( - '❌ Aucune recette disponible en mode auto. Ajoutez-en une dans config.json.', - ); - process.exit(1); - } - const recetteAnswer = await inquirer.prompt([ - { - type: 'input', - name: 'recette', - message: "🌍 Entrez l'URL de la recette :", - }, - ]); - config.recettes = [recetteAnswer.recette]; - } - fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); -} - -async function choisirProfil() { - if (config.profils.length === 0 || isAutoMode) { - if (config.profils.length === 0) { - const newProfil = await inquirer.prompt([ - { type: 'input', name: 'login', message: '🆕 Entrez le login :' }, - { - type: 'password', - name: 'password', - message: '🔒 Entrez le mot de passe :', - }, - ]); - return newProfil; - } - return config.profils[0]; - } - - const profiles = config.profils.map((profil, index) => ({ - name: profil.login, - value: index, - })); - profiles.push({ name: '🔑 Ajouter un nouveau profil', value: 'new' }); - - const profilAnswer = await inquirer.prompt([ - { - type: 'list', - name: 'profilIndex', - message: '📌 Sélectionnez un profil :', - choices: profiles, - }, - ]); - - if (profilAnswer.profilIndex === 'new') { - return await inquirer.prompt([ - { type: 'input', name: 'login', message: '🆕 Entrez le login :' }, - { - type: 'password', - name: 'password', - message: '🔒 Entrez le mot de passe :', - }, - ]); - } - - return config.profils[profilAnswer.profilIndex]; -} - -(async () => { - try { - await initialiserConfig(); - config = require(configPath); - - let selectedRecette = - config.recettes.length === 1 || isAutoMode - ? config.recettes[0] - : ( - await inquirer.prompt([ - { - type: 'list', - name: 'recette', - message: '📌 Sélectionnez une recette :', - choices: config.recettes, - }, - ]) - ).recette; - - console.log(`✅ Recette sélectionnée : ${selectedRecette}`); - - let selectedProfil = await choisirProfil(); - console.log( - `🌍 Connexion en tant que ${selectedProfil.login} sur ${selectedRecette}`, - ); - - browser = await chromium.launch({ headless: true }); - const context = await browser.newContext(); - const page = await context.newPage(); - - await page.goto(selectedRecette, { waitUntil: 'networkidle' }); - - await page.fill('#email', selectedProfil.login); - await page.fill('#password', selectedProfil.password); - await page.click('button.flex-magnet-bottom-right'); - - try { - await page.waitForSelector('.avatar', { timeout: 10000 }); - } catch (e) { - console.log('⏳ Temps de navigation dépassé, on continue...'); - } - - const cookies = await context.cookies(); - const xsrfToken = cookies.find((c) => c.name === 'XSRF-TOKEN')?.value || ''; - const sessionId = - cookies.find((c) => c.name === 'oneSessionId')?.value || ''; - - if (!xsrfToken || !sessionId) { - console.error( - '❌ Échec de la connexion. Vérifiez les identifiants et réessayez.', - ); - await browser.close(); - process.exit(1); - } - - console.log('🔑 Connexion réussie, récupération des cookies...'); - const now = new Date(); - const timestamp = now.toLocaleString('fr-FR', { timeZone: 'Europe/Paris' }); - const envContent = `# Connected as: ${selectedProfil.login}\n# Date: ${timestamp}\n\nVITE_XSRF_TOKEN=${xsrfToken}\nVITE_ONE_SESSION_ID=${sessionId}\nVITE_RECETTE=${selectedRecette}\n`; - fs.writeFileSync('.env', envContent); - console.log('✅ Cookies enregistrés dans .env'); - - if ( - !config.profils.some((profil) => profil.login === selectedProfil.login) && - !isAutoMode - ) { - console.log('🔑 Nouveau profil ajouté à la configuration.'); - config.profils.push(selectedProfil); - fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); - } - } catch (error) { - console.error('❌ Une erreur est survenue lors de la connexion:', error); - } finally { - if (browser) { - await browser.close(); - console.log('✅ Navigateur Playwright fermé.'); - } - process.exit(0); - } -})(); diff --git a/package.json b/package.json index 85dc3d0..91e6071 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,12 @@ "dev": "tsx src/cli/index.ts", "build": "tsup src/cli/index.ts --out-dir dist --format esm --dts --sourcemap --no-splitting", "start": "node dist/index.js", + "typecheck": "tsc -p tsconfig.typecheck.json", "lint": "eslint \"src/**/*.ts\" \"tests/**/*.ts\"", "format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\"", "format:check": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\"", - "test": "vitest" + "test": "vitest run", + "test:watch": "vitest" }, "engines": { "node": ">=24" diff --git a/src/cli/commands/connect.ts b/src/cli/commands/connect.ts index a651958..0573888 100644 --- a/src/cli/commands/connect.ts +++ b/src/cli/commands/connect.ts @@ -1,10 +1,8 @@ import { EnvSyncService, type ConnectOptions } from '../../services/EnvSyncService.js'; -import { createLogger } from '../../utils/logger.js'; +import { logger } from '../../utils/logger.js'; export type { ConnectOptions }; -const logger = createLogger(); - export async function runConnectCommand(options: ConnectOptions): Promise { logger.info('🔗 Connexion aux environnements de recette…'); diff --git a/src/cli/commands/list-apps.ts b/src/cli/commands/list-apps.ts index 5fd9290..456231d 100644 --- a/src/cli/commands/list-apps.ts +++ b/src/cli/commands/list-apps.ts @@ -1,8 +1,6 @@ import { loadAppConfig } from '../../config/appConfig.js'; import { discoverApps } from '../../core/apps/AppDiscovery.js'; -import { createLogger } from '../../utils/logger.js'; - -const logger = createLogger(); +import { logger } from '../../utils/logger.js'; export async function runListAppsCommand(): Promise { const config = await loadAppConfig(); diff --git a/src/cli/commands/onboard.ts b/src/cli/commands/onboard.ts index f3da47c..0956d82 100644 --- a/src/cli/commands/onboard.ts +++ b/src/cli/commands/onboard.ts @@ -1,7 +1,5 @@ import { OnboardingService } from '../../services/OnboardingService.js'; -import { createLogger } from '../../utils/logger.js'; - -const logger = createLogger(); +import { logger } from '../../utils/logger.js'; export async function runOnboardCommand(): Promise { logger.info('🔧 Onboarding du projet CLI…'); diff --git a/src/cli/commands/reconnect-last.ts b/src/cli/commands/reconnect-last.ts index b487088..9f345eb 100644 --- a/src/cli/commands/reconnect-last.ts +++ b/src/cli/commands/reconnect-last.ts @@ -1,8 +1,10 @@ import { getLastConnection } from '../../config/credentialsStore.js'; -import { EnvSyncService } from '../../services/EnvSyncService.js'; -import { createLogger } from '../../utils/logger.js'; - -const logger = createLogger(); +import { + EnvSyncService, + describeLastConnectionApps, + reconnectOptionsFromLast, +} from '../../services/EnvSyncService.js'; +import { logger } from '../../utils/logger.js'; export async function runReconnectLastCommand(): Promise { const last = await getLastConnection(); @@ -17,19 +19,9 @@ export async function runReconnectLastCommand(): Promise { const hasAppSelection = last.allApps === true || (appIdsOrNames?.length ?? 0) > 0; logger.info( hasAppSelection - ? `Reconnexion automatique : ${last.envId} / ${last.login} (${last.allApps ? 'toutes les apps' : appIdsOrNames!.join(', ')})` + ? `Reconnexion automatique : ${last.envId} / ${last.login} (${describeLastConnectionApps(last)})` : `Reconnexion : ${last.envId} / ${last.login} (sélection des apps à choisir)` ); - const service = new EnvSyncService(); - await service.runInteractive({ - env: last.envId, - login: last.login, - ...(last.allApps === true - ? { all: true } - : appIdsOrNames?.length - ? { apps: appIdsOrNames } - : {}), - skipConfirm: hasAppSelection, - }); + await new EnvSyncService().runInteractive(reconnectOptionsFromLast(last)); } diff --git a/src/cli/index.ts b/src/cli/index.ts index 7209641..06e39fe 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -30,6 +30,15 @@ program .option('-a, --app ', "Nom de l'application cible") .option('--all', 'Cibler toutes les applications détectées') .option('-l, --login ', "Login de l'utilisateur à utiliser") + .option( + '--watch', + 'Garder la session vivante par des pings keep-alive (ré-auth + réinjection seulement si elle tombe)' + ) + .option( + '--watch-interval ', + 'Intervalle des pings keep-alive en minutes (défaut : 5)', + (v) => parseInt(v, 10) + ) .action(async (options) => { await runConnectCommand(options); }); diff --git a/src/config/appConfig.ts b/src/config/appConfig.ts index d5245f1..f773832 100644 --- a/src/config/appConfig.ts +++ b/src/config/appConfig.ts @@ -2,50 +2,56 @@ import * as fs from 'fs/promises'; import * as path from 'path'; import { AppConfigError } from '../utils/errors.js'; -import { getAppConfigPath, getConfigDir, getEnvironmentsConfigDir } from '../utils/paths.js'; +import { getAppConfigPath, getLegacyAppConfigPath, getUserDataDir } from '../utils/paths.js'; import type { AppConfig } from './config.types.js'; const DEFAULT_APP_CONFIG: AppConfig = { appsRoot: '', defaultEnvironment: 'recette-ode1', - profiles: [], }; -/** - * Charge le fichier app.config.json. Retourne null si absent. - */ -export async function loadAppConfig(): Promise { - const configPath = getAppConfigPath(); +/** Parse et valide un fichier de config ; lève sur JSON/appsRoot invalide, null si absent. */ +async function readConfigFile(filePath: string): Promise { try { - const content = await fs.readFile(configPath, 'utf-8'); + const content = await fs.readFile(filePath, 'utf-8'); const data = JSON.parse(content) as AppConfig; if (!data.appsRoot || typeof data.appsRoot !== 'string') { - throw new AppConfigError('appsRoot manquant ou invalide dans app.config.json'); + throw new AppConfigError('appsRoot manquant ou invalide dans la config'); } - return { - ...DEFAULT_APP_CONFIG, - ...data, - }; + return { ...DEFAULT_APP_CONFIG, ...data }; } catch (err) { const nodeErr = err as NodeJS.ErrnoException; - if (nodeErr.code === 'ENOENT') { - return null; - } + if (nodeErr.code === 'ENOENT') return null; + if (err instanceof AppConfigError) throw err; throw new AppConfigError( - `Impossible de charger app.config.json: ${nodeErr instanceof Error ? nodeErr.message : String(err)}` + `Impossible de charger la config: ${err instanceof Error ? err.message : String(err)}` ); } } /** - * Sauvegarde la configuration globale. + * Charge la config utilisateur (~/.dev-auth-fetcher/config.json). Retourne null si absente. + * Migre une fois l'ancien fichier versionné (config/app.config.json) s'il existe encore. + */ +export async function loadAppConfig(): Promise { + const current = await readConfigFile(getAppConfigPath()); + if (current) return current; + + const legacy = await readConfigFile(getLegacyAppConfigPath()); + if (legacy) { + await saveAppConfig(legacy); + return legacy; + } + return null; +} + +/** + * Sauvegarde la configuration globale dans le dossier de données utilisateur. */ export async function saveAppConfig(config: AppConfig): Promise { - const configPath = getAppConfigPath(); - const dir = getConfigDir(); - await fs.mkdir(dir, { recursive: true }); - await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8'); + await fs.mkdir(getUserDataDir(), { recursive: true }); + await fs.writeFile(getAppConfigPath(), JSON.stringify(config, null, 2), 'utf-8'); } /** @@ -88,9 +94,6 @@ export async function setDefaultEnvironmentId(envId: string): Promise { export async function ensureAppConfigExists(): Promise { const config = await loadAppConfig(); if (config) return config; - const dir = getConfigDir(); - await fs.mkdir(dir, { recursive: true }); - await fs.mkdir(getEnvironmentsConfigDir(), { recursive: true }); const newConfig: AppConfig = { ...DEFAULT_APP_CONFIG, appsRoot: process.cwd() }; await saveAppConfig(newConfig); return newConfig; diff --git a/src/config/config.types.ts b/src/config/config.types.ts index 9540820..34c588b 100644 --- a/src/config/config.types.ts +++ b/src/config/config.types.ts @@ -5,8 +5,6 @@ export interface AppConfig { appsRoot: string; defaultEnvironment: string; - lastUsedProfile?: string; - profiles?: UserProfile[]; } export interface UserProfile { diff --git a/src/config/credentialsStore.ts b/src/config/credentialsStore.ts index b8de505..12a8f70 100644 --- a/src/config/credentialsStore.ts +++ b/src/config/credentialsStore.ts @@ -2,10 +2,9 @@ import * as fs from 'fs/promises'; import * as os from 'os'; import * as path from 'path'; -import type { UserProfile } from './config.types.js'; +import { getCredentialsDir, getLegacyCredentialsDir } from '../utils/paths.js'; -const CREDENTIALS_DIR = '.dev-auth-fetcher'; -const CREDENTIALS_SUBDIR = 'credentials'; +import type { UserProfile } from './config.types.js'; export interface LastConnection { envId: string; @@ -18,14 +17,27 @@ export interface LastConnection { appNames?: string[]; } +export interface RecentConnection extends LastConnection { + /** ms epoch — moment de la connexion. Sert aussi de clé de récence (tri des logins). */ + connectedAt: number; + /** ms epoch — expiration estimée (Max-Age/Expires du cookie de session) ; absent si inconnu. */ + expiresAt?: number; +} + export interface UserCredentialsStore { environmentProfiles: Record; + /** plus-récent-d'abord, dédupliqué par signature, plafonné à RECENT_CONNECTIONS_CAP. */ + recentConnections?: RecentConnection[]; + /** legacy mono-entrée : lu pour migration uniquement, plus écrit. */ lastConnection?: LastConnection; } +/** Nombre maximum de connexions récentes conservées. */ +const RECENT_CONNECTIONS_CAP = 3; + const DEFAULT_STORE: UserCredentialsStore = { environmentProfiles: {}, - lastConnection: undefined, + recentConnections: [], }; /** @@ -36,33 +48,62 @@ export function getUserId(): string { } /** - * Chemin du fichier de credentials pour l'utilisateur courant (relatif à process.cwd()). + * Chemin du fichier de credentials pour l'utilisateur courant (~/.dev-auth-fetcher/credentials). */ export function getUserCredentialsPath(): string { - return path.join(process.cwd(), CREDENTIALS_DIR, CREDENTIALS_SUBDIR, getUserId() + '.json'); + return path.join(getCredentialsDir(), getUserId() + '.json'); +} + +/** Ancien chemin du fichier de credentials (relatif au cwd), pour migration unique. */ +function getLegacyUserCredentialsPath(): string { + return path.join(getLegacyCredentialsDir(), getUserId() + '.json'); } /** - * Charge le store des credentials de l'utilisateur. Retourne un store vide si le fichier n'existe pas. + * Normalise l'historique des connexions au chargement, en migrant l'ancien champ + * mono-entrée `lastConnection` vers `recentConnections` si nécessaire. */ -export async function loadUserCredentialsStore(): Promise { - const filePath = getUserCredentialsPath(); +function migrateRecentConnections(data: UserCredentialsStore): RecentConnection[] { + if (Array.isArray(data.recentConnections) && data.recentConnections.length > 0) { + return data.recentConnections; + } + if (data.lastConnection) { + return [{ ...data.lastConnection, connectedAt: Date.now() }]; + } + return []; +} + +/** Lit et normalise un fichier store ; null si absent. */ +async function readStoreFile(filePath: string): Promise { try { const content = await fs.readFile(filePath, 'utf-8'); const data = JSON.parse(content) as UserCredentialsStore; return { environmentProfiles: data.environmentProfiles ?? {}, - lastConnection: data.lastConnection, + recentConnections: migrateRecentConnections(data), }; } catch (err) { - const nodeErr = err as NodeJS.ErrnoException; - if (nodeErr.code === 'ENOENT') { - return { ...DEFAULT_STORE }; - } + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null; throw err; } } +/** + * Charge le store des credentials de l'utilisateur. Retourne un store vide s'il n'existe pas. + * Migre une fois l'ancien fichier (relatif au cwd) vers le dossier de données utilisateur. + */ +export async function loadUserCredentialsStore(): Promise { + const current = await readStoreFile(getUserCredentialsPath()); + if (current) return current; + + const legacy = await readStoreFile(getLegacyUserCredentialsPath()); + if (legacy) { + await saveUserCredentialsStore(legacy); + return legacy; + } + return { ...DEFAULT_STORE }; +} + /** * Sauvegarde le store des credentials (crée le répertoire si besoin). */ @@ -103,28 +144,71 @@ export async function addOrUpdateProfileForEnvironment( } /** - * Retourne la dernière connexion (envId + login) si disponible. + * Signature d'une connexion : env + login + jeu d'apps. Rend distincts les logins et + * les jeux d'apps différents, déduplique le même combo (insensible à l'ordre des apps). */ -export async function getLastConnection(): Promise { +export function connectionSignature( + c: Pick +): string { + const apps = c.allApps ? 'ALL' : [...(c.appIds ?? c.appNames ?? [])].sort().join(','); + return `${c.envId}::${c.login}::${apps}`; +} + +/** + * Retourne les connexions récentes (plus-récent-d'abord). + */ +export async function getRecentConnections(): Promise { const store = await loadUserCredentialsStore(); - return store.lastConnection ?? null; + return store.recentConnections ?? []; +} + +/** + * Retourne la connexion la plus récente si disponible (rétrocompatibilité reconnect-last). + */ +export async function getLastConnection(): Promise { + const recents = await getRecentConnections(); + return recents[0] ?? null; +} + +/** + * Récence par login pour un environnement : `connectedAt` maximum observé par login. + * Utilisé pour faire remonter les derniers identifiants utilisés. + */ +export async function getLoginRecency(envId: string): Promise> { + const recents = await getRecentConnections(); + const recency = new Map(); + for (const c of recents) { + if (c.envId !== envId) continue; + const prev = recency.get(c.login) ?? 0; + if (c.connectedAt > prev) recency.set(c.login, c.connectedAt); + } + return recency; } /** - * Enregistre la dernière connexion (envId, login, et sélection d'apps) dans le store utilisateur. + * Enregistre une connexion (envId, login, sélection d'apps, expiration) en tête de + * l'historique : déduplique par signature, place en premier, plafonne à RECENT_CONNECTIONS_CAP. */ -export async function setLastConnection( +export async function recordConnection( envId: string, login: string, - appSelection?: { allApps: boolean; appIds: string[]; appNames?: string[] } + appSelection?: { allApps: boolean; appIds: string[]; appNames?: string[] }, + meta?: { expiresAt?: number } ): Promise { const store = await loadUserCredentialsStore(); - store.lastConnection = { + const entry: RecentConnection = { envId, login, allApps: appSelection?.allApps, appIds: appSelection?.appIds?.length ? appSelection.appIds : undefined, appNames: appSelection?.appNames?.length ? appSelection.appNames : undefined, + connectedAt: Date.now(), + expiresAt: meta?.expiresAt, }; + const signature = connectionSignature(entry); + const previous = (store.recentConnections ?? []).filter( + (c) => connectionSignature(c) !== signature + ); + store.recentConnections = [entry, ...previous].slice(0, RECENT_CONNECTIONS_CAP); await saveUserCredentialsStore(store); } diff --git a/src/config/envConfigs.ts b/src/config/envConfigs.ts index 7084964..4169025 100644 --- a/src/config/envConfigs.ts +++ b/src/config/envConfigs.ts @@ -1,14 +1,46 @@ import * as fs from 'fs/promises'; import * as path from 'path'; -import { getEnvironmentsConfigDir } from '../utils/paths.js'; +import { getEnvironmentsConfigDir, getLegacyEnvironmentsConfigDir } from '../utils/paths.js'; import type { EnvironmentConfig } from './config.types.js'; +/** Liste les fichiers .json d'un répertoire ; [] si absent. */ +async function listJsonFiles(dir: string): Promise { + try { + return (await fs.readdir(dir)).filter((n) => n.endsWith('.json')); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw err; + } +} + +/** + * Garantit que le répertoire des environnements (dossier utilisateur) est peuplé : + * migre une fois l'ancien dossier versionné du repo s'il existe, sinon seede les défauts du code. + */ +async function ensureEnvironmentsSeeded(): Promise { + const dir = getEnvironmentsConfigDir(); + if ((await listJsonFiles(dir)).length > 0) return; + + const legacyDir = getLegacyEnvironmentsConfigDir(); + const legacyFiles = legacyDir !== dir ? await listJsonFiles(legacyDir) : []; + if (legacyFiles.length > 0) { + await fs.mkdir(dir, { recursive: true }); + for (const name of legacyFiles) { + await fs.copyFile(path.join(legacyDir, name), path.join(dir, name)); + } + return; + } + + await generateDefaultEnvironmentConfigs(); +} + /** - * Charge tous les fichiers config/environments/*.json et retourne la liste des environnements. + * Charge tous les environnements depuis le dossier utilisateur (seedé si nécessaire). */ export async function listEnvironments(): Promise { + await ensureEnvironmentsSeeded(); const dir = getEnvironmentsConfigDir(); let entries: string[]; try { diff --git a/src/core/auth/AuthClient.ts b/src/core/auth/AuthClient.ts index e81c288..fe3c7a3 100644 --- a/src/core/auth/AuthClient.ts +++ b/src/core/auth/AuthClient.ts @@ -10,8 +10,15 @@ export interface AuthCredentials { export interface AuthCookies { xsrfToken: string; sessionId: string; + /** ms epoch — expiration estimée de la session (Max-Age/Expires du cookie) ; absent si inconnu. */ + expiresAt?: number; } export interface IAuthClient { loginAndGetCookies(envUrl: string, credentials: AuthCredentials): Promise; + /** + * Indique si une session est encore vivante. Sonder l'endpoint maintient aussi + * la session active (timeout d'inactivité glissant côté serveur). + */ + isSessionAlive(envUrl: string, sessionId: string): Promise; } diff --git a/src/core/auth/FetchAuthClient.ts b/src/core/auth/FetchAuthClient.ts index 4862a51..a4c8114 100644 --- a/src/core/auth/FetchAuthClient.ts +++ b/src/core/auth/FetchAuthClient.ts @@ -2,6 +2,17 @@ import { AuthError } from '../../utils/errors.js'; import type { IAuthClient, AuthCredentials, AuthCookies } from './AuthClient.js'; +/** Délai maximum (ms) d'attente d'une réponse du serveur de recette. */ +const DEFAULT_TIMEOUT_MS = 15_000; + +/** Endpoint d'auth léger : 200 si la session est vivante, redirection sinon. */ +const SESSION_PROBE_PATH = '/auth/oauth2/userinfo'; + +export interface FetchAuthClientOptions { + /** Délai maximum d'attente de la réponse, en millisecondes (défaut : 15 000). */ + timeoutMs?: number; +} + /** * Parse une chaîne Set-Cookie pour extraire le nom et la valeur du cookie. * Format typique : "name=value; Path=/; HttpOnly; ..." @@ -16,10 +27,49 @@ function parseCookie(setCookieHeader: string): { name: string; value: string } | return { name, value }; } +/** + * Estime l'expiration (ms epoch) d'un cookie depuis ses attributs `Max-Age` (prioritaire, + * en secondes) ou `Expires` (date HTTP). Retourne undefined si aucun n'est exploitable. + */ +export function parseCookieExpiry( + setCookieHeader: string, + now: number = Date.now() +): number | undefined { + const attributes = setCookieHeader.split(';').slice(1); + for (const attr of attributes) { + const [rawKey, ...rawVal] = attr.split('='); + const key = rawKey.trim().toLowerCase(); + const val = rawVal.join('=').trim(); + if (key === 'max-age') { + const seconds = Number(val); + if (Number.isFinite(seconds)) return now + seconds * 1000; + } + } + for (const attr of attributes) { + const [rawKey, ...rawVal] = attr.split('='); + if (rawKey.trim().toLowerCase() === 'expires') { + const ts = Date.parse(rawVal.join('=').trim()); + if (!Number.isNaN(ts)) return ts; + } + } + return undefined; +} + /** * Implémentation fetch : POST vers /auth/login, extraction des cookies depuis Set-Cookie. + * + * Fragilité connue : dépend de la forme actuelle du flux d'auth ENT (endpoint + * `/auth/login`, body `x-www-form-urlencoded`, cookies `authenticated` / `oneSessionId` + * / `XSRF-TOKEN`). Tout changement côté serveur (2FA, redirection, renommage de cookie) + * casse la connexion — d'où les messages d'erreur explicites ci-dessous. */ export class FetchAuthClient implements IAuthClient { + private readonly timeoutMs: number; + + constructor(options: FetchAuthClientOptions = {}) { + this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + } + async loginAndGetCookies(envUrl: string, credentials: AuthCredentials): Promise { const baseUrl = envUrl.replace(/\/$/, ''); const loginUrl = `${baseUrl}/auth/login`; @@ -31,23 +81,19 @@ export class FetchAuthClient implements IAuthClient { details: '', }).toString(); - const response = await fetch(loginUrl, { - method: 'POST', - headers: { - 'content-type': 'application/x-www-form-urlencoded', - }, - referrer: loginUrl, - redirect: 'manual', - body, - }); + const response = await this.post(loginUrl, body); const setCookies = response.headers.getSetCookie(); const cookieMap = new Map(); + let expiresAt: number | undefined; for (const header of setCookies) { const parsed = parseCookie(header); if (parsed) { cookieMap.set(parsed.name, parsed.value); + if (parsed.name === 'oneSessionId') { + expiresAt = parseCookieExpiry(header); + } } } @@ -74,6 +120,55 @@ export class FetchAuthClient implements IAuthClient { // Conserver la valeur brute si le décodage échoue } - return { xsrfToken, sessionId }; + return { xsrfToken, sessionId, expiresAt }; + } + + /** + * Indique si la session est vivante (GET sur l'endpoint d'auth, 200 = vivante). + * Sonder maintient aussi la session active côté serveur (timeout glissant). + * Lève une AuthError en cas d'échec réseau / dépassement de délai (≠ session morte). + */ + async isSessionAlive(envUrl: string, sessionId: string): Promise { + const baseUrl = envUrl.replace(/\/$/, ''); + const probeUrl = `${baseUrl}${SESSION_PROBE_PATH}`; + const response = await this.fetchWithTimeout(probeUrl, { + method: 'GET', + headers: { cookie: `oneSessionId=${sessionId}` }, + redirect: 'manual', + }); + return response.status === 200; + } + + /** POST `x-www-form-urlencoded` vers l'endpoint de login. */ + private async post(loginUrl: string, body: string): Promise { + return this.fetchWithTimeout(loginUrl, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + referrer: loginUrl, + redirect: 'manual', + body, + }); + } + + /** + * fetch avec timeout. Convertit les échecs réseau / dépassement de délai en AuthError lisible. + */ + private async fetchWithTimeout(url: string, init: RequestInit): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') { + throw new AuthError( + `Délai dépassé (${this.timeoutMs} ms) en contactant ${url}. Le serveur de recette est peut-être indisponible.` + ); + } + throw new AuthError( + `Impossible de contacter ${url} : ${err instanceof Error ? err.message : String(err)}` + ); + } finally { + clearTimeout(timer); + } } } diff --git a/src/services/EnvSyncService.ts b/src/services/EnvSyncService.ts index 7541a92..42c62e7 100644 --- a/src/services/EnvSyncService.ts +++ b/src/services/EnvSyncService.ts @@ -4,22 +4,37 @@ import ora from 'ora'; import { loadAppConfig } from '../config/appConfig.js'; import type { EnvironmentConfig } from '../config/config.types.js'; import { - getProfilesForEnvironment, addOrUpdateProfileForEnvironment, - getLastConnection, - setLastConnection, + getRecentConnections, + recordConnection, + type LastConnection, + type RecentConnection, } from '../config/credentialsStore.js'; import { listEnvironments, getEnvironmentById } from '../config/envConfigs.js'; -import type { AuthCredentials } from '../core/auth/AuthClient.js'; +import type { AppSummary } from '../core/apps/AppDiscovery.js'; +import type { AuthCookies, IAuthClient } from '../core/auth/AuthClient.js'; import { FetchAuthClient } from '../core/auth/FetchAuthClient.js'; import { updateAppsEnv } from '../core/env/EnvManager.js'; import { confirmAndRunStep } from '../steps/connect/ConfirmAndRunStep.js'; +import { + resolveCredentialsStep, + type ResolvedCredentials, +} from '../steps/connect/ResolveCredentialsStep.js'; import { selectAppsStep } from '../steps/connect/SelectAppsStep.js'; -import { createLogger } from '../utils/logger.js'; +import { logger } from '../utils/logger.js'; -const logger = createLogger(); +const RECONNECT_CHOICE = '__reconnect_last__'; -const CHOICE_NEW_CREDENTIALS = '__new__'; +/** Au-delà de ce délai sans info d'expiration, une session est considérée comme probablement périmée. */ +const STALE_AFTER_MS = 8 * 60 * 60 * 1000; +/** + * Intervalle par défaut entre deux pings keep-alive en mode watch. + * Volontairement court : le timeout d'inactivité de la recette observé est ~5 min, donc on + * ping bien avant pour réarmer la session en cours de vie (et non à l'échéance). + */ +const KEEPALIVE_INTERVAL_MS = 2 * 60 * 1000; +/** Intervalle minimum accepté (anti-boucle serrée). */ +const KEEPALIVE_MIN_INTERVAL_MS = 30 * 1000; export interface ConnectOptions { env?: string; @@ -30,12 +45,79 @@ export interface ConnectOptions { login?: string; /** Si true, ne pas demander de confirmation (reconnect-last entièrement automatique). */ skipConfirm?: boolean; + /** Si true, garder la session vivante par des pings keep-alive (ré-auth seulement si elle tombe). */ + watch?: boolean; + /** Intervalle des pings keep-alive en minutes (défaut : 5). */ + watchInterval?: number; +} + +/** Décrit la sélection d'apps d'une connexion (pour l'affichage). */ +export function describeLastConnectionApps(last: LastConnection): string { + if (last.allApps === true) return 'toutes les apps'; + const ids = last.appIds ?? last.appNames; + return ids?.length ? ids.join(', ') : 'apps à choisir'; +} + +/** Formate une durée (ms) en « 3h12 » / « 12 min » / « moins d'une minute ». */ +function formatDuration(ms: number): string { + const totalMin = Math.max(0, Math.round(ms / 60000)); + if (totalMin < 1) return "moins d'une minute"; + const hours = Math.floor(totalMin / 60); + const minutes = totalMin % 60; + return hours === 0 ? `${minutes} min` : `${hours}h${String(minutes).padStart(2, '0')}`; +} + +/** true si la session d'une connexion est (probablement) périmée. */ +export function isSessionStale(c: RecentConnection, now: number = Date.now()): boolean { + if (typeof c.expiresAt === 'number') return now >= c.expiresAt; + return now - c.connectedAt >= STALE_AFTER_MS; +} + +/** « connecté il y a 3h12 » (+ ⚠️ si probablement expiré). */ +export function describeFreshness(c: RecentConnection, now: number = Date.now()): string { + const age = `connecté il y a ${formatDuration(now - c.connectedAt)}`; + return isSessionStale(c, now) ? `${age} ⚠️ probablement expiré` : age; +} + +/** Construit les options `connect` permettant de rejouer une connexion. */ +export function reconnectOptionsFromLast(last: LastConnection): ConnectOptions { + const appIdsOrNames = last.appIds ?? last.appNames; + const hasAppSelection = last.allApps === true || (appIdsOrNames?.length ?? 0) > 0; + return { + env: last.envId, + login: last.login, + ...(last.allApps === true + ? { all: true } + : appIdsOrNames?.length + ? { apps: appIdsOrNames } + : {}), + skipConfirm: hasAppSelection, + }; +} + +/** Sommeil annulable via un AbortSignal. Retourne 'aborted' si interrompu. */ +function sleep(ms: number, signal: AbortSignal): Promise<'done' | 'aborted'> { + return new Promise((resolve) => { + if (signal.aborted) return resolve('aborted'); + const onAbort = () => { + clearTimeout(timer); + resolve('aborted'); + }; + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort); + resolve('done'); + }, ms); + signal.addEventListener('abort', onAbort, { once: true }); + }); } /** - * Orchestration de la commande connect : choix de l'env, des apps, identifiant (existant ou nouveau), auth fetch, mise à jour des .env. + * Orchestration de la commande connect : choix de l'env, des apps, identifiant + * (existant ou nouveau), auth, mise à jour des .env, et mode watch optionnel. */ export class EnvSyncService { + constructor(private readonly authClient: IAuthClient = new FetchAuthClient()) {} + async runInteractive(options: ConnectOptions): Promise { const config = await loadAppConfig(); if (!config?.appsRoot) { @@ -45,68 +127,10 @@ export class EnvSyncService { return; } - let envId = options.env; - let env: EnvironmentConfig | null = null; - - if (envId) { - env = await getEnvironmentById(envId); - if (!env) { - logger.error(`Environnement inconnu : ${envId}`); - return; - } - } else { - const envs = await listEnvironments(); - if (envs.length === 0) { - logger.error("Aucun environnement configuré. Exécutez d'abord : dev-auth-fetcher onboard"); - return; - } - - const last = await getLastConnection(); - const RECONNECT_CHOICE = '__reconnect_last__'; - const envChoices = envs.map((e) => ({ name: `${e.label} (${e.url})`, value: e.id })); - if (last) { - const appsDesc = - last.allApps === true - ? 'toutes les apps' - : (last.appIds?.length ?? last.appNames?.length) - ? (last.appIds ?? last.appNames)!.join(', ') - : 'apps à choisir'; - const quickReconnectLabel = `🔄 Reconnexion rapide : ${last.envId} / ${last.login} (${appsDesc})`; - envChoices.unshift({ name: quickReconnectLabel, value: RECONNECT_CHOICE }); - } - - const { selectedEnvId } = await inquirer.prompt<{ selectedEnvId: string }>([ - { - type: 'list', - name: 'selectedEnvId', - message: "Sélectionnez l'environnement :", - choices: envChoices, - }, - ]); - - if (selectedEnvId === RECONNECT_CHOICE && last) { - const appIdsOrNames = last.appIds ?? last.appNames; - const hasAppSelection = last.allApps === true || (appIdsOrNames?.length ?? 0) > 0; - await this.runInteractive({ - env: last.envId, - login: last.login, - ...(last.allApps === true - ? { all: true } - : appIdsOrNames?.length - ? { apps: appIdsOrNames } - : {}), - skipConfirm: hasAppSelection, - }); - return; - } - - envId = selectedEnvId; - env = (await getEnvironmentById(envId)) ?? null; - } - if (!env || !envId) { - logger.error('Environnement non trouvé.'); - return; - } + const resolved = await this.resolveEnvironment(options); + if (resolved === 'reconnect') return; // une reconnexion rapide a déjà été rejouée + if (!resolved) return; + const { env } = resolved; const { apps, allSelected } = await selectAppsStep(config.appsRoot, { app: options.app, @@ -118,92 +142,10 @@ export class EnvSyncService { return; } - const savedProfiles = await getProfilesForEnvironment(envId); - let login: string; - let password: string; - let role: string | undefined; - let shouldSaveAfterSuccess = false; - - if (options.login) { - login = options.login; - const existing = savedProfiles.find((p) => p.login === login); - if (existing) { - password = existing.password; - role = existing.role; - } else { - const result = await inquirer.prompt<{ password: string; role?: string }>([ - { type: 'password', name: 'password', message: 'Mot de passe :' }, - { - type: 'input', - name: 'role', - message: 'Rôle (optionnel, ex: Enseignant, Élève, Admin) :', - default: '', - }, - ]); - password = result.password; - role = result.role?.trim() || undefined; - shouldSaveAfterSuccess = true; - } - } else if (savedProfiles.length > 0) { - const choices = [ - ...savedProfiles.map((p) => ({ - name: p.role ? `${p.login} (${p.role})` : p.login, - value: p.login, - })), - { name: '➕ Nouvel identifiant', value: CHOICE_NEW_CREDENTIALS }, - ]; - const { selectedLogin } = await inquirer.prompt<{ selectedLogin: string }>([ - { - type: 'list', - name: 'selectedLogin', - message: 'Choisissez un identifiant ou saisissez un nouveau :', - choices, - }, - ]); - if (selectedLogin === CHOICE_NEW_CREDENTIALS) { - const creds = await inquirer.prompt([ - { type: 'input', name: 'login', message: 'Login :' }, - { type: 'password', name: 'password', message: 'Mot de passe :' }, - { - type: 'input', - name: 'role', - message: 'Rôle (optionnel, ex: Enseignant, Élève, Admin) :', - default: '', - }, - ]); - login = creds.login; - password = creds.password; - role = creds.role?.trim() || undefined; - shouldSaveAfterSuccess = true; - } else { - const profile = savedProfiles.find((p) => p.login === selectedLogin)!; - login = profile.login; - password = profile.password; - role = profile.role; - } - } else { - const creds = await inquirer.prompt([ - { type: 'input', name: 'login', message: 'Login :' }, - { type: 'password', name: 'password', message: 'Mot de passe :' }, - { - type: 'input', - name: 'role', - message: 'Rôle (optionnel, ex: Enseignant, Élève, Admin) :', - default: '', - }, - ]); - login = creds.login; - password = creds.password; - role = creds.role?.trim() || undefined; - shouldSaveAfterSuccess = true; - } + const creds = await resolveCredentialsStep(env.id, { login: options.login }); if (!options.skipConfirm) { - const confirmed = await confirmAndRunStep({ - environment: env, - apps, - login, - }); + const confirmed = await confirmAndRunStep({ environment: env, apps, login: creds.login }); if (!confirmed) { logger.info('Annulé.'); return; @@ -211,38 +153,192 @@ export class EnvSyncService { } const spinner = ora('Connexion en cours…').start(); - const authClient = new FetchAuthClient(); + let cookies: AuthCookies; try { - const cookies = await authClient.loginAndGetCookies(env.url, { login, password }); - spinner.succeed('Connexion réussie.'); + cookies = await this.authenticateAndInject(env, apps, creds, allSelected); + } catch (err) { + spinner.fail('Échec de la connexion.'); + logger.error(err instanceof Error ? err.message : String(err)); + throw err; + } + spinner.succeed(`Connexion réussie — ${apps.length} fichier(s) .env mis à jour.`); - if (shouldSaveAfterSuccess) { - await addOrUpdateProfileForEnvironment(envId, { login, password, role }); - logger.info('Identifiant enregistré pour cet environnement.'); - } + if (creds.shouldSave) { + await addOrUpdateProfileForEnvironment(env.id, { + login: creds.login, + password: creds.password, + role: creds.role, + }); + logger.info('Identifiant enregistré pour cet environnement.'); + } + + if (typeof cookies.expiresAt === 'number') { + logger.info(`Session valide ~${formatDuration(cookies.expiresAt - Date.now())}.`); + } + + if (options.watch) { + const intervalMs = Math.max( + KEEPALIVE_MIN_INTERVAL_MS, + options.watchInterval ? options.watchInterval * 60 * 1000 : KEEPALIVE_INTERVAL_MS + ); + await this.runWatch(env, apps, creds, allSelected, cookies, intervalMs); + } + } + + /** + * Authentifie, enregistre la connexion (historique + expiration) et met à jour les .env. + * Réutilisé par le connect one-shot et par la boucle watch. + */ + private async authenticateAndInject( + env: EnvironmentConfig, + apps: AppSummary[], + creds: ResolvedCredentials, + allSelected: boolean + ): Promise { + const cookies = await this.authClient.loginAndGetCookies(env.url, { + login: creds.login, + password: creds.password, + }); - // Mémoriser la dernière connexion (envId, login, apps) pour reconnect-last. - await setLastConnection(envId, login, { + await recordConnection( + env.id, + creds.login, + { allApps: allSelected, appIds: apps.map((a) => a.id), appNames: apps.map((a) => a.name), - }); + }, + { expiresAt: cookies.expiresAt } + ); - const updateSpinner = ora('Mise à jour des fichiers .env…').start(); - await updateAppsEnv( - apps.map((a) => ({ envPath: a.envPath })), - { - xsrfToken: cookies.xsrfToken, - sessionId: cookies.sessionId, - recetteUrl: env.url, - }, - { login } - ); - updateSpinner.succeed(`${apps.length} fichier(s) .env mis à jour.`); - } catch (err) { - spinner.fail('Échec de la connexion.'); - logger.error(err instanceof Error ? err.message : String(err)); - throw err; + await updateAppsEnv( + apps.map((a) => ({ envPath: a.envPath })), + { + xsrfToken: cookies.xsrfToken, + sessionId: cookies.sessionId, + recetteUrl: env.url, + }, + { login: creds.login } + ); + + return cookies; + } + + /** + * Boucle keep-alive (premier plan) : ping régulier de la session pour la maintenir active + * (timeout d'inactivité glissant). Tant que la session répond, on ne touche à rien — donc + * aucun reload Vite. Si elle tombe, on ré-authentifie + réinjecte (seul moment où le .env + * change). S'arrête sur Ctrl+C ou échec de ré-authentification. + */ + private async runWatch( + env: EnvironmentConfig, + apps: AppSummary[], + creds: ResolvedCredentials, + allSelected: boolean, + initial: AuthCookies, + intervalMs: number + ): Promise { + logger.warn( + `Mode --watch (keep-alive) actif : ping toutes les ${formatDuration(intervalMs)} pour maintenir la session. ` + + 'Réinjection du .env (et reload Vite) uniquement si la session tombe. Ctrl+C pour arrêter.' + ); + + const controller = new AbortController(); + const onSigint = () => controller.abort(); + process.on('SIGINT', onSigint); + let cookies = initial; + try { + while (!controller.signal.aborted) { + if ((await sleep(intervalMs, controller.signal)) === 'aborted') break; + + let alive: boolean; + try { + alive = await this.authClient.isSessionAlive(env.url, cookies.sessionId); + } catch (err) { + logger.warn( + `Vérification de session impossible (${err instanceof Error ? err.message : String(err)}) — nouvel essai au prochain ping.` + ); + continue; + } + + if (alive) { + logger.info('Session maintenue active.'); + continue; + } + + logger.warn('Session expirée — ré-authentification…'); + const spinner = ora('Ré-authentification…').start(); + try { + cookies = await this.authenticateAndInject(env, apps, creds, allSelected); + spinner.succeed(`Session rétablie — ${apps.length} fichier(s) .env mis à jour.`); + } catch (err) { + spinner.fail('Échec de la ré-authentification — arrêt du mode --watch.'); + logger.error(err instanceof Error ? err.message : String(err)); + break; + } + } + } finally { + process.removeListener('SIGINT', onSigint); + } + logger.info('Arrêt du mode --watch.'); + } + + /** + * Résout l'environnement cible. Retourne `{ env, envId }`, `'reconnect'` si une + * reconnexion rapide a été rejouée, ou `null` en cas d'abandon / erreur. + */ + private async resolveEnvironment( + options: ConnectOptions + ): Promise<{ env: EnvironmentConfig; envId: string } | 'reconnect' | null> { + if (options.env) { + const env = await getEnvironmentById(options.env); + if (!env) { + logger.error(`Environnement inconnu : ${options.env}`); + return null; + } + return { env, envId: options.env }; + } + + const envs = await listEnvironments(); + if (envs.length === 0) { + logger.error("Aucun environnement configuré. Exécutez d'abord : dev-auth-fetcher onboard"); + return null; + } + + const recents = await getRecentConnections(); + const recentChoices = recents.map((r, i) => ({ + name: `🔄 ${r.envId} / ${r.login} (${describeLastConnectionApps(r)}) — ${describeFreshness(r)}`, + value: `${RECONNECT_CHOICE}:${i}`, + })); + const envChoices = envs.map((e) => ({ name: `${e.label} (${e.url})`, value: e.id })); + const choices = recentChoices.length + ? [...recentChoices, new inquirer.Separator(), ...envChoices] + : envChoices; + + const { selectedEnvId } = await inquirer.prompt<{ selectedEnvId: string }>([ + { + type: 'list', + name: 'selectedEnvId', + message: "Sélectionnez l'environnement :", + choices, + }, + ]); + + if (selectedEnvId.startsWith(`${RECONNECT_CHOICE}:`)) { + const index = Number(selectedEnvId.slice(RECONNECT_CHOICE.length + 1)); + const recent = recents[index]; + if (recent) { + // Reporter le flag watch : il porte sur toute la commande, pas sur l'env choisi. + await this.runInteractive({ ...reconnectOptionsFromLast(recent), watch: options.watch }); + return 'reconnect'; + } + } + + const env = await getEnvironmentById(selectedEnvId); + if (!env) { + logger.error('Environnement non trouvé.'); + return null; } + return { env, envId: selectedEnvId }; } } diff --git a/src/services/OnboardingService.ts b/src/services/OnboardingService.ts index 4df6e95..e1662cb 100644 --- a/src/services/OnboardingService.ts +++ b/src/services/OnboardingService.ts @@ -4,9 +4,7 @@ import { askRootDirectoryStep } from '../steps/onboarding/AskRootDirectoryStep.j import { generateEnvConfigsStep } from '../steps/onboarding/GenerateEnvConfigsStep.js'; import { saveAppConfigStep } from '../steps/onboarding/SaveAppConfigStep.js'; import { selectEnvironmentStep } from '../steps/onboarding/SelectEnvironmentStep.js'; -import { createLogger } from '../utils/logger.js'; - -const logger = createLogger(); +import { logger } from '../utils/logger.js'; /** * Orchestration du parcours d'onboarding : root des apps, génération des configs d'environnements, sauvegarde. @@ -29,7 +27,6 @@ export class OnboardingService { const config: AppConfig = (await loadAppConfig()) ?? { appsRoot, defaultEnvironment: 'recette-ode1', - profiles: [], }; await saveAppConfigStep(config); logger.success('Configuration enregistrée dans config/app.config.json'); diff --git a/src/steps/connect/ResolveCredentialsStep.ts b/src/steps/connect/ResolveCredentialsStep.ts new file mode 100644 index 0000000..70e53ed --- /dev/null +++ b/src/steps/connect/ResolveCredentialsStep.ts @@ -0,0 +1,101 @@ +import inquirer from 'inquirer'; + +import { getLoginRecency, getProfilesForEnvironment } from '../../config/credentialsStore.js'; + +const CHOICE_NEW_CREDENTIALS = '__new__'; + +export interface ResolvedCredentials { + login: string; + password: string; + role?: string; + /** true si l'identifiant doit être enregistré après une connexion réussie. */ + shouldSave: boolean; +} + +/** + * Demande mot de passe (+ rôle), et login si non prérenseigné. + * Les identifiants saisis ici sont à enregistrer après succès (shouldSave: true). + */ +async function promptCredentials(opts: { + withLogin: boolean; + presetLogin?: string; +}): Promise { + const answers = await inquirer.prompt<{ login?: string; password: string; role?: string }>([ + ...(opts.withLogin ? [{ type: 'input' as const, name: 'login', message: 'Login :' }] : []), + { type: 'password' as const, name: 'password', message: 'Mot de passe :' }, + { + type: 'input' as const, + name: 'role', + message: 'Rôle (optionnel, ex: Enseignant, Élève, Admin) :', + default: '', + }, + ]); + return { + login: opts.presetLogin ?? answers.login!, + password: answers.password, + role: answers.role?.trim() || undefined, + shouldSave: true, + }; +} + +/** + * Résout l'identifiant à utiliser pour un environnement : + * - `options.login` fourni : réutilise le profil enregistré s'il existe, sinon demande le mot de passe ; + * - sinon, s'il existe des profils : laisse choisir un profil existant ou en saisir un nouveau ; + * - sinon : saisie complète. + */ +export async function resolveCredentialsStep( + envId: string, + options: { login?: string } +): Promise { + const savedProfiles = await getProfilesForEnvironment(envId); + + if (options.login) { + const existing = savedProfiles.find((p) => p.login === options.login); + if (existing) { + return { + login: existing.login, + password: existing.password, + role: existing.role, + shouldSave: false, + }; + } + return promptCredentials({ withLogin: false, presetLogin: options.login }); + } + + if (savedProfiles.length > 0) { + // Faire remonter les derniers logins utilisés (récence décroissante), alpha en égalité. + const recency = await getLoginRecency(envId); + const ordered = [...savedProfiles].sort((a, b) => { + const diff = (recency.get(b.login) ?? 0) - (recency.get(a.login) ?? 0); + return diff !== 0 ? diff : a.login.localeCompare(b.login); + }); + const choices = [ + ...ordered.map((p) => ({ + name: p.role ? `${p.login} (${p.role})` : p.login, + value: p.login, + })), + { name: '➕ Nouvel identifiant', value: CHOICE_NEW_CREDENTIALS }, + ]; + const { selectedLogin } = await inquirer.prompt<{ selectedLogin: string }>([ + { + type: 'list', + name: 'selectedLogin', + message: 'Choisissez un identifiant ou saisissez un nouveau :', + choices, + }, + ]); + if (selectedLogin === CHOICE_NEW_CREDENTIALS) { + return promptCredentials({ withLogin: true }); + } + const profile = savedProfiles.find((p) => p.login === selectedLogin)!; + return { + login: profile.login, + password: profile.password, + role: profile.role, + shouldSave: false, + }; + } + + return promptCredentials({ withLogin: true }); +} diff --git a/src/steps/onboarding/GenerateEnvConfigsStep.ts b/src/steps/onboarding/GenerateEnvConfigsStep.ts index 4c4cf13..ec8542b 100644 --- a/src/steps/onboarding/GenerateEnvConfigsStep.ts +++ b/src/steps/onboarding/GenerateEnvConfigsStep.ts @@ -1,7 +1,7 @@ import { generateDefaultEnvironmentConfigs } from '../../config/envConfigs.js'; /** - * Génère les fichiers config/environments/*.json par défaut. + * Génère les fichiers d'environnements par défaut dans ~/.dev-auth-fetcher/environments/. */ export async function generateEnvConfigsStep(): Promise { await generateDefaultEnvironmentConfigs(); diff --git a/src/utils/logger.ts b/src/utils/logger.ts index e57c44b..81b66e8 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -23,3 +23,6 @@ export function createLogger(): Logger { }, }; } + +/** Logger partagé de la CLI. */ +export const logger: Logger = createLogger(); diff --git a/src/utils/paths.ts b/src/utils/paths.ts index e3828ea..f6381ed 100644 --- a/src/utils/paths.ts +++ b/src/utils/paths.ts @@ -1,3 +1,4 @@ +import os from 'os'; import path from 'path'; /** @@ -5,6 +6,9 @@ import path from 'path'; * Toujours utiliser path.join / path.resolve au lieu de concaténer avec '/'. */ +/** Nom du dossier de données utilisateur (sous le HOME). */ +const DATA_DIR_NAME = '.dev-auth-fetcher'; + /** * Joint des segments de chemin de manière portable. */ @@ -20,22 +24,47 @@ export function resolvePath(base: string, ...segments: string[]): string { } /** - * Retourne le répertoire du fichier de config (racine du projet CLI). + * Dossier unique des données utilisateur (non versionnées) : config + credentials. + * Par défaut `~/.dev-auth-fetcher`, surchargeable via la variable DEV_AUTH_FETCHER_HOME. */ -export function getConfigDir(): string { - return resolvePath(process.cwd(), 'config'); +export function getUserDataDir(): string { + const override = process.env.DEV_AUTH_FETCHER_HOME?.trim(); + return override || path.join(os.homedir(), DATA_DIR_NAME); } /** - * Retourne le chemin du fichier app.config.json. + * Chemin du fichier de config utilisateur (appsRoot, defaultEnvironment). */ export function getAppConfigPath(): string { - return joinPath(getConfigDir(), 'app.config.json'); + return path.join(getUserDataDir(), 'config.json'); +} + +/** + * Répertoire des credentials utilisateur (un fichier par identité). + */ +export function getCredentialsDir(): string { + return path.join(getUserDataDir(), 'credentials'); } /** - * Retourne le répertoire des configs d'environnements. + * Répertoire des définitions d'environnements (dans le dossier utilisateur), seedé + * depuis les défauts du code. Indépendant du répertoire de lancement. */ export function getEnvironmentsConfigDir(): string { - return joinPath(getConfigDir(), 'environments'); + return path.join(getUserDataDir(), 'environments'); +} + +/** Ancien emplacement de la config (pour migration unique). */ +export function getLegacyAppConfigPath(): string { + return resolvePath(process.cwd(), 'config', 'app.config.json'); +} + +/** Ancien répertoire des credentials (pour migration unique). */ +export function getLegacyCredentialsDir(): string { + return path.join(process.cwd(), DATA_DIR_NAME, 'credentials'); +} + +/** Ancien répertoire des environnements versionnés dans le repo (pour migration unique). */ +export function getLegacyEnvironmentsConfigDir(): string { + return resolvePath(process.cwd(), 'config', 'environments'); } diff --git a/tests/config/credentialsStore.test.ts b/tests/config/credentialsStore.test.ts index c1c380b..15a34f4 100644 --- a/tests/config/credentialsStore.test.ts +++ b/tests/config/credentialsStore.test.ts @@ -10,9 +10,12 @@ import { getProfilesForEnvironment, addOrUpdateProfileForEnvironment, getLastConnection, - setLastConnection, + getRecentConnections, + getLoginRecency, + recordConnection, + connectionSignature, type UserCredentialsStore, -} from '../../src/config/credentialsStore'; +} from '../../src/config/credentialsStore.js'; describe.sequential('credentialsStore', () => { let tempDir: string; @@ -23,11 +26,13 @@ describe.sequential('credentialsStore', () => { originalCwd = process.cwd(); process.chdir(tempDir); process.env.DEV_AUTH_USER = 'test-user'; + process.env.DEV_AUTH_FETCHER_HOME = join(tempDir, 'home'); }); afterEach(async () => { process.chdir(originalCwd); delete process.env.DEV_AUTH_USER; + delete process.env.DEV_AUTH_FETCHER_HOME; await rm(tempDir, { recursive: true, force: true }); }); @@ -89,12 +94,63 @@ describe.sequential('credentialsStore', () => { ]); }); - it('setLastConnection et getLastConnection gèrent la dernière connexion', async () => { + it('recordConnection / getLastConnection : la dernière connexion remonte en tête', async () => { expect(await getLastConnection()).toBeNull(); - await setLastConnection('recette-ode1', 'user1'); + await recordConnection('recette-ode1', 'user1'); const last = await getLastConnection(); - expect(last).not.toBeNull(); expect(last?.envId).toBe('recette-ode1'); expect(last?.login).toBe('user1'); }); + + it('recordConnection plafonne à 3 et garde le plus-récent-d’abord', async () => { + await recordConnection('recette-ode1', 'a'); + await recordConnection('recette-ode1', 'b'); + await recordConnection('recette-ode1', 'c'); + await recordConnection('recette-ode1', 'd'); + const recents = await getRecentConnections(); + expect(recents).toHaveLength(3); + expect(recents.map((r) => r.login)).toEqual(['d', 'c', 'b']); + }); + + it('recordConnection déduplique le même combo et le remonte sans doublon', async () => { + await recordConnection('recette-ode1', 'a', { allApps: false, appIds: ['app1'], appNames: [] }); + await recordConnection('recette-ode1', 'b', { allApps: false, appIds: ['app1'], appNames: [] }); + await recordConnection('recette-ode1', 'a', { allApps: false, appIds: ['app1'], appNames: [] }); + const recents = await getRecentConnections(); + expect(recents.map((r) => r.login)).toEqual(['a', 'b']); + }); + + it('recordConnection : un jeu d’apps ou un login différent crée une entrée distincte', async () => { + await recordConnection('recette-ode1', 'a', { allApps: false, appIds: ['app1'], appNames: [] }); + await recordConnection('recette-ode1', 'a', { allApps: false, appIds: ['app2'], appNames: [] }); + await recordConnection('recette-ode1', 'b', { allApps: false, appIds: ['app1'], appNames: [] }); + expect(await getRecentConnections()).toHaveLength(3); + }); + + it('connectionSignature est insensible à l’ordre des apps', () => { + const a = connectionSignature({ envId: 'e', login: 'u', appIds: ['x', 'y'] }); + const b = connectionSignature({ envId: 'e', login: 'u', appIds: ['y', 'x'] }); + expect(a).toBe(b); + }); + + it('migre un store legacy lastConnection vers recentConnections', async () => { + await saveUserCredentialsStore({ + environmentProfiles: {}, + lastConnection: { envId: 'recette-ode2', login: 'legacy' }, + } as UserCredentialsStore); + const recents = await getRecentConnections(); + expect(recents).toHaveLength(1); + expect(recents[0].envId).toBe('recette-ode2'); + expect(recents[0].login).toBe('legacy'); + expect(typeof recents[0].connectedAt).toBe('number'); + }); + + it('getLoginRecency retourne le connectedAt max par login, scopé à l’env', async () => { + await recordConnection('recette-ode1', 'a'); + await recordConnection('recette-ode2', 'b'); + await recordConnection('recette-ode1', 'a', { allApps: true, appIds: [], appNames: [] }); + const recency = await getLoginRecency('recette-ode1'); + expect(recency.has('a')).toBe(true); + expect(recency.has('b')).toBe(false); + }); }); diff --git a/tests/config/envConfigs.test.ts b/tests/config/envConfigs.test.ts index 34c5dfb..d11b831 100644 --- a/tests/config/envConfigs.test.ts +++ b/tests/config/envConfigs.test.ts @@ -1,12 +1,28 @@ -import { describe, it, expect } from 'vitest'; +import { mkdtemp, rm } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { listEnvironments, getEnvironmentById, DEFAULT_ENVIRONMENTS, -} from '../../src/config/envConfigs'; +} from '../../src/config/envConfigs.js'; describe('envConfigs', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'env-configs-test-')); + process.env.DEV_AUTH_FETCHER_HOME = tempDir; + }); + + afterEach(async () => { + delete process.env.DEV_AUTH_FETCHER_HOME; + await rm(tempDir, { recursive: true, force: true }); + }); + it('DEFAULT_ENVIRONMENTS contient les 6 environnements attendus', () => { expect(DEFAULT_ENVIRONMENTS).toHaveLength(6); const ids = DEFAULT_ENVIRONMENTS.map((e) => e.id); diff --git a/tests/core/apps/AppDiscovery.test.ts b/tests/core/apps/AppDiscovery.test.ts index 8a0d6ff..20b12fe 100644 --- a/tests/core/apps/AppDiscovery.test.ts +++ b/tests/core/apps/AppDiscovery.test.ts @@ -4,8 +4,8 @@ import { join } from 'path'; import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { discoverApps } from '../../../src/core/apps/AppDiscovery'; -import { AppDiscoveryError } from '../../../src/utils/errors'; +import { discoverApps } from '../../../src/core/apps/AppDiscovery.js'; +import { AppDiscoveryError } from '../../../src/utils/errors.js'; describe('AppDiscovery', () => { let tempRoot: string; diff --git a/tests/core/auth/FetchAuthClient.test.ts b/tests/core/auth/FetchAuthClient.test.ts new file mode 100644 index 0000000..e1c375f --- /dev/null +++ b/tests/core/auth/FetchAuthClient.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; + +import { FetchAuthClient, parseCookieExpiry } from '../../../src/core/auth/FetchAuthClient.js'; + +describe('parseCookieExpiry', () => { + const NOW = 1_700_000_000_000; + + it('calcule expiresAt depuis Max-Age (secondes)', () => { + const header = 'oneSessionId=abc; Path=/; Max-Age=3600; HttpOnly'; + expect(parseCookieExpiry(header, NOW)).toBe(NOW + 3600 * 1000); + }); + + it('retombe sur Expires si Max-Age est absent', () => { + const header = 'oneSessionId=abc; Path=/; Expires=Wed, 21 Oct 2099 07:28:00 GMT'; + expect(parseCookieExpiry(header, NOW)).toBe(Date.parse('Wed, 21 Oct 2099 07:28:00 GMT')); + }); + + it('privilégie Max-Age sur Expires', () => { + const header = 'oneSessionId=abc; Max-Age=60; Expires=Wed, 21 Oct 2099 07:28:00 GMT'; + expect(parseCookieExpiry(header, NOW)).toBe(NOW + 60 * 1000); + }); + + it('retourne undefined sans attribut exploitable', () => { + expect(parseCookieExpiry('oneSessionId=abc; Path=/; HttpOnly', NOW)).toBeUndefined(); + }); +}); + +describe('FetchAuthClient.isSessionAlive', () => { + afterEach(() => vi.restoreAllMocks()); + + it('200 → session vivante', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 200 })); + expect(await new FetchAuthClient().isSessionAlive('https://x.example.com/', 'sid')).toBe(true); + }); + + it('statut non-200 → session morte', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 401 })); + expect(await new FetchAuthClient().isSessionAlive('https://x.example.com/', 'sid')).toBe(false); + }); + + it("sonde l'endpoint userinfo avec le seul cookie oneSessionId", async () => { + const spy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(null, { status: 200 })); + await new FetchAuthClient().isSessionAlive('https://x.example.com/', 'abc'); + const [url, init] = spy.mock.calls[0]; + expect(String(url)).toBe('https://x.example.com/auth/oauth2/userinfo'); + expect((init?.headers as Record).cookie).toBe('oneSessionId=abc'); + }); +}); diff --git a/tests/services/EnvSyncService.test.ts b/tests/services/EnvSyncService.test.ts new file mode 100644 index 0000000..e74c43d --- /dev/null +++ b/tests/services/EnvSyncService.test.ts @@ -0,0 +1,164 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import { + getRecentConnections, + saveUserCredentialsStore, +} from '../../src/config/credentialsStore.js'; +import type { AuthCredentials, AuthCookies, IAuthClient } from '../../src/core/auth/AuthClient.js'; +import { + EnvSyncService, + describeFreshness, + isSessionStale, +} from '../../src/services/EnvSyncService.js'; +import { readEnvFile } from '../../src/utils/envFile.js'; +import { AuthError } from '../../src/utils/errors.js'; + +/** Faux client d'auth : enregistre les arguments reçus et renvoie des cookies fixes. */ +class FakeAuthClient implements IAuthClient { + calls: Array<{ envUrl: string; credentials: AuthCredentials }> = []; + + constructor(private readonly result: AuthCookies | Error) {} + + async loginAndGetCookies(envUrl: string, credentials: AuthCredentials): Promise { + this.calls.push({ envUrl, credentials }); + if (this.result instanceof Error) throw this.result; + return this.result; + } + + async isSessionAlive(): Promise { + return true; + } +} + +describe.sequential('EnvSyncService.runInteractive', () => { + let tempDir: string; + let appsRoot: string; + let appEnvPath: string; + let originalCwd: string; + + const ENV_ID = 'recette-test'; + const ENV_URL = 'https://recette-test.example.com/'; + const LOGIN = 'me'; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'env-sync-test-')); + originalCwd = process.cwd(); + process.chdir(tempDir); + process.env.DEV_AUTH_USER = 'test-user'; + // données utilisateur (config + credentials) centralisées dans ce dossier + process.env.DEV_AUTH_FETCHER_HOME = join(tempDir, 'home'); + + // toutes les données utilisateur (config + environnements + credentials) dans le HOME + appsRoot = join(tempDir, 'apps'); + await mkdir(join(tempDir, 'home', 'environments'), { recursive: true }); + await writeFile( + join(tempDir, 'home', 'config.json'), + JSON.stringify({ appsRoot, defaultEnvironment: ENV_ID }, null, 2) + ); + await writeFile( + join(tempDir, 'home', 'environments', `${ENV_ID}.json`), + JSON.stringify({ id: ENV_ID, label: 'Recette Test', url: ENV_URL }, null, 2) + ); + + // une app détectable (apps/myapp/frontend) avec un .env existant à préserver + const frontendDir = join(appsRoot, 'myapp', 'frontend'); + await mkdir(frontendDir, { recursive: true }); + appEnvPath = join(frontendDir, '.env'); + await writeFile(appEnvPath, 'EXISTING=keepme\n'); + + // profil enregistré pour éviter tout prompt de mot de passe + await saveUserCredentialsStore({ + environmentProfiles: { [ENV_ID]: [{ login: LOGIN, password: 'secret', role: 'Enseignant' }] }, + }); + }); + + afterEach(async () => { + process.chdir(originalCwd); + delete process.env.DEV_AUTH_USER; + delete process.env.DEV_AUTH_FETCHER_HOME; + await rm(tempDir, { recursive: true, force: true }); + }); + + it('authentifie et injecte les cookies dans le .env de l’app (sans prompt)', async () => { + const expiresAt = Date.now() + 3600 * 1000; + const client = new FakeAuthClient({ xsrfToken: 'xsrf-123', sessionId: 'sess-456', expiresAt }); + const service = new EnvSyncService(client); + + await service.runInteractive({ env: ENV_ID, login: LOGIN, apps: ['myapp'], skipConfirm: true }); + + // le client a été appelé avec l'URL et les credentials du profil enregistré + expect(client.calls).toHaveLength(1); + expect(client.calls[0].envUrl).toBe(ENV_URL); + expect(client.calls[0].credentials).toEqual({ login: LOGIN, password: 'secret' }); + + // le .env contient les clés VITE_* et préserve l'existant + const env = await readEnvFile(appEnvPath); + expect(env.VITE_XSRF_TOKEN).toBe('xsrf-123'); + expect(env.VITE_ONE_SESSION_ID).toBe('sess-456'); + expect(env.VITE_RECETTE).toBe(ENV_URL); + expect(env.EXISTING).toBe('keepme'); + + // la connexion est enregistrée dans l'historique avec l'expiration propagée + const recents = await getRecentConnections(); + expect(recents[0]).toMatchObject({ envId: ENV_ID, login: LOGIN, appIds: ['myapp'], expiresAt }); + expect(typeof recents[0].connectedAt).toBe('number'); + }); + + it("ne touche pas au .env si l'authentification échoue", async () => { + const client = new FakeAuthClient(new AuthError('bad credentials')); + const service = new EnvSyncService(client); + + await expect( + service.runInteractive({ env: ENV_ID, login: LOGIN, apps: ['myapp'], skipConfirm: true }) + ).rejects.toThrow('bad credentials'); + + const env = await readEnvFile(appEnvPath); + expect(env).toEqual({ EXISTING: 'keepme' }); + }); + + it('abandonne proprement sur un environnement inconnu', async () => { + const client = new FakeAuthClient({ xsrfToken: 'x', sessionId: 's' }); + const service = new EnvSyncService(client); + + await service.runInteractive({ + env: 'does-not-exist', + login: LOGIN, + apps: ['myapp'], + skipConfirm: true, + }); + + expect(client.calls).toHaveLength(0); + const env = await readEnvFile(appEnvPath); + expect(env).toEqual({ EXISTING: 'keepme' }); + }); +}); + +describe('freshness helpers', () => { + const base = { envId: 'e', login: 'u' }; + + it('isSessionStale utilise expiresAt quand présent', () => { + const now = 1_000_000; + expect(isSessionStale({ ...base, connectedAt: now, expiresAt: now + 1000 }, now)).toBe(false); + expect(isSessionStale({ ...base, connectedAt: now, expiresAt: now - 1000 }, now)).toBe(true); + }); + + it('isSessionStale retombe sur un seuil de temps sans expiresAt', () => { + const now = 100 * 60 * 60 * 1000; + expect(isSessionStale({ ...base, connectedAt: now - 60 * 1000 }, now)).toBe(false); + expect(isSessionStale({ ...base, connectedAt: now - 9 * 60 * 60 * 1000 }, now)).toBe(true); + }); + + it('describeFreshness affiche l’âge et signale une session expirée', () => { + const now = 10_000_000; + const fresh = describeFreshness({ ...base, connectedAt: now - 3 * 60 * 1000 }, now); + expect(fresh).toContain('connecté il y a 3 min'); + expect(fresh).not.toContain('⚠️'); + + const stale = describeFreshness({ ...base, connectedAt: now, expiresAt: now - 1 }, now); + expect(stale).toContain('⚠️'); + }); +}); diff --git a/tests/utils/envFile.test.ts b/tests/utils/envFile.test.ts index c5d39c7..cf37f3a 100644 --- a/tests/utils/envFile.test.ts +++ b/tests/utils/envFile.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { parseEnvContent, mergeEnv, formatEnvContent } from '../../src/utils/envFile'; +import { parseEnvContent, mergeEnv, formatEnvContent } from '../../src/utils/envFile.js'; describe('parseEnvContent', () => { it('parse des lignes KEY=value', () => { diff --git a/tsconfig.typecheck.json b/tsconfig.typecheck.json new file mode 100644 index 0000000..93cda56 --- /dev/null +++ b/tsconfig.typecheck.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "." + }, + "include": ["src", "tests"], + "exclude": ["node_modules", "dist"] +}