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:
Michel Roegl-Brunner
2025-09-10 16:26:29 +02:00
parent e941e212a8
commit 57293b9e59
32 changed files with 4062 additions and 966 deletions

View File

@@ -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
View File

@@ -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.

View File

@@ -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
}

View File

@@ -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
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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}"

View File

@@ -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}"

View File

@@ -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}"

View File

@@ -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
View 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}"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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',

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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"

View File

@@ -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>
);
}

View File

@@ -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}

View File

@@ -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>
);
}

View File

@@ -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();
}
};

View File

@@ -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>
);

View File

@@ -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
};
}
})
});

View File

@@ -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;

View File

@@ -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);
});

View File

@@ -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);

View File

@@ -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);

View File

@@ -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');

View File

@@ -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

View File

@@ -49,6 +49,9 @@ export interface ScriptCard {
type: string;
updateable: boolean;
website: string | null;
source?: 'github' | 'local';
isDownloaded?: boolean;
localPath?: string;
}
export interface GitHubFile {