From f1bab0c29ab1a5da0ad9404342beef9aa1d8ea29 Mon Sep 17 00:00:00 2001 From: Malo Date: Mon, 10 Nov 2025 19:17:24 +0100 Subject: [PATCH 1/5] =?UTF-8?q?=F0=9F=92=A9=20Working=20toward=20plugin=20?= =?UTF-8?q?system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + PLUGIN_PLAN.md | 615 ++++++++++++++++++ apps/api/src/app/shared/HexaRegistryModule.ts | 23 +- apps/mcp-server/src/hexa-registry.ts | 13 +- package.json | 3 +- .../node-utils/src/hexa/HexaPluginLoader.ts | 447 +++++++++++++ packages/node-utils/src/index.ts | 1 + scripts/setup-plugin-dev.js | 176 +++++ 8 files changed, 1276 insertions(+), 3 deletions(-) create mode 100644 PLUGIN_PLAN.md create mode 100644 packages/node-utils/src/hexa/HexaPluginLoader.ts create mode 100755 scripts/setup-plugin-dev.js diff --git a/.gitignore b/.gitignore index 5ccfcd8f2..c358aa05e 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ dockerfile/prod/secrets tsconfig.base.effective.json packages/linter/js-playground-local +plugins/ DECISIONS.md LEARNINGS.md diff --git a/PLUGIN_PLAN.md b/PLUGIN_PLAN.md new file mode 100644 index 000000000..df6945ee1 --- /dev/null +++ b/PLUGIN_PLAN.md @@ -0,0 +1,615 @@ +# Packmind Plugin System Architecture + +## Overview + +This document outlines the architecture for adding plugin support to Packmind, allowing external repositories to build and bundle Hexa domains (and eventually frontend components) that can be loaded dynamically at runtime. + +**Status**: Phase 1 (Backend Hexa Loading) is **COMPLETED** ✅. Plugin loading is functional and integrated into API and MCP Server. + +## Core Principles + +1. **Pure Runtime Loading**: Plugins are loaded entirely at runtime - the build process only copies files +2. **Build Independence**: The build process does not analyze, validate, or bundle plugin content +3. **Hot-Pluggable**: Plugins can be added/removed without rebuilding the main applications +4. **Multi-App Support**: Plugins work across all Packmind applications (api, mcp-server, cli, frontend) + +## Plugin Structure + +### Directory Layout + +``` +plugins/ + my-plugin/ + manifest.json # Plugin metadata and configuration + hexaBundle.cjs # Backend Hexa bundle (CommonJS) + frontendBundle.cjs # Frontend routes/components bundle (CommonJS) + # Optional: assets, migrations, etc. +``` + +### Manifest Structure + +```json +{ + "name": "my-plugin", + "version": "1.0.0", + "id": "my-plugin", + "description": "Plugin description", + "backend": { + "hexaBundle": "hexaBundle.cjs", + "hexaExport": "MyPluginHexa", // Export name in the bundle + "opts": {} // Optional: Hexa constructor options + }, + "frontend": { + "bundle": "frontendBundle.cjs", + "routes": [ + // Route definitions + { + "path": "org/:orgSlug/my-feature", + "component": "MyFeatureRoute", + "loader": "myFeatureLoader" // Optional: loader function name + } + ] + }, + "dependencies": { + "packmind": "^1.3.0" + }, + "typeorm": { + "entities": [] // Optional: TypeORM entity classes + } +} +``` + +## File Locations + +### Source Location + +- **Development**: `plugins/` at monorepo root +- **Production**: `dist/plugins/` (copied during build) + +### Runtime Resolution + +- **Development**: Load from `plugins/` (relative to process.cwd()) +- **Production**: Load from `dist/plugins/` (relative to process.cwd()) +- **Configurable**: Via `PACKMIND_PLUGINS_DIR` environment variable + +## Build Process + +### Simple File Copy + +The build process **only copies** plugin files without any analysis: + +```json +// In each app's project.json or build config +{ + "assets": [ + { + "input": "plugins", + "glob": "**/*", + "output": "plugins" + } + ] +} +``` + +**Key Points:** + +- ✅ Build does NOT analyze plugin content +- ✅ Build does NOT validate plugins +- ✅ Build does NOT modify plugin files +- ✅ Build does NOT bundle plugins into app code +- ✅ Build is a simple file copy operation + +### Build Output + +``` +dist/ + ├── apps/ + │ ├── api/ + │ ├── mcp-server/ + │ ├── cli/ + │ └── frontend/ + └── plugins/ # Copied from source plugins/ + └── my-plugin/ + ├── manifest.json + ├── hexaBundle.cjs + └── frontendBundle.cjs +``` + +## Runtime Loading + +### Backend (api, mcp-server, cli) + +**Loading Process:** + +1. Scan plugin directory for `manifest.json` files +2. Read and parse manifest +3. Load `hexaBundle.cjs` using `require()` or dynamic `import()` +4. Extract Hexa class from the bundle using the `hexaExport` name +5. Validate that the class extends `BaseHexa` +6. Register Hexa with `HexaRegistry` (before initialization) +7. HexaRegistry initialization will instantiate and initialize the plugin Hexa + +**Implementation:** + +```typescript +// packages/node-utils/src/hexa/HexaPluginLoader.ts +export class HexaPluginLoader { + async loadFromDirectory(pluginDir: string): Promise { + // Scan for manifest.json files + // Load and parse manifests + // Load hexaBundle.cjs files + // Extract Hexa classes + // Return loaded plugins + } +} +``` + +**Integration Points:** + +- `apps/api/src/app/shared/HexaRegistryModule.ts` - Load plugins before registering built-in hexas +- `apps/mcp-server/src/hexa-registry.ts` - Load plugins before registering built-in hexas +- `apps/cli/src/PackmindCliHexaFactory.ts` - Load plugins when creating CLI hexa registry + +### Frontend + +**Loading Process:** + +1. Scan plugin directory for `manifest.json` files +2. Read and parse manifest +3. Load `frontendBundle.cjs` using dynamic `import()` +4. Extract route components from the bundle +5. Register routes with React Router dynamically + +**Implementation:** + +```typescript +// apps/frontend/app/routes.tsx +import { type RouteConfig } from '@react-router/dev/routes'; +import { flatRoutes } from '@react-router/fs-routes'; +import { loadPluginRoutes } from '../src/plugins/pluginLoader'; + +export default async function routes(): Promise { + const fileBasedRoutes = flatRoutes(); + const pluginRoutes = await loadPluginRoutes(); + return mergeRoutes(fileBasedRoutes, pluginRoutes); +} +``` + +**Route Registration:** + +- Plugin routes are merged with file-based routes +- Routes follow React Router v7 conventions +- Routes can be organization-scoped or space-scoped + +## Plugin Development + +### Development Workflow + +When developing a plugin in a separate repository while working on the core Packmind project: + +1. **Setup plugin development link:** + + ```bash + npm run setup-plugin-dev /path/to/plugin/repository + ``` + + This script: + - Creates a symlink from `plugins/plugin-name` to the plugin repository + - Sets up npm links for `@packmind/node-utils` and `@packmind/types` in the plugin repo + - Ensures the plugin can resolve core packages from the monorepo + +2. **Build core packages (if needed):** + + ```bash + nx build node-utils + nx build types + ``` + +3. **Develop plugin:** + - Work on plugin code in the separate repository + - Plugin can import from `@packmind/node-utils` and `@packmind/types` + - Changes to core packages require rebuilding them + +4. **Test plugin:** + - Plugin is automatically loaded from `plugins/plugin-name` in development + - No rebuild of core apps needed when plugin changes + +### Creating a Plugin + +1. **Create plugin structure:** + + ``` + my-plugin/ + src/ + MyPluginHexa.ts + frontend/ + MyFeatureRoute.tsx + package.json + manifest.json + ``` + +2. **Plugin package.json:** + + ```json + { + "name": "my-plugin", + "version": "1.0.0", + "dependencies": { + "@packmind/node-utils": "workspace:*", + "@packmind/types": "workspace:*" + } + } + ``` + +3. **Build hexaBundle.cjs:** + - Bundle `MyPluginHexa.ts` and dependencies + - Export as CommonJS + - Export the Hexa class with the name specified in manifest + - **Important**: Bundle must include `@packmind/node-utils` and `@packmind/types` or mark them as external + +4. **Build frontendBundle.cjs:** + - Bundle React components and routes + - Export as CommonJS + - Export route components + +5. **Create manifest.json:** + - Define plugin metadata + - Specify bundle file names + - Define route configurations + +### Plugin Hexa Requirements + +Plugins must export a class that: + +- Extends `BaseHexa` from `@packmind/node-utils` +- Implements all abstract methods: + - `initialize(registry: HexaRegistry): Promise` + - `getAdapter(): TPort` + - `getPortName(): string` + - `destroy(): void` + +**Example:** + +```typescript +import { BaseHexa, BaseHexaOpts, HexaRegistry } from '@packmind/node-utils'; +import { DataSource } from 'typeorm'; + +export class MyPluginHexa extends BaseHexa { + constructor(dataSource: DataSource, opts?: Partial) { + super(dataSource, opts); + // Initialize repositories, services, etc. + } + + async initialize(registry: HexaRegistry): Promise { + // Access other hexas via registry + // Set up adapters + // Async initialization + } + + getAdapter(): IMyPluginPort { + return this.adapter; + } + + getPortName(): string { + return 'IMyPluginPort'; + } + + destroy(): void { + // Cleanup + } +} +``` + +## Implementation Phases + +### Phase 1: Backend Hexa Loading ✅ COMPLETED + +- [x] Create `HexaPluginLoader` utility +- [x] Add plugin directory scanning +- [x] Implement manifest parsing +- [x] Implement hexaBundle.cjs loading +- [x] Integrate with HexaRegistry in api, mcp-server +- [x] Error handling and validation +- [x] Logging and debugging support +- [x] Build, test, and lint fixes +- [ ] Add build asset copying (for production builds) +- [ ] Integrate with CLI (packmind-cli) + +### Phase 2: Frontend Route Loading (Future) + +- [ ] Create frontend plugin loader +- [ ] Implement dynamic route registration +- [ ] Update React Router configuration +- [ ] Handle plugin route components +- [ ] Support plugin route loaders +- [ ] Navigation integration + +### Phase 3: Advanced Features (Future) + +- [ ] TypeORM entity support +- [ ] Plugin dependencies +- [ ] Plugin lifecycle hooks +- [ ] Plugin configuration UI +- [ ] Plugin marketplace/discovery + +## Error Handling + +### Plugin Loading Errors + +- Invalid manifest.json → Log error, skip plugin +- Missing bundle file → Log error, skip plugin +- Invalid Hexa class → Log error, skip plugin +- Hexa registration failure → Log error, skip plugin + +### Runtime Errors + +- Plugin Hexa initialization failure → Log error, continue with other hexas +- Plugin route loading failure → Log error, continue with other routes + +## Security Considerations + +- Plugins are loaded from a controlled directory +- Plugins must export valid Hexa classes +- Plugin code runs with same privileges as main app +- Consider sandboxing for untrusted plugins (future) + +## Configuration + +### Environment Variables + +- `PACKMIND_PLUGINS_DIR`: Override default plugin directory path +- `PACKMIND_PLUGINS_ENABLED`: Enable/disable plugin loading (default: true) + +### Development vs Production + +- **Development**: Load from `plugins/` (source directory) +- **Production**: Load from `dist/plugins/` (build output) + +## Testing Strategy + +1. **Unit Tests**: Test HexaPluginLoader in isolation +2. **Integration Tests**: Test plugin loading in each app +3. **E2E Tests**: Test plugin Hexa registration and initialization +4. **Plugin Examples**: Create example plugins for testing + +## Open Questions + +1. **TypeORM Entities**: How should plugins provide their own entities? + - Option A: Plugins register entities in their Hexa constructor + - Option B: Plugins export entity classes, main app registers them + - Option C: Plugins use existing entity registration mechanism + +2. **Plugin Dependencies**: How to handle plugin-to-plugin dependencies? + - Option A: Load plugins in dependency order (requires dependency graph) + - Option B: Plugins access other plugins via HexaRegistry + +3. **Frontend Bundle Format**: Should frontend bundles be ESM or CommonJS? + - React Router v7 supports both + - CommonJS might be simpler for initial implementation + +4. **Plugin Versioning**: How to handle plugin updates? + - Hot-reload plugins? + - Restart required? + - Version compatibility checks? + +## Plugin Development Setup - DX Challenges & Solutions + +### The Challenge + +Developing plugins in a separate repository while working on the core Packmind project presents several DX challenges: + +1. **Package Resolution**: `@packmind/node-utils` and `@packmind/types` are not published to npm +2. **Build Dependencies**: Plugin needs access to built packages, not source +3. **TypeScript Support**: Plugin needs type definitions for IDE/type checking +4. **Hot Reload**: Changes to core packages should be reflected in plugin development +5. **Bundling Strategy**: Should plugins bundle core packages or mark them external? + +### DX Challenges to Solve + +#### Challenge 1: Package Resolution + +**Problem**: Plugin repo can't use `workspace:*` because it's not part of the monorepo workspace. + +**Options:** + +- **Option A: npm link** (Global symlinks) + - ✅ Works across repos + - ❌ Global state, can be fragile + - ❌ Requires packages to be built first + - ❌ Paths are absolute, breaks if monorepo moves + +- **Option B: file: protocol** (Relative paths in package.json) + - ✅ Simple, no global state + - ❌ Requires absolute or relative paths + - ❌ Breaks if plugin repo moves + - ❌ Need to update package.json manually + +- **Option C: pnpm link** (If using pnpm) + - ✅ Better than npm link + - ❌ Requires pnpm in plugin repo + - ❌ Still requires built packages + +- **Option D: Manual node_modules symlinks** + - ✅ Full control + - ❌ Manual setup, error-prone + - ❌ Breaks on npm install + +**Recommendation**: Use npm link with a setup script that handles the complexity. + +#### Challenge 2: Build Requirements + +**Problem**: Core packages must be built before plugin can use them. + +**Questions:** + +- Should setup script auto-build if packages aren't built? +- Should it watch for changes and rebuild? +- How to handle when core packages change during development? + +**Proposed Solution:** + +- Setup script checks if packages are built, builds if needed +- Provide separate command: `npm run build-core-packages` +- Document that core changes require rebuild + +#### Challenge 3: TypeScript Type Resolution + +**Problem**: Plugin needs `.d.ts` files for TypeScript/IDE support. + +**Questions:** + +- Do built packages include `.d.ts` files? (Yes, they should) +- Does npm link preserve type definitions? +- How to configure plugin's tsconfig.json? + +**Proposed Solution:** + +- Built packages include `.d.ts` files in `dist/packages/*/src/*.d.ts` +- npm link should preserve types +- Plugin tsconfig can reference linked packages normally + +#### Challenge 4: Bundling Strategy + +**Problem**: When building plugin bundle, should `@packmind/node-utils` and `@packmind/types` be: + +- Bundled into the plugin? (Larger bundle, duplicates code) +- Marked as external? (Requires runtime resolution) + +**Considerations:** + +- Runtime loading: Plugin loader runs in Packmind context where packages already exist +- Bundle size: External is smaller +- Version conflicts: External avoids conflicts +- Runtime errors: External requires packages to be available + +**Recommendation**: Mark as external - plugins run in Packmind context where packages are already loaded. + +#### Challenge 5: Development Workflow Friction + +**Questions:** + +- How often do developers need to rebuild core packages? +- Should there be a watch mode for core packages? +- How to test plugin changes without restarting Packmind apps? +- What happens if plugin repo has its own node_modules? + +**Proposed Workflow:** + +1. Initial setup: `npm run setup-plugin-dev /path/to/plugin` +2. Develop plugin in separate repo +3. When core packages change: `npm run build-core-packages` (or auto-detect) +4. Plugin changes: Rebuild plugin bundle, Packmind auto-reloads (runtime loading) + +#### Challenge 6: Symlink Conflicts + +**Problem**: Symlinking plugin repo to `plugins/` might conflict with: + +- Plugin repo's own `.gitignore` +- Plugin repo's `node_modules` +- Plugin repo's build artifacts + +**Questions:** + +- Should plugin repo have its own `node_modules`? +- Should we symlink the entire repo or just the dist folder? +- How to handle plugin repo's git state? + +**Proposed Solution:** + +- Symlink entire plugin repo (simpler) +- Plugin repo manages its own `node_modules` (separate from core) +- Core's `.gitignore` should ignore `plugins/` directory (contains copied plugin files) +- Plugin's `.gitignore` should ignore its own build artifacts +- **Docker Support**: The `plugins/` directory is automatically available in Docker containers via the volume mount `.:/packmind` in docker-compose.yml + +### Proposed Setup Script Behavior + +```bash +npm run setup-plugin-dev /path/to/plugin/repository +``` + +**What it should do:** + +1. ✅ Validate plugin repo path exists +2. ✅ Read plugin name from `package.json` or `manifest.json` +3. ✅ Check if core packages are built, build if needed +4. ✅ Create symlink: `plugins/{plugin-name}` → `/path/to/plugin/repository` +5. ✅ Navigate to plugin repo +6. ✅ Run `npm link` for `@packmind/node-utils` and `@packmind/types` pointing to `dist/packages/*` +7. ✅ Run `npm install` in plugin repo (to install other dependencies) +8. ✅ Verify setup (check that packages resolve) + +**What it should NOT do:** + +- ❌ Modify plugin repo's package.json +- ❌ Create files in plugin repo +- ❌ Assume plugin repo structure + +### Open Questions + +1. **Package Manager**: Should we support both npm and pnpm? (Currently using npm) +2. **Watch Mode**: Should setup script offer a watch mode for core packages? +3. **Multiple Plugins**: How to handle multiple plugin repos? +4. **Cleanup**: Should there be a `teardown-plugin-dev` command? +5. **Plugin Structure**: Should we enforce a specific plugin repo structure? +6. **Error Handling**: What happens if link fails? How to recover? +7. **Cross-Platform**: Will symlinks work on Windows? (Need junction on Windows) + +## Implementation Status + +### ✅ Completed (Phase 1) + +1. **HexaPluginLoader Utility** (`packages/node-utils/src/hexa/HexaPluginLoader.ts`) + - Scans plugin directory for `manifest.json` files + - Loads and parses plugin manifests + - Dynamically loads `hexaBundle.cjs` files + - Extracts and validates Hexa classes + - Comprehensive error handling and logging + +2. **Integration Points** + - ✅ Integrated into `HexaRegistryModule` (NestJS API) + - ✅ Integrated into `hexa-registry.ts` (MCP Server) + - ⏳ CLI integration pending + +3. **Sample Plugin Repository** + - Created `/home/croquette/Code/packmind-plugin` with: + - Sample Hexa implementation (`SamplePluginHexa`) + - Nx build configuration + - Setup script for core dependencies (`setup-core-deps.js`) + - Manifest structure + +4. **Development Workflow** + - Plugin repository structure established + - Core dependency copying script (`setup-core-deps.js`) + - File-based package resolution using `file:` protocol + - Symlink support for plugin development + +5. **Quality Assurance** + - ✅ All tests passing (node-utils: 114 tests, api: 101 tests) + - ✅ Build successful for node-utils and api + - ✅ Lint passing for node-utils and api + +### 🔄 Next Steps + +1. **Build Asset Copying** + - Add plugin directory copying to build configs (api, mcp-server, cli) + - Ensure plugins are copied to `dist/plugins/` during build + +2. **CLI Integration** + - Integrate plugin loading into `PackmindCliHexaFactory` + +3. **Testing** + - Test plugin loading with sample plugin + - Verify plugin Hexa initialization + - Test plugin error handling + +4. **Documentation** + - Create plugin development guide + - Document plugin manifest structure + - Document bundling requirements + +5. **Future Enhancements** + - Frontend plugin loading (Phase 2) + - TypeORM entity support + - Plugin dependencies management + - Plugin lifecycle hooks diff --git a/apps/api/src/app/shared/HexaRegistryModule.ts b/apps/api/src/app/shared/HexaRegistryModule.ts index fbba6d5bc..7447ae77b 100644 --- a/apps/api/src/app/shared/HexaRegistryModule.ts +++ b/apps/api/src/app/shared/HexaRegistryModule.ts @@ -7,7 +7,12 @@ import { DeploymentsHexa } from '@packmind/deployments'; import { GitHexa } from '@packmind/git'; import { LinterHexa } from '@packmind/linter'; import { PackmindLogger } from '@packmind/logger'; -import { BaseHexa, BaseHexaOpts, HexaRegistry } from '@packmind/node-utils'; +import { + BaseHexa, + BaseHexaOpts, + HexaRegistry, + HexaPluginLoader, +} from '@packmind/node-utils'; import { RecipesHexa } from '@packmind/recipes'; import { SpacesHexa } from '@packmind/spaces'; import { StandardsHexa } from '@packmind/standards'; @@ -23,6 +28,7 @@ import { } from '@packmind/types'; import { DataSource } from 'typeorm'; import { ApiKeyServiceProvider } from './ApiKeyServiceProvider'; +import { logger } from '@sentry/node'; /** * Configuration interface for HexaRegistry integration with NestJS @@ -127,6 +133,21 @@ export class HexaRegistryModule { ): Promise => { const registry = new HexaRegistry(); + // Load plugins first (before built-in hexas) + const pluginLogger = new PackmindLogger('HexaPluginLoader'); + const pluginLoader = new HexaPluginLoader(pluginLogger); + const plugins = await pluginLoader.loadFromDirectory(); + logger.info(`Found ${plugins.length} plugins`); + for (const plugin of plugins) { + if (plugin.hexaClass) { + const opts = plugin.manifest.backend?.opts; + registry.register(plugin.hexaClass, opts); + pluginLogger.info( + `Registered plugin Hexa: ${plugin.manifest.name}`, + ); + } + } + // Register all hexa types in the specified order // For AccountsHexa, we need to pass the apiKeyService option for (const HexaClass of options.hexas) { diff --git a/apps/mcp-server/src/hexa-registry.ts b/apps/mcp-server/src/hexa-registry.ts index ea54911fb..902bc4919 100644 --- a/apps/mcp-server/src/hexa-registry.ts +++ b/apps/mcp-server/src/hexa-registry.ts @@ -6,7 +6,7 @@ import { GitHexa } from '@packmind/git'; import { JobsHexa } from '@packmind/jobs'; import { LinterHexa } from '@packmind/linter'; import { LogLevel, PackmindLogger } from '@packmind/logger'; -import { HexaRegistry } from '@packmind/node-utils'; +import { HexaRegistry, HexaPluginLoader } from '@packmind/node-utils'; import { RecipesHexa } from '@packmind/recipes'; import { SpacesHexa } from '@packmind/spaces'; import { StandardsHexa } from '@packmind/standards'; @@ -44,6 +44,17 @@ async function hexaRegistryPlugin(fastify: FastifyInstance) { const registry = new HexaRegistry(); logger.debug('HexaRegistry instance created'); + // Load plugins first (before built-in hexas) + const pluginLoader = new HexaPluginLoader(logger); + const plugins = await pluginLoader.loadFromDirectory(); + for (const plugin of plugins) { + if (plugin.hexaClass) { + const opts = plugin.manifest.backend?.opts; + registry.register(plugin.hexaClass, opts); + logger.info(`Registered plugin Hexa: ${plugin.manifest.name}`); + } + } + // Register domain hexas in dependency order // AccountsHexa has no dependencies, JobsHexa has no dependencies, GitHexa may depend on Accounts, RecipesHexa depends on Git, StandardsHexa depends on Git and Jobs registry.register(AccountsHexa); diff --git a/package.json b/package.json index 73ed03b38..304a973c8 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "prettier:check": "prettier -c .", "prettier:write": "prettier -w .", "chakra:typegen": "npx --workspace=packages/ui @chakra-ui/cli typegen ./src/lib/theme/theme.ts", - "typecheck:frontend": "tsc --noEmit -p apps/frontend/tsconfig.app.json" + "typecheck:frontend": "tsc --noEmit -p apps/frontend/tsconfig.app.json", + "setup-plugin-dev": "node scripts/setup-plugin-dev.js" }, "workspaces": [ "packages/*", diff --git a/packages/node-utils/src/hexa/HexaPluginLoader.ts b/packages/node-utils/src/hexa/HexaPluginLoader.ts new file mode 100644 index 000000000..dbe0c2f86 --- /dev/null +++ b/packages/node-utils/src/hexa/HexaPluginLoader.ts @@ -0,0 +1,447 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ +import { readdir, readFile, stat } from 'fs/promises'; +import { join, resolve } from 'path'; +import { createRequire } from 'module'; +import { BaseHexa, BaseHexaOpts } from './BaseHexa'; +import { PackmindLogger } from '@packmind/logger'; +import { DataSource } from 'typeorm'; + +const origin = 'HexaPluginLoader'; + +/** + * Plugin manifest structure + */ +export interface PluginManifest { + name: string; + version: string; + id: string; + description?: string; + backend?: { + hexaBundle: string; + hexaExport: string; + opts?: Partial; + }; + frontend?: { + bundle: string; + routes?: Array<{ + path: string; + component: string; + loader?: string; + }>; + }; + dependencies?: { + packmind?: string; + }; +} + +/** + * Loaded plugin information + */ +export interface LoadedPlugin { + manifest: PluginManifest; + pluginDir: string; + hexaClass?: new ( + dataSource: DataSource, + opts?: Partial, + ) => BaseHexa; +} + +/** + * Loader for Packmind plugins. + * + * Scans a plugin directory for manifest.json files, loads plugin bundles, + * and extracts Hexa classes for registration with HexaRegistry. + */ +export class HexaPluginLoader { + private readonly logger: PackmindLogger; + + constructor(logger?: PackmindLogger) { + this.logger = logger ?? new PackmindLogger(origin); + } + + /** + * Load all plugins from a directory. + * + * @param pluginDir - Path to the plugin directory (defaults to plugins/ or dist/plugins/) + * @returns Array of loaded plugins + */ + async loadFromDirectory(pluginDir?: string): Promise { + const resolvedDir = this.resolvePluginDirectory(pluginDir); + this.logger.info(`Loading plugins from: ${resolvedDir}`); + this.logger.info(`Current working directory: ${process.cwd()}`); + this.logger.info(`NODE_ENV: ${process.env['NODE_ENV'] || 'not set'}`); + + if (!(await this.directoryExists(resolvedDir))) { + this.logger.warn(`Plugin directory does not exist: ${resolvedDir}`); + return []; + } + + this.logger.info(`Plugin directory exists: ${resolvedDir}`); + const plugins: LoadedPlugin[] = []; + const entries = await readdir(resolvedDir, { withFileTypes: true }); + + this.logger.info(`Found ${entries.length} entries in plugin directory`); + for (const entry of entries) { + this.logger.info( + `Entry: ${entry.name} (isDirectory: ${entry.isDirectory()}, isSymbolicLink: ${entry.isSymbolicLink()})`, + ); + + if (!entry.isDirectory()) { + this.logger.debug(`Skipping non-directory entry: ${entry.name}`); + continue; + } + + const pluginPath = join(resolvedDir, entry.name); + this.logger.info(`Attempting to load plugin from: ${pluginPath}`); + try { + const plugin = await this.loadPlugin(pluginPath); + if (plugin) { + plugins.push(plugin); + this.logger.info( + `Successfully loaded plugin: ${plugin.manifest.name} (${plugin.manifest.id})`, + ); + } else { + this.logger.warn(`loadPlugin returned null for: ${pluginPath}`); + } + } catch (error) { + this.logger.error(`Failed to load plugin from ${pluginPath}`, { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }); + // Continue loading other plugins even if one fails + } + } + + this.logger.info(`Loaded ${plugins.length} plugin(s) total`); + return plugins; + } + + /** + * Load a single plugin from a directory. + */ + private async loadPlugin(pluginDir: string): Promise { + this.logger.info(`Loading plugin from directory: ${pluginDir}`); + const manifestPath = join(pluginDir, 'manifest.json'); + this.logger.info(`Looking for manifest at: ${manifestPath}`); + + if (!(await this.fileExists(manifestPath))) { + this.logger.warn(`No manifest.json found in ${pluginDir}`); + this.logger.info(`Listing directory contents of ${pluginDir}:`); + try { + const dirContents = await readdir(pluginDir); + this.logger.info(`Directory contents: ${dirContents.join(', ')}`); + } catch (err) { + this.logger.error(`Failed to list directory: ${err}`); + } + return null; + } + + this.logger.info(`Found manifest.json at: ${manifestPath}`); + // Read and parse manifest + const manifestContent = await readFile(manifestPath, 'utf-8'); + let manifest: PluginManifest; + try { + manifest = JSON.parse(manifestContent); + this.logger.info(`Parsed manifest: ${JSON.stringify(manifest, null, 2)}`); + } catch (error) { + this.logger.error(`Failed to parse manifest.json: ${error}`); + throw new Error(`Invalid manifest.json in ${pluginDir}: ${error}`); + } + + // Validate manifest + this.logger.info( + `Validating manifest for plugin: ${manifest.name || manifest.id}`, + ); + this.validateManifest(manifest, pluginDir); + this.logger.info(`Manifest validation passed`); + + const loadedPlugin: LoadedPlugin = { + manifest, + pluginDir, + }; + + // Load backend Hexa if present + if (manifest.backend) { + this.logger.info( + `Loading backend Hexa: bundle=${manifest.backend.hexaBundle}, export=${manifest.backend.hexaExport}`, + ); + loadedPlugin.hexaClass = await this.loadHexaClass( + pluginDir, + manifest.backend.hexaBundle, + manifest.backend.hexaExport, + ); + this.logger.info( + `Successfully loaded Hexa class: ${manifest.backend.hexaExport}`, + ); + } else { + this.logger.info(`No backend configuration in manifest`); + } + + return loadedPlugin; + } + + /** + * Load Hexa class from a bundle file. + */ + private async loadHexaClass( + pluginDir: string, + bundlePath: string, + exportName: string, + ): Promise< + new (dataSource: DataSource, opts?: Partial) => BaseHexa + > { + this.logger.info(`Loading Hexa class from bundle`); + this.logger.info(` pluginDir: ${pluginDir}`); + this.logger.info(` bundlePath (relative): ${bundlePath}`); + this.logger.info(` exportName: ${exportName}`); + + const fullBundlePath = resolve(pluginDir, bundlePath); + this.logger.info(` fullBundlePath (resolved): ${fullBundlePath}`); + + // Check if pluginDir exists + const pluginDirExists = await this.directoryExists(pluginDir); + this.logger.info(` pluginDir exists: ${pluginDirExists}`); + + // List dist directory if it exists + const distDir = join(pluginDir, 'dist'); + const distExists = await this.directoryExists(distDir); + this.logger.info(` dist directory exists: ${distExists} (${distDir})`); + if (distExists) { + try { + const distContents = await readdir(distDir); + this.logger.info( + ` dist directory contents: ${distContents.join(', ')}`, + ); + } catch (err) { + this.logger.warn(` Failed to list dist directory: ${err}`); + } + } + + // Check if bundle file exists + const bundleExists = await this.fileExists(fullBundlePath); + this.logger.info(` Bundle file exists: ${bundleExists}`); + + if (!bundleExists) { + // Try to find what files are in the plugin directory + this.logger.error(`Bundle file not found: ${fullBundlePath}`); + this.logger.info(`Attempting to list plugin directory to debug:`); + try { + const pluginContents = await readdir(pluginDir, { recursive: false }); + this.logger.info( + ` Plugin directory contents: ${pluginContents.join(', ')}`, + ); + } catch (err) { + this.logger.error(` Failed to list plugin directory: ${err}`); + } + throw new Error(`Bundle file not found: ${fullBundlePath}`); + } + + this.logger.info(`Bundle file found, proceeding to load...`); + + // Load the bundle using require (CommonJS) + // Dynamic require is necessary for plugin loading + // This is intentional for runtime plugin loading + this.logger.info(`Requiring bundle from: ${fullBundlePath}`); + + // Use createRequire to create a native Node.js require function + // This bypasses webpack's module system which doesn't support dynamic requires + // of external files. createRequire creates a require function that uses Node's + // native module resolution, which works with absolute paths and .cjs files. + // + // For workspace packages in a monorepo, we need to patch module resolution + // to resolve @packmind/* packages from dist/packages (built) instead of + // node_modules (which may point to source via symlinks). + const mainAppContext = resolve(process.cwd(), 'package.json'); + const nativeRequire = createRequire(mainAppContext); + + // Patch Module._resolveFilename to resolve @packmind packages from dist + const Module = require('module'); + const originalResolveFilename = Module._resolveFilename; + const fs = require('fs'); + const logger = this.logger; // Capture logger for use in patched function + + Module._resolveFilename = function ( + request: string, + parent: NodeModule, + isMain: boolean, + options?: { paths?: string[] }, + ) { + // For @packmind packages, try to resolve from dist/packages first + if (request.startsWith('@packmind/')) { + const packageName = request.replace('@packmind/', ''); + const distPath = resolve(process.cwd(), 'dist/packages', packageName); + const distIndex = join(distPath, 'src/index.js'); + + if (fs.existsSync(distIndex)) { + logger.info(`Resolving ${request} from dist: ${distIndex}`); + // Resolve the dist path as if it were a file + return originalResolveFilename.call( + Module, + distIndex, + parent, + isMain, + options, + ); + } + } + + // Fall back to normal resolution + return originalResolveFilename.call( + Module, + request, + parent, + isMain, + options, + ); + }; + + let bundle; + try { + this.logger.info( + `Using createRequire to load bundle (bypassing webpack module system)...`, + ); + bundle = nativeRequire(fullBundlePath); + this.logger.info(`Bundle loaded successfully using native require`); + this.logger.info(`Bundle exports: ${Object.keys(bundle).join(', ')}`); + } catch (requireError) { + this.logger.error(`Failed to require bundle: ${requireError}`); + + // Final check: verify file actually exists and is readable + try { + const stats = fs.statSync(fullBundlePath); + this.logger.error( + `File stats: ${JSON.stringify({ + exists: true, + size: stats.size, + isFile: stats.isFile(), + mode: stats.mode.toString(8), + })}`, + ); + } catch (statError) { + this.logger.error(`File stat failed: ${statError}`); + } + + throw new Error( + `Failed to load bundle from ${fullBundlePath}: ${requireError}`, + ); + } finally { + // Restore original resolve function + Module._resolveFilename = originalResolveFilename; + } + + // Extract the Hexa class from the bundle + this.logger.info(`Looking for export: ${exportName}`); + const HexaClass = + bundle[exportName] || bundle.default?.[exportName] || bundle.default; + + if (!HexaClass) { + this.logger.error( + `Hexa class "${exportName}" not found in bundle. Available exports: ${Object.keys(bundle).join(', ')}`, + ); + throw new Error( + `Hexa class "${exportName}" not found in bundle. Available exports: ${Object.keys(bundle).join(', ')}`, + ); + } + + this.logger.info(`Found Hexa class: ${HexaClass.name || 'unnamed'}`); + + // Validate that it's a class that extends BaseHexa + this.logger.info(`Validating Hexa class is a BaseHexa subclass...`); + if (typeof HexaClass !== 'function') { + this.logger.error(`Export "${exportName}" is not a class`); + throw new Error(`Export "${exportName}" is not a class`); + } + + // Check if it extends BaseHexa (basic check) + // We can't do instanceof check here, but we can check the prototype chain + const prototype = HexaClass.prototype; + if (!prototype || typeof prototype.initialize !== 'function') { + this.logger.error( + `Class "${exportName}" does not appear to extend BaseHexa (missing initialize method)`, + ); + throw new Error( + `Class "${exportName}" does not appear to extend BaseHexa (missing initialize method)`, + ); + } + + this.logger.info(`Hexa class validation passed`); + + return HexaClass as new ( + dataSource: DataSource, + opts?: Partial, + ) => BaseHexa; + } + + /** + * Validate plugin manifest structure. + */ + private validateManifest(manifest: PluginManifest, pluginDir: string): void { + if (!manifest.name) { + throw new Error(`Manifest missing "name" field in ${pluginDir}`); + } + if (!manifest.id) { + throw new Error(`Manifest missing "id" field in ${pluginDir}`); + } + if (!manifest.version) { + throw new Error(`Manifest missing "version" field in ${pluginDir}`); + } + + if (manifest.backend) { + if (!manifest.backend.hexaBundle) { + throw new Error( + `Manifest backend missing "hexaBundle" field in ${pluginDir}`, + ); + } + if (!manifest.backend.hexaExport) { + throw new Error( + `Manifest backend missing "hexaExport" field in ${pluginDir}`, + ); + } + } + } + + /** + * Resolve plugin directory path. + * Checks PACKMIND_PLUGINS_DIR env var, then defaults to plugins/ or dist/plugins/. + */ + private resolvePluginDirectory(pluginDir?: string): string { + if (pluginDir) { + return resolve(pluginDir); + } + + // Check environment variable + const envDir = process.env['PACKMIND_PLUGINS_DIR']; + if (envDir) { + return resolve(envDir); + } + + // Default: plugins/ in development, dist/plugins/ in production + const isDevelopment = process.env['NODE_ENV'] !== 'production'; + const defaultDir = isDevelopment ? 'plugins' : 'dist/plugins'; + return resolve(process.cwd(), defaultDir); + } + + /** + * Check if a directory exists. + */ + private async directoryExists(path: string): Promise { + try { + const stats = await stat(path); + return stats.isDirectory(); + } catch { + return false; + } + } + + /** + * Check if a file exists. + */ + private async fileExists(path: string): Promise { + try { + const stats = await stat(path); + return stats.isFile(); + } catch { + return false; + } + } +} diff --git a/packages/node-utils/src/index.ts b/packages/node-utils/src/index.ts index 8fc7a39e2..a9d1f60d0 100644 --- a/packages/node-utils/src/index.ts +++ b/packages/node-utils/src/index.ts @@ -6,6 +6,7 @@ export * from './ai/errors/AiNotConfigured'; export * from './cache/Cache'; export * from './hexa/HexaRegistry'; export * from './hexa/BaseHexa'; +export * from './hexa/HexaPluginLoader'; export * from './database/migrationColumns'; export * from './database/schemas'; export * from './database/types'; diff --git a/scripts/setup-plugin-dev.js b/scripts/setup-plugin-dev.js new file mode 100755 index 000000000..2321a41c5 --- /dev/null +++ b/scripts/setup-plugin-dev.js @@ -0,0 +1,176 @@ +#!/usr/bin/env node + +/** + * Script to set up a plugin repository for development with Packmind. + * + * This script: + * 1. Copies the plugin repository to plugins/ directory (instead of symlinking) + * 2. Sets up a watch process to copy changes from plugin repo to plugins/ directory + * + * Usage: + * npm run setup-plugin-dev /path/to/plugin/repository + * + * Environment variables: + * PACKMIND_PLUGINS_DIR - Override default plugin directory (default: plugins/) + */ + +const fs = require('fs'); +const path = require('path'); +const { execSync, spawn } = require('child_process'); + +const PLUGIN_ROOT = path.resolve(__dirname, '..'); +const PLUGINS_DIR = process.env.PACKMIND_PLUGINS_DIR + ? path.resolve(process.env.PACKMIND_PLUGINS_DIR) + : path.join(PLUGIN_ROOT, 'plugins'); + +function log(message) { + console.log(`[setup-plugin-dev] ${message}`); +} + +function error(message) { + console.error(`[setup-plugin-dev] ERROR: ${message}`); + process.exit(1); +} + +function getPluginName(pluginRepoPath) { + // Try to read from manifest.json first + const manifestPath = path.join(pluginRepoPath, 'manifest.json'); + if (fs.existsSync(manifestPath)) { + try { + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + if (manifest.id) return manifest.id; + if (manifest.name) return manifest.name; + } catch { + // Fall through to package.json + } + } + + // Fall back to package.json + const packageJsonPath = path.join(pluginRepoPath, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + try { + const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); + return pkg.name || path.basename(pluginRepoPath); + } catch { + // Fall through to directory name + } + } + + // Last resort: use directory name + return path.basename(pluginRepoPath); +} + +function copyRecursiveSync(src, dest) { + const exists = fs.existsSync(src); + const stats = exists && fs.statSync(src); + const isDirectory = exists && stats.isDirectory(); + + if (isDirectory) { + // Skip node_modules, .git, and other common directories + // BUT keep 'dist' as it contains the built bundles we need + const basename = path.basename(src); + if (['node_modules', '.git', '.nx', 'tmp', 'coverage'].includes(basename)) { + return; + } + + if (!fs.existsSync(dest)) { + fs.mkdirSync(dest, { recursive: true }); + } + fs.readdirSync(src).forEach((childItemName) => { + copyRecursiveSync( + path.join(src, childItemName), + path.join(dest, childItemName), + ); + }); + } else { + // Skip certain files + const basename = path.basename(src); + if (basename.startsWith('.') && basename !== '.gitignore') { + return; + } + fs.copyFileSync(src, dest); + } +} + +function copyPlugin(pluginRepoPath, pluginName) { + const targetPath = path.join(PLUGINS_DIR, pluginName); + + log(`Copying plugin from ${pluginRepoPath} to ${targetPath}...`); + + // Remove existing copy + if (fs.existsSync(targetPath)) { + fs.rmSync(targetPath, { recursive: true, force: true }); + } + + // Create plugins directory if it doesn't exist + if (!fs.existsSync(PLUGINS_DIR)) { + fs.mkdirSync(PLUGINS_DIR, { recursive: true }); + } + + // Copy plugin files + copyRecursiveSync(pluginRepoPath, targetPath); + + log(`✓ Plugin copied to ${targetPath}`); +} + +function startWatch(pluginRepoPath, pluginName) { + log(''); + log('Starting file watcher...'); + log('Press Ctrl+C to stop watching'); + + // Use chokidar-cli or similar for cross-platform file watching + // For now, we'll use a simple approach with fs.watch or recommend a tool + log(''); + log('To watch for changes, you can:'); + log('1. Run this script again after making changes'); + log('2. Use a file watcher like chokidar-cli:'); + log( + ` npx chokidar "${pluginRepoPath}/**/*" -c "npm run setup-plugin-dev ${pluginRepoPath}" --ignore "${pluginRepoPath}/node_modules/**" --ignore "${pluginRepoPath}/.git/**"`, + ); + log(''); + log('Or manually run this script again after building your plugin.'); +} + +function main() { + const pluginRepoPath = process.argv[2]; + + if (!pluginRepoPath) { + error('Please provide the path to the plugin repository'); + error('Usage: npm run setup-plugin-dev /path/to/plugin/repository'); + process.exit(1); + } + + const resolvedPluginPath = path.resolve(pluginRepoPath); + + if (!fs.existsSync(resolvedPluginPath)) { + error(`Plugin repository not found: ${resolvedPluginPath}`); + } + + if (!fs.statSync(resolvedPluginPath).isDirectory()) { + error(`Path is not a directory: ${resolvedPluginPath}`); + } + + log('Setting up plugin for development...'); + log(`Plugin repository: ${resolvedPluginPath}`); + log(`Plugins directory: ${PLUGINS_DIR}`); + + const pluginName = getPluginName(resolvedPluginPath); + log(`Plugin name: ${pluginName}`); + + copyPlugin(resolvedPluginPath, pluginName); + + log(''); + log('✓ Plugin setup complete!'); + log(''); + log('Next steps:'); + log('1. Build your plugin: cd && npm run build'); + log('2. The plugin will be loaded automatically from plugins/'); + log('3. Re-run this script after making changes to copy updates'); + + // Optionally start watch mode + if (process.argv.includes('--watch')) { + startWatch(resolvedPluginPath, pluginName); + } +} + +main(); From 98b9b25bb92d906dfc9b10824e4d4b30734bc031 Mon Sep 17 00:00:00 2001 From: Malo Date: Mon, 10 Nov 2025 22:38:07 +0100 Subject: [PATCH 2/5] =?UTF-8?q?=F0=9F=92=A9=20Register=20Hexa=20&=20nestjs?= =?UTF-8?q?=20module=20through=20plugin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/app/app.module.ts | 12 +- .../api/src/app/shared/PluginModulesModule.ts | 111 +++++ apps/api/src/main.ts | 14 +- .../node-utils/src/hexa/HexaPluginLoader.ts | 408 ++++++++++++++---- 4 files changed, 467 insertions(+), 78 deletions(-) create mode 100644 apps/api/src/app/shared/PluginModulesModule.ts diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 3afb46cf1..4ad427d09 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { DynamicModule, Module } from '@nestjs/common'; import { APP_GUARD, Reflector, RouterModule } from '@nestjs/core'; import { JwtModule } from '@nestjs/jwt'; import { TypeOrmModule } from '@nestjs/typeorm'; @@ -43,6 +43,15 @@ import { TargetsModule } from './targets/targets.module'; const logger = new PackmindLogger('AppModule', LogLevel.INFO); +// Get plugin module imports from globalThis (set in main.ts after plugins are loaded) +// This is evaluated when the module is imported, but globalThis should be set by then +// if loadPluginModules() is called before NestFactory.create() +const pluginModuleImports = (() => { + const pluginModule = (globalThis as { __pluginModule?: DynamicModule }) + .__pluginModule; + return pluginModule ? [pluginModule] : []; +})(); + @Module({ imports: [ TypeOrmModule.forRoot({ @@ -108,6 +117,7 @@ const logger = new PackmindLogger('AppModule', LogLevel.INFO); SSEModule, AmplitudeModule, LinterModule, + ...pluginModuleImports, // RouterModule configuration for organization-scoped routes // This must come after OrganizationsModule and its child modules are imported RouterModule.register([ diff --git a/apps/api/src/app/shared/PluginModulesModule.ts b/apps/api/src/app/shared/PluginModulesModule.ts new file mode 100644 index 000000000..cfb33e259 --- /dev/null +++ b/apps/api/src/app/shared/PluginModulesModule.ts @@ -0,0 +1,111 @@ +import { DynamicModule, Module, Type } from '@nestjs/common'; +import { PackmindLogger, LogLevel } from '@packmind/logger'; +import { HexaPluginLoader } from '@packmind/node-utils'; + +const logger = new PackmindLogger('PluginModulesModule', LogLevel.INFO); + +// Global store for loaded plugin modules and their controllers (set before app bootstrap) +let pluginModules: Array<{ + module: Type; + controllers?: Type[]; +}> = []; + +/** + * Load plugin modules before app bootstrap. + * This must be called in main.ts before NestFactory.create() + */ +export async function loadPluginModules(): Promise { + const pluginLoader = new HexaPluginLoader(logger); + const plugins = await pluginLoader.loadFromDirectory(); + + // Filter plugins that have NestJS modules + const pluginsWithModules = plugins.filter( + (plugin) => plugin.nestjsModule !== undefined, + ); + + logger.info( + `Found ${pluginsWithModules.length} plugin(s) with NestJS modules`, + ); + + pluginModules = []; + + for (const plugin of pluginsWithModules) { + if (plugin.nestjsModule) { + // Get controllers from the module (stored during loading) + const moduleWithExtracted = plugin.nestjsModule as Type & { + __extractedControllers?: Type[]; + }; + const extractedControllers = + moduleWithExtracted.__extractedControllers || []; + + pluginModules.push({ + module: plugin.nestjsModule, + controllers: + extractedControllers.length > 0 ? extractedControllers : undefined, + }); + } + } + + // Return the DynamicModule that can be used in AppModule + return PluginModulesModule.forRoot(); +} + +/** + * NestJS Module for dynamically loading and registering plugin NestJS modules. + * + * This module: + * - Imports all plugin NestJS modules that were loaded before app bootstrap + * - Makes them available to the NestJS dependency injection system + * + * Usage: + * ```typescript + * // In main.ts, before NestFactory.create(): + * await loadPluginModules(); + * + * // In app.module.ts: + * @Module({ + * imports: [ + * PluginModulesModule.forRoot(), + * // ... other modules + * ] + * }) + * export class AppModule {} + * ``` + */ +@Module({}) +export class PluginModulesModule { + /** + * Create a dynamic module that imports all plugin NestJS modules. + */ + static forRoot(): DynamicModule { + // Collect all controllers from plugins for manual registration + const allControllers: Type[] = []; + const modulesToImport: Type[] = []; + + for (const pluginModule of pluginModules) { + const { module, controllers } = pluginModule; + + modulesToImport.push(module); + + // Use controllers from pluginModule, or fall back to module.__extractedControllers + const moduleWithExtracted = module as Type & { + __extractedControllers?: Type[]; + }; + const controllersToUse = + controllers || moduleWithExtracted.__extractedControllers || []; + + if (controllersToUse && controllersToUse.length > 0) { + allControllers.push(...controllersToUse); + } + } + + // Create a DynamicModule that imports the plugin modules + // and explicitly registers controllers at the PluginModulesModule level + return { + module: PluginModulesModule, + imports: modulesToImport, + controllers: allControllers.length > 0 ? allControllers : undefined, // Register controllers at this module level + exports: modulesToImport, + }; + } +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 67f4e1937..b28d942e5 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -9,10 +9,11 @@ import './instrument'; import { Logger, VersioningType } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import cookieParser from 'cookie-parser'; -import { AppModule } from './app/app.module'; +// Don't import AppModule here - import it after plugins are loaded import { PackmindLogger, LogLevel } from '@packmind/logger'; import { Configuration, Cache } from '@packmind/node-utils'; import { enableAmplitudeProxy } from '@packmind/amplitude'; +import { loadPluginModules } from './app/shared/PluginModulesModule'; const logger = new PackmindLogger('PackmindAPI', LogLevel.INFO); @@ -82,6 +83,17 @@ async function bootstrap() { timestamp: new Date().toISOString(), }); + // Load plugin modules before creating the NestJS app + logger.info('Loading plugin modules...'); + const pluginModule = await loadPluginModules(); + logger.info('Plugin modules loaded'); + + // Store the plugin module globally so AppModule can access it + // This is a workaround for the timing issue where AppModule is imported before plugins are loaded + (globalThis as { __pluginModule?: unknown }).__pluginModule = pluginModule; + + // Import AppModule AFTER plugins are loaded so it can access globalThis.__pluginModule + const { AppModule } = await import('./app/app.module'); const app = await NestFactory.create(AppModule); // Enable cookie parsing diff --git a/packages/node-utils/src/hexa/HexaPluginLoader.ts b/packages/node-utils/src/hexa/HexaPluginLoader.ts index dbe0c2f86..3f989014d 100644 --- a/packages/node-utils/src/hexa/HexaPluginLoader.ts +++ b/packages/node-utils/src/hexa/HexaPluginLoader.ts @@ -2,6 +2,7 @@ import { readdir, readFile, stat } from 'fs/promises'; import { join, resolve } from 'path'; import { createRequire } from 'module'; +import { Type } from '@nestjs/common'; import { BaseHexa, BaseHexaOpts } from './BaseHexa'; import { PackmindLogger } from '@packmind/logger'; import { DataSource } from 'typeorm'; @@ -17,9 +18,10 @@ export interface PluginManifest { id: string; description?: string; backend?: { - hexaBundle: string; - hexaExport: string; + hexaBundle?: string; + hexaExport?: string; opts?: Partial; + nestjsModule?: string; // Export name of NestJS module class in the bundle }; frontend?: { bundle: string; @@ -44,6 +46,8 @@ export interface LoadedPlugin { dataSource: DataSource, opts?: Partial, ) => BaseHexa; + nestjsModule?: Type; // NestJS module class + nestjsControllers?: Type[]; // Controllers extracted from the module (for manual registration) } /** @@ -120,29 +124,18 @@ export class HexaPluginLoader { * Load a single plugin from a directory. */ private async loadPlugin(pluginDir: string): Promise { - this.logger.info(`Loading plugin from directory: ${pluginDir}`); const manifestPath = join(pluginDir, 'manifest.json'); - this.logger.info(`Looking for manifest at: ${manifestPath}`); if (!(await this.fileExists(manifestPath))) { this.logger.warn(`No manifest.json found in ${pluginDir}`); - this.logger.info(`Listing directory contents of ${pluginDir}:`); - try { - const dirContents = await readdir(pluginDir); - this.logger.info(`Directory contents: ${dirContents.join(', ')}`); - } catch (err) { - this.logger.error(`Failed to list directory: ${err}`); - } return null; } - this.logger.info(`Found manifest.json at: ${manifestPath}`); // Read and parse manifest const manifestContent = await readFile(manifestPath, 'utf-8'); let manifest: PluginManifest; try { manifest = JSON.parse(manifestContent); - this.logger.info(`Parsed manifest: ${JSON.stringify(manifest, null, 2)}`); } catch (error) { this.logger.error(`Failed to parse manifest.json: ${error}`); throw new Error(`Invalid manifest.json in ${pluginDir}: ${error}`); @@ -161,7 +154,7 @@ export class HexaPluginLoader { }; // Load backend Hexa if present - if (manifest.backend) { + if (manifest.backend?.hexaBundle && manifest.backend?.hexaExport) { this.logger.info( `Loading backend Hexa: bundle=${manifest.backend.hexaBundle}, export=${manifest.backend.hexaExport}`, ); @@ -173,8 +166,28 @@ export class HexaPluginLoader { this.logger.info( `Successfully loaded Hexa class: ${manifest.backend.hexaExport}`, ); - } else { - this.logger.info(`No backend configuration in manifest`); + } + + // Load NestJS module if present + if (manifest.backend?.nestjsModule) { + this.logger.info( + `Loading NestJS module: bundle=${manifest.backend.hexaBundle || 'hexaBundle'}, export=${manifest.backend.nestjsModule}`, + ); + // Use the same bundle as hexaBundle, or require hexaBundle to be specified + const bundlePath = manifest.backend.hexaBundle; + if (!bundlePath) { + throw new Error( + `nestjsModule specified but hexaBundle is required in ${pluginDir}`, + ); + } + loadedPlugin.nestjsModule = await this.loadNestJSModule( + pluginDir, + bundlePath, + manifest.backend.nestjsModule, + ); + this.logger.info( + `Successfully loaded NestJS module: ${manifest.backend.nestjsModule}`, + ); } return loadedPlugin; @@ -190,58 +203,15 @@ export class HexaPluginLoader { ): Promise< new (dataSource: DataSource, opts?: Partial) => BaseHexa > { - this.logger.info(`Loading Hexa class from bundle`); - this.logger.info(` pluginDir: ${pluginDir}`); - this.logger.info(` bundlePath (relative): ${bundlePath}`); - this.logger.info(` exportName: ${exportName}`); - const fullBundlePath = resolve(pluginDir, bundlePath); - this.logger.info(` fullBundlePath (resolved): ${fullBundlePath}`); - - // Check if pluginDir exists - const pluginDirExists = await this.directoryExists(pluginDir); - this.logger.info(` pluginDir exists: ${pluginDirExists}`); - // List dist directory if it exists - const distDir = join(pluginDir, 'dist'); - const distExists = await this.directoryExists(distDir); - this.logger.info(` dist directory exists: ${distExists} (${distDir})`); - if (distExists) { - try { - const distContents = await readdir(distDir); - this.logger.info( - ` dist directory contents: ${distContents.join(', ')}`, - ); - } catch (err) { - this.logger.warn(` Failed to list dist directory: ${err}`); - } - } - - // Check if bundle file exists - const bundleExists = await this.fileExists(fullBundlePath); - this.logger.info(` Bundle file exists: ${bundleExists}`); - - if (!bundleExists) { - // Try to find what files are in the plugin directory - this.logger.error(`Bundle file not found: ${fullBundlePath}`); - this.logger.info(`Attempting to list plugin directory to debug:`); - try { - const pluginContents = await readdir(pluginDir, { recursive: false }); - this.logger.info( - ` Plugin directory contents: ${pluginContents.join(', ')}`, - ); - } catch (err) { - this.logger.error(` Failed to list plugin directory: ${err}`); - } + if (!(await this.fileExists(fullBundlePath))) { throw new Error(`Bundle file not found: ${fullBundlePath}`); } - this.logger.info(`Bundle file found, proceeding to load...`); - // Load the bundle using require (CommonJS) // Dynamic require is necessary for plugin loading // This is intentional for runtime plugin loading - this.logger.info(`Requiring bundle from: ${fullBundlePath}`); // Use createRequire to create a native Node.js require function // This bypasses webpack's module system which doesn't support dynamic requires @@ -258,7 +228,6 @@ export class HexaPluginLoader { const Module = require('module'); const originalResolveFilename = Module._resolveFilename; const fs = require('fs'); - const logger = this.logger; // Capture logger for use in patched function Module._resolveFilename = function ( request: string, @@ -273,8 +242,6 @@ export class HexaPluginLoader { const distIndex = join(distPath, 'src/index.js'); if (fs.existsSync(distIndex)) { - logger.info(`Resolving ${request} from dist: ${distIndex}`); - // Resolve the dist path as if it were a file return originalResolveFilename.call( Module, distIndex, @@ -297,12 +264,7 @@ export class HexaPluginLoader { let bundle; try { - this.logger.info( - `Using createRequire to load bundle (bypassing webpack module system)...`, - ); bundle = nativeRequire(fullBundlePath); - this.logger.info(`Bundle loaded successfully using native require`); - this.logger.info(`Bundle exports: ${Object.keys(bundle).join(', ')}`); } catch (requireError) { this.logger.error(`Failed to require bundle: ${requireError}`); @@ -330,7 +292,6 @@ export class HexaPluginLoader { } // Extract the Hexa class from the bundle - this.logger.info(`Looking for export: ${exportName}`); const HexaClass = bundle[exportName] || bundle.default?.[exportName] || bundle.default; @@ -343,10 +304,7 @@ export class HexaPluginLoader { ); } - this.logger.info(`Found Hexa class: ${HexaClass.name || 'unnamed'}`); - // Validate that it's a class that extends BaseHexa - this.logger.info(`Validating Hexa class is a BaseHexa subclass...`); if (typeof HexaClass !== 'function') { this.logger.error(`Export "${exportName}" is not a class`); throw new Error(`Export "${exportName}" is not a class`); @@ -372,6 +330,302 @@ export class HexaPluginLoader { ) => BaseHexa; } + /** + * Load NestJS module class from a bundle file. + */ + private async loadNestJSModule( + pluginDir: string, + bundlePath: string, + exportName: string, + ): Promise> { + const fullBundlePath = resolve(pluginDir, bundlePath); + + if (!(await this.fileExists(fullBundlePath))) { + throw new Error(`Bundle file not found: ${fullBundlePath}`); + } + + // Ensure reflect-metadata is available before loading the bundle + // This is needed for NestJS decorators to work properly + try { + require('reflect-metadata'); + } catch { + // reflect-metadata might already be loaded, that's fine + } + + // Use the same require mechanism as Hexa loading + const mainAppContext = resolve(process.cwd(), 'package.json'); + const nativeRequire = createRequire(mainAppContext); + + // Patch Module._resolveFilename to resolve @packmind packages from dist + const Module = require('module'); + const originalResolveFilename = Module._resolveFilename; + const fs = require('fs'); + + Module._resolveFilename = function ( + request: string, + parent: NodeModule, + isMain: boolean, + options?: { paths?: string[] }, + ) { + if (request.startsWith('@packmind/')) { + const packageName = request.replace('@packmind/', ''); + const distPath = resolve(process.cwd(), 'dist/packages', packageName); + const distIndex = join(distPath, 'src/index.js'); + + if (fs.existsSync(distIndex)) { + return originalResolveFilename.call( + Module, + distIndex, + parent, + isMain, + options, + ); + } + } + + return originalResolveFilename.call( + Module, + request, + parent, + isMain, + options, + ); + }; + + let bundle; + try { + // Ensure reflect-metadata is available in the global scope before loading + // This is critical for decorator metadata to be stored correctly + if (typeof globalThis.Reflect === 'undefined') { + require('reflect-metadata'); + } + bundle = nativeRequire(fullBundlePath); + } catch (requireError) { + this.logger.error(`Failed to require bundle: ${requireError}`); + throw new Error( + `Failed to load bundle from ${fullBundlePath}: ${requireError}`, + ); + } finally { + Module._resolveFilename = originalResolveFilename; + } + + // Extract the NestJS module class from the bundle + let NestJSModule = + bundle[exportName] || bundle.default?.[exportName] || bundle.default; + + // Handle getter functions + if (typeof NestJSModule === 'function' && NestJSModule.length === 0) { + try { + NestJSModule = NestJSModule(); + } catch { + // Not a getter, use as-is + } + } + + if (!NestJSModule) { + this.logger.error( + `NestJS module "${exportName}" not found in bundle. Available exports: ${Object.keys(bundle).join(', ')}`, + ); + throw new Error( + `NestJS module "${exportName}" not found in bundle. Available exports: ${Object.keys(bundle).join(', ')}`, + ); + } + + // Basic validation: check if it looks like a NestJS module (has decorators/metadata) + if (typeof NestJSModule !== 'function') { + this.logger.error(`Export "${exportName}" is not a class`); + throw new Error(`Export "${exportName}" is not a class`); + } + + // Extract controllers from bundle for manual registration + const extractedControllers: Type[] = []; + for (const [key, value] of Object.entries(bundle)) { + if (key === exportName) continue; + + let controllerClass = value; + if (typeof value === 'function' && value.length === 0) { + try { + controllerClass = value(); + } catch { + // Not a getter + } + } + + if (typeof controllerClass === 'function' && key.includes('Controller')) { + const Reflect = globalThis.Reflect || require('reflect-metadata'); + const path = Reflect.getMetadata('path', controllerClass); + if (path !== undefined || key.includes('Controller')) { + extractedControllers.push(controllerClass as Type); + } + } + } + + // Try to manually reconstruct metadata if it's not accessible + // This is needed because when esbuild bundles the code, decorator metadata + // might not be accessible when the module is loaded dynamically + try { + const Reflect = globalThis.Reflect || require('reflect-metadata'); + const existingMetadata = Reflect.getMetadata( + 'module:metadata', + NestJSModule, + ); + + if (!existingMetadata) { + this.logger.info( + `Module metadata not accessible, attempting to reconstruct...`, + ); + + // Try to find controllers in the bundle exports + // Look for classes that might be controllers (they should be exported) + // Note: Bundle exports might be getter functions, so we need to call them + const possibleControllers: Type[] = []; + for (const [key, value] of Object.entries(bundle)) { + // Skip the module itself + if (key === exportName) { + continue; + } + + // Handle getter functions (e.g., SamplePluginController: () => SamplePluginController) + let actualValue = value; + if (typeof value === 'function') { + // Try calling it as a getter function first + try { + const called = value(); + if (called && typeof called === 'function') { + actualValue = called; + } + } catch { + // Not a getter, use as-is (it's the class itself) + actualValue = value; + } + } + + // Skip non-class exports + if ( + typeof actualValue !== 'function' || + actualValue === null || + actualValue === undefined + ) { + continue; + } + + // Check if it has controller metadata or if the key suggests it's a controller + const controllerPath = Reflect.getMetadata('path', actualValue); + const isController = + controllerPath !== undefined || key.includes('Controller'); + + if (isController) { + possibleControllers.push(actualValue as Type); + this.logger.info( + `Found possible controller: ${key} (${actualValue.name || 'unnamed'}), path: ${controllerPath || 'unknown'}`, + ); + } + } + + // If we found controllers, manually set the metadata + if (possibleControllers.length > 0) { + // Filter out any null/undefined controllers + const validControllers = possibleControllers.filter( + (c) => c !== null && c !== undefined && typeof c === 'function', + ); + + if (validControllers.length > 0) { + // Store controllers directly on the module as a property for debugging + // This helps ensure the reference persists + ( + NestJSModule as Type & { + __pluginControllers?: Type[]; + } + ).__pluginControllers = validControllers; + + const moduleMetadata = { + controllers: validControllers, + providers: [], + exports: [], + imports: [], + }; + + // Use defineMetadata to set the metadata + // This is the key that NestJS uses to discover controllers + Reflect.defineMetadata( + 'module:metadata', + moduleMetadata, + NestJSModule, + ); + + // Also try setting it with the NestJS-specific key + // NestJS might use a different metadata key internally + Reflect.defineMetadata( + 'controllers', + validControllers, + NestJSModule, + ); + + this.logger.info( + `Manually set module metadata with ${validControllers.length} controller(s)`, + ); + // Log controller details for debugging + for (const controller of validControllers) { + const path = Reflect.getMetadata('path', controller); + this.logger.info( + ` Controller: ${controller.name}, path: ${path || 'root'}, type: ${typeof controller}`, + ); + // Verify controller has its own metadata + const controllerMethods = Reflect.getMetadata( + 'routes', + controller, + ); + this.logger.info( + ` Controller methods: ${controllerMethods ? controllerMethods.length : 0}`, + ); + } + + // Verify metadata was set correctly + const verifyMetadata = Reflect.getMetadata( + 'module:metadata', + NestJSModule, + ); + if (verifyMetadata?.controllers) { + const allValid = verifyMetadata.controllers.every( + (c: unknown) => + c !== null && c !== undefined && typeof c === 'function', + ); + if (!allValid) { + this.logger.error( + `Metadata verification failed: some controllers are invalid`, + ); + } else { + this.logger.info( + `Metadata verification passed: all controllers are valid`, + ); + } + } + } else { + this.logger.warn( + `No valid controllers found after filtering (${possibleControllers.length} total, all invalid)`, + ); + } + } + } else { + this.logger.info(`Module metadata is accessible`); + } + } catch (err) { + this.logger.warn(`Failed to reconstruct module metadata: ${err}`); + // Continue anyway - NestJS might still be able to process the module + } + + // Store extracted controllers on the module for later use + if (extractedControllers.length > 0) { + ( + NestJSModule as Type & { + __extractedControllers?: Type[]; + } + ).__extractedControllers = extractedControllers; + } + + return NestJSModule as Type; + } + /** * Validate plugin manifest structure. */ @@ -387,14 +641,16 @@ export class HexaPluginLoader { } if (manifest.backend) { - if (!manifest.backend.hexaBundle) { + // If hexaExport is specified, hexaBundle is required + if (manifest.backend.hexaExport && !manifest.backend.hexaBundle) { throw new Error( - `Manifest backend missing "hexaBundle" field in ${pluginDir}`, + `Manifest backend has "hexaExport" but missing "hexaBundle" field in ${pluginDir}`, ); } - if (!manifest.backend.hexaExport) { + // If nestjsModule is specified, hexaBundle is required (they share the same bundle) + if (manifest.backend.nestjsModule && !manifest.backend.hexaBundle) { throw new Error( - `Manifest backend missing "hexaExport" field in ${pluginDir}`, + `Manifest backend has "nestjsModule" but missing "hexaBundle" field in ${pluginDir}`, ); } } From ba82d9d5722f5cb7c5ba9e387c5724041d24a3f1 Mon Sep 17 00:00:00 2001 From: Malo Date: Wed, 12 Nov 2025 08:42:34 +0100 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=92=A9=20plugin=20add=20route=20in=20?= =?UTF-8?q?frontend=20(at=20build=20time=20because=20router=20is=20built?= =?UTF-8?q?=20at=20this=20point=20--=20would=20need=20to=20switch=20file?= =?UTF-8?q?=20based=20router=20to=20object=20based=20router=20to=20enable?= =?UTF-8?q?=20updating=20routing=20at=20runtime)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/frontend/.gitignore | 2 + apps/frontend/app/routes.tsx | 8 + apps/frontend/vite-plugin-plugin-routes.ts | 210 ++++++++++++++++++ apps/frontend/vite.config.ts | 2 + .../node-utils/src/hexa/HexaPluginLoader.ts | 103 +++++++-- 5 files changed, 307 insertions(+), 18 deletions(-) create mode 100644 apps/frontend/vite-plugin-plugin-routes.ts diff --git a/apps/frontend/.gitignore b/apps/frontend/.gitignore index a70276cde..a6f9bef9e 100644 --- a/apps/frontend/.gitignore +++ b/apps/frontend/.gitignore @@ -3,3 +3,5 @@ build public/build .env .react-router +# Auto-generated plugin route files - these are generated by vite-plugin-plugin-routes.ts +app/routes/org.$orgSlug.plugin-feature.tsx diff --git a/apps/frontend/app/routes.tsx b/apps/frontend/app/routes.tsx index b3fe2c3bc..a850adddc 100644 --- a/apps/frontend/app/routes.tsx +++ b/apps/frontend/app/routes.tsx @@ -1,4 +1,12 @@ import { type RouteConfig } from '@react-router/dev/routes'; import { flatRoutes } from '@react-router/fs-routes'; +/** + * flatRoutes() automatically scans app/routes/ directory and includes: + * - File-based routes (manually created route files in app/routes/) + * - Plugin routes (auto-generated with plugin- prefix by vite-plugin-plugin-routes.ts) + * + * Plugin routes are generated with a plugin- prefix (e.g., plugin-org.$orgSlug.feature.tsx) + * and are gitignored, keeping auto-generated files out of the source tree. + */ export default flatRoutes() satisfies RouteConfig; diff --git a/apps/frontend/vite-plugin-plugin-routes.ts b/apps/frontend/vite-plugin-plugin-routes.ts new file mode 100644 index 000000000..bf4aebd1f --- /dev/null +++ b/apps/frontend/vite-plugin-plugin-routes.ts @@ -0,0 +1,210 @@ +/* eslint-disable @nx/enforce-module-boundaries */ +// This file runs at build time in Node.js, so it can import from node-utils +import type { Plugin } from 'vite'; +import { + readdirSync, + readFileSync, + writeFileSync, + mkdirSync, + existsSync, + statSync, + unlinkSync, +} from 'fs'; +import { join, resolve, dirname, relative } from 'path'; +import type { PluginManifest } from '@packmind/node-utils'; + +/** + * Vite plugin to generate plugin route files. + * + * Plugin routes are generated in app/routes/ with a plugin- prefix (e.g., plugin-org.$orgSlug.feature.tsx) + * to keep them clearly identifiable as auto-generated. These files are gitignored. + * flatRoutes() automatically discovers all route files in app/routes/, including plugin routes. + */ +export function pluginRoutes(): Plugin { + // Generate plugin routes directly in app/routes/ with a plugin- prefix + // This ensures flatRoutes() discovers them, and the prefix makes them clearly auto-generated + const ROUTES_DIR = resolve(__dirname, 'app/routes'); + let pluginDir: string; + + function resolvePluginDirectory(): string { + const envDir = process.env['PACKMIND_PLUGINS_DIR']; + if (envDir) { + return resolve(envDir); + } + // Resolve from monorepo root (two levels up from apps/frontend) + const monorepoRoot = resolve(__dirname, '../..'); + // Check plugins/ first (development), then dist/plugins/ (production) + const pluginsDir = resolve(monorepoRoot, 'plugins'); + const distPluginsDir = resolve(monorepoRoot, 'dist/plugins'); + + if (existsSync(pluginsDir) && statSync(pluginsDir).isDirectory()) { + return pluginsDir; + } + if (existsSync(distPluginsDir) && statSync(distPluginsDir).isDirectory()) { + return distPluginsDir; + } + // Default to plugins/ even if it doesn't exist yet + return pluginsDir; + } + + function routePathToFilePath(routePath: string): string { + // Convert route path like "/org/:orgSlug/my-feature" to file path like "org.$orgSlug.my-feature.tsx" + const pathParts = routePath + .replace(/^\/+/, '') // Remove leading slashes + .split('/') + .map((part) => { + if (part.startsWith(':')) { + // Convert :param to $param (React Router v7 convention) + return `$${part.slice(1)}`; + } + if (part === '*') { + return 'splat'; + } + return part; + }); + return pathParts.join('.') + '.tsx'; + } + + function generatePluginRoutes() { + // Clean existing auto-generated plugin route files + if (existsSync(ROUTES_DIR)) { + const existingFiles = readdirSync(ROUTES_DIR, { + recursive: true, + withFileTypes: true, + }); + for (const file of existingFiles) { + if ( + file.isFile() && + (file.name.endsWith('.tsx') || file.name.endsWith('.ts')) + ) { + const filePath = join(file.parentPath || ROUTES_DIR, file.name); + try { + const content = readFileSync(filePath, 'utf-8'); + // Only delete auto-generated plugin route files (identified by the comment) + if (content.includes('Auto-generated route file for plugin:')) { + unlinkSync(filePath); + } + } catch { + // Ignore errors + } + } + } + } + + pluginDir = resolvePluginDirectory(); + + if (!existsSync(pluginDir) || !statSync(pluginDir).isDirectory()) { + return; + } + + const entries = readdirSync(pluginDir, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + + const pluginPath = join(pluginDir, entry.name); + const manifestPath = join(pluginPath, 'manifest.json'); + + if (!existsSync(manifestPath) || !statSync(manifestPath).isFile()) { + continue; + } + + let manifest: PluginManifest; + try { + const manifestContent = readFileSync(manifestPath, 'utf-8'); + manifest = JSON.parse(manifestContent); + } catch (error) { + console.error(`Failed to parse manifest.json in ${pluginPath}:`, error); + continue; + } + + if (!manifest.frontend || !manifest.frontend.routes) { + continue; + } + + const bundlePath = join(pluginPath, manifest.frontend.bundle); + if (!existsSync(bundlePath) || !statSync(bundlePath).isFile()) { + console.warn( + `Frontend bundle not found for plugin ${manifest.name}: ${bundlePath}`, + ); + continue; + } + + // Generate route file for each route + for (const routeConfig of manifest.frontend.routes) { + const fileName = routePathToFilePath(routeConfig.path); + // Use the correct filename (no prefix) so the route path matches the manifest + // The file will be gitignored via the pattern in .gitignore + const routeFilePath = join(ROUTES_DIR, fileName); + + // Ensure parent directory exists + const routeFileDir = dirname(routeFilePath); + if (!existsSync(routeFileDir)) { + mkdirSync(routeFileDir, { recursive: true }); + } + + // Calculate relative path from route file to bundle + const relativeBundlePath = relative(routeFileDir, bundlePath); + + const routeFileContent = `/** + * Auto-generated route file for plugin: ${manifest.id} + * Route path: ${routeConfig.path} + * + * DO NOT EDIT - This file is generated by vite-plugin-plugin-routes.ts + * It is located in app/routes/ with a plugin- prefix and automatically discovered by flatRoutes(). + */ + +/* eslint-disable @nx/enforce-module-boundaries */ +// This file imports from external plugin bundles, which is intentional + +import { lazy } from 'react'; +import type { LoaderFunctionArgs } from 'react-router'; + +// Dynamically import component from plugin bundle +const ${routeConfig.component} = lazy(() => + // @ts-expect-error - Plugin bundle is external and doesn't have type declarations + import(/* @vite-ignore */ '${relativeBundlePath}').then(module => ({ + default: module.${routeConfig.component} + })) +); + +${ + routeConfig.loader + ? `// Loader function that imports from plugin bundle +export async function clientLoader(args: LoaderFunctionArgs) { + // @ts-expect-error - Plugin bundle is external and doesn't have type declarations + const module = await import(/* @vite-ignore */ '${relativeBundlePath}'); + return module.${routeConfig.loader}(args); +} +` + : '' +} + +export default ${routeConfig.component}; +`; + + writeFileSync(routeFilePath, routeFileContent); + } + } + } + + return { + name: 'plugin-routes', + enforce: 'pre', // Run before other plugins (especially reactRouter) + buildStart() { + // Generate plugin route files at build start + generatePluginRoutes(); + }, + configureServer(server) { + // Also generate in dev mode when server starts + generatePluginRoutes(); + // Watch plugin directory for changes (if it exists) + pluginDir = resolvePluginDirectory(); + if (pluginDir && existsSync(pluginDir)) { + server.watcher.add(pluginDir); + } + }, + }; +} diff --git a/apps/frontend/vite.config.ts b/apps/frontend/vite.config.ts index a9fd96a71..a0addb726 100644 --- a/apps/frontend/vite.config.ts +++ b/apps/frontend/vite.config.ts @@ -4,6 +4,7 @@ import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin'; import Checker from 'vite-plugin-checker'; import path from 'path'; +import { pluginRoutes } from './vite-plugin-plugin-routes'; export default defineConfig(() => { // Determine edition mode @@ -64,6 +65,7 @@ export default defineConfig(() => { host: 'localhost', }, plugins: [ + pluginRoutes(), // Load plugin routes at build time and write to routes.plugins.ts reactRouter(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md']), diff --git a/packages/node-utils/src/hexa/HexaPluginLoader.ts b/packages/node-utils/src/hexa/HexaPluginLoader.ts index 3f989014d..ed0c0a6b4 100644 --- a/packages/node-utils/src/hexa/HexaPluginLoader.ts +++ b/packages/node-utils/src/hexa/HexaPluginLoader.ts @@ -17,12 +17,26 @@ export interface PluginManifest { version: string; id: string; description?: string; + // Legacy backend section (for backward compatibility) backend?: { hexaBundle?: string; hexaExport?: string; opts?: Partial; nestjsModule?: string; // Export name of NestJS module class in the bundle }; + // New structure: separate sections for each target + hexa?: { + bundle: string; + export: string; + opts?: Partial; + }; + api?: { + bundle: string; + nestjsModule: string; // Export name of NestJS module class in the bundle + }; + mcp?: { + bundle: string; + }; frontend?: { bundle: string; routes?: Array<{ @@ -153,40 +167,55 @@ export class HexaPluginLoader { pluginDir, }; - // Load backend Hexa if present - if (manifest.backend?.hexaBundle && manifest.backend?.hexaExport) { + // Load Hexa if present (new structure or legacy backend) + const hexaConfig = + manifest.hexa || + (manifest.backend?.hexaBundle && manifest.backend?.hexaExport + ? { + bundle: manifest.backend.hexaBundle, + export: manifest.backend.hexaExport, + opts: manifest.backend.opts, + } + : undefined); + + if (hexaConfig) { this.logger.info( - `Loading backend Hexa: bundle=${manifest.backend.hexaBundle}, export=${manifest.backend.hexaExport}`, + `Loading Hexa: bundle=${hexaConfig.bundle}, export=${hexaConfig.export}`, ); loadedPlugin.hexaClass = await this.loadHexaClass( pluginDir, - manifest.backend.hexaBundle, - manifest.backend.hexaExport, - ); - this.logger.info( - `Successfully loaded Hexa class: ${manifest.backend.hexaExport}`, + hexaConfig.bundle, + hexaConfig.export, ); + this.logger.info(`Successfully loaded Hexa class: ${hexaConfig.export}`); } - // Load NestJS module if present - if (manifest.backend?.nestjsModule) { + // Load NestJS module if present (new api section or legacy backend) + const apiConfig = + manifest.api || + (manifest.backend?.nestjsModule + ? { + bundle: manifest.backend.hexaBundle!, + nestjsModule: manifest.backend.nestjsModule, + } + : undefined); + + if (apiConfig) { this.logger.info( - `Loading NestJS module: bundle=${manifest.backend.hexaBundle || 'hexaBundle'}, export=${manifest.backend.nestjsModule}`, + `Loading NestJS module: bundle=${apiConfig.bundle}, export=${apiConfig.nestjsModule}`, ); - // Use the same bundle as hexaBundle, or require hexaBundle to be specified - const bundlePath = manifest.backend.hexaBundle; - if (!bundlePath) { + if (!apiConfig.bundle) { throw new Error( - `nestjsModule specified but hexaBundle is required in ${pluginDir}`, + `api.nestjsModule specified but api.bundle is required in ${pluginDir}`, ); } loadedPlugin.nestjsModule = await this.loadNestJSModule( pluginDir, - bundlePath, - manifest.backend.nestjsModule, + apiConfig.bundle, + apiConfig.nestjsModule, ); this.logger.info( - `Successfully loaded NestJS module: ${manifest.backend.nestjsModule}`, + `Successfully loaded NestJS module: ${apiConfig.nestjsModule}`, ); } @@ -640,6 +669,7 @@ export class HexaPluginLoader { throw new Error(`Manifest missing "version" field in ${pluginDir}`); } + // Validate legacy backend structure if (manifest.backend) { // If hexaExport is specified, hexaBundle is required if (manifest.backend.hexaExport && !manifest.backend.hexaBundle) { @@ -654,6 +684,43 @@ export class HexaPluginLoader { ); } } + + // Validate new hexa structure + if (manifest.hexa) { + if (!manifest.hexa.bundle) { + throw new Error( + `Manifest hexa section missing "bundle" field in ${pluginDir}`, + ); + } + if (!manifest.hexa.export) { + throw new Error( + `Manifest hexa section missing "export" field in ${pluginDir}`, + ); + } + } + + // Validate new api structure + if (manifest.api) { + if (!manifest.api.bundle) { + throw new Error( + `Manifest api section missing "bundle" field in ${pluginDir}`, + ); + } + if (!manifest.api.nestjsModule) { + throw new Error( + `Manifest api section missing "nestjsModule" field in ${pluginDir}`, + ); + } + } + + // Validate frontend structure + if (manifest.frontend) { + if (!manifest.frontend.bundle) { + throw new Error( + `Manifest frontend section missing "bundle" field in ${pluginDir}`, + ); + } + } } /** From 264945330c427eeae929872cda5c81267ef7ec5b Mon Sep 17 00:00:00 2001 From: Malo Date: Wed, 12 Nov 2025 20:36:08 +0100 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=92=A9=20better=20frontend=20plugin?= =?UTF-8?q?=20handling=20for=20routes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/frontend/.gitignore | 4 +- apps/frontend/app/routes.tsx | 20 +- apps/frontend/src/shared/utils/mergeRoutes.ts | 164 ++++++++++++++ apps/frontend/vite-plugin-plugin-routes.ts | 210 ------------------ apps/frontend/vite.config.ts | 2 - scripts/setup-plugin-dev.js | 91 +++++++- 6 files changed, 268 insertions(+), 223 deletions(-) create mode 100644 apps/frontend/src/shared/utils/mergeRoutes.ts delete mode 100644 apps/frontend/vite-plugin-plugin-routes.ts diff --git a/apps/frontend/.gitignore b/apps/frontend/.gitignore index a6f9bef9e..3d1dfd610 100644 --- a/apps/frontend/.gitignore +++ b/apps/frontend/.gitignore @@ -3,5 +3,5 @@ build public/build .env .react-router -# Auto-generated plugin route files - these are generated by vite-plugin-plugin-routes.ts -app/routes/org.$orgSlug.plugin-feature.tsx +# Auto-generated plugin route files - these are generated by loadPluginRoutes() +app/routes/.plugins/ diff --git a/apps/frontend/app/routes.tsx b/apps/frontend/app/routes.tsx index a850adddc..29201e066 100644 --- a/apps/frontend/app/routes.tsx +++ b/apps/frontend/app/routes.tsx @@ -1,12 +1,20 @@ import { type RouteConfig } from '@react-router/dev/routes'; import { flatRoutes } from '@react-router/fs-routes'; +import { loadPluginRoutes } from '../src/plugins/loadPluginRoutes'; +import { mergeRoutes } from '../src/shared/utils/mergeRoutes'; /** - * flatRoutes() automatically scans app/routes/ directory and includes: - * - File-based routes (manually created route files in app/routes/) - * - Plugin routes (auto-generated with plugin- prefix by vite-plugin-plugin-routes.ts) + * Combines file-based routes with plugin routes. * - * Plugin routes are generated with a plugin- prefix (e.g., plugin-org.$orgSlug.feature.tsx) - * and are gitignored, keeping auto-generated files out of the source tree. + * - File-based routes: Automatically discovered from app/routes/ via flatRoutes() + * - Plugin routes: Loaded from plugin bundles via loadPluginRoutes() + * + * Plugin routes are merged with file-based routes using mergeRoutes(), which + * ensures plugin routes are properly nested under their parent layout routes + * (e.g., the protected layout for /org/:orgSlug/... routes). */ -export default flatRoutes() satisfies RouteConfig; +export default (async function routes(): Promise { + const fileBasedRoutes = await flatRoutes(); + const pluginRoutes = await loadPluginRoutes(); + return mergeRoutes(fileBasedRoutes, pluginRoutes); +})(); diff --git a/apps/frontend/src/shared/utils/mergeRoutes.ts b/apps/frontend/src/shared/utils/mergeRoutes.ts new file mode 100644 index 000000000..56ec43755 --- /dev/null +++ b/apps/frontend/src/shared/utils/mergeRoutes.ts @@ -0,0 +1,164 @@ +import type { RouteConfig } from '@react-router/dev/routes'; +import { route } from '@react-router/dev/routes'; + +/** + * RouteConfig is actually an array type, but TypeScript sees it as Promise. + * After awaiting flatRoutes(), we get the actual array. + */ +type RouteConfigArray = Awaited; + +/** + * Merges file-based routes with plugin routes, ensuring plugin routes are + * properly nested under their parent layout routes (e.g., protected layout). + * + * This function: + * 1. Finds parent routes (layout routes) in file-based routes + * 2. Explicitly nests plugin routes as children of their parent routes + * 3. Returns the merged route tree with proper nesting + * + * @param fileBasedRoutes - Routes discovered from file system via flatRoutes() (already awaited) + * @param pluginRoutes - Routes loaded from plugin bundles (already awaited) + * @returns Merged RouteConfig with plugin routes properly nested + */ +export function mergeRoutes( + fileBasedRoutes: RouteConfigArray, + pluginRoutes: RouteConfigArray, +): RouteConfigArray { + // If no plugin routes, return file-based routes as-is + if (pluginRoutes.length === 0) { + return fileBasedRoutes; + } + + // Helper to extract path from a route + const getRoutePath = (route: RouteConfigArray[number]): string | null => { + if (typeof route === 'object' && route !== null) { + return 'path' in route ? (route.path as string) : null; + } + return null; + }; + + // Helper to extract file from a route + const getRouteFile = (route: RouteConfigArray[number]): string | null => { + if (typeof route === 'object' && route !== null) { + return 'file' in route ? (route.file as string) : null; + } + return null; + }; + + // Helper to check if a path is a child of another path + const isChildOf = (childPath: string, parentPath: string): boolean => { + // Normalize paths + const normalizedChild = childPath.replace(/^\/+/, '').replace(/\/+$/, ''); + const normalizedParent = parentPath.replace(/^\/+/, '').replace(/\/+$/, ''); + + // Check if child path starts with parent path + if (!normalizedChild.startsWith(normalizedParent)) { + return false; + } + + // Ensure it's a direct child (not just a prefix match) + const remaining = normalizedChild.slice(normalizedParent.length); + return remaining !== '' && remaining.startsWith('/'); + }; + + // Helper to find parent route for a plugin route + const findParentRoute = ( + pluginRoute: RouteConfigArray[number], + fileBasedRoutes: RouteConfigArray, + ): { parent: RouteConfigArray[number]; index: number } | null => { + const pluginPath = getRoutePath(pluginRoute); + if (!pluginPath) return null; + + // Look for the best matching parent route (most specific) + let bestMatch: { + parent: RouteConfigArray[number]; + index: number; + specificity: number; + } | null = null; + + for (let i = 0; i < fileBasedRoutes.length; i++) { + const route = fileBasedRoutes[i]; + const routePath = getRoutePath(route); + + if (!routePath) continue; + + // Check if this route is a parent of the plugin route + if (isChildOf(pluginPath, routePath)) { + // Calculate specificity (longer path = more specific parent) + const specificity = routePath.split('/').length; + + if (!bestMatch || specificity > bestMatch.specificity) { + bestMatch = { parent: route, index: i, specificity }; + } + } + } + + return bestMatch + ? { parent: bestMatch.parent, index: bestMatch.index } + : null; + }; + + // Create a map to track which routes have been modified (to avoid duplicates) + const modifiedRoutes = new Set(); + const mergedRoutes: RouteConfigArray = [...fileBasedRoutes]; + const pluginRoutesByParent = new Map(); + + // Group plugin routes by their parent + for (const pluginRoute of pluginRoutes) { + const parentMatch = findParentRoute(pluginRoute, mergedRoutes); + + if (parentMatch) { + // Group by parent index + if (!pluginRoutesByParent.has(parentMatch.index)) { + pluginRoutesByParent.set(parentMatch.index, []); + } + const parentRoutes = pluginRoutesByParent.get(parentMatch.index); + if (parentRoutes) { + parentRoutes.push(pluginRoute); + } + modifiedRoutes.add(parentMatch.index); + } + } + + // Modify parent routes to include plugin routes as children + // Process in reverse order to maintain indices + const sortedIndices = Array.from(modifiedRoutes).sort((a, b) => b - a); + + for (const parentIndex of sortedIndices) { + const parent = mergedRoutes[parentIndex]; + const children = pluginRoutesByParent.get(parentIndex); + if (!children) continue; + + if (typeof parent === 'object' && parent !== null) { + const parentPath = getRoutePath(parent); + const parentFile = getRouteFile(parent); + + if (parentPath && parentFile) { + // Get existing children if any + const existingChildren = + 'children' in parent && Array.isArray(parent.children) + ? (parent.children as RouteConfigArray) + : []; + + // Create new route with children using route() helper + const parentWithChildren = route(parentPath, parentFile, [ + ...existingChildren, + ...children, + ]); + + // Replace the parent route + mergedRoutes[parentIndex] = parentWithChildren; + } + } + } + + // Add plugin routes that don't have a parent as top-level routes + for (const pluginRoute of pluginRoutes) { + const parentMatch = findParentRoute(pluginRoute, mergedRoutes); + if (!parentMatch) { + mergedRoutes.push(pluginRoute); + } + } + + return mergedRoutes; +} diff --git a/apps/frontend/vite-plugin-plugin-routes.ts b/apps/frontend/vite-plugin-plugin-routes.ts deleted file mode 100644 index bf4aebd1f..000000000 --- a/apps/frontend/vite-plugin-plugin-routes.ts +++ /dev/null @@ -1,210 +0,0 @@ -/* eslint-disable @nx/enforce-module-boundaries */ -// This file runs at build time in Node.js, so it can import from node-utils -import type { Plugin } from 'vite'; -import { - readdirSync, - readFileSync, - writeFileSync, - mkdirSync, - existsSync, - statSync, - unlinkSync, -} from 'fs'; -import { join, resolve, dirname, relative } from 'path'; -import type { PluginManifest } from '@packmind/node-utils'; - -/** - * Vite plugin to generate plugin route files. - * - * Plugin routes are generated in app/routes/ with a plugin- prefix (e.g., plugin-org.$orgSlug.feature.tsx) - * to keep them clearly identifiable as auto-generated. These files are gitignored. - * flatRoutes() automatically discovers all route files in app/routes/, including plugin routes. - */ -export function pluginRoutes(): Plugin { - // Generate plugin routes directly in app/routes/ with a plugin- prefix - // This ensures flatRoutes() discovers them, and the prefix makes them clearly auto-generated - const ROUTES_DIR = resolve(__dirname, 'app/routes'); - let pluginDir: string; - - function resolvePluginDirectory(): string { - const envDir = process.env['PACKMIND_PLUGINS_DIR']; - if (envDir) { - return resolve(envDir); - } - // Resolve from monorepo root (two levels up from apps/frontend) - const monorepoRoot = resolve(__dirname, '../..'); - // Check plugins/ first (development), then dist/plugins/ (production) - const pluginsDir = resolve(monorepoRoot, 'plugins'); - const distPluginsDir = resolve(monorepoRoot, 'dist/plugins'); - - if (existsSync(pluginsDir) && statSync(pluginsDir).isDirectory()) { - return pluginsDir; - } - if (existsSync(distPluginsDir) && statSync(distPluginsDir).isDirectory()) { - return distPluginsDir; - } - // Default to plugins/ even if it doesn't exist yet - return pluginsDir; - } - - function routePathToFilePath(routePath: string): string { - // Convert route path like "/org/:orgSlug/my-feature" to file path like "org.$orgSlug.my-feature.tsx" - const pathParts = routePath - .replace(/^\/+/, '') // Remove leading slashes - .split('/') - .map((part) => { - if (part.startsWith(':')) { - // Convert :param to $param (React Router v7 convention) - return `$${part.slice(1)}`; - } - if (part === '*') { - return 'splat'; - } - return part; - }); - return pathParts.join('.') + '.tsx'; - } - - function generatePluginRoutes() { - // Clean existing auto-generated plugin route files - if (existsSync(ROUTES_DIR)) { - const existingFiles = readdirSync(ROUTES_DIR, { - recursive: true, - withFileTypes: true, - }); - for (const file of existingFiles) { - if ( - file.isFile() && - (file.name.endsWith('.tsx') || file.name.endsWith('.ts')) - ) { - const filePath = join(file.parentPath || ROUTES_DIR, file.name); - try { - const content = readFileSync(filePath, 'utf-8'); - // Only delete auto-generated plugin route files (identified by the comment) - if (content.includes('Auto-generated route file for plugin:')) { - unlinkSync(filePath); - } - } catch { - // Ignore errors - } - } - } - } - - pluginDir = resolvePluginDirectory(); - - if (!existsSync(pluginDir) || !statSync(pluginDir).isDirectory()) { - return; - } - - const entries = readdirSync(pluginDir, { withFileTypes: true }); - - for (const entry of entries) { - if (!entry.isDirectory()) { - continue; - } - - const pluginPath = join(pluginDir, entry.name); - const manifestPath = join(pluginPath, 'manifest.json'); - - if (!existsSync(manifestPath) || !statSync(manifestPath).isFile()) { - continue; - } - - let manifest: PluginManifest; - try { - const manifestContent = readFileSync(manifestPath, 'utf-8'); - manifest = JSON.parse(manifestContent); - } catch (error) { - console.error(`Failed to parse manifest.json in ${pluginPath}:`, error); - continue; - } - - if (!manifest.frontend || !manifest.frontend.routes) { - continue; - } - - const bundlePath = join(pluginPath, manifest.frontend.bundle); - if (!existsSync(bundlePath) || !statSync(bundlePath).isFile()) { - console.warn( - `Frontend bundle not found for plugin ${manifest.name}: ${bundlePath}`, - ); - continue; - } - - // Generate route file for each route - for (const routeConfig of manifest.frontend.routes) { - const fileName = routePathToFilePath(routeConfig.path); - // Use the correct filename (no prefix) so the route path matches the manifest - // The file will be gitignored via the pattern in .gitignore - const routeFilePath = join(ROUTES_DIR, fileName); - - // Ensure parent directory exists - const routeFileDir = dirname(routeFilePath); - if (!existsSync(routeFileDir)) { - mkdirSync(routeFileDir, { recursive: true }); - } - - // Calculate relative path from route file to bundle - const relativeBundlePath = relative(routeFileDir, bundlePath); - - const routeFileContent = `/** - * Auto-generated route file for plugin: ${manifest.id} - * Route path: ${routeConfig.path} - * - * DO NOT EDIT - This file is generated by vite-plugin-plugin-routes.ts - * It is located in app/routes/ with a plugin- prefix and automatically discovered by flatRoutes(). - */ - -/* eslint-disable @nx/enforce-module-boundaries */ -// This file imports from external plugin bundles, which is intentional - -import { lazy } from 'react'; -import type { LoaderFunctionArgs } from 'react-router'; - -// Dynamically import component from plugin bundle -const ${routeConfig.component} = lazy(() => - // @ts-expect-error - Plugin bundle is external and doesn't have type declarations - import(/* @vite-ignore */ '${relativeBundlePath}').then(module => ({ - default: module.${routeConfig.component} - })) -); - -${ - routeConfig.loader - ? `// Loader function that imports from plugin bundle -export async function clientLoader(args: LoaderFunctionArgs) { - // @ts-expect-error - Plugin bundle is external and doesn't have type declarations - const module = await import(/* @vite-ignore */ '${relativeBundlePath}'); - return module.${routeConfig.loader}(args); -} -` - : '' -} - -export default ${routeConfig.component}; -`; - - writeFileSync(routeFilePath, routeFileContent); - } - } - } - - return { - name: 'plugin-routes', - enforce: 'pre', // Run before other plugins (especially reactRouter) - buildStart() { - // Generate plugin route files at build start - generatePluginRoutes(); - }, - configureServer(server) { - // Also generate in dev mode when server starts - generatePluginRoutes(); - // Watch plugin directory for changes (if it exists) - pluginDir = resolvePluginDirectory(); - if (pluginDir && existsSync(pluginDir)) { - server.watcher.add(pluginDir); - } - }, - }; -} diff --git a/apps/frontend/vite.config.ts b/apps/frontend/vite.config.ts index a0addb726..a9fd96a71 100644 --- a/apps/frontend/vite.config.ts +++ b/apps/frontend/vite.config.ts @@ -4,7 +4,6 @@ import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin'; import Checker from 'vite-plugin-checker'; import path from 'path'; -import { pluginRoutes } from './vite-plugin-plugin-routes'; export default defineConfig(() => { // Determine edition mode @@ -65,7 +64,6 @@ export default defineConfig(() => { host: 'localhost', }, plugins: [ - pluginRoutes(), // Load plugin routes at build time and write to routes.plugins.ts reactRouter(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md']), diff --git a/scripts/setup-plugin-dev.js b/scripts/setup-plugin-dev.js index 2321a41c5..871db0910 100755 --- a/scripts/setup-plugin-dev.js +++ b/scripts/setup-plugin-dev.js @@ -4,8 +4,9 @@ * Script to set up a plugin repository for development with Packmind. * * This script: - * 1. Copies the plugin repository to plugins/ directory (instead of symlinking) - * 2. Sets up a watch process to copy changes from plugin repo to plugins/ directory + * 1. Ensures core packages are built + * 2. Sets up core dependencies in the plugin repo (runs setup-core-deps) + * 3. Copies the plugin repository to plugins/ directory * * Usage: * npm run setup-plugin-dev /path/to/plugin/repository @@ -92,6 +93,81 @@ function copyRecursiveSync(src, dest) { } } +function checkCorePackages() { + log('Checking core packages...'); + + const corePackages = ['node-utils', 'types', 'logger', 'ui']; + const missingPackages = []; + + for (const pkg of corePackages) { + const distPath = path.join( + PLUGIN_ROOT, + 'dist', + 'packages', + pkg === 'ui' ? 'packmind-ui' : pkg, + ); + if (!fs.existsSync(distPath)) { + missingPackages.push(pkg); + } + } + + if (missingPackages.length > 0) { + log(`Building missing core packages: ${missingPackages.join(', ')}...`); + try { + for (const pkg of missingPackages) { + log(`Building ${pkg}...`); + execSync(`nx build ${pkg}`, { + cwd: PLUGIN_ROOT, + stdio: 'inherit', + }); + } + log('✓ Core packages built'); + } catch (err) { + error(`Failed to build core packages: ${err.message}`); + } + } else { + log('✓ All core packages are built'); + } +} + +function setupCoreDeps(pluginRepoPath) { + log('Setting up core dependencies in plugin repository...'); + + const setupScriptPath = path.join( + pluginRepoPath, + 'scripts', + 'setup-core-deps.js', + ); + + if (!fs.existsSync(setupScriptPath)) { + log( + '⚠ setup-core-deps.js not found in plugin repo, skipping core-deps setup', + ); + log( + ' Make sure to run "npm run setup-core-deps" manually in the plugin repo', + ); + return; + } + + try { + // Set PACKMIND_MAIN_REPO environment variable so the script knows where the main repo is + const env = { + ...process.env, + PACKMIND_MAIN_REPO: PLUGIN_ROOT, + }; + + execSync('node scripts/setup-core-deps.js', { + cwd: pluginRepoPath, + env, + stdio: 'inherit', + }); + + log('✓ Core dependencies set up in plugin repository'); + } catch (err) { + error(`Failed to set up core dependencies: ${err.message}`); + } +} + function copyPlugin(pluginRepoPath, pluginName) { const targetPath = path.join(PLUGINS_DIR, pluginName); @@ -153,10 +229,19 @@ function main() { log('Setting up plugin for development...'); log(`Plugin repository: ${resolvedPluginPath}`); log(`Plugins directory: ${PLUGINS_DIR}`); + log(''); + + // Step 1: Check and build core packages if needed + checkCorePackages(); + log(''); + // Step 2: Set up core dependencies in plugin repo + setupCoreDeps(resolvedPluginPath); + log(''); + + // Step 3: Copy plugin to plugins directory const pluginName = getPluginName(resolvedPluginPath); log(`Plugin name: ${pluginName}`); - copyPlugin(resolvedPluginPath, pluginName); log(''); From 27b9c05b4a0533a21b6167cda8c855b475c2d5d6 Mon Sep 17 00:00:00 2001 From: Malo Date: Wed, 12 Nov 2025 23:26:41 +0100 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=92=A9=20frontend=20plugin=20that=20a?= =?UTF-8?q?dd=20a=20page=20&=20a=20navigation=20item?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/frontend/app/entry.client.tsx | 9 +- .../components/OrganizationHomePage.tsx | 14 +++ .../components/SidebarNavigation.tsx | 15 +++ apps/frontend/vite.config.ts | 97 +++++++++++++++++++ packages/types/src/index.ts | 1 + packages/types/src/plugins.ts | 48 +++++++++ 6 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 packages/types/src/plugins.ts diff --git a/apps/frontend/app/entry.client.tsx b/apps/frontend/app/entry.client.tsx index d7cf4dd04..5bae6aa4f 100644 --- a/apps/frontend/app/entry.client.tsx +++ b/apps/frontend/app/entry.client.tsx @@ -13,6 +13,7 @@ import { AuthProvider } from '../src/providers/AuthProvider'; import { SSEProvider } from '../src/services/sse'; import { ErrorBoundary } from '../src/providers/ErrorBoundary'; import { AnalyticsProvider } from '@packmind/proprietary/frontend/domain/amplitude/providers/AnalyticsProvider'; +import { PluginProvider } from '../src/plugins/PluginProvider'; startTransition(() => { hydrateRoot( @@ -23,9 +24,11 @@ startTransition(() => { - - - + + + + + diff --git a/apps/frontend/src/domain/accounts/components/OrganizationHomePage.tsx b/apps/frontend/src/domain/accounts/components/OrganizationHomePage.tsx index a59d1b3ae..e11d5129f 100644 --- a/apps/frontend/src/domain/accounts/components/OrganizationHomePage.tsx +++ b/apps/frontend/src/domain/accounts/components/OrganizationHomePage.tsx @@ -6,9 +6,13 @@ import { GettingStartedWidget } from '../../organizations/components/dashboard/G import { OrganizationOnboardingChecklist } from '../../organizations/components/dashboard/OrganizationOnboardingChecklist'; import { useGetOnboardingStatusQuery } from '../api/queries/AccountsQueries'; import { useAuthContext } from '../hooks/useAuthContext'; +import { usePlugins } from '../../../plugins/hooks/usePlugins'; +import { Suspense } from 'react'; +import { PMSpinner } from '@packmind/ui'; export const OrganizationHomePage: React.FC = () => { const { organization } = useAuthContext(); + const { dashboardComponents } = usePlugins(); const orgId = organization?.id || ('' as string); const { data: onboardingStatus } = useGetOnboardingStatusQuery(orgId); @@ -27,6 +31,11 @@ export const OrganizationHomePage: React.FC = () => { + {dashboardComponents.map((Component, index) => ( + }> + + + ))} ) : ( @@ -35,6 +44,11 @@ export const OrganizationHomePage: React.FC = () => { + {dashboardComponents.map((Component, index) => ( + }> + + + ))} diff --git a/apps/frontend/src/domain/organizations/components/SidebarNavigation.tsx b/apps/frontend/src/domain/organizations/components/SidebarNavigation.tsx index 5a24c985c..33f9cc67b 100644 --- a/apps/frontend/src/domain/organizations/components/SidebarNavigation.tsx +++ b/apps/frontend/src/domain/organizations/components/SidebarNavigation.tsx @@ -14,6 +14,7 @@ import { SidebarHelpMenu } from './SidebarHelpMenu'; import { LuHouse, LuSettings } from 'react-icons/lu'; import { useGetSpacesQuery } from '../../spaces/api/queries/SpacesQueries'; import { routes } from '../../../shared/utils/routes'; +import { usePlugins } from '../../../plugins/hooks/usePlugins'; interface ISidebarNavigationProps { organization: AuthContextOrganization | undefined; @@ -61,6 +62,7 @@ export const SidebarNavigation: React.FunctionComponent< } const orgSlug = organization.slug; + const { navItems } = usePlugins(); // Don't render space-scoped links if we don't have a space slug yet if (!currentSpaceSlug) { @@ -119,6 +121,19 @@ export const SidebarNavigation: React.FunctionComponent< />, ]} /> + {navItems.length > 0 && ( + ( + + ))} + /> + )} {(() => { const lastEntries: React.ReactElement[] = []; diff --git a/apps/frontend/vite.config.ts b/apps/frontend/vite.config.ts index a9fd96a71..c030a310e 100644 --- a/apps/frontend/vite.config.ts +++ b/apps/frontend/vite.config.ts @@ -52,18 +52,115 @@ export default defineConfig(() => { }, resolve: { alias: resolveAliases, + // Resolve external dependencies from plugin bundles to main app's node_modules + dedupe: ['react', 'react-dom', 'react-router', 'react-router-dom'], + }, + optimizeDeps: { + // Include plugin bundle dependencies in optimization + include: ['react', 'react-dom', 'react-router', 'react-router-dom'], }, server: { port: 4200, host: 'localhost', allowedHosts: ['frontend'], proxy, + fs: { + // Allow serving files from plugins directory and packages (for assets) + allow: ['..', '../../packages'], + }, }, preview: { port: 4200, host: 'localhost', }, plugins: [ + // Plugin to serve plugin bundles from plugins/ directory + // This MUST run BEFORE React Router to intercept /plugins/* requests + { + name: 'serve-plugins', + enforce: 'pre', // Run before other plugins + configureServer(server) { + const fs = require('fs'); + // Add middleware that handles /plugins/* requests + server.middlewares.use('/plugins', (req, res, next) => { + // If the request has query parameters (like ?import), let Vite handle it + // Vite needs to process these to resolve external dependencies + if (req.url?.includes('?')) { + return next(); + } + + // For direct file requests without query params, serve the file + const urlPath = req.url || ''; + const pluginsDir = path.resolve(__dirname, '../../plugins'); + const filePath = path.join(pluginsDir, urlPath); + try { + if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { + const fileContent = fs.readFileSync(filePath); + const ext = path.extname(filePath); + const contentType = + ext === '.mjs' + ? 'application/javascript; charset=utf-8' + : ext === '.json' + ? 'application/json; charset=utf-8' + : 'text/plain; charset=utf-8'; + res.setHeader('Content-Type', contentType); + res.setHeader('Cache-Control', 'no-cache'); + res.end(fileContent); + return; // Don't call next() - we handled the request + } + } catch (err) { + // Log error but continue to next middleware + console.warn( + '[serve-plugins] Error serving file:', + filePath, + err, + ); + } + next(); + }); + }, + // Configure Vite to process plugin bundles and resolve external dependencies + resolveId(id) { + if (id.startsWith('/plugins/')) { + // Resolve plugin bundle paths to actual file system paths + const pluginsDir = path.resolve(__dirname, '../../plugins'); + const relativePath = id.replace('/plugins/', ''); + const filePath = path.join(pluginsDir, relativePath); + try { + const fs = require('fs'); + if (fs.existsSync(filePath)) { + return filePath; + } + } catch { + // Ignore errors + } + } + return null; + }, + // Transform plugin bundles to resolve external dependencies + load(id) { + if (id.includes('/plugins/') && id.endsWith('.mjs')) { + // Read the bundle file + const fs = require('fs'); + try { + const code = fs.readFileSync(id, 'utf-8'); + // Transform bare module specifiers to use Vite's resolution + // This allows external dependencies like 'react-router' to be resolved + const transformedCode = code.replace( + /import\s+([^"']+)\s+from\s+["'](react-router|react|react-dom|@packmind\/ui|@packmind\/types)["']/g, + (match, imports, moduleName) => { + // Keep the import but let Vite resolve it + return match; + }, + ); + return transformedCode; + } catch { + return null; + } + } + return null; + }, + }, reactRouter(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md']), diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 39cbf0a68..47fd2b3f1 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -13,3 +13,4 @@ export * from './linter'; export * from './sse'; export * from './ai/prompts/types'; export * from './database/types'; +export * from './plugins'; diff --git a/packages/types/src/plugins.ts b/packages/types/src/plugins.ts new file mode 100644 index 000000000..069aa6683 --- /dev/null +++ b/packages/types/src/plugins.ts @@ -0,0 +1,48 @@ +/** + * Plugin system types shared between plugins and the main application. + * These types define the contract for how plugins can inject content into the app. + */ + +/** + * Navigation item that can be added to the sidebar by plugins. + */ +export interface PluginNavigationItem { + /** + * Route path (supports :orgSlug substitution, e.g., "/org/:orgSlug/plugin-feature") + */ + path: string; + /** + * Display label for the navigation item + */ + label: string; + /** + * Optional icon - can be a React element or string identifier + * Note: Icons are typically React elements created at runtime + */ + icon?: unknown; + /** + * Whether the route should match exactly + */ + exact?: boolean; +} + +/** + * Content that can be injected into a named outlet. + */ +export interface PluginOutletContent { + /** + * React component for complex content (e.g., dashboard widgets) + * Note: Components are loaded dynamically at runtime + */ + component?: unknown; + /** + * Data for hooks (e.g., navigation items array) + */ + data?: unknown; +} + +/** + * Map of outlet names to their content arrays. + * Plugins export this structure via getPluginOutlets(). + */ +export type PluginOutlets = Record;