adaptorex
Version:
Connect all your live interactive storytelling devices and software
687 lines (593 loc) • 21.1 kB
JavaScript
/**
* 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