Fix script execution issues and improve container creation
- Fixed syntax errors in build.func (duplicate export, unmatched quotes) - Fixed color variable initialization by calling load_functions in core.func - Replaced undefined function calls (post_to_api, post_update_to_api) with echo statements - Fixed install script execution by copying scripts into container first - Made create_lxc.sh executable - Improved error handling and script sourcing - Added missing core functions and tools - Enhanced script downloader and local script management
This commit is contained in:
12
.env.example
12
.env.example
@@ -4,14 +4,18 @@
|
||||
# Prisma
|
||||
# https://www.prisma.io/docs/reference/database-reference/connection-urls#env
|
||||
DATABASE_URL="postgresql://postgres:password@localhost:5432/pve-scripts-local"
|
||||
REPO_URL="https://github.com/michelroegl-brunner/PVESciptslocal"
|
||||
REPO_URL="https://github.com/community-scripts/ProxmoxVE"
|
||||
REPO_BRANCH="main"
|
||||
SCRIPTS_DIRECTORY="scripts/ct"
|
||||
SCRIPTS_DIRECTORY="scripts"
|
||||
ALLOWED_SCRIPT_EXTENSIONS=".sh"
|
||||
|
||||
CT_SCRIPT_FOLDER="ct"
|
||||
INSTALL_SCRIPT_FOLDER="install"
|
||||
JSON_FOLDER="frontend/public/json"
|
||||
|
||||
# Security
|
||||
MAX_SCRIPT_EXECUTION_TIME="300000"
|
||||
MAX_SCRIPT_EXECUTION_TIME="900000"
|
||||
ALLOWED_SCRIPT_PATHS="scripts/"
|
||||
|
||||
# WebSocket Configuration
|
||||
WEBSOCKET_PORT="3000"
|
||||
WEBSOCKET_PORT="3001"
|
||||
|
||||
249
README.md
249
README.md
@@ -1,12 +1,249 @@
|
||||
# How to run this Alpha Software
|
||||
# PVE Scripts Local 🚀
|
||||
|
||||
You need npm and git installed on your PVE host
|
||||
A modern web-based management interface for Proxmox VE (PVE) helper scripts. This tool provides a user-friendly way to discover, download, and execute community-sourced Proxmox scripts locally with real-time terminal output streaming.
|
||||
|
||||
```git clone git@github.com:michelroegl-brunner/PVESciptslocal.git```
|
||||
## 🌟 Features
|
||||
|
||||
Then you need to run ```npm install```
|
||||
- **Web-based Interface**: Modern React/Next.js frontend with real-time terminal emulation
|
||||
- **Script Discovery**: Browse and search through community Proxmox scripts from GitHub
|
||||
- **One-Click Execution**: Run scripts directly from the web interface with live output
|
||||
- **Real-time Terminal**: Full terminal emulation with xterm.js for interactive script execution
|
||||
- **Script Management**: Download, update, and manage local script collections
|
||||
- **Security**: Sandboxed script execution with path validation and time limits
|
||||
- **Database Integration**: PostgreSQL backend for script metadata and execution history
|
||||
- **WebSocket Communication**: Real-time bidirectional communication for script execution
|
||||
|
||||
And to run the dev server on IP:3000
|
||||
## 🏗️ Architecture
|
||||
|
||||
```npm run dev```
|
||||
### Frontend
|
||||
- **Next.js 15** with React 19
|
||||
- **TypeScript** for type safety
|
||||
- **Tailwind CSS** for styling
|
||||
- **xterm.js** for terminal emulation
|
||||
- **tRPC** for type-safe API communication
|
||||
|
||||
### Backend
|
||||
- **Node.js** server with WebSocket support
|
||||
- **PostgreSQL** database with Prisma ORM
|
||||
- **WebSocket Server** for real-time script execution
|
||||
- **Script Downloader Service** for GitHub integration
|
||||
|
||||
### Scripts
|
||||
- **Core Functions**: Shared utilities and build functions
|
||||
- **Container Scripts**: Pre-configured LXC container setups
|
||||
- **Installation Scripts**: System setup and configuration tools
|
||||
|
||||
## 📋 Prerequisites
|
||||
|
||||
- **Node.js** 18+ and npm
|
||||
- **PostgreSQL** database (or Docker/Podman for local development)
|
||||
- **Git** for cloning the repository
|
||||
- **Linux/Unix environment** (tested on Proxmox VE hosts)
|
||||
|
||||
## 🚀 Installation
|
||||
|
||||
### 1. Clone the Repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/michelroegl-brunner/PVESciptslocal.git
|
||||
cd PVESciptslocal
|
||||
```
|
||||
|
||||
### 2. Install Dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 3. Environment Configuration
|
||||
|
||||
Copy the example environment file and configure your settings:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` with your configuration:
|
||||
|
||||
```env
|
||||
# Database Configuration
|
||||
DATABASE_URL="postgresql://postgres:password@localhost:5432/pve-scripts-local"
|
||||
|
||||
# GitHub Repository Configuration
|
||||
REPO_URL="https://github.com/community-scripts/ProxmoxVE"
|
||||
REPO_BRANCH="main"
|
||||
SCRIPTS_DIRECTORY="scripts/ct"
|
||||
|
||||
# Security Settings
|
||||
MAX_SCRIPT_EXECUTION_TIME="300000"
|
||||
ALLOWED_SCRIPT_PATHS="scripts/"
|
||||
|
||||
# WebSocket Configuration
|
||||
WEBSOCKET_PORT="3000"
|
||||
```
|
||||
|
||||
|
||||
### 4. Start the Application
|
||||
|
||||
|
||||
|
||||
#### Production Mode
|
||||
```bash
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
The application will be available at `http://IP:3000`
|
||||
|
||||
## 🎯 Usage
|
||||
|
||||
### 1. Access the Web Interface
|
||||
|
||||
Open your browser and navigate to `http://IP:3000` (or your configured host/port).
|
||||
|
||||
### 2. Browse Available Scripts
|
||||
|
||||
- The main page displays a grid of available Proxmox scripts
|
||||
- Use the search functionality to find specific scripts
|
||||
- Scripts are categorized by type (containers, installations, etc.)
|
||||
|
||||
### 3. Download Scripts
|
||||
|
||||
- Click on any script card to view details
|
||||
- Use the "Download" button to fetch scripts from GitHub
|
||||
- Downloaded scripts are stored locally in the `scripts/` directory
|
||||
|
||||
### 4. Execute Scripts
|
||||
|
||||
- Click "Run Script" on any downloaded script
|
||||
- A terminal window will open with real-time output
|
||||
- Interact with the script through the web terminal
|
||||
- Use the close button to stop execution
|
||||
|
||||
### 5. Script Management
|
||||
|
||||
- View script execution history
|
||||
- Update scripts to latest versions
|
||||
- Manage local script collections
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
PVESciptslocal/
|
||||
├── scripts/ # Script collection
|
||||
│ ├── core/ # Core utility functions
|
||||
│ │ ├── build.func # Build system functions
|
||||
│ │ ├── tools.func # Tool installation functions
|
||||
│ │ └── create_lxc.sh # LXC container creation
|
||||
│ ├── ct/ # Container templates
|
||||
│ │ ├── 2fauth.sh # 2FA authentication app
|
||||
│ │ ├── adguard.sh # AdGuard Home
|
||||
│ │ └── debian.sh # Debian base container
|
||||
│ └── install/ # Installation scripts
|
||||
├── src/ # Source code
|
||||
│ ├── app/ # Next.js app directory
|
||||
│ │ ├── _components/ # React components
|
||||
│ │ └── page.tsx # Main page
|
||||
│ └── server/ # Server-side code
|
||||
│ └── services/ # Business logic services
|
||||
├── prisma/ # Database schema
|
||||
├── public/ # Static assets
|
||||
├── server.js # Main server file
|
||||
└── package.json # Dependencies and scripts
|
||||
```
|
||||
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `DATABASE_URL` | PostgreSQL connection string | Required |
|
||||
| `REPO_URL` | GitHub repository URL | Required |
|
||||
| `REPO_BRANCH` | Git branch to use | `main` |
|
||||
| `SCRIPTS_DIRECTORY` | Local scripts directory | `scripts/ct` |
|
||||
| `MAX_SCRIPT_EXECUTION_TIME` | Max execution time (ms) | `300000` |
|
||||
| `ALLOWED_SCRIPT_PATHS` | Allowed script paths | `scripts/` |
|
||||
|
||||
### Database Configuration
|
||||
|
||||
The application uses PostgreSQL with Prisma ORM. The database stores:
|
||||
- Script metadata and descriptions
|
||||
- Execution history and logs
|
||||
- User preferences and settings
|
||||
|
||||
## 🚀 Development
|
||||
|
||||
### Prerequisites for Development
|
||||
- Node.js 18+
|
||||
- PostgreSQL or Docker
|
||||
- Git
|
||||
|
||||
### Development Commands
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start development server
|
||||
npm run dev
|
||||
|
||||
# Start Next.js in development mode
|
||||
npm run dev:next
|
||||
|
||||
# Type checking
|
||||
npm run typecheck
|
||||
|
||||
# Linting
|
||||
npm run lint
|
||||
npm run lint:fix
|
||||
|
||||
# Formatting
|
||||
npm run format:write
|
||||
npm run format:check
|
||||
|
||||
# Database operations
|
||||
npm run db:generate # Generate Prisma client
|
||||
npm run db:migrate # Run migrations
|
||||
npm run db:push # Push schema changes
|
||||
npm run db:studio # Open Prisma Studio
|
||||
```
|
||||
|
||||
### Project Structure for Developers
|
||||
|
||||
- **Frontend**: React components in `src/app/_components/`
|
||||
- **Backend**: Server logic in `src/server/`
|
||||
- **API**: tRPC routers for type-safe API communication
|
||||
- **Database**: Prisma schema in `prisma/schema.prisma`
|
||||
- **Scripts**: Bash scripts in `scripts/` directory
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
|
||||
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
### Adding New Scripts
|
||||
|
||||
1. Create a new `.sh` file in the appropriate directory (`scripts/ct/` for containers)
|
||||
2. Follow the existing script structure and include proper headers
|
||||
3. Test the script thoroughly
|
||||
4. Submit a pull request with the new script
|
||||
|
||||
## 📝 License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
### Logs
|
||||
|
||||
- Server logs: Check console output or `server.log`
|
||||
- Database logs: Check PostgreSQL logs
|
||||
- Script execution: View in web terminal
|
||||
|
||||
|
||||
---
|
||||
|
||||
**Note**: This is alpha software. Use with caution in production environments and always backup your Proxmox configuration before running scripts.
|
||||
@@ -1,5 +1,7 @@
|
||||
# Copyright (c) 2021-2025 michelroegl-brunner
|
||||
# Author: michelroegl-brunner
|
||||
# Copyright (c) 2021-2025 tteck
|
||||
# Author: tteck (tteckster)
|
||||
# Co-Author: MickLesk
|
||||
# Co-Author: michelroegl-brunner
|
||||
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
|
||||
|
||||
variables() {
|
||||
@@ -7,14 +9,15 @@ variables() {
|
||||
var_install="${NSAPP}-install" # sets the var_install variable by appending "-install" to the value of NSAPP.
|
||||
INTEGER='^[0-9]+([.][0-9]+)?$' # it defines the INTEGER regular expression pattern.
|
||||
PVEHOST_NAME=$(hostname) # gets the Proxmox Hostname and sets it to Uppercase
|
||||
DIAGNOSTICS="yes" # sets the DIAGNOSTICS variable to "yes", used for the API call.
|
||||
METHOD="default" # sets the METHOD variable to "default", used for the API call.
|
||||
RANDOM_UUID="$(cat /proc/sys/kernel/random/uuid)" # generates a random UUID and sets it to the RANDOM_UUID variable.
|
||||
CT_TYPE=${var_unprivileged:-$CT_TYPE}
|
||||
}
|
||||
|
||||
|
||||
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/core.func"
|
||||
|
||||
|
||||
# This function enables error handling in the script by setting options and defining a trap for the ERR signal.
|
||||
catch_errors() {
|
||||
set -Eeo pipefail
|
||||
@@ -23,7 +26,6 @@ catch_errors() {
|
||||
|
||||
# This function is called when an error occurs. It receives the exit code, line number, and command that caused the error, and displays an error message.
|
||||
error_handler() {
|
||||
|
||||
printf "\e[?25h"
|
||||
local exit_code="$?"
|
||||
local line_number="$1"
|
||||
@@ -351,25 +353,574 @@ exit_script() {
|
||||
exit
|
||||
}
|
||||
|
||||
# This function allows the user to configure advanced settings for the script.
|
||||
advanced_settings() {
|
||||
whiptail --backtitle "Proxmox VE Helper Scripts" --msgbox --title "Here is an instructional tip:" "To make a selection, use the Spacebar." 8 58
|
||||
# Setting Default Tag for Advanced Settings
|
||||
TAGS="community-script;${var_tags:-}"
|
||||
CT_DEFAULT_TYPE="${CT_TYPE}"
|
||||
CT_TYPE=""
|
||||
while [ -z "$CT_TYPE" ]; do
|
||||
if [ "$CT_DEFAULT_TYPE" == "1" ]; then
|
||||
if CT_TYPE=$(whiptail --backtitle "Proxmox VE Helper Scripts" --title "CONTAINER TYPE" --radiolist "Choose Type" 10 58 2 \
|
||||
"1" "Unprivileged" ON \
|
||||
"0" "Privileged" OFF \
|
||||
3>&1 1>&2 2>&3); then
|
||||
if [ -n "$CT_TYPE" ]; then
|
||||
CT_TYPE_DESC="Unprivileged"
|
||||
if [ "$CT_TYPE" -eq 0 ]; then
|
||||
CT_TYPE_DESC="Privileged"
|
||||
fi
|
||||
echo -e "${OS}${BOLD}${DGN}Operating System: ${BGN}$var_os${CL}"
|
||||
echo -e "${OSVERSION}${BOLD}${DGN}Version: ${BGN}$var_version${CL}"
|
||||
echo -e "${CONTAINERTYPE}${BOLD}${DGN}Container Type: ${BGN}$CT_TYPE_DESC${CL}"
|
||||
fi
|
||||
else
|
||||
exit_script
|
||||
fi
|
||||
fi
|
||||
if [ "$CT_DEFAULT_TYPE" == "0" ]; then
|
||||
if CT_TYPE=$(whiptail --backtitle "Proxmox VE Helper Scripts" --title "CONTAINER TYPE" --radiolist "Choose Type" 10 58 2 \
|
||||
"1" "Unprivileged" OFF \
|
||||
"0" "Privileged" ON \
|
||||
3>&1 1>&2 2>&3); then
|
||||
if [ -n "$CT_TYPE" ]; then
|
||||
CT_TYPE_DESC="Unprivileged"
|
||||
if [ "$CT_TYPE" -eq 0 ]; then
|
||||
CT_TYPE_DESC="Privileged"
|
||||
fi
|
||||
echo -e "${OS}${BOLD}${DGN}Operating System: ${BGN}$var_os${CL}"
|
||||
echo -e "${OSVERSION}${BOLD}${DGN}Version: ${BGN}$var_version${CL}"
|
||||
echo -e "${CONTAINERTYPE}${BOLD}${DGN}Container Type: ${BGN}$CT_TYPE_DESC${CL}"
|
||||
fi
|
||||
else
|
||||
exit_script
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
while true; do
|
||||
if PW1=$(whiptail --backtitle "Proxmox VE Helper Scripts" --passwordbox "\nSet Root Password (needed for root ssh access)" 9 58 --title "PASSWORD (leave blank for automatic login)" 3>&1 1>&2 2>&3); then
|
||||
# Empty = Autologin
|
||||
if [[ -z "$PW1" ]]; then
|
||||
PW=""
|
||||
PW1="Automatic Login"
|
||||
echo -e "${VERIFYPW}${BOLD}${DGN}Root Password: ${BGN}$PW1${CL}"
|
||||
break
|
||||
fi
|
||||
|
||||
# Invalid: contains spaces
|
||||
if [[ "$PW1" == *" "* ]]; then
|
||||
whiptail --msgbox "Password cannot contain spaces." 8 58
|
||||
continue
|
||||
fi
|
||||
|
||||
# Invalid: too short
|
||||
if ((${#PW1} < 5)); then
|
||||
whiptail --msgbox "Password must be at least 5 characters." 8 58
|
||||
continue
|
||||
fi
|
||||
|
||||
# Confirm password
|
||||
if PW2=$(whiptail --backtitle "Proxmox VE Helper Scripts" --passwordbox "\nVerify Root Password" 9 58 --title "PASSWORD VERIFICATION" 3>&1 1>&2 2>&3); then
|
||||
if [[ "$PW1" == "$PW2" ]]; then
|
||||
PW="-password $PW1"
|
||||
echo -e "${VERIFYPW}${BOLD}${DGN}Root Password: ${BGN}********${CL}"
|
||||
break
|
||||
else
|
||||
whiptail --msgbox "Passwords do not match. Please try again." 8 58
|
||||
fi
|
||||
else
|
||||
exit_script
|
||||
fi
|
||||
else
|
||||
exit_script
|
||||
fi
|
||||
done
|
||||
|
||||
if CT_ID=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set Container ID" 8 58 "$NEXTID" --title "CONTAINER ID" 3>&1 1>&2 2>&3); then
|
||||
if [ -z "$CT_ID" ]; then
|
||||
CT_ID="$NEXTID"
|
||||
echo -e "${CONTAINERID}${BOLD}${DGN}Container ID: ${BGN}$CT_ID${CL}"
|
||||
else
|
||||
echo -e "${CONTAINERID}${BOLD}${DGN}Container ID: ${BGN}$CT_ID${CL}"
|
||||
fi
|
||||
else
|
||||
exit_script
|
||||
fi
|
||||
|
||||
while true; do
|
||||
if CT_NAME=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set Hostname" 8 58 "$NSAPP" --title "HOSTNAME" 3>&1 1>&2 2>&3); then
|
||||
if [ -z "$CT_NAME" ]; then
|
||||
HN="$NSAPP"
|
||||
else
|
||||
HN=$(echo "${CT_NAME,,}" | tr -d ' ')
|
||||
fi
|
||||
# Hostname validate (RFC 1123)
|
||||
if [[ "$HN" =~ ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ ]]; then
|
||||
echo -e "${HOSTNAME}${BOLD}${DGN}Hostname: ${BGN}$HN${CL}"
|
||||
break
|
||||
else
|
||||
whiptail --backtitle "Proxmox VE Helper Scripts" \
|
||||
--msgbox "❌ Invalid hostname: '$HN'\n\nOnly lowercase letters, digits and hyphens (-) are allowed.\nUnderscores (_) or other characters are not permitted!" 10 70
|
||||
fi
|
||||
else
|
||||
exit_script
|
||||
fi
|
||||
done
|
||||
|
||||
while true; do
|
||||
DISK_SIZE=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set Disk Size in GB" 8 58 "$var_disk" --title "DISK SIZE" 3>&1 1>&2 2>&3) || exit_script
|
||||
|
||||
if [ -z "$DISK_SIZE" ]; then
|
||||
DISK_SIZE="$var_disk"
|
||||
fi
|
||||
|
||||
if [[ "$DISK_SIZE" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo -e "${DISKSIZE}${BOLD}${DGN}Disk Size: ${BGN}${DISK_SIZE} GB${CL}"
|
||||
break
|
||||
else
|
||||
whiptail --msgbox "Disk size must be a positive integer!" 8 58
|
||||
fi
|
||||
done
|
||||
|
||||
while true; do
|
||||
CORE_COUNT=$(whiptail --backtitle "Proxmox VE Helper Scripts" \
|
||||
--inputbox "Allocate CPU Cores" 8 58 "$var_cpu" --title "CORE COUNT" 3>&1 1>&2 2>&3) || exit_script
|
||||
|
||||
if [ -z "$CORE_COUNT" ]; then
|
||||
CORE_COUNT="$var_cpu"
|
||||
fi
|
||||
|
||||
if [[ "$CORE_COUNT" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo -e "${CPUCORE}${BOLD}${DGN}CPU Cores: ${BGN}$CORE_COUNT${CL}"
|
||||
break
|
||||
else
|
||||
whiptail --msgbox "CPU core count must be a positive integer!" 8 58
|
||||
fi
|
||||
done
|
||||
|
||||
while true; do
|
||||
RAM_SIZE=$(whiptail --backtitle "Proxmox VE Helper Scripts" \
|
||||
--inputbox "Allocate RAM in MiB" 8 58 "$var_ram" --title "RAM" 3>&1 1>&2 2>&3) || exit_script
|
||||
|
||||
if [ -z "$RAM_SIZE" ]; then
|
||||
RAM_SIZE="$var_ram"
|
||||
fi
|
||||
|
||||
if [[ "$RAM_SIZE" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo -e "${RAMSIZE}${BOLD}${DGN}RAM Size: ${BGN}${RAM_SIZE} MiB${CL}"
|
||||
break
|
||||
else
|
||||
whiptail --msgbox "RAM size must be a positive integer!" 8 58
|
||||
fi
|
||||
done
|
||||
|
||||
BRIDGES=""
|
||||
IFACE_FILEPATH_LIST="/etc/network/interfaces"$'\n'$(find "/etc/network/interfaces.d/" -type f)
|
||||
OLD_IFS=$IFS
|
||||
IFS=$'\n'
|
||||
|
||||
for iface_filepath in ${IFACE_FILEPATH_LIST}; do
|
||||
iface_indexes_tmpfile=$(mktemp -q -u '.iface-XXXX')
|
||||
|
||||
(grep -Pn '^\s*iface' "${iface_filepath}" | cut -d':' -f1 && wc -l "${iface_filepath}" | cut -d' ' -f1) |
|
||||
awk 'FNR==1 {line=$0; next} {print line":"$0-1; line=$0}' >"${iface_indexes_tmpfile}" || true
|
||||
|
||||
if [ -f "${iface_indexes_tmpfile}" ]; then
|
||||
while read -r pair; do
|
||||
start=$(echo "${pair}" | cut -d':' -f1)
|
||||
end=$(echo "${pair}" | cut -d':' -f2)
|
||||
|
||||
if awk "NR >= ${start} && NR <= ${end}" "${iface_filepath}" | grep -qP '^\s*(bridge[-_](ports|stp|fd|vlan-aware|vids)|ovs_type\s+OVSBridge)\b'; then
|
||||
iface_name=$(sed "${start}q;d" "${iface_filepath}" | awk '{print $2}')
|
||||
BRIDGES="${iface_name}"$'\n'"${BRIDGES}"
|
||||
fi
|
||||
|
||||
done <"${iface_indexes_tmpfile}"
|
||||
rm -f "${iface_indexes_tmpfile}"
|
||||
fi
|
||||
|
||||
done
|
||||
|
||||
IFS=$OLD_IFS
|
||||
|
||||
BRIDGES=$(echo "$BRIDGES" | grep -v '^\s*$' | sort | uniq)
|
||||
|
||||
if [[ -z "$BRIDGES" ]]; then
|
||||
BRG="vmbr0"
|
||||
echo -e "${BRIDGE}${BOLD}${DGN}Bridge: ${BGN}$BRG${CL}"
|
||||
else
|
||||
BRG=$(whiptail --backtitle "Proxmox VE Helper Scripts" --menu "Select network bridge:" 15 40 6 $(echo "$BRIDGES" | awk '{print $0, "Bridge"}') 3>&1 1>&2 2>&3)
|
||||
if [ -z "$BRG" ]; then
|
||||
exit_script
|
||||
else
|
||||
echo -e "${BRIDGE}${BOLD}${DGN}Bridge: ${BGN}$BRG${CL}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# IPv4 methods: dhcp, static, none
|
||||
while true; do
|
||||
IPV4_METHOD=$(whiptail --backtitle "Proxmox VE Helper Scripts" \
|
||||
--title "IPv4 Address Management" \
|
||||
--menu "Select IPv4 Address Assignment Method:" 12 60 2 \
|
||||
"dhcp" "Automatic (DHCP, recommended)" \
|
||||
"static" "Static (manual entry)" \
|
||||
3>&1 1>&2 2>&3)
|
||||
|
||||
exit_status=$?
|
||||
if [ $exit_status -ne 0 ]; then
|
||||
exit_script
|
||||
fi
|
||||
|
||||
case "$IPV4_METHOD" in
|
||||
dhcp)
|
||||
NET="dhcp"
|
||||
GATE=""
|
||||
echo -e "${NETWORK}${BOLD}${DGN}IPv4: DHCP${CL}"
|
||||
break
|
||||
;;
|
||||
static)
|
||||
# Static: call and validate CIDR address
|
||||
while true; do
|
||||
NET=$(whiptail --backtitle "Proxmox VE Helper Scripts" \
|
||||
--inputbox "Enter Static IPv4 CIDR Address (e.g. 192.168.100.50/24)" 8 58 "" \
|
||||
--title "IPv4 ADDRESS" 3>&1 1>&2 2>&3)
|
||||
if [ -z "$NET" ]; then
|
||||
whiptail --msgbox "IPv4 address must not be empty." 8 58
|
||||
continue
|
||||
elif [[ "$NET" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}/([0-9]|[1-2][0-9]|3[0-2])$ ]]; then
|
||||
echo -e "${NETWORK}${BOLD}${DGN}IPv4 Address: ${BGN}$NET${CL}"
|
||||
break
|
||||
else
|
||||
whiptail --msgbox "$NET is not a valid IPv4 CIDR address. Please enter a correct value!" 8 58
|
||||
fi
|
||||
done
|
||||
|
||||
# call and validate Gateway
|
||||
while true; do
|
||||
GATE1=$(whiptail --backtitle "Proxmox VE Helper Scripts" \
|
||||
--inputbox "Enter Gateway IP address for static IPv4" 8 58 "" \
|
||||
--title "Gateway IP" 3>&1 1>&2 2>&3)
|
||||
if [ -z "$GATE1" ]; then
|
||||
whiptail --msgbox "Gateway IP address cannot be empty." 8 58
|
||||
elif [[ ! "$GATE1" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
|
||||
whiptail --msgbox "Invalid Gateway IP address format." 8 58
|
||||
else
|
||||
GATE=",gw=$GATE1"
|
||||
echo -e "${GATEWAY}${BOLD}${DGN}Gateway IP Address: ${BGN}$GATE1${CL}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# IPv6 Address Management selection
|
||||
while true; do
|
||||
IPV6_METHOD=$(whiptail --backtitle "Proxmox VE Helper Scripts" --menu \
|
||||
"Select IPv6 Address Management Type:" 15 58 4 \
|
||||
"auto" "SLAAC/AUTO (recommended, default)" \
|
||||
"dhcp" "DHCPv6" \
|
||||
"static" "Static (manual entry)" \
|
||||
"none" "Disabled" \
|
||||
--default-item "auto" 3>&1 1>&2 2>&3)
|
||||
[ $? -ne 0 ] && exit_script
|
||||
|
||||
case "$IPV6_METHOD" in
|
||||
auto)
|
||||
echo -e "${NETWORK}${BOLD}${DGN}IPv6: ${BGN}SLAAC/AUTO${CL}"
|
||||
IPV6_ADDR=""
|
||||
IPV6_GATE=""
|
||||
break
|
||||
;;
|
||||
dhcp)
|
||||
echo -e "${NETWORK}${BOLD}${DGN}IPv6: ${BGN}DHCPv6${CL}"
|
||||
IPV6_ADDR="dhcp"
|
||||
IPV6_GATE=""
|
||||
break
|
||||
;;
|
||||
static)
|
||||
# Ask for static IPv6 address (CIDR notation, e.g., 2001:db8::1234/64)
|
||||
while true; do
|
||||
IPV6_ADDR=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox \
|
||||
"Set a static IPv6 CIDR address (e.g., 2001:db8::1234/64)" 8 58 "" \
|
||||
--title "IPv6 STATIC ADDRESS" 3>&1 1>&2 2>&3) || exit_script
|
||||
if [[ "$IPV6_ADDR" =~ ^([0-9a-fA-F:]+:+)+[0-9a-fA-F]+(/[0-9]{1,3})$ ]]; then
|
||||
echo -e "${NETWORK}${BOLD}${DGN}IPv6 Address: ${BGN}$IPV6_ADDR${CL}"
|
||||
break
|
||||
else
|
||||
whiptail --backtitle "Proxmox VE Helper Scripts" --msgbox \
|
||||
"$IPV6_ADDR is an invalid IPv6 CIDR address. Please enter a valid IPv6 CIDR address (e.g., 2001:db8::1234/64)" 8 58
|
||||
fi
|
||||
done
|
||||
# Optional: ask for IPv6 gateway for static config
|
||||
while true; do
|
||||
IPV6_GATE=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox \
|
||||
"Enter IPv6 gateway address (optional, leave blank for none)" 8 58 "" --title "IPv6 GATEWAY" 3>&1 1>&2 2>&3)
|
||||
if [ -z "$IPV6_GATE" ]; then
|
||||
IPV6_GATE=""
|
||||
break
|
||||
elif [[ "$IPV6_GATE" =~ ^([0-9a-fA-F:]+:+)+[0-9a-fA-F]+$ ]]; then
|
||||
break
|
||||
else
|
||||
whiptail --backtitle "Proxmox VE Helper Scripts" --msgbox \
|
||||
"Invalid IPv6 gateway format." 8 58
|
||||
|
||||
fi
|
||||
done
|
||||
break
|
||||
;;
|
||||
none)
|
||||
echo -e "${NETWORK}${BOLD}${DGN}IPv6: ${BGN}Disabled${CL}"
|
||||
IPV6_ADDR="none"
|
||||
IPV6_GATE=""
|
||||
break
|
||||
;;
|
||||
*)
|
||||
exit_script
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$var_os" == "alpine" ]; then
|
||||
APT_CACHER=""
|
||||
APT_CACHER_IP=""
|
||||
else
|
||||
if APT_CACHER_IP=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set APT-Cacher IP (leave blank for none)" 8 58 --title "APT-Cacher IP" 3>&1 1>&2 2>&3); then
|
||||
APT_CACHER="${APT_CACHER_IP:+yes}"
|
||||
echo -e "${NETWORK}${BOLD}${DGN}APT-Cacher IP Address: ${BGN}${APT_CACHER_IP:-Default}${CL}"
|
||||
else
|
||||
exit_script
|
||||
fi
|
||||
fi
|
||||
|
||||
if MTU1=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set Interface MTU Size (leave blank for default [The MTU of your selected vmbr, default is 1500])" 8 58 --title "MTU SIZE" 3>&1 1>&2 2>&3); then
|
||||
if [ -z "$MTU1" ]; then
|
||||
MTU1="Default"
|
||||
MTU=""
|
||||
else
|
||||
MTU=",mtu=$MTU1"
|
||||
fi
|
||||
echo -e "${DEFAULT}${BOLD}${DGN}Interface MTU Size: ${BGN}$MTU1${CL}"
|
||||
else
|
||||
exit_script
|
||||
fi
|
||||
|
||||
if SD=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set a DNS Search Domain (leave blank for HOST)" 8 58 --title "DNS Search Domain" 3>&1 1>&2 2>&3); then
|
||||
if [ -z "$SD" ]; then
|
||||
SX=Host
|
||||
SD=""
|
||||
else
|
||||
SX=$SD
|
||||
SD="-searchdomain=$SD"
|
||||
fi
|
||||
echo -e "${SEARCH}${BOLD}${DGN}DNS Search Domain: ${BGN}$SX${CL}"
|
||||
else
|
||||
exit_script
|
||||
fi
|
||||
|
||||
if NX=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set a DNS Server IP (leave blank for HOST)" 8 58 --title "DNS SERVER IP" 3>&1 1>&2 2>&3); then
|
||||
if [ -z "$NX" ]; then
|
||||
NX=Host
|
||||
NS=""
|
||||
else
|
||||
NS="-nameserver=$NX"
|
||||
fi
|
||||
echo -e "${NETWORK}${BOLD}${DGN}DNS Server IP Address: ${BGN}$NX${CL}"
|
||||
else
|
||||
exit_script
|
||||
fi
|
||||
|
||||
if MAC1=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set a MAC Address(leave blank for generated MAC)" 8 58 --title "MAC ADDRESS" 3>&1 1>&2 2>&3); then
|
||||
if [ -z "$MAC1" ]; then
|
||||
MAC1="Default"
|
||||
MAC=""
|
||||
else
|
||||
MAC=",hwaddr=$MAC1"
|
||||
echo -e "${MACADDRESS}${BOLD}${DGN}MAC Address: ${BGN}$MAC1${CL}"
|
||||
fi
|
||||
else
|
||||
exit_script
|
||||
fi
|
||||
|
||||
if VLAN1=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set a Vlan(leave blank for no VLAN)" 8 58 --title "VLAN" 3>&1 1>&2 2>&3); then
|
||||
if [ -z "$VLAN1" ]; then
|
||||
VLAN1="Default"
|
||||
VLAN=""
|
||||
else
|
||||
VLAN=",tag=$VLAN1"
|
||||
fi
|
||||
echo -e "${VLANTAG}${BOLD}${DGN}Vlan: ${BGN}$VLAN1${CL}"
|
||||
else
|
||||
exit_script
|
||||
fi
|
||||
|
||||
if ADV_TAGS=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set Custom Tags?[If you remove all, there will be no tags!]" 8 58 "${TAGS}" --title "Advanced Tags" 3>&1 1>&2 2>&3); then
|
||||
if [ -n "${ADV_TAGS}" ]; then
|
||||
ADV_TAGS=$(echo "$ADV_TAGS" | tr -d '[:space:]')
|
||||
TAGS="${ADV_TAGS}"
|
||||
else
|
||||
TAGS=";"
|
||||
fi
|
||||
echo -e "${NETWORK}${BOLD}${DGN}Tags: ${BGN}$TAGS${CL}"
|
||||
else
|
||||
exit_script
|
||||
fi
|
||||
|
||||
SSH_AUTHORIZED_KEY="$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "SSH Authorized key for root (leave empty for none)" 8 58 --title "SSH Key" 3>&1 1>&2 2>&3)"
|
||||
|
||||
if [[ -z "${SSH_AUTHORIZED_KEY}" ]]; then
|
||||
SSH_AUTHORIZED_KEY=""
|
||||
fi
|
||||
|
||||
if [[ "$PW" == -password* || -n "$SSH_AUTHORIZED_KEY" ]]; then
|
||||
if (whiptail --backtitle "Proxmox VE Helper Scripts" --defaultno --title "SSH ACCESS" --yesno "Enable Root SSH Access?" 10 58); then
|
||||
SSH="yes"
|
||||
else
|
||||
SSH="no"
|
||||
fi
|
||||
echo -e "${ROOTSSH}${BOLD}${DGN}Root SSH Access: ${BGN}$SSH${CL}"
|
||||
else
|
||||
SSH="no"
|
||||
echo -e "${ROOTSSH}${BOLD}${DGN}Root SSH Access: ${BGN}$SSH${CL}"
|
||||
fi
|
||||
|
||||
if (whiptail --backtitle "Proxmox VE Helper Scripts" --defaultno --title "FUSE Support" --yesno "Enable FUSE support?\nRequired for tools like rclone, mergerfs, AppImage, etc." 10 58); then
|
||||
ENABLE_FUSE="yes"
|
||||
else
|
||||
ENABLE_FUSE="no"
|
||||
fi
|
||||
echo -e "${FUSE}${BOLD}${DGN}Enable FUSE Support: ${BGN}$ENABLE_FUSE${CL}"
|
||||
|
||||
if (whiptail --backtitle "Proxmox VE Helper Scripts" --defaultno --title "VERBOSE MODE" --yesno "Enable Verbose Mode?" 10 58); then
|
||||
VERBOSE="yes"
|
||||
else
|
||||
VERBOSE="no"
|
||||
fi
|
||||
echo -e "${SEARCH}${BOLD}${DGN}Verbose Mode: ${BGN}$VERBOSE${CL}"
|
||||
|
||||
if (whiptail --backtitle "Proxmox VE Helper Scripts" --title "ADVANCED SETTINGS COMPLETE" --yesno "Ready to create ${APP} LXC?" 10 58); then
|
||||
echo -e "${CREATING}${BOLD}${RD}Creating a ${APP} LXC using the above advanced settings${CL}"
|
||||
|
||||
# Strip prefixes from DNS parameters for config file storage
|
||||
local SD_VALUE="$SD"
|
||||
local NS_VALUE="$NS"
|
||||
local MAC_VALUE="$MAC"
|
||||
local VLAN_VALUE="$VLAN"
|
||||
[[ "$SD" =~ ^-searchdomain= ]] && SD_VALUE="${SD#-searchdomain=}"
|
||||
[[ "$NS" =~ ^-nameserver= ]] && NS_VALUE="${NS#-nameserver=}"
|
||||
[[ "$MAC" =~ ^,hwaddr= ]] && MAC_VALUE="${MAC#,hwaddr=}"
|
||||
[[ "$VLAN" =~ ^,tag= ]] && VLAN_VALUE="${VLAN#,tag=}"
|
||||
|
||||
# Temporarily store original values
|
||||
local SD_ORIG="$SD"
|
||||
local NS_ORIG="$NS"
|
||||
local MAC_ORIG="$MAC"
|
||||
local VLAN_ORIG="$VLAN"
|
||||
|
||||
# Set clean values for config file writing
|
||||
SD="$SD_VALUE"
|
||||
NS="$NS_VALUE"
|
||||
MAC="$MAC_VALUE"
|
||||
VLAN="$VLAN_VALUE"
|
||||
|
||||
write_config
|
||||
|
||||
# Restore original formatted values for container creation
|
||||
SD="$SD_ORIG"
|
||||
NS="$NS_ORIG"
|
||||
MAC="$MAC_ORIG"
|
||||
VLAN="$VLAN_ORIG"
|
||||
else
|
||||
clear
|
||||
header_info
|
||||
echo -e "${ADVANCED}${BOLD}${RD}Using Advanced Settings on node $PVEHOST_NAME${CL}"
|
||||
advanced_settings
|
||||
fi
|
||||
}
|
||||
|
||||
diagnostics_check() {
|
||||
if ! [ -d "/usr/local/community-scripts" ]; then
|
||||
mkdir -p /usr/local/community-scripts
|
||||
fi
|
||||
|
||||
if ! [ -f "/usr/local/community-scripts/diagnostics" ]; then
|
||||
if (whiptail --backtitle "Proxmox VE Helper Scripts" --title "DIAGNOSTICS" --yesno "Send Diagnostics of LXC Installation?\n\n(This only transmits data without user data, just RAM, CPU, LXC name, ...)" 10 58); then
|
||||
cat <<EOF >/usr/local/community-scripts/diagnostics
|
||||
DIAGNOSTICS=yes
|
||||
|
||||
#This file is used to store the diagnostics settings for the Community-Scripts API.
|
||||
#https://github.com/community-scripts/ProxmoxVE/discussions/1836
|
||||
#Your diagnostics will be sent to the Community-Scripts API for troubleshooting/statistical purposes.
|
||||
#You can review the data at https://community-scripts.github.io/ProxmoxVE/data
|
||||
#If you do not wish to send diagnostics, please set the variable 'DIAGNOSTICS' to "no" in /usr/local/community-scripts/diagnostics, or use the menue.
|
||||
#This will disable the diagnostics feature.
|
||||
#To send diagnostics, set the variable 'DIAGNOSTICS' to "yes" in /usr/local/community-scripts/diagnostics, or use the menue.
|
||||
#This will enable the diagnostics feature.
|
||||
#The following information will be sent:
|
||||
#"ct_type"
|
||||
#"disk_size"
|
||||
#"core_count"
|
||||
#"ram_size"
|
||||
#"os_type"
|
||||
#"os_version"
|
||||
#"nsapp"
|
||||
#"method"
|
||||
#"pve_version"
|
||||
#"status"
|
||||
#If you have any concerns, please review the source code at /misc/build.func
|
||||
EOF
|
||||
DIAGNOSTICS="yes"
|
||||
else
|
||||
cat <<EOF >/usr/local/community-scripts/diagnostics
|
||||
DIAGNOSTICS=no
|
||||
|
||||
#This file is used to store the diagnostics settings for the Community-Scripts API.
|
||||
#https://github.com/community-scripts/ProxmoxVE/discussions/1836
|
||||
#Your diagnostics will be sent to the Community-Scripts API for troubleshooting/statistical purposes.
|
||||
#You can review the data at https://community-scripts.github.io/ProxmoxVE/data
|
||||
#If you do not wish to send diagnostics, please set the variable 'DIAGNOSTICS' to "no" in /usr/local/community-scripts/diagnostics, or use the menue.
|
||||
#This will disable the diagnostics feature.
|
||||
#To send diagnostics, set the variable 'DIAGNOSTICS' to "yes" in /usr/local/community-scripts/diagnostics, or use the menue.
|
||||
#This will enable the diagnostics feature.
|
||||
#The following information will be sent:
|
||||
#"ct_type"
|
||||
#"disk_size"
|
||||
#"core_count"
|
||||
#"ram_size"
|
||||
#"os_type"
|
||||
#"os_version"
|
||||
#"nsapp"
|
||||
#"method"
|
||||
#"pve_version"
|
||||
#"status"
|
||||
#If you have any concerns, please review the source code at /misc/build.func
|
||||
EOF
|
||||
DIAGNOSTICS="no"
|
||||
fi
|
||||
else
|
||||
DIAGNOSTICS=$(awk -F '=' '/^DIAGNOSTICS/ {print $2}' /usr/local/community-scripts/diagnostics)
|
||||
|
||||
fi
|
||||
|
||||
}
|
||||
|
||||
install_script() {
|
||||
pve_check
|
||||
shell_check
|
||||
root_check
|
||||
arch_check
|
||||
#ssh_check
|
||||
maxkeys_check
|
||||
|
||||
|
||||
|
||||
diagnostics_check
|
||||
|
||||
if systemctl is-active -q ping-instances.service; then
|
||||
systemctl -q stop ping-instances.service
|
||||
fi
|
||||
NEXTID=$(pvesh get /cluster/nextid)
|
||||
timezone=$(cat /etc/timezone)
|
||||
#header_info
|
||||
echo "TEST"
|
||||
header_info
|
||||
while true; do
|
||||
|
||||
TMP_CHOICE=$(whiptail --backtitle "Proxmox VE Helper Scripts" \
|
||||
@@ -378,7 +929,9 @@ install_script() {
|
||||
"1" "Default Settings" \
|
||||
"2" "Default Settings (with verbose)" \
|
||||
"3" "Advanced Settings" \
|
||||
"4" "Exit" \
|
||||
"4" "Use Config File" \
|
||||
"5" "Diagnostic Settings" \
|
||||
"6" "Exit" \
|
||||
--default-item "1" 3>&1 1>&2 2>&3) || true
|
||||
|
||||
if [ -z "$TMP_CHOICE" ]; then
|
||||
@@ -416,6 +969,32 @@ install_script() {
|
||||
break
|
||||
;;
|
||||
4)
|
||||
header_info
|
||||
echo -e "${INFO}${HOLD} ${GN}Using Config File on node $PVEHOST_NAME${CL}"
|
||||
METHOD="config_file"
|
||||
source <(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/config-file.func)
|
||||
config_file
|
||||
break
|
||||
;;
|
||||
5)
|
||||
if [[ $DIAGNOSTICS == "yes" ]]; then
|
||||
if whiptail --backtitle "Proxmox VE Helper Scripts" --title "DIAGNOSTICS SETTINGS" --yesno "Send Diagnostics of LXC Installation?\n\nCurrent setting: ${DIAGNOSTICS}" 10 58 \
|
||||
--yes-button "No" --no-button "Back"; then
|
||||
DIAGNOSTICS="no"
|
||||
sed -i 's/^DIAGNOSTICS=.*/DIAGNOSTICS=no/' /usr/local/community-scripts/diagnostics
|
||||
whiptail --backtitle "Proxmox VE Helper Scripts" --title "DIAGNOSTICS SETTINGS" --msgbox "Diagnostics settings changed to ${DIAGNOSTICS}." 8 58
|
||||
fi
|
||||
else
|
||||
if whiptail --backtitle "Proxmox VE Helper Scripts" --title "DIAGNOSTICS SETTINGS" --yesno "Send Diagnostics of LXC Installation?\n\nCurrent setting: ${DIAGNOSTICS}" 10 58 \
|
||||
--yes-button "Yes" --no-button "Back"; then
|
||||
DIAGNOSTICS="yes"
|
||||
sed -i 's/^DIAGNOSTICS=.*/DIAGNOSTICS=yes/' /usr/local/community-scripts/diagnostics
|
||||
whiptail --backtitle "Proxmox VE Helper Scripts" --title "DIAGNOSTICS SETTINGS" --msgbox "Diagnostics settings changed to ${DIAGNOSTICS}." 8 58
|
||||
fi
|
||||
fi
|
||||
|
||||
;;
|
||||
6)
|
||||
echo -e "\n${CROSS}${RD}Script terminated. Have a great day!${CL}\n"
|
||||
exit 0
|
||||
;;
|
||||
@@ -464,9 +1043,10 @@ check_container_storage() {
|
||||
}
|
||||
|
||||
start() {
|
||||
source <(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/tools.func)
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/tools.func"
|
||||
if command -v pveversion >/dev/null 2>&1; then
|
||||
install_script
|
||||
echo "TEST!!!"
|
||||
else
|
||||
CHOICE=$(whiptail --backtitle "Proxmox VE Helper Scripts" --title "${APP} LXC Update/Setting" --menu \
|
||||
"Support/Update functions for ${APP} LXC. Choose an option:" \
|
||||
@@ -496,6 +1076,7 @@ start() {
|
||||
|
||||
# This function collects user settings and integrates all the collected information.
|
||||
build_container() {
|
||||
echo "TEST"
|
||||
# if [ "$VERBOSE" == "yes" ]; then set -x; fi
|
||||
|
||||
NET_STRING="-net0 name=eth0,bridge=$BRG$MAC,ip=$NET$GATE$VLAN$MTU"
|
||||
@@ -518,13 +1099,16 @@ build_container() {
|
||||
FEATURES="$FEATURES,fuse=1"
|
||||
fi
|
||||
|
||||
if [[ $DIAGNOSTICS == "yes" ]]; then
|
||||
echo "Diagnostics enabled (post_to_api function not available)"
|
||||
fi
|
||||
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
pushd "$TEMP_DIR" >/dev/null
|
||||
if [ "$var_os" == "alpine" ]; then
|
||||
export FUNCTIONS_FILE_PATH="$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/alpine-install.func)"
|
||||
else
|
||||
export FUNCTIONS_FILE_PATH="$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/install.func)"
|
||||
export FUNCTIONS_FILE_PATH="$(dirname "${BASH_SOURCE[0]}")/install.func"
|
||||
fi
|
||||
|
||||
export DIAGNOSTICS="$DIAGNOSTICS"
|
||||
@@ -559,7 +1143,7 @@ build_container() {
|
||||
$PW
|
||||
"
|
||||
# This executes create_lxc.sh and creates the container and .conf file
|
||||
bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/create_lxc.sh)" $?
|
||||
bash "$(dirname "${BASH_SOURCE[0]}")/create_lxc.sh" $?
|
||||
|
||||
LXC_CONFIG="/etc/pve/lxc/${CTID}.conf"
|
||||
|
||||
@@ -752,7 +1336,9 @@ EOF'
|
||||
fi
|
||||
msg_ok "Customized LXC Container"
|
||||
|
||||
lxc-attach -n "$CTID" -- bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/install/${var_install}.sh)"
|
||||
# Copy the install script into the container and execute it
|
||||
pct push "$CTID" "$(dirname "${BASH_SOURCE[0]}")/../install/${var_install}.sh" "/tmp/${var_install}.sh"
|
||||
lxc-attach -n "$CTID" -- bash "/tmp/${var_install}.sh"
|
||||
}
|
||||
|
||||
# This function sets the description of the container.
|
||||
@@ -797,5 +1383,6 @@ EOF
|
||||
if [[ -f /etc/systemd/system/ping-instances.service ]]; then
|
||||
systemctl start ping-instances.service
|
||||
fi
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -407,3 +407,6 @@ check_or_create_swap() {
|
||||
}
|
||||
|
||||
trap 'stop_spinner' EXIT INT TERM
|
||||
|
||||
# Initialize functions when core.func is sourced
|
||||
load_functions
|
||||
380
scripts/core/create_lxc.sh
Executable file
380
scripts/core/create_lxc.sh
Executable file
@@ -0,0 +1,380 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Copyright (c) 2021-2025 tteck
|
||||
# Author: tteck (tteckster)
|
||||
# Co-Author: MickLesk
|
||||
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
|
||||
|
||||
# This sets verbose mode if the global variable is set to "yes"
|
||||
# if [ "$VERBOSE" == "yes" ]; then set -x; fi
|
||||
|
||||
source "$(dirname "$0")/core.func"
|
||||
|
||||
|
||||
# This sets error handling options and defines the error_handler function to handle errors
|
||||
set -Eeuo pipefail
|
||||
trap 'error_handler $LINENO "$BASH_COMMAND"' ERR
|
||||
trap on_exit EXIT
|
||||
trap on_interrupt INT
|
||||
trap on_terminate TERM
|
||||
|
||||
function on_exit() {
|
||||
local exit_code="$?"
|
||||
[[ -n "${lockfile:-}" && -e "$lockfile" ]] && rm -f "$lockfile"
|
||||
exit "$exit_code"
|
||||
}
|
||||
|
||||
function error_handler() {
|
||||
local exit_code="$?"
|
||||
local line_number="$1"
|
||||
local command="$2"
|
||||
printf "\e[?25h"
|
||||
echo -e "\n${RD}[ERROR]${CL} in line ${RD}$line_number${CL}: exit code ${RD}$exit_code${CL}: while executing command ${YW}$command${CL}\n"
|
||||
exit "$exit_code"
|
||||
}
|
||||
|
||||
function on_interrupt() {
|
||||
echo -e "\n${RD}Interrupted by user (SIGINT)${CL}"
|
||||
exit 130
|
||||
}
|
||||
|
||||
function on_terminate() {
|
||||
echo -e "\n${RD}Terminated by signal (SIGTERM)${CL}"
|
||||
exit 143
|
||||
}
|
||||
|
||||
function exit_script() {
|
||||
clear
|
||||
printf "\e[?25h"
|
||||
echo -e "\n${CROSS}${RD}User exited script${CL}\n"
|
||||
kill 0
|
||||
exit 1
|
||||
}
|
||||
|
||||
function check_storage_support() {
|
||||
local CONTENT="$1"
|
||||
local -a VALID_STORAGES=()
|
||||
while IFS= read -r line; do
|
||||
local STORAGE_NAME
|
||||
STORAGE_NAME=$(awk '{print $1}' <<<"$line")
|
||||
[[ -z "$STORAGE_NAME" ]] && continue
|
||||
VALID_STORAGES+=("$STORAGE_NAME")
|
||||
done < <(pvesm status -content "$CONTENT" 2>/dev/null | awk 'NR>1')
|
||||
|
||||
[[ ${#VALID_STORAGES[@]} -gt 0 ]]
|
||||
}
|
||||
|
||||
# This function selects a storage pool for a given content type (e.g., rootdir, vztmpl).
|
||||
function select_storage() {
|
||||
local CLASS=$1 CONTENT CONTENT_LABEL
|
||||
|
||||
case $CLASS in
|
||||
container)
|
||||
CONTENT='rootdir'
|
||||
CONTENT_LABEL='Container'
|
||||
;;
|
||||
template)
|
||||
CONTENT='vztmpl'
|
||||
CONTENT_LABEL='Container template'
|
||||
;;
|
||||
iso)
|
||||
CONTENT='iso'
|
||||
CONTENT_LABEL='ISO image'
|
||||
;;
|
||||
images)
|
||||
CONTENT='images'
|
||||
CONTENT_LABEL='VM Disk image'
|
||||
;;
|
||||
backup)
|
||||
CONTENT='backup'
|
||||
CONTENT_LABEL='Backup'
|
||||
;;
|
||||
snippets)
|
||||
CONTENT='snippets'
|
||||
CONTENT_LABEL='Snippets'
|
||||
;;
|
||||
*)
|
||||
msg_error "Invalid storage class '$CLASS'"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Check for preset STORAGE variable
|
||||
if [ "$CONTENT" = "rootdir" ] && [ -n "${STORAGE:-}" ]; then
|
||||
if pvesm status -content "$CONTENT" | awk 'NR>1 {print $1}' | grep -qx "$STORAGE"; then
|
||||
STORAGE_RESULT="$STORAGE"
|
||||
msg_info "Using preset storage: $STORAGE_RESULT for $CONTENT_LABEL"
|
||||
return 0
|
||||
else
|
||||
msg_error "Preset storage '$STORAGE' is not valid for content type '$CONTENT'."
|
||||
return 2
|
||||
fi
|
||||
fi
|
||||
|
||||
local -A STORAGE_MAP
|
||||
local -a MENU
|
||||
local COL_WIDTH=0
|
||||
|
||||
while read -r TAG TYPE _ TOTAL USED FREE _; do
|
||||
[[ -n "$TAG" && -n "$TYPE" ]] || continue
|
||||
local STORAGE_NAME="$TAG"
|
||||
local DISPLAY="${STORAGE_NAME} (${TYPE})"
|
||||
local USED_FMT=$(numfmt --to=iec --from-unit=K --format %.1f <<<"$USED")
|
||||
local FREE_FMT=$(numfmt --to=iec --from-unit=K --format %.1f <<<"$FREE")
|
||||
local INFO="Free: ${FREE_FMT}B Used: ${USED_FMT}B"
|
||||
STORAGE_MAP["$DISPLAY"]="$STORAGE_NAME"
|
||||
MENU+=("$DISPLAY" "$INFO" "OFF")
|
||||
((${#DISPLAY} > COL_WIDTH)) && COL_WIDTH=${#DISPLAY}
|
||||
done < <(pvesm status -content "$CONTENT" | awk 'NR>1')
|
||||
|
||||
if [ ${#MENU[@]} -eq 0 ]; then
|
||||
msg_error "No storage found for content type '$CONTENT'."
|
||||
return 2
|
||||
fi
|
||||
|
||||
if [ $((${#MENU[@]} / 3)) -eq 1 ]; then
|
||||
STORAGE_RESULT="${STORAGE_MAP[${MENU[0]}]}"
|
||||
STORAGE_INFO="${MENU[1]}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local WIDTH=$((COL_WIDTH + 42))
|
||||
while true; do
|
||||
local DISPLAY_SELECTED
|
||||
DISPLAY_SELECTED=$(whiptail --backtitle "Proxmox VE Helper Scripts" \
|
||||
--title "Storage Pools" \
|
||||
--radiolist "Which storage pool for ${CONTENT_LABEL,,}?\n(Spacebar to select)" \
|
||||
16 "$WIDTH" 6 "${MENU[@]}" 3>&1 1>&2 2>&3)
|
||||
|
||||
# Cancel or ESC
|
||||
[[ $? -ne 0 ]] && exit_script
|
||||
|
||||
# Strip trailing whitespace or newline (important for storages like "storage (dir)")
|
||||
DISPLAY_SELECTED=$(sed 's/[[:space:]]*$//' <<<"$DISPLAY_SELECTED")
|
||||
|
||||
if [[ -z "$DISPLAY_SELECTED" || -z "${STORAGE_MAP[$DISPLAY_SELECTED]+_}" ]]; then
|
||||
whiptail --msgbox "No valid storage selected. Please try again." 8 58
|
||||
continue
|
||||
fi
|
||||
|
||||
STORAGE_RESULT="${STORAGE_MAP[$DISPLAY_SELECTED]}"
|
||||
for ((i = 0; i < ${#MENU[@]}; i += 3)); do
|
||||
if [[ "${MENU[$i]}" == "$DISPLAY_SELECTED" ]]; then
|
||||
STORAGE_INFO="${MENU[$i + 1]}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
return 0
|
||||
done
|
||||
}
|
||||
|
||||
# Test if required variables are set
|
||||
[[ "${CTID:-}" ]] || {
|
||||
msg_error "You need to set 'CTID' variable."
|
||||
exit 203
|
||||
}
|
||||
[[ "${PCT_OSTYPE:-}" ]] || {
|
||||
msg_error "You need to set 'PCT_OSTYPE' variable."
|
||||
exit 204
|
||||
}
|
||||
|
||||
# Test if ID is valid
|
||||
[ "$CTID" -ge "100" ] || {
|
||||
msg_error "ID cannot be less than 100."
|
||||
exit 205
|
||||
}
|
||||
|
||||
# Test if ID is in use
|
||||
if qm status "$CTID" &>/dev/null || pct status "$CTID" &>/dev/null; then
|
||||
echo -e "ID '$CTID' is already in use."
|
||||
unset CTID
|
||||
msg_error "Cannot use ID that is already in use."
|
||||
exit 206
|
||||
fi
|
||||
|
||||
# This checks for the presence of valid Container Storage and Template Storage locations
|
||||
msg_info "Validating storage"
|
||||
if ! check_storage_support "rootdir"; then
|
||||
msg_error "No valid storage found for 'rootdir' [Container]"
|
||||
exit 1
|
||||
fi
|
||||
if ! check_storage_support "vztmpl"; then
|
||||
msg_error "No valid storage found for 'vztmpl' [Template]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
#msg_info "Checking template storage"
|
||||
while true; do
|
||||
if select_storage template; then
|
||||
TEMPLATE_STORAGE="$STORAGE_RESULT"
|
||||
TEMPLATE_STORAGE_INFO="$STORAGE_INFO"
|
||||
msg_ok "Storage ${BL}$TEMPLATE_STORAGE${CL} ($TEMPLATE_STORAGE_INFO) [Template]"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
while true; do
|
||||
if select_storage container; then
|
||||
CONTAINER_STORAGE="$STORAGE_RESULT"
|
||||
CONTAINER_STORAGE_INFO="$STORAGE_INFO"
|
||||
msg_ok "Storage ${BL}$CONTAINER_STORAGE${CL} ($CONTAINER_STORAGE_INFO) [Container]"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# Check free space on selected container storage
|
||||
STORAGE_FREE=$(pvesm status | awk -v s="$CONTAINER_STORAGE" '$1 == s { print $6 }')
|
||||
REQUIRED_KB=$((${PCT_DISK_SIZE:-8} * 1024 * 1024))
|
||||
if [ "$STORAGE_FREE" -lt "$REQUIRED_KB" ]; then
|
||||
msg_error "Not enough space on '$CONTAINER_STORAGE'. Needed: ${PCT_DISK_SIZE:-8}G."
|
||||
exit 214
|
||||
fi
|
||||
|
||||
# Check Cluster Quorum if in Cluster
|
||||
if [ -f /etc/pve/corosync.conf ]; then
|
||||
msg_info "Checking cluster quorum"
|
||||
if ! pvecm status | awk -F':' '/^Quorate/ { exit ($2 ~ /Yes/) ? 0 : 1 }'; then
|
||||
|
||||
msg_error "Cluster is not quorate. Start all nodes or configure quorum device (QDevice)."
|
||||
exit 210
|
||||
fi
|
||||
msg_ok "Cluster is quorate"
|
||||
fi
|
||||
|
||||
# Update LXC template list
|
||||
TEMPLATE_SEARCH="${PCT_OSTYPE}-${PCT_OSVERSION:-}"
|
||||
case "$PCT_OSTYPE" in
|
||||
debian | ubuntu)
|
||||
TEMPLATE_PATTERN="-standard_"
|
||||
;;
|
||||
alpine | fedora | rocky | centos)
|
||||
TEMPLATE_PATTERN="-default_"
|
||||
;;
|
||||
*)
|
||||
TEMPLATE_PATTERN=""
|
||||
;;
|
||||
esac
|
||||
|
||||
# 1. Check local templates first
|
||||
msg_info "Searching for template '$TEMPLATE_SEARCH'"
|
||||
mapfile -t TEMPLATES < <(
|
||||
pveam list "$TEMPLATE_STORAGE" |
|
||||
awk -v s="$TEMPLATE_SEARCH" -v p="$TEMPLATE_PATTERN" '$1 ~ s && $1 ~ p {print $1}' |
|
||||
sed 's/.*\///' | sort -t - -k 2 -V
|
||||
)
|
||||
|
||||
if [ ${#TEMPLATES[@]} -gt 0 ]; then
|
||||
TEMPLATE_SOURCE="local"
|
||||
else
|
||||
msg_info "No local template found, checking online repository"
|
||||
pveam update >/dev/null 2>&1
|
||||
mapfile -t TEMPLATES < <(
|
||||
pveam update >/dev/null 2>&1 &&
|
||||
pveam available -section system |
|
||||
sed -n "s/.*\($TEMPLATE_SEARCH.*$TEMPLATE_PATTERN.*\)/\1/p" |
|
||||
sort -t - -k 2 -V
|
||||
)
|
||||
TEMPLATE_SOURCE="online"
|
||||
fi
|
||||
|
||||
TEMPLATE="${TEMPLATES[-1]}"
|
||||
TEMPLATE_PATH="$(pvesm path $TEMPLATE_STORAGE:vztmpl/$TEMPLATE 2>/dev/null ||
|
||||
echo "/var/lib/vz/template/cache/$TEMPLATE")"
|
||||
msg_ok "Template ${BL}$TEMPLATE${CL} [$TEMPLATE_SOURCE]"
|
||||
|
||||
# 4. Validate template (exists & not corrupted)
|
||||
TEMPLATE_VALID=1
|
||||
|
||||
if [ ! -s "$TEMPLATE_PATH" ]; then
|
||||
TEMPLATE_VALID=0
|
||||
elif ! tar --use-compress-program=zstdcat -tf "$TEMPLATE_PATH" >/dev/null 2>&1; then
|
||||
TEMPLATE_VALID=0
|
||||
fi
|
||||
|
||||
if [ "$TEMPLATE_VALID" -eq 0 ]; then
|
||||
msg_warn "Template $TEMPLATE is missing or corrupted. Re-downloading."
|
||||
[[ -f "$TEMPLATE_PATH" ]] && rm -f "$TEMPLATE_PATH"
|
||||
for attempt in {1..3}; do
|
||||
msg_info "Attempt $attempt: Downloading LXC template..."
|
||||
if pveam download "$TEMPLATE_STORAGE" "$TEMPLATE" >/dev/null 2>&1; then
|
||||
msg_ok "Template download successful."
|
||||
break
|
||||
fi
|
||||
if [ $attempt -eq 3 ]; then
|
||||
msg_error "Failed after 3 attempts. Please check network access or manually run:\n pveam download $TEMPLATE_STORAGE $TEMPLATE"
|
||||
exit 208
|
||||
fi
|
||||
sleep $((attempt * 5))
|
||||
done
|
||||
fi
|
||||
|
||||
msg_info "Creating LXC Container"
|
||||
# Check and fix subuid/subgid
|
||||
grep -q "root:100000:65536" /etc/subuid || echo "root:100000:65536" >>/etc/subuid
|
||||
grep -q "root:100000:65536" /etc/subgid || echo "root:100000:65536" >>/etc/subgid
|
||||
|
||||
# Combine all options
|
||||
PCT_OPTIONS=(${PCT_OPTIONS[@]:-${DEFAULT_PCT_OPTIONS[@]}})
|
||||
[[ " ${PCT_OPTIONS[@]} " =~ " -rootfs " ]] || PCT_OPTIONS+=(-rootfs "$CONTAINER_STORAGE:${PCT_DISK_SIZE:-8}")
|
||||
|
||||
# Secure creation of the LXC container with lock and template check
|
||||
lockfile="/tmp/template.${TEMPLATE}.lock"
|
||||
exec 9>"$lockfile" || {
|
||||
msg_error "Failed to create lock file '$lockfile'."
|
||||
exit 200
|
||||
}
|
||||
flock -w 60 9 || {
|
||||
msg_error "Timeout while waiting for template lock"
|
||||
exit 211
|
||||
}
|
||||
|
||||
if ! pct create "$CTID" "${TEMPLATE_STORAGE}:vztmpl/${TEMPLATE}" "${PCT_OPTIONS[@]}" &>/dev/null; then
|
||||
msg_error "Container creation failed. Checking if template is corrupted or incomplete."
|
||||
|
||||
if [[ ! -s "$TEMPLATE_PATH" || "$(stat -c%s "$TEMPLATE_PATH")" -lt 1000000 ]]; then
|
||||
msg_error "Template file too small or missing – re-downloading."
|
||||
rm -f "$TEMPLATE_PATH"
|
||||
elif ! zstdcat "$TEMPLATE_PATH" | tar -tf - &>/dev/null; then
|
||||
msg_error "Template appears to be corrupted – re-downloading."
|
||||
rm -f "$TEMPLATE_PATH"
|
||||
else
|
||||
msg_error "Template is valid, but container creation still failed."
|
||||
exit 209
|
||||
fi
|
||||
|
||||
# Retry download
|
||||
for attempt in {1..3}; do
|
||||
msg_info "Attempt $attempt: Re-downloading template..."
|
||||
if timeout 120 pveam download "$TEMPLATE_STORAGE" "$TEMPLATE" >/dev/null; then
|
||||
msg_ok "Template re-download successful."
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq 3 ]; then
|
||||
msg_error "Three failed attempts. Aborting."
|
||||
exit 208
|
||||
fi
|
||||
sleep $((attempt * 5))
|
||||
done
|
||||
|
||||
sleep 1 # I/O-Sync-Delay
|
||||
msg_ok "Re-downloaded LXC Template"
|
||||
fi
|
||||
|
||||
if ! pct list | awk '{print $1}' | grep -qx "$CTID"; then
|
||||
msg_error "Container ID $CTID not listed in 'pct list' – unexpected failure."
|
||||
exit 215
|
||||
fi
|
||||
|
||||
if ! grep -q '^rootfs:' "/etc/pve/lxc/$CTID.conf"; then
|
||||
msg_error "RootFS entry missing in container config – storage not correctly assigned."
|
||||
exit 216
|
||||
fi
|
||||
|
||||
if grep -q '^hostname:' "/etc/pve/lxc/$CTID.conf"; then
|
||||
CT_HOSTNAME=$(grep '^hostname:' "/etc/pve/lxc/$CTID.conf" | awk '{print $2}')
|
||||
if [[ ! "$CT_HOSTNAME" =~ ^[a-z0-9-]+$ ]]; then
|
||||
msg_warn "Hostname '$CT_HOSTNAME' contains invalid characters – may cause issues with networking or DNS."
|
||||
fi
|
||||
fi
|
||||
|
||||
msg_ok "LXC Container ${BL}$CTID${CL} ${GN}was successfully created."
|
||||
2047
scripts/core/tools.func
Normal file
2047
scripts/core/tools.func
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,81 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
SCRIPT_DIR="$(dirname "$0")"
|
||||
source "$SCRIPT_DIR/../core/build.func"
|
||||
# Copyright (c) 2021-2025 community-scripts ORG
|
||||
# Author: jkrgr0
|
||||
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
|
||||
# Source: https://docs.2fauth.app/
|
||||
|
||||
APP="2FAuth"
|
||||
var_tags="${var_tags:-2fa;authenticator}"
|
||||
var_cpu="${var_cpu:-1}"
|
||||
var_ram="${var_ram:-512}"
|
||||
var_disk="${var_disk:-2}"
|
||||
var_os="${var_os:-debian}"
|
||||
var_version="${var_version:-12}"
|
||||
var_unprivileged="${var_unprivileged:-1}"
|
||||
|
||||
header_info "$APP"
|
||||
variables
|
||||
color
|
||||
catch_errors
|
||||
|
||||
function update_script() {
|
||||
header_info
|
||||
check_container_storage
|
||||
check_container_resources
|
||||
|
||||
if [[ ! -d "/opt/2fauth" ]]; then
|
||||
msg_error "No ${APP} Installation Found!"
|
||||
exit
|
||||
fi
|
||||
if check_for_gh_release "2fauth" "Bubka/2FAuth"; then
|
||||
$STD apt-get update
|
||||
$STD apt-get -y upgrade
|
||||
|
||||
msg_info "Creating Backup"
|
||||
mv "/opt/2fauth" "/opt/2fauth-backup"
|
||||
if ! dpkg -l | grep -q 'php8.3'; then
|
||||
cp /etc/nginx/conf.d/2fauth.conf /etc/nginx/conf.d/2fauth.conf.bak
|
||||
fi
|
||||
msg_ok "Backup Created"
|
||||
|
||||
if ! dpkg -l | grep -q 'php8.3'; then
|
||||
$STD apt-get install -y \
|
||||
lsb-release \
|
||||
gnupg2
|
||||
PHP_VERSION="8.3" PHP_MODULE="common,ctype,fileinfo,mysql,cli" PHP_FPM="YES" setup_php
|
||||
sed -i 's/php8.2/php8.3/g' /etc/nginx/conf.d/2fauth.conf
|
||||
fi
|
||||
fetch_and_deploy_gh_release "2fauth" "Bubka/2FAuth"
|
||||
setup_composer
|
||||
mv "/opt/2fauth-backup/.env" "/opt/2fauth/.env"
|
||||
mv "/opt/2fauth-backup/storage" "/opt/2fauth/storage"
|
||||
cd "/opt/2fauth" || return
|
||||
chown -R www-data: "/opt/2fauth"
|
||||
chmod -R 755 "/opt/2fauth"
|
||||
export COMPOSER_ALLOW_SUPERUSER=1
|
||||
$STD composer install --no-dev --prefer-source
|
||||
php artisan 2fauth:install
|
||||
$STD systemctl restart nginx
|
||||
|
||||
msg_info "Cleaning Up"
|
||||
if dpkg -l | grep -q 'php8.2'; then
|
||||
$STD apt-get remove --purge -y php8.2*
|
||||
fi
|
||||
$STD apt-get -y autoremove
|
||||
$STD apt-get -y autoclean
|
||||
msg_ok "Cleanup Completed"
|
||||
msg_ok "Updated Successfully"
|
||||
fi
|
||||
exit
|
||||
}
|
||||
|
||||
start
|
||||
build_container
|
||||
description
|
||||
|
||||
msg_ok "Completed Successfully!\n"
|
||||
echo -e "${CREATING}${GN}${APP} setup has been successfully initialized!${CL}"
|
||||
echo -e "${INFO}${YW} Access it using the following URL:${CL}"
|
||||
echo -e "${TAB}${GATEWAY}${BGN}http://${IP}:80${CL}"
|
||||
@@ -1,68 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
SCRIPT_DIR="$(dirname "$0")"
|
||||
source "$SCRIPT_DIR/../core/build.func"
|
||||
# Copyright (c) 2021-2025 community-scripts ORG
|
||||
# Author: MickLesk (CanbiZ)
|
||||
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
|
||||
# Source: https://actualbudget.org/
|
||||
|
||||
APP="Actual Budget"
|
||||
var_tags="${var_tags:-finance}"
|
||||
var_cpu="${var_cpu:-2}"
|
||||
var_ram="${var_ram:-2048}"
|
||||
var_disk="${var_disk:-4}"
|
||||
var_os="${var_os:-debian}"
|
||||
var_version="${var_version:-12}"
|
||||
var_unprivileged="${var_unprivileged:-1}"
|
||||
|
||||
header_info "$APP"
|
||||
variables
|
||||
color
|
||||
catch_errors
|
||||
|
||||
function update_script() {
|
||||
header_info
|
||||
check_container_storage
|
||||
check_container_resources
|
||||
|
||||
if [[ ! -f /opt/actualbudget_version.txt ]]; then
|
||||
msg_error "No ${APP} Installation Found!"
|
||||
exit
|
||||
fi
|
||||
NODE_VERSION="22"
|
||||
setup_nodejs
|
||||
RELEASE=$(curl -fsSL https://api.github.com/repos/actualbudget/actual/releases/latest | grep "tag_name" | awk '{print substr($2, 3, length($2)-4) }')
|
||||
if [[ -f /opt/actualbudget-data/config.json ]]; then
|
||||
if [[ ! -f /opt/actualbudget_version.txt ]] || [[ "${RELEASE}" != "$(cat /opt/actualbudget_version.txt)" ]]; then
|
||||
msg_info "Stopping ${APP}"
|
||||
systemctl stop actualbudget
|
||||
msg_ok "${APP} Stopped"
|
||||
|
||||
msg_info "Updating ${APP} to ${RELEASE}"
|
||||
$STD npm update -g @actual-app/sync-server
|
||||
echo "${RELEASE}" >/opt/actualbudget_version.txt
|
||||
msg_ok "Updated ${APP} to ${RELEASE}"
|
||||
|
||||
msg_info "Starting ${APP}"
|
||||
systemctl start actualbudget
|
||||
msg_ok "Restarted ${APP}"
|
||||
else
|
||||
msg_info "${APP} is already up to date"
|
||||
fi
|
||||
else
|
||||
msg_info "Old Installation Found, you need to migrate your data and recreate to a new container"
|
||||
msg_info "Please follow the instructions on the ${APP} website to migrate your data"
|
||||
msg_info "https://actualbudget.org/docs/backup-restore/backup"
|
||||
exit 1
|
||||
fi
|
||||
exit
|
||||
}
|
||||
|
||||
start
|
||||
build_container
|
||||
description
|
||||
|
||||
msg_ok "Completed Successfully!\n"
|
||||
echo -e "${CREATING}${GN}${APP} setup has been successfully initialized!${CL}"
|
||||
echo -e "${INFO}${YW} Access it using the following URL:${CL}"
|
||||
echo -e "${TAB}${GATEWAY}${BGN}https://${IP}:5006${CL}"
|
||||
@@ -1,42 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
SCRIPT_DIR="$(dirname "$0")"
|
||||
source "$SCRIPT_DIR/../core/build.func"
|
||||
# Copyright (c) 2021-2025 tteck
|
||||
# Author: tteck (tteckster)
|
||||
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
|
||||
# Source: https://adguard.com/
|
||||
|
||||
APP="Adguard"
|
||||
var_tags="${var_tags:-adblock}"
|
||||
var_cpu="${var_cpu:-1}"
|
||||
var_ram="${var_ram:-512}"
|
||||
var_disk="${var_disk:-2}"
|
||||
var_os="${var_os:-debian}"
|
||||
var_version="${var_version:-12}"
|
||||
var_unprivileged="${var_unprivileged:-1}"
|
||||
|
||||
header_info "$APP"
|
||||
variables
|
||||
color
|
||||
catch_errors
|
||||
|
||||
function update_script() {
|
||||
header_info
|
||||
check_container_storage
|
||||
check_container_resources
|
||||
if [[ ! -d /opt/AdGuardHome ]]; then
|
||||
msg_error "No ${APP} Installation Found!"
|
||||
exit
|
||||
fi
|
||||
msg_error "Adguard Home should be updated via the user interface."
|
||||
exit
|
||||
}
|
||||
|
||||
start
|
||||
build_container
|
||||
description
|
||||
|
||||
msg_ok "Completed Successfully!\n"
|
||||
echo -e "${CREATING}${GN}${APP} setup has been successfully initialized!${CL}"
|
||||
echo -e "${INFO}${YW} Access it using the following URL:${CL}"
|
||||
echo -e "${TAB}${GATEWAY}${BGN}http://${IP}:3000${CL}"
|
||||
@@ -1,47 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
SCRIPT_DIR="$(dirname "$0")"
|
||||
source "$SCRIPT_DIR/../core/build.func"
|
||||
# Copyright (c) 2021-2025 community-scripts ORG
|
||||
# Author: MickLesk (CanbiZ)
|
||||
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
|
||||
# Source: https://adguardhome.com/
|
||||
|
||||
APP="Alpine-AdGuard"
|
||||
var_tags="${var_tags:-alpine;adblock}"
|
||||
var_cpu="${var_cpu:-1}"
|
||||
var_ram="${var_ram:-256}"
|
||||
var_disk="${var_disk:-1}"
|
||||
var_os="${var_os:-alpine}"
|
||||
var_version="${var_version:-3.22}"
|
||||
var_unprivileged="${var_unprivileged:-1}"
|
||||
|
||||
header_info "$APP"
|
||||
variables
|
||||
color
|
||||
catch_errors
|
||||
|
||||
function update_script() {
|
||||
header_info
|
||||
msg_info "Updating Alpine Packages"
|
||||
$STD apk -U upgrade
|
||||
msg_ok "Updated Alpine Packages"
|
||||
|
||||
msg_info "Updating AdGuard Home"
|
||||
$STD /opt/AdGuardHome/AdGuardHome --update
|
||||
msg_ok "Updated AdGuard Home"
|
||||
|
||||
msg_info "Restarting AdGuard Home"
|
||||
$STD rc-service adguardhome restart
|
||||
msg_ok "Restarted AdGuard Home"
|
||||
|
||||
exit 0
|
||||
}
|
||||
|
||||
start
|
||||
build_container
|
||||
description
|
||||
|
||||
msg_ok "Completed Successfully!\n"
|
||||
echo -e "${CREATING}${GN}${APP} setup has been successfully initialized!${CL}"
|
||||
echo -e "${INFO}${YW} Access it using the following URL:${CL}"
|
||||
echo -e "${TAB}${GATEWAY}${BGN}http://${IP}:3000${CL}"
|
||||
2
scripts/ct/debian.sh
Executable file → Normal file
2
scripts/ct/debian.sh
Executable file → Normal file
@@ -37,7 +37,7 @@ function update_script() {
|
||||
|
||||
start
|
||||
build_container
|
||||
description
|
||||
|
||||
|
||||
msg_ok "Completed Successfully!\n"
|
||||
echo -e "${CREATING}${GN}${APP} setup has been successfully initialized!${CL}"
|
||||
@@ -1,104 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Copyright (c) 2021-2025 community-scripts ORG
|
||||
# Author: jkrgr0
|
||||
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
|
||||
# Source: https://docs.2fauth.app/
|
||||
|
||||
source /dev/stdin <<<"$FUNCTIONS_FILE_PATH"
|
||||
color
|
||||
verb_ip6
|
||||
catch_errors
|
||||
setting_up_container
|
||||
network_check
|
||||
update_os
|
||||
|
||||
msg_info "Installing Dependencies"
|
||||
$STD apt-get install -y \
|
||||
lsb-release \
|
||||
nginx
|
||||
msg_ok "Installed Dependencies"
|
||||
|
||||
PHP_VERSION="8.3" PHP_MODULE="common,ctype,fileinfo,mysql,cli" PHP_FPM="YES" setup_php
|
||||
setup_composer
|
||||
setup_mariadb
|
||||
|
||||
msg_info "Setting up Database"
|
||||
DB_NAME=2fauth_db
|
||||
DB_USER=2fauth
|
||||
DB_PASS=$(openssl rand -base64 18 | tr -dc 'a-zA-Z0-9' | head -c13)
|
||||
$STD mariadb -u root -e "CREATE DATABASE $DB_NAME;"
|
||||
$STD mariadb -u root -e "CREATE USER '$DB_USER'@'localhost' IDENTIFIED BY '$DB_PASS';"
|
||||
$STD mariadb -u root -e "GRANT ALL ON $DB_NAME.* TO '$DB_USER'@'localhost'; FLUSH PRIVILEGES;"
|
||||
{
|
||||
echo "2FAuth Credentials"
|
||||
echo "Database User: $DB_USER"
|
||||
echo "Database Password: $DB_PASS"
|
||||
echo "Database Name: $DB_NAME"
|
||||
} >>~/2FAuth.creds
|
||||
msg_ok "Set up Database"
|
||||
|
||||
fetch_and_deploy_gh_release "2fauth" "Bubka/2FAuth"
|
||||
|
||||
msg_info "Setup 2FAuth"
|
||||
cd /opt/2fauth
|
||||
cp .env.example .env
|
||||
IPADDRESS=$(hostname -I | awk '{print $1}')
|
||||
sed -i -e "s|^APP_URL=.*|APP_URL=http://$IPADDRESS|" \
|
||||
-e "s|^DB_CONNECTION=$|DB_CONNECTION=mysql|" \
|
||||
-e "s|^DB_DATABASE=$|DB_DATABASE=$DB_NAME|" \
|
||||
-e "s|^DB_HOST=$|DB_HOST=127.0.0.1|" \
|
||||
-e "s|^DB_PORT=$|DB_PORT=3306|" \
|
||||
-e "s|^DB_USERNAME=$|DB_USERNAME=$DB_USER|" \
|
||||
-e "s|^DB_PASSWORD=$|DB_PASSWORD=$DB_PASS|" .env
|
||||
export COMPOSER_ALLOW_SUPERUSER=1
|
||||
$STD composer update --no-plugins --no-scripts
|
||||
$STD composer install --no-dev --prefer-source --no-plugins --no-scripts
|
||||
$STD php artisan key:generate --force
|
||||
$STD php artisan migrate:refresh
|
||||
$STD php artisan passport:install -q -n
|
||||
$STD php artisan storage:link
|
||||
$STD php artisan config:cache
|
||||
chown -R www-data: /opt/2fauth
|
||||
chmod -R 755 /opt/2fauth
|
||||
msg_ok "Setup 2fauth"
|
||||
|
||||
msg_info "Configure Service"
|
||||
cat <<EOF >/etc/nginx/conf.d/2fauth.conf
|
||||
server {
|
||||
listen 80;
|
||||
root /opt/2fauth/public;
|
||||
server_name $IPADDRESS;
|
||||
index index.php;
|
||||
charset utf-8;
|
||||
|
||||
location / {
|
||||
try_files \$uri \$uri/ /index.php?\$query_string;
|
||||
}
|
||||
|
||||
location = /favicon.ico { access_log off; log_not_found off; }
|
||||
location = /robots.txt { access_log off; log_not_found off; }
|
||||
|
||||
error_page 404 /index.php;
|
||||
|
||||
location ~ \.php\$ {
|
||||
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
|
||||
fastcgi_param SCRIPT_FILENAME \$realpath_root\$fastcgi_script_name;
|
||||
include fastcgi_params;
|
||||
}
|
||||
|
||||
location ~ /\.(?!well-known).* {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
systemctl reload nginx
|
||||
msg_ok "Configured Service"
|
||||
|
||||
motd_ssh
|
||||
customize
|
||||
|
||||
msg_info "Cleaning up"
|
||||
$STD apt-get -y autoremove
|
||||
$STD apt-get -y autoclean
|
||||
msg_ok "Cleaned"
|
||||
@@ -1,97 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Copyright (c) 2021-2025 community-scripts ORG
|
||||
# Author: MickLesk (CanbiZ)
|
||||
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
|
||||
# Source: https://actualbudget.org/
|
||||
|
||||
source /dev/stdin <<<"$FUNCTIONS_FILE_PATH"
|
||||
color
|
||||
verb_ip6
|
||||
catch_errors
|
||||
setting_up_container
|
||||
network_check
|
||||
update_os
|
||||
|
||||
msg_info "Installing Dependencies"
|
||||
$STD apt-get install -y \
|
||||
make \
|
||||
g++
|
||||
msg_ok "Installed Dependencies"
|
||||
|
||||
msg_info "Installing Actual Budget"
|
||||
cd /opt
|
||||
RELEASE=$(curl -fsSL https://api.github.com/repos/actualbudget/actual/releases/latest | grep "tag_name" | awk '{print substr($2, 3, length($2)-4) }')
|
||||
NODE_VERSION="22"
|
||||
setup_nodejs
|
||||
mkdir -p /opt/actualbudget-data/{server-files,upload,migrate,user-files,migrations,config}
|
||||
chown -R root:root /opt/actualbudget-data
|
||||
chmod -R 755 /opt/actualbudget-data
|
||||
|
||||
cat <<EOF >/opt/actualbudget-data/config.json
|
||||
{
|
||||
"port": 5006,
|
||||
"hostname": "::",
|
||||
"serverFiles": "/opt/actualbudget-data/server-files",
|
||||
"userFiles": "/opt/actualbudget-data/user-files",
|
||||
"trustedProxies": [
|
||||
"10.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
"127.0.0.0/8",
|
||||
"::1/128",
|
||||
"fc00::/7"
|
||||
],
|
||||
"https": {
|
||||
"key": "/opt/actualbudget/selfhost.key",
|
||||
"cert": "/opt/actualbudget/selfhost.crt"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
mkdir -p /opt/actualbudget
|
||||
cd /opt/actualbudget
|
||||
$STD npm install --location=global @actual-app/sync-server
|
||||
$STD openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout selfhost.key -out selfhost.crt <<EOF
|
||||
US
|
||||
California
|
||||
San Francisco
|
||||
My Organization
|
||||
My Unit
|
||||
localhost
|
||||
myemail@example.com
|
||||
EOF
|
||||
echo "${RELEASE}" >"/opt/actualbudget_version.txt"
|
||||
msg_ok "Installed Actual Budget"
|
||||
|
||||
msg_info "Creating Service"
|
||||
cat <<EOF >/etc/systemd/system/actualbudget.service
|
||||
[Unit]
|
||||
Description=Actual Budget Service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
WorkingDirectory=/opt/actualbudget
|
||||
Environment=ACTUAL_UPLOAD_FILE_SIZE_LIMIT_MB=20
|
||||
Environment=ACTUAL_UPLOAD_SYNC_ENCRYPTED_FILE_SYNC_SIZE_LIMIT_MB=50
|
||||
Environment=ACTUAL_UPLOAD_FILE_SYNC_SIZE_LIMIT_MB=20
|
||||
ExecStart=/usr/bin/actual-server --config /opt/actualbudget-data/config.json
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
systemctl enable -q --now actualbudget
|
||||
msg_ok "Created Service"
|
||||
|
||||
motd_ssh
|
||||
customize
|
||||
|
||||
msg_info "Cleaning up"
|
||||
$STD apt-get -y autoremove
|
||||
$STD apt-get -y autoclean
|
||||
msg_ok "Cleaned"
|
||||
@@ -1,50 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Copyright (c) 2021-2025 tteck
|
||||
# Author: tteck (tteckster)
|
||||
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
|
||||
# Source: https://adguard.com/
|
||||
|
||||
source /dev/stdin <<<"$FUNCTIONS_FILE_PATH"
|
||||
color
|
||||
verb_ip6
|
||||
catch_errors
|
||||
setting_up_container
|
||||
network_check
|
||||
update_os
|
||||
|
||||
msg_info "Installing AdGuard Home"
|
||||
$STD tar zxvf <(curl -fsSL https://static.adtidy.org/adguardhome/release/AdGuardHome_linux_amd64.tar.gz) -C /opt
|
||||
msg_ok "Installed AdGuard Home"
|
||||
|
||||
msg_info "Creating Service"
|
||||
cat <<EOF >/etc/systemd/system/AdGuardHome.service
|
||||
[Unit]
|
||||
Description=AdGuard Home: Network-level blocker
|
||||
ConditionFileIsExecutable=/opt/AdGuardHome/AdGuardHome
|
||||
After=syslog.target network-online.target
|
||||
|
||||
[Service]
|
||||
StartLimitInterval=5
|
||||
StartLimitBurst=10
|
||||
ExecStart=/opt/AdGuardHome/AdGuardHome "-s" "run"
|
||||
WorkingDirectory=/opt/AdGuardHome
|
||||
StandardOutput=file:/var/log/AdGuardHome.out
|
||||
StandardError=file:/var/log/AdGuardHome.err
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
EnvironmentFile=-/etc/sysconfig/AdGuardHome
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
systemctl enable -q --now AdGuardHome
|
||||
msg_ok "Created Service"
|
||||
|
||||
motd_ssh
|
||||
customize
|
||||
|
||||
msg_info "Cleaning up"
|
||||
$STD apt-get -y autoremove
|
||||
$STD apt-get -y autoclean
|
||||
msg_ok "Cleaned"
|
||||
@@ -66,15 +66,12 @@ class ScriptExecutionHandler {
|
||||
|
||||
async handleMessage(ws, message) {
|
||||
const { action, scriptPath, executionId, input } = message;
|
||||
console.log('Handling message:', { action, scriptPath, executionId });
|
||||
|
||||
switch (action) {
|
||||
case 'start':
|
||||
if (scriptPath && executionId) {
|
||||
console.log('Starting script execution for:', scriptPath);
|
||||
await this.startScriptExecution(ws, scriptPath, executionId);
|
||||
} else {
|
||||
console.log('Missing scriptPath or executionId');
|
||||
this.sendMessage(ws, {
|
||||
type: 'error',
|
||||
data: 'Missing scriptPath or executionId',
|
||||
|
||||
152
src/app/_components/DiffViewer.tsx
Normal file
152
src/app/_components/DiffViewer.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { api } from '~/trpc/react';
|
||||
|
||||
interface DiffViewerProps {
|
||||
scriptSlug: string;
|
||||
filePath: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function DiffViewer({ scriptSlug, filePath, isOpen, onClose }: DiffViewerProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Get diff content
|
||||
const { data: diffData, refetch } = api.scripts.getScriptDiff.useQuery(
|
||||
{ slug: scriptSlug, filePath },
|
||||
{ enabled: isOpen && !!scriptSlug && !!filePath }
|
||||
);
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setIsLoading(true);
|
||||
await refetch();
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const renderDiffLine = (line: string, index: number) => {
|
||||
const lineNumber = line.match(/^([+-]?\d+):/)?.[1];
|
||||
const content = line.replace(/^[+-]?\d+:\s*/, '');
|
||||
const isAdded = line.startsWith('+');
|
||||
const isRemoved = line.startsWith('-');
|
||||
const isContext = line.startsWith(' ');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex font-mono text-sm ${
|
||||
isAdded
|
||||
? 'bg-green-50 text-green-800 border-l-4 border-green-400'
|
||||
: isRemoved
|
||||
? 'bg-red-50 text-red-800 border-l-4 border-red-400'
|
||||
: 'bg-gray-50 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="w-16 text-right pr-2 text-gray-500 select-none">
|
||||
{lineNumber}
|
||||
</div>
|
||||
<div className="flex-1 pl-2">
|
||||
<span className={isAdded ? 'text-green-600' : isRemoved ? 'text-red-600' : ''}>
|
||||
{isAdded ? '+' : isRemoved ? '-' : ' '}
|
||||
</span>
|
||||
<span className="whitespace-pre-wrap">{content}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900">Script Diff</h2>
|
||||
<p className="text-sm text-gray-600">{filePath}</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoading}
|
||||
className="px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded hover:bg-blue-200 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Refreshing...' : 'Refresh'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="px-4 py-2 bg-gray-50 border-b border-gray-200">
|
||||
<div className="flex items-center space-x-4 text-sm">
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="w-3 h-3 bg-green-100 border border-green-300"></div>
|
||||
<span className="text-green-700">Added (Remote)</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="w-3 h-3 bg-red-100 border border-red-300"></div>
|
||||
<span className="text-red-700">Removed (Local)</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="w-3 h-3 bg-gray-100 border border-gray-300"></div>
|
||||
<span className="text-gray-700">Unchanged</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Diff Content */}
|
||||
<div className="overflow-y-auto max-h-[calc(90vh-120px)]">
|
||||
{diffData?.success ? (
|
||||
diffData.diff ? (
|
||||
<div className="divide-y divide-gray-200">
|
||||
{diffData.diff.split('\n').map((line, index) =>
|
||||
line.trim() ? renderDiffLine(line, index) : null
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
<svg className="w-12 h-12 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p>No differences found</p>
|
||||
<p className="text-sm">The local and remote files are identical</p>
|
||||
</div>
|
||||
)
|
||||
) : diffData?.error ? (
|
||||
<div className="p-8 text-center text-red-500">
|
||||
<svg className="w-12 h-12 mx-auto mb-4 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p>Error loading diff</p>
|
||||
<p className="text-sm">{diffData.error}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p>Loading diff...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { api } from '~/trpc/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function RepoStatus() {
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
const { data: repoStatus, isLoading: statusLoading, refetch: refetchStatus } = api.scripts.getRepoStatus.useQuery();
|
||||
const updateRepoMutation = api.scripts.updateRepo.useMutation({
|
||||
onSuccess: () => {
|
||||
setIsUpdating(false);
|
||||
refetchStatus();
|
||||
},
|
||||
onError: () => {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
});
|
||||
|
||||
const handleUpdate = async () => {
|
||||
setIsUpdating(true);
|
||||
updateRepoMutation.mutate();
|
||||
};
|
||||
|
||||
if (statusLoading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4 shadow-sm">
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="text-gray-600">Loading repository status...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!repoStatus) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4 shadow-sm">
|
||||
<div className="text-red-600">Failed to load repository status</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getStatusColor = () => {
|
||||
if (!repoStatus.isRepo) return 'text-gray-500';
|
||||
if (repoStatus.isBehind) return 'text-yellow-600';
|
||||
return 'text-green-600';
|
||||
};
|
||||
|
||||
const getStatusText = () => {
|
||||
if (!repoStatus.isRepo) return 'No repository found';
|
||||
if (repoStatus.isBehind) return 'Behind remote';
|
||||
return 'Up to date';
|
||||
};
|
||||
|
||||
const getStatusIcon = () => {
|
||||
if (!repoStatus.isRepo) return '❓';
|
||||
if (repoStatus.isBehind) return '⚠️';
|
||||
return '✅';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-2xl">{getStatusIcon()}</span>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-800">Repository Status</h3>
|
||||
<div className={`text-sm font-medium ${getStatusColor()}`}>
|
||||
{getStatusText()}
|
||||
</div>
|
||||
{repoStatus.isRepo && (
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
<p>Branch: {repoStatus.branch || 'Unknown'}</p>
|
||||
{repoStatus.lastCommit && (
|
||||
<p>Last commit: {repoStatus.lastCommit.substring(0, 8)}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => refetchStatus()}
|
||||
className="px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded hover:bg-blue-200 transition-colors"
|
||||
>
|
||||
🔄 Refresh
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleUpdate}
|
||||
disabled={isUpdating || !repoStatus.isRepo}
|
||||
className={`px-4 py-2 text-sm font-medium rounded transition-colors ${
|
||||
isUpdating || !repoStatus.isRepo
|
||||
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||
: 'bg-green-600 text-white hover:bg-green-700'
|
||||
}`}
|
||||
>
|
||||
{isUpdating ? '⏳ Updating...' : '⬇️ Update Repo'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{updateRepoMutation.isSuccess && (
|
||||
<div className="mt-3 p-2 bg-green-50 border border-green-200 rounded text-sm text-green-700">
|
||||
✅ {updateRepoMutation.data?.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updateRepoMutation.isError && (
|
||||
<div className="mt-3 p-2 bg-red-50 border border-red-200 rounded text-sm text-red-700">
|
||||
❌ {updateRepoMutation.error?.message || 'Failed to update repository'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,10 +17,10 @@ export function ScriptCard({ script, onClick }: ScriptCardProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer border border-gray-200 hover:border-blue-300"
|
||||
className="bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer border border-gray-200 hover:border-blue-300 h-full flex flex-col"
|
||||
onClick={() => onClick(script)}
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="p-6 flex-1 flex flex-col">
|
||||
{/* Header with logo and name */}
|
||||
<div className="flex items-start space-x-4 mb-4">
|
||||
<div className="flex-shrink-0">
|
||||
@@ -43,31 +43,48 @@ export function ScriptCard({ script, onClick }: ScriptCardProps) {
|
||||
<h3 className="text-lg font-semibold text-gray-900 truncate">
|
||||
{script.name || 'Unnamed Script'}
|
||||
</h3>
|
||||
<div className="flex items-center space-x-2 mt-1">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
script.type === 'ct'
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{script.type?.toUpperCase() || 'UNKNOWN'}
|
||||
</span>
|
||||
{script.updateable && (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
Updateable
|
||||
<div className="mt-2 space-y-2">
|
||||
{/* Type and Updateable status on first row */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded text-xs font-medium ${
|
||||
script.type === 'ct'
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: script.type === 'addon'
|
||||
? 'bg-purple-100 text-purple-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{script.type?.toUpperCase() || 'UNKNOWN'}
|
||||
</span>
|
||||
)}
|
||||
{script.updateable && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-amber-100 text-amber-800">
|
||||
Updateable
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Download Status */}
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
script.isDownloaded ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}></div>
|
||||
<span className={`text-xs font-medium ${
|
||||
script.isDownloaded ? 'text-green-700' : 'text-red-700'
|
||||
}`}>
|
||||
{script.isDownloaded ? 'Downloaded' : 'Not Downloaded'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-gray-600 text-sm line-clamp-3 mb-4">
|
||||
<p className="text-gray-600 text-sm line-clamp-3 mb-4 flex-1">
|
||||
{script.description || 'No description available'}
|
||||
</p>
|
||||
|
||||
{/* Footer with website link */}
|
||||
{script.website && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="mt-auto">
|
||||
<a
|
||||
href={script.website}
|
||||
target="_blank"
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { useState } from 'react';
|
||||
import { api } from '~/trpc/react';
|
||||
import type { Script } from '~/types/script';
|
||||
import { DiffViewer } from './DiffViewer';
|
||||
import { TextViewer } from './TextViewer';
|
||||
|
||||
interface ScriptDetailModalProps {
|
||||
script: Script | null;
|
||||
@@ -15,6 +17,9 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [loadMessage, setLoadMessage] = useState<string | null>(null);
|
||||
const [diffViewerOpen, setDiffViewerOpen] = useState(false);
|
||||
const [selectedDiffFile, setSelectedDiffFile] = useState<string | null>(null);
|
||||
const [textViewerOpen, setTextViewerOpen] = useState(false);
|
||||
|
||||
// Check if script files exist locally
|
||||
const { data: scriptFilesData, refetch: refetchScriptFiles } = api.scripts.checkScriptFiles.useQuery(
|
||||
@@ -22,6 +27,12 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
|
||||
{ enabled: !!script && isOpen }
|
||||
);
|
||||
|
||||
// Compare local and remote script content
|
||||
const { data: comparisonData, refetch: refetchComparison } = api.scripts.compareScriptContent.useQuery(
|
||||
{ slug: script?.slug ?? '' },
|
||||
{ enabled: !!script && isOpen && scriptFilesData?.success && (scriptFilesData.ctExists || scriptFilesData.installExists) }
|
||||
);
|
||||
|
||||
// Load script mutation
|
||||
const loadScriptMutation = api.scripts.loadScript.useMutation({
|
||||
onSuccess: (data) => {
|
||||
@@ -29,8 +40,9 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
|
||||
if (data.success) {
|
||||
const message = 'message' in data ? data.message : 'Script loaded successfully';
|
||||
setLoadMessage(`✅ ${message}`);
|
||||
// Refetch script files status to update the UI
|
||||
// Refetch script files status and comparison data to update the UI
|
||||
refetchScriptFiles();
|
||||
refetchComparison();
|
||||
} else {
|
||||
const error = 'error' in data ? data.error : 'Failed to load script';
|
||||
setLoadMessage(`❌ ${error}`);
|
||||
@@ -78,6 +90,15 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowDiff = (filePath: string) => {
|
||||
setSelectedDiffFile(filePath);
|
||||
setDiffViewerOpen(true);
|
||||
};
|
||||
|
||||
const handleViewScript = () => {
|
||||
setTextViewerOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
|
||||
@@ -138,30 +159,95 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Load Script Button */}
|
||||
<button
|
||||
onClick={handleLoadScript}
|
||||
disabled={isLoading}
|
||||
className={`flex items-center space-x-2 px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
isLoading
|
||||
? 'bg-gray-400 text-white cursor-not-allowed'
|
||||
: 'bg-green-600 text-white hover:bg-green-700'
|
||||
}`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
<span>Loading...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<span>Load Script</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{/* View Button - only show if script files exist */}
|
||||
{scriptFilesData?.success && (scriptFilesData.ctExists || scriptFilesData.installExists) && (
|
||||
<button
|
||||
onClick={handleViewScript}
|
||||
className="flex items-center space-x-2 px-4 py-2 rounded-lg font-medium transition-colors bg-purple-600 text-white hover:bg-purple-700"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
<span>View</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Load/Update Script Button */}
|
||||
{(() => {
|
||||
const hasLocalFiles = scriptFilesData?.success && (scriptFilesData.ctExists || scriptFilesData.installExists);
|
||||
const hasDifferences = comparisonData?.success && comparisonData.hasDifferences;
|
||||
const isUpToDate = hasLocalFiles && !hasDifferences;
|
||||
|
||||
if (!hasLocalFiles) {
|
||||
// No local files - show Load Script button
|
||||
return (
|
||||
<button
|
||||
onClick={handleLoadScript}
|
||||
disabled={isLoading}
|
||||
className={`flex items-center space-x-2 px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
isLoading
|
||||
? 'bg-gray-400 text-white cursor-not-allowed'
|
||||
: 'bg-green-600 text-white hover:bg-green-700'
|
||||
}`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
<span>Loading...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<span>Load Script</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
} else if (isUpToDate) {
|
||||
// Local files exist and are up to date - show disabled Update button
|
||||
return (
|
||||
<button
|
||||
disabled
|
||||
className="flex items-center space-x-2 px-4 py-2 rounded-lg font-medium transition-colors bg-gray-400 text-white cursor-not-allowed"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
<span>Up to Date</span>
|
||||
</button>
|
||||
);
|
||||
} else {
|
||||
// Local files exist but have differences - show Update button
|
||||
return (
|
||||
<button
|
||||
onClick={handleLoadScript}
|
||||
disabled={isLoading}
|
||||
className={`flex items-center space-x-2 px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
isLoading
|
||||
? 'bg-gray-400 text-white cursor-not-allowed'
|
||||
: 'bg-orange-600 text-white hover:bg-orange-700'
|
||||
}`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
<span>Updating...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
<span>Update Script</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
@@ -192,12 +278,40 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
|
||||
<div className={`w-2 h-2 rounded-full ${scriptFilesData.installExists ? 'bg-green-500' : 'bg-gray-300'}`}></div>
|
||||
<span>Install Script: {scriptFilesData.installExists ? 'Available' : 'Not loaded'}</span>
|
||||
</div>
|
||||
{scriptFilesData?.success && (scriptFilesData.ctExists || scriptFilesData.installExists) && comparisonData?.success && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className={`w-2 h-2 rounded-full ${comparisonData.hasDifferences ? 'bg-orange-500' : 'bg-green-500'}`}></div>
|
||||
<span>Status: {comparisonData.hasDifferences ? 'Update available' : 'Up to date'}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{scriptFilesData.files.length > 0 && (
|
||||
<div className="mt-2 text-xs text-gray-600">
|
||||
Files: {scriptFilesData.files.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
{scriptFilesData?.success && (scriptFilesData.ctExists || scriptFilesData.installExists) &&
|
||||
comparisonData?.success && comparisonData.hasDifferences && comparisonData.differences.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs text-orange-600 mb-2">
|
||||
Differences in: {comparisonData.differences.join(', ')}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{comparisonData.differences.map((filePath, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleShowDiff(filePath)}
|
||||
className="px-2 py-1 text-xs bg-orange-100 text-orange-700 rounded hover:bg-orange-200 transition-colors flex items-center space-x-1"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<span>Show Diff: {filePath}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -372,6 +486,28 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }:
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Diff Viewer Modal */}
|
||||
{selectedDiffFile && (
|
||||
<DiffViewer
|
||||
scriptSlug={script.slug}
|
||||
filePath={selectedDiffFile}
|
||||
isOpen={diffViewerOpen}
|
||||
onClose={() => {
|
||||
setDiffViewerOpen(false);
|
||||
setSelectedDiffFile(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Text Viewer Modal */}
|
||||
{script && (
|
||||
<TextViewer
|
||||
scriptName={script.install_methods?.find(method => method.script?.startsWith('ct/'))?.script?.split('/').pop() || `${script.slug}.sh`}
|
||||
isOpen={textViewerOpen}
|
||||
onClose={() => setTextViewerOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { api } from '~/trpc/react';
|
||||
import { ScriptCard } from './ScriptCard';
|
||||
import { ScriptDetailModal } from './ScriptDetailModal';
|
||||
import type { ScriptCard as ScriptCardType, Script } from '~/types/script';
|
||||
|
||||
|
||||
interface ScriptsGridProps {
|
||||
onInstallScript?: (scriptPath: string, scriptName: string) => void;
|
||||
@@ -13,22 +13,86 @@ interface ScriptsGridProps {
|
||||
export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
const [selectedSlug, setSelectedSlug] = useState<string | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const { data: scriptCardsData, isLoading, error, refetch } = api.scripts.getScriptCards.useQuery();
|
||||
const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCards.useQuery();
|
||||
const { data: localScriptsData, isLoading: localLoading, error: localError } = api.scripts.getCtScripts.useQuery();
|
||||
const { data: scriptData } = api.scripts.getScriptBySlug.useQuery(
|
||||
{ slug: selectedSlug ?? '' },
|
||||
{ enabled: !!selectedSlug }
|
||||
);
|
||||
|
||||
// Debug logging
|
||||
console.log('ScriptsGrid render:', {
|
||||
isLoading,
|
||||
error: error?.message,
|
||||
scriptCardsData: scriptCardsData?.success,
|
||||
cardsCount: scriptCardsData?.cards?.length
|
||||
});
|
||||
// Get GitHub scripts with download status
|
||||
const combinedScripts = React.useMemo(() => {
|
||||
const githubScripts = scriptCardsData?.success ? scriptCardsData.cards
|
||||
.filter(script => script && script.name) // Filter out invalid scripts
|
||||
.map(script => ({
|
||||
...script,
|
||||
source: 'github' as const,
|
||||
isDownloaded: false, // Will be updated by status check
|
||||
isUpToDate: false, // Will be updated by status check
|
||||
})) : [];
|
||||
|
||||
const handleCardClick = (scriptCard: ScriptCardType) => {
|
||||
return githubScripts;
|
||||
}, [scriptCardsData]);
|
||||
|
||||
|
||||
// Update scripts with download status
|
||||
const scriptsWithStatus = React.useMemo(() => {
|
||||
return combinedScripts.map(script => {
|
||||
if (!script || !script.name) {
|
||||
return script; // Return as-is if invalid
|
||||
}
|
||||
|
||||
// Check if there's a corresponding local script
|
||||
const hasLocalVersion = localScriptsData?.scripts?.some(local => {
|
||||
if (!local || !local.name) return false;
|
||||
const localName = local.name.replace(/\.sh$/, '');
|
||||
return localName.toLowerCase() === script.name.toLowerCase() ||
|
||||
localName.toLowerCase() === (script.slug || '').toLowerCase();
|
||||
}) ?? false;
|
||||
|
||||
return {
|
||||
...script,
|
||||
isDownloaded: hasLocalVersion,
|
||||
// Removed isUpToDate - only show in modal for detailed comparison
|
||||
};
|
||||
});
|
||||
}, [combinedScripts, localScriptsData]);
|
||||
|
||||
// Filter scripts based on search query (name and slug only)
|
||||
const filteredScripts = React.useMemo(() => {
|
||||
if (!searchQuery || !searchQuery.trim()) {
|
||||
return scriptsWithStatus;
|
||||
}
|
||||
|
||||
const query = searchQuery.toLowerCase().trim();
|
||||
|
||||
// If query is too short, don't filter
|
||||
if (query.length < 1) {
|
||||
return scriptsWithStatus;
|
||||
}
|
||||
|
||||
const filtered = scriptsWithStatus.filter(script => {
|
||||
// Ensure script exists and has required properties
|
||||
if (!script || typeof script !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const name = (script.name || '').toLowerCase();
|
||||
const slug = (script.slug || '').toLowerCase();
|
||||
|
||||
const matches = name.includes(query) || slug.includes(query);
|
||||
|
||||
return matches;
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [scriptsWithStatus, searchQuery]);
|
||||
|
||||
|
||||
const handleCardClick = (scriptCard: any) => {
|
||||
// All scripts are GitHub scripts, open modal
|
||||
setSelectedSlug(scriptCard.slug);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
@@ -38,7 +102,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
setSelectedSlug(null);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
if (githubLoading || localLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
@@ -47,7 +111,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !scriptCardsData?.success) {
|
||||
if (githubError || localError) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-red-600 mb-4">
|
||||
@@ -56,12 +120,8 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
</svg>
|
||||
<p className="text-lg font-medium">Failed to load scripts</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{scriptCardsData?.error || 'Unknown error occurred'}
|
||||
{githubError?.message || localError?.message || 'Unknown error occurred'}
|
||||
</p>
|
||||
<div className="mt-4 text-xs text-gray-400">
|
||||
<p>No JSON files found in scripts/json directory.</p>
|
||||
<p>Use the "Resync Scripts" button to download from GitHub.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
@@ -73,9 +133,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const scripts = scriptCardsData?.cards || [];
|
||||
|
||||
if (!scripts || scripts.length === 0) {
|
||||
if (!scriptsWithStatus || scriptsWithStatus.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-gray-500">
|
||||
@@ -84,7 +142,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
</svg>
|
||||
<p className="text-lg font-medium">No scripts found</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
No script files were found in the repository.
|
||||
No script files were found in the repository or local directory.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -93,11 +151,67 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{scripts.map((script, index) => {
|
||||
{/* Search Bar */}
|
||||
<div className="mb-8">
|
||||
<div className="relative max-w-md mx-auto">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg className="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search scripts by name..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-lg leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{searchQuery && (
|
||||
<div className="text-center mt-2 text-sm text-gray-600">
|
||||
{filteredScripts.length === 0 ? (
|
||||
<span>No scripts found matching "{searchQuery}"</span>
|
||||
) : (
|
||||
<span>Found {filteredScripts.length} script{filteredScripts.length !== 1 ? 's' : ''} matching "{searchQuery}"</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scripts Grid */}
|
||||
{filteredScripts.length === 0 && searchQuery ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-gray-500">
|
||||
<svg className="w-12 h-12 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<p className="text-lg font-medium">No matching scripts found</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Try adjusting your search terms or clear the search to see all scripts.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Clear Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{filteredScripts.map((script, index) => {
|
||||
// Add validation to ensure script has required properties
|
||||
if (!script || typeof script !== 'object') {
|
||||
console.warn('Invalid script object at index', index, script);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -109,7 +223,8 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ScriptDetailModal
|
||||
script={scriptData?.success ? scriptData.script : null}
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { api } from '~/trpc/react';
|
||||
import { useState } from 'react';
|
||||
import { Terminal } from './Terminal';
|
||||
import { TextViewer } from './TextViewer';
|
||||
|
||||
|
||||
interface ScriptsListProps {
|
||||
onRunScript: (scriptPath: string, scriptName: string) => void;
|
||||
}
|
||||
|
||||
export function ScriptsList({ onRunScript }: ScriptsListProps) {
|
||||
const { data, isLoading, error, refetch } = api.scripts.getCtScripts.useQuery();
|
||||
const [selectedScript, setSelectedScript] = useState<string | null>(null);
|
||||
const [viewerScript, setViewerScript] = useState<string | null>(null);
|
||||
const [isViewerOpen, setIsViewerOpen] = useState(false);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="text-lg text-gray-600">Loading scripts...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="text-lg text-red-600">
|
||||
Error loading scripts: {error.message}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data?.scripts || data.scripts.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="text-lg text-gray-600">No scripts found in the scripts directory</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const formatDate = (date: Date): string => {
|
||||
return new Date(date).toLocaleString();
|
||||
};
|
||||
|
||||
const getFileIcon = (extension: string): string => {
|
||||
switch (extension) {
|
||||
case '.sh':
|
||||
case '.bash':
|
||||
return '🐚';
|
||||
case '.py':
|
||||
return '🐍';
|
||||
case '.js':
|
||||
return '📜';
|
||||
case '.ts':
|
||||
return '🔷';
|
||||
default:
|
||||
return '📄';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-2">Available Scripts</h2>
|
||||
<p className="text-gray-600">
|
||||
Found {data.scripts.length} script(s) in {data.directoryInfo.path}
|
||||
</p>
|
||||
<div className="mt-2 text-sm text-gray-500">
|
||||
<p>Allowed extensions: {data.directoryInfo.allowedExtensions.join(', ')}</p>
|
||||
<p>Max execution time: {Math.round(data.directoryInfo.maxExecutionTime / 1000)}s</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{data.scripts.map((script) => (
|
||||
<div
|
||||
key={script.path}
|
||||
className="bg-white rounded-lg border border-gray-200 p-4 shadow-sm hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
{script.logo ? (
|
||||
<img
|
||||
src={script.logo}
|
||||
alt={`${script.name} logo`}
|
||||
className="w-8 h-8 rounded object-contain"
|
||||
onError={(e) => {
|
||||
// Fallback to file icon if logo fails to load
|
||||
e.currentTarget.style.display = 'none';
|
||||
const nextElement = e.currentTarget.nextElementSibling as HTMLElement;
|
||||
if (nextElement) {
|
||||
nextElement.style.display = 'block';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<span className="text-2xl" style={{ display: script.logo ? 'none' : 'block' }}>
|
||||
{getFileIcon(script.extension)}
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-800">{script.name}</h3>
|
||||
<div className="text-sm text-gray-500 space-y-1">
|
||||
<p>Size: {formatFileSize(script.size)}</p>
|
||||
<p>Modified: {formatDate(script.lastModified)}</p>
|
||||
<p>Extension: {script.extension}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setViewerScript(script.name);
|
||||
setIsViewerOpen(true);
|
||||
}}
|
||||
className="px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded hover:bg-blue-200 transition-colors"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onRunScript(`scripts/ct/${script.name}`, script.name)}
|
||||
className="px-4 py-2 text-sm font-medium rounded transition-colors bg-green-600 text-white hover:bg-green-700"
|
||||
>
|
||||
▶️ Run
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedScript && (
|
||||
<div className="mt-6">
|
||||
<Terminal
|
||||
scriptPath={`scripts/ct/${selectedScript.split('/').pop()}`}
|
||||
onClose={() => setSelectedScript(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TextViewer
|
||||
scriptName={viewerScript || ''}
|
||||
isOpen={isViewerOpen}
|
||||
onClose={() => {
|
||||
setIsViewerOpen(false);
|
||||
setViewerScript(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -112,13 +112,11 @@ export function Terminal({ scriptPath, onClose }: TerminalProps) {
|
||||
useEffect(() => {
|
||||
// Prevent multiple connections in React Strict Mode
|
||||
if (hasConnectedRef.current || isConnectingRef.current || (wsRef.current && wsRef.current.readyState === WebSocket.OPEN)) {
|
||||
console.log('WebSocket already connected, connecting, or has connected, skipping...');
|
||||
return;
|
||||
}
|
||||
|
||||
// Close any existing connection first
|
||||
if (wsRef.current) {
|
||||
console.log('Closing existing WebSocket connection');
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
@@ -132,14 +130,10 @@ export function Terminal({ scriptPath, onClose }: TerminalProps) {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws/script-execution`;
|
||||
|
||||
console.log('Connecting to WebSocket:', wsUrl);
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket connected successfully');
|
||||
console.log('Script path:', scriptPath);
|
||||
console.log('Execution ID:', executionId);
|
||||
setIsConnected(true);
|
||||
isConnectingRef.current = false;
|
||||
|
||||
@@ -154,7 +148,6 @@ export function Terminal({ scriptPath, onClose }: TerminalProps) {
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message: TerminalMessage = JSON.parse(event.data);
|
||||
console.log('Received message:', message);
|
||||
handleMessage(message);
|
||||
} catch (error) {
|
||||
console.error('Error parsing WebSocket message:', error);
|
||||
@@ -162,7 +155,6 @@ export function Terminal({ scriptPath, onClose }: TerminalProps) {
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
console.log('WebSocket disconnected:', event.code, event.reason);
|
||||
setIsConnected(false);
|
||||
setIsRunning(false);
|
||||
isConnectingRef.current = false;
|
||||
@@ -184,7 +176,6 @@ export function Terminal({ scriptPath, onClose }: TerminalProps) {
|
||||
isConnectingRef.current = false;
|
||||
hasConnectedRef.current = false;
|
||||
if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) {
|
||||
console.log('Cleaning up WebSocket connection');
|
||||
wsRef.current.close();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,15 +2,12 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ScriptsList } from './_components/ScriptsList';
|
||||
import { ScriptsGrid } from './_components/ScriptsGrid';
|
||||
import { RepoStatus } from './_components/RepoStatus';
|
||||
import { ResyncButton } from './_components/ResyncButton';
|
||||
import { Terminal } from './_components/Terminal';
|
||||
|
||||
export default function Home() {
|
||||
const [runningScript, setRunningScript] = useState<{ path: string; name: string } | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'local' | 'github'>('github');
|
||||
|
||||
const handleRunScript = (scriptPath: string, scriptName: string) => {
|
||||
setRunningScript({ path: scriptPath, name: scriptName });
|
||||
@@ -26,39 +23,18 @@ export default function Home() {
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl font-bold text-gray-800 mb-2">
|
||||
🚀 PVE Scripts Local Management
|
||||
🚀 PVE Scripts Management
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
Manage and execute Proxmox helper scripts locally with live output streaming
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Script Source Tabs */}
|
||||
{/* Resync Button */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex space-x-1 bg-gray-100 p-1 rounded-lg">
|
||||
<button
|
||||
onClick={() => setActiveTab('github')}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
activeTab === 'github'
|
||||
? 'bg-white text-gray-900 shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Script Library
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('local')}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
activeTab === 'local'
|
||||
? 'bg-white text-gray-900 shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Local Scripts
|
||||
</button>
|
||||
</div>
|
||||
{activeTab === 'github' && <ResyncButton />}
|
||||
<div></div>
|
||||
<ResyncButton />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -73,11 +49,7 @@ export default function Home() {
|
||||
)}
|
||||
|
||||
{/* Scripts List */}
|
||||
{activeTab === 'github' ? (
|
||||
<ScriptsGrid onInstallScript={handleRunScript} />
|
||||
) : (
|
||||
<ScriptsList onRunScript={handleRunScript} />
|
||||
)}
|
||||
<ScriptsGrid onInstallScript={handleRunScript} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -219,5 +219,65 @@ export const scriptsRouter = createTRPCRouter({
|
||||
files: []
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
// Compare local and remote script content
|
||||
compareScriptContent: publicProcedure
|
||||
.input(z.object({ slug: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
try {
|
||||
const script = await localScriptsService.getScriptBySlug(input.slug);
|
||||
if (!script) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Script not found',
|
||||
hasDifferences: false,
|
||||
differences: []
|
||||
};
|
||||
}
|
||||
|
||||
const result = await scriptDownloaderService.compareScriptContent(script);
|
||||
return {
|
||||
success: true,
|
||||
...result
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in compareScriptContent:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to compare script content',
|
||||
hasDifferences: false,
|
||||
differences: []
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
// Get diff content for a specific script file
|
||||
getScriptDiff: publicProcedure
|
||||
.input(z.object({ slug: z.string(), filePath: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
try {
|
||||
const script = await localScriptsService.getScriptBySlug(input.slug);
|
||||
if (!script) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Script not found',
|
||||
diff: null
|
||||
};
|
||||
}
|
||||
|
||||
const result = await scriptDownloaderService.getScriptDiff(script, input.filePath);
|
||||
return {
|
||||
success: true,
|
||||
...result
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in getScriptDiff:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get script diff',
|
||||
diff: null
|
||||
};
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
@@ -73,28 +73,6 @@ export const createCallerFactory = t.createCallerFactory;
|
||||
*/
|
||||
export const createTRPCRouter = t.router;
|
||||
|
||||
/**
|
||||
* Middleware for timing procedure execution and adding an artificial delay in development.
|
||||
*
|
||||
* You can remove this if you don't like it, but it can help catch unwanted waterfalls by simulating
|
||||
* network latency that would occur in production but not in local development.
|
||||
*/
|
||||
const timingMiddleware = t.middleware(async ({ next, path }) => {
|
||||
const start = Date.now();
|
||||
|
||||
if (t._config.isDev) {
|
||||
// artificial delay in dev
|
||||
const waitMs = Math.floor(Math.random() * 400) + 100;
|
||||
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
||||
}
|
||||
|
||||
const result = await next();
|
||||
|
||||
const end = Date.now();
|
||||
console.log(`[TRPC] ${path} took ${end - start}ms to execute`);
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
/**
|
||||
* Public (unauthenticated) procedure
|
||||
@@ -103,4 +81,4 @@ const timingMiddleware = t.middleware(async ({ next, path }) => {
|
||||
* guarantee that a user querying is authorized, but you can still access user session data if they
|
||||
* are logged in.
|
||||
*/
|
||||
export const publicProcedure = t.procedure.use(timingMiddleware);
|
||||
export const publicProcedure = t.procedure;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import { IncomingMessage } from 'http';
|
||||
import { WebSocketServer, type WebSocket } from 'ws';
|
||||
import type { IncomingMessage } from 'http';
|
||||
import { scriptManager } from '~/server/lib/scripts';
|
||||
import { env } from '~/env.js';
|
||||
|
||||
interface ScriptExecutionMessage {
|
||||
type: 'start' | 'output' | 'error' | 'end';
|
||||
@@ -22,8 +21,7 @@ export class ScriptExecutionHandler {
|
||||
this.wss.on('connection', this.handleConnection.bind(this));
|
||||
}
|
||||
|
||||
private handleConnection(ws: WebSocket, request: IncomingMessage) {
|
||||
console.log('New WebSocket connection for script execution');
|
||||
private handleConnection(ws: WebSocket, _request: IncomingMessage) {
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
@@ -40,7 +38,6 @@ export class ScriptExecutionHandler {
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('WebSocket connection closed');
|
||||
// Clean up any active executions for this connection
|
||||
this.cleanupActiveExecutions(ws);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { simpleGit, SimpleGit } from 'simple-git';
|
||||
import { simpleGit, type SimpleGit } from 'simple-git';
|
||||
import { env } from '~/env.js';
|
||||
import { join } from 'path';
|
||||
|
||||
@@ -77,14 +77,13 @@ export class GitManager {
|
||||
return { success: false, message: 'No repository URL configured' };
|
||||
}
|
||||
|
||||
console.log(`Cloning repository from ${env.REPO_URL}...`);
|
||||
|
||||
// Clone the repository
|
||||
await this.git.clone(env.REPO_URL, this.repoPath, {
|
||||
'--branch': env.REPO_BRANCH,
|
||||
'--single-branch': true,
|
||||
'--depth': 1
|
||||
});
|
||||
await this.git.clone(env.REPO_URL, this.repoPath, [
|
||||
'--branch', env.REPO_BRANCH,
|
||||
'--single-branch',
|
||||
'--depth', '1'
|
||||
]);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -105,32 +104,23 @@ export class GitManager {
|
||||
async initializeRepository(): Promise<void> {
|
||||
try {
|
||||
if (!env.REPO_URL) {
|
||||
console.log('No remote repository configured, skipping initialization');
|
||||
return;
|
||||
}
|
||||
|
||||
const isRepo = await this.git.checkIsRepo();
|
||||
if (!isRepo) {
|
||||
console.log('Repository not found, cloning...');
|
||||
const result = await this.cloneRepository();
|
||||
if (result.success) {
|
||||
console.log('Repository initialized successfully');
|
||||
} else {
|
||||
console.error('Failed to initialize repository:', result.message);
|
||||
}
|
||||
} else {
|
||||
console.log('Repository already exists, checking for updates...');
|
||||
const behind = await this.isBehindRemote();
|
||||
if (behind) {
|
||||
console.log('Repository is behind remote, pulling updates...');
|
||||
const result = await this.pullUpdates();
|
||||
if (result.success) {
|
||||
console.log('Repository updated successfully');
|
||||
} else {
|
||||
console.error('Failed to update repository:', result.message);
|
||||
}
|
||||
} else {
|
||||
console.log('Repository is up to date');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -160,8 +150,8 @@ export class GitManager {
|
||||
return {
|
||||
isRepo: true,
|
||||
isBehind,
|
||||
lastCommit: log.latest?.hash,
|
||||
branch: status.current
|
||||
lastCommit: log.latest?.hash || undefined,
|
||||
branch: status.current || undefined
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting repository status:', error);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { readdir, stat, access } from 'fs/promises';
|
||||
import { readdir, stat } from 'fs/promises';
|
||||
import { join, resolve, extname } from 'path';
|
||||
import { env } from '~/env.js';
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import { spawn, type ChildProcess } from 'child_process';
|
||||
import { localScriptsService } from '~/server/services/localScripts';
|
||||
|
||||
export interface ScriptInfo {
|
||||
@@ -98,7 +98,6 @@ export class ScriptManager {
|
||||
logo = scriptData?.logo || undefined;
|
||||
} catch (error) {
|
||||
// JSON file might not exist, that's okay
|
||||
console.log(`No JSON data found for ${slug}:`, error);
|
||||
}
|
||||
|
||||
scripts.push({
|
||||
@@ -226,7 +225,6 @@ export class ScriptManager {
|
||||
const timeout = setTimeout(() => {
|
||||
if (!childProcess.killed) {
|
||||
childProcess.kill('SIGTERM');
|
||||
console.log(`Script execution timed out after ${this.maxExecutionTime}ms`);
|
||||
}
|
||||
}, this.maxExecutionTime);
|
||||
|
||||
|
||||
@@ -94,7 +94,6 @@ export class LocalScriptsService {
|
||||
await writeFile(filePath, content, 'utf-8');
|
||||
}
|
||||
|
||||
console.log(`Successfully saved ${scripts.length} scripts to ${this.scriptsDirectory}`);
|
||||
} catch (error) {
|
||||
console.error('Error saving scripts from GitHub:', error);
|
||||
throw new Error('Failed to save scripts from GitHub');
|
||||
|
||||
@@ -93,7 +93,6 @@ export class ScriptDownloaderService {
|
||||
files.push(`install/${installScriptName}`);
|
||||
} catch (error) {
|
||||
// Install script might not exist, that's okay
|
||||
console.log(`Install script not found for ${script.slug}: ${error}`);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -153,6 +152,209 @@ export class ScriptDownloaderService {
|
||||
return { ctExists: false, installExists: false, files: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async compareScriptContent(script: Script): Promise<{ hasDifferences: boolean; differences: string[] }> {
|
||||
const differences: string[] = [];
|
||||
let hasDifferences = false;
|
||||
|
||||
try {
|
||||
// First check if any local files exist
|
||||
const localFilesExist = await this.checkScriptExists(script);
|
||||
if (!localFilesExist.ctExists && !localFilesExist.installExists) {
|
||||
// No local files exist, so no comparison needed
|
||||
return { hasDifferences: false, differences: [] };
|
||||
}
|
||||
|
||||
// Compare CT script only if it exists locally
|
||||
if (localFilesExist.ctExists && script.install_methods && script.install_methods.length > 0) {
|
||||
for (const method of script.install_methods) {
|
||||
if (method.script && method.script.startsWith('ct/')) {
|
||||
const fileName = method.script.split('/').pop();
|
||||
if (fileName) {
|
||||
const localPath = join(this.scriptsDirectory, 'ct', fileName);
|
||||
try {
|
||||
// Read local content
|
||||
const localContent = await readFile(localPath, 'utf-8');
|
||||
|
||||
// Download remote content
|
||||
const remoteContent = await this.downloadFileFromGitHub(method.script);
|
||||
|
||||
// Apply the same modification that would be applied during load
|
||||
const modifiedRemoteContent = this.modifyScriptContent(remoteContent);
|
||||
|
||||
// Compare content
|
||||
if (localContent !== modifiedRemoteContent) {
|
||||
hasDifferences = true;
|
||||
differences.push(`ct/${fileName}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error comparing CT script ${fileName}:`, error);
|
||||
// Don't add to differences if there's an error reading files
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compare install script only if it exists locally
|
||||
if (localFilesExist.installExists) {
|
||||
const installScriptName = `${script.slug}-install.sh`;
|
||||
const localInstallPath = join(this.scriptsDirectory, 'install', installScriptName);
|
||||
try {
|
||||
// Read local content
|
||||
const localContent = await readFile(localInstallPath, 'utf-8');
|
||||
|
||||
// Download remote content
|
||||
const remoteContent = await this.downloadFileFromGitHub(`install/${installScriptName}`);
|
||||
|
||||
// Apply the same modification that would be applied during load
|
||||
const modifiedRemoteContent = this.modifyScriptContent(remoteContent);
|
||||
|
||||
// Compare content
|
||||
if (localContent !== modifiedRemoteContent) {
|
||||
hasDifferences = true;
|
||||
differences.push(`install/${installScriptName}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error comparing install script ${installScriptName}:`, error);
|
||||
// Don't add to differences if there's an error reading files
|
||||
}
|
||||
}
|
||||
|
||||
return { hasDifferences, differences };
|
||||
} catch (error) {
|
||||
console.error('Error comparing script content:', error);
|
||||
return { hasDifferences: false, differences: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async getScriptDiff(script: Script, filePath: string): Promise<{ diff: string | null; localContent: string | null; remoteContent: string | null }> {
|
||||
try {
|
||||
let localContent: string | null = null;
|
||||
let remoteContent: string | null = null;
|
||||
|
||||
if (filePath.startsWith('ct/')) {
|
||||
// Handle CT script
|
||||
const fileName = filePath.split('/').pop();
|
||||
if (fileName) {
|
||||
const localPath = join(this.scriptsDirectory, 'ct', fileName);
|
||||
try {
|
||||
localContent = await readFile(localPath, 'utf-8');
|
||||
} catch (error) {
|
||||
console.error('Error reading local CT script:', error);
|
||||
}
|
||||
|
||||
try {
|
||||
// Find the corresponding script path in install_methods
|
||||
const method = script.install_methods?.find(m => m.script === filePath);
|
||||
if (method?.script) {
|
||||
const downloadedContent = await this.downloadFileFromGitHub(method.script);
|
||||
remoteContent = this.modifyScriptContent(downloadedContent);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error downloading remote CT script:', error);
|
||||
}
|
||||
}
|
||||
} else if (filePath.startsWith('install/')) {
|
||||
// Handle install script
|
||||
const localPath = join(this.scriptsDirectory, filePath);
|
||||
try {
|
||||
localContent = await readFile(localPath, 'utf-8');
|
||||
} catch (error) {
|
||||
console.error('Error reading local install script:', error);
|
||||
}
|
||||
|
||||
try {
|
||||
remoteContent = await this.downloadFileFromGitHub(filePath);
|
||||
} catch (error) {
|
||||
console.error('Error downloading remote install script:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!localContent || !remoteContent) {
|
||||
return { diff: null, localContent, remoteContent };
|
||||
}
|
||||
|
||||
// Generate diff using a simple line-by-line comparison
|
||||
const diff = this.generateDiff(localContent, remoteContent);
|
||||
return { diff, localContent, remoteContent };
|
||||
} catch (error) {
|
||||
console.error('Error getting script diff:', error);
|
||||
return { diff: null, localContent: null, remoteContent: null };
|
||||
}
|
||||
}
|
||||
|
||||
private generateDiff(localContent: string, remoteContent: string): string {
|
||||
const localLines = localContent.split('\n');
|
||||
const remoteLines = remoteContent.split('\n');
|
||||
|
||||
let diff = '';
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
|
||||
while (i < localLines.length || j < remoteLines.length) {
|
||||
const localLine = localLines[i];
|
||||
const remoteLine = remoteLines[j];
|
||||
|
||||
if (i >= localLines.length) {
|
||||
// Only remote lines left
|
||||
diff += `+${j + 1}: ${remoteLine}\n`;
|
||||
j++;
|
||||
} else if (j >= remoteLines.length) {
|
||||
// Only local lines left
|
||||
diff += `-${i + 1}: ${localLine}\n`;
|
||||
i++;
|
||||
} else if (localLine === remoteLine) {
|
||||
// Lines are the same
|
||||
diff += ` ${i + 1}: ${localLine}\n`;
|
||||
i++;
|
||||
j++;
|
||||
} else {
|
||||
// Lines are different - find the best match
|
||||
let found = false;
|
||||
for (let k = j + 1; k < Math.min(j + 10, remoteLines.length); k++) {
|
||||
if (localLine === remoteLines[k]) {
|
||||
// Found match in remote, local line was removed
|
||||
for (let l = j; l < k; l++) {
|
||||
diff += `+${l + 1}: ${remoteLines[l]}\n`;
|
||||
}
|
||||
diff += ` ${i + 1}: ${localLine}\n`;
|
||||
i++;
|
||||
j = k + 1;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
for (let k = i + 1; k < Math.min(i + 10, localLines.length); k++) {
|
||||
if (remoteLine === localLines[k]) {
|
||||
// Found match in local, remote line was added
|
||||
diff += `-${i + 1}: ${localLine}\n`;
|
||||
for (let l = i + 1; l < k; l++) {
|
||||
diff += `-${l + 1}: ${localLines[l]}\n`;
|
||||
}
|
||||
diff += `+${j + 1}: ${remoteLine}\n`;
|
||||
i = k + 1;
|
||||
j++;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
// No match found, lines are different
|
||||
diff += `-${i + 1}: ${localLine}\n`;
|
||||
diff += `+${j + 1}: ${remoteLine}\n`;
|
||||
i++;
|
||||
j++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return diff;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
|
||||
@@ -49,6 +49,9 @@ export interface ScriptCard {
|
||||
type: string;
|
||||
updateable: boolean;
|
||||
website: string | null;
|
||||
source?: 'github' | 'local';
|
||||
isDownloaded?: boolean;
|
||||
localPath?: string;
|
||||
}
|
||||
|
||||
export interface GitHubFile {
|
||||
|
||||
Reference in New Issue
Block a user