diff --git a/backend/src/routes/versionRoutes.js b/backend/src/routes/versionRoutes.js
index 7211d2c..4cc557b 100644
--- a/backend/src/routes/versionRoutes.js
+++ b/backend/src/routes/versionRoutes.js
@@ -126,43 +126,61 @@ async function getLatestCommit(owner, repo) {
// Helper function to get commit count difference
async function getCommitDifference(owner, repo, currentVersion) {
- try {
- const currentVersionTag = `v${currentVersion}`;
- // Compare main branch with the released version tag
- const apiUrl = `https://api.github.com/repos/${owner}/${repo}/compare/${currentVersionTag}...main`;
+ // Try both with and without 'v' prefix for compatibility
+ const versionTags = [
+ currentVersion, // Try without 'v' first (new format)
+ `v${currentVersion}`, // Try with 'v' prefix (old format)
+ ];
- const response = await fetch(apiUrl, {
- method: "GET",
- headers: {
- Accept: "application/vnd.github.v3+json",
- "User-Agent": `PatchMon-Server/${getCurrentVersion()}`,
- },
- });
+ for (const versionTag of versionTags) {
+ try {
+ // Compare main branch with the released version tag
+ const apiUrl = `https://api.github.com/repos/${owner}/${repo}/compare/${versionTag}...main`;
- if (!response.ok) {
- const errorText = await response.text();
- if (
- errorText.includes("rate limit") ||
- errorText.includes("API rate limit")
- ) {
- throw new Error("GitHub API rate limit exceeded");
+ const response = await fetch(apiUrl, {
+ method: "GET",
+ headers: {
+ Accept: "application/vnd.github.v3+json",
+ "User-Agent": `PatchMon-Server/${getCurrentVersion()}`,
+ },
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ if (
+ errorText.includes("rate limit") ||
+ errorText.includes("API rate limit")
+ ) {
+ throw new Error("GitHub API rate limit exceeded");
+ }
+ // If 404, try next tag format
+ if (response.status === 404) {
+ continue;
+ }
+ throw new Error(
+ `GitHub API error: ${response.status} ${response.statusText}`,
+ );
}
- throw new Error(
- `GitHub API error: ${response.status} ${response.statusText}`,
- );
- }
- const compareData = await response.json();
- return {
- commitsBehind: compareData.behind_by || 0, // How many commits main is behind release
- commitsAhead: compareData.ahead_by || 0, // How many commits main is ahead of release
- totalCommits: compareData.total_commits || 0,
- branchInfo: "main branch vs release",
- };
- } catch (error) {
- console.error("Error fetching commit difference:", error.message);
- throw error;
+ const compareData = await response.json();
+ return {
+ commitsBehind: compareData.behind_by || 0, // How many commits main is behind release
+ commitsAhead: compareData.ahead_by || 0, // How many commits main is ahead of release
+ totalCommits: compareData.total_commits || 0,
+ branchInfo: "main branch vs release",
+ };
+ } catch (error) {
+ // If rate limit, throw immediately
+ if (error.message.includes("rate limit")) {
+ throw error;
+ }
+ }
}
+
+ // If all attempts failed, throw error
+ throw new Error(
+ `Could not find tag '${currentVersion}' or 'v${currentVersion}' in repository`,
+ );
}
// Helper function to compare version strings (semantic versioning)
@@ -296,10 +314,14 @@ router.get(
};
} else {
// Fall back to cached data for other errors
+ const githubRepoUrl = settings.githubRepoUrl || DEFAULT_GITHUB_REPO;
latestRelease = settings.latest_version
? {
version: settings.latest_version,
- tagName: `v${settings.latest_version}`,
+ tagName: settings.latest_version,
+ publishedAt: null, // Only use date from GitHub API, not cached data
+ // Note: URL may need 'v' prefix depending on actual tag format in repo
+ htmlUrl: `${githubRepoUrl.replace(/\.git$/, "")}/releases/tag/${settings.latest_version}`,
}
: null;
}
diff --git a/frontend/src/components/settings/VersionUpdateTab.jsx b/frontend/src/components/settings/VersionUpdateTab.jsx
index ed99728..6ed0ffb 100644
--- a/frontend/src/components/settings/VersionUpdateTab.jsx
+++ b/frontend/src/components/settings/VersionUpdateTab.jsx
@@ -128,12 +128,14 @@ const VersionUpdateTab = () => {
{versionInfo.github.latestRelease.tagName}
-
- Published:{" "}
- {new Date(
- versionInfo.github.latestRelease.publishedAt,
- ).toLocaleDateString()}
-
+ {versionInfo.github.latestRelease.publishedAt && (
+
+ Published:{" "}
+ {new Date(
+ versionInfo.github.latestRelease.publishedAt,
+ ).toLocaleDateString()}
+
+ )}
)}
diff --git a/setup.sh b/setup.sh
index fa3a932..bb916c7 100755
--- a/setup.sh
+++ b/setup.sh
@@ -34,7 +34,7 @@ BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Global variables
-SCRIPT_VERSION="self-hosting-install.sh v1.2.8-selfhost-2025-10-10-1"
+SCRIPT_VERSION="self-hosting-install.sh v1.2.8-selfhost-2025-10-10-5"
DEFAULT_GITHUB_REPO="https://github.com/PatchMon/PatchMon.git"
FQDN=""
CUSTOM_FQDN=""
@@ -60,6 +60,9 @@ SERVICE_USE_LETSENCRYPT="true" # Will be set based on user input
SERVER_PROTOCOL_SEL="https"
SERVER_PORT_SEL="" # Will be set to BACKEND_PORT in init_instance_vars
SETUP_NGINX="true"
+UPDATE_MODE="false"
+SELECTED_INSTANCE=""
+SELECTED_SERVICE_NAME=""
# Functions
print_status() {
@@ -642,31 +645,61 @@ EOF
# Setup database for instance
setup_database() {
- print_info "Creating database: $DB_NAME"
+ print_info "Setting up database: $DB_NAME"
# Check if sudo is available for user switching
if command -v sudo >/dev/null 2>&1; then
- # Drop and recreate database and user for clean state
- sudo -u postgres psql -c "DROP DATABASE IF EXISTS $DB_NAME;" || true
- sudo -u postgres psql -c "DROP USER IF EXISTS $DB_USER;" || true
+ # Check if user exists
+ user_exists=$(sudo -u postgres psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'" || echo "0")
- # Create database and user
- sudo -u postgres psql -c "CREATE USER $DB_USER WITH PASSWORD '$DB_PASS';"
- sudo -u postgres psql -c "CREATE DATABASE $DB_NAME OWNER $DB_USER;"
+ if [ "$user_exists" = "1" ]; then
+ print_info "Database user $DB_USER already exists, skipping creation"
+ else
+ print_info "Creating database user $DB_USER"
+ sudo -u postgres psql -c "CREATE USER $DB_USER WITH PASSWORD '$DB_PASS';"
+ fi
+
+ # Check if database exists
+ db_exists=$(sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='$DB_NAME'" || echo "0")
+
+ if [ "$db_exists" = "1" ]; then
+ print_info "Database $DB_NAME already exists, skipping creation"
+ else
+ print_info "Creating database $DB_NAME"
+ sudo -u postgres psql -c "CREATE DATABASE $DB_NAME OWNER $DB_USER;"
+ fi
+
+ # Always grant privileges (in case they were revoked)
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE $DB_NAME TO $DB_USER;"
else
# Alternative method for systems without sudo (run as postgres user directly)
print_warning "sudo not available, using alternative method for PostgreSQL setup"
- # Switch to postgres user using su
- su - postgres -c "psql -c \"DROP DATABASE IF EXISTS $DB_NAME;\"" || true
- su - postgres -c "psql -c \"DROP USER IF EXISTS $DB_USER;\"" || true
- su - postgres -c "psql -c \"CREATE USER $DB_USER WITH PASSWORD '$DB_PASS';\""
- su - postgres -c "psql -c \"CREATE DATABASE $DB_NAME OWNER $DB_USER;\""
+ # Check if user exists
+ user_exists=$(su - postgres -c "psql -tAc \"SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'\"" || echo "0")
+
+ if [ "$user_exists" = "1" ]; then
+ print_info "Database user $DB_USER already exists, skipping creation"
+ else
+ print_info "Creating database user $DB_USER"
+ su - postgres -c "psql -c \"CREATE USER $DB_USER WITH PASSWORD '$DB_PASS';\""
+ fi
+
+ # Check if database exists
+ db_exists=$(su - postgres -c "psql -tAc \"SELECT 1 FROM pg_database WHERE datname='$DB_NAME'\"" || echo "0")
+
+ if [ "$db_exists" = "1" ]; then
+ print_info "Database $DB_NAME already exists, skipping creation"
+ else
+ print_info "Creating database $DB_NAME"
+ su - postgres -c "psql -c \"CREATE DATABASE $DB_NAME OWNER $DB_USER;\""
+ fi
+
+ # Always grant privileges (in case they were revoked)
su - postgres -c "psql -c \"GRANT ALL PRIVILEGES ON DATABASE $DB_NAME TO $DB_USER;\""
fi
- print_status "Database $DB_NAME created with user $DB_USER"
+ print_status "Database setup complete for $DB_NAME"
}
# Clone application repository
@@ -1550,11 +1583,271 @@ deploy_instance() {
:
}
+# Detect existing PatchMon installations
+detect_installations() {
+ local installations=()
+
+ # Find all directories in /opt that contain PatchMon installations
+ if [ -d "/opt" ]; then
+ for dir in /opt/*/; do
+ local dirname=$(basename "$dir")
+ # Skip backup directories
+ if [[ "$dirname" =~ \.backup\. ]]; then
+ continue
+ fi
+ # Check if it's a PatchMon installation
+ if [ -f "$dir/backend/package.json" ] && grep -q "patchmon" "$dir/backend/package.json" 2>/dev/null; then
+ installations+=("$dirname")
+ fi
+ done
+ fi
+
+ echo "${installations[@]}"
+}
+
+# Select installation to update
+select_installation_to_update() {
+ local installations=($(detect_installations))
+
+ if [ ${#installations[@]} -eq 0 ]; then
+ print_error "No existing PatchMon installations found in /opt"
+ exit 1
+ fi
+
+ print_info "Found ${#installations[@]} existing installation(s):"
+ echo ""
+
+ local i=1
+ declare -A install_map
+ for install in "${installations[@]}"; do
+ # Get current version if possible
+ local version="unknown"
+ if [ -f "/opt/$install/backend/package.json" ]; then
+ version=$(grep '"version"' "/opt/$install/backend/package.json" | head -1 | sed 's/.*"version": "\([^"]*\)".*/\1/')
+ fi
+
+ # Get service status - try multiple naming conventions
+ # Convention 1: Just the install name (e.g., patchmon.internal)
+ local service_name="$install"
+ # Convention 2: patchmon. prefix (e.g., patchmon.patchmon.internal)
+ local alt_service_name1="patchmon.$install"
+ # Convention 3: patchmon- prefix with underscores (e.g., patchmon-patchmon_internal)
+ local alt_service_name2="patchmon-$(echo "$install" | tr '.' '_')"
+ local status="unknown"
+
+ # Try convention 1 first (most common)
+ if systemctl is-active --quiet "$service_name" 2>/dev/null; then
+ status="running"
+ elif systemctl is-enabled --quiet "$service_name" 2>/dev/null; then
+ status="stopped"
+ # Try convention 2
+ elif systemctl is-active --quiet "$alt_service_name1" 2>/dev/null; then
+ status="running"
+ service_name="$alt_service_name1"
+ elif systemctl is-enabled --quiet "$alt_service_name1" 2>/dev/null; then
+ status="stopped"
+ service_name="$alt_service_name1"
+ # Try convention 3
+ elif systemctl is-active --quiet "$alt_service_name2" 2>/dev/null; then
+ status="running"
+ service_name="$alt_service_name2"
+ elif systemctl is-enabled --quiet "$alt_service_name2" 2>/dev/null; then
+ status="stopped"
+ service_name="$alt_service_name2"
+ fi
+
+ printf "%2d. %-30s (v%-10s - %s)\n" "$i" "$install" "$version" "$status"
+ install_map[$i]="$install"
+ # Store the service name for later use
+ declare -g "service_map_$i=$service_name"
+ i=$((i + 1))
+ done
+
+ echo ""
+
+ while true; do
+ read_input "Select installation number to update" SELECTION "1"
+
+ if [[ "$SELECTION" =~ ^[0-9]+$ ]] && [ -n "${install_map[$SELECTION]}" ]; then
+ SELECTED_INSTANCE="${install_map[$SELECTION]}"
+ # Get the stored service name
+ local varname="service_map_$SELECTION"
+ SELECTED_SERVICE_NAME="${!varname}"
+ print_status "Selected: $SELECTED_INSTANCE"
+ print_info "Service: $SELECTED_SERVICE_NAME"
+ return 0
+ else
+ print_error "Invalid selection. Please enter a number from 1 to ${#installations[@]}"
+ fi
+ done
+}
+
+# Update existing installation
+update_installation() {
+ local instance_dir="/opt/$SELECTED_INSTANCE"
+ local service_name="$SELECTED_SERVICE_NAME"
+
+ print_info "Updating PatchMon installation: $SELECTED_INSTANCE"
+ print_info "Installation directory: $instance_dir"
+ print_info "Service name: $service_name"
+
+ # Verify it's a git repository
+ if [ ! -d "$instance_dir/.git" ]; then
+ print_error "Installation directory is not a git repository"
+ print_error "Cannot perform git-based update"
+ exit 1
+ fi
+
+ # Add git safe.directory to avoid ownership issues when running as root
+ print_info "Configuring git safe.directory..."
+ git config --global --add safe.directory "$instance_dir" 2>/dev/null || true
+
+ # Load existing .env to get database credentials
+ if [ -f "$instance_dir/backend/.env" ]; then
+ source "$instance_dir/backend/.env"
+ print_status "Loaded existing configuration"
+ else
+ print_error "Cannot find .env file at $instance_dir/backend/.env"
+ exit 1
+ fi
+
+ # Select branch/version to update to
+ select_branch
+
+ print_info "Updating to: $DEPLOYMENT_BRANCH"
+ echo ""
+
+ read_yes_no "Proceed with update? This will pull new code and restart services" CONFIRM_UPDATE "y"
+
+ if [ "$CONFIRM_UPDATE" != "y" ]; then
+ print_warning "Update cancelled by user"
+ exit 0
+ fi
+
+ # Stop the service
+ print_info "Stopping service: $service_name"
+ systemctl stop "$service_name" || true
+
+ # Create backup directory
+ local timestamp=$(date +%Y%m%d_%H%M%S)
+ local backup_dir="$instance_dir.backup.$timestamp"
+ local db_backup_file="$backup_dir/database_backup_$timestamp.sql"
+
+ print_info "Creating backup directory: $backup_dir"
+ mkdir -p "$backup_dir"
+
+ # Backup database
+ print_info "Backing up database: $DATABASE_NAME"
+ if PGPASSWORD="$DATABASE_PASSWORD" pg_dump -h localhost -U "$DATABASE_USER" -d "$DATABASE_NAME" -F c -f "$db_backup_file" 2>/dev/null; then
+ print_status "Database backup created: $db_backup_file"
+ else
+ print_warning "Database backup failed, but continuing with code backup"
+ fi
+
+ # Backup code
+ print_info "Backing up code files..."
+ cp -r "$instance_dir" "$backup_dir/code"
+ print_status "Code backup created"
+
+ # Update code
+ print_info "Pulling latest code from branch: $DEPLOYMENT_BRANCH"
+ cd "$instance_dir"
+
+ # Fetch latest changes
+ git fetch origin
+
+ # Checkout the selected branch/tag
+ git checkout "$DEPLOYMENT_BRANCH"
+ git pull origin "$DEPLOYMENT_BRANCH" || git pull # For tags, just pull
+
+ print_status "Code updated successfully"
+
+ # Update dependencies
+ print_info "Updating backend dependencies..."
+ cd "$instance_dir/backend"
+ npm install --production --ignore-scripts
+
+ print_info "Updating frontend dependencies..."
+ cd "$instance_dir/frontend"
+ npm install --ignore-scripts
+
+ # Build frontend
+ print_info "Building frontend..."
+ npm run build
+
+ # Run database migrations and generate Prisma client
+ print_info "Running database migrations..."
+ cd "$instance_dir/backend"
+ npx prisma generate
+ npx prisma migrate deploy
+
+ # Start the service
+ print_info "Starting service: $service_name"
+ systemctl start "$service_name"
+
+ # Wait a moment and check status
+ sleep 3
+
+ if systemctl is-active --quiet "$service_name"; then
+ print_success "✅ Update completed successfully!"
+ print_status "Service $service_name is running"
+
+ # Get new version
+ local new_version=$(grep '"version"' "$instance_dir/backend/package.json" | head -1 | sed 's/.*"version": "\([^"]*\)".*/\1/')
+ print_info "Updated to version: $new_version"
+ echo ""
+ print_info "Backup Information:"
+ print_info " Code backup: $backup_dir/code"
+ print_info " Database backup: $db_backup_file"
+ echo ""
+ print_info "To restore database if needed:"
+ print_info " PGPASSWORD=\"$DATABASE_PASSWORD\" pg_restore -h localhost -U \"$DATABASE_USER\" -d \"$DATABASE_NAME\" -c \"$db_backup_file\""
+ echo ""
+ else
+ print_error "Service failed to start after update"
+ echo ""
+ print_warning "ROLLBACK INSTRUCTIONS:"
+ print_info "1. Restore code:"
+ print_info " sudo rm -rf $instance_dir"
+ print_info " sudo mv $backup_dir/code $instance_dir"
+ echo ""
+ print_info "2. Restore database:"
+ print_info " PGPASSWORD=\"$DATABASE_PASSWORD\" pg_restore -h localhost -U \"$DATABASE_USER\" -d \"$DATABASE_NAME\" -c \"$db_backup_file\""
+ echo ""
+ print_info "3. Restart service:"
+ print_info " sudo systemctl start $service_name"
+ echo ""
+ print_info "Check logs: journalctl -u $service_name -f"
+ exit 1
+ fi
+}
+
# Main script execution
main() {
- # Log script entry
- echo "[$(date '+%Y-%m-%d %H:%M:%S')] Interactive installation started" >> "$DEBUG_LOG"
+ # Parse command-line arguments
+ if [ "$1" = "--update" ]; then
+ UPDATE_MODE="true"
+ fi
+ # Log script entry
+ echo "[$(date '+%Y-%m-%d %H:%M:%S')] Script started - Update mode: $UPDATE_MODE" >> "$DEBUG_LOG"
+
+ # Handle update mode
+ if [ "$UPDATE_MODE" = "true" ]; then
+ print_banner
+ print_info "🔄 PatchMon Update Mode"
+ echo ""
+
+ # Select installation to update
+ select_installation_to_update
+
+ # Perform update
+ update_installation
+
+ exit 0
+ fi
+
+ # Normal installation mode
# Run interactive setup
interactive_setup
@@ -1588,5 +1881,30 @@ main() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] deploy_instance function completed" >> "$DEBUG_LOG"
}
-# Run main function (no arguments needed for interactive mode)
-main
+# Show usage/help
+show_usage() {
+ echo "PatchMon Self-Hosting Installation & Update Script"
+ echo "Version: $SCRIPT_VERSION"
+ echo ""
+ echo "Usage:"
+ echo " $0 # Interactive installation (default)"
+ echo " $0 --update # Update existing installation"
+ echo " $0 --help # Show this help message"
+ echo ""
+ echo "Examples:"
+ echo " # New installation:"
+ echo " sudo bash $0"
+ echo ""
+ echo " # Update existing installation:"
+ echo " sudo bash $0 --update"
+ echo ""
+}
+
+# Check for help flag
+if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then
+ show_usage
+ exit 0
+fi
+
+# Run main function
+main "$@"