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:
@@ -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 ?? ''}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user