@homebridge-plugins/homebridge-plugin-update-check
Version:
A Homebridge plugin for checking for updates to Homebridge and plugins
378 lines • 15.5 kB
JavaScript
/* eslint-disable style/brace-style */
/* eslint-disable style/operator-linebreak */
import { spawn } from 'node:child_process';
import { readFileSync } from 'node:fs';
import https from 'node:https';
import path from 'node:path';
import process from 'node:process';
import axios from 'axios';
import axiosRetry from 'axios-retry';
import CacheableLookup from 'cacheable-lookup';
import jwt from 'jsonwebtoken';
class ApiPluginEndpoints {
static getHomebridgeVersion = '/api/status/homebridge-version';
static getPluginList = '/api/plugins';
static getIgnoredPluginList = '/api/config-editor/ui/plugins/hide-updates-for';
}
export class UiApi {
log;
secrets;
baseUrl;
httpsAgent;
token;
dockerUrl;
cacheable;
hbStoragePath;
constructor(hbStoragePath, log) {
this.log = log;
this.hbStoragePath = hbStoragePath;
axiosRetry(axios, {
retries: 3,
retryDelay: (...arg) => axiosRetry.exponentialDelay(...arg, 1000),
onRetry: (retryCount, error, requestConfig) => {
this.log.debug(`${requestConfig.url} - retry count: ${retryCount}, error: ${error.message}`);
},
});
const MAX_TTL_SEC = 86400; // limit TTL to 24 hours
this.cacheable = new CacheableLookup({ maxTtl: MAX_TTL_SEC });
const configPath = path.resolve(hbStoragePath, 'config.json');
const hbConfig = JSON.parse(readFileSync(configPath, 'utf8'));
const config = hbConfig.platforms.find((config) => config.platform === 'config' || config.platform === 'homebridge-config-ui-x.config');
if (config) {
const secretPath = path.resolve(hbStoragePath, '.uix-secrets');
this.secrets = JSON.parse(readFileSync(secretPath, 'utf8'));
const ssl = !!config.ssl?.key || !!config.ssl?.pfx;
const protocol = ssl ? 'https://' : 'http://';
const host = config.host ?? 'localhost';
const port = config.port ?? 8581;
this.baseUrl = `${protocol + host}:${port.toString()}`;
const dockerProtocol = 'https://';
const dockerHost = 'hub.docker.com';
this.dockerUrl = `${dockerProtocol + dockerHost}`;
if (ssl) {
this.httpsAgent = new https.Agent({ rejectUnauthorized: false }); // don't reject self-signed certs
}
}
}
isConfigured() {
return this.secrets !== undefined;
}
async getHomebridge() {
if (this.isConfigured()) {
const result = await this.makeCall(ApiPluginEndpoints.getHomebridgeVersion);
if (result.length > 0) {
return result[0];
}
}
return {
name: '',
installedVersion: '',
latestVersion: '',
updateAvailable: false,
};
}
async getPlugins() {
if (this.isConfigured()) {
return await this.makeCall(ApiPluginEndpoints.getPluginList);
}
else {
return [];
}
}
async getIgnoredPlugins() {
if (this.isConfigured()) {
try {
const result = await this.makeCall(ApiPluginEndpoints.getIgnoredPluginList);
// Validate the response format
if (!Array.isArray(result)) {
this.log.warn(`Unexpected response format from ignored plugins API: ${typeof result}, expected array`);
return [];
}
const ignoredPlugins = result;
this.log.debug(`API returned ${ignoredPlugins.length} ignored plugins: ${ignoredPlugins.join(', ')}`);
return ignoredPlugins;
}
catch (error) {
// Check if it's a 404 error (API endpoint doesn't exist)
if (error?.response?.status === 404) {
this.log.warn('Ignored plugins API endpoint not found - requires homebridge-config-ui-x v5.6.2-beta.2 or later');
}
else {
this.log.warn(`Failed to retrieve ignored plugins list from /config-editor/ui/plugins/hide-updates-for: ${error}`);
}
return [];
}
}
else {
this.log.debug('homebridge-config-ui-x not configured, cannot retrieve ignored plugins list');
return [];
}
}
async getDocker() {
const currentDockerVersion = process.env.DOCKER_HOMEBRIDGE_VERSION;
let dockerInfo = {
name: '',
installedVersion: '',
latestVersion: '',
updateAvailable: false,
};
if (this.isConfigured() && currentDockerVersion !== undefined) {
const json = await this.makeDockerCall('/v2/repositories/homebridge/homebridge/tags/?page_size=30&page=1&ordering=last_updated');
const images = json.results;
// If the currently installed version is not returned in the list of Docker versions (too old or deleted),
// then use a last-updated date Jan 1, 1970
const installedImage = images.filter(image => image.name === currentDockerVersion)[0] ?? undefined;
const installedImageDate = Date.parse(installedImage ? installedImage.last_updated : '1970-01-01T00:00:00.000000Z');
// Filter for version names YYYY-MM-DD (no alphas or betas)
const regex = /^\d{4}-\d{2}-\d{2}$/gm;
const availableImages = images.filter(image => image.name.match(regex) &&
(Date.parse(image.last_updated) > installedImageDate));
if (availableImages.length > 0) {
dockerInfo = {
name: 'Docker image',
installedVersion: currentDockerVersion,
latestVersion: availableImages[0].name,
updateAvailable: true,
};
}
}
return dockerInfo;
}
async updateHomebridge(targetVersion) {
this.log.info(`Attempting to update Homebridge${targetVersion ? ` to ${targetVersion}` : ' to latest version'}`);
try {
const args = ['install', '-g', `homebridge${targetVersion ? `@${targetVersion}` : '@latest'}`];
const result = await this.runNpmCommand(args);
this.log.info(`Homebridge update command completed successfully (${result})`);
return true;
}
catch (error) {
this.log.error(`Failed to update Homebridge: ${error}`);
return false;
}
}
async updatePlugin(pluginName, targetVersion) {
this.log.info(`Attempting to update plugin ${pluginName}${targetVersion ? ` to ${targetVersion}` : ' to latest version'}`);
try {
const args = ['install', '-g', `${pluginName}${targetVersion ? `@${targetVersion}` : '@latest'}`];
const result = await this.runNpmCommand(args);
this.log.info(`Plugin ${pluginName} update command completed successfully (${result})`);
return true;
}
catch (error) {
this.log.error(`Failed to update plugin ${pluginName}: ${error}`);
return false;
}
}
async runNpmCommand(args) {
return new Promise((resolve, reject) => {
try {
const npm = spawn('npm', args, {
env: process.env,
});
let stdout = '';
let stderr = '';
// eslint-disable-next-line node/prefer-global/buffer
npm.stdout.on('data', (chunk) => {
stdout += chunk.toString();
});
// eslint-disable-next-line node/prefer-global/buffer
npm.stderr.on('data', (chunk) => {
stderr += chunk.toString();
});
npm.on('close', (code) => {
if (code === 0) {
resolve(stdout);
}
else {
reject(new Error(`npm command failed with code ${code}: ${stderr}`));
}
});
npm.on('error', (error) => {
reject(error);
});
}
catch (ex) {
reject(ex);
}
});
}
async createBackup() {
this.log.info('Creating backup before performing updates');
try {
if (this.isConfigured()) {
// Try different possible backup API endpoints
const backupEndpoints = [
'/api/backup/create',
'/api/backups/create',
'/api/backup',
'/api/server/backup',
];
for (const endpoint of backupEndpoints) {
try {
await this.makeBackupCall(endpoint);
this.log.info(`Backup created successfully via UI API (${endpoint})`);
return true;
}
catch (error) {
this.log.debug(`Backup endpoint ${endpoint} failed: ${error}`);
// Continue to next endpoint
}
}
this.log.warn('All backup endpoints failed - backup creation unavailable');
return false;
}
else {
this.log.warn('UI API not configured - backup creation skipped');
return false;
}
}
catch (error) {
this.log.warn(`Failed to create backup: ${error}`);
this.log.warn('Continuing with updates despite backup failure - ensure you have manual backups in place');
return false;
}
}
async restartHomebridge() {
this.log.info('Attempting to restart Homebridge to apply updates');
try {
if (this.isConfigured()) {
// Try different restart endpoints with fallback strategy
const restartEndpoints = [
'/api/server/restart',
'/api/platform-tools/docker/restart-container',
'/api/platform-tools/linux/restart-host',
];
for (const endpoint of restartEndpoints) {
try {
await this.makeRestartCall(endpoint);
this.log.info(`Homebridge restart initiated via UI API (${endpoint})`);
return true;
}
catch (error) {
this.log.debug(`Restart endpoint ${endpoint} failed: ${error}`);
// Continue to next endpoint
}
}
this.log.warn('All restart endpoints failed - UI API restart unavailable');
// Fallback: exit process to trigger restart by process manager
this.log.info('Falling back to process exit for restart');
setTimeout(() => {
process.exit(0);
}, 5000); // 5 second delay to allow log message to be written
return true;
}
else {
// Fallback: exit process to trigger restart by process manager
this.log.info('UI API not available, triggering process exit for restart');
setTimeout(() => {
process.exit(0);
}, 5000); // 5 second delay to allow log message to be written
return true;
}
}
catch (error) {
this.log.error(`Failed to restart Homebridge: ${error}`);
return false;
}
}
async makeRestartCall(apiPath) {
return axios
.put(this.baseUrl + apiPath, {}, {
headers: {
Authorization: `Bearer ${this.getToken()}`,
},
httpsAgent: this.httpsAgent,
})
.then((response) => {
return response.data;
})
.catch((error) => {
// At this point, we should have exhausted the retries
this.log.error(`${error.code} error connecting to ${this.baseUrl + apiPath}`);
return null;
});
}
async makeBackupCall(apiPath) {
return axios
.post(this.baseUrl + apiPath, {}, {
headers: {
Authorization: `Bearer ${this.getToken()}`,
},
httpsAgent: this.httpsAgent,
timeout: 60000, // 60 second timeout for backup operations
})
.then((response) => {
return response.data;
})
.catch((error) => {
// At this point, we should have exhausted the retries
this.log.error(`${error.code} error connecting to ${this.baseUrl + apiPath}`);
return null;
});
}
async makeDockerCall(apiPath) {
return axios
.get(this.dockerUrl + apiPath, {
httpsAgent: this.httpsAgent,
lookup: this.cacheable.lookup,
timeout: 60000,
})
.then((response) => {
return response.data;
})
.catch((error) => {
// At this point, we should have exhausted the retries
if (error.code === 'ETIMEOUT') {
this.log.error(`Timeout error connecting to ${this.dockerUrl}`);
}
else {
this.log.error(`${error.code} error connecting to ${this.dockerUrl}`);
}
return '{ "count": 0, "results": [] }';
});
}
async makeCall(apiPath) {
return axios
.get(this.baseUrl + apiPath, {
headers: {
Authorization: `Bearer ${this.getToken()}`,
},
httpsAgent: this.httpsAgent,
lookup: this.cacheable.lookup,
})
.then((response) => {
this.log.debug(`${this.baseUrl + apiPath}: ${JSON.stringify(response.data)}`);
if (!Array.isArray(response.data)) {
return [response.data];
}
return response.data;
})
.catch((error) => {
// At this point, we should have exhausted the retries
this.log.error(`${error.code} error connecting to ${this.baseUrl + apiPath}`);
if (error.code === 'ERR_BAD_REQUEST' && error.status === 404 && apiPath === ApiPluginEndpoints.getIgnoredPluginList) {
this.log.debug(`Error: ${JSON.stringify(error, undefined, 2)}`);
this.log.warn(`This feature requires a newer version of Homebridge UI. Please update to the latest version.`);
}
return [];
});
}
getToken() {
if (this.token) {
return this.token;
}
const user = {
username: '@homebridge-plugins/homebridge-plugin-update-check',
name: '@homebridge-plugins/homebridge-plugin-update-check',
admin: true,
instanceId: 'xxxxxxx',
};
this.token = jwt.sign(user, this.secrets.secretKey, { expiresIn: '1m' });
setTimeout(() => {
this.token = undefined;
}, 30 * 1000);
return this.token;
}
}
//# sourceMappingURL=ui-api.js.map