mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-21 18:53:37 +00:00
f97749a5f1
Only show RC updates when the latest RC version is actually newer than the current version, not just when they are different. This prevents showing 'update available' when current RC (e.g., rc3) is newer than the latest GitHub RC (e.g., rc1). 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
623 lines
25 KiB
JavaScript
623 lines
25 KiB
JavaScript
const axios = require('axios');
|
|
const semver = require('semver');
|
|
const fs = require('fs').promises;
|
|
const path = require('path');
|
|
const { exec, spawn } = require('child_process');
|
|
const { promisify } = require('util');
|
|
const { getUpdateChannelPreference } = require('./configLoader');
|
|
const { getCurrentVersion } = require('./versionUtils');
|
|
const execAsync = promisify(exec);
|
|
|
|
class UpdateManager {
|
|
constructor() {
|
|
this.githubRepo = 'rcourtman/Pulse';
|
|
this.currentVersion = require('../package.json').version;
|
|
this.updateInProgress = false;
|
|
}
|
|
|
|
/**
|
|
* Check if current version is a release candidate
|
|
*/
|
|
isReleaseCandidate(version) {
|
|
// Only use currentVersion as default if no argument is passed at all
|
|
const versionToCheck = (arguments.length === 0) ? this.currentVersion : version;
|
|
|
|
if (!versionToCheck || typeof versionToCheck !== 'string') {
|
|
return false;
|
|
}
|
|
const versionLower = versionToCheck.toLowerCase();
|
|
return versionLower.includes('-rc') || versionLower.includes('-alpha') || versionLower.includes('-beta');
|
|
}
|
|
|
|
/**
|
|
* Validate download URL for security
|
|
*/
|
|
isValidDownloadUrl(downloadUrl) {
|
|
if (!downloadUrl || typeof downloadUrl !== 'string') {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const url = new URL(downloadUrl);
|
|
|
|
// Allow test mode URLs
|
|
if (process.env.UPDATE_TEST_MODE === 'true' &&
|
|
url.hostname === 'localhost' &&
|
|
url.pathname.includes('/api/test/mock-update.tar.gz')) {
|
|
return true;
|
|
}
|
|
|
|
// Only allow HTTPS GitHub release asset URLs
|
|
return url.protocol === 'https:' &&
|
|
url.hostname === 'github.com' &&
|
|
url.pathname.includes('/releases/download/') &&
|
|
url.pathname.includes(`/${this.githubRepo}/`);
|
|
} catch (error) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check for available updates
|
|
* @param {string} channelOverride - Optional channel override ('stable' or 'rc')
|
|
*/
|
|
async checkForUpdates(channelOverride = null) {
|
|
// Get the current version using centralized logic (outside try block for error handling)
|
|
const dynamicCurrentVersion = getCurrentVersion();
|
|
|
|
// Use override channel if provided and valid, otherwise use config
|
|
const configChannel = getUpdateChannelPreference();
|
|
const updateChannel = (channelOverride && ['stable', 'rc'].includes(channelOverride))
|
|
? channelOverride
|
|
: configChannel;
|
|
let channelDescription = '';
|
|
|
|
try {
|
|
console.log('[UpdateManager] Checking for updates...');
|
|
|
|
if (channelOverride && channelOverride !== configChannel) {
|
|
console.log(`[UpdateManager] Using channel override: ${channelOverride} (config: ${configChannel})`);
|
|
}
|
|
let response;
|
|
|
|
if (updateChannel === 'stable') {
|
|
// Stable channel: only check latest stable release
|
|
channelDescription = 'stable releases only';
|
|
console.log('[UpdateManager] Checking for stable releases...');
|
|
response = await axios.get(
|
|
`https://api.github.com/repos/${this.githubRepo}/releases/latest`,
|
|
{
|
|
headers: {
|
|
'Accept': 'application/vnd.github.v3+json',
|
|
'User-Agent': 'Pulse-Update-Checker'
|
|
},
|
|
timeout: 10000
|
|
}
|
|
);
|
|
} else {
|
|
// RC channel: check all releases for RC versions
|
|
channelDescription = 'RC releases only';
|
|
console.log('[UpdateManager] Checking for RC releases...');
|
|
response = await axios.get(
|
|
`https://api.github.com/repos/${this.githubRepo}/releases?per_page=10`,
|
|
{
|
|
headers: {
|
|
'Accept': 'application/vnd.github.v3+json',
|
|
'User-Agent': 'Pulse-Update-Checker'
|
|
},
|
|
timeout: 10000
|
|
}
|
|
);
|
|
|
|
// Find the latest RC release that's newer than current
|
|
let latestRelease = null;
|
|
const releases = response.data;
|
|
|
|
for (const release of releases) {
|
|
const releaseVersion = release.tag_name.replace('v', '');
|
|
const releaseIsRC = this.isReleaseCandidate(releaseVersion);
|
|
|
|
if (releaseIsRC) {
|
|
// For RC channel, show the latest RC regardless of current version
|
|
// This allows showing RC versions even if current is stable
|
|
if (!latestRelease || semver.gt(releaseVersion, latestRelease.tag_name.replace('v', ''))) {
|
|
latestRelease = release;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!latestRelease) {
|
|
// No newer RC version found
|
|
const updateInfo = {
|
|
currentVersion: dynamicCurrentVersion,
|
|
latestVersion: dynamicCurrentVersion,
|
|
updateAvailable: false,
|
|
isDocker: this.isDockerEnvironment(),
|
|
releaseNotes: 'No newer RC version available',
|
|
releaseUrl: null,
|
|
publishedAt: null,
|
|
assets: [],
|
|
updateChannel: channelDescription
|
|
};
|
|
console.log(`[UpdateManager] No RC updates available: ${dynamicCurrentVersion}`);
|
|
return updateInfo;
|
|
}
|
|
|
|
response.data = latestRelease;
|
|
}
|
|
|
|
const latestVersion = response.data.tag_name.replace('v', '');
|
|
|
|
// For stable channel, also consider "downgrade" from RC as an update
|
|
const isCurrentRC = this.isReleaseCandidate(dynamicCurrentVersion);
|
|
const isStableChannel = updateChannel === 'stable';
|
|
const isDifferentVersion = latestVersion !== dynamicCurrentVersion;
|
|
|
|
let updateAvailable;
|
|
if (isStableChannel && isCurrentRC && isDifferentVersion) {
|
|
// Offer stable version even if it's older than current RC
|
|
updateAvailable = true;
|
|
} else if (updateChannel === 'rc') {
|
|
// For RC channel, only show update if latest version is actually newer
|
|
updateAvailable = semver.gt(latestVersion, dynamicCurrentVersion);
|
|
} else {
|
|
// Normal case: only newer versions
|
|
updateAvailable = semver.gt(latestVersion, dynamicCurrentVersion);
|
|
}
|
|
|
|
const updateInfo = {
|
|
currentVersion: dynamicCurrentVersion,
|
|
latestVersion,
|
|
updateAvailable,
|
|
isDocker: this.isDockerEnvironment(),
|
|
releaseNotes: response.data.body || 'No release notes available',
|
|
releaseUrl: response.data.html_url,
|
|
publishedAt: response.data.published_at,
|
|
updateChannel: channelDescription,
|
|
assets: response.data.assets.map(asset => ({
|
|
name: asset.name,
|
|
size: asset.size,
|
|
downloadUrl: asset.browser_download_url
|
|
}))
|
|
};
|
|
|
|
console.log(`[UpdateManager] Current version: ${dynamicCurrentVersion}, Latest version: ${latestVersion}, Channel: ${channelDescription}, Docker: ${updateInfo.isDocker}`);
|
|
return updateInfo;
|
|
|
|
} catch (error) {
|
|
console.error('[UpdateManager] Error checking for updates:', error.message);
|
|
|
|
// Handle different types of errors gracefully
|
|
if (error.response?.status === 403) {
|
|
// GitHub API rate limit exceeded
|
|
console.warn('[UpdateManager] GitHub API rate limit exceeded, returning current version info');
|
|
return {
|
|
currentVersion: dynamicCurrentVersion,
|
|
latestVersion: dynamicCurrentVersion,
|
|
updateAvailable: false,
|
|
isDocker: this.isDockerEnvironment(),
|
|
releaseNotes: 'Unable to check for updates: GitHub API rate limit exceeded. Please try again later.',
|
|
releaseUrl: null,
|
|
publishedAt: null,
|
|
assets: [],
|
|
updateChannel: channelDescription || 'unknown',
|
|
rateLimited: true
|
|
};
|
|
} else if (error.response?.status === 404) {
|
|
// Repository or release not found
|
|
console.warn('[UpdateManager] Repository or release not found');
|
|
return {
|
|
currentVersion: dynamicCurrentVersion,
|
|
latestVersion: dynamicCurrentVersion,
|
|
updateAvailable: false,
|
|
isDocker: this.isDockerEnvironment(),
|
|
releaseNotes: 'Unable to check for updates: Repository or release not found.',
|
|
releaseUrl: null,
|
|
publishedAt: null,
|
|
assets: [],
|
|
updateChannel: channelDescription || 'unknown'
|
|
};
|
|
} else if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT') {
|
|
// Network connectivity issues
|
|
console.warn('[UpdateManager] Network connectivity issues');
|
|
return {
|
|
currentVersion: dynamicCurrentVersion,
|
|
latestVersion: dynamicCurrentVersion,
|
|
updateAvailable: false,
|
|
isDocker: this.isDockerEnvironment(),
|
|
releaseNotes: 'Unable to check for updates: Network connectivity issues. Please check your internet connection.',
|
|
releaseUrl: null,
|
|
publishedAt: null,
|
|
assets: [],
|
|
updateChannel: channelDescription || 'unknown'
|
|
};
|
|
}
|
|
|
|
// For other errors, still throw but with more context
|
|
throw new Error(`Failed to check for updates: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Download update package
|
|
*/
|
|
async downloadUpdate(downloadUrl, progressCallback) {
|
|
try {
|
|
// Validate download URL for security
|
|
if (!this.isValidDownloadUrl(downloadUrl)) {
|
|
throw new Error('Invalid download URL. Only GitHub release assets are allowed.');
|
|
}
|
|
|
|
console.log('[UpdateManager] Downloading update from:', downloadUrl);
|
|
|
|
const tempDir = path.join(__dirname, '..', 'temp');
|
|
await fs.mkdir(tempDir, { recursive: true });
|
|
|
|
const tempFile = path.join(tempDir, 'update.tar.gz');
|
|
|
|
// In test mode, create a mock tarball directly instead of downloading
|
|
if (process.env.UPDATE_TEST_MODE === 'true' && downloadUrl.includes('/api/test/mock-update.tar.gz')) {
|
|
console.log('[UpdateManager] Test mode: Creating mock update package...');
|
|
|
|
const tar = require('tar');
|
|
await tar.create({
|
|
gzip: true,
|
|
file: tempFile,
|
|
cwd: path.join(__dirname, '..'),
|
|
filter: (path) => {
|
|
return !path.includes('node_modules') &&
|
|
!path.includes('.git') &&
|
|
!path.includes('temp') &&
|
|
!path.includes('data/backups');
|
|
}
|
|
}, ['.']);
|
|
|
|
// Simulate download progress
|
|
if (progressCallback) {
|
|
for (let i = 0; i <= 100; i += 10) {
|
|
progressCallback({ phase: 'download', progress: i });
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
}
|
|
}
|
|
|
|
console.log('[UpdateManager] Test mode: Mock package created successfully');
|
|
return tempFile;
|
|
}
|
|
|
|
// Normal download for real updates
|
|
const response = await axios({
|
|
method: 'get',
|
|
url: downloadUrl,
|
|
responseType: 'stream',
|
|
timeout: 300000 // 5 minutes
|
|
});
|
|
|
|
const totalSize = parseInt(response.headers['content-length'], 10);
|
|
let downloadedSize = 0;
|
|
|
|
const writer = require('fs').createWriteStream(tempFile);
|
|
|
|
response.data.on('data', (chunk) => {
|
|
downloadedSize += chunk.length;
|
|
if (progressCallback) {
|
|
const progress = Math.round((downloadedSize / totalSize) * 100);
|
|
progressCallback({ phase: 'download', progress });
|
|
}
|
|
});
|
|
|
|
response.data.pipe(writer);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
writer.on('finish', () => resolve(tempFile));
|
|
writer.on('error', (error) => {
|
|
console.error('[UpdateManager] Writer error:', error);
|
|
reject(error);
|
|
});
|
|
response.data.on('error', (error) => {
|
|
console.error('[UpdateManager] Response stream error:', error);
|
|
writer.destroy();
|
|
reject(error);
|
|
});
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('[UpdateManager] Error downloading update:', error);
|
|
console.error('[UpdateManager] Error details:', {
|
|
message: error.message,
|
|
code: error.code,
|
|
response: error.response?.status,
|
|
responseData: error.response?.data
|
|
});
|
|
throw new Error(`Failed to download update: ${error.message || error.toString()}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if running in Docker
|
|
*/
|
|
isDockerEnvironment() {
|
|
return process.env.DOCKER_DEPLOYMENT === 'true' ||
|
|
require('fs').existsSync('/.dockerenv') ||
|
|
(process.env.container === 'docker');
|
|
}
|
|
|
|
/**
|
|
* Recursively copy directory
|
|
*/
|
|
async copyDirectory(src, dest) {
|
|
await fs.mkdir(dest, { recursive: true });
|
|
const entries = await fs.readdir(src, { withFileTypes: true });
|
|
|
|
for (const entry of entries) {
|
|
const srcPath = path.join(src, entry.name);
|
|
const destPath = path.join(dest, entry.name);
|
|
|
|
if (entry.isDirectory()) {
|
|
await this.copyDirectory(srcPath, destPath);
|
|
} else {
|
|
await fs.copyFile(srcPath, destPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Apply update without requiring sudo
|
|
*/
|
|
async applyUpdate(updateFile, progressCallback, downloadUrl = null) {
|
|
if (this.updateInProgress) {
|
|
throw new Error('Update already in progress');
|
|
}
|
|
|
|
// Check if running in Docker
|
|
if (this.isDockerEnvironment()) {
|
|
throw new Error(
|
|
'Automatic updates are not supported in Docker deployments. ' +
|
|
'Please update your Docker image by pulling the latest version:\n' +
|
|
'docker pull rcourtman/pulse:latest\n' +
|
|
'or update your docker-compose.yml to use the new version tag.'
|
|
);
|
|
}
|
|
|
|
this.updateInProgress = true;
|
|
|
|
try {
|
|
console.log('[UpdateManager] Starting update process...');
|
|
|
|
if (progressCallback) {
|
|
progressCallback({ phase: 'preparing', progress: 5 });
|
|
}
|
|
|
|
// Extract version from the download URL
|
|
let targetVersion = 'latest';
|
|
if (downloadUrl && typeof downloadUrl === 'string') {
|
|
const urlMatch = downloadUrl.match(/\/releases\/download\/(v[\d\.\-\w]+)\//);
|
|
if (urlMatch) {
|
|
targetVersion = urlMatch[1];
|
|
console.log(`[UpdateManager] Target version: ${targetVersion}`);
|
|
}
|
|
}
|
|
|
|
// Validate version parameter
|
|
if (targetVersion !== 'latest' && !/^v[\d\.\-\w]+$/.test(targetVersion)) {
|
|
throw new Error(`Invalid version format: ${targetVersion}. Expected format like v3.21.0`);
|
|
}
|
|
|
|
// Step 1: Create backup
|
|
if (progressCallback) {
|
|
progressCallback({ phase: 'backup', progress: 15 });
|
|
}
|
|
await this.createBackup();
|
|
|
|
// Step 2: Extract update
|
|
if (progressCallback) {
|
|
progressCallback({ phase: 'extract', progress: 40 });
|
|
}
|
|
await this.extractUpdate(updateFile);
|
|
|
|
// Step 3: Install dependencies
|
|
if (progressCallback) {
|
|
progressCallback({ phase: 'apply', progress: 70 });
|
|
}
|
|
await this.installDependencies();
|
|
|
|
// Step 4: Signal completion and schedule restart
|
|
if (progressCallback) {
|
|
progressCallback({ phase: 'restarting', progress: 100 });
|
|
}
|
|
|
|
console.log('[UpdateManager] Update completed successfully. Preparing for restart...');
|
|
|
|
// Cleanup temp file before restart
|
|
try {
|
|
await fs.unlink(updateFile);
|
|
console.log('[UpdateManager] Cleaned up temporary update file');
|
|
} catch (cleanupError) {
|
|
console.warn('[UpdateManager] Could not cleanup temp file:', cleanupError.message);
|
|
}
|
|
|
|
// Return success immediately, then restart after a delay
|
|
const result = {
|
|
success: true,
|
|
message: 'Update applied successfully. Service will restart shortly...',
|
|
targetVersion: targetVersion
|
|
};
|
|
|
|
// Schedule service restart after allowing time for response to be sent
|
|
setTimeout(async () => {
|
|
try {
|
|
this.updateInProgress = false;
|
|
await this.restartService();
|
|
} catch (error) {
|
|
console.error('[UpdateManager] Failed to restart service:', error.message);
|
|
this.updateInProgress = false;
|
|
}
|
|
}, 2000); // Give time for WebSocket message to be sent
|
|
|
|
return result;
|
|
|
|
} catch (error) {
|
|
console.error('[UpdateManager] Error applying update:', error.message);
|
|
|
|
// Cleanup temp file on failure
|
|
try {
|
|
await fs.unlink(updateFile);
|
|
console.log('[UpdateManager] Cleaned up temporary update file after failure');
|
|
} catch (cleanupError) {
|
|
console.warn('[UpdateManager] Could not cleanup temp file after failure:', cleanupError.message);
|
|
}
|
|
|
|
this.updateInProgress = false;
|
|
throw new Error(`Failed to apply update: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create backup of current installation
|
|
*/
|
|
async createBackup() {
|
|
console.log('[UpdateManager] Creating backup...');
|
|
|
|
const backupDir = path.join(__dirname, '..', 'data', 'backups');
|
|
await fs.mkdir(backupDir, { recursive: true });
|
|
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
|
|
const backupPath = path.join(backupDir, `pulse-backup-${timestamp}.tar.gz`);
|
|
|
|
const tar = require('tar');
|
|
await tar.create({
|
|
gzip: true,
|
|
file: backupPath,
|
|
cwd: path.join(__dirname, '..'),
|
|
filter: (path) => {
|
|
// Exclude node_modules, temp files, and backups from backup
|
|
return !path.includes('node_modules') &&
|
|
!path.includes('.git') &&
|
|
!path.includes('temp') &&
|
|
!path.includes('data/backups');
|
|
}
|
|
}, ['.']);
|
|
|
|
console.log(`[UpdateManager] Backup created: ${backupPath}`);
|
|
}
|
|
|
|
/**
|
|
* Extract update files
|
|
*/
|
|
async extractUpdate(updateFile) {
|
|
console.log('[UpdateManager] Extracting update files...');
|
|
|
|
const pulseDir = path.join(__dirname, '..');
|
|
const tar = require('tar');
|
|
|
|
await tar.extract({
|
|
file: updateFile,
|
|
cwd: pulseDir,
|
|
strip: 1 // Remove the top-level directory from the tarball
|
|
});
|
|
|
|
console.log('[UpdateManager] Update files extracted successfully');
|
|
}
|
|
|
|
/**
|
|
* Install npm dependencies if needed
|
|
*/
|
|
async installDependencies() {
|
|
console.log('[UpdateManager] Checking dependencies...');
|
|
|
|
const pulseDir = path.join(__dirname, '..');
|
|
const nodeModulesPath = path.join(pulseDir, 'node_modules');
|
|
|
|
try {
|
|
// Check if node_modules exists and has content
|
|
const nodeModulesExists = await fs.access(nodeModulesPath).then(() => true).catch(() => false);
|
|
|
|
if (nodeModulesExists) {
|
|
const moduleFiles = await fs.readdir(nodeModulesPath);
|
|
if (moduleFiles.length > 0) {
|
|
console.log('[UpdateManager] Dependencies already bundled in update package');
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Only run npm ci if node_modules is missing or empty
|
|
console.log('[UpdateManager] Installing missing dependencies...');
|
|
await execAsync('npm ci --production', {
|
|
cwd: pulseDir,
|
|
timeout: 300000 // 5 minutes
|
|
});
|
|
console.log('[UpdateManager] Dependencies installed successfully');
|
|
} catch (error) {
|
|
console.warn('[UpdateManager] Failed to install dependencies:', error.message);
|
|
// Don't fail the update for dependency issues
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Restart the pulse service using multiple strategies
|
|
*/
|
|
async restartService() {
|
|
console.log('[UpdateManager] Restarting pulse service...');
|
|
|
|
// Strategy 1: Try pkexec systemctl (most reliable with polkit)
|
|
try {
|
|
console.log('[UpdateManager] Attempting restart via pkexec...');
|
|
await execAsync('pkexec systemctl restart pulse.service', { timeout: 10000 });
|
|
console.log('[UpdateManager] Service restarted successfully via pkexec');
|
|
return;
|
|
} catch (error) {
|
|
console.warn('[UpdateManager] pkexec restart failed:', error.message);
|
|
}
|
|
|
|
// Strategy 2: Try direct systemctl (if polkit rules work)
|
|
try {
|
|
console.log('[UpdateManager] Attempting restart via systemctl...');
|
|
await execAsync('systemctl restart pulse.service', { timeout: 10000 });
|
|
console.log('[UpdateManager] Service restarted successfully via systemctl');
|
|
return;
|
|
} catch (error) {
|
|
console.warn('[UpdateManager] systemctl restart failed:', error.message);
|
|
}
|
|
|
|
// Strategy 3: Use systemd-run to restart in separate session
|
|
try {
|
|
console.log('[UpdateManager] Attempting restart via systemd-run...');
|
|
await execAsync('systemd-run --no-ask-password --scope systemctl restart pulse.service', { timeout: 10000 });
|
|
console.log('[UpdateManager] Service restarted successfully via systemd-run');
|
|
return;
|
|
} catch (error) {
|
|
console.warn('[UpdateManager] systemd-run restart failed:', error.message);
|
|
}
|
|
|
|
// Strategy 4: Graceful shutdown and let systemd restart
|
|
console.log('[UpdateManager] All restart methods failed. Using graceful shutdown...');
|
|
console.log('[UpdateManager] systemd will automatically restart the service');
|
|
|
|
// Close server gracefully then exit
|
|
if (global.server) {
|
|
global.server.close(() => {
|
|
console.log('[UpdateManager] Server closed gracefully');
|
|
process.exit(0);
|
|
});
|
|
|
|
// Force exit after 5 seconds if graceful close doesn't work
|
|
setTimeout(() => {
|
|
console.log('[UpdateManager] Forcing process exit');
|
|
process.exit(0);
|
|
}, 5000);
|
|
} else {
|
|
process.exit(0);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get update status
|
|
*/
|
|
getUpdateStatus() {
|
|
return {
|
|
updateInProgress: this.updateInProgress,
|
|
currentVersion: this.currentVersion
|
|
};
|
|
}
|
|
}
|
|
|
|
module.exports = UpdateManager; |