claw-dashboard 1.9.0 → 2.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.
Files changed (135) hide show
  1. package/.c8rc.json +38 -0
  2. package/.dockerignore +68 -0
  3. package/.github/dependabot.yml +45 -0
  4. package/.github/pull_request_template.md +39 -0
  5. package/.github/workflows/ci.yml +147 -0
  6. package/.github/workflows/release.yml +40 -0
  7. package/.github/workflows/security.yml +84 -0
  8. package/.husky/pre-commit +1 -0
  9. package/.planning/codebase/ARCHITECTURE.md +206 -0
  10. package/.planning/codebase/INTEGRATIONS.md +150 -0
  11. package/.planning/codebase/STACK.md +122 -0
  12. package/.planning/codebase/STRUCTURE.md +201 -0
  13. package/CHANGELOG.md +293 -0
  14. package/CONTRIBUTING.md +378 -0
  15. package/Dockerfile +54 -0
  16. package/FEATURES.md +195 -21
  17. package/README.md +83 -6
  18. package/TODO.md +28 -0
  19. package/build-cjs.js +127 -0
  20. package/cjs-shim.js +13 -0
  21. package/dist/clawdash +1729 -0
  22. package/dist/clawdash.meta.json +2236 -0
  23. package/dist/widgets.cjs +6511 -0
  24. package/docker-compose.yml +67 -0
  25. package/docs/API.md +1050 -0
  26. package/docs/PLUGINS.md +1504 -0
  27. package/esbuild.config.js +158 -0
  28. package/eslint.config.js +56 -0
  29. package/examples/plugins/README.md +122 -0
  30. package/examples/plugins/api-status/index.js +294 -0
  31. package/examples/plugins/api-status/plugin.json +19 -0
  32. package/examples/plugins/hello-world/index.js +104 -0
  33. package/examples/plugins/hello-world/plugin.json +15 -0
  34. package/examples/plugins/system-metrics-chart/index.js +339 -0
  35. package/examples/plugins/system-metrics-chart/plugin.json +18 -0
  36. package/examples/plugins/weather-widget/index.js +136 -0
  37. package/examples/plugins/weather-widget/plugin.json +19 -0
  38. package/index.cjs +23005 -0
  39. package/index.js +5236 -566
  40. package/jest.config.js +11 -0
  41. package/man/clawdash.1 +276 -0
  42. package/package.json +54 -5
  43. package/schemas/plugin-manifest.json +128 -0
  44. package/scripts/release.js +595 -0
  45. package/src/alerts.js +693 -0
  46. package/src/auto-save.js +584 -0
  47. package/src/cache.js +390 -0
  48. package/src/checksum.js +146 -0
  49. package/src/cli/args.js +110 -0
  50. package/src/cli/export-schedule.js +423 -0
  51. package/src/cli/export-snapshot.js +138 -0
  52. package/src/cli/help.js +69 -0
  53. package/src/cli/import-snapshot.js +230 -0
  54. package/src/cli/index.js +14 -0
  55. package/src/cli/list-templates.js +69 -0
  56. package/src/cli/validate-config.js +76 -0
  57. package/src/cli/validate-plugin.js +187 -0
  58. package/src/cli/version.js +15 -0
  59. package/src/config-validator.js +586 -0
  60. package/src/config-watcher.js +401 -0
  61. package/src/config.js +684 -0
  62. package/src/container-detector.js +499 -0
  63. package/src/database.js +734 -0
  64. package/src/differential-render.js +327 -0
  65. package/src/errors.js +169 -0
  66. package/src/export-scheduler.js +581 -0
  67. package/src/gateway-manager.js +685 -0
  68. package/src/hints.js +371 -0
  69. package/src/loading-states.js +509 -0
  70. package/src/logger.js +185 -0
  71. package/src/memory-pressure.js +467 -0
  72. package/src/performance-monitor.js +374 -0
  73. package/src/plugin-errors.js +586 -0
  74. package/src/plugin-manifest-validator.js +219 -0
  75. package/src/plugin-reload.js +481 -0
  76. package/src/plugin-scaffold.js +1941 -0
  77. package/src/plugin-validator.js +284 -0
  78. package/src/retry.js +218 -0
  79. package/src/security.js +860 -0
  80. package/src/snapshot.js +372 -0
  81. package/src/splash.js +115 -0
  82. package/src/theme-selector.js +411 -0
  83. package/src/themes.js +874 -0
  84. package/src/transitions.js +534 -0
  85. package/src/types.d.ts +174 -0
  86. package/src/validation.js +926 -0
  87. package/src/web-server.js +885 -0
  88. package/src/widgets/builtin-widgets.js +1057 -0
  89. package/src/widgets/config-processor.js +401 -0
  90. package/src/widgets/dependency-resolver.js +596 -0
  91. package/src/widgets/index.js +79 -0
  92. package/src/widgets/plugin-api.js +845 -0
  93. package/src/widgets/widget-error-boundary.js +773 -0
  94. package/src/widgets/widget-error-isolation.js +551 -0
  95. package/src/widgets/widget-loader.js +1437 -0
  96. package/src/workers/system-worker.js +130 -0
  97. package/src/workers/worker-pool.js +633 -0
  98. package/tests/alerts.test.js +437 -0
  99. package/tests/auto-save.test.js +529 -0
  100. package/tests/cache.test.js +317 -0
  101. package/tests/cli.test.js +351 -0
  102. package/tests/command-palette.test.js +320 -0
  103. package/tests/config-processor.test.js +452 -0
  104. package/tests/config-validator.test.js +710 -0
  105. package/tests/config-watcher.test.js +594 -0
  106. package/tests/config.test.js +755 -0
  107. package/tests/database.test.js +438 -0
  108. package/tests/errors.test.js +189 -0
  109. package/tests/example-plugins.test.js +624 -0
  110. package/tests/integration.test.js +1321 -0
  111. package/tests/loading-states.test.js +300 -0
  112. package/tests/manifest-validation-on-load.test.js +671 -0
  113. package/tests/performance-monitor.test.js +190 -0
  114. package/tests/plugin-api-rate-limit.test.js +302 -0
  115. package/tests/plugin-errors.test.js +311 -0
  116. package/tests/plugin-lifecycle-e2e.test.js +1036 -0
  117. package/tests/plugin-reload.test.js +764 -0
  118. package/tests/rate-limiter.test.js +539 -0
  119. package/tests/retry.test.js +308 -0
  120. package/tests/security.test.js +411 -0
  121. package/tests/settings-modal.test.js +226 -0
  122. package/tests/theme-selector.test.js +57 -0
  123. package/tests/utils.js +242 -0
  124. package/tests/utils.test.js +317 -0
  125. package/tests/validate-plugin-cli.test.js +359 -0
  126. package/tests/validation.test.js +837 -0
  127. package/tests/web-server.test.js +646 -0
  128. package/tests/widget-arrange-mode.test.js +183 -0
  129. package/tests/widget-config-hot-reload.test.js +465 -0
  130. package/tests/widget-dependency.test.js +591 -0
  131. package/tests/widget-error-boundary.test.js +749 -0
  132. package/tests/widget-error-isolation.test.js +469 -0
  133. package/tests/widget-integration.test.js +1147 -0
  134. package/tests/widget-refresh-intervals.test.js +284 -0
  135. package/tests/worker-pool.test.js +522 -0
@@ -0,0 +1,1437 @@
1
+ /**
2
+ * Widget Loader Module
3
+ * Provides lazy loading for widget modules to improve startup performance
4
+ */
5
+
6
+ import { existsSync, readdirSync } from 'fs';
7
+ import { join, resolve, extname, basename } from 'path';
8
+ import { pathToFileURL } from 'url';
9
+ import logger from '../logger.js';
10
+ import config from '../config.js';
11
+ import { sanitizeWidgetConfig, validateWidgetConfig, validatePluginPath, validatePluginName } from '../security.js';
12
+ import { processWidgetConfig } from './config-processor.js';
13
+ import { validateManifest } from '../plugin-manifest-validator.js';
14
+ import {
15
+ resolveDependencies,
16
+ validateWidgetDependencies,
17
+ buildDependencyGraph,
18
+ getAllDependencies,
19
+ getAllDependents,
20
+ } from './dependency-resolver.js';
21
+ import { PluginError, PluginErrorAnalyzer, PLUGIN_ERROR_CODES } from '../plugin-errors.js';
22
+ import { ConfigWatcher } from '../config-watcher.js';
23
+ import { EventEmitter } from 'events';
24
+
25
+ const { PATHS, WIDGETS } = config;
26
+
27
+ /**
28
+ * Extract default values from config schema definition
29
+ * Config schemas define fields with { type, default, min, max, options } etc.
30
+ * This function extracts just the default values for widget consumption.
31
+ *
32
+ * @param {Object} configSchema - Config schema from manifest
33
+ * @returns {Object} Config with default values extracted
34
+ */
35
+ function extractDefaultsFromSchema(configSchema) {
36
+ if (!configSchema || typeof configSchema !== 'object') {
37
+ return {};
38
+ }
39
+
40
+ const result = {};
41
+ for (const [key, value] of Object.entries(configSchema)) {
42
+ // If value is a schema definition (has 'type' property), extract default
43
+ if (value && typeof value === 'object' && value.type !== undefined) {
44
+ result[key] = value.default !== undefined ? value.default : null;
45
+ } else if (value && typeof value === 'object' && !Array.isArray(value)) {
46
+ // Nested object - recursively process
47
+ result[key] = extractDefaultsFromSchema(value);
48
+ } else {
49
+ // Direct value (legacy format)
50
+ result[key] = value;
51
+ }
52
+ }
53
+ return result;
54
+ }
55
+
56
+ /**
57
+ * Widget Loader class for lazy loading widget modules
58
+ * Extends EventEmitter to support hot-reload events
59
+ */
60
+ export class WidgetLoader extends EventEmitter {
61
+ constructor(options = {}) {
62
+ super();
63
+ this.widgetsDir = options.widgetsDir || PATHS.WIDGETS_DIR;
64
+ this.pluginsDir = options.pluginsDir || PATHS.PLUGINS_DIR;
65
+ this.loadedWidgets = new Map();
66
+ this.widgetRegistry = new Map();
67
+ this.loadPromises = new Map();
68
+ this.hooks = {
69
+ beforeLoad: [],
70
+ afterLoad: [],
71
+ beforeUnload: [],
72
+ };
73
+ this.configWatcher = null;
74
+ this._reloadStats = {
75
+ reloads: 0,
76
+ errors: 0,
77
+ lastReload: null,
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Register a widget without loading it
83
+ * @param {string} id - Unique widget identifier
84
+ * @param {Object} metadata - Widget metadata
85
+ * @param {Function} loader - Async function that returns the widget module
86
+ */
87
+ register(id, metadata, loader) {
88
+ if (this.widgetRegistry.has(id)) {
89
+ logger.warn(`Widget '${id}' is already registered, overwriting`);
90
+ }
91
+
92
+ this.widgetRegistry.set(id, {
93
+ id,
94
+ metadata: {
95
+ name: metadata.name || id,
96
+ description: metadata.description || '',
97
+ version: metadata.version || '1.0.0',
98
+ author: metadata.author || '',
99
+ category: metadata.category || 'system',
100
+ priority: metadata.priority || 100,
101
+ lazyLoad: metadata.lazyLoad !== false, // default true
102
+ dependencies: metadata.dependencies || [],
103
+ permissions: metadata.permissions || [],
104
+ ...metadata,
105
+ },
106
+ loader,
107
+ loaded: false,
108
+ instance: null,
109
+ error: null,
110
+ });
111
+
112
+ logger.debug(`Widget '${id}' registered`);
113
+ return this;
114
+ }
115
+
116
+ /**
117
+ * Unregister a widget
118
+ * @param {string} id - Widget identifier
119
+ */
120
+ async unregister(id) {
121
+ const widget = this.widgetRegistry.get(id);
122
+ if (!widget) {
123
+ logger.warn(`Widget '${id}' not found in registry`);
124
+ return false;
125
+ }
126
+
127
+ // Run beforeUnload hooks
128
+ await this.runHooks('beforeUnload', widget);
129
+
130
+ // Unload if loaded
131
+ if (widget.loaded && widget.instance?.destroy) {
132
+ try {
133
+ await widget.instance.destroy();
134
+ } catch (err) {
135
+ logger.error(`Error destroying widget '${id}': ${err.message}`);
136
+ }
137
+ }
138
+
139
+ this.loadedWidgets.delete(id);
140
+ this.widgetRegistry.delete(id);
141
+ this.loadPromises.delete(id);
142
+
143
+ logger.debug(`Widget '${id}' unregistered`);
144
+ return true;
145
+ }
146
+
147
+ /**
148
+ * Load a widget by ID (lazy loading)
149
+ * @param {string} id - Widget identifier
150
+ * @returns {Promise<Object>} Loaded widget instance
151
+ */
152
+ async load(id) {
153
+ // Return cached promise if loading is in progress
154
+ if (this.loadPromises.has(id)) {
155
+ return this.loadPromises.get(id);
156
+ }
157
+
158
+ const widget = this.widgetRegistry.get(id);
159
+ if (!widget) {
160
+ throw new Error(`Widget '${id}' not registered`);
161
+ }
162
+
163
+ // Return cached instance if already loaded
164
+ if (widget.loaded && widget.instance) {
165
+ return widget.instance;
166
+ }
167
+
168
+ // Create load promise
169
+ const loadPromise = this._doLoad(widget);
170
+ this.loadPromises.set(id, loadPromise);
171
+
172
+ try {
173
+ const instance = await loadPromise;
174
+ return instance;
175
+ } finally {
176
+ this.loadPromises.delete(id);
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Convenience method to register and load a widget in one call
182
+ * @param {string} id - Unique widget identifier
183
+ * @param {Object} metadata - Widget metadata
184
+ * @param {Function} loader - Async function that returns the widget module
185
+ * @returns {Promise<Object>} Loaded widget instance
186
+ */
187
+ async loadAndRegister(id, metadata, loader) {
188
+ this.register(id, metadata, loader);
189
+ return this.load(id);
190
+ }
191
+
192
+ /**
193
+ * Internal method to perform the actual loading
194
+ * @private
195
+ */
196
+ async _doLoad(widget) {
197
+ const startTime = Date.now();
198
+
199
+ try {
200
+ // Run beforeLoad hooks
201
+ await this.runHooks('beforeLoad', widget);
202
+
203
+ // Check dependencies
204
+ await this._resolveDependencies(widget);
205
+
206
+ // Load the widget module
207
+ const instance = await widget.loader();
208
+
209
+ if (!instance || typeof instance !== 'object') {
210
+ throw new Error('Widget loader did not return a valid object');
211
+ }
212
+
213
+ // Validate required methods
214
+ this._validateWidget(instance, widget.id);
215
+
216
+ widget.instance = instance;
217
+ widget.loaded = true;
218
+ widget.loadTime = Date.now() - startTime;
219
+ widget.error = null;
220
+
221
+ this.loadedWidgets.set(widget.id, instance);
222
+
223
+ // Run afterLoad hooks
224
+ await this.runHooks('afterLoad', widget);
225
+
226
+ logger.debug(`Widget '${widget.id}' loaded in ${widget.loadTime}ms`);
227
+ return instance;
228
+ } catch (err) {
229
+ widget.error = err;
230
+ widget.loaded = false;
231
+
232
+ // Enhance error message if not already a PluginError
233
+ if (!(err instanceof PluginError)) {
234
+ const enhanced = PluginErrorAnalyzer.analyze(err, widget.id, { phase: 'widget' });
235
+ logger.error(`Failed to load widget '${widget.id}': ${enhanced.getFormattedMessage()}`);
236
+ } else {
237
+ logger.error(`Failed to load widget '${widget.id}': ${err.getFormattedMessage()}`);
238
+ }
239
+
240
+ throw err;
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Resolve widget dependencies
246
+ * @private
247
+ */
248
+ async _resolveDependencies(widget) {
249
+ const deps = widget.metadata.dependencies || [];
250
+
251
+ for (const depId of deps) {
252
+ if (!this.widgetRegistry.has(depId)) {
253
+ const pluginError = new PluginError(
254
+ PLUGIN_ERROR_CODES.DEPENDENCY_MISSING,
255
+ `Dependency "${depId}" not found for widget "${widget.id}"`,
256
+ {
257
+ pluginId: widget.id,
258
+ dependencyId: depId,
259
+ availableDependencies: Array.from(this.widgetRegistry.keys()),
260
+ }
261
+ );
262
+ throw pluginError;
263
+ }
264
+
265
+ const depWidget = this.widgetRegistry.get(depId);
266
+ if (!depWidget.loaded) {
267
+ await this.load(depId);
268
+ }
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Validate widget has required methods
274
+ * @private
275
+ */
276
+ _validateWidget(instance, id) {
277
+ const required = ['render', 'getData'];
278
+ const missing = required.filter(method => typeof instance[method] !== 'function');
279
+
280
+ if (missing.length > 0) {
281
+ const pluginError = new PluginError(
282
+ PLUGIN_ERROR_CODES.WIDGET_MISSING_METHODS,
283
+ `Widget "${id}" is missing required methods: ${missing.join(', ')}`,
284
+ {
285
+ pluginId: id,
286
+ missingMethods: missing,
287
+ hasRender: typeof instance.render === 'function',
288
+ hasGetData: typeof instance.getData === 'function',
289
+ }
290
+ );
291
+ throw pluginError;
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Unload a widget
297
+ * @param {string} id - Widget identifier
298
+ */
299
+ async unload(id) {
300
+ const widget = this.widgetRegistry.get(id);
301
+ if (!widget || !widget.loaded) {
302
+ return false;
303
+ }
304
+
305
+ await this.runHooks('beforeUnload', widget);
306
+
307
+ if (widget.instance?.destroy) {
308
+ try {
309
+ await widget.instance.destroy();
310
+ } catch (err) {
311
+ logger.error(`Error destroying widget '${id}': ${err.message}`);
312
+ }
313
+ }
314
+
315
+ widget.instance = null;
316
+ widget.loaded = false;
317
+ this.loadedWidgets.delete(id);
318
+
319
+ logger.debug(`Widget '${id}' unloaded`);
320
+ return true;
321
+ }
322
+
323
+ /**
324
+ * Load multiple widgets in parallel
325
+ * @param {string[]} ids - Array of widget IDs
326
+ * @returns {Promise<Map>} Map of id to loaded instance
327
+ */
328
+ async loadMany(ids) {
329
+ const results = await Promise.allSettled(
330
+ ids.map(id => this.load(id).then(instance => ({ id, instance })))
331
+ );
332
+
333
+ const loaded = new Map();
334
+ const errors = [];
335
+
336
+ for (const result of results) {
337
+ if (result.status === 'fulfilled') {
338
+ loaded.set(result.value.id, result.value.instance);
339
+ } else {
340
+ errors.push(result.reason);
341
+ }
342
+ }
343
+
344
+ if (errors.length > 0) {
345
+ logger.warn(`Failed to load ${errors.length} widget(s): ${errors.map(e => e.message).join(', ')}`);
346
+ }
347
+
348
+ return loaded;
349
+ }
350
+
351
+ /**
352
+ * Preload widgets that are likely to be needed
353
+ * @param {string[]} priorityIds - Widget IDs to preload
354
+ */
355
+ async preload(priorityIds) {
356
+ const preloadList = priorityIds.filter(id => {
357
+ const widget = this.widgetRegistry.get(id);
358
+ return widget && widget.metadata.lazyLoad && !widget.loaded;
359
+ });
360
+
361
+ if (preloadList.length === 0) return;
362
+
363
+ logger.debug(`Preloading ${preloadList.length} widget(s)`);
364
+
365
+ // Load in order of priority
366
+ const sorted = preloadList
367
+ .map(id => ({ id, priority: this.widgetRegistry.get(id).metadata.priority }))
368
+ .sort((a, b) => a.priority - b.priority);
369
+
370
+ // Load high priority widgets first
371
+ for (const { id } of sorted.filter(w => w.priority < 50)) {
372
+ try {
373
+ await this.load(id);
374
+ } catch (err) {
375
+ // Non-critical, continue
376
+ }
377
+ }
378
+
379
+ // Load remaining in background
380
+ const remaining = sorted.filter(w => w.priority >= 50).map(w => w.id);
381
+ if (remaining.length > 0) {
382
+ this.loadMany(remaining).catch(() => {});
383
+ }
384
+ }
385
+
386
+ /**
387
+ * Get widget metadata without loading
388
+ * @param {string} id - Widget identifier
389
+ */
390
+ getMetadata(id) {
391
+ const widget = this.widgetRegistry.get(id);
392
+ return widget ? { ...widget.metadata } : null;
393
+ }
394
+
395
+ /**
396
+ * Get all registered widget metadata
397
+ */
398
+ getAllMetadata() {
399
+ const metadata = [];
400
+ for (const [id, widget] of this.widgetRegistry) {
401
+ metadata.push({
402
+ id,
403
+ ...widget.metadata,
404
+ loaded: widget.loaded,
405
+ hasError: !!widget.error,
406
+ });
407
+ }
408
+ return metadata.sort((a, b) => a.priority - b.priority);
409
+ }
410
+
411
+ /**
412
+ * Get loaded widget instance
413
+ * @param {string} id - Widget identifier
414
+ */
415
+ get(id) {
416
+ const widget = this.widgetRegistry.get(id);
417
+ return widget?.loaded ? widget.instance : null;
418
+ }
419
+
420
+ /**
421
+ * Check if widget is loaded
422
+ * @param {string} id - Widget identifier
423
+ */
424
+ isLoaded(id) {
425
+ const widget = this.widgetRegistry.get(id);
426
+ return widget?.loaded || false;
427
+ }
428
+
429
+ /**
430
+ * Add a hook
431
+ * @param {string} type - Hook type: 'beforeLoad', 'afterLoad', 'beforeUnload'
432
+ * @param {Function} handler - Hook handler
433
+ */
434
+ addHook(type, handler) {
435
+ if (!this.hooks[type]) {
436
+ throw new Error(`Unknown hook type: ${type}`);
437
+ }
438
+ this.hooks[type].push(handler);
439
+ }
440
+
441
+ /**
442
+ * Run hooks for a type
443
+ * @private
444
+ */
445
+ async runHooks(type, widget) {
446
+ for (const handler of this.hooks[type]) {
447
+ try {
448
+ await handler(widget);
449
+ } catch (err) {
450
+ logger.error(`Hook error (${type}): ${err.message}`);
451
+ }
452
+ }
453
+ }
454
+
455
+ /**
456
+ * Discover widgets from plugins directory
457
+ */
458
+ async discoverPlugins() {
459
+ // Validate pluginsDir exists and is safe
460
+ const pluginsDirValidation = validatePluginPath(this.pluginsDir, {
461
+ allowAbsolute: true,
462
+ mustExist: true,
463
+ expectedType: 'directory',
464
+ });
465
+
466
+ if (!pluginsDirValidation.valid) {
467
+ logger.warn(`Plugins directory validation failed: ${pluginsDirValidation.error}`);
468
+ return [];
469
+ }
470
+
471
+ const validatedPluginsDir = pluginsDirValidation.path;
472
+
473
+ if (!existsSync(validatedPluginsDir)) {
474
+ return [];
475
+ }
476
+
477
+ const discovered = [];
478
+ const entries = readdirSync(validatedPluginsDir, { withFileTypes: true });
479
+
480
+ for (const entry of entries) {
481
+ if (!entry.isDirectory()) continue;
482
+
483
+ // Validate plugin directory name
484
+ const nameValidation = validatePluginName(entry.name);
485
+ if (!nameValidation.valid) {
486
+ logger.warn(`Skipping plugin directory with invalid name '${entry.name}': ${nameValidation.error}`);
487
+ continue;
488
+ }
489
+
490
+ const pluginPath = join(validatedPluginsDir, entry.name);
491
+
492
+ // Validate the plugin path is within allowed directory
493
+ const pathValidation = validatePluginPath(entry.name, {
494
+ allowedDirs: [validatedPluginsDir],
495
+ allowAbsolute: false,
496
+ mustExist: true,
497
+ expectedType: 'directory',
498
+ });
499
+
500
+ if (!pathValidation.valid) {
501
+ logger.warn(`Skipping plugin with unsafe path '${entry.name}': ${pathValidation.error}`);
502
+ continue;
503
+ }
504
+
505
+ const manifestPath = join(pluginPath, 'plugin.json');
506
+ const indexPath = join(pluginPath, 'index.js');
507
+
508
+ // Validate manifest and index paths are within plugin directory
509
+ const manifestValidation = validatePluginPath('plugin.json', {
510
+ allowedDirs: [pluginPath],
511
+ allowAbsolute: false,
512
+ mustExist: true,
513
+ expectedType: 'file',
514
+ });
515
+
516
+ if (!manifestValidation.valid) {
517
+ logger.warn(`Plugin '${entry.name}' has invalid manifest path: ${manifestValidation.error}`);
518
+ continue;
519
+ }
520
+
521
+ const indexValidation = validatePluginPath('index.js', {
522
+ allowedDirs: [pluginPath],
523
+ allowAbsolute: false,
524
+ mustExist: true,
525
+ expectedType: 'file',
526
+ });
527
+
528
+ if (!indexValidation.valid) {
529
+ logger.warn(`Plugin '${entry.name}' has invalid entry point: ${indexValidation.error}`);
530
+ continue;
531
+ }
532
+
533
+ if (!existsSync(manifestPath) || !existsSync(indexPath)) {
534
+ continue;
535
+ }
536
+
537
+ try {
538
+ const manifest = JSON.parse(await import('fs').then(m => m.readFileSync(manifestPath, 'utf8')));
539
+
540
+ if (manifest.type !== 'widget') continue;
541
+
542
+ // Validate manifest against schema
543
+ const validation = validateManifest(manifest);
544
+ if (!validation.valid) {
545
+ const pluginError = PluginErrorAnalyzer.analyze(
546
+ new Error(validation.errors.join(', ')),
547
+ manifest.id || entry.name,
548
+ { phase: 'manifest', manifest }
549
+ );
550
+ logger.warn(pluginError.getFormattedMessage());
551
+ continue;
552
+ }
553
+
554
+ discovered.push({
555
+ id: manifest.id || entry.name,
556
+ manifest,
557
+ path: pluginPath,
558
+ entryPoint: indexPath,
559
+ });
560
+ } catch (err) {
561
+ if (err instanceof PluginError) {
562
+ logger.warn(err.getFormattedMessage());
563
+ } else {
564
+ const pluginError = PluginErrorAnalyzer.analyze(err, entry.name, { phase: 'manifest' });
565
+ logger.warn(pluginError.getFormattedMessage());
566
+ }
567
+ }
568
+ }
569
+
570
+ return discovered;
571
+ }
572
+
573
+ /**
574
+ * Load and register a plugin
575
+ * @param {string} pluginPath - Path to plugin directory
576
+ * @param {Object} options - Load options
577
+ * @param {boolean} options.sanitize - Whether to sanitize config (default: true)
578
+ * @param {boolean} options.fallbackOnError - Fall back to defaults on error (default: true)
579
+ */
580
+ async loadPlugin(pluginPath, options = {}) {
581
+ const {
582
+ sanitize = true,
583
+ fallbackOnError = true,
584
+ eager = true, // Default to eager loading (load immediately after register)
585
+ } = options;
586
+
587
+ // Validate plugin path before processing
588
+ const pathValidation = validatePluginPath(pluginPath, {
589
+ allowedDirs: [this.pluginsDir],
590
+ allowAbsolute: true,
591
+ mustExist: true,
592
+ expectedType: 'directory',
593
+ });
594
+
595
+ if (!pathValidation.valid) {
596
+ throw new Error(`Invalid plugin path: ${pathValidation.error}`);
597
+ }
598
+
599
+ // Use the validated, resolved path
600
+ const validatedPluginPath = pathValidation.path;
601
+ const manifestPath = join(validatedPluginPath, 'plugin.json');
602
+ const indexPath = join(validatedPluginPath, 'index.js');
603
+
604
+ // Validate manifest and index paths
605
+ const manifestValidation = validatePluginPath(manifestPath, {
606
+ allowedDirs: [validatedPluginPath],
607
+ allowAbsolute: true,
608
+ mustExist: true,
609
+ expectedType: 'file',
610
+ });
611
+
612
+ if (!manifestValidation.valid) {
613
+ const error = new Error(`Invalid manifest path: ${manifestValidation.error}`);
614
+ if (fallbackOnError) {
615
+ logger.warn(`Failed to load plugin at ${validatedPluginPath}: ${error.message}`);
616
+ return null;
617
+ }
618
+ throw error;
619
+ }
620
+
621
+ const indexValidation = validatePluginPath(indexPath, {
622
+ allowedDirs: [validatedPluginPath],
623
+ allowAbsolute: true,
624
+ mustExist: true,
625
+ expectedType: 'file',
626
+ });
627
+
628
+ if (!indexValidation.valid) {
629
+ const error = new Error(`Invalid entry point path: ${indexValidation.error}`);
630
+ if (fallbackOnError) {
631
+ logger.warn(`Failed to load plugin at ${validatedPluginPath}: ${error.message}`);
632
+ return null;
633
+ }
634
+ throw error;
635
+ }
636
+
637
+ if (!existsSync(manifestPath)) {
638
+ const pluginError = new PluginError(
639
+ PLUGIN_ERROR_CODES.MANIFEST_NOT_FOUND,
640
+ `Plugin manifest not found at ${validatedPluginPath}`,
641
+ { pluginId: basename(validatedPluginPath) }
642
+ );
643
+ throw pluginError;
644
+ }
645
+
646
+ let manifest;
647
+ try {
648
+ const manifestContent = await import('fs').then(m => m.readFileSync(manifestPath, 'utf8'));
649
+ manifest = JSON.parse(manifestContent);
650
+ } catch (err) {
651
+ const pluginError = PluginErrorAnalyzer.analyze(err, basename(validatedPluginPath), {
652
+ phase: 'manifest',
653
+ path: validatedPluginPath,
654
+ });
655
+ if (fallbackOnError) {
656
+ logger.warn(pluginError.getFormattedMessage());
657
+ return null;
658
+ }
659
+ throw pluginError;
660
+ }
661
+
662
+ // Validate manifest against schema
663
+ const validation = validateManifest(manifest);
664
+ if (!validation.valid) {
665
+ const pluginError = PluginErrorAnalyzer.analyze(
666
+ new Error(`Validation failed: ${validation.errors.join(', ')}`),
667
+ manifest.id || basename(validatedPluginPath),
668
+ { phase: 'manifest', manifest }
669
+ );
670
+ if (fallbackOnError) {
671
+ logger.warn(pluginError.getFormattedMessage());
672
+ return null;
673
+ }
674
+ throw pluginError;
675
+ }
676
+
677
+ // Validate manifest has required fields
678
+ if (!manifest.id && !manifest.name) {
679
+ manifest.id = basename(validatedPluginPath);
680
+ }
681
+
682
+ const id = manifest.id || basename(validatedPluginPath);
683
+
684
+ // Store plugin path in metadata for hot-reload support
685
+ manifest._pluginPath = validatedPluginPath;
686
+ manifest._manifestPath = manifestPath;
687
+ manifest._indexPath = indexPath;
688
+
689
+ // Process and sanitize plugin config
690
+ // Extract defaults from schema definition if config has schema format
691
+ let processedConfig = extractDefaultsFromSchema(manifest.config);
692
+
693
+ // Then apply env interpolation
694
+ if (manifest.config) {
695
+ const processingResult = processWidgetConfig(processedConfig, {
696
+ interpolateEnv: true,
697
+ validateVersion: false,
698
+ supportLegacy: true,
699
+ throwOnError: false,
700
+ });
701
+
702
+ if (processingResult.success) {
703
+ processedConfig = processingResult.config;
704
+ if (processingResult.warnings) {
705
+ processingResult.warnings.forEach(warning => {
706
+ logger.debug(`[${id}] ${warning}`);
707
+ });
708
+ }
709
+ }
710
+
711
+ // Apply security sanitization
712
+ if (sanitize) {
713
+ try {
714
+ processedConfig = sanitizeWidgetConfig(processedConfig);
715
+ } catch (err) {
716
+ logger.warn(`Failed to sanitize config for plugin '${id}': ${err.message}, using processed config`);
717
+ }
718
+ }
719
+ }
720
+
721
+ // Create loader function with error handling
722
+ const loader = async () => {
723
+ try {
724
+ const module = await import(pathToFileURL(indexPath).href);
725
+
726
+ // Support both default export and named exports
727
+ const WidgetClass = module.default || module.Widget || module;
728
+
729
+ if (typeof WidgetClass === 'function') {
730
+ return new WidgetClass(processedConfig);
731
+ }
732
+
733
+ // Handle invalid export
734
+ const pluginError = new PluginError(
735
+ PLUGIN_ERROR_CODES.ENTRY_INVALID_EXPORT,
736
+ `Plugin "${id}" does not export a valid widget class`,
737
+ {
738
+ pluginId: id,
739
+ exportType: typeof WidgetClass,
740
+ hasDefault: !!module.default,
741
+ hasNamed: !!module.Widget,
742
+ }
743
+ );
744
+ throw pluginError;
745
+ } catch (err) {
746
+ if (err instanceof PluginError) {
747
+ throw err;
748
+ }
749
+ const pluginError = PluginErrorAnalyzer.analyze(err, id, {
750
+ phase: 'entry',
751
+ path: indexPath,
752
+ });
753
+ throw pluginError;
754
+ }
755
+ };
756
+
757
+ this.register(id, manifest, loader);
758
+
759
+ // Auto-load based on options:
760
+ // - If eager: false, don't load (lazy mode - caller must call load() later)
761
+ // - If manifest.lazyLoad: true, don't load (plugin declares itself as lazy)
762
+ // - Otherwise, load immediately
763
+ const shouldLoad = eager !== false && manifest.lazyLoad !== true;
764
+
765
+ if (shouldLoad) {
766
+ try {
767
+ await this.load(id);
768
+ } catch (err) {
769
+ if (fallbackOnError) {
770
+ logger.warn(`Failed to auto-load plugin '${id}': ${err.message}`);
771
+ // Note: We still return the ID for backward compatibility
772
+ // The widget is registered, even if loading failed
773
+ // Callers can check isLoaded() to verify loading succeeded
774
+ } else {
775
+ throw err;
776
+ }
777
+ }
778
+ }
779
+
780
+ return id;
781
+ }
782
+
783
+ /**
784
+ * Load all discovered plugins with error handling and fallback
785
+ * Uses dependency resolution to ensure correct load order
786
+ * @param {Object} options - Load options
787
+ * @param {boolean} [options.resolveDependencies=true] - Whether to resolve and load in dependency order
788
+ * @param {boolean} [options.allowPartial=false] - Allow partial loading when dependencies are missing
789
+ * @returns {Object} Results with successful and failed plugin IDs
790
+ */
791
+ async loadAllPluginsWithFallback(options = {}) {
792
+ const {
793
+ sanitize = true,
794
+ fallbackOnError = true,
795
+ continueOnError = true,
796
+ resolveDependencies: shouldResolveDeps = true,
797
+ allowPartial = false,
798
+ } = options;
799
+
800
+ const discovered = await this.discoverPlugins();
801
+ const results = {
802
+ successful: [],
803
+ failed: [],
804
+ skipped: [],
805
+ dependencyErrors: [],
806
+ };
807
+
808
+ // First pass: register all discovered plugins without loading
809
+ for (const plugin of discovered) {
810
+ try {
811
+ // Register only - don't load yet so we can resolve dependencies
812
+ const id = await this.registerPlugin(plugin.path, { sanitize, fallbackOnError });
813
+ if (id) {
814
+ // Track as successfully registered (will be loaded in second pass)
815
+ if (!results.successful.includes(id)) {
816
+ results.successful.push(id);
817
+ }
818
+ } else {
819
+ results.skipped.push(plugin.id);
820
+ // Remove from successful if it was added
821
+ const idx = results.successful.indexOf(plugin.id);
822
+ if (idx > -1) results.successful.splice(idx, 1);
823
+ }
824
+ } catch (err) {
825
+ results.failed.push({ id: plugin.id, error: err.message });
826
+ // Remove from successful if it was added
827
+ const idx = results.successful.indexOf(plugin.id);
828
+ if (idx > -1) results.successful.splice(idx, 1);
829
+ logger.warn(`Plugin '${plugin.id}' failed to register: ${err.message}`);
830
+ }
831
+ }
832
+
833
+ // Second pass: resolve dependencies and load in order
834
+ if (shouldResolveDeps && this.widgetRegistry.size > 0) {
835
+ const resolution = resolveDependencies(this.widgetRegistry, {
836
+ allowPartial,
837
+ });
838
+
839
+ if (!resolution.success) {
840
+ results.dependencyErrors.push({
841
+ error: resolution.error,
842
+ circularPath: resolution.circularPath,
843
+ missingDeps: resolution.missingDeps,
844
+ constraintViolations: resolution.constraintViolations,
845
+ });
846
+
847
+ // Create enhanced error for dependency issues
848
+ // missingDeps is an Object mapping widget ID to missing dependency IDs array
849
+ const missingDepIds = resolution.missingDeps
850
+ ? Object.entries(resolution.missingDeps)
851
+ .map(([id, deps]) => `${id}(${deps.join(', ')})`)
852
+ .join('; ')
853
+ : 'unknown';
854
+ const depError = new PluginError(
855
+ resolution.circularPath ? PLUGIN_ERROR_CODES.DEPENDENCY_CIRCULAR : PLUGIN_ERROR_CODES.DEPENDENCY_MISSING,
856
+ resolution.error,
857
+ {
858
+ pluginId: missingDepIds,
859
+ circularPath: resolution.circularPath,
860
+ missingDeps: resolution.missingDeps,
861
+ }
862
+ );
863
+
864
+ if (!continueOnError) {
865
+ logger.error(depError.getFormattedMessage());
866
+ return results;
867
+ }
868
+
869
+ logger.warn(depError.getFormattedMessage());
870
+ }
871
+
872
+ // Load in dependency order
873
+ for (const id of resolution.order) {
874
+ const widget = this.widgetRegistry.get(id);
875
+ if (!widget || widget.loaded) continue;
876
+
877
+ try {
878
+ await this.load(id);
879
+ results.successful.push(id);
880
+ } catch (err) {
881
+ results.failed.push({ id, error: err.message });
882
+ logger.warn(`Widget '${id}' failed to load: ${err.message}`);
883
+
884
+ if (!continueOnError && !fallbackOnError) {
885
+ break;
886
+ }
887
+ }
888
+ }
889
+ } else {
890
+ // Fallback: load without dependency resolution (original behavior)
891
+ for (const plugin of discovered) {
892
+ // Skip already registered ones
893
+ if (this.widgetRegistry.has(plugin.id)) continue;
894
+
895
+ try {
896
+ const id = await this.loadPlugin(plugin.path, { sanitize, fallbackOnError });
897
+ if (id) {
898
+ results.successful.push(id);
899
+ } else {
900
+ results.skipped.push(plugin.id);
901
+ }
902
+ } catch (err) {
903
+ results.failed.push({ id: plugin.id, error: err.message });
904
+ logger.warn(`Plugin '${plugin.id}' failed to load: ${err.message}`);
905
+
906
+ if (!continueOnError && !fallbackOnError) {
907
+ break;
908
+ }
909
+ }
910
+ }
911
+ }
912
+
913
+ logger.debug(`Plugin loading complete: ${results.successful.length} loaded, ${results.failed.length} failed, ${results.skipped.length} skipped`);
914
+ return results;
915
+ }
916
+
917
+ /**
918
+ * Register a plugin without loading it (for dependency resolution)
919
+ * @param {string} pluginPath - Path to plugin directory
920
+ * @param {Object} options - Registration options
921
+ * @returns {string|null} Plugin ID or null if skipped
922
+ */
923
+ async registerPlugin(pluginPath, options = {}) {
924
+ const { sanitize = true, fallbackOnError = true } = options;
925
+
926
+ // Validate plugin path
927
+ const pathValidation = validatePluginPath(pluginPath, {
928
+ allowedDirs: [this.pluginsDir],
929
+ allowAbsolute: true,
930
+ mustExist: true,
931
+ expectedType: 'directory',
932
+ });
933
+
934
+ if (!pathValidation.valid) {
935
+ throw new Error(`Invalid plugin path: ${pathValidation.error}`);
936
+ }
937
+
938
+ const validatedPluginPath = pathValidation.path;
939
+ const manifestPath = join(validatedPluginPath, 'plugin.json');
940
+ const indexPath = join(validatedPluginPath, 'index.js');
941
+
942
+ if (!existsSync(manifestPath)) {
943
+ return null;
944
+ }
945
+
946
+ let manifest;
947
+ try {
948
+ const manifestContent = await import('fs').then(m => m.readFileSync(manifestPath, 'utf8'));
949
+ manifest = JSON.parse(manifestContent);
950
+ } catch (err) {
951
+ const pluginError = PluginErrorAnalyzer.analyze(err, basename(validatedPluginPath), {
952
+ phase: 'manifest',
953
+ path: validatedPluginPath,
954
+ });
955
+ if (fallbackOnError) {
956
+ logger.warn(pluginError.getFormattedMessage());
957
+ return null;
958
+ }
959
+ throw pluginError;
960
+ }
961
+
962
+ // Validate manifest against schema
963
+ const validation = validateManifest(manifest);
964
+ if (!validation.valid) {
965
+ const pluginError = PluginErrorAnalyzer.analyze(
966
+ new Error(`Validation failed: ${validation.errors.join(', ')}`),
967
+ manifest.id || basename(validatedPluginPath),
968
+ { phase: 'manifest', manifest }
969
+ );
970
+ if (fallbackOnError) {
971
+ logger.warn(pluginError.getFormattedMessage());
972
+ return null;
973
+ }
974
+ throw pluginError;
975
+ }
976
+
977
+ const id = manifest.id || basename(validatedPluginPath);
978
+
979
+ // Store plugin path in metadata for hot-reload support
980
+ manifest._pluginPath = validatedPluginPath;
981
+ manifest._manifestPath = manifestPath;
982
+ manifest._indexPath = indexPath;
983
+
984
+ // Process and sanitize plugin config
985
+ let processedConfig = {};
986
+ if (manifest.config) {
987
+ const processingResult = processWidgetConfig(manifest.config, {
988
+ interpolateEnv: true,
989
+ validateVersion: true,
990
+ supportLegacy: true,
991
+ throwOnError: false,
992
+ });
993
+
994
+ if (processingResult.success) {
995
+ processedConfig = processingResult.config;
996
+ }
997
+
998
+ if (sanitize) {
999
+ try {
1000
+ processedConfig = sanitizeWidgetConfig(processedConfig);
1001
+ } catch (err) {
1002
+ logger.warn(`Failed to sanitize config for plugin '${id}': ${err.message}`);
1003
+ }
1004
+ }
1005
+ }
1006
+
1007
+ // Create loader function
1008
+ const loader = async () => {
1009
+ try {
1010
+ const module = await import(pathToFileURL(indexPath).href);
1011
+ const WidgetClass = module.default || module.Widget || module;
1012
+
1013
+ if (typeof WidgetClass === 'function') {
1014
+ return new WidgetClass(processedConfig);
1015
+ }
1016
+
1017
+ return WidgetClass;
1018
+ } catch (err) {
1019
+ logger.error(`Failed to load plugin '${id}': ${err.message}`);
1020
+ throw err;
1021
+ }
1022
+ };
1023
+
1024
+ this.register(id, manifest, loader);
1025
+ return id;
1026
+ }
1027
+
1028
+ /**
1029
+ * Load widgets in dependency order
1030
+ * @param {string[]} ids - Widget IDs to load (loads all registered if empty)
1031
+ * @param {Object} options - Load options
1032
+ * @returns {Promise<Object>} Loading results
1033
+ */
1034
+ async loadInDependencyOrder(ids = null, options = {}) {
1035
+ const { allowPartial = false, continueOnError = true } = options;
1036
+
1037
+ const targetIds = ids || Array.from(this.widgetRegistry.keys());
1038
+
1039
+ const resolution = resolveDependencies(this.widgetRegistry, {
1040
+ targetIds,
1041
+ allowPartial,
1042
+ });
1043
+
1044
+ const results = {
1045
+ successful: [],
1046
+ failed: [],
1047
+ skipped: [],
1048
+ resolution,
1049
+ };
1050
+
1051
+ if (!resolution.success) {
1052
+ logger.error(`Dependency resolution failed: ${resolution.error}`);
1053
+ return results;
1054
+ }
1055
+
1056
+ for (const id of resolution.order) {
1057
+ const widget = this.widgetRegistry.get(id);
1058
+ if (!widget || widget.loaded) continue;
1059
+
1060
+ try {
1061
+ await this.load(id);
1062
+ results.successful.push(id);
1063
+ } catch (err) {
1064
+ results.failed.push({ id, error: err.message });
1065
+ if (!continueOnError) break;
1066
+ }
1067
+ }
1068
+
1069
+ return results;
1070
+ }
1071
+
1072
+ /**
1073
+ * Get dependency information for a widget
1074
+ * @param {string} id - Widget ID
1075
+ * @returns {Object|null} Dependency information
1076
+ */
1077
+ getDependencyInfo(id) {
1078
+ const widget = this.widgetRegistry.get(id);
1079
+ if (!widget) return null;
1080
+
1081
+ const validation = validateWidgetDependencies(this.widgetRegistry, id);
1082
+ const graph = buildDependencyGraph(this.widgetRegistry);
1083
+
1084
+ return {
1085
+ id,
1086
+ dependencies: widget.metadata.dependencies || [],
1087
+ allDependencies: getAllDependencies(graph, id),
1088
+ dependents: getAllDependents(graph, id),
1089
+ validation,
1090
+ };
1091
+ }
1092
+
1093
+ /**
1094
+ * Get the full dependency graph
1095
+ * @returns {Object} Dependency graph representation
1096
+ */
1097
+ getDependencyGraph() {
1098
+ const graph = buildDependencyGraph(this.widgetRegistry);
1099
+ const result = {};
1100
+
1101
+ for (const [id, node] of graph) {
1102
+ result[id] = {
1103
+ id,
1104
+ dependencies: node.dependencies.map(d => ({
1105
+ id: d.id,
1106
+ optional: d.optional,
1107
+ version: d.version,
1108
+ })),
1109
+ dependents: Array.from(node.dependents),
1110
+ };
1111
+ }
1112
+
1113
+ return result;
1114
+ }
1115
+
1116
+ /**
1117
+ * Validate dependencies for one or all widgets
1118
+ * @param {string} [id] - Specific widget ID (validates all if omitted)
1119
+ * @returns {Object} Validation results
1120
+ */
1121
+ validateDependencies(id = null) {
1122
+ if (id) {
1123
+ return {
1124
+ [id]: validateWidgetDependencies(this.widgetRegistry, id),
1125
+ };
1126
+ }
1127
+
1128
+ const results = {};
1129
+ for (const [widgetId] of this.widgetRegistry) {
1130
+ results[widgetId] = validateWidgetDependencies(this.widgetRegistry, widgetId);
1131
+ }
1132
+ return results;
1133
+ }
1134
+
1135
+ /**
1136
+ * Get loading statistics
1137
+ */
1138
+ getStats() {
1139
+ const all = Array.from(this.widgetRegistry.values());
1140
+ return {
1141
+ total: all.length,
1142
+ loaded: all.filter(w => w.loaded).length,
1143
+ failed: all.filter(w => w.error).length,
1144
+ loading: this.loadPromises.size,
1145
+ averageLoadTime: all.filter(w => w.loadTime).reduce((sum, w) => sum + w.loadTime, 0) / all.filter(w => w.loadTime).length || 0,
1146
+ };
1147
+ }
1148
+
1149
+ /**
1150
+ * Clear all widgets
1151
+ */
1152
+ async clear() {
1153
+ const ids = Array.from(this.loadedWidgets.keys());
1154
+ await Promise.all(ids.map(id => this.unload(id)));
1155
+ this.widgetRegistry.clear();
1156
+ this.loadedWidgets.clear();
1157
+ this.loadPromises.clear();
1158
+ }
1159
+
1160
+ /**
1161
+ * Enable hot-reload for widget configurations
1162
+ * Watches plugin.json files and reloads widgets when changed
1163
+ * @param {Object} options - Hot-reload options
1164
+ * @param {number} options.debounceMs - Debounce interval for changes (default: 500)
1165
+ * @param {boolean} options.usePolling - Use polling instead of native events (default: false)
1166
+ * @param {boolean} options.reloadWidgets - Automatically reload widgets when config changes (default: true)
1167
+ * @returns {ConfigWatcher|null} The config watcher instance or null if disabled
1168
+ */
1169
+ enableConfigHotReload(options = {}) {
1170
+ const {
1171
+ debounceMs = 500,
1172
+ usePolling = false,
1173
+ reloadWidgets = true,
1174
+ } = options;
1175
+
1176
+ // Don't enable if already watching
1177
+ if (this.configWatcher) {
1178
+ logger.debug('Config hot-reload already enabled');
1179
+ return this.configWatcher;
1180
+ }
1181
+
1182
+ // Create watcher
1183
+ this.configWatcher = new ConfigWatcher({
1184
+ debounceMs,
1185
+ usePolling,
1186
+ });
1187
+
1188
+ // Track reload stats
1189
+ this._reloadStats = {
1190
+ reloads: 0,
1191
+ errors: 0,
1192
+ lastReload: null,
1193
+ };
1194
+
1195
+ // Listen for reload events
1196
+ this.configWatcher.on('reload', async ({ filePath, timestamp }) => {
1197
+ try {
1198
+ // Find which widget this config belongs to
1199
+ const widgetId = this._findWidgetIdByConfigPath(filePath);
1200
+ if (!widgetId) {
1201
+ logger.debug(`Config reload: Could not find widget for ${filePath}`);
1202
+ return;
1203
+ }
1204
+
1205
+ logger.info(`Config hot-reload triggered for widget: ${widgetId}`);
1206
+
1207
+ // Read and process new config
1208
+ const reloadResult = await this._reloadWidgetConfig(widgetId, filePath);
1209
+
1210
+ if (reloadResult.success) {
1211
+ this._reloadStats.reloads++;
1212
+ this._reloadStats.lastReload = { widgetId, timestamp };
1213
+ logger.info(`Config hot-reload successful for ${widgetId}`);
1214
+
1215
+ // Emit event for external listeners
1216
+ this.emit?.('configReloaded', { widgetId, timestamp, config: reloadResult.config });
1217
+ } else {
1218
+ this._reloadStats.errors++;
1219
+ logger.error(`Config hot-reload failed for ${widgetId}: ${reloadResult.error}`);
1220
+
1221
+ // Emit error event
1222
+ this.emit?.('configReloadError', { widgetId, error: reloadResult.error, timestamp });
1223
+ }
1224
+ } catch (err) {
1225
+ this._reloadStats.errors++;
1226
+ logger.error(`Config hot-reload error: ${err.message}`);
1227
+ this.emit?.('configReloadError', { filePath, error: err.message, timestamp });
1228
+ }
1229
+ });
1230
+
1231
+ // Handle watcher errors
1232
+ this.configWatcher.on('error', ({ filePath, error }) => {
1233
+ this._reloadStats.errors++;
1234
+ logger.error(`Config watcher error for ${filePath}: ${error.message}`);
1235
+ this.emit?.('configWatcherError', { filePath, error: error.message });
1236
+ });
1237
+
1238
+ // Start watching all registered widgets' plugin.json files
1239
+ this._startWatchingWidgetConfigs();
1240
+
1241
+ logger.info('Widget config hot-reload enabled');
1242
+ return this.configWatcher;
1243
+ }
1244
+
1245
+ /**
1246
+ * Disable config hot-reload
1247
+ */
1248
+ disableConfigHotReload() {
1249
+ if (this.configWatcher) {
1250
+ this.configWatcher.unwatchAll();
1251
+ this.configWatcher = null;
1252
+ logger.info('Widget config hot-reload disabled');
1253
+ }
1254
+ }
1255
+
1256
+ /**
1257
+ * Check if hot-reload is enabled
1258
+ * @returns {boolean}
1259
+ */
1260
+ isConfigHotReloadEnabled() {
1261
+ return !!this.configWatcher;
1262
+ }
1263
+
1264
+ /**
1265
+ * Get hot-reload statistics
1266
+ * @returns {Object} Stats object
1267
+ */
1268
+ getHotReloadStats() {
1269
+ return {
1270
+ enabled: this.isConfigHotReloadEnabled(),
1271
+ ...this._reloadStats,
1272
+ watchedFiles: this.configWatcher?.getWatchedFiles().length || 0,
1273
+ };
1274
+ }
1275
+
1276
+ /**
1277
+ * Find widget ID by its config file path
1278
+ * @private
1279
+ * @param {string} configPath - Path to config file
1280
+ * @returns {string|null} Widget ID or null
1281
+ */
1282
+ _findWidgetIdByConfigPath(configPath) {
1283
+ for (const [id, widget] of this.widgetRegistry) {
1284
+ // Check if the widget has a plugin path that matches
1285
+ if (widget.metadata?._pluginPath) {
1286
+ const expectedPath = join(widget.metadata._pluginPath, 'plugin.json');
1287
+ if (configPath === expectedPath || configPath.endsWith(expectedPath)) {
1288
+ return id;
1289
+ }
1290
+ }
1291
+ }
1292
+ return null;
1293
+ }
1294
+
1295
+ /**
1296
+ * Reload widget configuration from file
1297
+ * @private
1298
+ * @param {string} widgetId - Widget ID
1299
+ * @param {string} filePath - Path to plugin.json
1300
+ * @returns {Object} Reload result { success: boolean, config?: Object, error?: string }
1301
+ */
1302
+ async _reloadWidgetConfig(widgetId, filePath) {
1303
+ const widget = this.widgetRegistry.get(widgetId);
1304
+ if (!widget) {
1305
+ return { success: false, error: 'Widget not found in registry' };
1306
+ }
1307
+
1308
+ try {
1309
+ // Read new manifest
1310
+ const fs = await import('fs');
1311
+ const manifestContent = fs.readFileSync(filePath, 'utf8');
1312
+ const manifest = JSON.parse(manifestContent);
1313
+
1314
+ // Validate manifest
1315
+ const validation = validateManifest(manifest);
1316
+ if (!validation.valid) {
1317
+ return { success: false, error: `Manifest validation failed: ${validation.errors.join(', ')}` };
1318
+ }
1319
+
1320
+ // Process new config
1321
+ let newConfig = {};
1322
+ if (manifest.config) {
1323
+ const processingResult = processWidgetConfig(manifest.config, {
1324
+ interpolateEnv: true,
1325
+ validateVersion: true,
1326
+ supportLegacy: true,
1327
+ throwOnError: false,
1328
+ });
1329
+
1330
+ if (!processingResult.success) {
1331
+ return { success: false, error: `Config processing failed: ${processingResult.error}` };
1332
+ }
1333
+
1334
+ newConfig = processingResult.config;
1335
+
1336
+ // Sanitize the new config
1337
+ try {
1338
+ newConfig = sanitizeWidgetConfig(newConfig);
1339
+ } catch (err) {
1340
+ return { success: false, error: `Config sanitization failed: ${err.message}` };
1341
+ }
1342
+ }
1343
+
1344
+ // Update widget metadata
1345
+ widget.metadata = {
1346
+ ...widget.metadata,
1347
+ ...manifest,
1348
+ config: newConfig,
1349
+ };
1350
+
1351
+ // Update widget instance config if loaded
1352
+ if (widget.loaded && widget.instance) {
1353
+ // Update instance config
1354
+ if (widget.instance.config) {
1355
+ widget.instance.config = newConfig;
1356
+ } else {
1357
+ widget.instance.config = newConfig;
1358
+ }
1359
+
1360
+ // Call onConfigChange if widget supports it
1361
+ if (typeof widget.instance.onConfigChange === 'function') {
1362
+ try {
1363
+ await widget.instance.onConfigChange(newConfig, widget.instance.config);
1364
+ } catch (err) {
1365
+ logger.warn(`Widget ${widgetId} onConfigChange failed: ${err.message}`);
1366
+ }
1367
+ }
1368
+ }
1369
+
1370
+ return { success: true, config: newConfig };
1371
+ } catch (err) {
1372
+ return { success: false, error: err.message };
1373
+ }
1374
+ }
1375
+
1376
+ /**
1377
+ * Start watching all widget config files
1378
+ * @private
1379
+ */
1380
+ _startWatchingWidgetConfigs() {
1381
+ if (!this.configWatcher) return;
1382
+
1383
+ for (const [id, widget] of this.widgetRegistry) {
1384
+ if (widget.metadata?._pluginPath) {
1385
+ const configPath = join(widget.metadata._pluginPath, 'plugin.json');
1386
+ this.configWatcher.watchFile(configPath);
1387
+ }
1388
+ }
1389
+ }
1390
+
1391
+ /**
1392
+ * Watch a specific widget's config file
1393
+ * @param {string} widgetId - Widget ID to watch
1394
+ * @returns {boolean} True if watching started
1395
+ */
1396
+ watchWidgetConfig(widgetId) {
1397
+ if (!this.configWatcher) {
1398
+ logger.warn('Config hot-reload not enabled, call enableConfigHotReload() first');
1399
+ return false;
1400
+ }
1401
+
1402
+ const widget = this.widgetRegistry.get(widgetId);
1403
+ if (!widget?.metadata?._pluginPath) {
1404
+ logger.warn(`Widget ${widgetId} does not have a plugin path to watch`);
1405
+ return false;
1406
+ }
1407
+
1408
+ const configPath = join(widget.metadata._pluginPath, 'plugin.json');
1409
+ return this.configWatcher.watchFile(configPath);
1410
+ }
1411
+
1412
+ /**
1413
+ * Stop watching a specific widget's config file
1414
+ * @param {string} widgetId - Widget ID to unwatch
1415
+ */
1416
+ unwatchWidgetConfig(widgetId) {
1417
+ if (!this.configWatcher) return;
1418
+
1419
+ const widget = this.widgetRegistry.get(widgetId);
1420
+ if (!widget?.metadata?._pluginPath) return;
1421
+
1422
+ const configPath = join(widget.metadata._pluginPath, 'plugin.json');
1423
+ this.configWatcher.unwatchFile(configPath);
1424
+ }
1425
+ }
1426
+
1427
+ // Singleton instance
1428
+ let defaultLoader = null;
1429
+
1430
+ export function getWidgetLoader(options) {
1431
+ if (!defaultLoader) {
1432
+ defaultLoader = new WidgetLoader(options);
1433
+ }
1434
+ return defaultLoader;
1435
+ }
1436
+
1437
+ export default WidgetLoader;