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,219 @@
1
+ /**
2
+ * Plugin manifest validation module
3
+ * Validates plugin.json files against the JSON Schema
4
+ */
5
+
6
+ import { readFileSync } from 'fs';
7
+ import { fileURLToPath } from 'url';
8
+ import { dirname, join } from 'path';
9
+
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = dirname(__filename);
12
+
13
+ // Load the schema
14
+ const schemaPath = join(__dirname, '..', 'schemas', 'plugin-manifest.json');
15
+ let schema;
16
+
17
+ try {
18
+ schema = JSON.parse(readFileSync(schemaPath, 'utf8'));
19
+ } catch (err) {
20
+ throw new Error(`Failed to load plugin manifest schema: ${err.message}`);
21
+ }
22
+
23
+ /**
24
+ * Validates a value against a JSON Schema type
25
+ * @param {*} value - Value to validate
26
+ * @param {string} type - Expected type
27
+ * @returns {boolean}
28
+ */
29
+ function validateType(value, type) {
30
+ if (type === 'string') return typeof value === 'string';
31
+ if (type === 'number') return typeof value === 'number' && !isNaN(value);
32
+ if (type === 'boolean') return typeof value === 'boolean';
33
+ if (type === 'object') return typeof value === 'object' && value !== null && !Array.isArray(value);
34
+ if (type === 'array') return Array.isArray(value);
35
+ return true;
36
+ }
37
+
38
+ /**
39
+ * Validates a string against a regex pattern
40
+ * @param {string} value - Value to validate
41
+ * @param {string} pattern - Regex pattern
42
+ * @returns {boolean}
43
+ */
44
+ function validatePattern(value, pattern) {
45
+ const regex = new RegExp(pattern);
46
+ return regex.test(value);
47
+ }
48
+
49
+ /**
50
+ * Validates semantic version format
51
+ * @param {string} version - Version string
52
+ * @returns {boolean}
53
+ */
54
+ function validateSemver(version) {
55
+ const semverPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
56
+ return semverPattern.test(version);
57
+ }
58
+
59
+ /**
60
+ * Validates a plugin ID format
61
+ * @param {string} id - Plugin ID
62
+ * @returns {boolean}
63
+ */
64
+ function validatePluginId(id) {
65
+ const idPattern = /^[a-zA-Z0-9]([a-zA-Z0-9_-]*[a-zA-Z0-9])?$/;
66
+ return idPattern.test(id);
67
+ }
68
+
69
+ /**
70
+ * Validates a plugin manifest against the schema
71
+ * @param {object} manifest - Plugin manifest to validate
72
+ * @returns {object} Validation result with { valid: boolean, errors: string[] }
73
+ */
74
+ export function validateManifest(manifest) {
75
+ const errors = [];
76
+
77
+ if (!manifest || typeof manifest !== 'object') {
78
+ return { valid: false, errors: ['Manifest must be a valid object'] };
79
+ }
80
+
81
+ // Check required fields
82
+ const required = schema.required || [];
83
+ for (const field of required) {
84
+ if (!(field in manifest)) {
85
+ errors.push(`Missing required field: ${field}`);
86
+ }
87
+ }
88
+
89
+ // Validate each property
90
+ const properties = schema.properties || {};
91
+ for (const [key, value] of Object.entries(manifest)) {
92
+ const propSchema = properties[key];
93
+ if (!propSchema) {
94
+ if (schema.additionalProperties === false) {
95
+ errors.push(`Unknown property: ${key}`);
96
+ }
97
+ continue;
98
+ }
99
+
100
+ // Type validation
101
+ if (propSchema.type && !validateType(value, propSchema.type)) {
102
+ errors.push(`Invalid type for ${key}: expected ${propSchema.type}, got ${typeof value}`);
103
+ continue;
104
+ }
105
+
106
+ // String validations
107
+ if (propSchema.type === 'string') {
108
+ if (propSchema.minLength !== undefined && value.length < propSchema.minLength) {
109
+ errors.push(`${key} must be at least ${propSchema.minLength} characters`);
110
+ }
111
+ if (propSchema.maxLength !== undefined && value.length > propSchema.maxLength) {
112
+ errors.push(`${key} must be at most ${propSchema.maxLength} characters`);
113
+ }
114
+ if (propSchema.pattern && !validatePattern(value, propSchema.pattern)) {
115
+ errors.push(`${key} format is invalid`);
116
+ }
117
+ }
118
+
119
+ // Number validations
120
+ if (propSchema.type === 'number') {
121
+ if (propSchema.minimum !== undefined && value < propSchema.minimum) {
122
+ errors.push(`${key} must be at least ${propSchema.minimum}`);
123
+ }
124
+ if (propSchema.maximum !== undefined && value > propSchema.maximum) {
125
+ errors.push(`${key} must be at most ${propSchema.maximum}`);
126
+ }
127
+ }
128
+
129
+ // Array validations
130
+ if (propSchema.type === 'array' && Array.isArray(value)) {
131
+ if (propSchema.uniqueItems) {
132
+ const uniqueValues = new Set(value);
133
+ if (uniqueValues.size !== value.length) {
134
+ errors.push(`${key} contains duplicate values`);
135
+ }
136
+ }
137
+ if (propSchema.items) {
138
+ for (let i = 0; i < value.length; i++) {
139
+ const item = value[i];
140
+ if (propSchema.items.type && !validateType(item, propSchema.items.type)) {
141
+ errors.push(`${key}[${i}] must be of type ${propSchema.items.type}`);
142
+ }
143
+ if (propSchema.items.pattern && !validatePattern(item, propSchema.items.pattern)) {
144
+ errors.push(`${key}[${i}] format is invalid`);
145
+ }
146
+ if (propSchema.items.enum && !propSchema.items.enum.includes(item)) {
147
+ errors.push(`${key}[${i}] must be one of: ${propSchema.items.enum.join(', ')}`);
148
+ }
149
+ }
150
+ }
151
+ }
152
+
153
+ // Enum validation
154
+ if (propSchema.enum && !propSchema.enum.includes(value)) {
155
+ errors.push(`${key} must be one of: ${propSchema.enum.join(', ')}`);
156
+ }
157
+ }
158
+
159
+ // Semantic version validation for the version field
160
+ if (manifest.version && typeof manifest.version === 'string') {
161
+ if (!validateSemver(manifest.version)) {
162
+ errors.push('version must be a valid semantic version (e.g., 1.0.0)');
163
+ }
164
+ }
165
+
166
+ return {
167
+ valid: errors.length === 0,
168
+ errors,
169
+ };
170
+ }
171
+
172
+ /**
173
+ * Validates a plugin ID
174
+ * @param {string} id - Plugin ID to validate
175
+ * @returns {object} Validation result
176
+ */
177
+ export function validatePluginIdFormat(id) {
178
+ if (!id || typeof id !== 'string') {
179
+ return { valid: false, error: 'Plugin ID must be a non-empty string' };
180
+ }
181
+ if (!validatePluginId(id)) {
182
+ return {
183
+ valid: false,
184
+ error: 'Plugin ID must contain only alphanumeric characters, hyphens, and underscores, and cannot start or end with a hyphen/underscore',
185
+ };
186
+ }
187
+ if (id.length > 64) {
188
+ return { valid: false, error: 'Plugin ID must be 64 characters or less' };
189
+ }
190
+ return { valid: true };
191
+ }
192
+
193
+ /**
194
+ * Generates a default manifest with common fields
195
+ * @param {string} id - Plugin ID
196
+ * @param {object} options - Additional options
197
+ * @returns {object} Default manifest
198
+ */
199
+ export function generateDefaultManifest(id, options = {}) {
200
+ const { name, description, author } = options;
201
+
202
+ return {
203
+ id,
204
+ name: name || id.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),
205
+ description: description || 'A custom widget plugin for Claw Dashboard',
206
+ version: '1.0.0',
207
+ author: author || '',
208
+ category: 'custom',
209
+ type: 'widget',
210
+ lazyLoad: true,
211
+ priority: 100,
212
+ config: {
213
+ refreshInterval: 5000,
214
+ },
215
+ __version: 1,
216
+ };
217
+ }
218
+
219
+ export default { validateManifest, validatePluginIdFormat, generateDefaultManifest };
@@ -0,0 +1,481 @@
1
+ /**
2
+ * Plugin Hot-Reload Module
3
+ * Provides automatic plugin reloading during development
4
+ * Integrates ConfigWatcher with WidgetLoader for seamless plugin development
5
+ */
6
+
7
+ import { join, dirname, basename } from 'path';
8
+ import { pathToFileURL } from 'url';
9
+ import { existsSync, readdirSync } from 'fs';
10
+ import { ConfigWatcher } from './config-watcher.js';
11
+ import { WidgetLoader } from './widgets/widget-loader.js';
12
+ import logger from './logger.js';
13
+ import config from './config.js';
14
+
15
+ const { PATHS } = config;
16
+
17
+ /**
18
+ * PluginReloadManager class
19
+ * Manages hot-reloading of plugins during development
20
+ */
21
+ export class PluginReloadManager {
22
+ constructor(options = {}) {
23
+ this.widgetLoader = options.widgetLoader || null;
24
+ this.pluginsDir = options.pluginsDir || PATHS.PLUGINS_DIR;
25
+ this.watcher = null;
26
+ this.watchedPlugins = new Map(); // pluginId -> { manifestPath, indexPath, manifest }
27
+ this.isRunning = false;
28
+ this.options = {
29
+ debounceMs: 300, // Faster debounce for dev mode
30
+ persistent: true,
31
+ usePolling: false,
32
+ pollInterval: 500,
33
+ autoReload: true, // Automatically reload on change
34
+ showNotifications: true, // Show reload notifications
35
+ ...options,
36
+ };
37
+ this.hooks = {
38
+ beforeReload: [],
39
+ afterReload: [],
40
+ onError: [],
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Set the widget loader instance
46
+ * @param {WidgetLoader} loader - WidgetLoader instance
47
+ */
48
+ setWidgetLoader(loader) {
49
+ this.widgetLoader = loader;
50
+ }
51
+
52
+ /**
53
+ * Add a hook
54
+ * @param {string} type - Hook type: 'beforeReload', 'afterReload', 'onError'
55
+ * @param {Function} handler - Hook handler
56
+ */
57
+ addHook(type, handler) {
58
+ if (!this.hooks[type]) {
59
+ throw new Error(`Unknown hook type: ${type}`);
60
+ }
61
+ this.hooks[type].push(handler);
62
+ }
63
+
64
+ /**
65
+ * Run hooks for a type
66
+ * @private
67
+ */
68
+ async runHooks(type, data) {
69
+ for (const handler of this.hooks[type]) {
70
+ try {
71
+ await handler(data);
72
+ } catch (err) {
73
+ logger.error(`PluginReloadManager hook error (${type}): ${err.message}`);
74
+ }
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Start watching plugins directory for changes
80
+ * @returns {boolean} True if started successfully
81
+ */
82
+ start() {
83
+ if (this.isRunning) {
84
+ logger.debug('PluginReloadManager: Already running');
85
+ return true;
86
+ }
87
+
88
+ if (!this.widgetLoader) {
89
+ logger.error('PluginReloadManager: No WidgetLoader set. Call setWidgetLoader() first.');
90
+ return false;
91
+ }
92
+
93
+ try {
94
+ // Create watcher with options
95
+ this.watcher = new ConfigWatcher({
96
+ debounceMs: this.options.debounceMs,
97
+ persistent: this.options.persistent,
98
+ usePolling: this.options.usePolling,
99
+ pollInterval: this.options.pollInterval,
100
+ });
101
+
102
+ // Handle reload events
103
+ this.watcher.on('reload', async ({ filePath }) => {
104
+ await this._handleFileChange(filePath);
105
+ });
106
+
107
+ this.watcher.on('error', ({ filePath, error }) => {
108
+ logger.error(`PluginReloadManager: Watcher error for ${filePath}: ${error.message}`);
109
+ this.runHooks('onError', { filePath, error, type: 'watch' });
110
+ });
111
+
112
+ // Scan and watch all plugin manifests
113
+ this._scanAndWatchPlugins();
114
+
115
+ this.isRunning = true;
116
+ logger.info('PluginReloadManager: Started watching for plugin changes');
117
+ return true;
118
+ } catch (err) {
119
+ logger.error(`PluginReloadManager: Failed to start: ${err.message}`);
120
+ return false;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Stop watching for changes
126
+ */
127
+ stop() {
128
+ if (!this.isRunning) {
129
+ return;
130
+ }
131
+
132
+ if (this.watcher) {
133
+ this.watcher.unwatchAll();
134
+ this.watcher = null;
135
+ }
136
+
137
+ this.watchedPlugins.clear();
138
+ this.isRunning = false;
139
+ logger.info('PluginReloadManager: Stopped');
140
+ }
141
+
142
+ /**
143
+ * Scan plugins directory and watch all manifests
144
+ * @private
145
+ */
146
+ _scanAndWatchPlugins() {
147
+ if (!existsSync(this.pluginsDir)) {
148
+ logger.warn(`PluginReloadManager: Plugins directory not found: ${this.pluginsDir}`);
149
+ return;
150
+ }
151
+
152
+ try {
153
+ const entries = readdirSync(this.pluginsDir, { withFileTypes: true });
154
+ let watchCount = 0;
155
+
156
+ for (const entry of entries) {
157
+ if (!entry.isDirectory()) continue;
158
+
159
+ const pluginPath = join(this.pluginsDir, entry.name);
160
+ const manifestPath = join(pluginPath, 'plugin.json');
161
+ const indexPath = join(pluginPath, 'index.js');
162
+
163
+ if (!existsSync(manifestPath)) continue;
164
+
165
+ // Store plugin info
166
+ this.watchedPlugins.set(entry.name, {
167
+ manifestPath,
168
+ indexPath,
169
+ pluginPath,
170
+ id: entry.name,
171
+ });
172
+
173
+ // Watch the manifest file
174
+ if (this.watcher.watchFile(manifestPath)) {
175
+ watchCount++;
176
+ }
177
+
178
+ // Also watch the index.js file if it exists
179
+ if (existsSync(indexPath)) {
180
+ if (this.watcher.watchFile(indexPath)) {
181
+ watchCount++;
182
+ }
183
+ }
184
+ }
185
+
186
+ logger.info(`PluginReloadManager: Watching ${this.watchedPlugins.size} plugins (${watchCount} files)`);
187
+ } catch (err) {
188
+ logger.error(`PluginReloadManager: Failed to scan plugins: ${err.message}`);
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Handle file change event
194
+ * @private
195
+ * @param {string} filePath - Path of changed file
196
+ */
197
+ async _handleFileChange(filePath) {
198
+ const pluginInfo = this._findPluginByPath(filePath);
199
+ if (!pluginInfo) {
200
+ logger.debug(`PluginReloadManager: Changed file not associated with a known plugin: ${filePath}`);
201
+ return;
202
+ }
203
+
204
+ const { id, pluginPath, manifestPath, indexPath } = pluginInfo;
205
+
206
+ logger.info(`PluginReloadManager: Detected change in plugin '${id}'`);
207
+
208
+ if (!this.options.autoReload) {
209
+ logger.info(`PluginReloadManager: Auto-reload disabled, skipping reload of '${id}'`);
210
+ return;
211
+ }
212
+
213
+ await this.reloadPlugin(id, pluginPath, manifestPath, indexPath);
214
+ }
215
+
216
+ /**
217
+ * Find plugin info by file path
218
+ * @private
219
+ */
220
+ _findPluginByPath(filePath) {
221
+ for (const [id, info] of this.watchedPlugins) {
222
+ if (filePath === info.manifestPath || filePath === info.indexPath) {
223
+ return { ...info, id };
224
+ }
225
+ }
226
+ return null;
227
+ }
228
+
229
+ /**
230
+ * Reload a single plugin
231
+ * @param {string} id - Plugin ID
232
+ * @param {string} pluginPath - Path to plugin directory
233
+ * @param {string} manifestPath - Path to plugin.json
234
+ * @param {string} indexPath - Path to index.js
235
+ * @returns {Object} Reload result
236
+ */
237
+ async reloadPlugin(id, pluginPath, manifestPath, indexPath) {
238
+ const startTime = Date.now();
239
+
240
+ try {
241
+ // Run beforeReload hooks
242
+ await this.runHooks('beforeReload', { id, pluginPath, manifestPath, indexPath });
243
+
244
+ // Check if plugin is currently loaded
245
+ const isLoaded = this.widgetLoader.isLoaded(id);
246
+ const wasRegistered = this.widgetLoader.widgetRegistry.has(id);
247
+
248
+ logger.debug(`PluginReloadManager: Reloading plugin '${id}' (was loaded: ${isLoaded}, was registered: ${wasRegistered})`);
249
+
250
+ // Step 1: Unload and unregister the existing plugin
251
+ if (wasRegistered) {
252
+ try {
253
+ await this.widgetLoader.unregister(id);
254
+ logger.debug(`PluginReloadManager: Unregistered plugin '${id}'`);
255
+ } catch (err) {
256
+ logger.warn(`PluginReloadManager: Error unregistering plugin '${id}': ${err.message}`);
257
+ }
258
+ }
259
+
260
+ // Step 2: Clear module cache to force re-import
261
+ this._clearModuleCache(indexPath);
262
+
263
+ // Step 3: Reload the plugin
264
+ const newId = await this.widgetLoader.loadPlugin(pluginPath, {
265
+ sanitize: true,
266
+ fallbackOnError: false,
267
+ });
268
+
269
+ const loadTime = Date.now() - startTime;
270
+
271
+ // Step 4: Update watched files (in case plugin structure changed)
272
+ await this._updateWatchedFiles(id, pluginPath, manifestPath, indexPath);
273
+
274
+ // Run afterReload hooks
275
+ await this.runHooks('afterReload', {
276
+ id: newId,
277
+ pluginPath,
278
+ manifestPath,
279
+ indexPath,
280
+ loadTime,
281
+ isNew: !wasRegistered,
282
+ });
283
+
284
+ if (this.options.showNotifications) {
285
+ logger.info(`✓ Plugin '${newId}' reloaded successfully in ${loadTime}ms`);
286
+ }
287
+
288
+ return {
289
+ success: true,
290
+ id: newId,
291
+ loadTime,
292
+ isNew: !wasRegistered,
293
+ };
294
+ } catch (err) {
295
+ logger.error(`✗ Failed to reload plugin '${id}': ${err.message}`);
296
+
297
+ await this.runHooks('onError', {
298
+ id,
299
+ pluginPath,
300
+ manifestPath,
301
+ indexPath,
302
+ error: err,
303
+ type: 'reload',
304
+ });
305
+
306
+ return {
307
+ success: false,
308
+ id,
309
+ error: err.message,
310
+ };
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Clear module cache for a file
316
+ * @private
317
+ * @param {string} filePath - Path to clear from cache
318
+ */
319
+ _clearModuleCache(filePath) {
320
+ try {
321
+ const fileUrl = pathToFileURL(filePath).href;
322
+
323
+ // Clear from ESM cache (Node.js internal)
324
+ // Note: This is a best-effort approach as ESM cache clearing is limited
325
+ if (import.meta.resolve && typeof import.meta.resolve === 'function') {
326
+ // For Node.js with import.meta.resolve, we can't directly clear cache
327
+ // but we use query parameters to bypass cache on next import
328
+ }
329
+
330
+ // Also try to clear any dynamic imports by appending cache-buster
331
+ // This is handled in the loader by using a timestamp query
332
+ logger.debug(`PluginReloadManager: Module cache cleared for ${filePath}`);
333
+ } catch (err) {
334
+ logger.debug(`PluginReloadManager: Could not clear module cache: ${err.message}`);
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Update watched files for a plugin
340
+ * @private
341
+ */
342
+ async _updateWatchedFiles(id, pluginPath, manifestPath, indexPath) {
343
+ // Re-watch manifest and index files
344
+ if (existsSync(manifestPath)) {
345
+ this.watcher.watchFile(manifestPath);
346
+ }
347
+ if (existsSync(indexPath)) {
348
+ this.watcher.watchFile(indexPath);
349
+ }
350
+
351
+ // Update stored info
352
+ this.watchedPlugins.set(id, {
353
+ manifestPath,
354
+ indexPath,
355
+ pluginPath,
356
+ id,
357
+ });
358
+ }
359
+
360
+ /**
361
+ * Manually trigger reload of a specific plugin
362
+ * @param {string} id - Plugin ID to reload
363
+ * @returns {Object} Reload result
364
+ */
365
+ async reload(id) {
366
+ const pluginInfo = this.watchedPlugins.get(id);
367
+ if (!pluginInfo) {
368
+ throw new Error(`Plugin '${id}' is not being watched`);
369
+ }
370
+
371
+ return this.reloadPlugin(id, pluginInfo.pluginPath, pluginInfo.manifestPath, pluginInfo.indexPath);
372
+ }
373
+
374
+ /**
375
+ * Add a new plugin to watch
376
+ * @param {string} pluginPath - Path to plugin directory
377
+ * @returns {boolean} True if added successfully
378
+ */
379
+ async addPlugin(pluginPath) {
380
+ const pluginId = basename(pluginPath);
381
+ const manifestPath = join(pluginPath, 'plugin.json');
382
+ const indexPath = join(pluginPath, 'index.js');
383
+
384
+ if (!existsSync(manifestPath)) {
385
+ throw new Error(`Plugin manifest not found at ${pluginPath}`);
386
+ }
387
+
388
+ // Add to watched plugins
389
+ this.watchedPlugins.set(pluginId, {
390
+ manifestPath,
391
+ indexPath,
392
+ pluginPath,
393
+ id: pluginId,
394
+ });
395
+
396
+ // Watch the files
397
+ let watched = 0;
398
+ if (this.watcher?.watchFile(manifestPath)) watched++;
399
+ if (existsSync(indexPath) && this.watcher?.watchFile(indexPath)) watched++;
400
+
401
+ logger.debug(`PluginReloadManager: Added plugin '${pluginId}' to watch list (${watched} files)`);
402
+ return true;
403
+ }
404
+
405
+ /**
406
+ * Remove a plugin from watching
407
+ * @param {string} id - Plugin ID to remove
408
+ */
409
+ removePlugin(id) {
410
+ const pluginInfo = this.watchedPlugins.get(id);
411
+ if (!pluginInfo) {
412
+ return false;
413
+ }
414
+
415
+ // Unwatch files
416
+ if (this.watcher) {
417
+ this.watcher.unwatchFile(pluginInfo.manifestPath);
418
+ this.watcher.unwatchFile(pluginInfo.indexPath);
419
+ }
420
+
421
+ this.watchedPlugins.delete(id);
422
+ logger.debug(`PluginReloadManager: Removed plugin '${id}' from watch list`);
423
+ return true;
424
+ }
425
+
426
+ /**
427
+ * Get list of watched plugins
428
+ * @returns {string[]} Array of plugin IDs
429
+ */
430
+ getWatchedPlugins() {
431
+ return Array.from(this.watchedPlugins.keys());
432
+ }
433
+
434
+ /**
435
+ * Check if a plugin is being watched
436
+ * @param {string} id - Plugin ID
437
+ * @returns {boolean}
438
+ */
439
+ isWatching(id) {
440
+ return this.watchedPlugins.has(id);
441
+ }
442
+
443
+ /**
444
+ * Get statistics
445
+ * @returns {Object} Stats object
446
+ */
447
+ getStats() {
448
+ return {
449
+ isRunning: this.isRunning,
450
+ watchedPlugins: this.watchedPlugins.size,
451
+ watchedFiles: this.watcher?.getWatchedFiles().length || 0,
452
+ autoReload: this.options.autoReload,
453
+ };
454
+ }
455
+ }
456
+
457
+ /**
458
+ * Create a plugin reload manager
459
+ * @param {Object} options - Options for the manager
460
+ * @returns {PluginReloadManager} New PluginReloadManager instance
461
+ */
462
+ export function createPluginReloadManager(options = {}) {
463
+ return new PluginReloadManager(options);
464
+ }
465
+
466
+ // Singleton instance
467
+ let defaultManager = null;
468
+
469
+ /**
470
+ * Get the default plugin reload manager instance
471
+ * @param {Object} options - Options for creating the manager if needed
472
+ * @returns {PluginReloadManager} Default manager instance
473
+ */
474
+ export function getPluginReloadManager(options) {
475
+ if (!defaultManager) {
476
+ defaultManager = new PluginReloadManager(options);
477
+ }
478
+ return defaultManager;
479
+ }
480
+
481
+ export default PluginReloadManager;