diff --git a/package.json b/package.json index 9b099d5ec..178bfe276 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "etherpad", "description": "A free and open source realtime collaborative editor", "homepage": "https://etherpad.org", + "type": "module", "keywords": [ "etherpad", "realtime", diff --git a/src/static/js/pluginfw/LinkInstaller.ts b/src/static/js/pluginfw/LinkInstaller.ts new file mode 100644 index 000000000..a50a5b801 --- /dev/null +++ b/src/static/js/pluginfw/LinkInstaller.ts @@ -0,0 +1,204 @@ +import {IPluginInfo, PluginManager} from "live-plugin-manager-pnpm"; +import path from "path"; +import {node_modules, pluginInstallPath} from "./installer"; +import {accessSync, constants, rmSync, symlinkSync, unlinkSync} from "node:fs"; +import {dependencies, name} from '../../../package.json' +import {pathToFileURL} from 'node:url'; +const settings = require('../../../node/utils/Settings'); +import {readFileSync} from "fs"; + +export class LinkInstaller { + private livePluginManager: PluginManager; + private loadedPlugins: IPluginInfo[] = []; + /* + * A map of dependencies to their dependents + * + */ + private readonly dependenciesMap: Map>; + + constructor() { + this.livePluginManager = new PluginManager({ + pluginsPath: pluginInstallPath, + cwd: path.join(settings.root, 'src') + }); + this.dependenciesMap = new Map(); + + } + + + public async init() { + // Insert Etherpad lite dependencies + for (let [dependency] of Object.entries(dependencies)) { + if (this.dependenciesMap.has(dependency)) { + this.dependenciesMap.get(dependency)?.add(name) + } else { + this.dependenciesMap.set(dependency, new Set([name])) + } + } + + } + + + public async installFromPath(path: string) { + const installedPlugin = await this.livePluginManager.installFromPath(path) + await this.checkLinkedDependencies(installedPlugin) + } + + + public async installPlugin(pluginName: string, version?: string) { + if (version) { + const installedPlugin = await this.livePluginManager.install(pluginName, version); + await this.checkLinkedDependencies(installedPlugin) + } else { + const installedPlugin = await this.livePluginManager.install(pluginName); + await this.checkLinkedDependencies(installedPlugin) + } + } + + public async listPlugins() { + const plugins = this.livePluginManager.list() + if (plugins && plugins.length > 0 && this.loadedPlugins.length == 0) { + // Check already installed plugins + for (let plugin of plugins) { + await this.checkLinkedDependencies(plugin) + } + } + return plugins + } + + public async uninstallPlugin(pluginName: string) { + const installedPlugin = this.livePluginManager.getInfo(pluginName) + if (installedPlugin) { + await this.removeSymlink(installedPlugin) + await this.livePluginManager.uninstall(pluginName) + await this.uninstallDependencies(installedPlugin) + } + } + + public async uninstallDependencies(plugin: IPluginInfo) { + for (let [dependency] of Object.entries(plugin.dependencies)) { + if (this.dependenciesMap.has(dependency)) { + const dependants = this.dependenciesMap.get(dependency) + if (dependants?.size == 1) { + // Remove the dependency + rmSync(path.join(pluginInstallPath, dependency), { + force: true, + recursive: true + }) + } + // Remove the dependant from the dependency map + dependants?.delete(plugin.name) + this.dependenciesMap.set(dependency, dependants!) + } + } + } + + private async removeSymlink(plugin: IPluginInfo) { + console.log(`Removing symlink for ${plugin.name}`) + try { + accessSync(path.join(node_modules, plugin.name), constants.F_OK) + await this.unlinkSubDependencies(plugin) + // Remove the plugin itself + this.unlinkDependency(plugin.name) + } catch (err) { + // Symlink does not exist + // So nothing to do + } + } + + private async unlinkSubDependencies(plugin: IPluginInfo) { + const pluginDependencies = Object.keys(plugin.dependencies) + for (let dependency of pluginDependencies) { + console.log(`Unlinking sub dependency ${dependency} for ${plugin.name}`) + await this.unlinkSubDependency(plugin.name, dependency) + } + } + + private async unlinkSubDependency(plugin: string, dependency: string) { + if (this.dependenciesMap.has(dependency)) { + this.dependenciesMap.get(dependency)?.delete(plugin) + if (this.dependenciesMap.get(dependency)!.size > 0) { + // We have other dependants so do not uninstall + return + } + } + this.unlinkDependency(dependency) + // Read sub dependencies + try { + const json:IPluginInfo = JSON.parse( + readFileSync(pathToFileURL(path.join(pluginInstallPath, dependency, 'package.json'))) as unknown as string); + if(json.dependencies){ + for (let [subDependency] of Object.entries(json.dependencies)) { + await this.unlinkSubDependency(dependency, subDependency) + } + } + } catch (e){} + + this.dependenciesMap.delete(dependency) + } + + + private async addSubDependencies(plugin: IPluginInfo) { + const pluginDependencies = Object.keys(plugin.dependencies) + for (let dependency of pluginDependencies) { + await this.addSubDependency(plugin.name, dependency) + } + } + + private async addSubDependency(plugin: string, dependency: string) { + if (this.dependenciesMap.has(dependency)) { + // We already added the sub dependency + this.dependenciesMap.get(dependency)?.add(plugin) + } else { + this.linkDependency(dependency) + // Read sub dependencies + try { + const json:IPluginInfo = JSON.parse( + readFileSync(pathToFileURL(path.join(pluginInstallPath, dependency, 'package.json'))) as unknown as string); + if(json.dependencies){ + Object.keys(json.dependencies).forEach((subDependency: string) => { + this.addSubDependency(dependency, subDependency) + }) + } + } catch (err) { + console.error(`Error reading package.json ${err} for ${pathToFileURL(path.join(pluginInstallPath, dependency, 'package.json')).toString()}`) + } + this.dependenciesMap.set(dependency, new Set([plugin])) + } + } + + private linkDependency(dependency: string) { + try { + // Check if the dependency is already installed + accessSync(path.join(node_modules, dependency), constants.F_OK) + } catch (err) { + symlinkSync(path.join(pluginInstallPath, dependency), path.join(node_modules, dependency), 'dir') + } + } + + private unlinkDependency(dependency: string) { + try { + // Check if the dependency is already installed + accessSync(path.join(node_modules, dependency), constants.F_OK) + unlinkSync(path.join(node_modules, dependency)) + } catch (err) { + // Symlink does not exist + // So nothing to do + } + } + + + private async checkLinkedDependencies(plugin: IPluginInfo) { + // Check if the plugin really exists at source + try { + accessSync(path.join(pluginInstallPath, plugin.name), constants.F_OK) + // Skip if the plugin is already linked + } catch (err) { + // The plugin is not installed + console.error(`Plugin ${plugin.name} is not installed`) + } + await this.addSubDependencies(plugin) + this.dependenciesMap.set(plugin.name, new Set()) + console.log(this.dependenciesMap) + } +} diff --git a/src/static/js/pluginfw/installer.ts b/src/static/js/pluginfw/installer.ts index 0d2c2d334..1912ed7f5 100644 --- a/src/static/js/pluginfw/installer.ts +++ b/src/static/js/pluginfw/installer.ts @@ -14,22 +14,13 @@ const plugins = require('./plugins'); const hooks = require('./hooks'); const runCmd = require('../../../node/utils/run_cmd'); const settings = require('../../../node/utils/Settings'); -import {PluginManager} from 'live-plugin-manager-pnpm'; +import {LinkInstaller} from "./LinkInstaller"; const {findEtherpadRoot} = require('../../../node/utils/AbsolutePaths'); const logger = log4js.getLogger('plugins'); export const pluginInstallPath = path.join(settings.root, 'src','plugin_packages'); - -export const manager = new PluginManager({ - pluginsPath: pluginInstallPath, - hostRequire: require, - requireCoreModules: true, - sandbox: { - env: process.env, - global: global - } -}); +export const node_modules = path.join(findEtherpadRoot(),'src', 'node_modules'); export const installedPluginsPath = path.join(settings.root, 'var/installed_plugins.json'); @@ -47,6 +38,8 @@ const headers = { let tasks = 0; +export const linkInstaller = new LinkInstaller(); + const wrapTaskCb = (cb:Function|null) => { tasks++; @@ -73,9 +66,9 @@ const migratePluginsFromNodeModules = async () => { const _info = info as PackageInfo if (!_info.resolved) { // Install from node_modules directory - await manager.installFromPath(`${findEtherpadRoot()}/node_modules/${pkg}`); + await linkInstaller.installFromPath(`${findEtherpadRoot()}/node_modules/${pkg}`); } else { - await manager.install(pkg); + await linkInstaller.installPlugin(pkg); } })); await persistInstalledPlugins(); @@ -83,6 +76,8 @@ const migratePluginsFromNodeModules = async () => { export const checkForMigration = async () => { logger.info('check installed plugins for migration'); + // Initialize linkInstaller + await linkInstaller.init() @@ -104,7 +99,6 @@ export const checkForMigration = async () => { fs.stat(pluginInstallPath).then(async (err) => { const files = await fs.readdir(pluginInstallPath); - const node_modules = path.join(findEtherpadRoot(),'src', 'node_modules'); for (let file of files){ const moduleName = path.basename(file); try { @@ -124,7 +118,7 @@ export const checkForMigration = async () => { for (const plugin of installedPlugins.plugins) { if (plugin.name.startsWith(plugins.prefix) && plugin.name !== 'ep_etherpad-lite') { - await manager.install(plugin.name, plugin.version); + await linkInstaller.installPlugin(plugin.name, plugin.version); } } }; @@ -146,13 +140,8 @@ const persistInstalledPlugins = async () => { export const uninstall = async (pluginName: string, cb:Function|null = null) => { cb = wrapTaskCb(cb); logger.info(`Uninstalling plugin ${pluginName}...`); - //TODO track if a dependency is used by another plugin - const plugins = manager.list(); - const info = manager.getInfo(pluginName) - await manager.uninstall(pluginName); - - + await linkInstaller.uninstallPlugin(pluginName); logger.info(`Successfully uninstalled plugin ${pluginName}`); await hooks.aCallAll('pluginUninstall', {pluginName}); cb(null); @@ -161,7 +150,7 @@ export const uninstall = async (pluginName: string, cb:Function|null = null) => export const install = async (pluginName: string, cb:Function|null = null) => { cb = wrapTaskCb(cb); logger.info(`Installing plugin ${pluginName}...`); - await manager.install(pluginName); + await linkInstaller.installPlugin(pluginName); logger.info(`Successfully installed plugin ${pluginName}`); await hooks.aCallAll('pluginInstall', {pluginName}); cb(null); diff --git a/src/static/js/pluginfw/plugins.js b/src/static/js/pluginfw/plugins.js index 4e7108747..61c28ae7f 100644 --- a/src/static/js/pluginfw/plugins.js +++ b/src/static/js/pluginfw/plugins.js @@ -121,8 +121,8 @@ exports.update = async () => { }; exports.getPackages = async () => { - const {manager} = require("./installer"); - const plugins = manager.list(); + const {linkInstaller} = require("./installer"); + const plugins = await linkInstaller.listPlugins(); const newDependencies = {}; for (const plugin of plugins) { diff --git a/src/tsconfig.json b/src/tsconfig.json index 179aae429..a42ef0188 100644 --- a/src/tsconfig.json +++ b/src/tsconfig.json @@ -6,12 +6,13 @@ /* Language and Environment */ "target": "es6", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ /* Modules */ - "module": "commonjs", /* Specify what module code is generated. */ + "module": "CommonJS", /* Specify what module code is generated. */ "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ /* Type Checking */ "strict": true, /* Enable all strict type-checking options. */ /* Completeness */ - "skipLibCheck": true /* Skip type checking all .d.ts files. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */, + "resolveJsonModule": true } }