diff --git a/package.json b/package.json index 47a4a62..4d3f25f 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "test:watch": "vitest", "format": "prettier --write \"src/**/*.{ts,tsx,css,md}\"", "format:check": "prettier --check \"src/**/*.{ts,tsx,css,md}\"", - "preview": "vite preview" + "preview": "vite preview", + "sync:roadmap": "node scripts/sync-roadmap.mjs" }, "dependencies": { "@supabase/supabase-js": "^2.103.2", diff --git a/scripts/sync-roadmap.mjs b/scripts/sync-roadmap.mjs new file mode 100644 index 0000000..18f9a0d --- /dev/null +++ b/scripts/sync-roadmap.mjs @@ -0,0 +1,195 @@ +#!/usr/bin/env node +// @ts-check + +/** + * sync-roadmap.mjs — synchronise la ROADMAP frontend → backend (sens UNIQUE). + * + * SOURCE DE VÉRITÉ : expria-frontend/docs/ROADMAP.md (versionnée). + * CIBLE (copie générée) : expria-backend/docs/ROADMAP.md. + * + * HYPOTHÈSE D'ARBORESCENCE : les deux repos sont FRÈRES sous un parent commun, + * p.ex. D:\expria-v2\{expria-frontend, expria-backend}. Ce script ne fonctionne + * QUE dans cette disposition. Override possible via la variable d'environnement + * EXPRIA_BACKEND_DIR (chemin absolu vers le repo expria-backend). + * + * SENS UNIQUE STRICT : jamais backend → frontend. Aucun paramètre n'inverse le sens ; + * une vérification défensive refuse d'écrire vers un chemin contenant 'expria-frontend'. + * + * DÉCISION (B) — bannière auto-générée : la cible reçoit, EN TÊTE, un commentaire HTML + * (invisible au rendu Markdown) avertissant que le fichier est généré. Le diff IGNORE + * cette bannière (comparaison corps source vs corps cible), pour rester idempotent. + * + * PAS DE COMMIT AUTO : le script écrit le fichier uniquement ; le commit backend reste + * une action manuelle validée. + * + * USAGE (depuis expria-frontend/) : + * npm run sync:roadmap # interactif : montre le diff, demande confirmation y/N + * node scripts/sync-roadmap.mjs --check # dry-run : exit 1 si désynchro, n'écrit rien + * node scripts/sync-roadmap.mjs --yes # non-interactif : écrit sans confirmation + */ + +import { readFileSync, writeFileSync, existsSync, statSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, join, resolve, sep } from 'node:path' +import { createInterface } from 'node:readline' + +const BANNER = + '\n\n' + +const EXPECTED_HEADER = '# ROADMAP.md' + +/** Affiche un message d'erreur et termine en échec. */ +function fail(message) { + console.error(`\n[sync-roadmap] ERREUR : ${message}\n`) + process.exit(1) +} + +// --- Résolution des chemins (dérivés de la position du script) ------------- +const scriptDir = dirname(fileURLToPath(import.meta.url)) +const frontendRoot = resolve(scriptDir, '..') +const sourcePath = join(frontendRoot, 'docs', 'ROADMAP.md') + +const backendRoot = process.env.EXPRIA_BACKEND_DIR + ? resolve(process.env.EXPRIA_BACKEND_DIR) + : resolve(frontendRoot, '..', 'expria-backend') +const backendDocsDir = join(backendRoot, 'docs') +const targetPath = join(backendDocsDir, 'ROADMAP.md') + +// --- Garde-fou : sens unique strict ---------------------------------------- +// La cible ne doit JAMAIS être (ni vivre dans) le repo source. La garde qui fait +// foi est l'identité de chemins RÉSOLUS ; le test de sous-chaîne ne reste qu'un +// signal secondaire. Sur Windows les chemins sont insensibles à la casse. +const norm = (p) => (process.platform === 'win32' ? p.toLowerCase() : p) +const resolvedTarget = norm(resolve(targetPath)) +const resolvedSource = norm(resolve(sourcePath)) +const resolvedFrontend = norm(resolve(frontendRoot)) + +// 1. Garde PRIMAIRE : cible == source (identité de chemin résolu). +if (resolvedTarget === resolvedSource) { + fail(`refus : la cible EST la source (sens unique). Chemin = ${targetPath}`) +} +// 2. Garde ÉLARGIE : cible à l'intérieur du repo source (tout fichier du frontend). +if (resolvedTarget.startsWith(resolvedFrontend + sep)) { + fail(`refus : la cible est à l'intérieur du repo source (sens unique). Cible = ${targetPath}`) +} +// 3. Garde SECONDAIRE (signal de bon sens) : nom contenant 'expria-frontend'. +if (resolvedTarget.includes('expria-frontend')) { + fail(`refus : la cible référence le repo source (sens unique). Cible = ${targetPath}`) +} + +// --- Garde-fou : arborescence backend -------------------------------------- +if (!existsSync(backendDocsDir)) { + fail( + `repo backend introuvable comme frère.\n` + + ` Attendu : ${backendDocsDir}\n` + + ` Astuce : place expria-backend à côté de expria-frontend, ` + + `ou exporte EXPRIA_BACKEND_DIR vers le repo backend.`, + ) +} + +// --- Garde-fou : source valide --------------------------------------------- +if (!existsSync(sourcePath)) { + fail(`source introuvable : ${sourcePath}`) +} +if (statSync(sourcePath).size === 0) { + fail(`source vide : ${sourcePath} (on ne remplace jamais la cible par du vide)`) +} + +const sourceBody = readFileSync(sourcePath, 'utf8') +if (!sourceBody.trimStart().startsWith(EXPECTED_HEADER)) { + fail( + `la source ne commence pas par « ${EXPECTED_HEADER} » — ` + + `format inattendu, abandon par sécurité.`, + ) +} + +// --- Calcul du contenu cible (bannière + corps source) --------------------- +const desiredTarget = BANNER + sourceBody + +/** Retire la bannière auto-générée d'un contenu cible, pour comparer le corps seul. */ +function stripBanner(content) { + if (content.startsWith('') + if (end !== -1) { + // Saute le '-->' puis les sauts de ligne qui le suivent. + return content.slice(end + 3).replace(/^\r?\n+/, '') + } + } + return content +} + +const currentTargetRaw = existsSync(targetPath) ? readFileSync(targetPath, 'utf8') : null +const currentBody = currentTargetRaw === null ? null : stripBanner(currentTargetRaw) + +// --- Idempotence : déjà synchro ? ------------------------------------------ +// On compare les CORPS (bannière ignorée). +if (currentBody === sourceBody) { + console.log('[sync-roadmap] déjà synchro — rien à faire.') + process.exit(0) +} + +// --- Diff (corps source vs corps cible, bannière exclue) ------------------- +function printDiff(oldBody, newBody) { + const oldLines = (oldBody ?? '').split('\n') + const newLines = newBody.split('\n') + const max = Math.max(oldLines.length, newLines.length) + console.log('\n[sync-roadmap] différences (corps, bannière exclue) :') + console.log(` source : ${sourcePath}`) + console.log(` cible : ${targetPath}\n`) + let shown = 0 + for (let i = 0; i < max; i++) { + if (oldLines[i] !== newLines[i]) { + if (oldLines[i] !== undefined) console.log(` - ${oldLines[i]}`) + if (newLines[i] !== undefined) console.log(` + ${newLines[i]}`) + shown++ + } + } + if (currentTargetRaw === null) { + console.log(" (la cible n'existe pas encore — création complète)") + } + console.log(`\n ${shown} ligne(s) divergente(s).`) +} + +printDiff(currentBody, sourceBody) + +const args = new Set(process.argv.slice(2)) + +// --- Mode --check : dry-run, signale la désynchro, n'écrit rien ------------ +if (args.has('--check')) { + console.error('\n[sync-roadmap] --check : cible DÉSYNCHRONISÉE (aucune écriture).\n') + process.exit(1) +} + +/** Demande une confirmation y/N (refus par défaut). */ +function confirm(question) { + return new Promise((res) => { + const rl = createInterface({ input: process.stdin, output: process.stdout }) + rl.question(question, (answer) => { + rl.close() + res(/^y(es)?$/i.test(answer.trim())) + }) + }) +} + +/** Écrit la cible et rappelle la marche à suivre manuelle. */ +function writeTarget() { + writeFileSync(targetPath, desiredTarget, 'utf8') + console.log(`\n[sync-roadmap] écrit : ${targetPath}`) + console.log('\n[sync-roadmap] PAS de commit automatique. Marche à suivre manuelle :') + console.log(' cd ../expria-backend') + console.log(' git add docs/ROADMAP.md') + console.log(' git commit -m "docs(roadmap): sync depuis frontend"\n') +} + +if (args.has('--yes')) { + writeTarget() + process.exit(0) +} + +const ok = await confirm('\n[sync-roadmap] Écrire la cible backend ? (y/N) ') +if (!ok) { + console.log('[sync-roadmap] annulé — aucune écriture.') + process.exit(0) +} +writeTarget()