claw-dashboard 1.8.4 → 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.
- package/.c8rc.json +38 -0
- package/.dockerignore +68 -0
- package/.github/dependabot.yml +45 -0
- package/.github/pull_request_template.md +39 -0
- package/.github/workflows/ci.yml +147 -0
- package/.github/workflows/release.yml +40 -0
- package/.github/workflows/security.yml +84 -0
- package/.husky/pre-commit +1 -0
- package/.planning/codebase/ARCHITECTURE.md +206 -0
- package/.planning/codebase/INTEGRATIONS.md +150 -0
- package/.planning/codebase/STACK.md +122 -0
- package/.planning/codebase/STRUCTURE.md +201 -0
- package/CHANGELOG.md +293 -0
- package/CONTRIBUTING.md +378 -0
- package/Dockerfile +54 -0
- package/FEATURES.md +195 -21
- package/README.md +83 -6
- package/TODO.md +28 -0
- package/build-cjs.js +127 -0
- package/cjs-shim.js +13 -0
- package/dist/clawdash +1729 -0
- package/dist/clawdash.meta.json +2236 -0
- package/dist/widgets.cjs +6511 -0
- package/docker-compose.yml +67 -0
- package/docs/API.md +1050 -0
- package/docs/PLUGINS.md +1504 -0
- package/esbuild.config.js +158 -0
- package/eslint.config.js +56 -0
- package/examples/plugins/README.md +122 -0
- package/examples/plugins/api-status/index.js +294 -0
- package/examples/plugins/api-status/plugin.json +19 -0
- package/examples/plugins/hello-world/index.js +104 -0
- package/examples/plugins/hello-world/plugin.json +15 -0
- package/examples/plugins/system-metrics-chart/index.js +339 -0
- package/examples/plugins/system-metrics-chart/plugin.json +18 -0
- package/examples/plugins/weather-widget/index.js +136 -0
- package/examples/plugins/weather-widget/plugin.json +19 -0
- package/index.cjs +23005 -0
- package/index.js +5331 -512
- package/jest.config.js +11 -0
- package/man/clawdash.1 +276 -0
- package/package.json +54 -5
- package/schemas/plugin-manifest.json +128 -0
- package/scripts/release.js +595 -0
- package/src/alerts.js +693 -0
- package/src/auto-save.js +584 -0
- package/src/cache.js +390 -0
- package/src/checksum.js +146 -0
- package/src/cli/args.js +110 -0
- package/src/cli/export-schedule.js +423 -0
- package/src/cli/export-snapshot.js +138 -0
- package/src/cli/help.js +69 -0
- package/src/cli/import-snapshot.js +230 -0
- package/src/cli/index.js +14 -0
- package/src/cli/list-templates.js +69 -0
- package/src/cli/validate-config.js +76 -0
- package/src/cli/validate-plugin.js +187 -0
- package/src/cli/version.js +15 -0
- package/src/config-validator.js +586 -0
- package/src/config-watcher.js +401 -0
- package/src/config.js +684 -0
- package/src/container-detector.js +499 -0
- package/src/database.js +734 -0
- package/src/differential-render.js +327 -0
- package/src/errors.js +169 -0
- package/src/export-scheduler.js +581 -0
- package/src/gateway-manager.js +685 -0
- package/src/hints.js +371 -0
- package/src/loading-states.js +509 -0
- package/src/logger.js +185 -0
- package/src/memory-pressure.js +467 -0
- package/src/performance-monitor.js +374 -0
- package/src/plugin-errors.js +586 -0
- package/src/plugin-manifest-validator.js +219 -0
- package/src/plugin-reload.js +481 -0
- package/src/plugin-scaffold.js +1941 -0
- package/src/plugin-validator.js +284 -0
- package/src/retry.js +218 -0
- package/src/security.js +860 -0
- package/src/snapshot.js +372 -0
- package/src/splash.js +115 -0
- package/src/theme-selector.js +411 -0
- package/src/themes.js +874 -0
- package/src/transitions.js +534 -0
- package/src/types.d.ts +174 -0
- package/src/validation.js +926 -0
- package/src/web-server.js +885 -0
- package/src/widgets/builtin-widgets.js +1057 -0
- package/src/widgets/config-processor.js +401 -0
- package/src/widgets/dependency-resolver.js +596 -0
- package/src/widgets/index.js +79 -0
- package/src/widgets/plugin-api.js +845 -0
- package/src/widgets/widget-error-boundary.js +773 -0
- package/src/widgets/widget-error-isolation.js +551 -0
- package/src/widgets/widget-loader.js +1437 -0
- package/src/workers/system-worker.js +130 -0
- package/src/workers/worker-pool.js +633 -0
- package/tests/alerts.test.js +437 -0
- package/tests/auto-save.test.js +529 -0
- package/tests/cache.test.js +317 -0
- package/tests/cli.test.js +351 -0
- package/tests/command-palette.test.js +320 -0
- package/tests/config-processor.test.js +452 -0
- package/tests/config-validator.test.js +710 -0
- package/tests/config-watcher.test.js +594 -0
- package/tests/config.test.js +755 -0
- package/tests/database.test.js +438 -0
- package/tests/errors.test.js +189 -0
- package/tests/example-plugins.test.js +624 -0
- package/tests/integration.test.js +1321 -0
- package/tests/loading-states.test.js +300 -0
- package/tests/manifest-validation-on-load.test.js +671 -0
- package/tests/performance-monitor.test.js +190 -0
- package/tests/plugin-api-rate-limit.test.js +302 -0
- package/tests/plugin-errors.test.js +311 -0
- package/tests/plugin-lifecycle-e2e.test.js +1036 -0
- package/tests/plugin-reload.test.js +764 -0
- package/tests/rate-limiter.test.js +539 -0
- package/tests/retry.test.js +308 -0
- package/tests/security.test.js +411 -0
- package/tests/settings-modal.test.js +226 -0
- package/tests/theme-selector.test.js +57 -0
- package/tests/utils.js +242 -0
- package/tests/utils.test.js +317 -0
- package/tests/validate-plugin-cli.test.js +359 -0
- package/tests/validation.test.js +837 -0
- package/tests/web-server.test.js +646 -0
- package/tests/widget-arrange-mode.test.js +183 -0
- package/tests/widget-config-hot-reload.test.js +465 -0
- package/tests/widget-dependency.test.js +591 -0
- package/tests/widget-error-boundary.test.js +749 -0
- package/tests/widget-error-isolation.test.js +469 -0
- package/tests/widget-integration.test.js +1147 -0
- package/tests/widget-refresh-intervals.test.js +284 -0
- 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;
|