UNPKG

homebridge-config-ui-x

Version:

A web based management, configuration and control platform for Homebridge.

1,008 lines (1,007 loc) • 60.9 kB
"use strict"; var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; var __metadata = (this && this.__metadata) || function (k, v) { if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v); }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; var PluginsService_1; Object.defineProperty(exports, "__esModule", { value: true }); exports.PluginsService = void 0; const node_child_process_1 = require("node:child_process"); const node_os_1 = require("node:os"); const node_path_1 = require("node:path"); const node_process_1 = __importDefault(require("node:process")); const axios_1 = require("@nestjs/axios"); const common_1 = require("@nestjs/common"); const axios_2 = __importDefault(require("axios")); const bash_color_1 = require("bash-color"); const fs_extra_1 = require("fs-extra"); const lodash_1 = require("lodash"); const node_cache_1 = __importDefault(require("node-cache")); const p_limit_1 = __importDefault(require("p-limit")); const rxjs_1 = require("rxjs"); const semver_1 = require("semver"); const config_service_1 = require("../../core/config/config.service"); const logger_service_1 = require("../../core/logger/logger.service"); const node_pty_service_1 = require("../../core/node-pty/node-pty.service"); let PluginsService = PluginsService_1 = class PluginsService { constructor(httpService, nodePtyService, logger, configService) { this.httpService = httpService; this.nodePtyService = nodePtyService; this.logger = logger; this.configService = configService; this.npm = this.getNpmPath(); this.paths = this.getBasePaths(); this.pluginListUrl = 'https://raw.githubusercontent.com/homebridge/plugins/latest/'; this.pluginListFile = `${this.pluginListUrl}assets/plugins-v2.min.json`; this.hiddenPlugins = []; this.maintainedPlugins = []; this.pluginIcons = {}; this.pluginAuthors = {}; this.pluginNames = {}; this.newScopePlugins = {}; this.verifiedPlugins = []; this.verifiedPlusPlugins = []; this.npmPluginCache = new node_cache_1.default({ stdTTL: 300 }); this.pluginAliasCache = new node_cache_1.default({ stdTTL: 86400 }); this.pluginAliasHints = { 'homebridge-broadlink-rm-pro': { pluginAlias: 'BroadlinkRM', pluginType: 'platform', }, }; this.httpService.axiosRef.interceptors.request.use((config) => { const source = axios_2.default.CancelToken.source(); config.cancelToken = source.token; setTimeout(() => { source.cancel('Timeout: request took more than 15 seconds'); }, 15000); return config; }); this.loadPluginList(); setInterval(this.loadPluginList.bind(this), 60000 * 60 * 12); } fixDisplayName(plugin) { plugin.displayName = plugin.displayName || (plugin.name.charAt(0) === '@' ? plugin.name.split('/')[1] : plugin.name) .replace(/-/g, ' ') .replace(/\w\S*/g, (txt) => txt.charAt(0).toUpperCase() + txt.substring(1).toLowerCase()); return plugin; } async getInstalledPlugins() { const plugins = []; const modules = await this.getInstalledModules(); const disabledPlugins = await this.getDisabledPlugins(); const homebridgePlugins = modules .filter(module => (module.name.indexOf('homebridge-') === 0) || this.isScopedPlugin(module.name)) .filter(module => (0, fs_extra_1.pathExistsSync)((0, node_path_1.join)(module.installPath, 'package.json'))); const limit = (0, p_limit_1.default)((0, node_os_1.cpus)().length); await Promise.all(homebridgePlugins.map(async (pkg) => { return limit(async () => { try { const pkgJson = await (0, fs_extra_1.readJson)((0, node_path_1.join)(pkg.installPath, 'package.json')); if (pkgJson.keywords && pkgJson.keywords.includes('homebridge-plugin')) { const plugin = await this.parsePackageJson(pkgJson, pkg.path); plugin.disabled = disabledPlugins.includes(plugin.name); if (!plugins.find(x => plugin.name === x.name)) { plugins.push(plugin); } else if (!plugin.globalInstall && plugins.find(x => plugin.name === x.name && x.globalInstall === true)) { const index = plugins.findIndex(x => plugin.name === x.name && x.globalInstall === true); plugins[index] = plugin; } } } catch (e) { this.logger.error(`Failed to parse plugin ${pkg.name} as ${e.message}.`); } }); })); this.installedPlugins = plugins.map(plugin => this.fixDisplayName(plugin)); return this.installedPlugins; } async getOutOfDatePlugins() { const plugins = await this.getInstalledPlugins(); return plugins.filter(x => x.updateAvailable); } async lookupPlugin(pluginName) { if (!PluginsService_1.PLUGIN_IDENTIFIER_PATTERN.test(pluginName)) { throw new common_1.BadRequestException('Invalid plugin name.'); } const lookup = await this.searchNpmRegistrySingle(pluginName); if (!lookup.length) { throw new common_1.NotFoundException(); } return lookup[0]; } async getAvailablePluginVersions(pluginName) { if (!PluginsService_1.PLUGIN_IDENTIFIER_PATTERN.test(pluginName) && pluginName !== 'homebridge') { throw new common_1.BadRequestException('Invalid plugin name.'); } try { const fromCache = this.npmPluginCache.get(`lookup-${pluginName}`); const pkg = fromCache || (await (0, rxjs_1.firstValueFrom)((this.httpService.get(`https://registry.npmjs.org/${encodeURIComponent(pluginName).replace(/%40/g, '@')}`, { headers: { accept: 'application/vnd.npm.install-v1+json', }, })))).data; if (!fromCache) { this.npmPluginCache.set(`lookup-${pluginName}`, pkg, 60); } return { tags: pkg['dist-tags'], versions: Object.keys(pkg.versions).reduce((acc, key) => { acc[key] = { version: pkg.versions[key].version, engines: pkg.versions[key].engines || null, }; return acc; }, {}), }; } catch (e) { throw new common_1.NotFoundException(); } } async searchNpmRegistry(query) { if (!this.installedPlugins) { await this.getInstalledPlugins(); } query = query.trim().toLowerCase(); if ((query.startsWith('homebridge-') || this.isScopedPlugin(query)) && !this.hiddenPlugins.includes(query)) { if (!this.installedPlugins.find(x => x.name === query) && Object.keys(this.newScopePlugins).includes(query)) { query = `@homebridge-plugins/${query}`; } return await this.searchNpmRegistrySingle(query); } const q = `${(!query || !query.length) ? '' : `${query.substring(0, 15)}+`}keywords:homebridge-plugin+not:deprecated&size=99`; let searchResults; try { searchResults = (await (0, rxjs_1.firstValueFrom)(this.httpService.get(`https://registry.npmjs.org/-/v1/search?text=${q}`))).data; } catch (e) { this.logger.error(`Failed to search the npm registry (see https://homebridge.io/w/JJSz6 for help) as ${e.message}.`); throw new common_1.InternalServerErrorException(`Failed to search the npm registry as ${e.message}, see logs.`); } const result = searchResults.objects .filter(x => x.package.name.indexOf('homebridge-') === 0 || this.isScopedPlugin(x.package.name)) .filter(x => !this.hiddenPlugins.includes(x.package.name)) .map((pkg) => { let plugin = { name: pkg.package.name, displayName: this.pluginNames[pkg.package.name], private: false, }; const isInstalled = this.installedPlugins.find(x => x.name === plugin.name); if (isInstalled) { plugin = isInstalled; plugin.lastUpdated = pkg.package.date; plugin.keywords = pkg.package.keywords; return plugin; } plugin.publicPackage = true; plugin.installedVersion = null; plugin.latestVersion = pkg.package.version; plugin.lastUpdated = pkg.package.date; plugin.description = (pkg.package.description) ? pkg.package.description.replace(/\(?(?:https?|ftp):\/\/[\n\S]+/g, '').trim() : pkg.package.name; plugin.keywords = pkg.package.keywords; plugin.links = pkg.package.links; plugin.author = this.pluginAuthors[pkg.package.name] || ((pkg.package.publisher) ? pkg.package.publisher.username : null); plugin.verifiedPlugin = this.verifiedPlugins.includes(pkg.package.name); plugin.verifiedPlusPlugin = this.verifiedPlusPlugins.includes(pkg.package.name); plugin.icon = this.pluginIcons[pkg.package.name] ? `${this.pluginListUrl}${this.pluginIcons[pkg.package.name]}` : null; plugin.isHbScoped = pkg.package.name.startsWith('@homebridge-plugins/'); plugin.newHbScope = this.newScopePlugins[pkg.package.name]; plugin.isHbMaintained = this.maintainedPlugins.includes(pkg.package.name); return plugin; }); const searchTerm = query .replace(/[.,/#!$%^&*;:{}=\-_`~()]/g, ''); const searchTerms = searchTerm .split(/\s+/) .filter(term => term.length > 0); const exactMatchPlugins = []; const partialMatchPlugins = []; result.forEach((plugin) => { const pluginKeywords = plugin.keywords.map(keyword => keyword.toLowerCase()); const isExactMatch = pluginKeywords.includes(searchTerm); if (isExactMatch) { exactMatchPlugins.push(plugin); return; } const pluginName = plugin.name.toLowerCase(); const pluginDescription = plugin.description.toLowerCase(); const isPartialMatch = searchTerms.some(term => pluginName.includes(term)) || searchTerms.some(term => pluginKeywords.some(keyword => keyword.includes(term))) || searchTerms.some(term => pluginDescription.includes(term)); if (isPartialMatch) { partialMatchPlugins.push(plugin); } }); return (0, lodash_1.orderBy)([...exactMatchPlugins, ...partialMatchPlugins], ['verifiedPlusPlugin', 'verifiedPlugin'], ['desc', 'desc']) .slice(0, 30) .map(plugin => this.fixDisplayName(plugin)); } async searchNpmRegistrySingle(query) { try { const fromCache = this.npmPluginCache.get(`lookup-${query}`); const pkg = fromCache || (await (0, rxjs_1.firstValueFrom)((this.httpService.get(`https://registry.npmjs.org/${encodeURIComponent(query).replace(/%40/g, '@')}`)))).data; if (!fromCache) { this.npmPluginCache.set(`lookup-${query}`, pkg, 60); } if (!pkg.keywords || !pkg.keywords.includes('homebridge-plugin')) { return []; } let plugin; if (!this.installedPlugins) { await this.getInstalledPlugins(); } const isInstalled = this.installedPlugins.find(x => x.name === pkg.name); if (isInstalled) { plugin = isInstalled; plugin.lastUpdated = pkg.time.modified; return [plugin]; } plugin = { name: pkg.name, private: false, description: (pkg.description) ? pkg.description.replace(/(?:https?|ftp):\/\/[\n\S]+/g, '').trim() : pkg.name, verifiedPlugin: this.verifiedPlugins.includes(pkg.name), verifiedPlusPlugin: this.verifiedPlusPlugins.includes(pkg.name), icon: this.pluginIcons[pkg.name], isHbScoped: pkg.name.startsWith('@homebridge-plugins/'), newHbScope: this.newScopePlugins[pkg.name], isHbMaintained: this.maintainedPlugins.includes(pkg.name), }; plugin.displayName = this.pluginNames[pkg.name]; plugin.publicPackage = true; plugin.latestVersion = pkg['dist-tags'] ? pkg['dist-tags'].latest : undefined; plugin.lastUpdated = pkg.time.modified; plugin.updateAvailable = false; plugin.updateTag = null; plugin.links = { npm: `https://www.npmjs.com/package/${plugin.name}`, homepage: pkg.homepage, bugs: typeof pkg.bugs === 'object' && pkg.bugs?.url ? pkg.bugs.url : null, }; plugin.author = this.pluginAuthors[pkg.name] || ((pkg.maintainers && pkg.maintainers.length) ? pkg.maintainers[0].name : null); plugin.verifiedPlugin = this.verifiedPlugins.includes(pkg.name); plugin.verifiedPlusPlugin = this.verifiedPlusPlugins.includes(pkg.name); plugin.icon = this.pluginIcons[pkg.name] ? `${this.pluginListUrl}${this.pluginIcons[pkg.name]}` : null; plugin.isHbScoped = pkg.name.startsWith('@homebridge-plugins/'); plugin.newHbScope = this.newScopePlugins[pkg.name]; plugin.isHbMaintained = this.maintainedPlugins.includes(pkg.name); return [this.fixDisplayName(plugin)]; } catch (e) { if (e.response?.status !== 404) { this.logger.error(`Failed to search the npm registry (see https://homebridge.io/w/JJSz6 for help) as ${e.message}.`); } return []; } } async manageUi(action, pluginAction, client) { if (action === 'uninstall') { throw new Error('Cannot uninstall the Homebridge UI.'); } if (this.configService.dockerOfflineUpdate && pluginAction.version === 'latest') { await this.updateSelfOffline(client); return true; } if (action === 'install' && pluginAction.version === 'latest') { pluginAction.version = await this.getNpmModuleLatestVersion(pluginAction.name); } const userPlatform = (0, node_os_1.platform)(); if (+pluginAction.version.split('.')[0] > 4) { if (!(0, semver_1.satisfies)(node_process_1.default.version, '>=20')) { throw new Error('Homebridge UI v5 requires Node.js v20 or above.'); } if (!this.configService.serviceMode) { throw new Error('Homebridge UI v5 requires using service mode.'); } if (this.configService.usePnpm) { throw new Error('Homebridge UI v5 is not compatible with the pnpm package manager.'); } if (userPlatform === 'linux' && (0, node_child_process_1.execSync)('uname -m').toString().trim() === 'armv6l') { throw new Error('Homebridge UI v5 is not compatible with your armv6l device.'); } } let installPath = this.configService.customPluginPath ? this.configService.customPluginPath : this.installedPlugins.find(x => x.name === this.configService.name).installPath; await this.getInstalledPlugins(); const existingPlugin = this.installedPlugins.find(x => x.name === pluginAction.name); if (existingPlugin) { installPath = existingPlugin.installPath; } const githubReleaseName = await this.isUiUpdateBundleAvailable(pluginAction); if (githubReleaseName) { try { await this.doUiBundleUpdate(pluginAction, client, githubReleaseName); return true; } catch (e) { client.emit('stdout', (0, bash_color_1.yellow)('\r\nBundled update failed. Trying regular update using npm.\r\n\r\n')); } } if ((0, node_os_1.cpus)().length === 1 && (0, node_os_1.arch)() === 'arm') { client.emit('stdout', (0, bash_color_1.yellow)('***************************************************************\r\n')); client.emit('stdout', (0, bash_color_1.yellow)(`Please be patient while ${this.configService.name} updates.\r\n`)); client.emit('stdout', (0, bash_color_1.yellow)('This process may take 5-15 minutes to complete on your device.\r\n')); client.emit('stdout', (0, bash_color_1.yellow)('***************************************************************\r\n\r\n')); } const installOptions = []; if (installPath === this.configService.customPluginPath && await (0, fs_extra_1.pathExists)((0, node_path_1.resolve)(installPath, '../package.json'))) { installOptions.push('--save'); } installPath = (0, node_path_1.resolve)(installPath, '../'); if (!this.configService.customPluginPath || userPlatform === 'win32' || existingPlugin?.globalInstall === true) { installOptions.push('-g'); } if (!this.configService.usePnpm) { installOptions.push('--omit=dev'); } const npmPluginLabel = `${pluginAction.name}@${pluginAction.version}`; await this.cleanNpmCache(); await this.runNpmCommand([...this.npm, action, ...installOptions, npmPluginLabel], installPath, client, pluginAction.termCols, pluginAction.termRows); await this.ensureCustomPluginDirExists(); return true; } async managePlugin(action, pluginAction, client) { pluginAction.version = pluginAction.version || 'latest'; if (pluginAction.name === this.configService.name) { return await this.manageUi(action, pluginAction, client); } if (action === 'install' && pluginAction.version === 'latest') { pluginAction.version = await this.getNpmModuleLatestVersion(pluginAction.name); } let installPath = this.configService.customPluginPath ? this.configService.customPluginPath : this.installedPlugins.find(x => x.name === this.configService.name).installPath; await this.getInstalledPlugins(); const existingPlugin = this.installedPlugins.find(x => x.name === pluginAction.name); if (existingPlugin) { installPath = existingPlugin.installPath; } if (action === 'install' && await this.isPluginBundleAvailable(pluginAction)) { try { await this.doPluginBundleUpdate(pluginAction, client); return true; } catch (e) { client.emit('stdout', (0, bash_color_1.yellow)('\r\nBundled install / update could not complete. Trying regular install / update using npm.\r\n\r\n')); } } const installOptions = []; let npmPluginLabel = pluginAction.name; if (installPath === this.configService.customPluginPath && !(action === 'uninstall' && this.configService.usePnpm) && await (0, fs_extra_1.pathExists)((0, node_path_1.resolve)(installPath, '../package.json'))) { installOptions.push('--save'); } installPath = (0, node_path_1.resolve)(installPath, '../'); if (!this.configService.customPluginPath || (0, node_os_1.platform)() === 'win32' || existingPlugin?.globalInstall === true) { installOptions.push('-g'); } if (action === 'install') { if (!this.configService.usePnpm) { installOptions.push('--omit=dev'); } npmPluginLabel = `${pluginAction.name}@${pluginAction.version}`; } await this.cleanNpmCache(); await this.runNpmCommand([...this.npm, action, ...installOptions, npmPluginLabel], installPath, client, pluginAction.termCols, pluginAction.termRows); await this.ensureCustomPluginDirExists(); return true; } async getHomebridgePackage() { if (this.configService.ui.homebridgePackagePath) { const pkgJsonPath = (0, node_path_1.join)(this.configService.ui.homebridgePackagePath, 'package.json'); if (await (0, fs_extra_1.pathExists)(pkgJsonPath)) { return await this.parsePackageJson(await (0, fs_extra_1.readJson)(pkgJsonPath), this.configService.ui.homebridgePackagePath); } else { this.logger.error(`The Homebridge path ${this.configService.ui.homebridgePackagePath} does not exist.`); } } const modules = await this.getInstalledModules(); const homebridgeInstalls = modules.filter(x => x.name === 'homebridge'); if (homebridgeInstalls.length > 1) { this.logger.warn('Multiple instances of Homebridge were found, see https://homebridge.io/w/JJSgm for help.'); homebridgeInstalls.forEach((instance) => { this.logger.warn(instance.installPath); }); } if (!homebridgeInstalls.length) { this.configService.hbServiceUiRestartRequired = true; this.logger.error('Unable to find Homebridge installation, see https://homebridge.io/w/JJSgZ for help.'); throw new Error('Unable To Find Homebridge Installation.'); } const homebridgeModule = homebridgeInstalls[0]; const pkgJson = await (0, fs_extra_1.readJson)((0, node_path_1.join)(homebridgeModule.installPath, 'package.json')); const homebridge = await this.parsePackageJson(pkgJson, homebridgeModule.path); if (!homebridge.latestVersion) { return homebridge; } const homebridgeVersion = (0, semver_1.parse)(homebridge.installedVersion); const installedTag = homebridgeVersion.prerelease[0]?.toString(); if (installedTag && ['alpha', 'beta', 'test'].includes(installedTag) && (0, semver_1.gt)(homebridge.installedVersion, homebridge.latestVersion)) { const versions = await this.getAvailablePluginVersions('homebridge'); if (versions.tags[installedTag] && (0, semver_1.gt)(versions.tags[installedTag], homebridge.installedVersion)) { homebridge.latestVersion = versions.tags[installedTag]; homebridge.updateAvailable = true; homebridge.updateEngines = versions.versions?.[homebridge.latestVersion]?.engines || null; homebridge.updateTag = installedTag; } } this.configService.homebridgeVersion = homebridge.installedVersion; return homebridge; } async updateHomebridgePackage(homebridgeUpdateAction, client) { const homebridge = await this.getHomebridgePackage(); homebridgeUpdateAction.version = homebridgeUpdateAction.version || 'latest'; if (homebridgeUpdateAction.version === 'latest' && homebridge.latestVersion) { homebridgeUpdateAction.version = homebridge.latestVersion; } let installPath = homebridge.installPath; const installOptions = []; if (!this.configService.usePnpm) { installOptions.push('--omit=dev'); } if (installPath === this.configService.customPluginPath && await (0, fs_extra_1.pathExists)((0, node_path_1.resolve)(installPath, '../package.json'))) { installOptions.push('--save'); } installPath = (0, node_path_1.resolve)(installPath, '../'); if (homebridge.globalInstall || (0, node_os_1.platform)() === 'win32') { installOptions.push('-g'); } await this.runNpmCommand([...this.npm, 'install', ...installOptions, `${homebridge.name}@${homebridgeUpdateAction.version}`], installPath, client, homebridgeUpdateAction.termCols, homebridgeUpdateAction.termRows); return true; } async getHomebridgeUiPackage() { const plugins = await this.getInstalledPlugins(); const plugin = plugins.find((x) => x.name === this.configService.name); return { ...plugin, readyForV5: { node: (0, semver_1.satisfies)(node_process_1.default.version, '>=20'), pnpm: !this.configService.usePnpm, arch: (0, node_os_1.platform)() !== 'linux' || (0, node_child_process_1.execSync)('uname -m').toString().trim() !== 'armv6l', service: this.configService.serviceMode, }, }; } async getNpmPackage() { if (this.npmPackage) { return this.npmPackage; } else { const modules = await this.getInstalledModules(); const npmPkg = modules.find(x => x.name === 'npm'); if (!npmPkg) { throw new Error('Could not find npm package'); } const pkgJson = await (0, fs_extra_1.readJson)((0, node_path_1.join)(npmPkg.installPath, 'package.json')); const npm = await this.parsePackageJson(pkgJson, npmPkg.path); npm.showUpdateWarning = (0, semver_1.lt)(npm.installedVersion, '9.5.0'); this.npmPackage = npm; return npm; } } async isPluginBundleAvailable(pluginAction) { if (this.configService.usePluginBundles === true && this.configService.customPluginPath && this.configService.strictPluginResolution && pluginAction.name !== this.configService.name && pluginAction.version !== 'latest') { try { await (0, rxjs_1.firstValueFrom)(this.httpService.head(`https://github.com/homebridge/plugins/releases/download/v1.0.0/${pluginAction.name.replace('/', '@')}-${pluginAction.version}.sha256`)); return true; } catch (e) { return false; } } else { return false; } } async doPluginBundleUpdate(pluginAction, client) { const pluginUpgradeInstallScriptPath = (0, node_path_1.join)(node_process_1.default.env.UIX_BASE_PATH, 'scripts/upgrade-install-plugin.sh'); await this.runNpmCommand([pluginUpgradeInstallScriptPath, pluginAction.name, pluginAction.version, this.configService.customPluginPath], this.configService.storagePath, client, pluginAction.termCols, pluginAction.termRows); return true; } async isUiUpdateBundleAvailable(pluginAction) { if ([ '/usr/local/lib/node_modules', '/usr/lib/node_modules', '/opt/homebridge/lib/node_modules', '/var/packages/homebridge/target/app/lib/node_modules', ].includes((0, node_path_1.dirname)(node_process_1.default.env.UIX_BASE_PATH)) && pluginAction.name === this.configService.name && !['latest', 'alpha', 'beta'].includes(pluginAction.version)) { try { try { const withV = `v${pluginAction.version}`; await (0, rxjs_1.firstValueFrom)(this.httpService.head(`https://github.com/homebridge/homebridge-config-ui-x/releases/download/${withV}/homebridge-config-ui-x-${pluginAction.version}.tar.gz`)); return withV; } catch (e2) { const withoutV = pluginAction.version; await (0, rxjs_1.firstValueFrom)(this.httpService.head(`https://github.com/homebridge/homebridge-config-ui-x/releases/download/${withoutV}/homebridge-config-ui-x-${pluginAction.version}.tar.gz`)); return withoutV; } } catch (e) { this.logger.error(`Failed to check for bundled update: ${e.message}.`); return ''; } } else { return ''; } } async doUiBundleUpdate(pluginAction, client, githubReleaseName) { const prefix = (0, node_path_1.dirname)((0, node_path_1.dirname)((0, node_path_1.dirname)(node_process_1.default.env.UIX_BASE_PATH))); const upgradeInstallScriptPath = (0, node_path_1.join)(node_process_1.default.env.UIX_BASE_PATH, 'scripts/upgrade-install.sh'); await this.runNpmCommand(this.configService.ui.sudo ? ['npm', 'run', 'upgrade-install', '--', pluginAction.version, prefix, githubReleaseName] : [upgradeInstallScriptPath, pluginAction.version, prefix, githubReleaseName], node_process_1.default.env.UIX_BASE_PATH, client, pluginAction.termCols, pluginAction.termRows); } async updateSelfOffline(client) { client.emit('stdout', (0, bash_color_1.yellow)(`${this.configService.name} has been scheduled to update on the next container restart.\n\r\n\r`)); await new Promise(res => setTimeout(res, 800)); client.emit('stdout', (0, bash_color_1.yellow)('The Docker container will now try and restart.\n\r\n\r')); await new Promise(res => setTimeout(res, 800)); client.emit('stdout', (0, bash_color_1.yellow)('If you have not started the Docker container with ') + (0, bash_color_1.red)('--restart=always') + (0, bash_color_1.yellow)(' you may\n\rneed to manually start the container again.\n\r\n\r')); await new Promise(res => setTimeout(res, 800)); client.emit('stdout', (0, bash_color_1.yellow)('This process may take several minutes. Please be patient.\n\r')); await new Promise(res => setTimeout(res, 10000)); await (0, fs_extra_1.createFile)('/homebridge/.uix-upgrade-on-restart'); } async getPluginConfigSchema(pluginName) { if (!this.installedPlugins) { await this.getInstalledPlugins(); } const plugin = this.installedPlugins.find(x => x.name === pluginName); if (!plugin) { throw new common_1.NotFoundException(); } if (!plugin.settingsSchema) { throw new common_1.NotFoundException(); } const schemaPath = (0, node_path_1.resolve)(plugin.installPath, pluginName, 'config.schema.json'); let configSchema = await (0, fs_extra_1.readJson)(schemaPath); if (configSchema.dynamicSchemaVersion) { const dynamicSchemaPath = (0, node_path_1.resolve)(this.configService.storagePath, `.${pluginName}-v${configSchema.dynamicSchemaVersion}.schema.json`); this.logger.log(`[${pluginName}] dynamic schema path: ${dynamicSchemaPath}.`); if ((0, fs_extra_1.existsSync)(dynamicSchemaPath)) { try { configSchema = await (0, fs_extra_1.readJson)(dynamicSchemaPath); this.logger.log(`[${pluginName}] dynamic schema loaded from ${dynamicSchemaPath}.`); } catch (e) { this.logger.error(`[${pluginName}] failed to load dynamic schema from ${dynamicSchemaPath} as ${e.message}.`); } } } if (pluginName === this.configService.name) { configSchema.schema.properties.port.default = this.configService.ui.port; if (this.configService.serviceMode) { configSchema.layout = configSchema.layout.filter((x) => { return x.ref !== 'log'; }); configSchema.layout = configSchema.layout.filter((x) => { return !(x === 'sudo' || x.key === 'restart'); }); } } if (pluginName === 'homebridge-alexa') { configSchema.schema.properties.pin.default = this.configService.homebridgeConfig.bridge.pin; } if (plugin.displayName) { configSchema.displayName = plugin.displayName; } const childBridgeSchema = { type: 'object', notitle: true, condition: { functionBody: 'return false', }, properties: { name: { type: 'string', }, username: { type: 'string', }, pin: { type: 'string', }, port: { type: 'integer', maximum: 65535, }, setupID: { type: 'string', }, manufacturer: { type: 'string', }, firmwareRevision: { type: 'string', }, model: { type: 'string', }, debugModeEnabled: { type: 'boolean', }, env: { type: 'object', properties: { DEBUG: { type: 'string', }, NODE_OPTIONS: { type: 'string', }, }, }, }, }; if (configSchema.schema && typeof configSchema.schema.properties === 'object') { configSchema.schema.properties._bridge = childBridgeSchema; } else if (typeof configSchema.schema === 'object') { configSchema.schema._bridge = childBridgeSchema; } return configSchema; } async getPluginChangeLog(pluginName) { await this.getInstalledPlugins(); const plugin = this.installedPlugins.find(x => x.name === pluginName); if (!plugin) { throw new common_1.NotFoundException(); } const changeLog = (0, node_path_1.resolve)(plugin.installPath, plugin.name, 'CHANGELOG.md'); if (await (0, fs_extra_1.pathExists)(changeLog)) { return { changelog: await (0, fs_extra_1.readFile)(changeLog, 'utf8'), }; } else { throw new common_1.NotFoundException(); } } async getPluginRelease(pluginName) { if (!this.installedPlugins) { await this.getInstalledPlugins(); } const plugin = pluginName === 'homebridge' ? await this.getHomebridgePackage() : this.installedPlugins.find(x => x.name === pluginName); if (!plugin) { throw new common_1.NotFoundException(); } if (!plugin.links.homepage && !plugin.links.bugs) { throw new common_1.NotFoundException(); } const repoMatch = plugin.links.homepage?.match(/https:\/\/github.com\/([^/]+)\/([^/#]+)/); const bugsMatch = plugin.links.bugs?.match(/https:\/\/github.com\/([^/]+)\/([^/#]+)/); let match = repoMatch; if (!repoMatch) { if (!bugsMatch) { throw new common_1.NotFoundException(); } match = bugsMatch; } const version = (0, semver_1.parse)(plugin.latestVersion); const tag = version.prerelease[0]?.toString(); if (tag) { let branch; if (['homebridge-config-ui-x', 'homebridge'].includes(plugin.name)) { try { branch = (await (0, rxjs_1.firstValueFrom)(this.httpService.get(`https://api.github.com/repos/homebridge/${plugin.name}/branches`))) .data .find((b) => b.name.startsWith(`${tag}-`)) ?.name; } catch (e) { this.logger.error(`Failed to get list of branches from GitHub as ${e.message}.`); } } return { name: `v${plugin.latestVersion}`, changelog: `Thank you for helping improve ${plugin.displayName || `\`${plugin.name}\``} by testing a beta version.\n\n` + 'You can use the Homebridge UI at any time to revert back to the stable version.\n\n' + `Please remember this **${tag}** version is a pre-release, and report any issues to the GitHub repository page:\n` + `- https://github.com/${repoMatch[1]}/${repoMatch[2]}/issues${branch ? `\n\nSee the commit history for recent changes:\n- https://github.com/${repoMatch[1]}/${repoMatch[2]}/commits/${branch}` : ''}`, }; } try { const release = (await (0, rxjs_1.firstValueFrom)(this.httpService.get(`https://api.github.com/repos/${match[1]}/${match[2]}/releases/latest`))).data; return { name: release.name, changelog: release.body, }; } catch (e) { throw new common_1.NotFoundException(); } } async getPluginAlias(pluginName) { if (!this.installedPlugins) { await this.getInstalledPlugins(); } const plugin = this.installedPlugins.find(x => x.name === pluginName); if (!plugin) { throw new common_1.NotFoundException(); } const fromCache = this.pluginAliasCache.get(pluginName); if (fromCache) { return fromCache; } const output = { pluginAlias: null, pluginType: null, }; if (plugin.settingsSchema) { const schema = await this.getPluginConfigSchema(pluginName); output.pluginAlias = schema.pluginAlias; output.pluginType = schema.pluginType; } else { try { await new Promise((res, rej) => { const child = (0, node_child_process_1.fork)((0, node_path_1.resolve)(node_process_1.default.env.UIX_BASE_PATH, 'scripts/extract-plugin-alias.js'), { env: { UIX_EXTRACT_PLUGIN_PATH: (0, node_path_1.resolve)(plugin.installPath, plugin.name), }, stdio: 'ignore', }); child.once('message', (data) => { if (data.pluginAlias && data.pluginType) { output.pluginAlias = data.pluginAlias; output.pluginType = data.pluginType; res(null); } else { rej(new Error('Invalid Response')); } }); child.once('close', (code) => { if (code !== 0) { rej(new Error()); } }); }); } catch (e) { this.logger.debug(`Failed to extract ${pluginName} plugin alias as ${e.message}.`); if (this.pluginAliasHints[pluginName]) { output.pluginAlias = this.pluginAliasHints[pluginName].pluginAlias; output.pluginType = this.pluginAliasHints[pluginName].pluginType; } } } this.pluginAliasCache.set(pluginName, output); return output; } async getPluginUiMetadata(pluginName) { if (!this.installedPlugins) { await this.getInstalledPlugins(); } const plugin = this.installedPlugins.find(x => x.name === pluginName); const fullPath = (0, node_path_1.resolve)(plugin.installPath, plugin.name); const schema = await (0, fs_extra_1.readJson)((0, node_path_1.resolve)(fullPath, 'config.schema.json')); const customUiPath = (0, node_path_1.resolve)(fullPath, schema.customUiPath || 'homebridge-ui'); const publicPath = (0, node_path_1.resolve)(customUiPath, 'public'); const serverPath = (0, node_path_1.resolve)(customUiPath, 'server.js'); const devServer = plugin.private ? schema.customUiDevServer : null; if (!devServer && !await (0, fs_extra_1.pathExists)(customUiPath)) { throw new Error(`Plugin does not provide a custom UI at expected location: ${customUiPath}`); } if (!devServer && !(await (0, fs_extra_1.realpath)(customUiPath)).startsWith(await (0, fs_extra_1.realpath)(fullPath))) { throw new Error(`Custom UI path is outside the plugin root: ${await (0, fs_extra_1.realpath)(customUiPath)}`); } if (await (0, fs_extra_1.pathExists)((0, node_path_1.resolve)(publicPath, 'index.html')) || devServer) { return { devServer, serverPath, publicPath, plugin, }; } throw new Error('Plugin does not provide a custom UI'); } async getDisabledPlugins() { try { const config = await (0, fs_extra_1.readJson)(this.configService.configPath); if (Array.isArray(config.disabledPlugins)) { return config.disabledPlugins; } else { return []; } } catch (e) { return []; } } async getInstalledScopedModules(requiredPath, scope) { try { if ((await (0, fs_extra_1.stat)((0, node_path_1.join)(requiredPath, scope))).isDirectory()) { const scopedModules = await (0, fs_extra_1.readdir)((0, node_path_1.join)(requiredPath, scope)); return scopedModules .filter(x => x.startsWith('homebridge-')) .map((x) => { return { name: (0, node_path_1.join)(scope, x).split(node_path_1.sep).join('/'), installPath: (0, node_path_1.join)(requiredPath, scope, x), path: requiredPath, }; }); } else { return []; } } catch (e) { this.logger.log(e); return []; } } async getInstalledModules() { const allModules = []; for (const requiredPath of this.paths) { const modules = await (0, fs_extra_1.readdir)(requiredPath); for (const module of modules) { try { if (module.charAt(0) === '@') { allModules.push(...await this.getInstalledScopedModules(requiredPath, module)); } else { allModules.push({ name: module, installPath: (0, node_path_1.join)(requiredPath, module), path: requiredPath, }); } } catch (e) { this.logger.log(`Failed to parse ${module} in ${requiredPath} as ${e.message}.`); } } } if (allModules.findIndex(x => x.name === 'homebridge-config-ui-x') === -1) { allModules.push({ name: 'homebridge-config-ui-x', installPath: node_process_1.default.env.UIX_BASE_PATH, path: (0, node_path_1.dirname)(node_process_1.default.env.UIX_BASE_PATH), }); } if (allModules.findIndex(x => x.name === 'homebridge') === -1) { if ((0, fs_extra_1.existsSync)((0, node_path_1.join)(node_process_1.default.env.UIX_BASE_PATH, '..', 'homebridge'))) { allModules.push({ name: 'homebridge', installPath: (0, node_path_1.join)(node_process_1.default.env.UIX_BASE_PATH, '..', 'homebridge'), path: (0, node_path_1.dirname)((0, node_path_1.join)(node_process_1.default.env.UIX_BASE_PATH, '..', 'homebridge')), }); } } return allModules; } isScopedPlugin(name) { return (name.charAt(0) === '@' && name.split('/').length > 0 && name.split('/')[1].indexOf('homebridge-') === 0); } getNpmPath() { if ((0, node_os_1.platform)() === 'win32') { const windowsNpmPath = [ (0, node_path_1.join)(node_process_1.default.env.APPDATA, 'npm/npm.cmd'), (0, node_path_1.join)(node_process_1.default.env.ProgramFiles, 'nodejs/npm.cmd'), (0, node_path_1.join)(node_process_1.default.env.NVM_SYMLINK || `${node_process_1.default.env.ProgramFiles}/nodejs`, 'npm.cmd'), ].filter(fs_extra_1.existsSync); if (windowsNpmPath.length) { return [windowsNpmPath[0]]; } else { this.logger.error('Cannot find npm binary, you will not be able to manage plugins or update Homebridge. You might be able to fix this problem by running:'); this.logger.error('npm install -g npm'); } } return this.configService.usePnpm ? ['pnpm'] : ['npm']; } getBasePaths() { let paths = []; if (this.configService.customPluginPath) { paths.unshift(this.configService.customPluginPath); } if (this.configService.strictPluginResolution) { if (!paths.length) { paths.push(...this.getNpmPrefixToSearchPaths()); } } else { paths = paths.concat(require.main?.paths || []); if (node_process_1.default.env.NODE_PATH) { paths = node_process_1.default.env.NODE_PATH.split(node_path_1.delimiter).filter(p => !!p).concat(paths); } else { if (((0, node_os_1.platform)() !== 'win32')) { paths.push('/usr/local/lib/node_modules'); paths.push('/usr/lib/node_modules'); } paths.push(...this.getNpmPrefixToSearchPaths()); } paths = paths.filter(x => x !== (0, node_path_1.join)(node_process_1.default.env.UIX_BASE_PATH, 'node_modules')); } return (0, lodash_1.uniq)(paths).filter((requiredPath) => { return (0, fs_extra_1.existsSync)(requiredPath); }); } getNpmPrefixToSearchPaths() { const paths = []; if (((0, node_os_1.platform)() === 'win32')) { paths.push((0, node_path_1.join)(node_process_1.default.env.APPDATA, 'npm/node_modules')); } else { paths.push((0, node_child_process_1.execSync)('/bin/echo -n "$(npm -g prefix)/lib/node_modules"', { env: Object.assign({ npm_config_loglevel: 'silent', npm_update_notifier: 'false', }, node_process_1.default.env), }).toString('utf8')); } return paths; } async parsePackageJson(pkgJson, installPath) { const plugin = { name: pkgJson.name, displayName: pkgJson.displayName || this.pluginNames[pkgJson.name], private: pkgJson.private || false, description: (pkgJson.description) ? pkgJson.description.replace(/(?:https?|ftp):\/\/[\n\S]+/g, '').trim() : pkgJson.name, verifiedPlugin: this.verifiedPlugins.includes(pkgJson.name), verifiedPlusPlugin: this.verifiedPlusPlugins.includes(pkgJson.name), icon: this.pluginIcons[pkgJson.name] ? `${this.pluginListUrl}${this.pluginIcons[pkgJson.name]}` : null, isHbScoped: pkgJson.name.startsWith('@homebridge-plugins/'), newHbScope: this.newScopePlugins[pkgJson.name], isHbMaintained: this.maintainedPlugins.includes(pkgJson.name), installedVersion: installPath ? (pkgJson.version || '0.0.1') : null, globalInstall: (installPath !== this.configService.customPluginPath), settingsSchema: await (0, fs_extra_1.pathExists)((0, node_path_1.resolve)(installPath, pkgJson.name, 'config.schema.json')), engines: pkgJson.engines, installPath, }; plugin.funding = (plugin.verifiedPlugin || plugin.verifiedPlusPlugin) ? pkgJson.funding : undefined; if (pkgJson.private) { plugin.publicPackage = false; plugin.latestVersion = null; plugin.updateAvailable = false; plugin.links = {}; return plugin; } return this.getPluginFromNpm(plugin); } async getPluginFromNpm(plugin) { try { const fromCache = this.npmPluginCache.get(plugin.name); plugin.updateAvailable = false; plugin.updateTag = null;