import fs from 'fs-extra'; import path from 'path'; import { URL } from 'url'; import semver from 'semver'; import tar from 'tar'; import { httpGET } from '../../common'; import { logger } from '../../core'; import { IEnvironment } from '../../types'; import { PluginManager } from '../manager'; import { IPlugin, IPluginManifest } from '../plugin'; import { IRepoPluginDef, IRepository } from './repository'; /** * Plugin repository manager */ export class RepositoryManager { private repositories: Map = new Map(); constructor(private env: IEnvironment, private plugins: PluginManager) {} /** * Determine if this repository provides a specific plugin * @param repo Repository manifest * @param mf Plugin manifest * @returns Repository plugin definition */ public repoProvidesPlugin( repo: IRepository, mf: IPluginManifest ): IRepoPluginDef | undefined { return repo.plugins.find(plugin => plugin.name === mf.name && repo.name === mf.repository); } /** * Find repository providing plugin * @param pname Plugin name * @returns Repository manifest */ public findRepoForPlugin(pname: string): IRepository | undefined { return Array.from(this.repositories.values()).find(repo => repo.plugins.find(plugin => plugin.name === pname) !== undefined ); } /** * Find a repository by name * @param name Repository name * @returns Repository manifest */ public getRepoByName(name: string): IRepository | undefined { return this.repositories.get(name); } /** * Get the list of all installed repositories * @returns All repositories */ public getAll(): IRepository[] { return Array.from(this.repositories.values()); } /** * Install a plugin by name * @param name Plugin name * @returns Plugin manifest */ public async installPlugin(name: string): Promise { let repo; if (name.indexOf('/') !== -1) { const naming = name.split('/'); repo = this.getRepoByName(naming[0]); name = naming[1]; } else { repo = this.findRepoForPlugin(name); } if (!repo) { throw new Error('Could not find a repository for a plugin named ' + name); } const srcFile = name + '.plugin.tgz'; const pluginPath = repo.url + '/' + srcFile; const tempdir = await fs.mkdtemp(path.join(this.env.path, '.sbdl')); const tempfile = path.join(tempdir, srcFile); let manifest: IPluginManifest; try { const save = await httpGET(pluginPath, {}, false, tempfile); const extract = await tar.x({ file: tempfile, C: tempdir, }); const findByName = path.join(tempdir, name); if (!await fs.pathExists(findByName)) { throw new Error('Invalid file provided.'); } const manifestFile = path.join(findByName, 'plugin.json'); if (!await fs.pathExists(manifestFile)) { throw new Error('manifest file does not exist.'); } const loadManifest = await fs.readJSON(manifestFile); if (!loadManifest.name || !loadManifest.version) { throw new Error('Not a valid plugin manifest file.'); } manifest = loadManifest; const fp = path.join(this.env.pluginsPath, manifest.name); manifest.fullPath = fp; manifest.repository = repo.name; await fs.writeJSON(manifestFile, manifest); await fs.copy(findByName, fp); } catch (e) { await fs.remove(tempdir); throw e; } await fs.remove(tempdir); this.plugins.addAvailable(manifest); return manifest; } /** * Uninstall a plugin * @param plugin Plugin name, manifest or object */ public async uninstallPlugin(plugin: string | IPluginManifest | IPlugin): Promise { let realName: string; if (typeof plugin === 'string') { realName = plugin; } else if ('manifest' in plugin) { realName = plugin.manifest.name; } else { realName = plugin.name; } const pluginMeta = this.plugins.getAvailableByName(realName); if (!pluginMeta) { throw new Error('No such plugin exists or is available.'); } const remoPath = path.join(this.env.pluginsPath, realName); await fs.remove(remoPath); this.plugins.removeAvailable(pluginMeta); } /** * Get an up-to-date version of a plugin manifest from remote server. * @param url Repository manifest URL * @returns Repository manifest */ public async getRemote(url: string): Promise { const indexFileGet = await httpGET(url); const meta = JSON.parse(indexFileGet); if (!meta.name || !meta.plugins || !(meta.$schema || meta.schema)) { throw new Error('Invalid metadata file for repository.'); } if (!(/^[a-zA-Z0-9_\-+]+$/.test(meta.name))) { throw new Error('Illegal name for repository!'); } if (meta.schema > 1) { throw new Error('Unsupported metadata version!'); } return meta; } /** * Install a repository by remote URL * @param url Remote repository manifest URL * @returns Repository manifest */ public async installRepository(url: string): Promise { // Add standard repository file path, if missing if (!url.endsWith('/repository.json')) { if (!url.endsWith('/')) { url += '/'; } url += 'repository.json'; } const remote = await this.getRemote(url); const local = path.join(this.env.repositoryPath, remote.name + '.json'); const urlParsed = new URL(url); // Change the path to not include the metadata file const basePath = path.dirname(urlParsed.pathname); urlParsed.pathname = basePath; remote.url = urlParsed.href; await fs.writeJSON(local, remote); this.repositories.set(remote.name, remote); return remote; } /** * Uninstall a repository * @param repo Repository name or manifest */ public async uninstallRepository(repo: string | IRepository): Promise { if (typeof repo === 'string') { repo = this.getRepoByName(repo) as IRepository; if (!repo) { throw new Error('No such repository found!'); } } // Remove repository metadata file const repoFile = path.join(this.env.repositoryPath, repo.name + '.json'); await fs.remove(repoFile); this.repositories.delete(repo.name); // Uninstall all plugins from this repository for (const plugin of repo.plugins) { // Ignore non-installed plugins try { await this.uninstallPlugin(plugin.name); } catch (e) {} } } /** * Check for plugin updates from this repository. * @param repo Repository manifest * @returns Plugin manifests of plugins that can be updated */ public async checkForUpdates(repo: IRepository): Promise { const oprep = await this.updateRepository(repo); // Checking for version differences in the plugins // Get locally installed plugins return this.plugins.availablePlugins.filter(avail => { const repoPlugin = this.repoProvidesPlugin(oprep, avail); if (!repoPlugin) { return false; } return semver.gt(repoPlugin.version, avail.version); }); } /** * Update a repository manifest by going to the remote. * @param repo Repository manifest * @returns Up-to-date repository manifest */ public async updateRepository(repo: IRepository): Promise { const remote = await this.getRemote(repo.url + '/repository.json'); if (remote.created <= repo.created) { return repo; } // Repository needs an update logger.debug('Updating repository %s', repo.name); const local = path.join(this.env.repositoryPath, remote.name + '.json'); // Set the URL to the previous value as it shouldn't change remote.url = repo.url; await fs.writeJSON(local, remote); this.repositories.set(remote.name, remote); return remote; } /** * Load installed repositories from manifest files. */ public async loadFromFiles(): Promise { const repos = await fs.readdir(this.env.repositoryPath); const loaded = []; for (const rf of repos) { const file = path.join(this.env.repositoryPath, rf); try { const contents = await fs.readJSON(file); if (!contents.name || !contents.url || !contents.plugins) { throw new Error('Invalid repository file ' + rf); } if (!(/^[a-zA-Z0-9_\-+]+$/.test(contents.name))) { throw new Error(`"${rf}" is an illegal name for a repository!`); } loaded.push(contents.name); this.repositories.set(contents.name, contents); } catch (e) { logger.warn('Could not load repository ' + rf); } } logger.debug('Loaded repositories:', loaded.join(', ')); } }