UNPKG

homebridge-config-ui-x

Version:

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

2,074 lines 94.9 kB
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 __param = (this && this.__param) || function (paramIndex, decorator) {
    return function (target, key) { decorator(target, key, paramIndex); }
};
var PluginsService_1;
import { Buffer } from 'node:buffer';
import { execSync, fork, spawn } from 'node:child_process';
import { EventEmitter } from 'node:events';
import { constants, existsSync } from 'node:fs';
import { access, readdir, readFile, realpath, stat } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { arch, cpus, platform, userInfo } from 'node:os';
import { basename, delimiter, dirname, join, resolve, sep, } from 'node:path';
import process from 'node:process';
import { HttpService } from '@nestjs/axios';
import { BadRequestException, Inject, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import axios from 'axios';
import { cyan, green, red, yellow } from 'bash-color';
import { createFile, ensureDir, pathExists, pathExistsSync, readJson, remove } from 'fs-extra/esm';
import NodeCache from 'node-cache';
import pLimit from 'p-limit';
import { firstValueFrom } from 'rxjs';
import { gt, lt, parse, rcompare, satisfies } from 'semver';
import { ConfigService } from '../../core/config/config.service.js';
import { HomebridgeIpcService } from '../../core/homebridge-ipc/homebridge-ipc.service.js';
import { Logger } from '../../core/logger/logger.service.js';
import { NodePtyService } from '../../core/node-pty/node-pty.service.js';
import { RE_ENCODED_AT, RE_GITHUB_REPO, RE_HYPHEN, RE_HYPHEN_GLOBAL, RE_PLUGIN_NAME, RE_PRERELEASE_TYPE, RE_URL, RE_URL_WITH_OPTIONAL_PAREN, RE_WHITESPACE, RE_WORD_SEQUENCE, } from '../../core/regex.constants.js';
import { ChildBridgesService } from '../child-bridges/child-bridges.service.js';
const require = createRequire(import.meta.url);
const module = require('node:module');
let PluginsService = class PluginsService {
    static { PluginsService_1 = this; }
    httpService;
    nodePtyService;
    logger;
    configService;
    homebridgeIpcService;
    childBridgesService;
    _npm;
    npmMajorVersion = null;
    _paths;
    warnedDuplicateUiInstall = false;
    get npm() {
        if (!this._npm) {
            this._npm = this.getNpmPath();
        }
        return this._npm;
    }
    get paths() {
        if (!this._paths) {
            this._paths = this.getBasePaths();
        }
        return this._paths;
    }
    static UI_RESTART_DELAY_MS = 5000;
    uiRestartTimer;
    scheduleUiRestart() {
        this.uiRestartTimer = setTimeout(() => {
            process.exit(0);
        }, PluginsService_1.UI_RESTART_DELAY_MS);
        this.uiRestartTimer.unref();
    }
    onModuleDestroy() {
        if (this.uiRestartTimer) {
            clearTimeout(this.uiRestartTimer);
            this.uiRestartTimer = undefined;
        }
    }
    installedPlugins;
    npmPackage;
    pluginListUrl = 'https://raw.githubusercontent.com/homebridge/plugins/latest/';
    pluginListFile = `${this.pluginListUrl}assets/plugins-v2.min.json`;
    pluginListRetryTimeout;
    hiddenPlugins = [];
    hiddenScopes = [];
    unmaintainedPlugins = [];
    pluginIcons = {};
    pluginAuthors = {};
    pluginNames = {};
    pluginChangelogs = {};
    newScopePlugins = {};
    scopedPluginNames = [];
    verifiedPlugins = [];
    verifiedPlusPlugins = [];
    npmPluginCache = new NodeCache({ stdTTL: 300 });
    pluginAliasCache = new NodeCache({ stdTTL: 86400 });
    installedPluginsCache = new NodeCache({ stdTTL: 60 });
    pluginManagementInProgress = 0;
    get isPluginManagementInProgress() {
        return this.pluginManagementInProgress > 0;
    }
    pluginAliasHints = {
        'homebridge-broadlink-rm-pro': {
            pluginAlias: 'BroadlinkRM',
            pluginType: 'platform',
        },
    };
    constructor(httpService, nodePtyService, logger, configService, homebridgeIpcService, childBridgesService) {
        this.httpService = httpService;
        this.nodePtyService = nodePtyService;
        this.logger = logger;
        this.configService = configService;
        this.homebridgeIpcService = homebridgeIpcService;
        this.childBridgesService = childBridgesService;
        this.httpService.axiosRef.interceptors.request.use((config) => {
            const source = axios.CancelToken.source();
            config.cancelToken = source.token;
            setTimeout(() => {
                source.cancel('Timeout: request took more than 35 seconds');
            }, 35000);
            return config;
        });
        this.loadPluginList().catch((err) => {
            this.logger.error('Failed to load plugin list during initialization:', err);
        });
        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(RE_HYPHEN_GLOBAL, ' ')
            .replace(RE_WORD_SEQUENCE, (txt) => txt.charAt(0).toUpperCase() + txt.substring(1).toLowerCase());
        return plugin;
    }
    getCachedInstalledPlugins() {
        return this.installedPluginsCache.get('installed-plugins');
    }
    async getInstalledPlugins() {
        const cached = this.installedPluginsCache.get('installed-plugins');
        if (cached) {
            this.installedPlugins = cached;
            return cached;
        }
        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))
            && pathExistsSync(join(module.installPath, 'package.json')));
        const limit = pLimit(cpus().length);
        await Promise.all(homebridgePlugins.map(async (pkg) => {
            return limit(async () => {
                try {
                    const pkgJson = await readJson(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);
                        const existingPlugin = plugins.find(x => plugin.name === x.name);
                        const isUi = plugin.name === this.configService.name;
                        const isRunningUi = isUi && resolve(plugin.installPath) === dirname(resolve(process.env.UIX_BASE_PATH));
                        if (!existingPlugin) {
                            plugins.push(plugin);
                        }
                        else if (isUi ? isRunningUi : (!plugin.globalInstall && existingPlugin.globalInstall === true)) {
                            const index = plugins.indexOf(existingPlugin);
                            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));
        this.installedPluginsCache.set('installed-plugins', this.installedPlugins);
        return this.installedPlugins;
    }
    async getOutOfDatePlugins() {
        const plugins = await this.getInstalledPlugins();
        return plugins.filter(x => x.updateAvailable);
    }
    async lookupPlugin(pluginName) {
        if (!RE_PLUGIN_NAME.test(pluginName)) {
            throw new BadRequestException('Invalid plugin name.');
        }
        const lookup = await this.searchNpmRegistrySingle(pluginName);
        if (!lookup.length) {
            throw new NotFoundException();
        }
        return lookup[0];
    }
    async getAvailablePluginVersions(pluginName) {
        if (!RE_PLUGIN_NAME.test(pluginName) && pluginName !== 'homebridge') {
            throw new BadRequestException('Invalid plugin name.');
        }
        try {
            const fromCache = this.npmPluginCache.get(`lookup-${pluginName}`);
            const pkg = fromCache || (await firstValueFrom((this.httpService.get(`https://registry.npmjs.org/${encodeURIComponent(pluginName).replace(RE_ENCODED_AT, '@')}`, {
                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) => {
                    if (!pkg.versions[key].deprecated) {
                        acc[key] = {
                            version: pkg.versions[key].version,
                            engines: pkg.versions[key].engines || null,
                        };
                    }
                    return acc;
                }, {}),
            };
        }
        catch (e) {
            throw new NotFoundException();
        }
    }
    extractTerms(query, separator) {
        return query
            .toLowerCase()
            .split(separator)
            .map(term => term.trim())
            .filter(term => term && term !== 'homebridge' && term !== 'plugin');
    }
    getPluginKeywords(plugin) {
        return Array.isArray(plugin.keywords)
            ? plugin.keywords.map((k) => k.toLowerCase())
            : [];
    }
    supportsMatter(keywords) {
        return Array.isArray(keywords) && keywords.some(k => k?.toLowerCase() === 'supports-matter');
    }
    supportsHap(keywords) {
        return Array.isArray(keywords) && keywords.some(k => k?.toLowerCase() === 'supports-hap');
    }
    matchesPlugin(plugin, searchTerms) {
        const pluginName = plugin.name.toLowerCase();
        const pluginKeywords = this.getPluginKeywords(plugin);
        const pluginDescription = (plugin.description || '').toLowerCase();
        const nameTerms = this.extractTerms(pluginName.substring(pluginName.lastIndexOf('/') + 1), RE_HYPHEN);
        const searchTermsSet = new Set(searchTerms);
        const keywordsSet = new Set(pluginKeywords);
        const nameTermsSet = new Set(nameTerms);
        if (nameTerms.every(term => searchTermsSet.has(term))) {
            return 'exactName';
        }
        if (searchTerms.every(term => keywordsSet.has(term)) || searchTerms.every(term => nameTermsSet.has(term))) {
            return 'exactKeyword';
        }
        if (searchTerms.some(term => pluginName.includes(term))
            || searchTerms.some(term => pluginKeywords.some(k => k.includes(term)))
            || searchTerms.some(term => pluginDescription.includes(term))) {
            return 'partial';
        }
        return null;
    }
    async searchNpmRegistry(query) {
        if (!this.installedPlugins) {
            await this.getInstalledPlugins();
        }
        const searchTerms = this.extractTerms(query, RE_WHITESPACE);
        const normalizedQuery = searchTerms.length > 0 ? searchTerms.join(' ') : 'homebridge';
        if ((normalizedQuery.startsWith('homebridge-') || this.isScopedPlugin(normalizedQuery))
            && !this.isHiddenPlugin(normalizedQuery)) {
            if (!this.installedPlugins.some(x => x.name === normalizedQuery)
                && Object.keys(this.newScopePlugins).includes(normalizedQuery)) {
                return await this.searchNpmRegistrySingle(`@homebridge-plugins/${normalizedQuery}`);
            }
            return await this.searchNpmRegistrySingle(normalizedQuery);
        }
        const q = `${normalizedQuery.substring(0, 15)}+keywords:homebridge-plugin+not:deprecated&size=99`;
        let searchResults;
        try {
            searchResults = (await 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 InternalServerErrorException(`Failed to search the npm registry as ${e.message}, see logs.`);
        }
        const plugins = searchResults.objects
            .filter(x => (x.package.name.startsWith('homebridge-') || this.isScopedPlugin(x.package.name))
            && !this.isHiddenPlugin(x.package.name))
            .map((pkg) => {
            const isInstalled = this.installedPlugins.find(x => x.name === pkg.package.name);
            if (isInstalled) {
                return {
                    ...isInstalled,
                    lastUpdated: pkg.package.date,
                    keywords: pkg.package.keywords || [],
                };
            }
            return {
                name: pkg.package.name,
                displayName: this.pluginNames[pkg.package.name],
                private: false,
                publicPackage: true,
                installedVersion: null,
                latestVersion: pkg.package.version,
                lastUpdated: pkg.package.date,
                description: (pkg.package.description || pkg.package.name).replace(RE_URL_WITH_OPTIONAL_PAREN, '').trim(),
                keywords: pkg.package.keywords || [],
                links: pkg.package.links,
                author: this.pluginAuthors[pkg.package.name] || (pkg.package.publisher ? pkg.package.publisher.username : null),
                verifiedPlugin: this.verifiedPlugins.includes(pkg.package.name),
                verifiedPlusPlugin: this.verifiedPlusPlugins.includes(pkg.package.name),
                supportsMatter: this.supportsMatter(pkg.package.keywords),
                supportsHap: this.supportsHap(pkg.package.keywords),
                icon: this.pluginIcons[pkg.package.name] ? `${this.pluginListUrl}${this.pluginIcons[pkg.package.name]}` : null,
                isHbScoped: pkg.package.name.startsWith('@homebridge-plugins/'),
                newHbScope: this.newScopePlugins[pkg.package.name],
                isUnmaintained: this.unmaintainedPlugins.includes(pkg.package.name),
            };
        });
        const resultNames = new Set(plugins.map(p => p.name));
        const scopedLookups = [];
        for (const name of this.scopedPluginNames) {
            if (!resultNames.has(name) && !this.isHiddenPlugin(name)) {
                const unscopedName = name.substring(name.lastIndexOf('/') + 1).toLowerCase();
                if (searchTerms.some(term => unscopedName.includes(term))) {
                    scopedLookups.push(this.searchNpmRegistrySingle(name).catch(() => []));
                }
            }
        }
        if (scopedLookups.length > 0) {
            const scopedResults = await Promise.all(scopedLookups);
            for (const results of scopedResults) {
                for (const plugin of results) {
                    if (!resultNames.has(plugin.name)) {
                        plugins.push(plugin);
                        resultNames.add(plugin.name);
                    }
                }
            }
        }
        const matchGroups = {
            exactName: [],
            exactKeyword: [],
            partial: [],
        };
        for (const plugin of plugins) {
            const matchType = this.matchesPlugin(plugin, searchTerms);
            if (matchType) {
                matchGroups[matchType].push(plugin);
            }
        }
        const orderPlugins = (arr) => [...arr].sort((a, b) => {
            const aPlus = a.verifiedPlusPlugin ? 1 : 0;
            const bPlus = b.verifiedPlusPlugin ? 1 : 0;
            if (aPlus !== bPlus) {
                return bPlus - aPlus;
            }
            const aVerified = a.verifiedPlugin ? 1 : 0;
            const bVerified = b.verifiedPlugin ? 1 : 0;
            if (aVerified !== bVerified) {
                return bVerified - aVerified;
            }
            return (b.lastUpdated ?? '').localeCompare(a.lastUpdated ?? '');
        });
        const allResults = [
            ...matchGroups.exactName,
            ...matchGroups.exactKeyword,
            ...matchGroups.partial,
        ];
        const scopedResults = allResults.filter(p => p.isHbScoped);
        const unscopedResults = allResults.filter(p => !p.isHbScoped);
        return [...orderPlugins(scopedResults), ...orderPlugins(unscopedResults)]
            .slice(0, 30)
            .map(plugin => this.fixDisplayName(plugin));
    }
    async searchNpmRegistrySingle(query) {
        try {
            const fromCache = this.npmPluginCache.get(`lookup-${query}`);
            const pkg = fromCache || (await firstValueFrom((this.httpService.get(`https://registry.npmjs.org/${encodeURIComponent(query).replace(RE_ENCODED_AT, '@')}`)))).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(RE_URL, '').trim()
                    : pkg.name,
                verifiedPlugin: this.verifiedPlugins.includes(pkg.name),
                verifiedPlusPlugin: this.verifiedPlusPlugins.includes(pkg.name),
                supportsMatter: this.supportsMatter(pkg.keywords),
                supportsHap: this.supportsHap(pkg.keywords),
                icon: this.pluginIcons[pkg.name],
                isHbScoped: pkg.name.startsWith('@homebridge-plugins/'),
                newHbScope: this.newScopePlugins[pkg.name],
                isUnmaintained: this.unmaintainedPlugins.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.supportsMatter = this.supportsMatter(pkg.keywords);
            plugin.supportsHap = this.supportsHap(pkg.keywords);
            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.isUnmaintained = this.unmaintainedPlugins.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 = platform();
        let installPath = this.configService.customPluginPath
            ? this.configService.customPluginPath
            : this.installedPlugins.find(x => x.name === this.configService.name).installPath;
        await this.getInstalledPlugins();
        const uiPlugins = this.installedPlugins.filter(x => x.name === pluginAction.name);
        const runningParent = dirname(resolve(process.env.UIX_BASE_PATH));
        const existingPlugin = uiPlugins.find(x => resolve(x.installPath) === runningParent) ?? uiPlugins[0];
        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', yellow('\r\nBundled update failed. Trying regular update using npm.\r\n\r\n'));
            }
        }
        if (cpus().length === 1 && arch() === 'arm') {
            client.emit('stdout', yellow('***************************************************************\r\n'));
            client.emit('stdout', yellow(`Please be patient while ${this.configService.name} updates.\r\n`));
            client.emit('stdout', yellow('This process may take 5-15 minutes to complete on your device.\r\n'));
            client.emit('stdout', yellow('***************************************************************\r\n\r\n'));
        }
        const installOptions = [];
        if (installPath === this.configService.customPluginPath && await pathExists(resolve(installPath, '../package.json'))) {
            installOptions.push('--save');
        }
        installPath = resolve(installPath, '../');
        if (!this.configService.customPluginPath || userPlatform === 'win32' || existingPlugin?.globalInstall === true) {
            installOptions.push('-g');
        }
        installOptions.push('--omit=dev');
        const npmPluginLabel = `${pluginAction.name}@${pluginAction.version}`;
        await this.applyAllowScripts(installOptions, client, pluginAction);
        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);
        }
        await this.getInstalledPlugins();
        let installPath = this.configService.customPluginPath
            ? this.configService.customPluginPath
            : this.installedPlugins.find(x => x.name === this.configService.name).installPath;
        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', 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 && await pathExists(resolve(installPath, '../package.json'))) {
            installOptions.push('--save');
        }
        installPath = resolve(installPath, '../');
        if (!this.configService.customPluginPath || platform() === 'win32' || existingPlugin?.globalInstall === true) {
            installOptions.push('-g');
        }
        if (action === 'install') {
            installOptions.push('--omit=dev');
            npmPluginLabel = `${pluginAction.name}@${pluginAction.version}`;
            await this.applyAllowScripts(installOptions, client, pluginAction);
        }
        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 = join(this.configService.ui.homebridgePackagePath, 'package.json');
            if (await pathExists(pkgJsonPath)) {
                return await this.parsePackageJson(await 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.');
        }
        let homebridgeModule = homebridgeInstalls[0];
        const runningModule = await this.findRunningHomebridgeInstall(homebridgeInstalls);
        if (runningModule) {
            homebridgeModule = runningModule;
        }
        const pkgJson = await readJson(join(homebridgeModule.installPath, 'package.json'));
        const homebridge = await this.parsePackageJson(pkgJson, homebridgeModule.path);
        homebridge.multipleInstances = homebridgeInstalls.length > 1;
        if (!homebridge.latestVersion) {
            return homebridge;
        }
        const updatePolicy = this.configService.ui.homebridgeUpdatePolicy || 'all';
        if (updatePolicy === 'none') {
            homebridge.updateAvailable = false;
            homebridge.latestVersion = null;
        }
        else if (updatePolicy === 'major') {
            const currentMajor = Number.parseInt(homebridge.installedVersion.split('.')[0], 10);
            const versions = await this.getAvailablePluginVersions('homebridge');
            const sameMajorVersions = Object.keys(versions.versions)
                .filter((version) => {
                const versionMajor = Number.parseInt(version.split('.')[0], 10);
                return versionMajor === currentMajor;
            })
                .sort(rcompare);
            if (sameMajorVersions.length > 0 && gt(sameMajorVersions[0], homebridge.installedVersion)) {
                homebridge.latestVersion = sameMajorVersions[0];
                homebridge.updateAvailable = true;
                homebridge.updateEngines = versions.versions[sameMajorVersions[0]]?.engines || null;
            }
            else {
                homebridge.updateAvailable = false;
            }
        }
        else if (updatePolicy === 'beta') {
            await this.checkForBetaUpdates(homebridge, 'homebridge', true);
        }
        if (!this.configService.runningHomebridgeModulePath || runningModule) {
            this.configService.homebridgeVersion = homebridge.installedVersion;
        }
        return homebridge;
    }
    async findRunningHomebridgeInstall(installs) {
        if (!this.configService.runningHomebridgeModulePath) {
            return null;
        }
        try {
            const runningPath = await realpath(this.configService.runningHomebridgeModulePath);
            for (const install of installs) {
                try {
                    if (await realpath(install.installPath) === runningPath) {
                        return install;
                    }
                }
                catch (e) {
                    this.logger.debug(`Failed to resolve Homebridge install path ${install.installPath} as ${e.message}.`);
                }
            }
        }
        catch (e) {
            this.logger.debug(`Failed to resolve running Homebridge module path as ${e.message}.`);
        }
        return null;
    }
    async containsInstallPath(modules, name, resolvedPath) {
        for (const module of modules.filter(x => x.name === name)) {
            try {
                if (await realpath(module.installPath) === resolvedPath) {
                    return true;
                }
            }
            catch (e) {
                this.logger.debug(`Failed to resolve ${name} install path ${module.installPath} as ${e.message}.`);
            }
        }
        return false;
    }
    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 = [];
        installOptions.push('--omit=dev');
        if (installPath === this.configService.customPluginPath && await pathExists(resolve(installPath, '../package.json'))) {
            installOptions.push('--save');
        }
        installPath = resolve(installPath, '../');
        if (homebridge.globalInstall || 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 triggerUpdate(name, version) {
        if (version !== undefined && typeof version !== 'string') {
            throw new BadRequestException('Invalid version parameter.');
        }
        let targetVersion = version || 'latest';
        try {
            switch (name) {
                case 'homebridge': {
                    const homebridge = await this.getHomebridgePackage();
                    if (targetVersion === 'latest' && homebridge.latestVersion) {
                        targetVersion = homebridge.latestVersion;
                    }
                    break;
                }
                case 'homebridge-config-ui-x': {
                    const uiPackage = await this.getHomebridgeUiPackage();
                    if (!uiPackage) {
                        throw new NotFoundException(`Package ${name} is not installed.`);
                    }
                    if (targetVersion === 'latest' && uiPackage.latestVersion) {
                        targetVersion = uiPackage.latestVersion;
                    }
                    break;
                }
                default: {
                    if (!RE_PLUGIN_NAME.test(name)) {
                        throw new BadRequestException('Invalid package name. Must be "homebridge", "homebridge-config-ui-x", or a valid Homebridge plugin name.');
                    }
                    const plugins = await this.getInstalledPlugins();
                    const plugin = plugins.find(p => p.name === name);
                    if (!plugin) {
                        throw new NotFoundException(`Plugin ${name} is not installed.`);
                    }
                    if (targetVersion === 'latest' && plugin.latestVersion) {
                        targetVersion = plugin.latestVersion;
                    }
                }
            }
        }
        catch (e) {
            if (e instanceof NotFoundException) {
                throw e;
            }
            this.logger.error(`Failed to validate package ${name} for update: ${e.message}`);
            throw new BadRequestException(`Failed to validate package ${name} for update.`);
        }
        setImmediate(async () => {
            this.logger.log(`Starting scheduled update for ${name} to version ${targetVersion}`);
            const mockClient = new EventEmitter();
            mockClient.on('stdout', (data) => {
                this.logger.log(`[${name} update] ${data.toString().trim()}`);
            });
            const result = await this.performPackageUpdate(name, targetVersion, mockClient);
            if (!result.ok) {
                this.logger.error(`Failed to update ${name}: ${result.error}`);
                try {
                    this.logger.warn('Attempting fallback restart of Homebridge process...');
                    this.homebridgeIpcService.restartHomebridge();
                }
                catch (restartError) {
                    this.logger.error(`Failed to restart Homebridge: ${restartError.message}`);
                }
                return;
            }
            try {
                if (result.restart.homebridge && name === 'homebridge') {
                    this.logger.log(`Successfully updated Homebridge to version ${targetVersion}. Performing quick restart of Homebridge process...`);
                    this.homebridgeIpcService.restartHomebridge();
                }
                else if (result.restart.ui) {
                    this.logger.warn(`homebridge-config-ui-x has been updated, server will restart in ${PluginsService_1.UI_RESTART_DELAY_MS / 1000} seconds...`);
                    this.scheduleUiRestart();
                }
                else if (result.restart.childBridgeUsernames.length > 0) {
                    this.logger.log(`Successfully updated ${name} to version ${targetVersion}.`);
                    this.logger.log(`${name} is running in ${result.restart.childBridgeUsernames.length} child bridge(s). Restarting child bridges: ${result.restart.childBridgeUsernames.join(', ')}`);
                    for (const username of result.restart.childBridgeUsernames) {
                        this.logger.log(`Restarting child bridge ${username}...`);
                        this.childBridgesService.restartChildBridge(username);
                    }
                }
                else if (result.restart.homebridge) {
                    this.logger.log(`Successfully updated ${name} to version ${targetVersion}.`);
                    this.logger.log(`${name} is not running in a child bridge. Performing quick restart of Homebridge process...`);
                    this.homebridgeIpcService.restartHomebridge();
                }
            }
            catch (error) {
                this.logger.error(`Failed to update ${name}: ${error.message}`);
                try {
                    this.logger.warn('Attempting fallback restart of Homebridge process...');
                    this.homebridgeIpcService.restartHomebridge();
                }
                catch (restartError) {
                    this.logger.error(`Failed to restart Homebridge: ${restartError.message}`);
                }
            }
        });
        return {
            ok: true,
            name,
            version: targetVersion,
        };
    }
    async performPackageUpdate(name, version, client) {
        const restart = { homebridge: false, ui: false, childBridgeUsernames: [] };
        try {
            if (name === 'homebridge') {
                await this.updateHomebridgePackage({ version }, client);
                restart.homebridge = true;
            }
            else if (name === this.configService.name) {
                await this.managePlugin('install', { name, version }, client);
                restart.ui = true;
            }
            else {
                await this.managePlugin('install', { name, version }, client);
                restart.childBridgeUsernames = await this.getPluginChildBridgeUsernames(name);
                if (restart.childBridgeUsernames.length === 0) {
                    restart.homebridge = true;
                }
            }
            return { ok: true, name, version, restart };
        }
        catch (error) {
            return { ok: false, name, version, error: error.message, restart };
        }
    }
    clearInstalledPluginsCache() {
        this.installedPluginsCache.del('installed-plugins');
    }
    async getHomebridgeUiPackage() {
        const modules = await this.getInstalledModules();
        const uiModules = modules.filter(x => x.name === this.configService.name);
        const runningPath = resolve(process.env.UIX_BASE_PATH);
        const uiModule = uiModules.find(x => resolve(x.installPath) === runningPath) ?? uiModules[0];
        if (!uiModule) {
            throw new Error('Unable to find Homebridge UI installation.');
        }
        if (uiModules.length > 1 && !this.warnedDuplicateUiInstall) {
            this.warnedDuplicateUiInstall = true;
            this.logger.warn(`Found more than one installation of ${this.configService.name}: ${uiModules.map(x => x.installPath).join(', ')}. Using ${uiModule.installPath}, the one currently running. You should remove the others.`);
        }
        const pkgJson = await readJson(join(uiModule.installPath, 'package.json'));
        const uiPackage = {
            name: pkgJson.name,
            displayName: pkgJson.displayName || this.pluginNames[pkgJson.name],
            private: pkgJson.private || false,
            description: (pkgJson.description)
                ? pkgJson.description.replace(RE_URL, '').trim()
                : pkgJson.name,
            verifiedPlugin: this.verifiedPlugins.includes(pkgJson.name),
            verifiedPlusPlugin: this.verifiedPlusPlugins.includes(pkgJson.name),
            supportsMatter: this.supportsMatter(pkgJson.keywords),
            supportsHap: this.supportsHap(pkgJson.keywords),
            icon: this.pluginIcons[pkgJson.name]
                ? `${this.pluginListUrl}${this.pluginIcons[pkgJson.name]}`
                : null,
            isHbScoped: pkgJson.name.startsWith('@homebridge-plugins/'),
            newHbScope: this.newScopePlugins[pkgJson.name],
            isUnmaintained: this.unmaintainedPlugins.includes(pkgJson.name),
            installedVersion: pkgJson.version || '0.0.1',
            globalInstall: (uiModule.path !== this.configService.customPluginPath),
            settingsSchema: await pathExists(resolve(uiModule.path, pkgJson.name, 'config.schema.json')),
            engines: pkgJson.engines,
            installPath: uiModule.path,
            funding: (this.verifiedPlugins.includes(pkgJson.name) || this.verifiedPlusPlugins.includes(pkgJson.name))
                ? pkgJson.funding
                : undefined,
            directories: pkgJson.directories,
            publicPackage: false,
            latestVersion: null,
            updateAvailable: false,
            links: {},
        };
        await this.getPluginFromNpm(uiPackage, true);
        if (!uiPackage.latestVersion) {
            return uiPackage;
        }
        const updatePolicy = this.configService.ui.homebridgeUiUpdatePolicy || 'all';
        if (updatePolicy === 'none') {
            uiPackage.updateAvailable = false;
            uiPackage.latestVersion = null;
        }
        else if (updatePolicy === 'major') {
            const currentMajor = Number.parseInt(uiPackage.installedVersion.split('.')[0], 10);
            const versions = await this.getAvailablePluginVersions(this.configService.name);
            const sameMajorVersions = Object.keys(versions.versions)
                .filter((version) => {
                const versionMajor = Number.parseInt(version.split('.')[0], 10);
                return versionMajor === currentMajor;
            })
                .sort(rcompare);
            if (sameMajorVersions.length > 0 && gt(sameMajorVersions[0], uiPackage.installedVersion)) {
                uiPackage.latestVersion = sameMajorVersions[0];
                uiPackage.updateAvailable = true;
                uiPackage.updateEngines = versions.versions[sameMajorVersions[0]]?.engines || null;
            }
            else {
                uiPackage.updateAvailable = false;
            }
        }
        else if (updatePolicy === 'beta') {
            await this.checkForBetaUpdates(uiPackage, this.configService.name, true);
        }
        return uiPackage;
    }
    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 readJson(join(npmPkg.installPath, 'package.json'));
            const npm = await this.parsePackageJson(pkgJson, npmPkg.path);
            npm.showUpdateWarning = 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 {
                const repoVersion = this.getPluginReleaseTag(pluginAction.name);
                await firstValueFrom(this.httpService.head(`https://github.com/homebridge/plugins/releases/download/${repoVersion}/${pluginAction.name.replace('/', '@')}-${pluginAction.version}.sha256`));
                return true;
            }
            catch (e) {
                return false;
            }
        }
        else {
            return false;
        }
    }
    async doPluginBundleUpdate(pluginAction, client) {
        const pluginUpgradeInstallScriptPath = join(process.env.UIX_BASE_PATH, 'scripts/upgrade-install-plugin.sh');
        const repoVersion = this.getPluginReleaseTag(pluginAction.name);
        await this.runNpmCommand([pluginUpgradeInstallScriptPath, pluginAction.name, pluginAction.version, this.configService.customPluginPath, repoVersion], this.configService.storagePath, client, pluginAction.termCols, pluginAction.termRows);
        return true;
    }
    getPluginReleaseTag(pluginName) {
        if (pluginName.startsWith('@')) {
            return 'v2.0.0';
        }
        const ch = pluginName.startsWith('homebridge-') ? pluginName.charAt(11) : pluginName.charAt(0);
        return ch < 'n' ? 'v2.0.0-1' : 'v2.0.0-2';
    }
    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(dirname(process.env.UIX_BASE_PATH))
            && pluginAction.name === this.configService.name
            && !['latest', 'alpha', 'beta'].includes(pluginAction.version)) {
            try {
                try {
                    const withV = `v${pluginAction.version}`;
                    await 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 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 = dirname(dirname(dirname(process.env.UIX_BASE_PATH)));
        const upgradeInstallScriptPath = join(process.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], process.env.UIX_BASE_PATH, client, pluginAction.termCols, pluginAction.termRows);
    }
    async updateSelfOffline(client) {
        client.emit('stdout', 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', yellow('The Docker container will now try and restart.\n\r\n\r'));
        await new Promise(res => setTimeout(res, 800));
        client.emit('stdout', yellow('If you have not started the Docker container with ')
            + red('--restart=always') + 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', yellow('This process may take several minutes. Please be patient.\n\r'));
        await new Promise(res => setTimeout(res, 10000));
        await 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 NotFoundException();
        }
        if (!plugin.settingsSchema) {
            throw new NotFoundException();
        }
        let schemaPath;
        const i18nPath = plugin.directories?.schemas;
        if (i18nPath) {
            const lang = this.configService.ui.lang === 'auto' ? 'en' : this.configService.ui.lang;
            if (lang && lang !== 'en' && lang !== 'auto') {
                const i18nSchemaPath = resolve(plugin.installPath, pluginName, i18nPath, `config.schema.${lang}.json`);
                if (existsSync(i18nSchemaPath)) {
                    schemaPath = i18nSchemaPath;
                }
            }
        }
        schemaPath ??= resolve(plugin.installPath, pluginName, 'config.schema.json');
        let configSchema = await readJson(schemaPath);
        if (configSchema.dynamicSchemaVersion) {
            const dynamicSchemaPath = resolve(this.configService.storagePath, `.${pluginName}-v${configSchema.dynamicSchemaVersion}.schema.json`);
            const storageBoundary = this.configService.storagePath + sep;
            if (!dynamicSchemaPath.startsWith(storageBoundary)) {
                this.logger.warn(`[${pluginName}] ignoring dynamic schema path ${dynamicSchemaPath} — outside storage directory.`);
            }
            else {
                this.logger.log(`[${pluginName}] dynamic schema path: ${dynamicSchemaPath}.`);
                if (existsSync(dynamicSchemaPath)) {
                    try {
                        configSchema = await 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 (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',
                        },
                    },
                },
                matter: {
                    type: 'object',
                    properties: {
                        port: {
                            type: 'integer',
                            maximum: 65535,
                        },
                    },
                },
            },
        };
        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 NotFoundException();
        }
        const changeLog = resolve(plugin.installPath, plugin.name, 'CHANGELOG.md');
        if (await pathExists(changeLog)) {
            return {
                changelog: await readFile(changeLog, 'utf8'),
            };
        }
        else {
            throw new NotFoundException();
        }
    }
    async getPluginRelease(pluginName, version) {
        let latestVersion = null;
        let resolvedVersion = null;
        try {
            const pkg = (await firstValueFrom((this.httpService.get(`https://registry.npmjs.org/${encodeURIComponent(pluginName).replace(RE_ENCODED_AT, '@')}`)))).data;
            latestVersion = pkg['dist-tags'] ? pkg['dist-tags'].latest : null;
            if (!version || version === 'latest') {
                resolvedVersion = latestVersion;
            }
            else if (pkg['dist-tags']?.[version]) {
                resolvedVersion = pkg['dist-tags'][version];
            }
            else {
                resolvedVersion = version;
            }
        }
        catch (e) {
            throw new NotFoundException();
        }
        const fetchReleaseByVersion = async (owner, repo, ver) => {
            for (const tag of [`v${ver}`, ver]) {
                try {
                    const release = await firstValueFrom(this.httpService.get(`https://api.github.com/repos/${owner}/${repo}/releases/tags/${tag}`));
                    return release.data;
                }
                catch { }
            }
            return null;
        };
        const prereleaseType = resolvedVersion?.match(RE_PRERELEASE_TYPE)?.[1] ?? null;
        const findPrereleaseBranch = async (owner, repo, keyword) => {
            try {
                const response = await firstValueFrom(this.httpService.get(`https://api.github.com/repos/${owner}/${repo}/branches`, {
                    params: { per_page: 100 },
                }));
                const matched = response.data.filter((b) => b.name.includes(keyword));
                return matched.length > 0 ? matched.at(-1).name : null;
            }
            catch { }
            return null;
        };
        switch (pluginName) {
            case 'homebridge':
            case 'homebridge-config-ui-x': {
                try {
                    const tag = resolvedVersion ? `v${resolvedVersion}` : null;
                    const release = tag
                        ? await firstValueFrom(this.httpService.get(`https://api.github.com/repos/homebridge/${pluginName}/releases/tags/${tag}`)).then(r => r.data).catch(() => null)
                        : null;
                    let changelogData = null;
                    if (prereleaseType) {
                        const branch = await findPrereleaseBranch('homebridge', pluginName, prereleaseType);
                        if (branch) {
                            try {
                                const changelog = await firstValueFrom(this.httpService.get(`https://raw.githubusercontent.com/homebridge/${pluginName}/refs/heads/${branch}/CHANGELOG.md`));
                                changelogData = changelog.data;
                            }
                            catch { }
                        }
                    }
                    if (!changelogData && tag) {
                        try {
                            const changelog = await firstValueFrom(this.httpService.get(`https://raw.githubusercontent.com/homebridge/${pluginName}/refs/tags/${tag}/CHANGELOG.md`));
                            changelogData = changelog.data;
                        }
                        catch { }
                    }
                    if (!changelogData) {
                        try {
                            const changelog = await firstValueFrom(this.httpService.get(`https://raw.githubusercontent.com/homebridge/${pluginName}/HEAD/CHANGELOG.md`));
                            changelogData = changelog.data;
                        }
                        catch { }
                    }
                    return {
                        name: release?.tag_name ?? null,
                        notes: release?.body ?? null,
                        changelog: changelogData,
                        latestVersion,
                    };
                }
                catch {
                    return {
                        name: null,
                        notes: null,
                        changelog: null,
                        latestVersion,
                    };
                }
            }
            default: {
                await this.getInstalledPlugins();
                const plugin = this.installedPlugins.find(x => x.name === pluginName);
                if (!plugin) {
                    throw new NotFoundException();
                }
                if (!plugin.links.homepage && !plugin.links.bugs) {
                    throw new NotFoundException();
                }
                const repoMatch = plugin.links.homepage?.match(RE_GITHUB_REPO);
                const bugsMatch = plugin.links.bugs?.match(RE_GITHUB_REPO);
                let match = repoMatch;
                if (!repoMatch) {
                    if (!bugsMatch) {
                        throw new NotFoundException();
                    }
                    match = bugsMatch;
                }
                const changelogPath = this.pluginChangelogs[pluginName] || '';
                const fetchChangelog = async (ref) => {
                    for (const filename of ['CHANGELOG.md', 'changelog.md']) {
                        try {
                            const changelog = await firstValueFrom(this.httpService.get(`https://raw.githubusercontent.com/${match[1]}/${match[2]}/${ref}/${changelogPath}${filename}`));
                            return changelog.data;
                        }
                        catch { }
                    }
                    return null;
                };
                try {
                    const release = resolvedVersion
                        ? await fetchReleaseByVersion(match[1], match[2], resolvedVersion)
                        : null;
                    const releaseTag = release?.tag_name;
                    let changelogData = null;
                    if (prereleaseType) {
                        const branch = await findPrereleaseBranch(match[1], match[2], prereleaseType);
                        if (branch) {
                            changelogData = await fetchChangelog(`refs/heads/${branch}`);
                        }
                    }
                    if (!changelogData) {
                        const changelogRef = releaseTag ? `refs/tags/${releaseTag}` : 'HEAD';
                        changelogData = await fetchChangelog(changelogRef);
                    }
                    return {
                        name: releaseTag ?? null,
                        notes: release?.body ?? null,
                        changelog: changelogData,
                        latestVersion,
                    };
                }
                catch (e) {
                    let changelogData = null;
                    if (prereleaseType) {
                        const branch = await findPrereleaseBranch(match[1], match[2], prereleaseType);
                        if (branch) {
                            changelogData = await fetchChangelog(`refs/heads/${branch}`);
                        }
                    }
                    if (!changelogData) {
                        changelogData = await fetchChangelog('HEAD');
                    }
                    if (changelogData) {
                        return {
                            name: null,
                            notes: null,
                            changelog: changelogData,
                            latestVersion,
                        };
                    }
                    throw new NotFoundException();
                }
            }
        }
    }
    async getPluginAlias(pluginName) {
        if (!this.installedPlugins) {
            await this.getInstalledPlugins();
        }
        const plugin = this.installedPlugins.find(x => x.name === pluginName);
        if (!plugin) {
            throw new 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 = fork(resolve(process.env.UIX_BASE_PATH, 'scripts/extract-plugin-alias.js'), {
                        env: {
                            UIX_EXTRACT_PLUGIN_PATH: 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 getEditorContext(pluginName) {
        const [alias, configSchema, config, allChildBridges] = await Promise.all([
            this.getPluginAlias(pluginName),
            this.getPluginConfigSchemaSafe(pluginName),
            this.getConfigBlocksForPlugin(pluginName),
            this.childBridgesService.getChildBridges(),
        ]);
        return {
            pluginName,
            alias,
            configSchema,
            config,
            childBridges: allChildBridges.filter(b => b.plugin === pluginName),
        };
    }
    async getPluginConfigSchemaSafe(pluginName) {
        try {
            return await this.getPluginConfigSchema(pluginName);
        }
        catch (e) {
            if (e instanceof NotFoundException) {
                return null;
            }
            throw e;
        }
    }
    async getConfigBlocksForPlugin(pluginName) {
        const alias = await this.getPluginAlias(pluginName);
        if (!alias.pluginAlias) {
            return [];
        }
        const config = await readJson(this.configService.configPath);
        return this.filterConfigBlocksForPlugin(config, pluginName, alias);
    }
    filterConfigBlocksForPlugin(config, pluginName, alias) {
        if (!alias.pluginAlias) {
            return [];
        }
        const arrayKey = alias.pluginType === 'accessory' ? 'accessories' : 'platforms';
        const blocks = (config[arrayKey] ?? []);
        return blocks.filter(block => block[alias.pluginType] === alias.pluginAlias
            || block[alias.pluginType] === `${pluginName}.${alias.pluginAlias}`);
    }
    async getInstalledPluginsWithConfig() {
        const plugins = await this.getInstalledPlugins();
        let config;
        try {
            config = await readJson(this.configService.configPath);
        }
        catch (e) {
            this.logger.error(`Failed to read config.json while attaching plugin config blocks: ${e.message}.`);
            return plugins.map(plugin => ({ ...plugin, config: [] }));
        }
        return Promise.all(plugins.map(async (plugin) => {
            try {
                const alias = await this.getPluginAlias(plugin.name);
                return { ...plugin, config: this.filterConfigBlocksForPlugin(config, plugin.name, alias) };
            }
            catch (e) {
                this.logger.error(`Failed to attach config blocks for plugin ${plugin.name}: ${e.message}.`);
                return { ...plugin, config: [] };
            }
        }));
    }
    async getPluginChildBridgeUsernames(pluginName) {
        try {
            const plugin = await this.getPluginAlias(pluginName);
            if (!plugin.pluginAlias) {
                return [];
            }
            const config = await readJson(this.configService.configPath);
            const arrayKey = plugin.pluginType === 'accessory' ? 'accessories' : 'platforms';
            const usernamesSet = new Set();
            const pluginBlocks = config[arrayKey]?.filter((block) => {
                const matchesPlugin = block[plugin.pluginType] === plugin.pluginAlias
                    || block[plugin.pluginType] === `${pluginName}.${plugin.pluginAlias}`;
                return matchesPlugin && block._bridge?.username;
            }) || [];
            for (const block of pluginBlocks) {
                if (block._bridge?.username) {
                    usernamesSet.add(block._bridge.username);
                }
            }
            return [...usernamesSet];
        }
        catch (e) {
            this.logger.error(`Failed to get child bridge usernames for ${pluginName}: ${e.message}`);
            return [];
        }
    }
    async getPluginUiMetadata(pluginName) {
        if (!this.installedPlugins) {
            await this.getInstalledPlugins();
        }
        const plugin = this.installedPlugins.find(x => x.name === pluginName);
        const fullPath = resolve(plugin.installPath, plugin.name);
        const schema = await readJson(resolve(fullPath, 'config.schema.json'));
        const customUiPath = resolve(fullPath, schema.customUiPath || 'homebridge-ui');
        const customUiCspDomains = this.sanitizeCspDomains(schema.customUiCspDomains);
        const publicPath = resolve(customUiPath, 'public');
        const serverPath = resolve(customUiPath, 'server.js');
        const devServer = plugin.private ? schema.customUiDevServer : null;
        if (!devServer && !await pathExists(customUiPath)) {
            throw new Error(`Plugin does not provide a custom UI at expected location: ${customUiPath}`);
        }
        if (!devServer && !(await realpath(customUiPath)).startsWith(await realpath(fullPath))) {
            throw new Error(`Custom UI path is outside the plugin root: ${await realpath(customUiPath)}`);
        }
        if (await pathExists(resolve(publicPath, 'index.html')) || devServer) {
            return {
                devServer,
                serverPath,
                publicPath,
                plugin,
                customUiCspDomains,
            };
        }
        throw new Error('Plugin does not provide a custom UI');
    }
    async getDisabledPlugins() {
        try {
            const config = await readJson(this.configService.configPath);
            if (Array.isArray(config.disabledPlugins)) {
                return config.disabledPlugins;
            }
            else {
                return [];
            }
        }
        catch (e) {
            return [];
        }
    }
    async getInstalledScopedModules(requiredPath, scope) {
        try {
            if ((await stat(join(requiredPath, scope))).isDirectory()) {
                const scopedModules = await readdir(join(requiredPath, scope));
                return scopedModules
                    .filter(x => x.startsWith('homebridge-') && existsSync(join(requiredPath, scope, x, 'package.json')))
                    .map((x) => {
                    return {
                        name: join(scope, x).split(sep).join('/'),
                        installPath: 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 readdir(requiredPath);
            for (const module of modules) {
                try {
                    if (module.charAt(0) === '@') {
                        allModules.push(...await this.getInstalledScopedModules(requiredPath, module));
                    }
                    else {
                        const modulePath = join(requiredPath, module);
                        if (existsSync(join(modulePath, 'package.json'))) {
                            allModules.push({
                                name: module,
                                installPath: modulePath,
                                path: requiredPath,
                            });
                        }
                    }
                }
                catch (e) {
                    this.logger.log(`Failed to parse ${module} in ${requiredPath} as ${e.message}.`);
                }
            }
        }
        const runningUiPath = resolve(process.env.UIX_BASE_PATH);
        if (!allModules.some(x => x.name === 'homebridge-config-ui-x' && resolve(x.installPath) === runningUiPath)) {
            allModules.push({
                name: 'homebridge-config-ui-x',
                installPath: process.env.UIX_BASE_PATH,
                path: dirname(process.env.UIX_BASE_PATH),
            });
        }
        const runningHomebridgePath = this.configService.runningHomebridgeModulePath;
        if (runningHomebridgePath) {
            let resolvedRunningPath;
            try {
                resolvedRunningPath = await realpath(runningHomebridgePath);
            }
            catch (e) {
                this.logger.debug(`Failed to resolve running Homebridge module path as ${e.message}.`);
            }
            if (resolvedRunningPath && existsSync(join(resolvedRunningPath, 'package.json'))) {
                const alreadyFound = await this.containsInstallPath(allModules, 'homebridge', resolvedRunningPath);
                if (!alreadyFound) {
                    allModules.push({
                        name: 'homebridge',
                        installPath: resolvedRunningPath,
                        path: dirname(resolvedRunningPath),
                    });
                }
            }
        }
        if (allModules.findIndex(x => x.name === 'homebridge') === -1) {
            if (existsSync(join(process.env.UIX_BASE_PATH, '..', 'homebridge'))) {
                allModules.push({
                    name: 'homebridge',
                    installPath: join(process.env.UIX_BASE_PATH, '..', 'homebridge'),
                    path: dirname(join(process.env.UIX_BASE_PATH, '..', 'homebridge')),
                });
            }
        }
        return allModules;
    }
    isScopedPlugin(name) {
        return (name.charAt(0) === '@' && name.split('/').length > 0 && name.split('/')[1].indexOf('homebridge-') === 0);
    }
    isHiddenPlugin(name) {
        return this.hiddenPlugins.includes(name) || this.hiddenScopes.some(scope => name.startsWith(scope));
    }
    getNpmPath() {
        if (platform() === 'win32') {
            const windowsNpmPath = [
                join(process.env.APPDATA, 'npm/npm.cmd'),
                join(process.env.ProgramFiles, 'nodejs/npm.cmd'),
                join(process.env.NVM_SYMLINK || `${process.env.ProgramFiles}/nodejs`, 'npm.cmd'),
            ].filter(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 ['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, ...module._nodeModulePaths(dirname(require.resolve.paths('.')?.[0] || process.cwd()))];
            if (process.env.NODE_PATH) {
                paths = [...process.env.NODE_PATH.split(delimiter).filter(p => !!p), ...paths];
            }
            else {
                if ((platform() !== 'win32')) {
                    paths.push('/usr/local/lib/node_modules');
                    paths.push('/usr/lib/node_modules');
                }
                paths.push(...this.getNpmPrefixToSearchPaths());
            }
            paths = paths.filter(x => x !== join(process.env.UIX_BASE_PATH, 'node_modules'));
        }
        return [...new Set(paths)].filter((requiredPath) => {
            return existsSync(requiredPath);
        });
    }
    getNpmPrefixToSearchPaths() {
        const paths = [];
        if ((platform() === 'win32')) {
            paths.push(join(process.env.APPDATA, 'npm/node_modules'));
        }
        else {
            paths.push(execSync('/bin/echo -n "$(npm -g prefix)/lib/node_modules"', {
                env: {
                    npm_config_loglevel: 'silent',
                    npm_update_notifier: 'false',
                    ...process.env,
                },
            }).toString('utf8'));
        }
        return paths;
    }
    async checkForBetaUpdates(plugin, packageName, preferBetas) {
        const pluginVersion = parse(plugin.installedVersion);
        const installedTag = pluginVersion.prerelease[0]?.toString();
        const shouldCheckBetas = (installedTag
            && ['alpha', 'beta', 'test'].includes(installedTag)
            && gt(plugin.installedVersion, plugin.latestVersion)) || preferBetas;
        if (!shouldCheckBetas) {
            return;
        }
        const versions = await this.getAvailablePluginVersions(packageName);
        const targetTag = preferBetas && !installedTag ? 'beta' : installedTag;
        const candidate = versions.tags[targetTag];
        const beatsInstalled = candidate && gt(candidate, plugin.installedVersion);
        const beatsStable = !plugin.updateAvailable || gt(candidate, plugin.latestVersion);
        if (beatsInstalled && beatsStable) {
            plugin.latestVersion = candidate;
            plugin.updateAvailable = true;
            plugin.updateEngines = versions.versions?.[plugin.latestVersion]?.engines || null;
            plugin.updateTag = targetTag;
        }
    }
    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(RE_URL, '').trim()
                : pkgJson.name,
            verifiedPlugin: this.verifiedPlugins.includes(pkgJson.name),
            verifiedPlusPlugin: this.verifiedPlusPlugins.includes(pkgJson.name),
            supportsMatter: this.supportsMatter(pkgJson.keywords),
            supportsHap: this.supportsHap(pkgJson.keywords),
            icon: this.pluginIcons[pkgJson.name]
                ? `${this.pluginListUrl}${this.pluginIcons[pkgJson.name]}`
                : null,
            isHbScoped: pkgJson.name.startsWith('@homebridge-plugins/'),
            newHbScope: this.newScopePlugins[pkgJson.name],
            isUnmaintained: this.unmaintainedPlugins.includes(pkgJson.name),
            installedVersion: installPath ? (pkgJson.version || '0.0.1') : null,
            globalInstall: (installPath !== this.configService.customPluginPath),
            settingsSchema: await pathExists(resolve(installPath, pkgJson.name, 'config.schema.json')),
            engines: pkgJson.engines,
            installPath,
        };
        plugin.funding = (plugin.verifiedPlugin || plugin.verifiedPlusPlugin) ? pkgJson.funding : undefined;
        plugin.directories = pkgJson.directories;
        if (pkgJson.private) {
            plugin.publicPackage = false;
            plugin.latestVersion = null;
            plugin.updateAvailable = false;
            plugin.links = {};
            return plugin;
        }
        return this.getPluginFromNpm(plugin);
    }
    async getPluginFromNpm(plugin, skipBetaCheck = false) {
        try {
            const fromCache = this.npmPluginCache.get(plugin.name);
            plugin.updateAvailable = false;
            plugin.updateTag = null;
            const pkg = fromCache || (await firstValueFrom(this.httpService.get(`https://registry.npmjs.org/${encodeURIComponent(plugin.name).replace(RE_ENCODED_AT, '@')}/latest`))).data;
            plugin.latestVersion = pkg.version;
            plugin.updateAvailable = gt(pkg.version, plugin.installedVersion);
            plugin.updateEngines = plugin.updateAvailable ? pkg.engines : null;
            if (!skipBetaCheck) {
                const preferBetas = this.configService.ui.plugins?.showBetasFor?.includes(plugin.name) || false;
                await this.checkForBetaUpdates(plugin, plugin.name, preferBetas);
            }
            if (!fromCache) {
                this.npmPluginCache.set(plugin.name, pkg);
            }
            plugin.publicPackage = true;
            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);
        }
        catch (e) {
            if (e.response?.status !== 404) {
                this.logger.log(`[${plugin.name}] failed to check registry.npmjs.org for updates (see https://homebridge.io/w/JJSz6 for help) as ${e.message}.`);
            }
            plugin.publicPackage = false;
            plugin.latestVersion = null;
            plugin.updateAvailable = false;
            plugin.updateTag = null;
            plugin.links = {};
        }
        return plugin;
    }
    async getNpmModuleLatestVersion(npmModuleName) {
        try {
            const response = await firstValueFrom(this.httpService.get(`https://registry.npmjs.org/${npmModuleName}/latest`));
            return response.data.version;
        }
        catch (e) {
            return 'latest';
        }
    }
    getNpmMajorVersion() {
        if (this.npmMajorVersion === null) {
            try {
                this.npmMajorVersion = Number.parseInt(execSync('npm --version', { timeout: 10000 }).toString().trim().split('.')[0], 10) || 0;
            }
            catch (error) {
                this.logger.debug(`Could not determine npm version: ${error.message}`);
                this.npmMajorVersion = 0;
            }
        }
        return this.npmMajorVersion;
    }
    async getAllowedInstallScripts(pluginName, pluginVersion) {
        if (this.getNpmMajorVersion() < 12) {
            return { allowed: [], withScripts: [] };
        }
        const allowed = new Set([pluginName]);
        const withScripts = new Set();
        try {
            const pkg = (await firstValueFrom(this.httpService.get(`https://registry.npmjs.org/${encodeURIComponent(pluginName).replace(RE_ENCODED_AT, '@')}`))).data;
            const manifest = pkg.versions?.[pluginVersion];
            if (manifest?.hasInstallScript === true || ['preinstall', 'install', 'postinstall'].some(script => manifest?.scripts?.[script])) {
                withScripts.add(`${pluginName}@${pluginVersion}`);
            }
            const declared = manifest?.allowScripts;
            if (Array.isArray(declared)) {
                for (const name of declared) {
                    if (typeof name === 'string' && name.length) {
                        allowed.add(name);
                        withScripts.add(name);
                    }
                }
            }
            else if (declared && typeof declared === 'object') {
                for (const [name, enabled] of Object.entries(declared)) {
                    if (enabled === true) {
                        allowed.add(name);
                        withScripts.add(name);
                    }
                }
            }
        }
        catch (error) {
            this.logger.debug(`Could not read allowScripts for ${pluginName}@${pluginVersion}: ${error.message}`);
        }
        return { allowed: [...allowed], withScripts: [...withScripts] };
    }
    async getLocalAllowScripts() {
        if (!this.configService.customPluginPath) {
            return undefined;
        }
        try {
            return (await readJson(resolve(this.configService.customPluginPath, '../package.json')))?.allowScripts;
        }
        catch {
            return undefined;
        }
    }
    filterLocallyHandledScripts(scriptPackages, localAllowScripts) {
        const splitKey = (key) => {
            const at = key.indexOf('@', 1);
            return at === -1 ? { name: key } : { name: key.slice(0, at), version: key.slice(at + 1) };
        };
        const entries = [];
        if (Array.isArray(localAllowScripts)) {
            for (const key of localAllowScripts) {
                if (typeof key === 'string' && key.length) {
                    entries.push({ ...splitKey(key), enabled: true });
                }
            }
        }
        else if (localAllowScripts && typeof localAllowScripts === 'object') {
            for (const [key, enabled] of Object.entries(localAllowScripts)) {
                if (typeof enabled === 'boolean') {
                    entries.push({ ...splitKey(key), enabled });
                }
            }
        }
        return scriptPackages.filter((packageKey) => {
            const candidate = splitKey(packageKey);
            const matches = entries.filter(x => x.name === candidate.name);
            if (matches.some(x => !x.enabled)) {
                return false;
            }
            return !matches.some(x => !x.version || !candidate.version || x.version === candidate.version);
        });
    }
    async applyAllowScripts(installOptions, client, pluginAction) {
        const { allowed, withScripts } = await this.getAllowedInstallScripts(pluginAction.name, pluginAction.version);
        if (!allowed.length) {
            return;
        }
        if (!installOptions.includes('-g')) {
            const skipped = this.filterLocallyHandledScripts(withScripts, await this.getLocalAllowScripts());
            if (skipped.length) {
                client.emit('stdout', yellow(`Install scripts for ${skipped.join(', ')} will not run: npm only accepts --allow-scripts for global installs. Add an "allowScripts" entry to the package.json alongside your plugins to permit them.\r\n\r\n`));
            }
            return;
        }
        installOptions.push(`--allow-scripts=${allowed.join(',')}`);
        client.emit('stdout', yellow(`Allowing install scripts for: ${allowed.join(', ')}.\r\n\r\n`));
    }
    async runNpmCommand(command, cwd, client, cols, rows) {
        await this.removeSynologyMetadata();
        let timeoutTimer;
        command = command.map(x => String(x)).filter(x => x.length);
        if (this.configService.ui.sudo) {
            command.unshift('sudo', '-E', '-n');
        }
        else {
            let npmInstallPath;
            try {
                npmInstallPath = execSync('npm root -g').toString().trim();
            }
            catch (e) {
                npmInstallPath = resolve(cwd, 'node_modules');
            }
            try {
                await access(npmInstallPath, constants.W_OK);
            }
            catch (e) {
                client.emit('stdout', yellow(`The user "${userInfo().username}" does not have write access to the target directory:\n\r\n\r`));
                client.emit('stdout', `${npmInstallPath}\n\r\n\r`);
                client.emit('stdout', yellow('This may cause the operation to fail.\n\r'));
                client.emit('stdout', yellow('See the docs for details on how to enable sudo mode:\n\r'));
                client.emit('stdout', yellow('https://github.com/homebridge/homebridge-config-ui-x/wiki/Manual-Configuration#sudo-mode\n\r\n\r'));
            }
        }
        this.logger.log(`Running command ${command.join(' ')}.`);
        if (!satisfies(process.version, `>=${this.configService.minimumNodeVersion}`)) {
            client.emit('stdout', yellow(`Node.js v${this.configService.minimumNodeVersion} higher is required for ${this.configService.name}.\n\r`));
            client.emit('stdout', yellow(`You may experience issues while running on Node.js ${process.version}.\n\r\n\r`));
        }
        const env = this.sanitizeNpmEnv(process.env);
        Object.assign(env, {
            npm_config_global_style: 'true',
            npm_config_update_notifier: 'false',
            npm_config_prefer_online: 'true',
            npm_config_foreground_scripts: 'true',
            npm_config_loglevel: 'error',
        });
        if (command.includes('-g') && basename(cwd) === 'lib') {
            cwd = dirname(cwd);
            Object.assign(env, {
                npm_config_prefix: cwd,
            });
        }
        if (platform() === 'win32') {
            Object.assign(env, {
                npm_config_prefix: cwd,
            });
        }
        client.emit('stdout', cyan(`USER: ${userInfo().username}\n\r`));
        client.emit('stdout', cyan(`DIR: ${cwd}\n\r`));
        client.emit('stdout', cyan(`CMD: ${command.join(' ')}\n\r\n\r`));
        this.pluginManagementInProgress += 1;
        try {
            await new Promise((res, rej) => {
                const term = this.nodePtyService.spawn(command.shift(), command, {
                    name: 'xterm-color',
                    cols: cols || 80,
                    rows: rows || 30,
                    cwd,
                    env,
                });
                term.onData((data) => {
                    client.emit('stdout', data);
                });
                term.onExit(({ exitCode }) => {
                    if (exitCode === 0) {
                        clearTimeout(timeoutTimer);
                        client.emit('stdout', green('\n\rOperation succeeded!.\n\r'));
                        res(null);
                    }
                    else {
                        clearTimeout(timeoutTimer);
                        rej(new Error(`Operation failed with code ${exitCode}.\n\rYou can download this log file for future reference.\n\rSee https://github.com/homebridge/homebridge-config-ui-x/wiki/Troubleshooting for help.`));
                    }
                });
                timeoutTimer = setTimeout(() => {
                    term.kill('SIGTERM');
                }, 300000);
            });
        }
        finally {
            this.installedPluginsCache.del('installed-plugins');
            this.pluginManagementInProgress -= 1;
        }
    }
    async ensureCustomPluginDirExists() {
        if (!this.configService.customPluginPath) {
            return;
        }
        if (!await pathExists(this.configService.customPluginPath)) {
            this.logger.warn(`Custom plugin directory was removed, re-creating ${this.configService.customPluginPath}.`);
            try {
                await ensureDir(this.configService.customPluginPath);
            }
            catch (e) {
                this.logger.error(`Failed to re-create custom plugin directory as ${e.message}.`);
            }
        }
    }
    sanitizeNpmEnv(env) {
        const droppedPrefixes = ['AWS_', 'AZURE_', 'GOOGLE_APPLICATION_', 'GCP_'];
        const droppedExact = new Set(['GITHUB_TOKEN', 'GH_TOKEN', 'NPM_TOKEN']);
        const droppedPattern = /SECRET|PASSWORD|PASSWD|PRIVATE_KEY|_TOKEN$/i;
        const result = {};
        for (const [key, value] of Object.entries(env)) {
            if (droppedExact.has(key)) {
                continue;
            }
            if (droppedPrefixes.some(prefix => key.startsWith(prefix))) {
                continue;
            }
            if (droppedPattern.test(key)) {
                continue;
            }
            result[key] = value;
        }
        return result;
    }
    async removeSynologyMetadata() {
        if (!this.configService.customPluginPath) {
            return;
        }
        const offendingPath = resolve(this.configService.customPluginPath, '@eaDir');
        try {
            if (!await pathExists(offendingPath)) {
                await remove(offendingPath);
            }
        }
        catch (e) {
            this.logger.error(`Failed to remove ${offendingPath} as ${e.message}.`);
        }
    }
    async cleanNpmCache() {
        const command = [...this.npm, 'cache', 'clean', '--force'];
        if (this.configService.ui.sudo) {
            command.unshift('sudo', '-E', '-n');
        }
        return new Promise((res) => {
            let child;
            try {
                child = spawn(command[0], command.slice(1));
            }
            catch (e) {
                this.logger.warn(`Skipped npm cache clean as ${e.message}.`);
                res(null);
                return;
            }
            child.on('exit', (code) => {
                this.logger.log(`Executed npm cache clear command with exit code ${code}.`);
                res(null);
            });
            child.on('error', () => {
                res(null);
            });
        });
    }
    async loadPluginList() {
        clearTimeout(this.pluginListRetryTimeout);
        try {
            const pluginList = (await firstValueFrom(this.httpService.get(this.pluginListFile, {
                httpsAgent: null,
            })));
            const pluginListData = pluginList.data;
            const verifiedPlugins = [];
            const verifiedPlusPlugins = [];
            const pluginIcons = {};
            const hiddenPlugins = [];
            const hiddenScopes = [];
            const unmaintainedPlugins = [];
            const pluginAuthors = {};
            const pluginNames = {};
            const pluginChangelogs = {};
            const newScopePlugins = {};
            const scopedPluginNames = [];
            Object.keys(pluginListData).forEach((key) => {
                if (key.startsWith('@homebridge-plugins/')) {
                    scopedPluginNames.push(key);
                }
                const plugin = pluginListData[key];
                if (plugin.i) {
                    pluginIcons[key] = `icons/${plugin.i}.png`;
                }
                if (plugin.h) {
                    if (key.endsWith('/')) {
                        hiddenScopes.push(key);
                    }
                    else {
                        hiddenPlugins.push(key);
                    }
                }
                if (plugin.u) {
                    unmaintainedPlugins.push(key);
                }
                if (plugin.a) {
                    pluginAuthors[key] = plugin.a;
                }
                if (plugin.n) {
                    pluginNames[key] = plugin.n;
                }
                if (plugin.s) {
                    newScopePlugins[key] = plugin.s;
                }
                if (plugin.v) {
                    verifiedPlugins.push(key);
                }
                if (plugin.p) {
                    verifiedPlusPlugins.push(key);
                }
                if (plugin.c) {
                    pluginChangelogs[key] = plugin.c;
                }
            });
            this.verifiedPlugins = verifiedPlugins;
            this.verifiedPlusPlugins = verifiedPlusPlugins;
            this.pluginIcons = pluginIcons;
            this.hiddenPlugins = hiddenPlugins;
            this.hiddenScopes = hiddenScopes;
            this.unmaintainedPlugins = unmaintainedPlugins;
            this.pluginAuthors = pluginAuthors;
            this.pluginNames = pluginNames;
            this.pluginChangelogs = pluginChangelogs;
            this.newScopePlugins = newScopePlugins;
            this.scopedPluginNames = scopedPluginNames;
        }
        catch (e) {
            this.pluginListRetryTimeout = setTimeout(() => this.loadPluginList(), 60000);
            this.logger.debug(`Could not obtain plugin list from plugins repo as ${e.message}.`);
        }
    }
    sanitizeCspDomains(domains) {
        if (!Array.isArray(domains)) {
            return [];
        }
        const RE_VALID_CSP_DOMAIN = /^https:\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)+$/i;
        return domains
            .filter((d) => {
            if (typeof d !== 'string') {
                return false;
            }
            if (Buffer.byteLength(d, 'utf8') > 256) {
                this.logger.error('Ignoring customUiCspDomains entry longer than 256 bytes.');
                return false;
            }
            return RE_VALID_CSP_DOMAIN.test(d);
        })
            .slice(0, 10);
    }
};
PluginsService = PluginsService_1 = __decorate([
    Injectable(),
    __param(0, Inject(HttpService)),
    __param(1, Inject(NodePtyService)),
    __param(2, Inject(Logger)),
    __param(3, Inject(ConfigService)),
    __param(4, Inject(HomebridgeIpcService)),
    __param(5, Inject(ChildBridgesService)),
    __metadata("design:paramtypes", [HttpService,
        NodePtyService,
        Logger,
        ConfigService,
        HomebridgeIpcService,
        ChildBridgesService])
], PluginsService);
export { PluginsService };
//# sourceMappingURL=plugins.service.js.map