feat(modules): add external module system with auto-discovery and public pages support

- add `src/core/modules/` with registry, discovery (server), and public index
- add `src/core/public-pages/` with registry, server component, and public index
- add `src/core/users/permissions-registry.js` for runtime permission registration
- expose `./modules`, `./public-pages`, and `./public-pages/server` package exports
- rename `registerFeatureRoutes` to `registerApiRoutes` with backward-compatible alias
- extend `seedDefaultRolesAndPermissions` to include module-registered permissions
- update `initializeZen` and shared init to wire module discovery and registration
- add `docs/MODULES.md` documenting the `@zen/module-*` authoring contract
- update `docs/DEV.md` with references to module system docs
This commit is contained in:
2026-04-25 10:50:13 -04:00
parent 3098940905
commit a3aff9fa49
23 changed files with 776 additions and 33 deletions
+129
View File
@@ -0,0 +1,129 @@
import { readFile } from 'node:fs/promises';
import { resolve, join } from 'node:path';
import { createRequire } from 'node:module';
import { info, warn, fail } from '@zen/core/shared/logger';
import { registerModule, getRegisteredModule } from './registry.js';
/**
* Découverte automatique des modules `@zen/module-*` installés dans le projet consommateur.
*
* Stratégie :
* 1. Lire `package.json` du process.cwd() (le projet consommateur, pas @zen/core).
* 2. Pour chaque dépendance dont le nom matche `^@zen/module-` ou `^zen-module-`,
* résoudre son point d'entrée et l'importer.
* 3. Pour les noms qui ne matchent pas le préfixe, fallback : lire
* `keywords` du package.json du package — si "zen-module" est présent, charger.
* 4. Valider la forme du module (manifest, register, createTables/dropTables) et
* l'enregistrer via registerModule().
*
* Cette fonction ne lance PAS les hooks register() — elle se contente de découvrir
* et d'enregistrer les modules dans le registre. Le boot (initializeZen) et le CLI
* (zen-db) consomment ensuite getRegisteredModules() selon leurs besoins.
*
* Idempotente : appeler plusieurs fois ne charge chaque module qu'une seule fois.
*/
const NAME_PREFIX = /^(@zen\/module-|zen-module-)/;
function isCandidate(name) {
return NAME_PREFIX.test(name);
}
async function readJson(path) {
try {
return JSON.parse(await readFile(path, 'utf-8'));
} catch {
return null;
}
}
async function isThirdPartyModule(name, projectCwd) {
// Fallback pour les modules tiers : on regarde le keywords du package.
const require = createRequire(join(projectCwd, 'package.json'));
let pkgJsonPath;
try {
pkgJsonPath = require.resolve(`${name}/package.json`);
} catch {
return false;
}
const pkg = await readJson(pkgJsonPath);
return Array.isArray(pkg?.keywords) && pkg.keywords.includes('zen-module');
}
async function loadModule(name) {
if (getRegisteredModule(name)) return; // déjà chargé
let mod;
try {
// Node résout via node_modules à partir du module appelant ; en pratique
// depuis dist/core/modules/ dans @zen/core (lui-même installé chez le
// consommateur), Node remonte jusqu'aux node_modules du consommateur.
mod = await import(name);
} catch (error) {
fail(`zen-modules: failed to import "${name}" — ${error.message}`);
return;
}
if (!mod.manifest || typeof mod.register !== 'function') {
warn(`zen-modules: "${name}" missing required exports (manifest, register) — skipping`);
return;
}
registerModule({
manifest: mod.manifest,
register: mod.register,
createTables: mod.createTables,
dropTables: mod.dropTables,
});
info(`zen-modules: discovered ${mod.manifest.name}@${mod.manifest.version ?? '?'}`);
}
/**
* Découvre et enregistre tous les modules installés dans le projet consommateur.
*
* @param {object} [options]
* @param {string} [options.cwd] - Répertoire racine du projet consommateur.
* @returns {Promise<{ loaded: string[] }>}
*/
export async function discoverModules({ cwd = process.cwd() } = {}) {
const pkgPath = resolve(cwd, 'package.json');
const pkg = await readJson(pkgPath);
if (!pkg) {
warn(`zen-modules: no package.json at ${pkgPath} — skipping discovery`);
return { loaded: [] };
}
const allDeps = {
...(pkg.dependencies ?? {}),
...(pkg.devDependencies ?? {}),
};
const candidates = [];
for (const name of Object.keys(allDeps)) {
if (isCandidate(name)) {
candidates.push(name);
} else if (await isThirdPartyModule(name, cwd)) {
candidates.push(name);
}
}
for (const name of candidates) {
await loadModule(name);
}
return { loaded: candidates };
}
/**
* Valide les variables d'environnement requises par chaque module.
* Ne lance pas — log un warning pour chaque variable absente.
*/
export function validateModuleEnvVars(modules) {
for (const mod of modules) {
const envVars = mod.manifest?.envVars ?? [];
for (const v of envVars) {
if (v.required && !process.env[v.key]) {
warn(`zen-modules: ${mod.manifest.name} requires env var "${v.key}" — ${v.description ?? ''}`);
}
}
}
}