UNPKG

homebridge-config-ui-x

Version:

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

1,051 lines • 43.8 kB
#!/usr/bin/env node import { Buffer } from 'node:buffer'; import { execFileSync, execSync, fork } from 'node:child_process'; import { randomInt } from 'node:crypto'; import { chownSync, createReadStream, createWriteStream, existsSync } from 'node:fs'; import { mkdtemp, open, readFile, rename, stat } from 'node:fs/promises'; import { arch, cpus, homedir, platform, release, tmpdir, type } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import process from 'node:process'; import { StringDecoder } from 'node:string_decoder'; import { fileURLToPath } from 'node:url'; import axios from 'axios'; import { program } from 'commander'; import { mkdirp, pathExists, pathExistsSync, readJson, readJsonSync, remove, writeJson } from 'fs-extra/esm'; import ora from 'ora'; import { gt, gte, parse } from 'semver'; import { networkInterfaceDefault, networkInterfaces } from 'systeminformation'; import { Tail } from 'tail'; import { extract } from 'tar'; import { check as tcpCheck } from 'tcp-port-used'; import { RE_COLON, RE_NON_SCOPED, RE_PLUGIN_NAME, RE_SCOPED, RE_SERVICE_NAME } from '../core/regex.constants.js'; import { DarwinInstaller } from './platforms/darwin.js'; import { FreeBSDInstaller } from './platforms/freebsd.js'; import { LinuxInstaller } from './platforms/linux.js'; import { Win32Installer } from './platforms/win32.js'; process.title = 'hb-service'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); export class HomebridgeServiceHelper { action; selfPath = __filename; serviceName = 'Homebridge'; storagePath; usingCustomStoragePath = false; allowRunRoot = false; enableHbServicePluginManagement = false; asUser; addGroup; log; homebridgeModulePath; homebridgePackage; homebridgeBinary; homebridge; homebridgeOpts = ['-I']; homebridgeCustomEnv = {}; uiBinary; stdout; docker; uid; gid; uiPort = 8581; installer; ipcService; get logPath() { return resolve(this.storagePath, 'homebridge.log'); } constructor() { this.nodeVersionCheck(); switch (platform()) { case 'linux': this.installer = new LinuxInstaller(this); break; case 'win32': this.installer = new Win32Installer(this); break; case 'darwin': this.installer = new DarwinInstaller(this); break; case 'freebsd': this.installer = new FreeBSDInstaller(this); break; default: this.logger(`ERROR: This command is not supported on ${platform()}.`, 'fail'); process.exit(1); } program .allowUnknownOption() .allowExcessArguments() .storeOptionsAsProperties(true) .arguments('[install|uninstall|start|stop|restart|rebuild|run|logs|view|add|remove]') .option('-P, --plugin-path <path>', '', (p) => { process.env.UIX_CUSTOM_PLUGIN_PATH = p; this.homebridgeOpts.push('-P', p); }) .option('-U, --user-storage-path <path>', '', (p) => { this.storagePath = p; this.usingCustomStoragePath = true; }) .option('-S, --service-name <service name>', 'The name of the homebridge service to install or control', p => this.serviceName = p) .option('-T, --no-timestamp', '', () => this.homebridgeOpts.push('-T')) .option('--strict-plugin-resolution', '', () => { process.env.UIX_STRICT_PLUGIN_RESOLUTION = '1'; }) .option('--port <port>', 'The port to set to the Homebridge UI when installing as a service', p => this.uiPort = Number.parseInt(p, 10)) .option('--user <user>', 'The user account the Homebridge service will be installed as (Linux, FreeBSD, macOS only)', p => this.asUser = p) .option('--group <group>', 'The group the Homebridge service will be added to (Linux, FreeBSD, macOS only)', p => this.addGroup = p) .option('--stdout', '', () => this.stdout = true) .option('--allow-root', '', () => this.allowRunRoot = true) .option('--docker', '', () => this.docker = true) .option('--uid <number>', '', i => this.uid = Number.parseInt(i, 10)) .option('--gid <number>', '', i => this.gid = Number.parseInt(i, 10)) .option('-v, --version', 'output the version number', () => this.showVersion()) .action((cmd) => { this.action = cmd; }) .parse(process.argv); this.setEnv(); switch (this.action) { case 'install': { this.nvmCheck(); this.logger(`Installing ${this.serviceName} service...`); this.installer.install(); break; } case 'uninstall': { this.logger(`Removing ${this.serviceName} service...`); this.installer.uninstall(); break; } case 'start': { this.installer.start(); break; } case 'stop': { this.installer.stop(); break; } case 'restart': { this.logger(`Restarting ${this.serviceName} service...`); this.installer.restart(); break; } case 'rebuild': { this.logger(`Rebuilding for Node.js ${process.version}...`); this.installer.rebuild(program.args.includes('--all')); break; } case 'run': { this.launch(); break; } case 'logs': { this.tailLogs(); break; } case 'view': { this.viewLogs(); break; } case 'add': { this.npmPluginManagement(program.args); break; } case 'remove': { this.npmPluginManagement(program.args); break; } case 'update-node': { this.checkForNodejsUpdates(program.args.length === 2 ? program.args[1] : null); break; } case 'update-homebridge': { this.installer.updateHomebridgePackage(); break; } case 'before-start': { this.installer.beforeStart(); break; } case 'status': { this.checkStatus(); break; } default: { program.outputHelp(); console.log('\nThe hb-service command is provided by homebridge-config-ui-x\n'); console.log('Please provide a command:'); console.log(' install install homebridge as a service'); console.log(' uninstall remove the homebridge service'); console.log(' start start the homebridge service'); console.log(' stop stop the homebridge service'); console.log(' restart restart the homebridge service'); if (this.enableHbServicePluginManagement) { console.log(' add <plugin>@<version> install a plugin'); console.log(' remove <plugin>@<version> remove a plugin'); } console.log(' rebuild rebuild ui'); console.log(' rebuild --all rebuild all npm modules (use after updating Node.js)'); console.log(' run run homebridge daemon'); console.log(' logs tails the homebridge service logs'); console.log(' view views the homebridge service logs for 30 seconds'); console.log(' update-node [version] update Node.js'); console.log(' update-homebridge update Homebridge apt package'); console.log('\nSee the wiki for help with hb-service: https://homebridge.io/w/JTtHK \n'); process.exit(1); } } } logger(msg, level = 'info') { if (this.action === 'run') { msg = `\x1B[37m[${new Date().toLocaleString()}]\x1B[0m ` + `\x1B[36m[HB Supervisor]\x1B[0m ${msg}`; if (this.log) { this.log.write(`${msg}\n`); } else { console.log(msg); } } else { ora()[level](msg); } } setEnv() { if (!RE_SERVICE_NAME.test(this.serviceName)) { this.logger('Service name must not contain spaces or special characters.', 'fail'); process.exit(1); } if (!this.storagePath) { if (platform() === 'linux' || platform() === 'freebsd') { this.storagePath = resolve('/var/lib', this.serviceName.toLowerCase()); } else { this.storagePath = resolve(homedir(), `.${this.serviceName.toLowerCase()}`); } } if (process.env.CONFIG_UI_VERSION && process.env.HOMEBRIDGE_VERSION && process.env.QEMU_ARCH) { if (platform() === 'linux' && ['install', 'uninstall', 'start', 'stop', 'restart', 'logs'].includes(this.action)) { this.logger(`Sorry, the ${this.action} command is not supported in Docker.`, 'fail'); process.exit(1); } } this.enableHbServicePluginManagement = (process.env.UIX_CUSTOM_PLUGIN_PATH && (Boolean(process.env.HOMEBRIDGE_SYNOLOGY_PACKAGE === '1') || Boolean(process.env.HOMEBRIDGE_APT_PACKAGE === '1'))); process.env.UIX_STORAGE_PATH = this.storagePath; process.env.UIX_CONFIG_PATH = resolve(this.storagePath, 'config.json'); process.env.UIX_BASE_PATH = process.env.UIX_BASE_PATH_OVERRIDE || resolve(__dirname, '../../'); process.env.UIX_SERVICE_MODE = '1'; process.env.UIX_INSECURE_MODE = '1'; } showVersion() { const pjson = readJsonSync(resolve(__dirname, '../../', 'package.json')); console.log(`v${pjson.version}`); process.exit(0); } async startLog() { if (this.stdout === true) { this.log = process.stdout; return; } this.logger(`Logging to ${this.logPath}.`); this.log = createWriteStream(this.logPath, { flags: 'a' }); process.stdout.write = process.stderr.write = this.log.write.bind(this.log); } async readConfig() { return readJson(process.env.UIX_CONFIG_PATH); } async truncateLog() { if (!(await pathExists(this.logPath))) { return; } try { const currentConfig = await this.readConfig(); const uiConfigBlock = currentConfig.platforms?.find((x) => x.platform === 'config'); const maxSize = uiConfigBlock?.log?.maxSize ?? 1000000; const truncateSize = uiConfigBlock?.log?.truncateSize ?? 200000; if (maxSize < 0) { return; } const logStats = await stat(this.logPath); if (logStats.size < maxSize) { return; } const logStartPosition = logStats.size - truncateSize; const logBuffer = Buffer.alloc(truncateSize); const logFileHandle = await open(this.logPath, 'a+'); const corked = this.log && typeof this.log.cork === 'function'; if (corked) { this.log.cork(); } try { await logFileHandle.read(logBuffer, 0, truncateSize, logStartPosition); await logFileHandle.truncate(); await logFileHandle.write(logBuffer); } finally { await logFileHandle.close(); if (corked) { this.log.uncork(); } } } catch (e) { this.logger(`Failed to truncate log file: ${e.message}.`, 'fail'); } } async launch() { if (platform() !== 'win32' && process.getuid() === 0 && !this.allowRunRoot) { this.logger('The hb-service run command should not be executed as root.'); this.logger('Use the --allow-root flag to force the service to run as the root user.'); process.exit(0); } this.logger(`Homebridge storage path: ${this.storagePath}.`); this.logger(`Homebridge config path: ${process.env.UIX_CONFIG_PATH}.`); setInterval(() => { this.truncateLog(); }, (1000 * 60 * 60) * 2); try { await this.storagePathCheck(); await this.startLog(); await this.configCheck(); this.logger(`OS: ${type()} ${release()} ${arch()}.`); this.logger(`Node.js ${process.version} ${process.execPath}.`); this.homebridgeBinary = await this.findHomebridgePath(); this.logger(`Homebridge path: ${this.homebridgeBinary}.`); await this.loadHomebridgeStartupOptions(); this.uiBinary = resolve(process.env.UIX_BASE_PATH, 'dist', 'bin', 'standalone.js'); this.logger(`UI path: ${this.uiBinary}.`); } catch (e) { this.logger(e.message); process.exit(1); } this.startExitHandler(); await this.runUi(); if (this.ipcService && this.homebridgePackage) { this.ipcService.setHomebridgeVersion(this.homebridgePackage.version); } if (cpus().length === 1 && arch() === 'arm') { this.logger('Delaying Homebridge startup by 20 seconds on low powered server.'); setTimeout(() => { this.runHomebridge(); }, 20000); } else { this.runHomebridge(); } } startExitHandler() { const exitHandler = () => { this.logger('Stopping services...'); try { this.homebridge.kill(); } catch (e) { } setTimeout(() => { try { this.homebridge.kill('SIGKILL'); } catch (e) { } process.exit(1282); }, 7000); }; process.on('SIGTERM', exitHandler); process.on('SIGINT', exitHandler); } runHomebridge() { if (!this.homebridgeBinary || !pathExistsSync(this.homebridgeBinary)) { this.logger('Could not find Homebridge. Make sure you have installed Homebridge using the -g flag then restart.', 'fail'); this.logger('npm install -g --unsafe-perm homebridge', 'fail'); return; } if (process.env.UIX_STRICT_PLUGIN_RESOLUTION === '1') { if (!this.homebridgeOpts.includes('--strict-plugin-resolution')) { this.homebridgeOpts.push('--strict-plugin-resolution'); } } if (this.homebridgeOpts.length) { this.logger(`Starting Homebridge with extra flags: ${this.homebridgeOpts.join(' ')}.`); } if (Object.keys(this.homebridgeCustomEnv).length) { this.logger(`Starting Homebridge with custom env: ${JSON.stringify(this.homebridgeCustomEnv)}.`); } const env = {}; Object.assign(env, process.env); Object.assign(env, this.homebridgeCustomEnv); const childProcessOpts = { env, silent: true, }; if (this.allowRunRoot && this.uid && this.gid) { childProcessOpts.uid = this.uid; childProcessOpts.gid = this.gid; } if (this.docker) { this.fixDockerPermissions(); } this.homebridge = fork(this.homebridgeBinary, [ '-C', '-Q', '-U', this.storagePath, ...this.homebridgeOpts, ], childProcessOpts); if (this.ipcService) { this.ipcService.setHomebridgeProcess(this.homebridge); this.ipcService.setHomebridgeVersion(this.homebridgePackage.version); } this.logger(`Started Homebridge v${this.homebridgePackage.version} with PID: ${this.homebridge.pid}.`); const outDecoder = new StringDecoder('utf8'); const errDecoder = new StringDecoder('utf8'); let outBuf = ''; let errBuf = ''; const flushLines = (key) => { const buf = key === 'out' ? outBuf : errBuf; let consumed = 0; let idx = buf.indexOf('\n', consumed); while (idx !== -1) { this.log.write(buf.slice(consumed, idx + 1)); consumed = idx + 1; idx = buf.indexOf('\n', consumed); } const remainder = buf.slice(consumed); if (key === 'out') { outBuf = remainder; } else { errBuf = remainder; } }; this.homebridge.stdout.on('data', (data) => { outBuf += outDecoder.write(data); flushLines('out'); }); this.homebridge.stderr.on('data', (data) => { errBuf += errDecoder.write(data); flushLines('err'); }); this.homebridge.on('close', (code, signal) => { outBuf += outDecoder.end(); errBuf += errDecoder.end(); if (outBuf) { this.log.write(outBuf.endsWith('\n') ? outBuf : `${outBuf}\n`); outBuf = ''; } if (errBuf) { this.log.write(errBuf.endsWith('\n') ? errBuf : `${errBuf}\n`); errBuf = ''; } this.handleHomebridgeClose(code, signal); }); } handleHomebridgeClose(code, signal) { this.logger(`Homebridge process ended. Code: ${code}, signal: ${signal}.`); this.checkForStaleHomebridgeProcess(); this.refreshHomebridgePackage(); setTimeout(() => { this.logger('Restarting Homebridge...'); this.runHomebridge(); }, 5000); } async runUi() { try { const main = await import('../main.js'); const ui = await main.app; this.ipcService = ui.get(main.HomebridgeIpcService); } catch (e) { this.logger('The user interface threw an unhandled error.'); console.error(e); setTimeout(() => { process.exit(1); }, 4500); if (this.homebridge) { this.homebridge.kill(); } } } async getNpmGlobalModulesDirectory() { try { const npmPrefix = execSync('npm -g prefix', { env: { npm_config_loglevel: 'silent', npm_update_notifier: 'false', ...process.env, }, }).toString('utf8').trim(); return platform() === 'win32' ? join(npmPrefix, 'node_modules') : join(npmPrefix, 'lib', 'node_modules'); } catch (e) { return null; } } async findHomebridgePath() { const nodeModules = resolve(process.env.UIX_BASE_PATH, '..'); if (await pathExists(resolve(nodeModules, 'homebridge', 'package.json'))) { this.homebridgeModulePath = resolve(nodeModules, 'homebridge'); } if (!this.homebridgeModulePath && !(process.env.UIX_STRICT_PLUGIN_RESOLUTION === '1' && process.env.UIX_CUSTOM_PLUGIN_PATH)) { const globalModules = await this.getNpmGlobalModulesDirectory(); if (globalModules && await pathExists(resolve(globalModules, 'homebridge'))) { this.homebridgeModulePath = resolve(globalModules, 'homebridge'); } } if (!this.homebridgeModulePath && process.env.UIX_CUSTOM_PLUGIN_PATH) { if (await pathExists(resolve(process.env.UIX_CUSTOM_PLUGIN_PATH, 'homebridge', 'package.json'))) { this.homebridgeModulePath = resolve(process.env.UIX_CUSTOM_PLUGIN_PATH, 'homebridge'); } } if (this.homebridgeModulePath) { try { await this.refreshHomebridgePackage(); return resolve(this.homebridgeModulePath, this.homebridgePackage.bin.homebridge); } catch (e) { console.log(e); } } return null; } async refreshHomebridgePackage() { try { if (await pathExists(this.homebridgeModulePath)) { this.homebridgePackage = await readJson(join(this.homebridgeModulePath, 'package.json')); } else { this.logger(`Homebridge not longer found at ${this.homebridgeModulePath}.`, 'fail'); this.homebridgeModulePath = undefined; this.homebridgeBinary = await this.findHomebridgePath(); this.logger(`Found new Homebridge path: ${this.homebridgeBinary}.`); } } catch (e) { console.log(e); } } nodeVersionCheck() { if (Number.parseInt(process.versions.modules, 10) < 64) { this.logger(`Node.js v10.13.0 or greater is required, current: ${process.version}.`, 'fail'); process.exit(1); } } nvmCheck() { if (process.execPath.includes('nvm') && platform() === 'linux') { this.logger('WARNING: It looks like you are running Node.js via NVM (Node Version Manager).\n' + ' Using hb-service with NVM may not work unless you have configured NVM for the\n' + ' user this service will run as. See https://homebridge.io/w/JUZ2g for instructions on how\n' + ' to remove NVM, then follow the wiki instructions to install Node.js and Homebridge.', 'warn'); } } async printPostInstallInstructions() { const defaultAdapter = await networkInterfaceDefault(); const defaultInterface = (await networkInterfaces()).find((x) => x.iface === defaultAdapter); console.log('\nManage Homebridge by going to one of the following in your browser:\n'); console.log(`* http://localhost:${this.uiPort}`); if (defaultInterface && defaultInterface.ip4) { console.log(`* http://${defaultInterface.ip4}:${this.uiPort}`); } if (defaultInterface && defaultInterface.ip6) { console.log(`* http://[${defaultInterface.ip6}]:${this.uiPort}`); } console.log(''); this.logger('Homebridge setup complete.', 'succeed'); } async portCheck() { const inUse = await tcpCheck(this.uiPort); if (inUse) { this.logger(`Port ${this.uiPort} is already in use by another process on this host.`, 'fail'); this.logger('You can specify another port using the --port flag, e.g.:', 'fail'); this.logger(`hb-service ${this.action} --port 8581`, 'fail'); process.exit(1); } } async storagePathCheck() { if (platform() === 'darwin' && !await pathExists(dirname(this.storagePath))) { this.logger(`Cannot create Homebridge storage directory, base path does not exist: ${dirname(this.storagePath)}.`, 'fail'); process.exit(1); } if (!await pathExists(this.storagePath)) { this.logger(`Creating Homebridge directory: ${this.storagePath}.`); await mkdirp(this.storagePath); await this.chownPath(this.storagePath); } } async configCheck() { let saveRequired = false; let restartRequired = false; if (!await pathExists(process.env.UIX_CONFIG_PATH)) { this.logger(`Creating default config.json: ${process.env.UIX_CONFIG_PATH}.`); await this.createDefaultConfig(); restartRequired = true; } try { const currentConfig = await this.readConfig(); if (!Array.isArray(currentConfig.platforms)) { currentConfig.platforms = []; } let uiConfigBlock = currentConfig.platforms.find((x) => x.platform === 'config'); if (!uiConfigBlock) { this.logger(`Adding missing UI platform block to ${process.env.UIX_CONFIG_PATH}.`, 'info'); uiConfigBlock = await this.createDefaultUiConfig(); currentConfig.platforms.push(uiConfigBlock); saveRequired = true; restartRequired = true; } if (this.action !== 'install' && typeof uiConfigBlock.port !== 'number') { uiConfigBlock.port = await this.getLastKnownUiPort(); this.logger(`Added missing port number to UI config: ${uiConfigBlock.port}.`, 'info'); saveRequired = true; restartRequired = true; } if (this.action === 'install') { if (uiConfigBlock.port !== this.uiPort) { uiConfigBlock.port = this.uiPort; this.logger(`Homebridge UI port in ${process.env.UIX_CONFIG_PATH} changed to: ${this.uiPort}.`, 'warn'); } delete uiConfigBlock.restart; delete uiConfigBlock.sudo; delete uiConfigBlock.log; saveRequired = true; } if (typeof uiConfigBlock.port !== 'number') { uiConfigBlock.port = await this.getLastKnownUiPort(); this.logger(`Added missing port number to UI config: ${uiConfigBlock.port}.`, 'info'); saveRequired = true; restartRequired = true; } if (!currentConfig.bridge) { currentConfig.bridge = await this.generateBridgeConfig(); this.logger('Added missing Homebridge bridge section to the config.json.', 'info'); saveRequired = true; } if (!currentConfig.bridge.port) { currentConfig.bridge.port = await this.generatePort(); this.logger(`Added port to the Homebridge bridge section of the config.json: ${currentConfig.bridge.port}.`, 'info'); saveRequired = true; } if ((uiConfigBlock && currentConfig.bridge.port === uiConfigBlock.port) || currentConfig.bridge.port === 8080) { currentConfig.bridge.port = await this.generatePort(); this.logger(`Bridge port must not be the same as the UI port. Changing bridge port to: ${currentConfig.bridge.port}.`, 'info'); saveRequired = true; } if (currentConfig.plugins && Array.isArray(currentConfig.plugins)) { if (!currentConfig.plugins.includes('homebridge-config-ui-x')) { currentConfig.plugins.push('homebridge-config-ui-x'); this.logger('Added Homebridge UI to the plugins array in the config.json.', 'info'); saveRequired = true; } } if (saveRequired) { await writeJson(process.env.UIX_CONFIG_PATH, currentConfig, { spaces: 4 }); } } catch (e) { const backupFile = resolve(this.storagePath, `config.json.invalid.${Date.now().toString()}`); this.logger(`${process.env.UIX_CONFIG_PATH} does not contain valid JSON.`, 'warn'); this.logger(`Invalid config.json file has been backed up to ${backupFile}.`, 'warn'); await rename(process.env.UIX_CONFIG_PATH, backupFile); await this.createDefaultConfig(); restartRequired = true; } if (restartRequired && this.action === 'run' && await this.isRaspbianImage()) { this.logger('Restarting process after port number update.', 'info'); process.exit(1); } } async createDefaultConfig() { await writeJson(process.env.UIX_CONFIG_PATH, { bridge: await this.generateBridgeConfig(), accessories: [], platforms: [ await this.createDefaultUiConfig(), ], }, { spaces: 4 }); await this.chownPath(process.env.UIX_CONFIG_PATH); } async generateBridgeConfig() { const username = this.generateUsername(); const port = await this.generatePort(); const name = `Homebridge ${username.substring(username.length - 5).replace(RE_COLON, '')}`; const pin = this.generatePin(); const advertiser = await this.isAvahiDaemonRunning() ? 'avahi' : 'bonjour-hap'; return { name, username, port, pin, advertiser, }; } async createDefaultUiConfig() { return { name: 'Config', port: this.action === 'install' ? this.uiPort : await this.getLastKnownUiPort(), platform: 'config', }; } async isRaspbianImage() { return platform() === 'linux' && await pathExists('/etc/hb-ui-port'); } async getLastKnownUiPort() { if (await this.isRaspbianImage()) { const lastPort = Number.parseInt((await readFile('/etc/hb-ui-port', 'utf8')), 10); if (!Number.isNaN(lastPort) && lastPort <= 65535) { return lastPort; } } const envPort = Number.parseInt(process.env.HOMEBRIDGE_CONFIG_UI_PORT, 10); if (!Number.isNaN(envPort) && envPort <= 65535) { return envPort; } return this.uiPort; } generatePin() { let code = `${randomInt(10000000, 100000000)}`; code = code.split(''); code.splice(3, 0, '-'); code.splice(6, 0, '-'); code = code.join(''); return code; } generateUsername() { const hexDigits = '0123456789ABCDEF'; let username = '0E:'; for (let i = 0; i < 5; i += 1) { username += hexDigits.charAt(randomInt(0, 16)); username += hexDigits.charAt(randomInt(0, 16)); if (i !== 4) { username += ':'; } } return username; } async generatePort() { const randomPort = () => randomInt(51000, 52001); let port = randomPort(); while (await tcpCheck(port)) { port = randomPort(); } return port; } avahiDaemonRunning; async isAvahiDaemonRunning() { if (this.avahiDaemonRunning !== undefined) { return this.avahiDaemonRunning; } if (platform() !== 'linux') { this.avahiDaemonRunning = false; return false; } if (!await pathExists('/etc/avahi/avahi-daemon.conf') || !await pathExists('/usr/bin/systemctl')) { this.avahiDaemonRunning = false; return false; } try { if (await pathExists('/usr/lib/systemd/system/avahi.service')) { execSync('systemctl is-active --quiet avahi 2> /dev/null'); this.avahiDaemonRunning = true; return true; } else if (await pathExists('/lib/systemd/system/avahi-daemon.service')) { execSync('systemctl is-active --quiet avahi-daemon 2> /dev/null'); this.avahiDaemonRunning = true; return true; } else { this.avahiDaemonRunning = false; return false; } } catch (e) { this.avahiDaemonRunning = false; return false; } } async chownPath(pathToChown) { if (platform() !== 'win32' && process.getuid() === 0) { const { uid, gid } = await this.installer.getId(); chownSync(pathToChown, uid, gid); } } async checkForStaleHomebridgeProcess() { if (platform() === 'win32') { return; } try { const currentConfig = await this.readConfig(); if (!currentConfig.bridge || !currentConfig.bridge.port) { return; } if (!await tcpCheck(Number.parseInt(currentConfig.bridge.port.toString(), 10))) { return; } const pid = Number.parseInt(this.installer.getPidOfPort(Number.parseInt(currentConfig.bridge.port.toString(), 10)), 10); if (!pid) { return; } this.logger(`Found stale Homebridge process running on port: ${currentConfig.bridge.port}, with PID: ${pid}, killing...`); process.kill(pid, 'SIGKILL'); } catch (e) { } } async tailLogs() { if (!existsSync(this.logPath)) { this.logger(`Log file does not exist at expected location: ${this.logPath}.`, 'fail'); process.exit(1); } const logStats = await stat(this.logPath); const logStartPosition = logStats.size <= 200000 ? 0 : logStats.size - 200000; const logStream = createReadStream(this.logPath, { start: logStartPosition }); logStream.on('data', (buffer) => { process.stdout.write(buffer); }); logStream.on('end', () => { logStream.close(); }); const tail = new Tail(this.logPath, { fromBeginning: false, useWatchFile: true, fsWatchOptions: { interval: 200, }, }); tail.on('line', console.log); } async viewLogs() { this.installer.viewLogs(); if (!existsSync(this.logPath)) { this.logger(`Log file does not exist at expected location: ${this.logPath}.`, 'fail'); process.exit(1); } const logStats = await stat(this.logPath); const logStartPosition = logStats.size <= 200000 ? 0 : logStats.size - 200000; const logStream = createReadStream(this.logPath, { start: logStartPosition }); logStream.on('data', (buffer) => { process.stdout.write(buffer); }); logStream.on('end', () => { logStream.close(); }); const tail = new Tail(this.logPath, { fromBeginning: false, useWatchFile: true, fsWatchOptions: { interval: 200, }, }); tail.on('line', console.log); setTimeout(() => { tail.unwatch(); }, 30000); } get homebridgeStartupOptionsPath() { return resolve(this.storagePath, '.uix-hb-service-homebridge-startup.json'); } async loadHomebridgeStartupOptions() { try { if (await pathExists(this.homebridgeStartupOptionsPath)) { const homebridgeStartupOptions = await readJson(this.homebridgeStartupOptionsPath); if (homebridgeStartupOptions.debugMode && !this.homebridgeOpts.includes('-D')) { this.homebridgeOpts.push('-D'); } if (homebridgeStartupOptions.keepOrphans && !this.homebridgeOpts.includes('-K')) { this.homebridgeOpts.push('-K'); } if (homebridgeStartupOptions.insecureMode === false && this.homebridgeOpts.includes('-I')) { this.homebridgeOpts.splice(this.homebridgeOpts.findIndex(x => x === '-I'), 1); process.env.UIX_INSECURE_MODE = '0'; } Object.assign(this.homebridgeCustomEnv, homebridgeStartupOptions.env); } else if (this.docker) { if (process.env.HOMEBRIDGE_DEBUG === '1' && !this.homebridgeOpts.includes('-D')) { this.homebridgeOpts.push('-D'); } if (process.env.HOMEBRIDGE_INSECURE !== '1' && this.homebridgeOpts.includes('-I')) { this.homebridgeOpts.splice(this.homebridgeOpts.findIndex(x => x === '-I'), 1); process.env.UIX_INSECURE_MODE = '0'; } } } catch (e) { this.logger(`Failed to load startup options as ${e.message}.`); } } fixDockerPermissions() { try { execSync(`chown -R ${this.uid}:${this.gid} "${this.storagePath}"`); } catch (e) { } } async checkForNodejsUpdates(requestedVersion) { const versionList = (await axios.get('https://nodejs.org/dist/index.json')).data; if (!Array.isArray(versionList)) { this.logger('Failed to check for Node.js updates.', 'fail'); return { update: false }; } const currentLts = versionList.filter(x => x.lts)[0]; if (requestedVersion) { const wantedVersion = versionList.find(x => x.version.startsWith(`v${requestedVersion}`)); if (wantedVersion) { if (!gte(wantedVersion.version, '22.12.0')) { this.logger('Refusing to install Node.js version lower than v22.12.0.', 'fail'); return { update: false }; } this.logger(`Installing Node.js ${wantedVersion.version} over ${process.version}...`, 'info'); return this.installer.updateNodejs({ target: wantedVersion.version, rebuild: wantedVersion.modules !== process.versions.modules, }); } else { this.logger(`v${requestedVersion} is not a valid Node.js version.`, 'info'); return { update: false }; } } if (gt(currentLts.version, process.version)) { this.logger(`Updating Node.js from ${process.version} to ${currentLts.version}...`, 'info'); return this.installer.updateNodejs({ target: currentLts.version, rebuild: currentLts.modules !== process.versions.modules, }); } const currentMajor = parse(process.version).major; const latestVersion = versionList.filter(x => parse(x.version).major === currentMajor)[0]; if (gt(latestVersion.version, process.version)) { this.logger(`Updating Node.js from ${process.version} to ${latestVersion.version}...`, 'info'); return this.installer.updateNodejs({ target: latestVersion.version, rebuild: latestVersion.modules !== process.versions.modules, }); } this.logger(`Node.js ${process.version} already up-to-date.`); return { update: false }; } async downloadNodejs(downloadUrl) { const spinner = ora(`Downloading ${downloadUrl}`).start(); try { const tempDir = await mkdtemp(join(tmpdir(), 'node')); const tempFilePath = join(tempDir, 'node.tar.gz'); const tempFile = createWriteStream(tempFilePath); await axios.get(downloadUrl, { responseType: 'stream' }) .then((response) => { return new Promise((res, rej) => { response.data.pipe(tempFile).on('finish', () => { return res(tempFile); }).on('error', (err) => { return rej(err); }); }); }); spinner.succeed('Download complete.'); return tempFilePath; } catch (e) { spinner.fail(e.message); process.exit(1); } } async extractNodejs(targetVersion, extractConfig) { const spinner = ora(`Installing Node.js ${targetVersion}`).start(); try { await extract(extractConfig); spinner.succeed(`Installed Node.js ${targetVersion}`); } catch (e) { spinner.fail(e.message); process.exit(1); } } async removeNpmPackage(npmInstallPath) { if (!await pathExists(npmInstallPath)) { return; } const spinner = ora(`Cleaning up npm at ${npmInstallPath}...`).start(); try { await remove(npmInstallPath); spinner.succeed(`Cleaned up npm at ${npmInstallPath}`); } catch (e) { spinner.fail(e.message); } } async checkStatus() { this.logger(`Testing hb-service is running on port ${this.uiPort}...`); try { const res = await axios.get(`http://localhost:${this.uiPort}/api`); if (res.data === 'Hello World!') { this.logger('Homebridge UI running.', 'succeed'); } else { this.logger('Unexpected response.', 'fail'); process.exit(1); } } catch (e) { this.logger('Homebridge UI not running.', 'fail'); process.exit(1); } } parseNpmPackageString(input) { const m = RE_SCOPED.exec(input) || RE_NON_SCOPED.exec(input); if (!m) { this.logger('Invalid plugin name.', 'fail'); process.exit(1); } return { name: m[1] || '', version: m[2] || 'latest', path: m[3] || '', }; } async npmPluginManagement(args) { if (!this.enableHbServicePluginManagement) { this.logger('Plugin management is not supported on your platform using hb-service.', 'fail'); process.exit(1); } if (args.length === 1) { this.logger('Plugin name required.', 'fail'); process.exit(1); } const action = args[0]; const target = this.parseNpmPackageString(args.at(-1)); if (!target.name) { this.logger('Invalid plugin name.', 'fail'); process.exit(1); } if (!RE_PLUGIN_NAME.test(target.name)) { this.logger('Invalid plugin name.', 'fail'); process.exit(1); } const RE_NPM_VERSION_OR_TAG = /^[\w.\-^~>=<*|+]+$/; if (!RE_NPM_VERSION_OR_TAG.test(target.version)) { this.logger(`Invalid plugin version "${target.version}".`, 'fail'); process.exit(1); } const cwd = dirname(process.env.UIX_CUSTOM_PLUGIN_PATH); if (!await pathExists(cwd)) { this.logger(`Path does not exist: ${cwd}.`, 'fail'); } const npmArgs = ['--prefix', cwd, action, action === 'add' ? `${target.name}@${target.version}` : target.name]; this.logger(`CMD: npm ${npmArgs.join(' ')}`, 'info'); try { execFileSync('npm', npmArgs, { cwd, stdio: 'inherit', }); this.logger(`Installed ${target.name}@${target.version}.`, 'succeed'); } catch (e) { this.logger(`Plugin installation failed as ${e.message}.`, 'fail'); } } } function bootstrap() { return new HomebridgeServiceHelper(); } bootstrap(); //# sourceMappingURL=hb-service.js.map