matterbridge
Version:
Matterbridge plugin manager for Matter
975 lines (974 loc) • 53.9 kB
JavaScript
/**
* This file contains the Plugins class.
*
* @file plugins.ts
* @author Luca Liguori
* @date 2024-07-14
* @version 1.1.1
*
* Copyright 2024, 2025, 2026 Luca Liguori.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License. *
*/
// AnsiLogger module
import { AnsiLogger, UNDERLINE, UNDERLINEOFF, BLUE, db, er, nf, nt, rs, wr } from './logger/export.js';
import { plg, typ } from './matterbridgeTypes.js';
export class PluginManager {
_plugins = new Map();
nodeContext;
matterbridge;
log;
constructor(matterbridge) {
this.matterbridge = matterbridge;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.nodeContext = matterbridge.nodeContext;
this.log = new AnsiLogger({ logName: 'PluginManager', logTimestampFormat: 4 /* TimestampFormat.TIME_MILLIS */, logLevel: matterbridge.log.logLevel });
this.log.debug('Matterbridge plugin manager starting...');
}
get length() {
return this._plugins.size;
}
get size() {
return this._plugins.size;
}
has(name) {
return this._plugins.has(name);
}
get(name) {
return this._plugins.get(name);
}
set(plugin) {
this._plugins.set(plugin.name, plugin);
return plugin;
}
clear() {
this._plugins.clear();
}
array() {
return Array.from(this._plugins.values());
}
[Symbol.iterator]() {
return this._plugins.values();
}
async forEach(callback) {
if (this.size === 0)
return;
const tasks = Array.from(this._plugins.values()).map(async (plugin) => {
try {
await callback(plugin);
}
catch (error) {
this.log.error(`Error processing forEach plugin ${plg}${plugin.name}${er}:`, error);
// throw error;
}
});
await Promise.all(tasks);
}
set logLevel(logLevel) {
this.log.logLevel = logLevel;
}
/**
* Loads registered plugins from storage.
*
* This method retrieves an array of registered plugins from the storage and converts it
* into a map where the plugin names are the keys and the plugin objects are the values.
*
* @returns {Promise<RegisteredPlugin[]>} A promise that resolves to an array of registered plugins.
*/
async loadFromStorage() {
// Load the array from storage and convert it to a map
const pluginsArray = await this.nodeContext.get('plugins', []);
for (const plugin of pluginsArray)
this._plugins.set(plugin.name, plugin);
return pluginsArray;
}
/**
* Loads registered plugins from storage.
*
* This method retrieves an array of registered plugins from the storage and converts it
* into a map where the plugin names are the keys and the plugin objects are the values.
*
* @returns {Promise<RegisteredPlugin[]>} A promise that resolves to an array of registered plugins.
*/
async saveToStorage() {
// Convert the map to an array
const plugins = [];
const pluginArrayFromMap = Array.from(this._plugins.values());
for (const plugin of pluginArrayFromMap) {
plugins.push({
name: plugin.name,
path: plugin.path,
type: plugin.type,
version: plugin.version,
description: plugin.description,
author: plugin.author,
enabled: plugin.enabled,
qrPairingCode: plugin.qrPairingCode,
manualPairingCode: plugin.manualPairingCode,
});
}
await this.nodeContext.set('plugins', plugins);
this.log.debug(`Saved ${BLUE}${plugins.length}${db} plugins to storage`);
return plugins.length;
}
/**
* Resolves the name of a plugin by loading and parsing its package.json file.
* @param pluginPath - The path to the plugin or the path to the plugin's package.json file.
* @returns The path to the resolved package.json file, or null if the package.json file is not found or does not contain a name.
*/
async resolve(pluginPath) {
const { default: path } = await import('node:path');
const { promises } = await import('node:fs');
if (!pluginPath.endsWith('package.json'))
pluginPath = path.join(pluginPath, 'package.json');
// Resolve the package.json of the plugin
let packageJsonPath = path.resolve(pluginPath);
this.log.debug(`Resolving plugin path ${plg}${packageJsonPath}${db}`);
// Check if the package.json file exists
try {
await promises.access(packageJsonPath);
}
catch {
this.log.debug(`Package.json not found at ${plg}${packageJsonPath}${db}`);
packageJsonPath = path.join(this.matterbridge.globalModulesDirectory, pluginPath);
this.log.debug(`Trying at ${plg}${packageJsonPath}${db}`);
}
try {
// Load the package.json of the plugin
const packageJson = JSON.parse(await promises.readFile(packageJsonPath, 'utf8'));
// Check for main issues
if (!packageJson.name) {
this.log.error(`Package.json name not found at ${packageJsonPath}`);
return null;
}
if (!packageJson.type || packageJson.type !== 'module') {
this.log.error(`Plugin at ${packageJsonPath} is not a module`);
return null;
}
if (!packageJson.main) {
this.log.error(`Plugin at ${packageJsonPath} has no main entrypoint in package.json`);
return null;
}
// Check for @project-chip and @matter packages in dependencies and devDependencies
const checkForProjectChipPackages = (dependencies) => {
return Object.keys(dependencies).filter((pkg) => pkg.startsWith('@project-chip') || pkg.startsWith('@matter'));
};
const projectChipDependencies = checkForProjectChipPackages(packageJson.dependencies || {});
if (projectChipDependencies.length > 0) {
this.log.error(`Found @project-chip packages "${projectChipDependencies.join(', ')}" in plugin dependencies.`);
this.log.error(`Please open an issue on the plugin repository to remove them.`);
return null;
}
const projectChipDevDependencies = checkForProjectChipPackages(packageJson.devDependencies || {});
if (projectChipDevDependencies.length > 0) {
this.log.error(`Found @project-chip packages "${projectChipDevDependencies.join(', ')}" in plugin devDependencies.`);
this.log.error(`Please open an issue on the plugin repository to remove them.`);
return null;
}
const projectChipPeerDependencies = checkForProjectChipPackages(packageJson.peerDependencies || {});
if (projectChipPeerDependencies.length > 0) {
this.log.error(`Found @project-chip packages "${projectChipPeerDependencies.join(', ')}" in plugin peerDependencies.`);
this.log.error(`Please open an issue on the plugin repository to remove them.`);
return null;
}
// Check for matterbridge package in dependencies and devDependencies
const checkForMatterbridgePackage = (dependencies) => {
return Object.keys(dependencies).filter((pkg) => pkg === 'matterbridge');
};
const matterbridgeDependencies = checkForMatterbridgePackage(packageJson.dependencies || {});
if (matterbridgeDependencies.length > 0) {
this.log.error(`Found matterbridge package in the plugin dependencies.`);
this.log.error(`Please open an issue on the plugin repository to remove them.`);
return null;
}
const matterbridgeDevDependencies = checkForMatterbridgePackage(packageJson.devDependencies || {});
if (matterbridgeDevDependencies.length > 0) {
this.log.error(`Found matterbridge package in the plugin devDependencies.`);
this.log.error(`Please open an issue on the plugin repository to remove them.`);
return null;
}
const matterbridgePeerDependencies = checkForMatterbridgePackage(packageJson.peerDependencies || {});
if (matterbridgePeerDependencies.length > 0) {
this.log.error(`Found matterbridge package in the plugin peerDependencies.`);
this.log.error(`Please open an issue on the plugin repository to remove them.`);
return null;
}
this.log.debug(`Resolved plugin path ${plg}${pluginPath}${db}: ${packageJsonPath}`);
return packageJsonPath;
}
catch (err) {
this.log.error(`Failed to resolve plugin path ${plg}${pluginPath}${er}: ${err}`);
return null;
}
}
/**
* Get the author of a plugin from its package.json.
*
* @param {Record<string, string | number | Record<string, string | number | object>>} packageJson - The package.json object of the plugin.
* @returns {string} The author of the plugin, or 'Unknown author' if not found.
*/
getAuthor(packageJson) {
if (packageJson.author && typeof packageJson.author === 'string')
return packageJson.author;
if (packageJson.author && typeof packageJson.author === 'object' && packageJson.author.name && typeof packageJson.author.name === 'string')
return packageJson.author.name;
return 'Unknown author';
}
/**
* Get the homepage of a plugin from its package.json.
*
* @param {Record<string, string | number | Record<string, string | number | object>>} packageJson - The package.json object of the plugin.
* @returns {string | undefined} The homepage of the plugin, or undefined if not found.
*/
getHomepage(packageJson) {
if (packageJson.homepage && typeof packageJson.homepage === 'string') {
return packageJson.homepage.replace('git+', '').replace('.git', '');
}
if (packageJson.repository && typeof packageJson.repository === 'object' && packageJson.repository.url && typeof packageJson.repository.url === 'string') {
return packageJson.repository.url.replace('git+', '').replace('.git', '');
}
}
/**
* Get the help URL of a plugin from its package.json.
*
* @param {Record<string, string | number | Record<string, string | number | object>>} packageJson - The package.json object of the plugin.
* @returns {string | undefined} The URL to the help page or to the README file, or undefined if not found.
*/
getHelp(packageJson) {
// If there's a help field that looks like a URL, return it.
if (packageJson.help && typeof packageJson.help === 'string' && packageJson.help.startsWith('http')) {
return packageJson.help;
}
// Derive a base URL from homepage or repository.
let baseUrl;
if (packageJson.homepage && typeof packageJson.homepage === 'string') {
// Remove a trailing "/README.md" if present.
baseUrl = packageJson.homepage
.replace(/\/README\.md$/i, '')
.replace('git+', '')
.replace('.git', '');
}
else if (packageJson.repository && typeof packageJson.repository === 'object' && packageJson.repository.url && typeof packageJson.repository.url === 'string') {
baseUrl = packageJson.repository.url.replace('git+', '').replace('.git', '');
}
return baseUrl ? `${baseUrl}/blob/main/README.md` : undefined;
}
/**
* Get the changelog URL of a plugin from its package.json.
*
* @param {Record<string, string | number | Record<string, string | number | object>>} packageJson - The package.json object of the plugin.
* @returns {string | undefined} The URL to the CHANGELOG file, or undefined if not found.
*/
getChangelog(packageJson) {
// If there's a changelog field that looks like a URL, return it.
if (packageJson.changelog && typeof packageJson.changelog === 'string' && packageJson.changelog.startsWith('http')) {
return packageJson.changelog;
}
// Derive a base URL from homepage or repository.
let baseUrl;
if (packageJson.homepage && typeof packageJson.homepage === 'string') {
baseUrl = packageJson.homepage
.replace(/\/README\.md$/i, '')
.replace('git+', '')
.replace('.git', '');
}
else if (packageJson.repository && typeof packageJson.repository === 'object' && packageJson.repository.url && typeof packageJson.repository.url === 'string') {
baseUrl = packageJson.repository.url.replace('git+', '').replace('.git', '');
}
return baseUrl ? `${baseUrl}/blob/main/CHANGELOG.md` : undefined;
}
/**
* Get the first funding URL(s) of a plugin from its package.json.
*
* @param {Record<string, any>} packageJson - The package.json object of the plugin.
* @returns {string | undefined} The first funding URLs, or undefined if not found.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getFunding(packageJson) {
const funding = packageJson.funding;
if (!funding)
return undefined;
if (typeof funding === 'string' && !funding.startsWith('http'))
return;
if (typeof funding === 'string' && funding.startsWith('http'))
return funding;
// Normalize funding into an array.
const fundingEntries = Array.isArray(funding) ? funding : [funding];
for (const entry of fundingEntries) {
if (entry && typeof entry === 'string' && entry.startsWith('http')) {
// If the funding entry is a string, assume it is a URL.
return entry;
}
else if (entry && typeof entry === 'object' && typeof entry.url === 'string' && entry.url.startsWith('http')) {
// If it's an object with a 'url' property, use that.
return entry.url;
}
}
}
/**
* Loads and parse the plugin package.json and returns it.
* @param plugin - The plugin to load the package from.
* @returns A Promise that resolves to the package.json object or undefined if the package.json could not be loaded.
*/
async parse(plugin) {
const { promises } = await import('node:fs');
try {
this.log.debug(`Parsing package.json of plugin ${plg}${plugin.name}${db}`);
const packageJson = JSON.parse(await promises.readFile(plugin.path, 'utf8'));
if (!packageJson.name)
this.log.warn(`Plugin ${plg}${plugin.name}${wr} has no name in package.json`);
if (!packageJson.version)
this.log.warn(`Plugin ${plg}${plugin.name}${wr} has no version in package.json`);
if (!packageJson.description)
this.log.warn(`Plugin ${plg}${plugin.name}${wr} has no description in package.json`);
if (!packageJson.author)
this.log.warn(`Plugin ${plg}${plugin.name}${wr} has no author in package.json`);
if (!packageJson.homepage)
this.log.warn(`Plugin ${plg}${plugin.name}${wr} has no homepage in package.json`);
if (!packageJson.type || packageJson.type !== 'module')
this.log.error(`Plugin ${plg}${plugin.name}${er} is not a module`);
if (!packageJson.main)
this.log.error(`Plugin ${plg}${plugin.name}${er} has no main entrypoint in package.json`);
plugin.name = packageJson.name || 'Unknown name';
plugin.version = packageJson.version || '1.0.0';
plugin.description = packageJson.description || 'Unknown description';
plugin.author = this.getAuthor(packageJson);
plugin.homepage = this.getHomepage(packageJson);
plugin.help = this.getHelp(packageJson);
plugin.changelog = this.getChangelog(packageJson);
plugin.funding = this.getFunding(packageJson);
if (!plugin.path)
this.log.warn(`Plugin ${plg}${plugin.name}${wr} has no path`);
if (!plugin.type)
this.log.warn(`Plugin ${plg}${plugin.name}${wr} has no type`);
// Check for @project-chip and @matter packages in dependencies and devDependencies
const checkForProjectChipPackages = (dependencies) => {
return Object.keys(dependencies).filter((pkg) => pkg.startsWith('@project-chip') || pkg.startsWith('@matter'));
};
const projectChipDependencies = checkForProjectChipPackages(packageJson.dependencies || {});
if (projectChipDependencies.length > 0) {
this.log.error(`Found @project-chip packages "${projectChipDependencies.join(', ')}" in plugin ${plg}${plugin.name}${er} dependencies.`);
this.log.error(`Please open an issue on the plugin repository to remove them.`);
return null;
}
const projectChipDevDependencies = checkForProjectChipPackages(packageJson.devDependencies || {});
if (projectChipDevDependencies.length > 0) {
this.log.error(`Found @project-chip packages "${projectChipDevDependencies.join(', ')}" in plugin ${plg}${plugin.name}${er} devDependencies.`);
this.log.error(`Please open an issue on the plugin repository to remove them.`);
return null;
}
const projectChipPeerDependencies = checkForProjectChipPackages(packageJson.peerDependencies || {});
if (projectChipPeerDependencies.length > 0) {
this.log.error(`Found @project-chip packages "${projectChipPeerDependencies.join(', ')}" in plugin ${plg}${plugin.name}${er} peerDependencies.`);
this.log.error(`Please open an issue on the plugin repository to remove them.`);
return null;
}
// Check for matterbridge package in dependencies and devDependencies
const checkForMatterbridgePackage = (dependencies) => {
return Object.keys(dependencies).filter((pkg) => pkg === 'matterbridge');
};
const matterbridgeDependencies = checkForMatterbridgePackage(packageJson.dependencies || {});
if (matterbridgeDependencies.length > 0) {
this.log.error(`Found matterbridge package in the plugin ${plg}${plugin.name}${er} dependencies.`);
this.log.error(`Please open an issue on the plugin repository to remove them.`);
return null;
}
const matterbridgeDevDependencies = checkForMatterbridgePackage(packageJson.devDependencies || {});
if (matterbridgeDevDependencies.length > 0) {
this.log.error(`Found matterbridge package in the plugin ${plg}${plugin.name}${er} devDependencies.`);
this.log.error(`Please open an issue on the plugin repository to remove them.`);
return null;
}
const matterbridgePeerDependencies = checkForMatterbridgePackage(packageJson.peerDependencies || {});
if (matterbridgePeerDependencies.length > 0) {
this.log.error(`Found matterbridge package in the plugin ${plg}${plugin.name}${er} peerDependencies.`);
this.log.error(`Please open an issue on the plugin repository to remove them.`);
return null;
}
// await this.saveToStorage(); // No need to save the plugin to storage
return packageJson;
}
catch (err) {
this.log.error(`Failed to parse package.json of plugin ${plg}${plugin.name}${er}: ${err}`);
plugin.error = true;
return null;
}
}
/**
* Enables a plugin by its name or path.
*
* This method enables a plugin by setting its `enabled` property to `true` and saving the updated
* plugin information to storage. It first checks if the plugin is already registered in the `_plugins` map.
* If not, it attempts to resolve the plugin's `package.json` file to retrieve its name and enable it.
*
* @param {string} nameOrPath - The name or path of the plugin to enable.
* @returns {Promise<RegisteredPlugin | null>} A promise that resolves to the enabled plugin object, or null if the plugin could not be enabled.
*/
async enable(nameOrPath) {
const { promises } = await import('node:fs');
if (!nameOrPath || nameOrPath === '')
return null;
if (this._plugins.has(nameOrPath)) {
const plugin = this._plugins.get(nameOrPath);
plugin.enabled = true;
this.log.info(`Enabled plugin ${plg}${plugin.name}${nf}`);
await this.saveToStorage();
return plugin;
}
const packageJsonPath = await this.resolve(nameOrPath);
if (!packageJsonPath) {
this.log.error(`Failed to enable plugin ${plg}${nameOrPath}${er}: package.json not found`);
return null;
}
try {
const packageJson = JSON.parse(await promises.readFile(packageJsonPath, 'utf8'));
const plugin = this._plugins.get(packageJson.name);
if (!plugin) {
this.log.error(`Failed to enable plugin ${plg}${nameOrPath}${er}: plugin not registered`);
return null;
}
plugin.enabled = true;
this.log.info(`Enabled plugin ${plg}${plugin.name}${nf}`);
await this.saveToStorage();
return plugin;
}
catch (err) {
this.log.error(`Failed to parse package.json of plugin ${plg}${nameOrPath}${er}: ${err}`);
return null;
}
}
/**
* Enables a plugin by its name or path.
*
* This method enables a plugin by setting its `enabled` property to `true` and saving the updated
* plugin information to storage. It first checks if the plugin is already registered in the `_plugins` map.
* If not, it attempts to resolve the plugin's `package.json` file to retrieve its name and enable it.
*
* @param {string} nameOrPath - The name or path of the plugin to enable.
* @returns {Promise<RegisteredPlugin | null>} A promise that resolves to the enabled plugin object, or null if the plugin could not be enabled.
*/
async disable(nameOrPath) {
const { promises } = await import('node:fs');
if (!nameOrPath || nameOrPath === '')
return null;
if (this._plugins.has(nameOrPath)) {
const plugin = this._plugins.get(nameOrPath);
plugin.enabled = false;
this.log.info(`Disabled plugin ${plg}${plugin.name}${nf}`);
await this.saveToStorage();
return plugin;
}
const packageJsonPath = await this.resolve(nameOrPath);
if (!packageJsonPath) {
this.log.error(`Failed to disable plugin ${plg}${nameOrPath}${er}: package.json not found`);
return null;
}
try {
const packageJson = JSON.parse(await promises.readFile(packageJsonPath, 'utf8'));
const plugin = this._plugins.get(packageJson.name);
if (!plugin) {
this.log.error(`Failed to disable plugin ${plg}${nameOrPath}${er}: plugin not registered`);
return null;
}
plugin.enabled = false;
this.log.info(`Disabled plugin ${plg}${plugin.name}${nf}`);
await this.saveToStorage();
return plugin;
}
catch (err) {
this.log.error(`Failed to parse package.json of plugin ${plg}${nameOrPath}${er}: ${err}`);
return null;
}
}
/**
* Removes a plugin by its name or path.
*
* This method removes a plugin from the `_plugins` map and saves the updated plugin information to storage.
* It first checks if the plugin is already registered in the `_plugins` map. If not, it attempts to resolve
* the plugin's `package.json` file to retrieve its name and remove it.
*
* @param {string} nameOrPath - The name or path of the plugin to remove.
* @returns {Promise<RegisteredPlugin | null>} A promise that resolves to the removed plugin object, or null if the plugin could not be removed.
*/
async remove(nameOrPath) {
const { promises } = await import('node:fs');
if (!nameOrPath || nameOrPath === '')
return null;
if (this._plugins.has(nameOrPath)) {
const plugin = this._plugins.get(nameOrPath);
this._plugins.delete(nameOrPath);
this.log.info(`Removed plugin ${plg}${plugin.name}${nf}`);
await this.saveToStorage();
return plugin;
}
const packageJsonPath = await this.resolve(nameOrPath);
if (!packageJsonPath) {
this.log.error(`Failed to remove plugin ${plg}${nameOrPath}${er}: package.json not found`);
return null;
}
try {
const packageJson = JSON.parse(await promises.readFile(packageJsonPath, 'utf8'));
const plugin = this._plugins.get(packageJson.name);
if (!plugin) {
this.log.error(`Failed to remove plugin ${plg}${nameOrPath}${er}: plugin not registered`);
return null;
}
this._plugins.delete(packageJson.name);
this.log.info(`Removed plugin ${plg}${plugin.name}${nf}`);
await this.saveToStorage();
return plugin;
}
catch (err) {
this.log.error(`Failed to parse package.json of plugin ${plg}${nameOrPath}${er}: ${err}`);
return null;
}
}
/**
* Adds a plugin by its name or path.
*
* This method adds a plugin to the `_plugins` map and saves the updated plugin information to storage.
* It first resolves the plugin's `package.json` file to retrieve its details. If the plugin is already
* registered, it logs an info message and returns null. Otherwise, it registers the plugin, enables it,
* and saves the updated plugin information to storage.
*
* @param {string} nameOrPath - The name or path of the plugin to add.
* @returns {Promise<RegisteredPlugin | null>} A promise that resolves to the added plugin object, or null if the plugin could not be added.
*/
async add(nameOrPath) {
const { promises } = await import('node:fs');
if (!nameOrPath || nameOrPath === '')
return null;
const packageJsonPath = await this.resolve(nameOrPath);
if (!packageJsonPath) {
this.log.error(`Failed to add plugin ${plg}${nameOrPath}${er}: package.json not found`);
return null;
}
try {
const packageJson = JSON.parse(await promises.readFile(packageJsonPath, 'utf8'));
if (this._plugins.get(packageJson.name)) {
this.log.info(`Plugin ${plg}${nameOrPath}${nf} already registered`);
return null;
}
this._plugins.set(packageJson.name, {
name: packageJson.name,
enabled: true,
path: packageJsonPath,
type: 'AnyPlatform',
version: packageJson.version,
description: packageJson.description,
author: this.getAuthor(packageJson),
});
this.log.info(`Added plugin ${plg}${packageJson.name}${nf}`);
await this.saveToStorage();
const plugin = this._plugins.get(packageJson.name);
return plugin || null;
}
catch (err) {
this.log.error(`Failed to parse package.json of plugin ${plg}${nameOrPath}${er}: ${err instanceof Error ? err.message : err}`);
return null;
}
}
/**
* Installs a plugin by its name.
*
* This method first uninstalls any existing version of the plugin, then installs the plugin globally using npm.
* It logs the installation process and retrieves the installed version of the plugin.
*
* @param {string} name - The name of the plugin to install.
* @returns {Promise<string | undefined>} A promise that resolves to the installed version of the plugin, or undefined if the installation failed.
*/
async install(name) {
const { exec } = await import('node:child_process');
await this.uninstall(name);
this.log.info(`Installing plugin ${plg}${name}${nf}`);
return new Promise((resolve) => {
exec(`npm install -g ${name} --omit=dev`, (error, stdout, stderr) => {
if (error) {
this.log.error(`Failed to install plugin ${plg}${name}${er}: ${error}`);
this.log.debug(`Failed to install plugin ${plg}${name}${db}: ${stderr}`);
resolve(undefined);
}
else {
this.log.info(`Installed plugin ${plg}${name}${nf}`);
this.log.debug(`Installed plugin ${plg}${name}${db}: ${stdout}`);
// Get the installed version
// eslint-disable-next-line @typescript-eslint/no-unused-vars
exec(`npm list -g ${name} --depth=0`, (listError, listStdout, listStderr) => {
if (listError) {
this.log.error(`List error: ${listError}`);
resolve(undefined);
}
// Clean the output to get only the package name and version
const lines = listStdout.split('\n');
const versionLine = lines.find((line) => line.includes(`${name}@`));
if (versionLine) {
const version = versionLine.split('@')[1].trim();
this.log.info(`Installed plugin ${plg}${name}@${version}${nf}`);
resolve(version);
}
else {
resolve(undefined);
}
});
}
});
});
}
/**
* Uninstalls a plugin by its name.
*
* This method uninstalls a globally installed plugin using npm. It logs the uninstallation process
* and returns the name of the uninstalled plugin if successful, or undefined if the uninstallation failed.
*
* @param {string} name - The name of the plugin to uninstall.
* @returns {Promise<string | undefined>} A promise that resolves to the name of the uninstalled plugin, or undefined if the uninstallation failed.
*/
async uninstall(name) {
const { exec } = await import('node:child_process');
this.log.info(`Uninstalling plugin ${plg}${name}${nf}`);
return new Promise((resolve) => {
exec(`npm uninstall -g ${name}`, (error, stdout, stderr) => {
if (error) {
this.log.error(`Failed to uninstall plugin ${plg}${name}${er}: ${error}`);
this.log.debug(`Failed to uninstall plugin ${plg}${name}${db}: ${stderr}`);
resolve(undefined);
}
else {
this.log.info(`Uninstalled plugin ${plg}${name}${nf}`);
this.log.debug(`Uninstalled plugin ${plg}${name}${db}: ${stdout}`);
resolve(name);
}
});
});
}
/**
* Loads a plugin and returns the corresponding MatterbridgePlatform instance.
* @param plugin - The plugin to load.
* @param start - Optional flag indicating whether to start the plugin after loading. Default is false.
* @param message - Optional message to pass to the plugin when starting.
* @returns A Promise that resolves to the loaded MatterbridgePlatform instance.
* @throws An error if the plugin is not enabled, already loaded, or fails to load.
*/
async load(plugin, start = false, message = '', configure = false) {
const { promises } = await import('node:fs');
const { default: path } = await import('node:path');
if (!plugin.enabled) {
this.log.error(`Plugin ${plg}${plugin.name}${er} not enabled`);
return undefined;
}
if (plugin.platform) {
this.log.error(`Plugin ${plg}${plugin.name}${er} already loaded`);
return plugin.platform;
}
this.log.info(`Loading plugin ${plg}${plugin.name}${nf} type ${typ}${plugin.type}${nf}`);
try {
// Load the package.json of the plugin
const packageJson = JSON.parse(await promises.readFile(plugin.path, 'utf8'));
// Resolve the main module path relative to package.json
const pluginEntry = path.resolve(path.dirname(plugin.path), packageJson.main);
// Dynamically import the plugin
const { pathToFileURL } = await import('node:url');
const pluginUrl = pathToFileURL(pluginEntry);
this.log.debug(`Importing plugin ${plg}${plugin.name}${db} from ${pluginUrl.href}`);
const pluginInstance = await import(pluginUrl.href);
this.log.debug(`Imported plugin ${plg}${plugin.name}${db} from ${pluginUrl.href}`);
// Call the default export function of the plugin, passing this MatterBridge instance, the log and the config
if (pluginInstance.default) {
const config = await this.loadConfig(plugin);
// Preset the plugin properties here in case the plugin throws an error during loading. In this case the user can change the config and restart the plugin.
plugin.name = packageJson.name;
plugin.description = packageJson.description ?? 'No description';
plugin.version = packageJson.version;
plugin.author = this.getAuthor(packageJson);
plugin.configJson = config;
plugin.schemaJson = await this.loadSchema(plugin);
config.name = plugin.name;
config.version = packageJson.version;
const log = new AnsiLogger({ logName: plugin.description ?? 'No description', logTimestampFormat: 4 /* TimestampFormat.TIME_MILLIS */, logLevel: config.debug ? "debug" /* LogLevel.DEBUG */ : this.matterbridge.log.logLevel });
const platform = pluginInstance.default(this.matterbridge, log, config);
config.type = platform.type;
platform.name = packageJson.name;
platform.config = config;
platform.version = packageJson.version;
plugin.name = packageJson.name;
plugin.description = packageJson.description ?? 'No description';
plugin.version = packageJson.version;
plugin.author = this.getAuthor(packageJson);
plugin.type = platform.type;
plugin.platform = platform;
plugin.loaded = true;
plugin.registeredDevices = 0;
plugin.addedDevices = 0;
await this.saveToStorage(); // Save the plugin to storage
this.log.notice(`Loaded plugin ${plg}${plugin.name}${nt} type ${typ}${platform.type}${nt} (entrypoint ${UNDERLINE}${pluginEntry}${UNDERLINEOFF})`);
if (start)
await this.start(plugin, message, false);
if (configure)
await this.configure(plugin);
return platform;
}
else {
this.log.error(`Plugin ${plg}${plugin.name}${er} does not provide a default export`);
plugin.error = true;
}
}
catch (err) {
this.log.error(`Failed to load plugin ${plg}${plugin.name}${er}: ${err instanceof Error ? err.message : err}`);
plugin.error = true;
}
return undefined;
}
/**
* Starts a plugin.
*
* @param {RegisteredPlugin} plugin - The plugin to start.
* @param {string} [message] - Optional message to pass to the plugin's onStart method.
* @param {boolean} [configure] - Indicates whether to configure the plugin after starting (default false).
* @returns {Promise<RegisteredPlugin | undefined>} A promise that resolves when the plugin is started successfully, or rejects with an error if starting the plugin fails.
*/
async start(plugin, message, configure = false) {
if (!plugin.loaded) {
this.log.error(`Plugin ${plg}${plugin.name}${er} not loaded`);
return undefined;
}
if (!plugin.platform) {
this.log.error(`Plugin ${plg}${plugin.name}${er} no platform found`);
return undefined;
}
if (plugin.started) {
this.log.error(`Plugin ${plg}${plugin.name}${er} already started`);
return undefined;
}
this.log.info(`Starting plugin ${plg}${plugin.name}${nf} type ${typ}${plugin.type}${nf}`);
try {
await plugin.platform.onStart(message);
this.log.notice(`Started plugin ${plg}${plugin.name}${nt} type ${typ}${plugin.type}${nt}`);
plugin.started = true;
await this.saveConfigFromPlugin(plugin);
if (configure)
await this.configure(plugin);
return plugin;
}
catch (err) {
plugin.error = true;
this.log.error(`Failed to start plugin ${plg}${plugin.name}${er}: ${err instanceof Error ? err.message : err}`);
}
return undefined;
}
/**
* Configures a plugin.
*
* @param {RegisteredPlugin} plugin - The plugin to configure.
* @returns {Promise<void>} A promise that resolves when the plugin is configured successfully, or rejects with an error if configuration fails.
*/
async configure(plugin) {
if (!plugin.loaded) {
this.log.error(`Plugin ${plg}${plugin.name}${er} not loaded`);
return undefined;
}
if (!plugin.started) {
this.log.error(`Plugin ${plg}${plugin.name}${er} not started`);
return undefined;
}
if (!plugin.platform) {
this.log.error(`Plugin ${plg}${plugin.name}${er} no platform found`);
return undefined;
}
if (plugin.configured) {
this.log.debug(`Plugin ${plg}${plugin.name}${db} already configured`);
return undefined;
}
this.log.info(`Configuring plugin ${plg}${plugin.name}${nf} type ${typ}${plugin.type}${nf}`);
try {
await plugin.platform.onConfigure();
this.log.notice(`Configured plugin ${plg}${plugin.name}${nt} type ${typ}${plugin.type}${nt}`);
plugin.configured = true;
return plugin;
}
catch (err) {
plugin.error = true;
this.log.error(`Failed to configure plugin ${plg}${plugin.name}${er}: ${err}`);
}
return undefined;
}
/**
* Shuts down a plugin.
*
* This method shuts down a plugin by calling its `onShutdown` method and resetting its state.
* It logs the shutdown process and optionally removes all devices associated with the plugin.
*
* @param {RegisteredPlugin} plugin - The plugin to shut down.
* @param {string} [reason] - The reason for shutting down the plugin.
* @param {boolean} [removeAllDevices=false] - Whether to remove all devices associated with the plugin.
* @param {boolean} [force=false] - Whether to force the shutdown even if the plugin is not loaded or started.
* @returns {Promise<RegisteredPlugin | undefined>} A promise that resolves to the shut down plugin object, or undefined if the shutdown failed.
*/
async shutdown(plugin, reason, removeAllDevices = false, force = false) {
this.log.debug(`Shutting down plugin ${plg}${plugin.name}${db}`);
if (!plugin.loaded) {
this.log.debug(`Plugin ${plg}${plugin.name}${db} not loaded`);
if (!force)
return undefined;
}
if (!plugin.started) {
this.log.debug(`Plugin ${plg}${plugin.name}${db} not started`);
if (!force)
return undefined;
}
if (!plugin.configured) {
this.log.debug(`Plugin ${plg}${plugin.name}${db} not configured`);
}
if (!plugin.platform) {
this.log.debug(`Plugin ${plg}${plugin.name}${db} no platform found`);
return undefined;
}
this.log.info(`Shutting down plugin ${plg}${plugin.name}${nf}: ${reason}...`);
try {
await plugin.platform.onShutdown(reason);
plugin.locked = undefined;
plugin.error = undefined;
plugin.loaded = undefined;
plugin.started = undefined;
plugin.configured = undefined;
plugin.platform = undefined;
if (removeAllDevices) {
this.log.info(`Removing all endpoints for plugin ${plg}${plugin.name}${nf}: ${reason}...`);
await this.matterbridge.removeAllBridgedEndpoints(plugin.name);
}
plugin.registeredDevices = undefined;
plugin.addedDevices = undefined;
this.log.notice(`Shutdown of plugin ${plg}${plugin.name}${nt} completed`);
return plugin;
}
catch (err) {
this.log.error(`Failed to shut down plugin ${plg}${plugin.name}${er}: ${err instanceof Error ? err.message : err}`);
}
return undefined;
}
/**
* Loads the configuration for a plugin.
* If the configuration file exists, it reads the file and returns the parsed JSON data.
* If the configuration file does not exist, it creates a new file with default configuration and returns it.
* If any error occurs during file access or creation, it logs an error and return un empty config.
*
* @param plugin - The plugin for which to load the configuration.
* @returns A promise that resolves to the loaded or created configuration.
*/
async loadConfig(plugin) {
const { default: path } = await import('node:path');
const { promises } = await import('node:fs');
const { shelly_config, somfytahoma_config, zigbee2mqtt_config } = await import('./defaultConfigSchema.js');
const configFile = path.join(this.matterbridge.matterbridgeDirectory, `${plugin.name}.config.json`);
try {
await promises.access(configFile);
const data = await promises.readFile(configFile, 'utf8');
const config = JSON.parse(data);
this.log.debug(`Loaded config file ${configFile} for plugin ${plg}${plugin.name}${db}.`);
// this.log.debug(`Loaded config file ${configFile} for plugin ${plg}${plugin.name}${db}.\nConfig:${rs}\n`, config);
// The first time a plugin is added to the system, the config file is created with the plugin name and type "AnyPlatform".
config.name = plugin.name;
config.type = plugin.type;
if (config.debug === undefined)
config.debug = false;
if (config.unregisterOnShutdown === undefined)
config.unregisterOnShutdown = false;
return config;
}
catch (err) {
const nodeErr = err;
if (nodeErr.code === 'ENOENT') {
let config;
if (plugin.name === 'matterbridge-zigbee2mqtt')
config = zigbee2mqtt_config;
else if (plugin.name === 'matterbridge-somfy-tahoma')
config = somfytahoma_config;
else if (plugin.name === 'matterbridge-shelly')
config = shelly_config;
else
config = { name: plugin.name, type: plugin.type, debug: false, unregisterOnShutdown: false };
try {
await promises.writeFile(configFile, JSON.stringify(config, null, 2), 'utf8');
this.log.debug(`Created config file ${configFile} for plugin ${plg}${plugin.name}${db}.`);
// this.log.debug(`Created config file ${configFile} for plugin ${plg}${plugin.name}${db}.\nConfig:${rs}\n`, config);
return config;
}
catch (err) {
this.log.error(`Error creating config file ${configFile} for plugin ${plg}${plugin.name}${er}: ${err instanceof Error ? err.message : err}`);
return config;
}
}
else {
this.log.error(`Error accessing config file ${configFile} for plugin ${plg}${plugin.name}${er}: ${err instanceof Error ? err.message : err}`);
return { name: plugin.name, type: plugin.type, debug: false, unregisterOnShutdown: false };
}
}
}
/**
* Saves the configuration of a plugin to a file.
*
* This method saves the configuration of the specified plugin to a JSON file in the matterbridge directory.
* If the plugin's configuration is not found, it logs an error and rejects the promise. If the configuration
* is successfully saved, it logs a debug message. If an error occurs during the file write operation, it logs
* the error and rejects the promise.
*
* @param {RegisteredPlugin} plugin - The plugin whose configuration is to be saved.
* @returns {Promise<void>} A promise that resolves when the configuration is successfully saved, or rejects if an error occurs.
* @throws {Error} If the plugin's configuration is not found.
*/
async saveConfigFromPlugin(plugin) {
const { default: path } = await import('node:path');
const { promises } = await import('node:fs');
if (!plugin.platform?.config) {
this.log.error(`Error saving config file for plugin ${plg}${plugin.name}${er}: config not found`);
return Promise.reject(new Error(`Error saving config file for plugin ${plg}${plugin.name}${er}: config not found`));
}
const configFile = path.join(this.matterbridge.matterbridgeDirectory, `${plugin.name}.config.json`);
try {
await promises.writeFile(configFile, JSON.stringify(plugin.platform.config, null, 2), 'utf8');
plugin.configJson = plugin.platform.config;
this.log.debug(`Saved config file ${configFile} for plugin ${plg}${plugin.name}${db}`);
// this.log.debug(`Saved config file ${configFile} for plugin ${plg}${plugin.name}${db}.\nConfig:${rs}\n`, plugin.platform.config);
return Promise.resolve();
}
catch (err) {
this.log.error(`Error saving config file ${configFile} for plugin ${plg}${plugin.name}${er}: ${err}`);
return Promise.reject(err);
}
}
/**
* Saves the configuration of a plugin from a JSON object to a file.
*
* This method saves the provided configuration of the specified plugin to a JSON file in the matterbridge directory.
* It first checks if the configuration data is valid by ensuring it contains the correct name and type, and matches
* the plugin's name. If the configuration data is invalid, it logs an error and returns. If the configuration is
* successfully saved, it updates the plugin's `configJson` property and logs a debug message. If an error occurs
* during the file write operation, it logs the error and returns.
*
* @param {RegisteredPlugin} plugin - The plugin whose configuration is to be saved.
* @param {PlatformConfig} config - The configuration data to be saved.
* @returns {Promise<void>} A promise that resolves when the configuration is successfully saved, or returns if an error occurs.
*/
async saveConfigFromJson(plugin, config) {
const { default: path } = await import('node:path');
const { promises } = await import('node:fs');
if (!config.name || !config.type || config.name !== plugin.name) {
this.log.error(`Error saving config file for plugin ${plg}${plugin.name}${er}. Wrong config data content:${rs}\n`, config);
return;
}
const configFile = path.join(this.matterbridge.matterbridgeDirectory, `${plugin.name}.config.json`);
try {
await promises.writeFile(configFile, JSON.stringify(config, null, 2), 'utf8');
plugin.configJson = config;
plugin.restartRequired = true;
if (plugin.platform) {
plugin.platform.config = config;