From 148e2712b14380921742c3f8f7f8c197cd1678b4 Mon Sep 17 00:00:00 2001 From: "Pascal S." Date: Mon, 22 Jun 2026 14:11:42 +0200 Subject: [PATCH 01/10] chore: add CLAUDE.md and remove legacy old/ directory - Add CLAUDE.md documenting stack, commands and architecture - Remove unused old/ (legacy Playwright-based implementation, replaced by the fetch-based FetchAuthClient) Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 53 ++++++++++++ old/config.json | 58 ------------- old/config.template.json | 16 ---- old/index.cjs | 173 --------------------------------------- 4 files changed, 53 insertions(+), 247 deletions(-) create mode 100644 CLAUDE.md delete mode 100644 old/config.json delete mode 100644 old/config.template.json delete mode 100644 old/index.cjs diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b2075b7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,53 @@ +# 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 test # vitest (watch). Un seul fichier : pnpm test tests/utils/envFile.test.ts +pnpm run lint # eslint sur src/ et tests/ +pnpm run format # prettier --write +pnpm run format:check +``` + +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'`, parse les `Set-Cookie`. Valide `authenticated=true` puis extrait `XSRF-TOKEN` (URL-décodé) et `oneSessionId`. + - `env/EnvManager` — applique le patch de cookies aux `.env`, **en préservant les variables existantes**. + - `apps/AppDiscovery` — scanne `appsRoot` ; détecte deux schémas : `/frontend` à la racine, et `entcore//frontend` (id = `entcore/`). +- **`src/config/`** — `appConfig` (`config/app.config.json` : `appsRoot`, `defaultEnvironment`), `envConfigs` (un fichier par env dans `config/environments/`), `credentialsStore` (profils et dernière connexion, **par utilisateur**), `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. +- **Secrets non versionnés** : les credentials et la dernière connexion vivent dans `.dev-auth-fetcher/credentials/.json` (gitignoré). L'utilisateur courant vient de `os.userInfo().username`, surchargeable via la variable d'env `DEV_AUTH_USER`. Ne jamais committer ce dossier ni 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. +- **Chemins de config relatifs à `process.cwd()`** : la CLI lit `config/` et `.dev-auth-fetcher/` depuis le répertoire de lancement (en pratique, la racine du repo). 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); - } -})(); From 3ac35321450500f685435b10fd53ec375a38f5ce Mon Sep 17 00:00:00 2001 From: "Pascal S." Date: Mon, 22 Jun 2026 14:28:15 +0200 Subject: [PATCH 02/10] chore: tooling hygiene (test/typecheck scripts, dead fields, shared logger) - test now runs once (vitest run); add test:watch for watch mode - add typecheck script (tsc) covering src + tests via tsconfig.typecheck.json, wired into CI; fixes latent test issues it surfaced (missing .js extensions, implicit any) - remove dead AppConfig fields (profiles, lastUsedProfile) - expose a shared logger singleton instead of re-creating it per module Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 3 +++ config/app.config.json | 5 ++--- package.json | 4 +++- src/cli/commands/connect.ts | 4 +--- src/cli/commands/list-apps.ts | 4 +--- src/cli/commands/onboard.ts | 4 +--- src/cli/commands/reconnect-last.ts | 4 +--- src/config/appConfig.ts | 1 - src/config/config.types.ts | 2 -- src/services/EnvSyncService.ts | 4 +--- src/services/OnboardingService.ts | 5 +---- src/utils/logger.ts | 3 +++ tests/config/credentialsStore.test.ts | 2 +- tests/config/envConfigs.test.ts | 2 +- tests/core/apps/AppDiscovery.test.ts | 4 ++-- tests/utils/envFile.test.ts | 2 +- tsconfig.typecheck.json | 9 +++++++++ 17 files changed, 31 insertions(+), 31 deletions(-) create mode 100644 tsconfig.typecheck.json 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/config/app.config.json b/config/app.config.json index 2e586b1..9689b3a 100644 --- a/config/app.config.json +++ b/config/app.config.json @@ -1,5 +1,4 @@ { "appsRoot": "/Volumes/Work", - "defaultEnvironment": "recette-ode1", - "profiles": [] -} \ No newline at end of file + "defaultEnvironment": "recette-ode1" +} 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..065f2ad 100644 --- a/src/cli/commands/reconnect-last.ts +++ b/src/cli/commands/reconnect-last.ts @@ -1,8 +1,6 @@ import { getLastConnection } from '../../config/credentialsStore.js'; import { EnvSyncService } from '../../services/EnvSyncService.js'; -import { createLogger } from '../../utils/logger.js'; - -const logger = createLogger(); +import { logger } from '../../utils/logger.js'; export async function runReconnectLastCommand(): Promise { const last = await getLastConnection(); diff --git a/src/config/appConfig.ts b/src/config/appConfig.ts index d5245f1..86f5a78 100644 --- a/src/config/appConfig.ts +++ b/src/config/appConfig.ts @@ -9,7 +9,6 @@ import type { AppConfig } from './config.types.js'; const DEFAULT_APP_CONFIG: AppConfig = { appsRoot: '', defaultEnvironment: 'recette-ode1', - profiles: [], }; /** 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/services/EnvSyncService.ts b/src/services/EnvSyncService.ts index 7541a92..651188d 100644 --- a/src/services/EnvSyncService.ts +++ b/src/services/EnvSyncService.ts @@ -15,9 +15,7 @@ import { FetchAuthClient } from '../core/auth/FetchAuthClient.js'; import { updateAppsEnv } from '../core/env/EnvManager.js'; import { confirmAndRunStep } from '../steps/connect/ConfirmAndRunStep.js'; import { selectAppsStep } from '../steps/connect/SelectAppsStep.js'; -import { createLogger } from '../utils/logger.js'; - -const logger = createLogger(); +import { logger } from '../utils/logger.js'; const CHOICE_NEW_CREDENTIALS = '__new__'; 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/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/tests/config/credentialsStore.test.ts b/tests/config/credentialsStore.test.ts index c1c380b..1836808 100644 --- a/tests/config/credentialsStore.test.ts +++ b/tests/config/credentialsStore.test.ts @@ -12,7 +12,7 @@ import { getLastConnection, setLastConnection, type UserCredentialsStore, -} from '../../src/config/credentialsStore'; +} from '../../src/config/credentialsStore.js'; describe.sequential('credentialsStore', () => { let tempDir: string; diff --git a/tests/config/envConfigs.test.ts b/tests/config/envConfigs.test.ts index 34c5dfb..324e0bf 100644 --- a/tests/config/envConfigs.test.ts +++ b/tests/config/envConfigs.test.ts @@ -4,7 +4,7 @@ import { listEnvironments, getEnvironmentById, DEFAULT_ENVIRONMENTS, -} from '../../src/config/envConfigs'; +} from '../../src/config/envConfigs.js'; describe('envConfigs', () => { it('DEFAULT_ENVIRONMENTS contient les 6 environnements attendus', () => { 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/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"] +} From b9f572438aa1fb5fc6fa7d98e4911ad4ae50e500 Mon Sep 17 00:00:00 2001 From: "Pascal S." Date: Mon, 22 Jun 2026 14:29:00 +0200 Subject: [PATCH 03/10] fix: add request timeout and clear network errors to FetchAuthClient POST now aborts after a configurable timeout (default 15s) so the CLI no longer hangs forever on an unreachable recette server, and network/abort failures are surfaced as readable AuthError messages. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/core/auth/FetchAuthClient.ts | 60 +++++++++++++++++++++++++++----- 1 file changed, 51 insertions(+), 9 deletions(-) diff --git a/src/core/auth/FetchAuthClient.ts b/src/core/auth/FetchAuthClient.ts index 4862a51..59fd352 100644 --- a/src/core/auth/FetchAuthClient.ts +++ b/src/core/auth/FetchAuthClient.ts @@ -2,6 +2,14 @@ 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; + +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; ..." @@ -18,8 +26,19 @@ function parseCookie(setCookieHeader: string): { name: string; value: string } | /** * 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,15 +50,7 @@ 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(); @@ -76,4 +87,35 @@ export class FetchAuthClient implements IAuthClient { return { xsrfToken, sessionId }; } + + /** + * POST avec timeout. Convertit les échecs réseau / dépassement de délai en AuthError lisible. + */ + private async post(loginUrl: string, body: string): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + try { + return await fetch(loginUrl, { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + }, + referrer: loginUrl, + redirect: 'manual', + body, + signal: controller.signal, + }); + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') { + throw new AuthError( + `Délai dépassé (${this.timeoutMs} ms) en contactant ${loginUrl}. Le serveur de recette est peut-être indisponible.` + ); + } + throw new AuthError( + `Impossible de contacter ${loginUrl} : ${err instanceof Error ? err.message : String(err)}` + ); + } finally { + clearTimeout(timer); + } + } } From 3068916b5e89cf7095a1a296b8e39c60bb0bd573 Mon Sep 17 00:00:00 2001 From: "Pascal S." Date: Mon, 22 Jun 2026 14:31:56 +0200 Subject: [PATCH 04/10] refactor: slim EnvSyncService, inject auth client, dedupe reconnect logic - inject IAuthClient into EnvSyncService (constructor, defaults to FetchAuthClient) so the orchestration is testable without network - extract the 4-branch credential resolution into ResolveCredentialsStep - extract environment resolution into a private resolveEnvironment method - dedupe the "replay last connection" logic into reconnectOptionsFromLast / describeLastConnectionApps, shared by the service and reconnect-last No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cli/commands/reconnect-last.ts | 20 +- src/services/EnvSyncService.ts | 258 ++++++++------------ src/steps/connect/ResolveCredentialsStep.ts | 95 +++++++ 3 files changed, 204 insertions(+), 169 deletions(-) create mode 100644 src/steps/connect/ResolveCredentialsStep.ts diff --git a/src/cli/commands/reconnect-last.ts b/src/cli/commands/reconnect-last.ts index 065f2ad..9f345eb 100644 --- a/src/cli/commands/reconnect-last.ts +++ b/src/cli/commands/reconnect-last.ts @@ -1,5 +1,9 @@ import { getLastConnection } from '../../config/credentialsStore.js'; -import { EnvSyncService } from '../../services/EnvSyncService.js'; +import { + EnvSyncService, + describeLastConnectionApps, + reconnectOptionsFromLast, +} from '../../services/EnvSyncService.js'; import { logger } from '../../utils/logger.js'; export async function runReconnectLastCommand(): Promise { @@ -15,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/services/EnvSyncService.ts b/src/services/EnvSyncService.ts index 651188d..298b80a 100644 --- a/src/services/EnvSyncService.ts +++ b/src/services/EnvSyncService.ts @@ -4,20 +4,21 @@ import ora from 'ora'; import { loadAppConfig } from '../config/appConfig.js'; import type { EnvironmentConfig } from '../config/config.types.js'; import { - getProfilesForEnvironment, addOrUpdateProfileForEnvironment, getLastConnection, setLastConnection, + type LastConnection, } from '../config/credentialsStore.js'; import { listEnvironments, getEnvironmentById } from '../config/envConfigs.js'; -import type { AuthCredentials } from '../core/auth/AuthClient.js'; +import type { 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 } from '../steps/connect/ResolveCredentialsStep.js'; import { selectAppsStep } from '../steps/connect/SelectAppsStep.js'; import { logger } from '../utils/logger.js'; -const CHOICE_NEW_CREDENTIALS = '__new__'; +const RECONNECT_CHOICE = '__reconnect_last__'; export interface ConnectOptions { env?: string; @@ -30,10 +31,36 @@ export interface ConnectOptions { skipConfirm?: boolean; } +/** Décrit la sélection d'apps d'une dernière 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'; +} + +/** Construit les options `connect` permettant de rejouer une dernière 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, + }; +} + /** - * 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. */ export class EnvSyncService { + constructor(private readonly authClient: IAuthClient = new FetchAuthClient()) {} + async runInteractive(options: ConnectOptions): Promise { const config = await loadAppConfig(); if (!config?.appsRoot) { @@ -43,68 +70,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, envId } = resolved; const { apps, allSelected } = await selectAppsStep(config.appsRoot, { app: options.app, @@ -116,92 +85,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(envId, { 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; @@ -209,18 +96,24 @@ export class EnvSyncService { } const spinner = ora('Connexion en cours…').start(); - const authClient = new FetchAuthClient(); try { - const cookies = await authClient.loginAndGetCookies(env.url, { login, password }); + const cookies = await this.authClient.loginAndGetCookies(env.url, { + login: creds.login, + password: creds.password, + }); spinner.succeed('Connexion réussie.'); - if (shouldSaveAfterSuccess) { - await addOrUpdateProfileForEnvironment(envId, { login, password, role }); + if (creds.shouldSave) { + await addOrUpdateProfileForEnvironment(envId, { + login: creds.login, + password: creds.password, + role: creds.role, + }); logger.info('Identifiant enregistré pour cet environnement.'); } // Mémoriser la dernière connexion (envId, login, apps) pour reconnect-last. - await setLastConnection(envId, login, { + await setLastConnection(envId, creds.login, { allApps: allSelected, appIds: apps.map((a) => a.id), appNames: apps.map((a) => a.name), @@ -234,7 +127,7 @@ export class EnvSyncService { sessionId: cookies.sessionId, recetteUrl: env.url, }, - { login } + { login: creds.login } ); updateSpinner.succeed(`${apps.length} fichier(s) .env mis à jour.`); } catch (err) { @@ -243,4 +136,57 @@ export class EnvSyncService { throw err; } } + + /** + * 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 last = await getLastConnection(); + const envChoices = envs.map((e) => ({ name: `${e.label} (${e.url})`, value: e.id })); + if (last) { + envChoices.unshift({ + name: `🔄 Reconnexion rapide : ${last.envId} / ${last.login} (${describeLastConnectionApps(last)})`, + 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) { + await this.runInteractive(reconnectOptionsFromLast(last)); + return 'reconnect'; + } + + const env = await getEnvironmentById(selectedEnvId); + if (!env) { + logger.error('Environnement non trouvé.'); + return null; + } + return { env, envId: selectedEnvId }; + } } diff --git a/src/steps/connect/ResolveCredentialsStep.ts b/src/steps/connect/ResolveCredentialsStep.ts new file mode 100644 index 0000000..98a8e29 --- /dev/null +++ b/src/steps/connect/ResolveCredentialsStep.ts @@ -0,0 +1,95 @@ +import inquirer from 'inquirer'; + +import { 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) { + 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) { + 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 }); +} From 978a91cf9682b928807ecef218324307f74a3711 Mon Sep 17 00:00:00 2001 From: "Pascal S." Date: Mon, 22 Jun 2026 14:34:34 +0200 Subject: [PATCH 05/10] docs: update CLAUDE.md (scripts, 4 discovery schemas, auth client injection) Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b2075b7..5f9d03e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,12 +20,16 @@ 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 test # vitest (watch). Un seul fichier : pnpm test tests/utils/envFile.test.ts +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 @@ -36,9 +40,9 @@ Flux en couches, du plus haut au plus bas niveau : - **`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'`, parse les `Set-Cookie`. Valide `authenticated=true` puis extrait `XSRF-TOKEN` (URL-décodé) et `oneSessionId`. + - `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 deux schémas : `/frontend` à la racine, et `entcore//frontend` (id = `entcore/`). + - `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` (`config/app.config.json` : `appsRoot`, `defaultEnvironment`), `envConfigs` (un fichier par env dans `config/environments/`), `credentialsStore` (profils et dernière connexion, **par utilisateur**), `config.types.ts` (types + `VITE_ENV_KEYS`). - **`src/utils/`** — `envFile` (parse/merge/write `.env`), `paths` (helpers cross-platform), `logger`, classes d'erreurs (`errors.ts`). From d28ff9fb644fdc54910082312e6ed3a5c8a8f794 Mon Sep 17 00:00:00 2001 From: "Pascal S." Date: Mon, 22 Jun 2026 14:38:36 +0200 Subject: [PATCH 06/10] test: add EnvSyncService orchestration tests (no network, no prompts) Drives runInteractive with a fake IAuthClient and a temp cwd (config, environments, discoverable app, saved profile) to cover: cookie injection into the app .env with preservation of existing vars, no-op on auth failure, and clean abort on unknown environment. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/services/EnvSyncService.test.ts | 118 ++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 tests/services/EnvSyncService.test.ts diff --git a/tests/services/EnvSyncService.test.ts b/tests/services/EnvSyncService.test.ts new file mode 100644 index 0000000..7d915fb --- /dev/null +++ b/tests/services/EnvSyncService.test.ts @@ -0,0 +1,118 @@ +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 { saveUserCredentialsStore } from '../../src/config/credentialsStore.js'; +import type { AuthCredentials, AuthCookies, IAuthClient } from '../../src/core/auth/AuthClient.js'; +import { EnvSyncService } 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; + } +} + +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'; + + // config/app.config.json + config/environments/.json + await mkdir(join(tempDir, 'config', 'environments'), { recursive: true }); + appsRoot = join(tempDir, 'apps'); + await writeFile( + join(tempDir, 'config', 'app.config.json'), + JSON.stringify({ appsRoot, defaultEnvironment: ENV_ID }, null, 2) + ); + await writeFile( + join(tempDir, 'config', '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; + await rm(tempDir, { recursive: true, force: true }); + }); + + it('authentifie et injecte les cookies dans le .env de l’app (sans prompt)', async () => { + const client = new FakeAuthClient({ xsrfToken: 'xsrf-123', sessionId: 'sess-456' }); + 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'); + }); + + 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' }); + }); +}); From 6647acbda8436012a407dc59bb1f248adec74e05 Mon Sep 17 00:00:00 2001 From: "Pascal S." Date: Mon, 22 Jun 2026 17:04:00 +0200 Subject: [PATCH 07/10] feat: recent-connections history, recency-ordered logins & session freshness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses three developer-experience asks around switching contexts and session expiry: - Multiple histories: replace the single lastConnection with a deduplicated recents history (cap 3, signed by env+login+app-set, timestamped). The interactive connect menu lists each recent combo for one-key replay, so juggling several topics/accounts no longer means redoing the whole flow. Migrates legacy lastConnection stores transparently. - Recency-first login ordering: ResolveCredentialsStep surfaces the most recently used logins first (alpha tiebreak), derived from the history. - Session freshness + opt-in --watch: capture the session cookie's Max-Age/Expires, show "connecté il y a Xh" / ⚠️ in the recents menu and on connect, and add `connect --watch`. The watch flag is propagated through the quick-reconnect replay path. Auth client now exposes expiresAt; EnvSyncService extracts authenticateAndInject (shared by one-shot and watch). Tests cover dedup/cap/migration/getLoginRecency, cookie-expiry parsing, freshness helpers, and recents recording. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 21 +- src/cli/index.ts | 4 + src/config/credentialsStore.ts | 88 +++++++- src/core/auth/AuthClient.ts | 2 + src/core/auth/FetchAuthClient.ts | 34 ++- src/services/EnvSyncService.ts | 234 ++++++++++++++++---- src/steps/connect/ResolveCredentialsStep.ts | 10 +- tests/config/credentialsStore.test.ts | 62 +++++- tests/core/auth/FetchAuthClient.test.ts | 26 +++ tests/services/EnvSyncService.test.ts | 45 +++- 10 files changed, 456 insertions(+), 70 deletions(-) create mode 100644 tests/core/auth/FetchAuthClient.test.ts diff --git a/README.md b/README.md index 7fb589d..879fb00 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,13 @@ 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` : garder la session fraîche (voir [Mode watch](#mode-watch--watch)) -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 +86,18 @@ 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 cookies de session expirent au bout d'un certain temps : votre front local se met alors à recevoir des 401. Le mode `--watch` garde la session fraîche en **ré-authentifiant et réinjectant les `.env` automatiquement avant l'expiration**, tant que le process tourne (premier plan, `Ctrl+C` pour arrêter). + +```bash +dev-auth-fetcher connect -e recette-ode1 -a mon-app --watch +``` + +Le délai de rafraîchissement est calculé à partir de l'expiration estimée du cookie de session (attribut `Max-Age`/`Expires`), avec un repli sur un intervalle fixe si cette info est absente. + +> ⚠️ **À savoir** : chaque rafraîchissement réécrit le `.env`. Vite **surveille les `.env`** et **redémarre le dev-server** quand ils changent, ce qui provoque un **rechargement complet de la page** (perte de l'état HMR). Le mode watch est donc surtout utile pour de longues sessions ; pour un coup ponctuel, préférez `reconnect-last`. + ### Lister les applications Affiche les applications détectées (avec dossier `frontend`) dans le répertoire configuré : @@ -106,7 +123,7 @@ dev-auth-fetcher list-apps - **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`. + - 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`. ## Scripts diff --git a/src/cli/index.ts b/src/cli/index.ts index 7209641..06cf038 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -30,6 +30,10 @@ 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 fraîche : ré-authentifier et réinjecter avant expiration (Vite recharge la page à chaque refresh)' + ) .action(async (options) => { await runConnectCommand(options); }); diff --git a/src/config/credentialsStore.ts b/src/config/credentialsStore.ts index b8de505..0187705 100644 --- a/src/config/credentialsStore.ts +++ b/src/config/credentialsStore.ts @@ -18,14 +18,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: [], }; /** @@ -42,6 +55,20 @@ export function getUserCredentialsPath(): string { return path.join(process.cwd(), CREDENTIALS_DIR, CREDENTIALS_SUBDIR, getUserId() + '.json'); } +/** + * Normalise l'historique des connexions au chargement, en migrant l'ancien champ + * mono-entrée `lastConnection` vers `recentConnections` si nécessaire. + */ +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 []; +} + /** * Charge le store des credentials de l'utilisateur. Retourne un store vide si le fichier n'existe pas. */ @@ -52,7 +79,7 @@ export async function loadUserCredentialsStore(): Promise 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; @@ -103,28 +130,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/core/auth/AuthClient.ts b/src/core/auth/AuthClient.ts index e81c288..9315b28 100644 --- a/src/core/auth/AuthClient.ts +++ b/src/core/auth/AuthClient.ts @@ -10,6 +10,8 @@ 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 { diff --git a/src/core/auth/FetchAuthClient.ts b/src/core/auth/FetchAuthClient.ts index 59fd352..41b3caf 100644 --- a/src/core/auth/FetchAuthClient.ts +++ b/src/core/auth/FetchAuthClient.ts @@ -24,6 +24,34 @@ 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. * @@ -54,11 +82,15 @@ export class FetchAuthClient implements IAuthClient { 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); + } } } @@ -85,7 +117,7 @@ export class FetchAuthClient implements IAuthClient { // Conserver la valeur brute si le décodage échoue } - return { xsrfToken, sessionId }; + return { xsrfToken, sessionId, expiresAt }; } /** diff --git a/src/services/EnvSyncService.ts b/src/services/EnvSyncService.ts index 298b80a..424732e 100644 --- a/src/services/EnvSyncService.ts +++ b/src/services/EnvSyncService.ts @@ -5,21 +5,35 @@ import { loadAppConfig } from '../config/appConfig.js'; import type { EnvironmentConfig } from '../config/config.types.js'; import { addOrUpdateProfileForEnvironment, - getLastConnection, - setLastConnection, + getRecentConnections, + recordConnection, type LastConnection, + type RecentConnection, } from '../config/credentialsStore.js'; import { listEnvironments, getEnvironmentById } from '../config/envConfigs.js'; -import type { IAuthClient } 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 } from '../steps/connect/ResolveCredentialsStep.js'; +import { + resolveCredentialsStep, + type ResolvedCredentials, +} from '../steps/connect/ResolveCredentialsStep.js'; import { selectAppsStep } from '../steps/connect/SelectAppsStep.js'; import { logger } from '../utils/logger.js'; const RECONNECT_CHOICE = '__reconnect_last__'; +/** 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; +/** En mode watch, on rafraîchit ce délai avant l'expiration estimée. */ +const REFRESH_BUFFER_MS = 2 * 60 * 1000; +/** Intervalle de rafraîchissement de repli quand l'expiration du cookie est inconnue. */ +const WATCH_FALLBACK_INTERVAL_MS = 30 * 60 * 1000; +/** Délai minimum entre deux rafraîchissements (anti-boucle serrée). */ +const WATCH_MIN_DELAY_MS = 5000; + export interface ConnectOptions { env?: string; app?: string; @@ -29,16 +43,39 @@ export interface ConnectOptions { login?: string; /** Si true, ne pas demander de confirmation (reconnect-last entièrement automatique). */ skipConfirm?: boolean; + /** Si true, garder la session fraîche : ré-authentifier et réinjecter avant expiration. */ + watch?: boolean; } -/** Décrit la sélection d'apps d'une dernière connexion (pour l'affichage). */ +/** 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'; } -/** Construit les options `connect` permettant de rejouer une dernière connexion. */ +/** 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; @@ -54,9 +91,25 @@ export function reconnectOptionsFromLast(last: LastConnection): ConnectOptions { }; } +/** 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, mise à jour des .env. + * (existant ou nouveau), auth, mise à jour des .env, et mode watch optionnel. */ export class EnvSyncService { constructor(private readonly authClient: IAuthClient = new FetchAuthClient()) {} @@ -73,7 +126,7 @@ export class EnvSyncService { const resolved = await this.resolveEnvironment(options); if (resolved === 'reconnect') return; // une reconnexion rapide a déjà été rejouée if (!resolved) return; - const { env, envId } = resolved; + const { env } = resolved; const { apps, allSelected } = await selectAppsStep(config.appsRoot, { app: options.app, @@ -85,7 +138,7 @@ export class EnvSyncService { return; } - const creds = await resolveCredentialsStep(envId, { login: options.login }); + const creds = await resolveCredentialsStep(env.id, { login: options.login }); if (!options.skipConfirm) { const confirmed = await confirmAndRunStep({ environment: env, apps, login: creds.login }); @@ -96,45 +149,122 @@ export class EnvSyncService { } const spinner = ora('Connexion en cours…').start(); + let cookies: AuthCookies; try { - const cookies = await this.authClient.loginAndGetCookies(env.url, { + 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 (creds.shouldSave) { + await addOrUpdateProfileForEnvironment(env.id, { login: creds.login, password: creds.password, + role: creds.role, }); - spinner.succeed('Connexion réussie.'); - - if (creds.shouldSave) { - await addOrUpdateProfileForEnvironment(envId, { - login: creds.login, - password: creds.password, - role: creds.role, - }); - logger.info('Identifiant enregistré pour cet environnement.'); - } + logger.info('Identifiant enregistré pour cet environnement.'); + } + + if (typeof cookies.expiresAt === 'number') { + logger.info(`Session valide ~${formatDuration(cookies.expiresAt - Date.now())}.`); + } + + if (options.watch) { + await this.runWatch(env, apps, creds, allSelected, cookies); + } + } + + /** + * 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, creds.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: creds.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; + } + + /** Délai avant le prochain rafraîchissement watch. */ + private computeRefreshDelay(expiresAt?: number): number { + if (typeof expiresAt === 'number') { + return Math.max(WATCH_MIN_DELAY_MS, expiresAt - Date.now() - REFRESH_BUFFER_MS); } + return WATCH_FALLBACK_INTERVAL_MS; + } + + /** + * Boucle de rafraîchissement (premier plan) : ré-authentifie et réinjecte avant expiration, + * jusqu'à Ctrl+C ou échec. Chaque réinjection redémarre le dev-server Vite (reload de la page). + */ + private async runWatch( + env: EnvironmentConfig, + apps: AppSummary[], + creds: ResolvedCredentials, + allSelected: boolean, + initial: AuthCookies + ): Promise { + logger.warn( + 'Mode --watch actif : chaque rafraîchissement réécrit les .env → Vite redémarre et recharge la page (perte du HMR). 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) { + const delay = this.computeRefreshDelay(cookies.expiresAt); + logger.info( + `Prochain rafraîchissement dans ${formatDuration(delay)} (Ctrl+C pour arrêter).` + ); + if ((await sleep(delay, controller.signal)) === 'aborted') break; + + const spinner = ora('Rafraîchissement de la session…').start(); + try { + cookies = await this.authenticateAndInject(env, apps, creds, allSelected); + spinner.succeed(`Session rafraîchie — ${apps.length} fichier(s) .env mis à jour.`); + } catch (err) { + spinner.fail('Échec du rafraîchissement — 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.'); } /** @@ -159,27 +289,33 @@ export class EnvSyncService { return null; } - const last = await getLastConnection(); + 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 })); - if (last) { - envChoices.unshift({ - name: `🔄 Reconnexion rapide : ${last.envId} / ${last.login} (${describeLastConnectionApps(last)})`, - value: RECONNECT_CHOICE, - }); - } + 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: envChoices, + choices, }, ]); - if (selectedEnvId === RECONNECT_CHOICE && last) { - await this.runInteractive(reconnectOptionsFromLast(last)); - return 'reconnect'; + 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); diff --git a/src/steps/connect/ResolveCredentialsStep.ts b/src/steps/connect/ResolveCredentialsStep.ts index 98a8e29..70e53ed 100644 --- a/src/steps/connect/ResolveCredentialsStep.ts +++ b/src/steps/connect/ResolveCredentialsStep.ts @@ -1,6 +1,6 @@ import inquirer from 'inquirer'; -import { getProfilesForEnvironment } from '../../config/credentialsStore.js'; +import { getLoginRecency, getProfilesForEnvironment } from '../../config/credentialsStore.js'; const CHOICE_NEW_CREDENTIALS = '__new__'; @@ -64,8 +64,14 @@ export async function resolveCredentialsStep( } 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 = [ - ...savedProfiles.map((p) => ({ + ...ordered.map((p) => ({ name: p.role ? `${p.login} (${p.role})` : p.login, value: p.login, })), diff --git a/tests/config/credentialsStore.test.ts b/tests/config/credentialsStore.test.ts index 1836808..75a2423 100644 --- a/tests/config/credentialsStore.test.ts +++ b/tests/config/credentialsStore.test.ts @@ -10,7 +10,10 @@ import { getProfilesForEnvironment, addOrUpdateProfileForEnvironment, getLastConnection, - setLastConnection, + getRecentConnections, + getLoginRecency, + recordConnection, + connectionSignature, type UserCredentialsStore, } from '../../src/config/credentialsStore.js'; @@ -89,12 +92,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/core/auth/FetchAuthClient.test.ts b/tests/core/auth/FetchAuthClient.test.ts new file mode 100644 index 0000000..2913c8c --- /dev/null +++ b/tests/core/auth/FetchAuthClient.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; + +import { 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(); + }); +}); diff --git a/tests/services/EnvSyncService.test.ts b/tests/services/EnvSyncService.test.ts index 7d915fb..5712487 100644 --- a/tests/services/EnvSyncService.test.ts +++ b/tests/services/EnvSyncService.test.ts @@ -4,9 +4,16 @@ import { join } from 'path'; import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { saveUserCredentialsStore } from '../../src/config/credentialsStore.js'; +import { + getRecentConnections, + saveUserCredentialsStore, +} from '../../src/config/credentialsStore.js'; import type { AuthCredentials, AuthCookies, IAuthClient } from '../../src/core/auth/AuthClient.js'; -import { EnvSyncService } from '../../src/services/EnvSyncService.js'; +import { + EnvSyncService, + describeFreshness, + isSessionStale, +} from '../../src/services/EnvSyncService.js'; import { readEnvFile } from '../../src/utils/envFile.js'; import { AuthError } from '../../src/utils/errors.js'; @@ -70,7 +77,8 @@ describe.sequential('EnvSyncService.runInteractive', () => { }); it('authentifie et injecte les cookies dans le .env de l’app (sans prompt)', async () => { - const client = new FakeAuthClient({ xsrfToken: 'xsrf-123', sessionId: 'sess-456' }); + 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 }); @@ -86,6 +94,11 @@ describe.sequential('EnvSyncService.runInteractive', () => { 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 () => { @@ -116,3 +129,29 @@ describe.sequential('EnvSyncService.runInteractive', () => { 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('⚠️'); + }); +}); From b184a9f06cd448a96488469916c532e4c74c4454 Mon Sep 17 00:00:00 2001 From: "Pascal S." Date: Mon, 22 Jun 2026 17:11:09 +0200 Subject: [PATCH 08/10] feat: keep --watch sessions alive via a probe loop The recette oneSessionId cookie carries no Max-Age/Expires (pure session cookie) and the server uses a sliding ~5 min inactivity timeout, so timer-based re-auth was guessing and forced a Vite reload on every tick. Instead, --watch now pings GET /auth/oauth2/userinfo (200 = alive) on an interval: while the session answers, the ping itself keeps the sliding server-side session alive, so nothing is rewritten and Vite never reloads. Re-auth + .env re-injection happen only when the probe reports the session is actually dead. - IAuthClient.isSessionAlive(envUrl, sessionId) + FetchAuthClient impl (shared fetch-with-timeout helper); GET with only the oneSessionId cookie. - runWatch rewritten as a keep-alive loop; --watch-interval , default 2 min (kept below the observed ~5 min idle timeout so a ping refreshes the session mid-life rather than at expiry). - README updated; tests for isSessionAlive (mocked fetch). Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 13 +++-- src/cli/index.ts | 7 ++- src/core/auth/AuthClient.ts | 5 ++ src/core/auth/FetchAuthClient.ts | 47 +++++++++++----- src/services/EnvSyncService.ts | 72 +++++++++++++++---------- tests/core/auth/FetchAuthClient.test.ts | 28 +++++++++- tests/services/EnvSyncService.test.ts | 4 ++ 7 files changed, 128 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 879fb00..064b09d 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,8 @@ 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` : garder la session fraîche (voir [Mode watch](#mode-watch--watch)) +- `--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 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)). @@ -88,15 +89,19 @@ dev-auth-fetcher reconnect-last ### Mode watch (`--watch`) -Les cookies de session expirent au bout d'un certain temps : votre front local se met alors à recevoir des 401. Le mode `--watch` garde la session fraîche en **ré-authentifiant et réinjectant les `.env` automatiquement avant l'expiration**, tant que le process tourne (premier plan, `Ctrl+C` pour arrêter). +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 ``` -Le délai de rafraîchissement est calculé à partir de l'expiration estimée du cookie de session (attribut `Max-Age`/`Expires`), avec un repli sur un intervalle fixe si cette info est absente. +À 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 : -> ⚠️ **À savoir** : chaque rafraîchissement réécrit le `.env`. Vite **surveille les `.env`** et **redémarre le dev-server** quand ils changent, ce qui provoque un **rechargement complet de la page** (perte de l'état HMR). Le mode watch est donc surtout utile pour de longues sessions ; pour un coup ponctuel, préférez `reconnect-last`. +- **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 diff --git a/src/cli/index.ts b/src/cli/index.ts index 06cf038..06e39fe 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -32,7 +32,12 @@ program .option('-l, --login ', "Login de l'utilisateur à utiliser") .option( '--watch', - 'Garder la session fraîche : ré-authentifier et réinjecter avant expiration (Vite recharge la page à chaque refresh)' + '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/core/auth/AuthClient.ts b/src/core/auth/AuthClient.ts index 9315b28..fe3c7a3 100644 --- a/src/core/auth/AuthClient.ts +++ b/src/core/auth/AuthClient.ts @@ -16,4 +16,9 @@ export interface AuthCookies { 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 41b3caf..a4c8114 100644 --- a/src/core/auth/FetchAuthClient.ts +++ b/src/core/auth/FetchAuthClient.ts @@ -5,6 +5,9 @@ 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; @@ -121,30 +124,48 @@ export class FetchAuthClient implements IAuthClient { } /** - * POST avec timeout. Convertit les échecs réseau / dépassement de délai en AuthError lisible. + * 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(loginUrl, { - method: 'POST', - headers: { - 'content-type': 'application/x-www-form-urlencoded', - }, - referrer: loginUrl, - redirect: 'manual', - body, - signal: controller.signal, - }); + 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 ${loginUrl}. Le serveur de recette est peut-être indisponible.` + `Délai dépassé (${this.timeoutMs} ms) en contactant ${url}. Le serveur de recette est peut-être indisponible.` ); } throw new AuthError( - `Impossible de contacter ${loginUrl} : ${err instanceof Error ? err.message : String(err)}` + `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 424732e..42c62e7 100644 --- a/src/services/EnvSyncService.ts +++ b/src/services/EnvSyncService.ts @@ -27,12 +27,14 @@ const RECONNECT_CHOICE = '__reconnect_last__'; /** 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; -/** En mode watch, on rafraîchit ce délai avant l'expiration estimée. */ -const REFRESH_BUFFER_MS = 2 * 60 * 1000; -/** Intervalle de rafraîchissement de repli quand l'expiration du cookie est inconnue. */ -const WATCH_FALLBACK_INTERVAL_MS = 30 * 60 * 1000; -/** Délai minimum entre deux rafraîchissements (anti-boucle serrée). */ -const WATCH_MIN_DELAY_MS = 5000; +/** + * 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; @@ -43,8 +45,10 @@ export interface ConnectOptions { login?: string; /** Si true, ne pas demander de confirmation (reconnect-last entièrement automatique). */ skipConfirm?: boolean; - /** Si true, garder la session fraîche : ré-authentifier et réinjecter avant expiration. */ + /** 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). */ @@ -173,7 +177,11 @@ export class EnvSyncService { } if (options.watch) { - await this.runWatch(env, apps, creds, allSelected, cookies); + 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); } } @@ -216,27 +224,23 @@ export class EnvSyncService { return cookies; } - /** Délai avant le prochain rafraîchissement watch. */ - private computeRefreshDelay(expiresAt?: number): number { - if (typeof expiresAt === 'number') { - return Math.max(WATCH_MIN_DELAY_MS, expiresAt - Date.now() - REFRESH_BUFFER_MS); - } - return WATCH_FALLBACK_INTERVAL_MS; - } - /** - * Boucle de rafraîchissement (premier plan) : ré-authentifie et réinjecte avant expiration, - * jusqu'à Ctrl+C ou échec. Chaque réinjection redémarre le dev-server Vite (reload de la page). + * 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 + initial: AuthCookies, + intervalMs: number ): Promise { logger.warn( - 'Mode --watch actif : chaque rafraîchissement réécrit les .env → Vite redémarre et recharge la page (perte du HMR). Ctrl+C pour arrêter.' + `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(); @@ -245,18 +249,30 @@ export class EnvSyncService { let cookies = initial; try { while (!controller.signal.aborted) { - const delay = this.computeRefreshDelay(cookies.expiresAt); - logger.info( - `Prochain rafraîchissement dans ${formatDuration(delay)} (Ctrl+C pour arrêter).` - ); - if ((await sleep(delay, controller.signal)) === 'aborted') break; + 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; + } - const spinner = ora('Rafraîchissement de la session…').start(); + 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 rafraîchie — ${apps.length} fichier(s) .env mis à jour.`); + spinner.succeed(`Session rétablie — ${apps.length} fichier(s) .env mis à jour.`); } catch (err) { - spinner.fail('Échec du rafraîchissement — arrêt du mode --watch.'); + spinner.fail('Échec de la ré-authentification — arrêt du mode --watch.'); logger.error(err instanceof Error ? err.message : String(err)); break; } diff --git a/tests/core/auth/FetchAuthClient.test.ts b/tests/core/auth/FetchAuthClient.test.ts index 2913c8c..e1c375f 100644 --- a/tests/core/auth/FetchAuthClient.test.ts +++ b/tests/core/auth/FetchAuthClient.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; -import { parseCookieExpiry } from '../../../src/core/auth/FetchAuthClient.js'; +import { FetchAuthClient, parseCookieExpiry } from '../../../src/core/auth/FetchAuthClient.js'; describe('parseCookieExpiry', () => { const NOW = 1_700_000_000_000; @@ -24,3 +24,27 @@ describe('parseCookieExpiry', () => { 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 index 5712487..ba96488 100644 --- a/tests/services/EnvSyncService.test.ts +++ b/tests/services/EnvSyncService.test.ts @@ -28,6 +28,10 @@ class FakeAuthClient implements IAuthClient { if (this.result instanceof Error) throw this.result; return this.result; } + + async isSessionAlive(): Promise { + return true; + } } describe.sequential('EnvSyncService.runInteractive', () => { From 4b49888fc7630af7955f3cb57f23133851bf3e04 Mon Sep 17 00:00:00 2001 From: "Pascal S." Date: Mon, 22 Jun 2026 18:03:50 +0200 Subject: [PATCH 09/10] fix: centralize user config & credentials in ~/.dev-auth-fetcher app.config.json was versioned but held the per-developer appsRoot, so each dev had to edit it locally and avoid committing it (and a personal path leaked into git). User data was also split between config/app.config.json (repo) and ./.dev-auth-fetcher/credentials (cwd). Centralize all non-versioned per-user data into a single home folder ~/.dev-auth-fetcher/ (override via DEV_AUTH_FETCHER_HOME): config.json (appsRoot, defaultEnvironment) + credentials/.json. Environment definitions stay versioned in the repo (config/environments/, shared defaults). loadAppConfig/loadUserCredentialsStore migrate the legacy cwd locations once. app.config.json is untracked and gitignored. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 4 +- CLAUDE.md | 6 +-- README.md | 21 +++++----- config/app.config.json | 4 -- src/config/appConfig.ts | 57 ++++++++++++++++----------- src/config/credentialsStore.ts | 42 +++++++++++++------- src/utils/paths.ts | 38 ++++++++++++++---- tests/config/credentialsStore.test.ts | 2 + tests/services/EnvSyncService.test.ts | 10 +++-- 9 files changed, 120 insertions(+), 64 deletions(-) delete mode 100644 config/app.config.json 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 index 5f9d03e..0e3740a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,15 +43,15 @@ Flux en couches, du plus haut au plus bas niveau : - `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` (`config/app.config.json` : `appsRoot`, `defaultEnvironment`), `envConfigs` (un fichier par env dans `config/environments/`), `credentialsStore` (profils et dernière connexion, **par utilisateur**), `config.types.ts` (types + `VITE_ENV_KEYS`). +- **`src/config/`** — `appConfig` (`~/.dev-auth-fetcher/config.json` : `appsRoot`, `defaultEnvironment`), `envConfigs` (un fichier par env dans `config/environments/`, versionné), `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. -- **Secrets non versionnés** : les credentials et la dernière connexion vivent dans `.dev-auth-fetcher/credentials/.json` (gitignoré). L'utilisateur courant vient de `os.userInfo().username`, surchargeable via la variable d'env `DEV_AUTH_USER`. Ne jamais committer ce dossier ni logger de mot de passe. +- **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. -- **Chemins de config relatifs à `process.cwd()`** : la CLI lit `config/` et `.dev-auth-fetcher/` depuis le répertoire de lancement (en pratique, la racine du repo). +- **Deux familles de stockage** : les **environnements** (`config/environments/`, versionnés, partagés) sont lus relativement au `process.cwd()` (racine du repo) ; les **données utilisateur** (config + credentials) sont dans `~/.dev-auth-fetcher/` (cf. ci-dessus), indépendantes du répertoire de lancement. diff --git a/README.md b/README.md index 064b09d..60f3679 100644 --- a/README.md +++ b/README.md @@ -113,11 +113,19 @@ 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`). -- **Environnements** : un fichier par environnement dans `config/environments/` +- **Configuration utilisateur** : `~/.dev-auth-fetcher/config.json` + - `appsRoot` : chemin racine des applications + - `defaultEnvironment` : environnement par défaut + +- **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** (versionnés, partagés par l'équipe) : un fichier par environnement dans `config/environments/` du repo - Ex. `recette-ode1.json` : `{ "id", "label", "url" }` - **Fichier .env cible** (dans chaque `application/frontend/.env` ou `entcore/application/frontend/.env`) : @@ -125,11 +133,6 @@ dev-auth-fetcher list-apps - `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 **historique des dernières connexions** (jusqu'à 3 : env, login, apps, horodatage et expiration estimée) pour les reconnexions rapides et `reconnect-last`. - ## Scripts | Commande | Description | diff --git a/config/app.config.json b/config/app.config.json deleted file mode 100644 index 9689b3a..0000000 --- a/config/app.config.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "appsRoot": "/Volumes/Work", - "defaultEnvironment": "recette-ode1" -} diff --git a/src/config/appConfig.ts b/src/config/appConfig.ts index 86f5a78..d625ea5 100644 --- a/src/config/appConfig.ts +++ b/src/config/appConfig.ts @@ -2,7 +2,12 @@ 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, + getEnvironmentsConfigDir, + getLegacyAppConfigPath, + getUserDataDir, +} from '../utils/paths.js'; import type { AppConfig } from './config.types.js'; @@ -11,40 +16,47 @@ const DEFAULT_APP_CONFIG: AppConfig = { defaultEnvironment: 'recette-ode1', }; -/** - * 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'); } /** @@ -87,8 +99,7 @@ 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(getUserDataDir(), { recursive: true }); await fs.mkdir(getEnvironmentsConfigDir(), { recursive: true }); const newConfig: AppConfig = { ...DEFAULT_APP_CONFIG, appsRoot: process.cwd() }; await saveAppConfig(newConfig); diff --git a/src/config/credentialsStore.ts b/src/config/credentialsStore.ts index 0187705..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; @@ -49,10 +48,15 @@ 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'); } /** @@ -69,11 +73,8 @@ function migrateRecentConnections(data: UserCredentialsStore): RecentConnection[ return []; } -/** - * Charge le store des credentials de l'utilisateur. Retourne un store vide si le fichier n'existe pas. - */ -export async function loadUserCredentialsStore(): Promise { - const filePath = getUserCredentialsPath(); +/** 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; @@ -82,14 +83,27 @@ export async function loadUserCredentialsStore(): Promise 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). */ diff --git a/src/utils/paths.ts b/src/utils/paths.ts index e3828ea..8189fa5 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,42 @@ 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 : versionnées et partagées, + * elles restent dans le repo (`/config/environments`). */ export function getEnvironmentsConfigDir(): string { - return joinPath(getConfigDir(), 'environments'); + return resolvePath(process.cwd(), 'config', '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'); } diff --git a/tests/config/credentialsStore.test.ts b/tests/config/credentialsStore.test.ts index 75a2423..15a34f4 100644 --- a/tests/config/credentialsStore.test.ts +++ b/tests/config/credentialsStore.test.ts @@ -26,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 }); }); diff --git a/tests/services/EnvSyncService.test.ts b/tests/services/EnvSyncService.test.ts index ba96488..8574be2 100644 --- a/tests/services/EnvSyncService.test.ts +++ b/tests/services/EnvSyncService.test.ts @@ -49,14 +49,17 @@ describe.sequential('EnvSyncService.runInteractive', () => { 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'); - // config/app.config.json + config/environments/.json - await mkdir(join(tempDir, 'config', 'environments'), { recursive: true }); + // config utilisateur (HOME) + environnements versionnés (dans le repo / cwd) appsRoot = join(tempDir, 'apps'); + await mkdir(join(tempDir, 'home'), { recursive: true }); await writeFile( - join(tempDir, 'config', 'app.config.json'), + join(tempDir, 'home', 'config.json'), JSON.stringify({ appsRoot, defaultEnvironment: ENV_ID }, null, 2) ); + await mkdir(join(tempDir, 'config', 'environments'), { recursive: true }); await writeFile( join(tempDir, 'config', 'environments', `${ENV_ID}.json`), JSON.stringify({ id: ENV_ID, label: 'Recette Test', url: ENV_URL }, null, 2) @@ -77,6 +80,7 @@ describe.sequential('EnvSyncService.runInteractive', () => { 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 }); }); From ea7badb7f692d4b8891155b5e5d5e45bbd95a8c4 Mon Sep 17 00:00:00 2001 From: "Pascal S." Date: Mon, 22 Jun 2026 18:26:46 +0200 Subject: [PATCH 10/10] fix: resolve environments from ~/.dev-auth-fetcher too (cwd-independent CLI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Config & credentials already moved to the home dir; environments were the last piece still read from process.cwd()/config/environments, so the CLI only worked when launched from the repo root (a global `bin` install elsewhere found no environments — the likely "it doesn't find my config" reports). Environments now resolve from ~/.dev-auth-fetcher/environments/, seeded on first use from the code-level DEFAULT_ENVIRONMENTS (single versioned source of truth, bundled), with a one-time migration of a legacy config/environments/ if present. The redundant repo config/environments/*.json are removed. The whole runtime state (config + credentials + environments) now lives under the home dir. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 4 +-- README.md | 6 ++-- config/environments/local.json | 5 --- config/environments/recette-ode1.json | 5 --- config/environments/recette-ode2.json | 5 --- config/environments/recette-ode3.json | 5 --- config/environments/recette-ode4.json | 5 --- config/environments/recette-release.json | 5 --- src/config/appConfig.ts | 9 +---- src/config/envConfigs.ts | 36 +++++++++++++++++-- .../onboarding/GenerateEnvConfigsStep.ts | 2 +- src/utils/paths.ts | 11 ++++-- tests/config/envConfigs.test.ts | 18 +++++++++- tests/services/EnvSyncService.test.ts | 7 ++-- 14 files changed, 69 insertions(+), 54 deletions(-) delete mode 100644 config/environments/local.json delete mode 100644 config/environments/recette-ode1.json delete mode 100644 config/environments/recette-ode2.json delete mode 100644 config/environments/recette-ode3.json delete mode 100644 config/environments/recette-ode4.json delete mode 100644 config/environments/recette-release.json diff --git a/CLAUDE.md b/CLAUDE.md index 0e3740a..ae97911 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ Flux en couches, du plus haut au plus bas niveau : - `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` (un fichier par env dans `config/environments/`, versionné), `credentialsStore` (profils + historique des connexions récentes, **par utilisateur**, dans `~/.dev-auth-fetcher/credentials/`), `config.types.ts` (types + `VITE_ENV_KEYS`). +- **`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 @@ -54,4 +54,4 @@ Flux en couches, du plus haut au plus bas niveau : - **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. -- **Deux familles de stockage** : les **environnements** (`config/environments/`, versionnés, partagés) sont lus relativement au `process.cwd()` (racine du repo) ; les **données utilisateur** (config + credentials) sont dans `~/.dev-auth-fetcher/` (cf. ci-dessus), indépendantes du répertoire de lancement. +- **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 60f3679..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 @@ -125,8 +125,8 @@ Les données **non versionnées** (propres à chaque dev) sont centralisées dan > 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** (versionnés, partagés par l'équipe) : un fichier par environnement dans `config/environments/` du repo - - Ex. `recette-ode1.json` : `{ "id", "label", "url" }` +- **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=...` 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/src/config/appConfig.ts b/src/config/appConfig.ts index d625ea5..f773832 100644 --- a/src/config/appConfig.ts +++ b/src/config/appConfig.ts @@ -2,12 +2,7 @@ import * as fs from 'fs/promises'; import * as path from 'path'; import { AppConfigError } from '../utils/errors.js'; -import { - getAppConfigPath, - getEnvironmentsConfigDir, - getLegacyAppConfigPath, - getUserDataDir, -} from '../utils/paths.js'; +import { getAppConfigPath, getLegacyAppConfigPath, getUserDataDir } from '../utils/paths.js'; import type { AppConfig } from './config.types.js'; @@ -99,8 +94,6 @@ export async function setDefaultEnvironmentId(envId: string): Promise { export async function ensureAppConfigExists(): Promise { const config = await loadAppConfig(); if (config) return config; - await fs.mkdir(getUserDataDir(), { 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/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/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/paths.ts b/src/utils/paths.ts index 8189fa5..f6381ed 100644 --- a/src/utils/paths.ts +++ b/src/utils/paths.ts @@ -47,11 +47,11 @@ export function getCredentialsDir(): string { } /** - * Répertoire des définitions d'environnements : versionnées et partagées, - * elles restent dans le repo (`/config/environments`). + * 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 resolvePath(process.cwd(), 'config', 'environments'); + return path.join(getUserDataDir(), 'environments'); } /** Ancien emplacement de la config (pour migration unique). */ @@ -63,3 +63,8 @@ export function getLegacyAppConfigPath(): string { 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/envConfigs.test.ts b/tests/config/envConfigs.test.ts index 324e0bf..d11b831 100644 --- a/tests/config/envConfigs.test.ts +++ b/tests/config/envConfigs.test.ts @@ -1,4 +1,8 @@ -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, @@ -7,6 +11,18 @@ import { } 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/services/EnvSyncService.test.ts b/tests/services/EnvSyncService.test.ts index 8574be2..e74c43d 100644 --- a/tests/services/EnvSyncService.test.ts +++ b/tests/services/EnvSyncService.test.ts @@ -52,16 +52,15 @@ describe.sequential('EnvSyncService.runInteractive', () => { // données utilisateur (config + credentials) centralisées dans ce dossier process.env.DEV_AUTH_FETCHER_HOME = join(tempDir, 'home'); - // config utilisateur (HOME) + environnements versionnés (dans le repo / cwd) + // toutes les données utilisateur (config + environnements + credentials) dans le HOME appsRoot = join(tempDir, 'apps'); - await mkdir(join(tempDir, 'home'), { recursive: true }); + await mkdir(join(tempDir, 'home', 'environments'), { recursive: true }); await writeFile( join(tempDir, 'home', 'config.json'), JSON.stringify({ appsRoot, defaultEnvironment: ENV_ID }, null, 2) ); - await mkdir(join(tempDir, 'config', 'environments'), { recursive: true }); await writeFile( - join(tempDir, 'config', 'environments', `${ENV_ID}.json`), + join(tempDir, 'home', 'environments', `${ENV_ID}.json`), JSON.stringify({ id: ENV_ID, label: 'Recette Test', url: ENV_URL }, null, 2) );