mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-21 18:53:37 +00:00
ef2b551dfa
- Handle 403 rate limit errors without throwing 500 server errors - Return informative response when rate limited instead of crashing - Fix variable scope issue in error handling - Prevent Settings modal from breaking when GitHub API is unavailable - Add specific error messages for rate limits, 404s, and network issues This resolves the Settings modal 500 errors when GitHub API rate limits are exceeded, providing a better user experience. 🤖 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, show update if versions differ or if latest is newer
|
|
updateAvailable = isDifferentVersion || 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; |