UNPKG

adaptorex

Version:

Connect all your live interactive storytelling devices and software

687 lines (593 loc) 21.1 kB
/** * manages adaptor:ex plugin installation, discovery and loading * * @requires path * @requires module * @requires fs * * @requires pacote * @requires @npmcli/arborist * @requires file * * @module plugin_management * @copyright Lasse Marburg 2026 * @license MIT */ const semver = require("semver") const { createRequire } = require("node:module") const path = require("path") const fs = require("node:fs/promises") const pacote = require("pacote") const Arborist = require("@npmcli/arborist") const file = require("./file.js") /** * Plugin Manager * * Manages plugin discovery, installation, and loading */ class PluginManager { constructor(config) { this.config = config /** * @type {string} * default plugin folder in data directory */ this.plugin_dir = path.resolve(this.config.data, "plugins") // Ensure plugin directory exists file.mkdir(this.plugin_dir) /** * @type {string} * folder plugins get installed to (inside plugins directory) */ this.install_dir = path.join(this.plugin_dir, "installed") // Ensure installed plugins directory exists file.mkdir(this.install_dir) /** * @type {string} * folder for auto reloaded development plugins (inside plugins directory) */ this.development_dir = path.join(this.plugin_dir, "development") // Ensure development plugins directory exists file.mkdir(this.development_dir) this.log = log.getContextLog("plugin manager") /** * @type {Object<string, Object>} * plugin modules that have been installed and required via {@link loadPlugin} */ this.plugin_modules = {} /** * @type {Object<string, Object>} * cache for remote plugins that have been fetched from npm registry recently */ this.remote_plugins_cache = {} /** * @type {number} * timestamp of last fetch of remote plugins */ this.last_fetch = 0 /** * @type {number} * time in ms between two fetches of remote plugins (3 minutes) */ this.fetch_interval = 3 * 60 * 1000 } /** * Print a list of all available plugins with install status and version information. */ async listPlugins() { const plugins = await this.getAvailablePlugins() this.log.info(`${plugins.length} Plugins available:`) console.log() plugins.forEach(async (p, i) => { let latest_version = p.version || "n.d." let install_hint = "" let used_hint = "" if (this.plugin_modules[p.name]) { used_hint = `in use` } const installed = this.isPluginInstalled(p) if (installed.installed) { if (installed.path) { let installed_pckg try { installed_pckg = await file.loadJSON( path.join(installed.path, "package.json") ) if ( p.version && installed_pckg.version && semver.gt(p.version, installed_pckg.version) ) { install_hint = `installed: v${installed_pckg.version} ↑ update available` } else { install_hint = `installed: v${installed_pckg.version}` } } catch (e) { this.log.warn( `Could not load package.json for installed plugin ${p.name}: ${e}` ) install_hint = `installed: n.d.` } } } let source = "" switch (p.source) { case "development": source = "(dev)" break case "local": source = "(loc)" break case "internal": source = "(int)" break case "npm": source = "(npm)" break } log.info( `${i + 1}`.padStart(3), `${p.name.padEnd(15)} v${latest_version.padStart(5)} ${source.padEnd(5)} ${used_hint.padEnd(7)} ${install_hint}` ) }) } /** * Games that load a plugin, use this function, so that all plugin modules are only required once. * If another game added the same plugin already the required module is used. * * plugin has to be a folder in the source plugin directory a custom plugin directory or an npm package and needs to contain a package.json. * * @callback GetPlugin * @param {string} - plugin name * @returns {Object} new instance of the plugins main class */ async loadPlugin(plugin) { if (!this.plugin_modules.hasOwnProperty(plugin)) { let pckg = await this.findAvailablePlugin("name", plugin) if (!pckg) { throw new adaptor.NotFoundError(`Could not find plugin ${plugin}`) } const installed = this.isPluginInstalled(pckg) if (!installed.installed) { await this.installPlugin(pckg) } else if (installed.source == "development") { pckg.source = "development" } if (pckg.source == "internal") { this.plugin_modules[plugin] = require(path.join(pckg.path, pckg.main)) } else if (pckg.source == "development") { this.log.debug( `get plugin ${plugin} from ${pckg.source} package ${pckg.package_name} at ${pckg.path}` ) const req = createRequire(__filename) this.plugin_modules[plugin] = req(pckg.package_name) } else { const plugin_install_dir = path.join( this.install_dir, pckg.package_name, "node_modules" ) this.log.debug( `get plugin ${plugin} from ${pckg.source} package ${pckg.package_name} at ${plugin_install_dir}` ) const req = createRequire(plugin_install_dir + path.sep) this.plugin_modules[plugin] = req(pckg.package_name) } this.plugin_modules[plugin].package = pckg } let plugin_object if (this.plugin_modules[plugin].hasOwnProperty("Plugin")) { plugin_object = new this.plugin_modules[plugin].Plugin() plugin_object.name = plugin plugin_object.package = this.plugin_modules[plugin].package } else { plugin_object = this.plugin_modules[plugin] } return plugin_object } /** * Remove a plugin from the plugin manager. * * Plugin instance and node module will be removed. * * @param {string} plugin - plugin name * @returns {Promise<void>} - returns once plugin was removed */ async removePluginModule(plugin) { if (!this.plugin_modules.hasOwnProperty(plugin)) { this.log.debug(`Did not remove plugin module. Plugin ${plugin} not found`) return } const pckg = this.plugin_modules[plugin].package delete this.plugin_modules[plugin] if (pckg.source == "development") { const req = createRequire(__filename) delete req.cache[req.resolve(pckg.package_name)] } else { const plugin_install_dir = path.join( this.install_dir, pckg.package_name, "node_modules" ) const req = createRequire(plugin_install_dir + path.sep) delete req.cache[req.resolve(pckg.package_name)] } this.log.debug( `Removed ${pckg.source} plugin module ${plugin} (${pckg.package_name})` ) } /** * Searches for plugin based on a directory name * * @param {string} dir * @returns {string} Name of plugin */ findPluginNameByDirectory(dir) { return Object.keys(this.plugin_modules).find((key) => { const pckg = this.plugin_modules[key].package return ( pckg.source === "development" && (path.basename(pckg.path) === dir || pckg.package_name === dir || pckg.name === dir) ) }) } /** * Finds a plugin in the available plugins list that matches the given key and value. * * @param {string} key - key to look up in the plugin object * @param value - value in key to match * @returns {Object} a plugin object that matches the given key and value, or undefined if no plugin was found. */ async findAvailablePlugin(key, value) { let plugins = await this.getAvailablePlugins() return plugins.find((p) => p[key] == value) } /** * Gets a list of all available plugins, both local and remote (if internet is available). * * Local plugins with same name are preferred over remote plugins * * @returns {Promise<Array<Object>>} resolves to an array of plugin information objects */ async getAvailablePlugins() { const local_plugins = this.getAvailableLocalPlugins() let remote_plugins = [] try { remote_plugins = await this.getAvailableRemotePlugins() remote_plugins = remote_plugins.filter( (remote_plugin) => !local_plugins.some( (local_plugin) => local_plugin.name === remote_plugin.name ) ) } catch (error) { this.log.warn(`Could not get remote npm packages: ${error.message}`) } return local_plugins.concat(remote_plugins) } /** * Returns a list of available local plugins. Local plugin sources can be: * * 1) plugins folder of the adaptor:ex source (internal). * 2) plugins folder of the game data directory (local). * 3) development folder of the plugins directory (development). * 3) folders specified in the game config under the key "plugins" (local). * * @returns {Array<Object>} a list of plugin information objects */ getAvailableLocalPlugins() { let plugins = this.getPackages(path.join(__dirname, "plugins"), "internal") plugins = plugins.concat(this.getPackages(this.plugin_dir, "local")) plugins = plugins.concat( this.getPackages(this.development_dir, "development") ) if (this.config.hasOwnProperty("plugins")) { let dirs = typeof this.config.plugins === "string" ? [this.config.plugins] : this.config.plugins for (let dir of dirs) { plugins = plugins.concat(this.getPackages(dir, "local")) } } return plugins } /** * Scans a directory for all subdirectories containing a package.json file. * Returns an array of objects containing the package information and the path to the package. * The source parameter is used to set the source property of the returned objects. * * @param {string} directory - directory to scan for plugins * @param {string} source - source of the plugin, must be one of the following values: "local", "npm", "internal" * @returns {Array<Object>} array of package information objects */ getPackages(directory, source) { let directories = [] if (path.isAbsolute(directory)) { directories = file .ls(directory, "directories") .map((dir) => path.join(directory, dir)) } else { directories = file .ls(path.join(directory), "directories") .map((dir) => path.resolve(directory, dir)) } let plugin_packages = [] for (let plugin_dir of directories) { if (file.exists(path.join(plugin_dir, "package.json"))) { let pckg = require(plugin_dir + "/package.json") if ( !pckg.hasOwnProperty("adaptorex") && !pckg.hasOwnProperty("package_name") ) { this.log.trace( `Plugin ${pckg.name} is missing the required "adaptorex" field in ${plugin_dir}/package.json` ) continue } pckg.path = plugin_dir if (!pckg.package_name) { pckg.package_name = pckg.name } Object.assign(pckg, pckg.adaptorex) delete pckg.adaptorex pckg.source = source const installed = this.isPluginInstalled(pckg) pckg.installed = installed.installed plugin_packages.push(pckg) } } return plugin_packages } /** * Fetches available plugins from npm registry * * Only packages that use the tag "adaptorex-plugin" are fetched * * @returns {Promise<Array<Object>>} resolves to an array of plugin information objects */ async getAvailableRemotePlugins() { if (Date.now() - this.last_fetch < this.fetch_interval) { return this.remote_plugins_cache } const response = await fetch( `https://registry.npmjs.org/-/v1/search?text=keywords:adaptorex-plugin` ) if (!response.ok) { throw new adaptor.ConnectionError( `Could not fetch plugins from npm. Registry returned ${response.status}: ${response.statusText}` ) } const data = await response.json() this.log.debug( `Found ${data.objects.length} adaptorex plugins in npm registry.` ) let pluginPromises = data.objects.map(async (obj) => { const pckgResponse = await fetch( `https://registry.npmjs.org/${encodeURIComponent(obj.package.name)}` ) if (!pckgResponse.ok) { throw new adaptor.ConnectionError( `Could not fetch plugin data for ${obj.plugin.name} from npm. Registry returned ${response.status}: ${response.statusText}` ) } const pckgData = await pckgResponse.json() // Get the latest version metadata const latestVersion = pckgData["dist-tags"]?.latest || Object.keys(pckgData.versions).pop() const versionData = pckgData.versions[latestVersion] if ( semver.lt( versionData.version, this.config["minNpmPluginVersion"] || "0.1.0" ) ) { return undefined } if (!versionData.package_name) { versionData.package_name = versionData.name } Object.assign(versionData, versionData.adaptorex) delete versionData.adaptorex versionData.source = "npm" const installed = this.isPluginInstalled(versionData) versionData.installed = installed.installed return versionData }) const plugins = await Promise.all(pluginPromises) this.remote_plugins_cache = plugins.filter(Boolean) this.last_fetch = Date.now() return this.remote_plugins_cache } resetRemotePluginsCache() { this.last_fetch = 0 } /** * Checks if a plugin is already installed in the plugin directory. * To be installed, a plugin must be resolvable using createRequire. * * "internal" plugins are always considered installed * * @param {Object} pckg - plugin data object * @returns {Object} object with "installed" (true/false) and optional "source" ("internal"/"development") properties */ isPluginInstalled(pckg) { if (pckg.source == "internal") { return { installed: true, source: "internal" } } try { const req = createRequire(__filename) let resolved_path = req.resolve(pckg.package_name) return { installed: true, source: "development", path: path.dirname(resolved_path) } } catch (err) { // Plugin is not a development plugin } try { const plugin_install_dir = path.join(this.install_dir, pckg.package_name) if (!file.exists(plugin_install_dir)) { return { installed: false } } const req = createRequire(plugin_install_dir + path.sep) let resolved_path = req.resolve(pckg.package_name) return { installed: true, path: path.dirname(resolved_path) } } catch (err) { // Plugin is not installed return { installed: false } } } /** * Installs a plugin either from the local file system (source == "local") or from npm (source == "npm"). * * @param {string|Object} plugin - name of plugin to install or plugin data object * @param {string} [version] - version of the plugin to install. NPM only * @throws {adaptor.NotFoundError} - if the plugin was not found * @returns {Promise<void>} resolves when the plugin was installed */ async installPlugin(plugin, version) { if (typeof plugin === "string") { plugin = await this.findAvailablePlugin("name", plugin) } if (!plugin) { throw new adaptor.NotFoundError( `Could not find plugin ${JSON.stringify(plugin)}` ) } if (plugin.source == "npm") { await this.installRemotePlugin(plugin, version) } else if (plugin.source == "local") { await this.installLocalPlugin(plugin) } else if (plugin.source == "development") { await this.linkLocalPlugin(plugin) } } /** * Links a plugin from the local file system using npm link. * * Creates a symlink to the plugin source directory, allowing real-time development * without reinstalling after each change. * * @param {Object} plugin - plugin data object * @returns {Promise<void>} resolves when the plugin has been linked */ async linkLocalPlugin(plugin) { this.log.info(`Linking ${plugin.name} from ${plugin.path}...`) // Link local plugin into adaptor:ex node_modules const link_path = path.join(__dirname, "node_modules", plugin.package_name) // Remove existing symlink if present await fs.rm(link_path, { recursive: true, force: true }) await fs.symlink(plugin.path, link_path, "junction") this.log.info( `Linked local plugin "${plugin.name}" (changes will be reflected immediately)` ) } /** * Installs a plugin from the local file system. * * The plugin is packed into a tarball using npm pack, then installed into the plugin directory * using npm install. * * @param {Object} plugin - plugin data object * @returns {Promise<void>} resolves when the plugin has been installed */ async installLocalPlugin(plugin) { this.log.info(`Installing ${plugin.name} from ${plugin.path}...`) const prefix = path.join(this.install_dir, plugin.package_name) file.mkdir(prefix) // Extract local package directly using pacote (no npm pack needed) const dest = path.join(prefix, "node_modules", plugin.package_name) await pacote.extract(`file:${plugin.path}`, dest, { cache: path.join(this.install_dir, ".cache") }) // Reify (install) the plugin's own dependencies using arborist const arb = new Arborist({ path: prefix, cache: path.join(this.install_dir, ".cache") }) await arb.reify({ add: [`file:${plugin.path}`], legacyPeerDeps: true }) this.log.info(`installed local plugin "${plugin.name}"`) } /** * Installs a plugin from npm * * @param {string} packageName - Name of the npm package * @param {string} version - Version to install (default: 'latest') */ async installRemotePlugin(plugin, version) { const package_spec = version ? `${plugin.package_name}@${version}` : `${plugin.package_name}@${plugin.version || "latest"}` const install_path = path.join(this.install_dir, plugin.package_name) this.log.info(`Installing ${package_spec} from npm ...`) file.mkdir(install_path) // Extract the package itself into the expected location const dest = path.join(install_path, "node_modules", plugin.package_name) await pacote.extract(package_spec, dest, { cache: path.join(this.install_dir, ".cache") }) // Reify (install) the plugin's own dependencies using arborist const arb = new Arborist({ path: install_path, cache: path.join(this.install_dir, ".cache") }) await arb.reify({ add: [package_spec], legacyPeerDeps: true }) this.resetRemotePluginsCache() this.log.info( `installed ${plugin.name} from npm package ${plugin.package_name} (${version || plugin.version})` ) } /** * Uninstalls a plugin by removing the package folder from the plugins directory. * * Internal plugins can not be uninstalled. * * @param {string|Object} plugin - Name of the plugin or plugin information object * @returns {Promise<void>} resolves when the plugin was uninstalled * @throws {adaptor.NotFoundError} - if the plugin or the directory do not exist * @throws {adaptor.ForbiddenError} - if the plugin is an internal plugin */ async uninstallPlugin(plugin) { if (typeof plugin === "string") { plugin = await this.findAvailablePlugin("name", plugin) } if (!plugin) { throw new adaptor.NotFoundError(`Could not find plugin`) } if (plugin.source == "internal") { throw new adaptor.ForbiddenError( `Internal plugins can not be uninstalled` ) } if (plugin.source == "development") { this.log.info(`Unlinking ${plugin.name}...`) const link_path = path.join( __dirname, "node_modules", plugin.package_name ) await fs.rm(link_path, { recursive: true, force: true }) this.log.info(`Unlinked local development plugin "${plugin.name}"`) } else { const plugin_install_dir = path.join( this.install_dir, plugin.package_name ) if (!file.exists(plugin_install_dir)) { throw new adaptor.NotFoundError( `Can not uninstall. Plugin folder does not exist: ${plugin_install_dir}` ) } this.log.info(`Uninstalling ${plugin.name}...`) file.rm(plugin_install_dir) this.log.info( `${plugin.source} package ${plugin.package_name} has been removed` ) } } } module.exports.PluginManager = PluginManager