From a39609d8572a0ea5913be34b8ec1216b897f2d49 Mon Sep 17 00:00:00 2001 From: Pascal Saussier Date: Wed, 11 Feb 2026 15:18:18 +0100 Subject: [PATCH 1/4] feat: first version with ci --- .github/workflows/ci.yml | 39 +++++++++++++++++++++++++++++++++ tests/config/envConfigs.test.ts | 5 +++-- 2 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9c7cd35 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +on: + pull_request: + branches: ['**'] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint-format-test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm run lint + + - name: Format check + run: pnpm run format:check + + - name: Test + run: pnpm test diff --git a/tests/config/envConfigs.test.ts b/tests/config/envConfigs.test.ts index 775344d..f887fe1 100644 --- a/tests/config/envConfigs.test.ts +++ b/tests/config/envConfigs.test.ts @@ -7,11 +7,12 @@ import { } from '../../src/config/envConfigs'; describe('envConfigs', () => { - it('DEFAULT_ENVIRONMENTS contient les 4 environnements attendus', () => { - expect(DEFAULT_ENVIRONMENTS).toHaveLength(4); + it('DEFAULT_ENVIRONMENTS contient les 5 environnements attendus', () => { + expect(DEFAULT_ENVIRONMENTS).toHaveLength(5); const ids = DEFAULT_ENVIRONMENTS.map((e) => e.id); expect(ids).toContain('recette-ode1'); expect(ids).toContain('recette-ode2'); + expect(ids).toContain('recette-ode3'); expect(ids).toContain('recette-release'); expect(ids).toContain('local'); }); From c9445f2090f2bef8850f1f38365c88ccf7de7ec4 Mon Sep 17 00:00:00 2001 From: Pascal Saussier Date: Wed, 11 Feb 2026 15:22:56 +0100 Subject: [PATCH 2/4] chore: format and lint --- src/core/auth/FetchAuthClient.ts | 2 +- src/steps/connect/SelectAppsStep.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/auth/FetchAuthClient.ts b/src/core/auth/FetchAuthClient.ts index d84bf27..4862a51 100644 --- a/src/core/auth/FetchAuthClient.ts +++ b/src/core/auth/FetchAuthClient.ts @@ -58,7 +58,7 @@ export class FetchAuthClient implements IAuthClient { ); } - let sessionId = cookieMap.get('oneSessionId') ?? ''; + const sessionId = cookieMap.get('oneSessionId') ?? ''; let xsrfToken = cookieMap.get('XSRF-TOKEN') ?? ''; if (!xsrfToken || !sessionId) { diff --git a/src/steps/connect/SelectAppsStep.ts b/src/steps/connect/SelectAppsStep.ts index 3322fdc..b206700 100644 --- a/src/steps/connect/SelectAppsStep.ts +++ b/src/steps/connect/SelectAppsStep.ts @@ -49,7 +49,7 @@ export async function selectAppsStep( { type: 'checkbox', name: 'selected', - message: "Sélectionnez les applications à mettre à jour :", + message: 'Sélectionnez les applications à mettre à jour :', choices, }, ]); From d358a5df8ba3bdaccc4bbf25d8011e523d3f5608 Mon Sep 17 00:00:00 2001 From: Pascal Saussier Date: Tue, 17 Feb 2026 16:42:22 +0100 Subject: [PATCH 3/4] feat(app-discovery): adds entcore app support Adds support for discovering applications located under an `entcore` subdirectory. This change introduces a new application discovery mechanism that allows the CLI to identify applications structured as `entcore/{application}/frontend`. The application ID is now `entcore/{application}` to distinguish it from applications located directly under the root directory. The "reconnect-last" feature is updated to use app IDs for reconnecting to selected applications, ensuring correct identification of applications, especially those under `entcore`. The command `connect` can now use app ids to target specific applications. --- README.md | 11 +++--- src/cli/commands/reconnect-last.ts | 9 ++--- src/config/credentialsStore.ts | 7 ++-- src/core/apps/AppDiscovery.ts | 37 ++++++++++++++++++-- src/services/EnvSyncService.ts | 14 ++++---- src/steps/connect/SelectAppsStep.ts | 15 ++++++-- src/steps/onboarding/AskRootDirectoryStep.ts | 2 +- tests/core/apps/AppDiscovery.test.ts | 31 ++++++++++++++++ 8 files changed, 104 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 69f7a7d..7fb589d 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,9 @@ CLI pour connecter un environnement local aux environnements de recette et injec - **Node.js** >= 24 - **pnpm** (gestionnaire de paquets) -- Les applications cibles ont une structure `///frontend/.env` +- Les applications cibles ont une structure : + - à la racine : `///frontend/.env` + - ou sous entcore : `//entcore//frontend/.env` ## Installation @@ -40,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 (chaque app ayant un dossier `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 fichiers dans `config/environments/` (recette-ode1, recette-ode2, recette-release, local) sont créés par défaut. ### Connexion et injection des cookies @@ -55,7 +57,7 @@ node bin/dev-auth-fetcher connect Options de la commande `connect` : - `-e, --env ` : identifiant de l'environnement (ex. `recette-ode1`) -- `-a, --app ` : nom d'une application cible +- `-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) @@ -66,6 +68,7 @@ Exemples : ```bash dev-auth-fetcher connect --env recette-ode1 --all dev-auth-fetcher connect -e recette-ode2 -a mon-app -l mon.login +dev-auth-fetcher connect -e recette-ode1 -a entcore/mediacentre # app sous entcore ``` ### Reconnexion automatique (reconnect-last) @@ -95,7 +98,7 @@ dev-auth-fetcher list-apps - **Environnements** : un fichier par environnement dans `config/environments/` - Ex. `recette-ode1.json` : `{ "id", "label", "url" }` -- **Fichier .env cible** (dans chaque `application/frontend/.env`) : +- **Fichier .env cible** (dans chaque `application/frontend/.env` ou `entcore/application/frontend/.env`) : - `VITE_XSRF_TOKEN=...` - `VITE_ONE_SESSION_ID=...` - `VITE_RECETTE=` diff --git a/src/cli/commands/reconnect-last.ts b/src/cli/commands/reconnect-last.ts index 809cbac..b487088 100644 --- a/src/cli/commands/reconnect-last.ts +++ b/src/cli/commands/reconnect-last.ts @@ -13,10 +13,11 @@ export async function runReconnectLastCommand(): Promise { return; } - const hasAppSelection = last.allApps === true || (last.appNames?.length ?? 0) > 0; + const appIdsOrNames = last.appIds ?? last.appNames; + const hasAppSelection = last.allApps === true || (appIdsOrNames?.length ?? 0) > 0; logger.info( hasAppSelection - ? `Reconnexion automatique : ${last.envId} / ${last.login} (${last.allApps ? 'toutes les apps' : last.appNames!.join(', ')})` + ? `Reconnexion automatique : ${last.envId} / ${last.login} (${last.allApps ? 'toutes les apps' : appIdsOrNames!.join(', ')})` : `Reconnexion : ${last.envId} / ${last.login} (sélection des apps à choisir)` ); @@ -26,8 +27,8 @@ export async function runReconnectLastCommand(): Promise { login: last.login, ...(last.allApps === true ? { all: true } - : last.appNames?.length - ? { apps: last.appNames } + : appIdsOrNames?.length + ? { apps: appIdsOrNames } : {}), skipConfirm: hasAppSelection, }); diff --git a/src/config/credentialsStore.ts b/src/config/credentialsStore.ts index f412d46..b8de505 100644 --- a/src/config/credentialsStore.ts +++ b/src/config/credentialsStore.ts @@ -12,7 +12,9 @@ export interface LastConnection { login: string; /** true si "toutes les applications" avaient été sélectionnées */ allApps?: boolean; - /** noms des applications sélectionnées (vide si allApps) */ + /** ids des applications sélectionnées (prioritaire pour reconnect) */ + appIds?: string[]; + /** noms des applications (rétrocompatibilité, affichage) */ appNames?: string[]; } @@ -114,13 +116,14 @@ export async function getLastConnection(): Promise { export async function setLastConnection( envId: string, login: string, - appSelection?: { allApps: boolean; appNames: string[] } + appSelection?: { allApps: boolean; appIds: string[]; appNames?: string[] } ): Promise { const store = await loadUserCredentialsStore(); store.lastConnection = { envId, login, allApps: appSelection?.allApps, + appIds: appSelection?.appIds?.length ? appSelection.appIds : undefined, appNames: appSelection?.appNames?.length ? appSelection.appNames : undefined, }; await saveUserCredentialsStore(store); diff --git a/src/core/apps/AppDiscovery.ts b/src/core/apps/AppDiscovery.ts index 556d148..89267f5 100644 --- a/src/core/apps/AppDiscovery.ts +++ b/src/core/apps/AppDiscovery.ts @@ -12,7 +12,9 @@ export interface AppSummary { /** * Parcourt appsRoot et détecte les applications ayant un dossier frontend. - * Vérifie ou crée frontend/.env. + * Deux schémas supportés : + * - À la racine : {application}/frontend + * - Sous entcore : entcore/{application}/frontend (id = "entcore/{application}") */ export async function discoverApps(appsRoot: string): Promise { let entries: string[]; @@ -29,6 +31,8 @@ export async function discoverApps(appsRoot: string): Promise { } const apps: AppSummary[] = []; + + // Schéma racine : {application}/frontend for (const name of entries) { const appPath = path.join(appsRoot, name); const stat = await fs.stat(appPath).catch(() => null); @@ -39,7 +43,6 @@ export async function discoverApps(appsRoot: string): Promise { if (!frontendStat?.isDirectory()) continue; const envPath = path.join(frontendPath, '.env'); - // On ne crée pas le .env ici; EnvManager le fera à la première écriture. apps.push({ id: name, name, @@ -47,5 +50,35 @@ export async function discoverApps(appsRoot: string): Promise { envPath, }); } + + // Schéma entcore : entcore/{application}/frontend (id = "entcore/{application}") + const entcorePath = path.join(appsRoot, 'entcore'); + const entcoreStat = await fs.stat(entcorePath).catch(() => null); + if (entcoreStat?.isDirectory()) { + let entcoreEntries: string[]; + try { + entcoreEntries = await fs.readdir(entcorePath); + } catch { + entcoreEntries = []; + } + for (const appName of entcoreEntries) { + const appPath = path.join(entcorePath, appName); + const stat = await fs.stat(appPath).catch(() => null); + if (!stat?.isDirectory()) continue; + + const frontendPath = path.join(appPath, 'frontend'); + const frontendStat = await fs.stat(frontendPath).catch(() => null); + if (!frontendStat?.isDirectory()) continue; + + const envPath = path.join(frontendPath, '.env'); + apps.push({ + id: `entcore/${appName}`, + name: appName, + path: appPath, + envPath, + }); + } + } + return apps.sort((a, b) => a.name.localeCompare(b.name)); } diff --git a/src/services/EnvSyncService.ts b/src/services/EnvSyncService.ts index e147183..7541a92 100644 --- a/src/services/EnvSyncService.ts +++ b/src/services/EnvSyncService.ts @@ -25,7 +25,7 @@ export interface ConnectOptions { env?: string; app?: string; all?: boolean; - /** Liste de noms d'apps (utilisée par reconnect-last). */ + /** Liste d'ids ou noms d'apps (ex. app1, entcore/mediacentre). Utilisée par reconnect-last. */ apps?: string[]; login?: string; /** Si true, ne pas demander de confirmation (reconnect-last entièrement automatique). */ @@ -68,8 +68,8 @@ export class EnvSyncService { const appsDesc = last.allApps === true ? 'toutes les apps' - : last.appNames?.length - ? last.appNames.join(', ') + : (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 }); @@ -85,14 +85,15 @@ export class EnvSyncService { ]); if (selectedEnvId === RECONNECT_CHOICE && last) { - const hasAppSelection = last.allApps === true || (last.appNames?.length ?? 0) > 0; + 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 } - : last.appNames?.length - ? { apps: last.appNames } + : appIdsOrNames?.length + ? { apps: appIdsOrNames } : {}), skipConfirm: hasAppSelection, }); @@ -223,6 +224,7 @@ export class EnvSyncService { // Mémoriser la dernière connexion (envId, login, apps) pour reconnect-last. await setLastConnection(envId, login, { allApps: allSelected, + appIds: apps.map((a) => a.id), appNames: apps.map((a) => a.name), }); diff --git a/src/steps/connect/SelectAppsStep.ts b/src/steps/connect/SelectAppsStep.ts index b206700..f4b74a9 100644 --- a/src/steps/connect/SelectAppsStep.ts +++ b/src/steps/connect/SelectAppsStep.ts @@ -30,11 +30,17 @@ export async function selectAppsStep( } if (options.apps && options.apps.length > 0) { - const apps = discovered.filter((a) => options.apps!.includes(a.name)); - return { apps, allSelected: false }; + const byId = discovered.filter((a) => options.apps!.includes(a.id)); + if (byId.length > 0) return { apps: byId, allSelected: false }; + const byName = discovered.filter((a) => options.apps!.includes(a.name)); + return { apps: byName, allSelected: false }; } if (options.app) { + if (options.app.startsWith('entcore/')) { + const found = discovered.find((a) => a.id === options.app); + return { apps: found ? [found] : [], allSelected: false }; + } const found = discovered.find((a) => a.name === options.app); return { apps: found ? [found] : [], allSelected: false }; } @@ -42,7 +48,10 @@ export async function selectAppsStep( const choices = [ { name: 'Toutes les applications', value: CHOICE_ALL }, new inquirer.Separator(), - ...discovered.map((a) => ({ name: a.name, value: a.id })), + ...discovered.map((a) => ({ + name: a.id !== a.name ? `${a.name} (${a.id})` : a.name, + value: a.id, + })), ]; const { selected } = await inquirer.prompt<{ selected: string[] }>([ diff --git a/src/steps/onboarding/AskRootDirectoryStep.ts b/src/steps/onboarding/AskRootDirectoryStep.ts index 98f31b5..76499be 100644 --- a/src/steps/onboarding/AskRootDirectoryStep.ts +++ b/src/steps/onboarding/AskRootDirectoryStep.ts @@ -16,7 +16,7 @@ export async function askRootDirectoryStep(currentAppsRoot?: string): Promise { if (!input?.trim()) return 'Le chemin ne peut pas être vide.'; diff --git a/tests/core/apps/AppDiscovery.test.ts b/tests/core/apps/AppDiscovery.test.ts index 779da83..787f17e 100644 --- a/tests/core/apps/AppDiscovery.test.ts +++ b/tests/core/apps/AppDiscovery.test.ts @@ -38,4 +38,35 @@ describe('AppDiscovery', () => { it("lance AppDiscoveryError si le répertoire n'existe pas", async () => { await expect(discoverApps(join(tempRoot, 'nonexistent'))).rejects.toThrow(AppDiscoveryError); }); + + it('détecte les applications sous entcore (entcore/{application}/frontend)', async () => { + await mkdir(join(tempRoot, 'entcore', 'app1', 'frontend'), { recursive: true }); + await mkdir(join(tempRoot, 'entcore', 'app2', 'frontend'), { recursive: true }); + const apps = await discoverApps(tempRoot); + expect(apps).toHaveLength(2); + const app1 = apps.find((a) => a.id === 'entcore/app1'); + const app2 = apps.find((a) => a.id === 'entcore/app2'); + expect(app1).toBeDefined(); + expect(app2).toBeDefined(); + expect(app1!.name).toBe('app1'); + expect(app1!.envPath).toContain('entcore'); + expect(app1!.envPath).toContain('app1'); + expect(app1!.envPath).toContain('frontend'); + expect(app1!.envPath).toContain('.env'); + }); + + it('détecte à la fois les apps à la racine et sous entcore (ids distincts)', async () => { + await mkdir(join(tempRoot, 'app1', 'frontend'), { recursive: true }); + await mkdir(join(tempRoot, 'entcore', 'app1', 'frontend'), { recursive: true }); + const apps = await discoverApps(tempRoot); + expect(apps).toHaveLength(2); + const rootApp = apps.find((a) => a.id === 'app1'); + const entcoreApp = apps.find((a) => a.id === 'entcore/app1'); + expect(rootApp).toBeDefined(); + expect(entcoreApp).toBeDefined(); + expect(rootApp!.name).toBe('app1'); + expect(entcoreApp!.name).toBe('app1'); + expect(rootApp!.path).not.toContain('entcore'); + expect(entcoreApp!.path).toContain('entcore'); + }); }); From 45c115539e12800b2e68f017001d2498f1ce9dd1 Mon Sep 17 00:00:00 2001 From: Pascal Saussier Date: Tue, 17 Feb 2026 16:44:31 +0100 Subject: [PATCH 4/4] chore: lint --- src/steps/onboarding/AskRootDirectoryStep.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/steps/onboarding/AskRootDirectoryStep.ts b/src/steps/onboarding/AskRootDirectoryStep.ts index 76499be..b7a95da 100644 --- a/src/steps/onboarding/AskRootDirectoryStep.ts +++ b/src/steps/onboarding/AskRootDirectoryStep.ts @@ -15,8 +15,7 @@ export async function askRootDirectoryStep(currentAppsRoot?: string): Promise { if (!input?.trim()) return 'Le chemin ne peut pas être vide.';