homebridge-config-ui-x
Version:
A web based management, configuration and control platform for Homebridge.
1,008 lines (1,007 loc) • 60.9 kB
JavaScript
"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;