@majkapp/plugin-kit 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,695 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.definePlugin = definePlugin;
7
+ const http_1 = __importDefault(require("http"));
8
+ const fs_1 = __importDefault(require("fs"));
9
+ const path_1 = __importDefault(require("path"));
10
+ /**
11
+ * Build-time errors with clear, actionable messages
12
+ */
13
+ class PluginBuildError extends Error {
14
+ constructor(message, suggestion, context) {
15
+ super(message);
16
+ this.suggestion = suggestion;
17
+ this.context = context;
18
+ this.name = 'PluginBuildError';
19
+ }
20
+ toString() {
21
+ let msg = `\n❌ Plugin Build Failed: ${this.message}`;
22
+ if (this.suggestion) {
23
+ msg += `\n💡 Suggestion: ${this.suggestion}`;
24
+ }
25
+ if (this.context) {
26
+ msg += `\n📋 Context: ${JSON.stringify(this.context, null, 2)}`;
27
+ }
28
+ return msg;
29
+ }
30
+ }
31
+ /**
32
+ * Runtime errors with clear context
33
+ */
34
+ class PluginRuntimeError extends Error {
35
+ constructor(message, operation, context) {
36
+ super(message);
37
+ this.operation = operation;
38
+ this.context = context;
39
+ this.name = 'PluginRuntimeError';
40
+ }
41
+ }
42
+ /**
43
+ * Validation helper for descriptions
44
+ */
45
+ function validateDescription(desc, name) {
46
+ if (!desc)
47
+ return;
48
+ const trimmed = desc.trim();
49
+ if (!trimmed.endsWith('.')) {
50
+ throw new PluginBuildError(`Description for "${name}" must end with a period`, `Add a period at the end: "${desc}."`);
51
+ }
52
+ const sentences = trimmed.split(/[.!?]/).map(s => s.trim()).filter(Boolean);
53
+ if (sentences.length < 2 || sentences.length > 3) {
54
+ throw new PluginBuildError(`Description for "${name}" must be 2-3 sentences, found ${sentences.length} sentences`, `Rewrite the description to have 2-3 clear sentences. Current: "${desc}"`, { sentences });
55
+ }
56
+ }
57
+ /**
58
+ * Path parameter regex builder
59
+ */
60
+ function pathToRegex(p) {
61
+ const keys = [];
62
+ const pattern = p
63
+ .replace(/([.+*?=^!:${}()[\]|/\\])/g, '\\$1')
64
+ .replace(/\/:([A-Za-z0-9_]+)/g, (_m, k) => {
65
+ keys.push(k);
66
+ return '/([^/]+)';
67
+ });
68
+ return { regex: new RegExp(`^${pattern}$`), keys };
69
+ }
70
+ /**
71
+ * CORS headers
72
+ */
73
+ function corsHeaders(extra = {}) {
74
+ return {
75
+ 'Access-Control-Allow-Origin': '*',
76
+ 'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
77
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
78
+ ...extra
79
+ };
80
+ }
81
+ /**
82
+ * Read request body
83
+ */
84
+ async function readBody(req) {
85
+ if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') {
86
+ return undefined;
87
+ }
88
+ return new Promise((resolve) => {
89
+ let data = '';
90
+ req.on('data', (chunk) => (data += chunk.toString()));
91
+ req.on('end', () => {
92
+ if (!data) {
93
+ resolve(undefined);
94
+ return;
95
+ }
96
+ try {
97
+ resolve(JSON.parse(data));
98
+ }
99
+ catch {
100
+ resolve(data);
101
+ }
102
+ });
103
+ });
104
+ }
105
+ /**
106
+ * Create response wrapper
107
+ */
108
+ function makeResponse(res) {
109
+ return {
110
+ status(code) {
111
+ res.statusCode = code;
112
+ return this;
113
+ },
114
+ setHeader(key, value) {
115
+ res.setHeader(key, value);
116
+ },
117
+ json(data) {
118
+ if (!res.headersSent) {
119
+ res.writeHead(res.statusCode || 200, corsHeaders({ 'Content-Type': 'application/json' }));
120
+ }
121
+ res.end(JSON.stringify(data));
122
+ },
123
+ send(data) {
124
+ if (!res.headersSent) {
125
+ res.writeHead(res.statusCode || 200, corsHeaders());
126
+ }
127
+ res.end(data);
128
+ }
129
+ };
130
+ }
131
+ /**
132
+ * Content type helper
133
+ */
134
+ function getContentType(filePath) {
135
+ const ext = path_1.default.extname(filePath).toLowerCase();
136
+ const types = {
137
+ '.html': 'text/html',
138
+ '.js': 'application/javascript',
139
+ '.css': 'text/css',
140
+ '.json': 'application/json',
141
+ '.png': 'image/png',
142
+ '.jpg': 'image/jpeg',
143
+ '.jpeg': 'image/jpeg',
144
+ '.svg': 'image/svg+xml',
145
+ '.woff': 'font/woff',
146
+ '.woff2': 'font/woff2',
147
+ '.ttf': 'font/ttf'
148
+ };
149
+ return types[ext] || 'application/octet-stream';
150
+ }
151
+ /**
152
+ * Serve React SPA
153
+ */
154
+ function serveSpa(ui, req, res, ctx) {
155
+ const url = new URL(req.url, 'http://localhost');
156
+ const rel = url.pathname.substring(ui.base.length) || '/';
157
+ const distPath = path_1.default.join(process.cwd(), ui.appDir);
158
+ let filePath = path_1.default.join(distPath, rel);
159
+ if (filePath.endsWith('/')) {
160
+ filePath = path_1.default.join(filePath, 'index.html');
161
+ }
162
+ // Security: prevent path traversal
163
+ const normalizedPath = path_1.default.resolve(filePath);
164
+ if (!normalizedPath.startsWith(path_1.default.resolve(distPath))) {
165
+ res.writeHead(403, corsHeaders({ 'Content-Type': 'text/plain' }));
166
+ res.end('Forbidden: Path traversal detected');
167
+ ctx.logger.warn(`Path traversal attempt blocked: ${rel}`);
168
+ return;
169
+ }
170
+ const exists = fs_1.default.existsSync(filePath) && fs_1.default.statSync(filePath).isFile();
171
+ const targetFile = exists ? filePath : path_1.default.join(distPath, 'index.html');
172
+ if (!fs_1.default.existsSync(targetFile)) {
173
+ res.writeHead(404, corsHeaders({ 'Content-Type': 'text/plain' }));
174
+ res.end('Not found');
175
+ return;
176
+ }
177
+ let content = fs_1.default.readFileSync(targetFile);
178
+ // Inject base URL for React apps
179
+ if (path_1.default.basename(targetFile) === 'index.html') {
180
+ const html = content.toString('utf-8');
181
+ const inject = `<script>` +
182
+ `window.__MAJK_BASE_URL__=${JSON.stringify(ctx.http.baseUrl)};` +
183
+ `window.__MAJK_IFRAME_BASE__=${JSON.stringify(ui.base)};` +
184
+ `window.__MAJK_PLUGIN_ID__=${JSON.stringify(ctx.pluginId)};` +
185
+ `</script>`;
186
+ const injected = html.replace('</head>', `${inject}</head>`);
187
+ content = Buffer.from(injected, 'utf-8');
188
+ }
189
+ res.writeHead(200, corsHeaders({ 'Content-Type': getContentType(targetFile) }));
190
+ res.end(content);
191
+ }
192
+ /**
193
+ * Main plugin class built by the fluent API
194
+ */
195
+ class BuiltPlugin {
196
+ constructor(id, name, version, capabilities, tools, apiRoutes, uiConfig, reactScreens, htmlScreens, wizard, onReadyFn, healthCheckFn) {
197
+ this.capabilities = capabilities;
198
+ this.tools = tools;
199
+ this.apiRoutes = apiRoutes;
200
+ this.uiConfig = uiConfig;
201
+ this.reactScreens = reactScreens;
202
+ this.htmlScreens = htmlScreens;
203
+ this.wizard = wizard;
204
+ this.onReadyFn = onReadyFn;
205
+ this.healthCheckFn = healthCheckFn;
206
+ this.cleanups = [];
207
+ this.router = [];
208
+ this.id = id;
209
+ this.name = name;
210
+ this.version = version;
211
+ }
212
+ getCapabilities() {
213
+ return {
214
+ apiVersion: 'majk.capabilities/v1',
215
+ capabilities: this.capabilities
216
+ };
217
+ }
218
+ /**
219
+ * Config wizard shouldShow hook - called by ConfigWizardCapabilityHandler
220
+ */
221
+ async shouldShowConfigWizard() {
222
+ if (this.wizard?.shouldShow) {
223
+ return await this.wizard.shouldShow(this.context);
224
+ }
225
+ return false;
226
+ }
227
+ async createTool(toolName) {
228
+ const handler = this.tools.get(toolName);
229
+ if (!handler) {
230
+ throw new PluginRuntimeError(`Tool "${toolName}" not found`, 'createTool', { toolName, availableTools: Array.from(this.tools.keys()) });
231
+ }
232
+ return {
233
+ handler: (input) => {
234
+ const toolContext = {
235
+ majk: this.context.majk,
236
+ storage: this.context.storage,
237
+ logger: this.context.logger
238
+ };
239
+ return Promise.resolve(handler(input, toolContext));
240
+ }
241
+ };
242
+ }
243
+ async onLoad(context) {
244
+ this.context = context;
245
+ context.logger.info('═══════════════════════════════════════════════════════');
246
+ context.logger.info(`🚀 Loading Plugin: ${this.name} (${this.id}) v${this.version}`);
247
+ context.logger.info(`📍 Plugin Root: ${context.pluginRoot}`);
248
+ context.logger.info(`🌐 HTTP Port: ${context.http.port}`);
249
+ context.logger.info(`🔗 Base URL: ${context.http.baseUrl}`);
250
+ context.logger.info('═══════════════════════════════════════════════════════');
251
+ try {
252
+ await this.startServer();
253
+ // Register API routes
254
+ for (const route of this.apiRoutes) {
255
+ const { regex, keys } = pathToRegex(route.path);
256
+ this.router.push({
257
+ method: route.method,
258
+ regex,
259
+ keys,
260
+ name: route.name,
261
+ description: route.description,
262
+ handler: route.handler
263
+ });
264
+ context.logger.info(`📝 Registered route: ${route.method} ${route.path} - ${route.name}`);
265
+ }
266
+ // Call onReady hook
267
+ if (this.onReadyFn) {
268
+ context.logger.info('⚙️ Calling onReady hook...');
269
+ await this.onReadyFn(this.context, (fn) => this.cleanups.push(fn));
270
+ context.logger.info('✅ onReady hook completed');
271
+ }
272
+ context.logger.info(`✅ Plugin "${this.name}" loaded successfully`);
273
+ }
274
+ catch (error) {
275
+ context.logger.error(`❌ Failed to load plugin "${this.name}": ${error.message}`);
276
+ if (error.stack) {
277
+ context.logger.error(error.stack);
278
+ }
279
+ throw error;
280
+ }
281
+ }
282
+ async onUnload() {
283
+ this.context.logger.info(`🛑 Unloading plugin: ${this.name}`);
284
+ // Run cleanup functions
285
+ for (const cleanup of this.cleanups.splice(0)) {
286
+ try {
287
+ cleanup();
288
+ }
289
+ catch (error) {
290
+ this.context.logger.error(`Error during cleanup: ${error.message}`);
291
+ }
292
+ }
293
+ // Stop HTTP server
294
+ if (this.server) {
295
+ await new Promise((resolve) => {
296
+ this.server.close(() => {
297
+ this.context.logger.info('✅ HTTP server stopped');
298
+ resolve();
299
+ });
300
+ });
301
+ }
302
+ this.context.logger.info(`✅ Plugin "${this.name}" unloaded`);
303
+ }
304
+ async isHealthy() {
305
+ if (this.healthCheckFn) {
306
+ try {
307
+ const result = await this.healthCheckFn({
308
+ majk: this.context.majk,
309
+ storage: this.context.storage,
310
+ logger: this.context.logger
311
+ });
312
+ return { ...result, errors: [], warnings: [] };
313
+ }
314
+ catch (error) {
315
+ return {
316
+ healthy: false,
317
+ errors: [`Health check failed: ${error.message}`]
318
+ };
319
+ }
320
+ }
321
+ return { healthy: true };
322
+ }
323
+ async startServer() {
324
+ const { port } = this.context.http;
325
+ this.server = http_1.default.createServer(async (req, res) => {
326
+ try {
327
+ // CORS preflight
328
+ if (req.method === 'OPTIONS') {
329
+ res.writeHead(204, corsHeaders());
330
+ res.end();
331
+ return;
332
+ }
333
+ // Health check
334
+ if (req.method === 'GET' && req.url === '/health') {
335
+ const health = await this.isHealthy();
336
+ res.writeHead(200, corsHeaders({ 'Content-Type': 'application/json' }));
337
+ res.end(JSON.stringify({ ...health, plugin: this.name, version: this.version }));
338
+ return;
339
+ }
340
+ // HTML screens virtual route
341
+ if (req.method === 'GET' && req.url?.startsWith('/__html/')) {
342
+ const id = decodeURIComponent(req.url.substring('/__html/'.length)).split('?')[0];
343
+ const screen = this.htmlScreens.find(s => s.id === id);
344
+ if (!screen) {
345
+ res.writeHead(404, corsHeaders({ 'Content-Type': 'text/plain' }));
346
+ res.end('HTML screen not found');
347
+ return;
348
+ }
349
+ res.writeHead(200, corsHeaders({ 'Content-Type': 'text/html; charset=utf-8' }));
350
+ if ('html' in screen) {
351
+ res.end(screen.html);
352
+ }
353
+ else {
354
+ const filePath = path_1.default.join(process.cwd(), screen.htmlFile);
355
+ // Security check
356
+ const normalized = path_1.default.resolve(filePath);
357
+ if (!normalized.startsWith(process.cwd())) {
358
+ res.writeHead(403, corsHeaders({ 'Content-Type': 'text/plain' }));
359
+ res.end('Forbidden');
360
+ this.context.logger.warn(`Path traversal attempt: ${screen.htmlFile}`);
361
+ return;
362
+ }
363
+ try {
364
+ res.end(fs_1.default.readFileSync(filePath));
365
+ }
366
+ catch (error) {
367
+ this.context.logger.error(`Failed to read HTML file: ${error.message}`);
368
+ res.writeHead(500, corsHeaders({ 'Content-Type': 'text/html' }));
369
+ res.end('<!doctype html><html><body><h1>Error loading screen</h1></body></html>');
370
+ }
371
+ }
372
+ return;
373
+ }
374
+ // React SPA
375
+ if (req.method === 'GET' && this.uiConfig && req.url?.startsWith(this.uiConfig.base)) {
376
+ serveSpa(this.uiConfig, req, res, this.context);
377
+ return;
378
+ }
379
+ // API routes
380
+ if (req.url?.startsWith('/api/')) {
381
+ const url = new URL(req.url, `http://localhost:${port}`);
382
+ const pathname = url.pathname;
383
+ const method = req.method;
384
+ const route = this.router.find(r => r.method === method && r.regex.test(pathname));
385
+ if (!route) {
386
+ res.writeHead(404, corsHeaders({ 'Content-Type': 'application/json' }));
387
+ res.end(JSON.stringify({
388
+ error: 'Route not found',
389
+ path: pathname,
390
+ method,
391
+ hint: `Available routes: ${this.router.map(r => `${r.method} ${r.name}`).join(', ')}`
392
+ }));
393
+ return;
394
+ }
395
+ const body = await readBody(req);
396
+ const match = pathname.match(route.regex);
397
+ const params = {};
398
+ route.keys.forEach((key, i) => {
399
+ params[key] = decodeURIComponent(match[i + 1]);
400
+ });
401
+ const request = {
402
+ method,
403
+ url: req.url,
404
+ pathname,
405
+ query: url.searchParams,
406
+ params,
407
+ body,
408
+ headers: req.headers
409
+ };
410
+ const response = makeResponse(res);
411
+ const routeContext = {
412
+ majk: this.context.majk,
413
+ storage: this.context.storage,
414
+ logger: this.context.logger,
415
+ http: this.context.http
416
+ };
417
+ try {
418
+ this.context.logger.debug(`📥 ${method} ${pathname}`, { params, body });
419
+ const result = await route.handler(request, response, routeContext);
420
+ if (!res.headersSent) {
421
+ res.writeHead(200, corsHeaders({ 'Content-Type': 'application/json' }));
422
+ res.end(JSON.stringify(result ?? { success: true }));
423
+ }
424
+ this.context.logger.debug(`📤 ${method} ${pathname} - Success`);
425
+ }
426
+ catch (error) {
427
+ this.context.logger.error(`❌ ${method} ${pathname} - Error: ${error.message}`);
428
+ if (error.stack) {
429
+ this.context.logger.error(error.stack);
430
+ }
431
+ if (!res.headersSent) {
432
+ res.writeHead(500, corsHeaders({ 'Content-Type': 'application/json' }));
433
+ res.end(JSON.stringify({
434
+ error: error.message || 'Internal server error',
435
+ route: route.name,
436
+ path: pathname
437
+ }));
438
+ }
439
+ }
440
+ return;
441
+ }
442
+ // 404
443
+ res.writeHead(404, corsHeaders({ 'Content-Type': 'application/json' }));
444
+ res.end(JSON.stringify({ error: 'Not found', path: req.url }));
445
+ }
446
+ catch (error) {
447
+ this.context.logger.error(`Unhandled server error: ${error.message}`);
448
+ if (!res.headersSent) {
449
+ res.writeHead(500, corsHeaders({ 'Content-Type': 'text/plain' }));
450
+ res.end('Internal server error');
451
+ }
452
+ }
453
+ });
454
+ await new Promise((resolve) => {
455
+ this.server.listen(port, () => {
456
+ this.context.logger.info(`✅ HTTP server listening on port ${port}`);
457
+ resolve();
458
+ });
459
+ });
460
+ }
461
+ }
462
+ /**
463
+ * Group tools by scope
464
+ */
465
+ function groupByScope(tools) {
466
+ const map = new Map();
467
+ for (const { scope, spec } of tools) {
468
+ const arr = map.get(scope) || [];
469
+ arr.push(spec);
470
+ map.set(scope, arr);
471
+ }
472
+ return Array.from(map, ([scope, tools]) => ({ scope, tools }));
473
+ }
474
+ /**
475
+ * Create a plugin with fluent builder API
476
+ */
477
+ function definePlugin(id, name, version) {
478
+ const _capabilities = [];
479
+ const _tools = new Map();
480
+ const _toolSpecs = [];
481
+ const _apiRoutes = [];
482
+ const _reactScreens = [];
483
+ const _htmlScreens = [];
484
+ const _topbars = [];
485
+ const _entities = [];
486
+ let _ui = { appDir: 'ui/dist', base: '/', history: 'browser' };
487
+ let _uiConfigured = false;
488
+ let _wizard = null;
489
+ let _settings = null;
490
+ let _onReady = null;
491
+ let _healthCheck = null;
492
+ const builder = {
493
+ topbar(route, opts) {
494
+ _topbars.push({
495
+ id: opts?.id || `${id}-topbar-${_topbars.length + 1}`,
496
+ name: opts?.name || name,
497
+ icon: opts?.icon,
498
+ route
499
+ });
500
+ return this;
501
+ },
502
+ ui(config) {
503
+ _uiConfigured = true;
504
+ if (config?.appDir)
505
+ _ui.appDir = config.appDir;
506
+ if (config?.base)
507
+ _ui.base = config.base;
508
+ if (config?.history)
509
+ _ui.history = config.history;
510
+ return this;
511
+ },
512
+ screenReact(screen) {
513
+ validateDescription(screen.description, `React screen "${screen.name}"`);
514
+ if (!screen.route.startsWith(`/plugin-screens/${id}/`)) {
515
+ throw new PluginBuildError(`React screen route must start with "/plugin-screens/${id}/"`, `Change route from "${screen.route}" to "/plugin-screens/${id}/your-route"`, { screen: screen.id, route: screen.route });
516
+ }
517
+ const iframeUrl = `http://localhost:{port}${_ui.base}${screen.reactPath}`;
518
+ _reactScreens.push(screen);
519
+ _capabilities.push({
520
+ type: 'screen',
521
+ id: screen.id,
522
+ path: screen.route,
523
+ iframeUrl
524
+ });
525
+ return this;
526
+ },
527
+ screenHtml(screen) {
528
+ validateDescription(screen.description, `HTML screen "${screen.name}"`);
529
+ if (!screen.route.startsWith(`/plugin-screens/${id}/`)) {
530
+ throw new PluginBuildError(`HTML screen route must start with "/plugin-screens/${id}/"`, `Change route from "${screen.route}" to "/plugin-screens/${id}/your-route"`, { screen: screen.id, route: screen.route });
531
+ }
532
+ const iframeUrl = `http://localhost:{port}/__html/${encodeURIComponent(screen.id)}`;
533
+ _htmlScreens.push(screen);
534
+ _capabilities.push({
535
+ type: 'screen',
536
+ id: screen.id,
537
+ path: screen.route,
538
+ iframeUrl
539
+ });
540
+ return this;
541
+ },
542
+ apiRoute(route) {
543
+ validateDescription(route.description, `API route "${route.name}"`);
544
+ if (!route.path.startsWith('/api/')) {
545
+ throw new PluginBuildError(`API route path must start with "/api/"`, `Change path from "${route.path}" to "/api/your-endpoint"`, { route: route.name });
546
+ }
547
+ _apiRoutes.push(route);
548
+ return this;
549
+ },
550
+ tool(scope, spec, handler) {
551
+ validateDescription(spec.description, `Tool "${spec.name}"`);
552
+ if (_tools.has(spec.name)) {
553
+ throw new PluginBuildError(`Duplicate tool name: "${spec.name}"`, 'Tool names must be unique within a plugin', { tool: spec.name, existingTools: Array.from(_tools.keys()) });
554
+ }
555
+ _tools.set(spec.name, handler);
556
+ _toolSpecs.push({ scope, spec });
557
+ return this;
558
+ },
559
+ entity(entityType, entities) {
560
+ _entities.push({ entityType, entities });
561
+ return this;
562
+ },
563
+ configWizard(def) {
564
+ if (_wizard) {
565
+ throw new PluginBuildError('Config wizard already defined', 'You can only have one config wizard per plugin');
566
+ }
567
+ validateDescription(def.description, 'Config wizard');
568
+ _wizard = def;
569
+ return this;
570
+ },
571
+ settings(def) {
572
+ if (_settings) {
573
+ throw new PluginBuildError('Settings already defined', 'You can only have one settings screen per plugin');
574
+ }
575
+ validateDescription(def.description, 'Settings');
576
+ _settings = def;
577
+ return this;
578
+ },
579
+ onReady(fn) {
580
+ _onReady = fn;
581
+ return this;
582
+ },
583
+ health(fn) {
584
+ _healthCheck = fn;
585
+ return this;
586
+ },
587
+ build() {
588
+ // ========== Build-Time Validation ==========
589
+ // Validate React UI setup
590
+ if (_reactScreens.length > 0 && !_uiConfigured) {
591
+ throw new PluginBuildError('UI not configured but React screens were added', 'Call .ui() before adding React screens', { reactScreens: _reactScreens.map(s => s.id) });
592
+ }
593
+ // Validate React dist exists
594
+ if (_reactScreens.length > 0) {
595
+ const indexPath = path_1.default.join(process.cwd(), _ui.appDir, 'index.html');
596
+ if (!fs_1.default.existsSync(indexPath)) {
597
+ throw new PluginBuildError(`React app not built: ${indexPath} does not exist`, `Run "npm run build" in your UI directory to build the React app`, { appDir: _ui.appDir, indexPath });
598
+ }
599
+ }
600
+ // Validate HTML screen files
601
+ for (const screen of _htmlScreens) {
602
+ if ('htmlFile' in screen) {
603
+ const filePath = path_1.default.join(process.cwd(), screen.htmlFile);
604
+ if (!fs_1.default.existsSync(filePath)) {
605
+ throw new PluginBuildError(`HTML screen file not found: ${screen.htmlFile}`, `Create the HTML file at ${filePath} or fix the path`, { screen: screen.id, file: screen.htmlFile });
606
+ }
607
+ }
608
+ }
609
+ // Validate screen routes
610
+ const screenRoutes = new Set();
611
+ for (const screen of [..._reactScreens, ..._htmlScreens]) {
612
+ if (screenRoutes.has(screen.route)) {
613
+ throw new PluginBuildError(`Duplicate screen route: ${screen.route}`, 'Each screen must have a unique route', { route: screen.route, screens: [..._reactScreens, ..._htmlScreens].map(s => ({ id: s.id, route: s.route })) });
614
+ }
615
+ screenRoutes.add(screen.route);
616
+ }
617
+ // Validate topbar routes
618
+ for (const topbar of _topbars) {
619
+ if (!screenRoutes.has(topbar.route)) {
620
+ throw new PluginBuildError(`Topbar route "${topbar.route}" doesn't match any screen`, 'Topbar routes must point to declared screens', { topbarId: topbar.id, route: topbar.route, availableScreens: Array.from(screenRoutes) });
621
+ }
622
+ }
623
+ // Validate API route uniqueness
624
+ const apiSignatures = new Set();
625
+ for (const route of _apiRoutes) {
626
+ const signature = `${route.method} ${route.path}`;
627
+ if (apiSignatures.has(signature)) {
628
+ throw new PluginBuildError(`Duplicate API route: ${signature}`, 'Each API route (method + path) must be unique', { route: signature, allRoutes: Array.from(apiSignatures) });
629
+ }
630
+ apiSignatures.add(signature);
631
+ }
632
+ // ========== Build Capabilities ==========
633
+ // Add topbars
634
+ for (const topbar of _topbars) {
635
+ _capabilities.push({
636
+ type: 'topbar',
637
+ id: topbar.id,
638
+ name: topbar.name,
639
+ icon: topbar.icon,
640
+ route: topbar.route
641
+ });
642
+ }
643
+ // Add tools (grouped by scope)
644
+ const toolGroups = groupByScope(_toolSpecs);
645
+ for (const group of toolGroups) {
646
+ _capabilities.push({
647
+ type: 'tool',
648
+ scope: group.scope,
649
+ tools: group.tools
650
+ });
651
+ }
652
+ // Add entities
653
+ for (const entity of _entities) {
654
+ _capabilities.push({
655
+ type: 'entity',
656
+ entityType: entity.entityType,
657
+ entities: entity.entities
658
+ });
659
+ }
660
+ // Add wizard
661
+ if (_wizard) {
662
+ const wizardCap = {
663
+ type: 'config-wizard',
664
+ screen: {
665
+ path: _wizard.path,
666
+ title: _wizard.title,
667
+ width: _wizard.width,
668
+ height: _wizard.height
669
+ },
670
+ required: true
671
+ };
672
+ // Add shouldShow as method name reference if provided
673
+ if (_wizard.shouldShow) {
674
+ wizardCap.shouldShow = 'shouldShowConfigWizard';
675
+ }
676
+ _capabilities.push(wizardCap);
677
+ }
678
+ // Add settings
679
+ if (_settings) {
680
+ _capabilities.push({
681
+ type: 'settings-screen',
682
+ path: `/plugins/${id}/settings`,
683
+ name: _settings.title,
684
+ screen: {
685
+ path: _settings.path,
686
+ title: _settings.title
687
+ }
688
+ });
689
+ }
690
+ // ========== Build Plugin Instance ==========
691
+ return new BuiltPlugin(id, name, version, _capabilities, _tools, _apiRoutes, _uiConfigured ? _ui : null, _reactScreens, _htmlScreens, _wizard, _onReady, _healthCheck);
692
+ }
693
+ };
694
+ return builder;
695
+ }