Initial commit: DashCaddy v1.0

Full codebase including API server (32 modules + routes), dashboard frontend,
DashCA certificate distribution, installer script, and deployment skills.
This commit is contained in:
2026-03-05 02:26:12 -08:00
commit f61e85d9a7
337 changed files with 75282 additions and 0 deletions

View File

@@ -0,0 +1,260 @@
# DashCaddy Installer - Build Guide
## Overview
The DashCaddy Installer is a cross-platform Electron application that guides users through installing and configuring DashCaddy on their system.
## Prerequisites
- Node.js 18+ and npm
- Git
- Windows: No additional requirements
- macOS: Xcode Command Line Tools
- Linux: Standard build tools (gcc, make)
## Installation
```bash
# Clone the repository
cd dashcaddy-installer
# Install dependencies
npm install
```
## Development
### Run in Development Mode
```bash
# Start the installer with DevTools
npm run dev
# Or start normally
npm start
```
### Run Tests
```bash
# Run all tests
npm test
# Run tests in watch mode
npm run test:watch
```
## Building
### Build for Current Platform
```bash
npm run build
```
### Build for Specific Platforms
```bash
# Windows (creates portable .exe and installer)
npm run build:win
# macOS (creates .dmg)
npm run build:mac
# Linux (creates AppImage and .deb)
npm run build:linux
```
### Build Output
Built applications are placed in the `dist/` directory:
- **Windows**: `dist/win-unpacked/DashCaddy Installer.exe` (portable)
- **macOS**: `dist/DashCaddy Installer.dmg`
- **Linux**: `dist/DashCaddy Installer.AppImage` and `.deb`
## Project Structure
```
dashcaddy-installer/
├── src/
│ ├── main/ # Electron main process
│ │ ├── index.js # Main entry point & IPC handlers
│ │ ├── dependency-checker.js # Check/install Docker & Caddy
│ │ ├── config-manager.js # Configuration persistence
│ │ ├── file-deployer.js # Copy dashboard & API files
│ │ ├── caddyfile-generator.js # Generate Caddyfile
│ │ ├── browser-launcher.js # Open URLs in browser
│ │ └── service-manager.js # Start/stop services
│ ├── renderer/ # UI layer
│ │ ├── index.html # Main HTML
│ │ ├── wizard.js # Wizard logic & state
│ │ └── styles.css # Styling
│ ├── preload/ # IPC bridge
│ │ └── index.js # Secure IPC exposure
│ └── shared/ # Shared utilities
│ ├── constants.js # App constants
│ └── platform-utils.js # Platform detection
├── templates/ # Configuration templates
│ ├── Caddyfile.template # Caddyfile template
│ └── docker-compose.template.yml
├── assets/ # Images & icons
│ └── icon.png # App icon
└── package.json # Dependencies & build config
```
## Key Features
### 1. Platform Detection
Automatically detects Windows, macOS, or Linux and adjusts paths and commands accordingly.
### 2. Dependency Management
- Checks for Docker and Caddy installation
- Provides installation instructions/automation
- Validates versions
### 3. Guided Installation
5-step wizard:
1. **Welcome** - Introduction and platform detection
2. **Folders** - Select installation directories
3. **Dependencies** - Check and install requirements
4. **Install** - Deploy files and configure
5. **Complete** - Success screen with dashboard link
### 4. File Deployment
- Copies dashboard files from `status/` directory
- Copies API server from `dashcaddy-api/` directory
- Installs npm dependencies for API
- Skips unnecessary files (node_modules, .git)
### 5. Configuration Generation
- Creates Caddyfile for reverse proxy
- Generates docker-compose.yml for API container
- Saves installation configuration for upgrades
### 6. Service Management
- Starts Caddy web server
- Launches Docker containers
- Opens dashboard in browser when ready
## Configuration
### Build Configuration
Edit `package.json` under the `build` section:
```json
{
"build": {
"appId": "com.dashcaddy.installer",
"productName": "DashCaddy Installer",
"icon": "assets/icon.png",
"win": {
"target": ["portable", "dir"]
}
}
}
```
### Source Paths
The installer copies files from these source directories (relative to project root):
- Dashboard: `../status/`
- API Server: `../dashcaddy-api/`
These paths are configured in `src/main/file-deployer.js`.
## Troubleshooting
### Build Fails
1. **Missing dependencies**: Run `npm install`
2. **Electron download fails**: Check internet connection or use `npm config set electron_mirror https://npmmirror.com/mirrors/electron/`
3. **Icon errors**: Ensure `assets/icon.png` exists and is valid
### Installer Doesn't Start
1. **Check Node version**: Must be 18+
2. **Reinstall dependencies**: `rm -rf node_modules && npm install`
3. **Check console**: Run with `npm run dev` to see errors
### Files Not Deploying
1. **Check source paths**: Verify `status/` and `dashcaddy-api/` directories exist
2. **Check permissions**: Ensure write access to installation directory
3. **Check disk space**: Ensure sufficient space for installation
## Testing
### Unit Tests
Tests are located in `src/main/*.test.js`:
```bash
npm test
```
### Property-Based Tests
Some modules include property-based tests using fast-check:
```bash
npm test -- --testNamePattern="property"
```
### Manual Testing
1. Run installer: `npm start`
2. Go through all wizard steps
3. Verify files are copied correctly
4. Check that services start
5. Confirm dashboard opens in browser
## Deployment
### Creating a Release
1. Update version in `package.json`
2. Build for all platforms:
```bash
npm run build:win
npm run build:mac
npm run build:linux
```
3. Test each build on target platform
4. Create GitHub release with built artifacts
### Distribution
- **Windows**: Distribute `.exe` file (portable, no installation required)
- **macOS**: Distribute `.dmg` file
- **Linux**: Distribute `.AppImage` (universal) or `.deb` (Debian/Ubuntu)
## Advanced
### Custom Branding
Replace `assets/icon.png` with your custom icon (512x512 PNG recommended).
### Custom Templates
Edit templates in `templates/` directory to customize generated configurations.
### Adding New Steps
1. Add step definition to `wizard.js` steps array
2. Create render function (e.g., `renderMyStep()`)
3. Add case to `renderCurrentStep()` switch
4. Update navigation logic if needed
## Support
For issues or questions:
- Check existing documentation
- Review console logs with `npm run dev`
- Check GitHub issues
## License
MIT

View File

@@ -0,0 +1,184 @@
# DashCaddy Installer - Quick Start
## For Developers
### Setup & Run
```bash
# Install dependencies
npm install
# Run in development mode
npm start
# Build for Windows
npm run build:win
```
The built installer will be at: `dist/win-unpacked/DashCaddy Installer.exe`
## For End Users
### Running the Installer
1. **Download** the DashCaddy Installer executable
2. **Run** the installer (no installation required - it's portable)
3. **Follow** the 5-step wizard:
#### Step 1: Welcome
- Review features and system requirements
- Confirm detected platform
#### Step 2: Choose Folders
- Select installation directory (default: `C:\DashCaddy`)
- Optionally customize Docker data and Caddy config paths
- Click **Next**
#### Step 3: Check Dependencies
- Installer checks for Docker and Caddy
- If missing, click **Install** buttons to install them
- Wait for installation to complete
- Click **Install** when both are ready
#### Step 4: Installation
- Watch progress as DashCaddy is installed
- Files are copied and configured automatically
- Takes 1-3 minutes depending on system
#### Step 5: Complete
- Installation successful!
- Click **Open Dashboard** to launch DashCaddy
- Dashboard opens at `http://localhost:8080`
### What Gets Installed
The installer creates this structure:
```
C:\DashCaddy\
├── dashboard\ # Web dashboard files
├── api\ # API server
├── docker-data\ # Docker volumes
├── caddy\ # Caddy configuration
├── Caddyfile # Reverse proxy config
├── docker-compose.yml # Container orchestration
└── .dashcaddy-config.json # Installation settings
```
### System Requirements
- **OS**: Windows 10/11, macOS 10.15+, or Linux
- **RAM**: 4GB minimum, 8GB recommended
- **Disk**: 2GB free space
- **Docker**: Required (installer can install)
- **Caddy**: Required (installer can install)
### Accessing DashCaddy
After installation:
- **Dashboard**: http://localhost:8080
- **API**: http://localhost:3001
- **Caddy Admin**: http://localhost:2021
### Troubleshooting
#### Installer Won't Start
- Ensure you have administrator/sudo privileges
- Check antivirus isn't blocking the installer
- Try running as administrator (Windows)
#### Dependencies Won't Install
- Check internet connection
- Manually install Docker from docker.com
- Manually install Caddy from caddyserver.com
#### Dashboard Won't Open
- Check if Caddy is running: `caddy version`
- Check if Docker is running: `docker ps`
- Verify port 8080 isn't in use
- Check installation logs
#### Services Not Starting
- Ensure Docker daemon is running
- Check firewall settings
- Verify installation paths are correct
### Uninstalling
To remove DashCaddy:
1. Stop services:
```bash
cd C:\DashCaddy
docker compose down
taskkill /IM caddy.exe /F
```
2. Delete installation directory:
```bash
rmdir /s C:\DashCaddy
```
### Next Steps
After installation:
1. **Add Services**: Click "+" to deploy Docker containers
2. **Configure DNS**: Set up custom domains (optional)
3. **Import Existing**: Import services from other systems
4. **Customize**: Change themes, logos, and settings
### Getting Help
- **Documentation**: Check README.md in installation directory
- **Logs**: View container logs in dashboard
- **Support**: Open GitHub issue for bugs
## Development Notes
### Testing the Installer
```bash
# Run with DevTools for debugging
npm run dev
# Test specific functionality
npm test
# Build and test portable exe
npm run build:win
./dist/win-unpacked/DashCaddy\ Installer.exe
```
### Modifying the Installer
Key files to edit:
- **UI**: `src/renderer/wizard.js` and `styles.css`
- **Logic**: `src/main/index.js` (IPC handlers)
- **Deployment**: `src/main/file-deployer.js`
- **Config**: `src/main/caddyfile-generator.js`
### Adding Features
1. Add IPC handler in `src/main/index.js`
2. Expose in `src/preload/index.js`
3. Call from `src/renderer/wizard.js`
4. Update UI in `styles.css` if needed
## Tips
- **Portable**: The Windows .exe is fully portable - no installation needed
- **Upgrades**: Installer detects existing installations and can upgrade
- **Backups**: Installation config saved in `.dashcaddy-config.json`
- **Logs**: Check console with DevTools (Ctrl+Shift+I in dev mode)
## What's Next?
After successful installation, explore:
- **App Templates**: 50+ pre-configured applications
- **DNS Integration**: Set up local domains with Cloudflare
- **Monitoring**: Real-time service health checks
- **Backups**: Export/import dashboard configurations

View File

@@ -0,0 +1,230 @@
# DashCaddy Installer
A cross-platform Electron application that provides a guided installation wizard for DashCaddy - the unified management platform for Docker containers and Caddy reverse proxy.
## ✨ Features
- **🎯 Guided 5-Step Wizard** - Simple, intuitive installation process
- **🔍 Automatic Dependency Detection** - Checks for Docker and Caddy
- **📦 One-Click Dependency Installation** - Installs missing requirements
- **🖥️ Cross-Platform Support** - Windows, macOS, and Linux
- **📁 Smart File Deployment** - Copies and configures all necessary files
- **⚙️ Auto-Configuration** - Generates Caddyfile and docker-compose.yml
- **🚀 Service Management** - Starts services and opens dashboard
- **💾 Configuration Persistence** - Saves settings for upgrades
- **🎨 Modern UI** - Clean, responsive interface with progress tracking
## 🚀 Quick Start
### For End Users
1. **Download** the installer for your platform
2. **Run** the executable (portable, no installation needed)
3. **Follow** the wizard steps
4. **Access** your dashboard at http://localhost:8080
See [QUICK_START.md](QUICK_START.md) for detailed user instructions.
### For Developers
```bash
# Install dependencies
npm install
# Run in development mode with DevTools
npm run dev
# Build for Windows
npm run build:win
```
See [BUILD_GUIDE.md](BUILD_GUIDE.md) for comprehensive build instructions.
## 📋 System Requirements
- **Operating System**: Windows 10/11, macOS 10.15+, or Linux
- **RAM**: 4GB minimum, 8GB recommended
- **Disk Space**: 2GB free space
- **Dependencies**: Docker and Caddy (installer can install these)
## 🏗️ Project Structure
```
dashcaddy-installer/
├── src/
│ ├── main/ # Electron main process
│ │ ├── index.js # Main entry & IPC handlers
│ │ ├── dependency-checker.js # Docker/Caddy detection
│ │ ├── config-manager.js # Configuration management
│ │ ├── file-deployer.js # File copying & deployment
│ │ ├── caddyfile-generator.js # Config generation
│ │ ├── browser-launcher.js # Browser integration
│ │ └── service-manager.js # Service control
│ ├── renderer/ # UI layer
│ │ ├── index.html # Main HTML
│ │ ├── wizard.js # Wizard logic
│ │ └── styles.css # Styling
│ ├── preload/ # IPC bridge
│ │ └── index.js # Secure IPC exposure
│ └── shared/ # Shared utilities
│ ├── constants.js # App constants
│ └── platform-utils.js # Platform detection
├── templates/ # Configuration templates
│ ├── Caddyfile.template # Caddyfile template
│ ├── docker-compose.template.yml
│ └── README.md
├── assets/ # Images & icons
│ ├── icon.png # App icon
│ └── dashcaddy-logo.png
├── dist/ # Build output (generated)
├── BUILD_GUIDE.md # Comprehensive build guide
├── QUICK_START.md # User quick start guide
└── package.json # Dependencies & config
```
## 🛠️ Development
### Setup
```bash
# Clone the repository
git clone <repository-url>
cd dashcaddy-installer
# Install dependencies
npm install
```
### Running
```bash
# Development mode with DevTools
npm run dev
# Production mode
npm start
```
### Testing
```bash
# Run all tests
npm test
# Run tests in watch mode
npm run test:watch
# Run property-based tests
npm test -- --testNamePattern="property"
```
### Building
```bash
# Build for current platform
npm run build
# Build for specific platforms
npm run build:win # Windows
npm run build:mac # macOS
npm run build:linux # Linux
```
**Build Output:**
- Windows: `dist/win-unpacked/DashCaddy Installer.exe` (168MB portable)
- macOS: `dist/DashCaddy Installer.dmg`
- Linux: `dist/DashCaddy Installer.AppImage`
## 📖 Documentation
- **[BUILD_GUIDE.md](BUILD_GUIDE.md)** - Complete build and development guide
- **[QUICK_START.md](QUICK_START.md)** - User installation guide
- **[templates/README.md](templates/README.md)** - Template documentation
## 🎯 Installation Wizard Steps
### 1. Welcome
- Introduction to DashCaddy
- Platform detection
- Feature overview
### 2. Choose Folders
- Select installation directory
- Configure Docker data path
- Set Caddy config location
### 3. Check Dependencies
- Detect Docker installation
- Detect Caddy installation
- Install missing dependencies
### 4. Installation
- Deploy dashboard files
- Deploy API server
- Install npm dependencies
- Generate configurations
- Start services
### 5. Complete
- Installation summary
- Dashboard URL
- Quick access button
## 🔧 Configuration
### Source Directories
The installer copies files from:
- **Dashboard**: `../status/``{install}/dashboard/`
- **API Server**: `../dashcaddy-api/``{install}/api/`
### Generated Files
The installer creates:
- `Caddyfile` - Reverse proxy configuration
- `docker-compose.yml` - Container orchestration
- `.dashcaddy-config.json` - Installation settings
### Default Ports
- **Dashboard**: 8080
- **API Server**: 3001
- **Caddy Admin**: 2019
## 🐛 Troubleshooting
### Build Issues
- Ensure Node.js 18+ is installed
- Delete `node_modules` and run `npm install`
- Check that source directories exist
### Runtime Issues
- Run with `npm run dev` to see console errors
- Check that Docker daemon is running
- Verify Caddy is installed and accessible
- Ensure ports 8080, 3001, 2021 are available
### Deployment Issues
- Verify source paths in `file-deployer.js`
- Check write permissions on installation directory
- Ensure sufficient disk space
## 🤝 Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Add tests if applicable
5. Submit a pull request
## 📝 License
MIT
## 🙏 Acknowledgments
Built with:
- [Electron](https://www.electronjs.org/) - Cross-platform desktop apps
- [electron-builder](https://www.electron.build/) - Build and packaging
- [Jest](https://jestjs.io/) - Testing framework
- [fast-check](https://fast-check.dev/) - Property-based testing

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -0,0 +1,960 @@
#!/usr/bin/env bash
# ============================================================================
# DashCaddy Linux Installer v1.0
#
# Zero-effort install:
# curl -fsSL https://get.dashcaddy.net | bash
#
# Zero-typing install (local mode, instant access):
# curl -fsSL https://get.dashcaddy.net | bash -s -- quick
#
# With a domain (only thing you type is the domain):
# curl -fsSL https://get.dashcaddy.net | bash -s -- --domain my.example.com
#
# Designed for accessibility — all choices are single keystrokes.
# ============================================================================
set -euo pipefail
# ---- Constants -------------------------------------------------------------
readonly DASHCADDY_VERSION="1.0.0"
readonly DASHCADDY_DOWNLOAD="https://get.dashcaddy.net/release/latest.tar.gz"
readonly DASHCADDY_REPO="" # Set to a git URL to clone instead of downloading
readonly INSTALL_DIR="/etc/dashcaddy"
readonly DOCKER_DATA="/opt/dockerdata"
readonly SITES_DIR="${INSTALL_DIR}/sites"
readonly API_DIR="${SITES_DIR}/dashcaddy-api"
readonly DASHBOARD_DIR="${SITES_DIR}/status"
readonly CONTAINER_NAME="dashcaddy-api"
readonly CADDY_ADMIN_PORT=2019
# ---- Tunables (overridable via flags) --------------------------------------
API_PORT=3001
LOCAL_PORT=8080
# ---- Runtime state ---------------------------------------------------------
DOMAIN_MODE="" # public | custom-tld | local
DOMAIN=""
EMAIL=""
TLD=""
CA_NAME="DashCaddy Local CA"
SOURCE_PATH=""
GIT_BRANCH="main"
SKIP_DOCKER=false
SKIP_CADDY=false
UNINSTALL=false
KEEP_CONFIG=false
AUTO_YES=false
QUICK=false
DISTRO=""
DISTRO_FAMILY="" # debian | rhel | arch
PUBLIC_IP=""
LAN_IP=""
STEP=0
TOTAL_STEPS=7
# ---- Colors ----------------------------------------------------------------
if [[ -t 1 ]]; then
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[0;33m'
BLUE='\033[0;34m'; CYAN='\033[0;36m'; BOLD='\033[1m'
DIM='\033[2m'; NC='\033[0m'
else
RED=''; GREEN=''; YELLOW=''; BLUE=''; CYAN=''; BOLD=''; DIM=''; NC=''
fi
# ============================================================================
# Output Helpers
# ============================================================================
log() { echo -e "${CYAN}[DashCaddy]${NC} $*"; }
ok() { echo -e "${GREEN}${NC} $*"; }
warn() { echo -e "${YELLOW} !${NC} $*"; }
err() { echo -e "${RED}${NC} $*" >&2; }
fatal() { err "$*"; echo -e "${DIM} If this keeps failing, see: https://dashcaddy.net/docs/troubleshoot${NC}" >&2; exit 1; }
step() {
STEP=$((STEP + 1))
echo ""
echo -e "${BOLD}${BLUE} [${STEP}/${TOTAL_STEPS}] $*${NC}"
echo -e "${BLUE} ─────────────────────────────────────────${NC}"
}
# Simple progress: run command in background, show dots
progress() {
local msg="$1"; shift
printf "${CYAN}${NC} %s " "$msg"
# Run the command, capture output for error reporting
local logfile="/tmp/dashcaddy-install-$$.log"
if "$@" >"$logfile" 2>&1; then
echo -e "${GREEN}done${NC}"
rm -f "$logfile"
return 0
else
echo -e "${RED}failed${NC}"
echo -e "${DIM} Last output:${NC}"
tail -5 "$logfile" 2>/dev/null | sed 's/^/ /'
rm -f "$logfile"
return 1
fi
}
elapsed() {
local diff=$(( $(date +%s) - $1 ))
if (( diff < 60 )); then
echo "${diff}s"
else
printf '%dm%02ds' $((diff / 60)) $((diff % 60))
fi
}
# ============================================================================
# System Detection
# ============================================================================
detect_system() {
# --- Root check ---
if [[ $EUID -ne 0 ]]; then
fatal "Must run as root. Try: sudo bash install.sh"
fi
# --- OS detection ---
if [[ ! -f /etc/os-release ]]; then
fatal "Cannot detect OS — /etc/os-release not found."
fi
source /etc/os-release
case "${ID,,}" in
ubuntu|debian|pop|linuxmint|elementary|zorin|raspbian)
DISTRO="${ID}"; DISTRO_FAMILY="debian" ;;
fedora|rhel|centos|rocky|almalinux|ol|amzn)
DISTRO="${ID}"; DISTRO_FAMILY="rhel" ;;
arch|manjaro|endeavouros)
DISTRO="${ID}"; DISTRO_FAMILY="arch" ;;
*)
DISTRO="${ID}"; DISTRO_FAMILY="debian"
warn "Unknown distro '${ID}' — using Debian-style commands" ;;
esac
ok "OS: ${PRETTY_NAME:-$DISTRO}"
# --- Architecture ---
local arch
arch=$(uname -m)
case "$arch" in
x86_64|amd64|aarch64|arm64) ok "Arch: ${arch}" ;;
*) warn "Arch '${arch}' may not be fully supported" ;;
esac
# --- Network ---
LAN_IP=$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{print $7; exit}' || hostname -I 2>/dev/null | awk '{print $1}' || echo "unknown")
PUBLIC_IP=$(curl -fsSL --max-time 3 https://api.ipify.org 2>/dev/null || curl -fsSL --max-time 3 https://ifconfig.me 2>/dev/null || echo "unknown")
ok "LAN IP: ${LAN_IP}"
ok "Public IP: ${PUBLIC_IP}"
# --- Existing install? ---
if [[ -f "${INSTALL_DIR}/config.json" ]]; then
warn "Existing DashCaddy installation detected"
warn "Re-running will upgrade in place (configs preserved)"
fi
}
# ============================================================================
# Interactive Setup — minimal typing, number keys only
# ============================================================================
interactive_setup() {
# Skip if mode already set via flags
if [[ -n "$DOMAIN_MODE" ]]; then return; fi
# Detect environment to offer smart defaults
local is_vps=false
if [[ "$PUBLIC_IP" != "unknown" && "$PUBLIC_IP" != "$LAN_IP" ]] || \
[[ "$LAN_IP" =~ ^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.) ]]; then
: # Private IP — probably homelab or local
else
is_vps=true
fi
echo ""
echo -e "${BOLD} How will you access DashCaddy?${NC}"
echo ""
if $is_vps; then
echo -e " ${BOLD}1${NC}) Public domain ${DIM}— Let's Encrypt TLS (recommended for VPS)${NC}"
echo -e " ${BOLD}2${NC}) Quick start ${DIM}— http://${PUBLIC_IP}:${LOCAL_PORT} right now, add domain later${NC}"
echo -e " ${BOLD}3${NC}) Custom TLD ${DIM}— Internal CA for homelab (e.g., .home, .lab)${NC}"
else
echo -e " ${BOLD}1${NC}) Quick start ${DIM}— http://${LAN_IP}:${LOCAL_PORT} right now${NC}"
echo -e " ${BOLD}2${NC}) Custom TLD ${DIM}— Internal CA for homelab (e.g., .home, .lab)${NC}"
echo -e " ${BOLD}3${NC}) Public domain ${DIM}— Let's Encrypt TLS${NC}"
fi
echo ""
local choice
read -rp "$(echo -e " ${YELLOW}Press 1, 2, or 3:${NC} ")" -n1 choice
echo ""
if $is_vps; then
case "$choice" in
1) setup_public_domain ;;
2) DOMAIN_MODE="local" ;;
3) setup_custom_tld ;;
*) DOMAIN_MODE="local"; warn "Defaulting to Quick Start" ;;
esac
else
case "$choice" in
1) DOMAIN_MODE="local" ;;
2) setup_custom_tld ;;
3) setup_public_domain ;;
*) DOMAIN_MODE="local"; warn "Defaulting to Quick Start" ;;
esac
fi
}
setup_public_domain() {
DOMAIN_MODE="public"
echo ""
read -rp "$(echo -e " ${YELLOW}Domain name:${NC} ")" DOMAIN
if [[ -z "$DOMAIN" ]]; then
fatal "Domain is required for public mode."
fi
# Auto-check if domain points to this server
local resolved
resolved=$(dig +short "$DOMAIN" 2>/dev/null | head -1)
if [[ -n "$resolved" && "$resolved" == "$PUBLIC_IP" ]]; then
ok "${DOMAIN} correctly points to ${PUBLIC_IP}"
elif [[ -n "$resolved" ]]; then
warn "${DOMAIN} resolves to ${resolved}, but this server is ${PUBLIC_IP}"
echo -e " ${DIM}Let's Encrypt will fail if DNS doesn't point here.${NC}"
echo ""
read -rp "$(echo -e " ${YELLOW}Continue anyway? [y/N]:${NC} ")" -n1 cont
echo ""
[[ "$cont" =~ ^[Yy]$ ]] || fatal "Fix DNS first, then re-run the installer."
else
warn "Could not verify DNS for ${DOMAIN}"
echo -e " ${DIM}Make sure ${DOMAIN} points to ${PUBLIC_IP} for TLS to work.${NC}"
fi
# Email — offer skip
echo ""
echo -e " ${DIM}Email for Let's Encrypt notifications (optional, press Enter to skip):${NC}"
read -rp " " EMAIL
}
setup_custom_tld() {
DOMAIN_MODE="custom-tld"
echo ""
echo -e " ${DIM}Common choices: .home .local .lab .lan${NC}"
read -rp "$(echo -e " ${YELLOW}TLD (default .home):${NC} ")" TLD
[[ -z "$TLD" ]] && TLD=".home"
[[ "$TLD" != .* ]] && TLD=".$TLD"
echo ""
read -rp "$(echo -e " ${YELLOW}CA name (default: DashCaddy Local CA):${NC} ")" input
[[ -n "$input" ]] && CA_NAME="$input"
}
# ============================================================================
# Dependency Installation
# ============================================================================
install_prereqs() {
# Only install what's missing
local needed=()
command -v curl &>/dev/null || needed+=(curl)
command -v jq &>/dev/null || needed+=(jq)
command -v dig &>/dev/null || needed+=(dnsutils)
# git only needed if using --source with a repo URL
[[ -n "$DASHCADDY_REPO" ]] && ! command -v git &>/dev/null && needed+=(git)
if [[ ${#needed[@]} -eq 0 ]]; then
ok "Prerequisites already installed"
return
fi
progress "Installing ${needed[*]}..." bash -c "
case '$DISTRO_FAMILY' in
debian) apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq ${needed[*]} ;;
rhel) dnf install -y -q ${needed[*]} 2>/dev/null || yum install -y -q ${needed[*]} ;;
arch) pacman -Sy --noconfirm ${needed[*]} ;;
esac
" || warn "Some prerequisites may not have installed"
}
install_docker() {
if $SKIP_DOCKER; then ok "Docker: skipped (--skip-docker)"; return; fi
if command -v docker &>/dev/null && docker info &>/dev/null; then
ok "Docker: $(docker --version | grep -oP 'Docker version [\d.]+')"
return
fi
progress "Installing Docker (this is the slowest step)..." bash -c "
case '$DISTRO_FAMILY' in
debian)
curl -fsSL https://get.docker.com | sh
;;
rhel)
if command -v dnf &>/dev/null; then
dnf install -y -q dnf-plugins-core
dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
dnf install -y -q docker-ce docker-ce-cli containerd.io docker-compose-plugin
else
yum install -y -q yum-utils
yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
yum install -y -q docker-ce docker-ce-cli containerd.io docker-compose-plugin
fi
;;
arch)
pacman -Sy --noconfirm docker docker-compose
;;
esac
systemctl enable docker
systemctl start docker
" || fatal "Docker installation failed. Install manually: https://docs.docker.com/engine/install/"
if docker info &>/dev/null; then
ok "Docker: $(docker --version | grep -oP 'Docker version [\d.]+')"
else
fatal "Docker installed but daemon not running. Try: systemctl start docker"
fi
}
install_caddy() {
if $SKIP_CADDY; then ok "Caddy: skipped (--skip-caddy)"; return; fi
if command -v caddy &>/dev/null; then
ok "Caddy: $(caddy version 2>/dev/null | head -1)"
return
fi
progress "Installing Caddy..." bash -c "
case '$DISTRO_FAMILY' in
debian)
apt-get install -y -qq debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list > /dev/null
apt-get update -qq
apt-get install -y -qq caddy
;;
rhel)
if command -v dnf &>/dev/null; then
dnf install -y -q 'dnf-command(copr)'
dnf copr enable -y @caddy/caddy
dnf install -y -q caddy
else
yum install -y -q yum-plugin-copr
yum copr enable -y @caddy/caddy
yum install -y -q caddy
fi
;;
arch)
pacman -Sy --noconfirm caddy
;;
esac
# Stop default Caddy — we configure it ourselves
systemctl stop caddy 2>/dev/null || true
" || fatal "Caddy installation failed. Install manually: https://caddyserver.com/docs/install"
ok "Caddy: $(caddy version 2>/dev/null | head -1)"
}
# ============================================================================
# Fix known OS issues automatically
# ============================================================================
fix_system_issues() {
# systemd-resolved stub — breaks Docker DNS and Tailscale
if [[ -L /etc/resolv.conf ]] && readlink /etc/resolv.conf | grep -q stub; then
rm -f /etc/resolv.conf
cat > /etc/resolv.conf <<'EOF'
# Set by DashCaddy installer (replaced systemd-resolved stub)
nameserver 1.1.1.1
nameserver 8.8.8.8
nameserver 1.0.0.1
EOF
ok "Fixed systemd-resolved stub (replaced with public DNS)"
fi
# Ensure /etc/caddy exists
mkdir -p /etc/caddy
}
# ============================================================================
# Directory & File Setup
# ============================================================================
create_directories() {
mkdir -p "$INSTALL_DIR" "$DOCKER_DATA" "$SITES_DIR" "$API_DIR" "$DASHBOARD_DIR" "${DASHBOARD_DIR}/assets"
ok "Directories created"
}
fetch_source() {
local tmp_src="/tmp/dashcaddy-src-$$"
if [[ -n "$SOURCE_PATH" ]]; then
if [[ ! -d "$SOURCE_PATH" ]]; then
fatal "Source path not found: $SOURCE_PATH"
fi
tmp_src="$SOURCE_PATH"
ok "Using local source: $SOURCE_PATH"
elif [[ -n "$DASHCADDY_REPO" ]]; then
# Git clone mode (for development)
progress "Cloning DashCaddy..." \
git clone --depth 1 --branch "$GIT_BRANCH" "$DASHCADDY_REPO" "$tmp_src" \
|| fatal "Failed to clone DashCaddy. Check network."
else
# Download release tarball (default — no git needed)
mkdir -p "$tmp_src"
progress "Downloading DashCaddy v${DASHCADDY_VERSION}..." bash -c "
curl -fsSL '${DASHCADDY_DOWNLOAD}' | tar xz -C '$tmp_src' --strip-components=1
" || fatal "Failed to download DashCaddy. Check network or try --source with a local copy."
fi
# Find API source (handle different repo layouts)
local api_src=""
for try in "${tmp_src}/dashcaddy-api" "${tmp_src}/sites/dashcaddy-api" "${tmp_src}/api"; do
[[ -d "$try" ]] && api_src="$try" && break
done
[[ -z "$api_src" ]] && fatal "Cannot find dashcaddy-api/ in source"
# Find dashboard source
local dash_src=""
for try in "${tmp_src}/status" "${tmp_src}/sites/status" "${tmp_src}/dashboard"; do
[[ -d "$try" ]] && dash_src="$try" && break
done
[[ -z "$dash_src" ]] && fatal "Cannot find dashboard source in source"
# Deploy API files
cp -f "${api_src}"/*.js "$API_DIR/" 2>/dev/null || true
cp -f "${api_src}/package.json" "$API_DIR/"
cp -f "${api_src}/package-lock.json" "$API_DIR/" 2>/dev/null || true
cp -f "${api_src}/Dockerfile" "$API_DIR/"
cp -f "${api_src}/openapi.yaml" "$API_DIR/" 2>/dev/null || true
[[ -d "${api_src}/routes" ]] && cp -rf "${api_src}/routes" "$API_DIR/"
ok "API files deployed"
# Deploy dashboard files
cp -f "${dash_src}/index.html" "$DASHBOARD_DIR/"
cp -f "${dash_src}/sw.js" "$DASHBOARD_DIR/" 2>/dev/null || true
for dir in css js dist vendor assets; do
[[ -d "${dash_src}/${dir}" ]] && cp -rf "${dash_src}/${dir}" "$DASHBOARD_DIR/"
done
ok "Dashboard files deployed"
# Cleanup
[[ -z "$SOURCE_PATH" ]] && rm -rf "$tmp_src"
}
create_seed_configs() {
local config_type dashboard_host tz
case "$DOMAIN_MODE" in
public) config_type="public"; dashboard_host="$DOMAIN" ;;
custom-tld) config_type="homelab"; dashboard_host="dashcaddy${TLD}" ;;
local) config_type="local"; dashboard_host="${LAN_IP}:${LOCAL_PORT}" ;;
esac
tz=$(timedatectl show --property=Timezone --value 2>/dev/null || echo 'UTC')
# Only create files that don't already exist (preserve on re-install)
[[ -f "${INSTALL_DIR}/services.json" ]] || echo '[]' > "${INSTALL_DIR}/services.json"
[[ -f "${INSTALL_DIR}/dns-credentials.json" ]] || echo '{}' > "${INSTALL_DIR}/dns-credentials.json"
[[ -f "${INSTALL_DIR}/credentials.json" ]] || echo '{}' > "${INSTALL_DIR}/credentials.json"
[[ -f "${INSTALL_DIR}/notifications.json" ]] || echo '[]' > "${INSTALL_DIR}/notifications.json"
if [[ ! -f "${INSTALL_DIR}/.encryption-key" ]]; then
openssl rand -hex 32 > "${INSTALL_DIR}/.encryption-key"
chmod 600 "${INSTALL_DIR}/.encryption-key"
fi
# config.json — always regenerate (contains dashboard URL which may change)
cat > "${INSTALL_DIR}/config.json" <<CFGEOF
{
"setupComplete": true,
"configurationType": "${config_type}",
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)",
"timezone": "${tz}",
"dashboardTitle": "DashCaddy",
"dashboardHost": "${dashboard_host}",
"tld": "${TLD}",
"caName": "${CA_NAME}",
"license": {
"active": true,
"tier": "community",
"features": []
}
}
CFGEOF
ok "Config files ready"
}
# ============================================================================
# Caddyfile Generation
# ============================================================================
generate_caddyfile() {
local cf="${INSTALL_DIR}/Caddyfile"
# --- Shared snippets ---
local snippets
read -r -d '' snippets <<'SNIP' || true
(cors-preflight) {
@preflight method OPTIONS
header @preflight {
Access-Control-Allow-Origin "*"
Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS"
Access-Control-Allow-Headers "*"
Access-Control-Max-Age "600"
}
respond @preflight 204
}
(cors-allow) {
header {
Access-Control-Allow-Origin "*"
Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS"
Access-Control-Allow-Headers "*"
Access-Control-Max-Age "600"
}
}
SNIP
local auth_snippet="(dashcaddy_auth) {
forward_auth localhost:${API_PORT} {
uri /api/auth/gate/{args[0]}
copy_headers Authorization X-Api-Key X-App-Cookie X-Emby-Token X-Plex-Token
}
}"
local site_body=" root * ${DASHBOARD_DIR}
encode gzip
handle /api/* {
reverse_proxy localhost:${API_PORT}
}
handle {
@notFile not file {path}
rewrite @notFile /index.html
file_server
}"
# --- Mode-specific Caddyfile ---
case "$DOMAIN_MODE" in
public)
cat > "$cf" <<CEOF
# DashCaddy - Public Domain (Let's Encrypt)
{
admin localhost:${CADDY_ADMIN_PORT}
$([ -n "$EMAIL" ] && echo " email ${EMAIL}")
}
${snippets}
${auth_snippet}
${DOMAIN} {
${site_body}
}
CEOF
;;
custom-tld)
cat > "$cf" <<CEOF
# DashCaddy - Custom TLD (${TLD})
{
admin localhost:${CADDY_ADMIN_PORT}
pki {
ca local {
name "${CA_NAME}"
root_cn "${CA_NAME} Root CA"
intermediate_cn "${CA_NAME} Intermediate CA"
}
}
}
${snippets}
${auth_snippet}
dashcaddy${TLD} {
tls internal
${site_body}
}
CEOF
;;
local)
cat > "$cf" <<CEOF
# DashCaddy - Local Mode
{
admin localhost:${CADDY_ADMIN_PORT}
auto_https off
}
${snippets}
:${LOCAL_PORT} {
${site_body}
}
CEOF
;;
esac
ok "Caddyfile generated"
}
# ============================================================================
# Docker Compose
# ============================================================================
generate_docker_compose() {
cat > "${API_DIR}/docker-compose.yml" <<DCEOF
services:
dashcaddy-api:
build: .
container_name: ${CONTAINER_NAME}
ports:
- "${API_PORT}:${API_PORT}"
volumes:
- ${INSTALL_DIR}/Caddyfile:/caddyfile:rw
- ${INSTALL_DIR}/services.json:/app/services.json:rw
- ${INSTALL_DIR}/dns-credentials.json:/app/dns-credentials.json:rw
- ${INSTALL_DIR}/config.json:/app/config.json:rw
- ${INSTALL_DIR}/credentials.json:/app/credentials.json:rw
- ${INSTALL_DIR}/.encryption-key:/app/.encryption-key:rw
- ${INSTALL_DIR}/.license-secret:/app/.license-secret:ro
- ${INSTALL_DIR}/totp-config.json:/app/totp-config.json:rw
- ${INSTALL_DIR}/notifications.json:/app/notifications.json:rw
- ${DASHBOARD_DIR}/assets:/app/assets:rw
- /var/run/docker.sock:/var/run/docker.sock
environment:
- CADDYFILE_PATH=/caddyfile
- CADDY_ADMIN_URL=http://host.docker.internal:${CADDY_ADMIN_PORT}
- ASSETS_PATH=/app/assets
- CREDENTIALS_FILE=/app/credentials.json
- CONFIG_FILE=/app/config.json
- SERVICES_FILE=/app/services.json
- DNS_CREDENTIALS_FILE=/app/dns-credentials.json
- HOST_LAN_IP=${LAN_IP}
- NODE_ENV=production
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
DCEOF
ok "docker-compose.yml generated"
}
# ============================================================================
# Build & Launch
# ============================================================================
build_and_start() {
# Remove old container if exists
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
cd "$API_DIR"
progress "Building container image (30-60s)..." \
docker compose build --quiet \
|| progress "Building container image (fallback)..." \
docker-compose build --quiet \
|| fatal "Docker build failed. Check: docker info"
progress "Starting container..." \
docker compose up -d \
|| docker-compose up -d 2>/dev/null \
|| fatal "Failed to start container"
# Wait for healthy
local i=0
printf "${CYAN}${NC} Waiting for API health check "
while (( i < 30 )); do
if curl -fsSL --max-time 2 "http://localhost:${API_PORT}/health" &>/dev/null; then
echo -e "${GREEN}healthy${NC}"
return
fi
printf "."
sleep 1
i=$((i + 1))
done
echo -e "${YELLOW}timeout${NC}"
warn "API may still be starting. Check: docker logs ${CONTAINER_NAME}"
}
start_caddy() {
# Symlink our Caddyfile to where Caddy's systemd unit expects it
if [[ -f /etc/caddy/Caddyfile && ! -L /etc/caddy/Caddyfile ]]; then
mv /etc/caddy/Caddyfile /etc/caddy/Caddyfile.original 2>/dev/null || true
fi
ln -sf "${INSTALL_DIR}/Caddyfile" /etc/caddy/Caddyfile
systemctl enable caddy >/dev/null 2>&1
systemctl restart caddy 2>/dev/null
sleep 2
if systemctl is-active --quiet caddy; then
ok "Caddy is running"
else
warn "Caddy may have issues. Check: systemctl status caddy"
fi
}
# ============================================================================
# Firewall
# ============================================================================
open_firewall() {
if command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -q "active"; then
case "$DOMAIN_MODE" in
public)
ufw allow 80/tcp >/dev/null 2>&1; ufw allow 443/tcp >/dev/null 2>&1
ok "Firewall: opened 80, 443 (UFW)" ;;
local)
ufw allow "${LOCAL_PORT}"/tcp >/dev/null 2>&1
ok "Firewall: opened ${LOCAL_PORT} (UFW)" ;;
esac
elif command -v firewall-cmd &>/dev/null && systemctl is-active --quiet firewalld 2>/dev/null; then
case "$DOMAIN_MODE" in
public)
firewall-cmd --permanent --add-service=http --add-service=https >/dev/null 2>&1
firewall-cmd --reload >/dev/null 2>&1
ok "Firewall: opened 80, 443 (firewalld)" ;;
local)
firewall-cmd --permanent --add-port="${LOCAL_PORT}"/tcp >/dev/null 2>&1
firewall-cmd --reload >/dev/null 2>&1
ok "Firewall: opened ${LOCAL_PORT} (firewalld)" ;;
esac
fi
}
# ============================================================================
# Uninstall
# ============================================================================
do_uninstall() {
echo -e "\n${BOLD} Uninstalling DashCaddy${NC}\n"
echo " This will:"
echo " - Stop and remove the dashcaddy-api container"
echo " - Remove DashCaddy files from ${INSTALL_DIR}/"
if $KEEP_CONFIG; then
echo " - KEEP your config files (--keep-config)"
fi
echo " - NOT remove Docker or Caddy"
echo ""
read -rp "$(echo -e " ${YELLOW}Continue? [y/N]:${NC} ")" -n1 answer
echo ""
[[ "$answer" =~ ^[Yy]$ ]] || { echo " Cancelled."; exit 0; }
docker rm -f "$CONTAINER_NAME" 2>/dev/null && ok "Container removed" || true
if [[ -L /etc/caddy/Caddyfile ]] && readlink /etc/caddy/Caddyfile | grep -q dashcaddy; then
rm -f /etc/caddy/Caddyfile
[[ -f /etc/caddy/Caddyfile.original ]] && mv /etc/caddy/Caddyfile.original /etc/caddy/Caddyfile
systemctl restart caddy 2>/dev/null || true
ok "Caddy config restored"
fi
if $KEEP_CONFIG; then
rm -rf "$API_DIR" "$DASHBOARD_DIR"
ok "App files removed, config preserved in ${INSTALL_DIR}/"
else
rm -rf "$INSTALL_DIR"
ok "All files removed from ${INSTALL_DIR}/"
fi
echo -e "\n${GREEN} DashCaddy uninstalled.${NC}\n"
exit 0
}
# ============================================================================
# Argument Parsing
# ============================================================================
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
quick) QUICK=true; DOMAIN_MODE="local"; shift ;;
--domain) DOMAIN_MODE="public"; DOMAIN="${2:-}"; shift; shift ;;
--email) EMAIL="${2:-}"; shift; shift ;;
--tld) DOMAIN_MODE="custom-tld"; TLD="${2:-}"; shift; shift ;;
--ca-name) CA_NAME="${2:-}"; shift; shift ;;
--local) DOMAIN_MODE="local"; shift ;;
--port) LOCAL_PORT="${2:-8080}"; shift; shift ;;
--api-port) API_PORT="${2:-3001}"; shift; shift ;;
--source) SOURCE_PATH="${2:-}"; shift; shift ;;
--branch) GIT_BRANCH="${2:-main}"; shift; shift ;;
--skip-docker) SKIP_DOCKER=true; shift ;;
--skip-caddy) SKIP_CADDY=true; shift ;;
--uninstall) UNINSTALL=true; shift ;;
--keep-config) KEEP_CONFIG=true; shift ;;
--yes|-y) AUTO_YES=true; shift ;;
--help|-h) print_help; exit 0 ;;
*) warn "Unknown option: $1 (ignored)"; shift ;;
esac
done
# Normalize TLD
if [[ -n "$TLD" && "$TLD" != .* ]]; then TLD=".$TLD"; fi
}
print_help() {
cat <<'HELP'
DashCaddy Linux Installer
QUICK INSTALL (zero typing):
curl -fsSL https://get.dashcaddy.net | bash -s -- quick
WITH DOMAIN:
curl -fsSL https://get.dashcaddy.net | bash -s -- --domain my.example.com
INTERACTIVE:
curl -fsSL https://get.dashcaddy.net | bash
OPTIONS:
quick Instant local install, zero questions
--domain DOMAIN Public domain (Let's Encrypt TLS)
--email EMAIL Let's Encrypt email (optional)
--tld TLD Custom TLD, internal CA (e.g., .home)
--local Local mode (http://IP:port)
--port PORT Port for local mode (default: 8080)
--source PATH Use local source files
--skip-docker Already have Docker
--skip-caddy Already have Caddy
--uninstall Remove DashCaddy
--keep-config Keep configs during uninstall
--yes Skip confirmations
HELP
}
# ============================================================================
# Banner & Success
# ============================================================================
print_banner() {
echo ""
echo -e "${BOLD}${BLUE} ╔══════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}${BLUE}${NC}${BOLD} DashCaddy Installer v${DASHCADDY_VERSION} ${BLUE}${NC}"
echo -e "${BOLD}${BLUE} ╚══════════════════════════════════════════════╝${NC}"
echo ""
}
print_success() {
local total_time=$1
local url
case "$DOMAIN_MODE" in
public) url="https://${DOMAIN}" ;;
custom-tld) url="https://dashcaddy${TLD}" ;;
local) url="http://${PUBLIC_IP}:${LOCAL_PORT}" ;;
esac
# Also show LAN URL for local mode
local lan_url=""
if [[ "$DOMAIN_MODE" == "local" && "$LAN_IP" != "$PUBLIC_IP" ]]; then
lan_url="http://${LAN_IP}:${LOCAL_PORT}"
fi
echo ""
echo -e "${GREEN}${BOLD} ┌──────────────────────────────────────────────┐${NC}"
echo -e "${GREEN}${BOLD} │ Installation Complete! │${NC}"
echo -e "${GREEN}${BOLD} └──────────────────────────────────────────────┘${NC}"
echo ""
echo -e " ${BOLD}Open in browser:${NC} ${url}"
[[ -n "$lan_url" ]] && echo -e " ${BOLD}LAN access:${NC} ${lan_url}"
echo ""
echo -e " ${DIM}Config: ${INSTALL_DIR}/ | Logs: docker logs dashcaddy-api${NC}"
echo -e " ${DIM}Installed in: ${total_time}${NC}"
if [[ "$DOMAIN_MODE" == "public" ]]; then
echo ""
echo -e " ${CYAN}TLS will auto-provision on first visit.${NC}"
echo -e " ${CYAN}Ensure DNS for ${DOMAIN}${PUBLIC_IP}${NC}"
fi
echo ""
echo -e " ${BOLD}Everything is managed from the dashboard — no CLI needed.${NC}"
echo -e " ${DIM}Deploy apps, manage Docker containers, edit Caddy configs,${NC}"
echo -e " ${DIM}and monitor services all from the web UI.${NC}"
echo ""
}
# ============================================================================
# Main
# ============================================================================
main() {
local start_time
start_time=$(date +%s)
parse_args "$@"
# Handle uninstall
if $UNINSTALL; then do_uninstall; fi
print_banner
# ---- Step 1: System check ----
step "Checking system"
detect_system
fix_system_issues
# ---- Step 2: Configuration ----
if $QUICK; then
ok "Quick mode: http://${PUBLIC_IP}:${LOCAL_PORT}"
else
step "Configuration"
interactive_setup
fi
# Show what we're doing
echo ""
case "$DOMAIN_MODE" in
public) log "Installing with domain: ${DOMAIN}" ;;
custom-tld) log "Installing with TLD: ${TLD}" ;;
local) log "Installing in local mode (port ${LOCAL_PORT})" ;;
esac
# ---- Step 3: Dependencies ----
step "Installing dependencies"
install_prereqs
install_docker
install_caddy
# ---- Step 4: Deploy files ----
step "Deploying DashCaddy"
create_directories
fetch_source
create_seed_configs
# ---- Step 5: Configuration ----
step "Generating configuration"
generate_caddyfile
generate_docker_compose
open_firewall
# ---- Step 6: Build & start ----
step "Building & starting services"
build_and_start
# ---- Step 7: Start Caddy ----
step "Starting web server"
start_caddy
print_success "$(elapsed "$start_time")"
}
main "$@"

View File

@@ -0,0 +1,87 @@
{
"name": "dashcaddy-installer",
"version": "1.0.0",
"description": "Cross-platform installer for DashCaddy platform",
"main": "src/main/index.js",
"scripts": {
"start": "electron .",
"dev": "electron . --dev",
"test": "jest",
"test:watch": "jest --watch",
"build": "electron-builder",
"build:win": "electron-builder --win",
"build:mac": "electron-builder --mac",
"build:linux": "electron-builder --linux"
},
"keywords": [
"dashcaddy",
"installer",
"docker",
"caddy"
],
"author": {
"name": "DashCaddy Team",
"email": "dashcaddy@sami.cloud"
},
"homepage": "https://github.com/dashcaddy/dashcaddy",
"license": "MIT",
"devDependencies": {
"electron": "^28.3.3",
"electron-builder": "^24.9.1",
"fast-check": "^3.15.0",
"jest": "^29.7.0"
},
"build": {
"appId": "com.dashcaddy.installer",
"productName": "DashCaddy Installer",
"asar": true,
"directories": {
"output": "build-output"
},
"files": [
"src/**/*",
"assets/**/*",
"templates/**/*"
],
"extraResources": [
{
"from": "../status",
"to": "status",
"filter": ["**/*", "!node_modules/**", "!.git/**", "!**/*.test.js", "!**/*.spec.js"]
},
{
"from": "../dashcaddy-api",
"to": "dashcaddy-api",
"filter": ["**/*", "!node_modules/**", "!.git/**", "!**/*.test.js", "!**/*.spec.js"]
}
],
"icon": "assets/favicon.ico",
"win": {
"target": [
"nsis",
"portable"
],
"icon": "assets/favicon.ico",
"signAndEditExecutable": false
},
"mac": {
"target": "dmg",
"icon": "assets/dashcaddy-logo.png"
},
"linux": {
"target": [
"AppImage",
"deb"
],
"icon": "assets/dashcaddy-logo.png",
"category": "Utility"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"installerIcon": "assets/icon.ico",
"uninstallerIcon": "assets/icon.ico",
"installerHeaderIcon": "assets/icon.ico"
}
}
}

View File

@@ -0,0 +1,111 @@
const { exec } = require('child_process');
const { promisify } = require('util');
const { getPlatformInfo } = require('../shared/platform-utils');
const execAsync = promisify(exec);
/**
* BrowserLauncher - Opens URLs in the default browser
*/
class BrowserLauncher {
/**
* Open URL in default browser
* @param {string} url - URL to open
*/
async openURL(url) {
try {
const platform = getPlatformInfo();
let command;
switch (platform.os) {
case 'windows':
command = `start ${url}`;
break;
case 'macos':
command = `open ${url}`;
break;
case 'linux':
command = `xdg-open ${url}`;
break;
default:
throw new Error(`Unsupported platform: ${platform.os}`);
}
await execAsync(command);
return {
success: true,
url,
platform: platform.os
};
} catch (error) {
return {
success: false,
error: error.message,
url
};
}
}
/**
* Open dashboard in browser
* @param {number} port - Port dashboard is running on
* @param {string} hostname - Hostname (default: localhost)
*/
async openDashboard(port = 8080, hostname = 'localhost') {
const url = `http://${hostname}:${port}`;
return this.openURL(url);
}
/**
* Wait for service to be ready before opening
* @param {string} url - URL to check
* @param {number} maxAttempts - Maximum number of attempts
* @param {number} delayMs - Delay between attempts in milliseconds
*/
async waitForService(url, maxAttempts = 30, delayMs = 1000) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const response = await fetch(url);
if (response.ok) {
return { success: true, attempts: attempt };
}
} catch (error) {
// Service not ready yet, continue waiting
}
if (attempt < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
return {
success: false,
error: `Service did not become ready after ${maxAttempts} attempts`
};
}
/**
* Open dashboard after waiting for it to be ready
* @param {number} port - Port dashboard is running on
* @param {string} hostname - Hostname (default: localhost)
*/
async openDashboardWhenReady(port = 8080, hostname = 'localhost') {
const url = `http://${hostname}:${port}`;
// Wait for service to be ready
const waitResult = await this.waitForService(url);
if (!waitResult.success) {
return {
success: false,
error: waitResult.error
};
}
// Open in browser
return this.openURL(url);
}
}
module.exports = BrowserLauncher;

View File

@@ -0,0 +1,375 @@
const fs = require('fs');
const path = require('path');
const { DEFAULT_PORTS } = require('../shared/constants');
/**
* CaddyfileGenerator - Creates Caddyfile and docker-compose.yml for DashCaddy
*
* Generates production-grade configs that match the patterns used by the
* running DashCaddy deployment (CORS snippets, admin origins, PKI, etc.)
*/
class CaddyfileGenerator {
/**
* Normalize a Windows path to forward slashes for Caddyfile/Docker Compose
*/
_p(s) {
return s.replace(/\\/g, '/');
}
/**
* Generate the CORS snippet block used by the API
*/
_corsSnippets() {
return `(cors-preflight) {
@preflight method OPTIONS
header @preflight {
Access-Control-Allow-Origin "*"
Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS"
Access-Control-Allow-Headers "*"
Access-Control-Max-Age "600"
}
respond @preflight 204
}
(cors-allow) {
header {
Access-Control-Allow-Origin "*"
Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS"
Access-Control-Allow-Headers "*"
Access-Control-Max-Age "600"
}
}
`;
}
/**
* Generate the dashcaddy_auth snippet for SSO
*/
_authSnippet(apiPort) {
return `# DashCaddy SSO auth snippet
(dashcaddy_auth) {
forward_auth localhost:${apiPort} {
uri /api/auth/gate/{args[0]}
copy_headers Authorization X-Api-Key X-App-Cookie X-Emby-Token X-Plex-Token
}
}
`;
}
/**
* Generate the dashboard site block content (shared across all modes)
*/
_dashboardBlock(dashboardPath, tier, apiPort) {
let block = '';
block += ` root * ${this._p(dashboardPath)}\n`;
block += ` encode gzip\n\n`;
// API proxy for intermediate/advanced tiers
if (tier !== 'basic') {
block += ` # API proxy to Docker container\n`;
block += ` handle /api/* {\n`;
block += ` reverse_proxy localhost:${apiPort}\n`;
block += ` }\n\n`;
}
// SPA fallback with file_server
block += ` # Static site + SPA fallback\n`;
block += ` handle {\n`;
block += ` @notFile not file {path}\n`;
block += ` rewrite @notFile /index.html\n`;
block += ` file_server\n`;
block += ` }\n`;
return block;
}
/**
* Generate Caddyfile for local mode (IP:Port, no TLS)
*/
generateCaddyfile(options) {
const {
dashboardPath,
port = 8080,
tier = 'basic',
apiPort = DEFAULT_PORTS.API
} = options;
const adminPort = DEFAULT_PORTS.CADDY_ADMIN;
let caddyfile = `# DashCaddy Caddyfile - Local Mode
# Generated by DashCaddy Installer
{
admin localhost:${adminPort} {
origins localhost localhost:${adminPort} host.docker.internal host.docker.internal:${adminPort}
}
auto_https off
}
`;
if (tier !== 'basic') {
caddyfile += this._corsSnippets() + '\n';
}
caddyfile += `:${port} {\n`;
caddyfile += this._dashboardBlock(dashboardPath, tier, apiPort);
caddyfile += `}\n`;
return caddyfile;
}
/**
* Generate Caddyfile for public domain mode (Let's Encrypt TLS)
*/
generateCaddyfileWithDomain(options) {
const {
dashboardPath,
domain,
email = '',
tier = 'basic',
apiPort = DEFAULT_PORTS.API
} = options;
const adminPort = DEFAULT_PORTS.CADDY_ADMIN;
let caddyfile = `# DashCaddy Caddyfile - Public Domain Mode
# Generated by DashCaddy Installer
{
admin localhost:${adminPort} {
origins localhost localhost:${adminPort} host.docker.internal host.docker.internal:${adminPort}
}
`;
if (email) {
caddyfile += ` email ${email}\n`;
}
caddyfile += `}\n\n`;
if (tier !== 'basic') {
caddyfile += this._corsSnippets() + '\n';
caddyfile += this._authSnippet(apiPort) + '\n';
}
caddyfile += `${domain} {\n`;
caddyfile += this._dashboardBlock(dashboardPath, tier, apiPort);
caddyfile += `}\n`;
return caddyfile;
}
/**
* Generate Caddyfile for custom TLD mode (internal CA)
*/
generateCaddyfileCustomTLD(options) {
const {
dashboardPath,
installPath,
tld = '.home',
caName = 'DashCaddy Local CA',
tier = 'basic',
apiPort = DEFAULT_PORTS.API
} = options;
const adminPort = DEFAULT_PORTS.CADDY_ADMIN;
const dashboardDomain = `dashcaddy${tld}`;
const certsPath = installPath ? this._p(path.join(installPath, 'certs')) : './certs';
let caddyfile = `# DashCaddy Caddyfile - Custom TLD Mode (${tld})
# Generated by DashCaddy Installer
{
storage file_system {
root ${certsPath}
}
admin localhost:${adminPort} {
origins localhost localhost:${adminPort} host.docker.internal host.docker.internal:${adminPort}
}
pki {
ca local {
name "${caName}"
root_cn "${caName} Root CA"
intermediate_cn "${caName} Intermediate CA"
}
}
}
`;
if (tier !== 'basic') {
caddyfile += this._corsSnippets() + '\n';
caddyfile += this._authSnippet(apiPort) + '\n';
}
caddyfile += `# Dashboard - ${dashboardDomain}\n`;
caddyfile += `${dashboardDomain} {\n`;
caddyfile += ` tls internal\n\n`;
caddyfile += this._dashboardBlock(dashboardPath, tier, apiPort);
caddyfile += `}\n`;
return caddyfile;
}
/**
* Write Caddyfile to disk
*/
async writeCaddyfile(content, outputPath) {
try {
await fs.promises.writeFile(outputPath, content, 'utf8');
return { success: true, path: outputPath };
} catch (error) {
return { success: false, error: error.message };
}
}
/**
* Create complete Caddyfile setup
* @param {string} installPath - Installation directory
* @param {Object} options - Configuration options
*/
async createCaddyfileSetup(installPath, options = {}) {
try {
const dashboardPath = path.join(installPath, 'sites', 'status');
const caddyfilePath = path.join(installPath, 'Caddyfile');
let content;
if (options.domainMode === 'public') {
content = this.generateCaddyfileWithDomain({
dashboardPath,
domain: options.publicDomain,
email: options.email,
tier: options.tier,
apiPort: options.apiPort
});
} else if (options.domainMode === 'custom-tld') {
content = this.generateCaddyfileCustomTLD({
dashboardPath,
installPath,
tld: options.tld,
caName: options.caName || 'DashCaddy Local CA',
tier: options.tier,
apiPort: options.apiPort
});
} else {
content = this.generateCaddyfile({
dashboardPath,
...options
});
}
const result = await this.writeCaddyfile(content, caddyfilePath);
if (!result.success) {
throw new Error(result.error);
}
return {
success: true,
path: caddyfilePath,
content
};
} catch (error) {
return {
success: false,
error: error.message
};
}
}
/**
* Generate docker-compose.yml for running the API server
* @param {string} installPath - Installation directory
* @param {Object} options - Configuration options
* @param {number} options.apiPort - API server port
* @param {string} options.lanIP - Host LAN IP address
* @param {string} options.tailscaleIP - Host Tailscale IP address
* @param {string} options.domainMode - Domain mode (local, public, custom-tld)
*/
generateDockerCompose(installPath, options = {}) {
const apiPort = options.apiPort || DEFAULT_PORTS.API;
const adminPort = DEFAULT_PORTS.CADDY_ADMIN;
const p = this._p.bind(this);
// Core volume mounts
let volumes = ` - ${p(installPath)}/Caddyfile:/caddyfile:rw
- ${p(installPath)}/services.json:/app/services.json:rw
- ${p(installPath)}/dns-credentials.json:/app/dns-credentials.json:rw
- ${p(installPath)}/config.json:/app/config.json:rw
- ${p(installPath)}/credentials.json:/app/credentials.json:rw
- ${p(installPath)}/.encryption-key:/app/.encryption-key:rw
- ${p(installPath)}/notifications.json:/app/notifications.json:rw
- ${p(installPath)}/sites/status/assets:/app/assets:rw
- /var/run/docker.sock:/var/run/docker.sock`;
// Add CA cert mount for custom-tld mode
if (options.domainMode === 'custom-tld') {
volumes += `\n - ${p(installPath)}/certs/pki/authorities/local:/app/pki:ro`;
}
// Environment variables
let envVars = ` - CADDYFILE_PATH=/caddyfile
- CADDY_ADMIN_URL=http://host.docker.internal:${adminPort}
- ASSETS_PATH=/app/assets
- CREDENTIALS_FILE=/app/credentials.json
- NODE_ENV=production`;
if (options.domainMode === 'custom-tld') {
envVars += `\n - CA_CERT_PATH=/app/pki/root.crt`;
}
if (options.lanIP) {
envVars += `\n - HOST_LAN_IP=${options.lanIP}`;
}
if (options.tailscaleIP) {
envVars += `\n - HOST_TAILSCALE_IP=${options.tailscaleIP}`;
}
const dockerCompose = `services:
dashcaddy-api:
build: .
container_name: dashcaddy-api
ports:
- "${apiPort}:${apiPort}"
volumes:
${volumes}
environment:
${envVars}
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
`;
return dockerCompose;
}
/**
* Write docker-compose.yml to disk
* @param {string} installPath - Installation directory
* @param {Object} options - Configuration options
*/
async createDockerCompose(installPath, options = {}) {
try {
const content = this.generateDockerCompose(installPath, options);
const apiDir = path.join(installPath, 'sites', 'dashcaddy-api');
await fs.promises.mkdir(apiDir, { recursive: true });
const outputPath = path.join(apiDir, 'docker-compose.yml');
await fs.promises.writeFile(outputPath, content, 'utf8');
return {
success: true,
path: outputPath,
content
};
} catch (error) {
return {
success: false,
error: error.message
};
}
}
}
module.exports = CaddyfileGenerator;

View File

@@ -0,0 +1,781 @@
const fs = require('fs').promises;
const path = require('path');
const { REQUIRED_DIRS } = require('../shared/constants');
// Import crypto utilities for secure credential storage
let cryptoUtils;
try {
// Try to load from dashcaddy-api if available
cryptoUtils = require('../../dashcaddy-api/crypto-utils');
} catch {
// Fallback: create minimal crypto implementation
const crypto = require('crypto');
const ALGORITHM = 'aes-256-gcm';
const KEY_LENGTH = 32;
const IV_LENGTH = 16;
let encryptionKey = null;
function loadOrCreateKey() {
if (encryptionKey) return encryptionKey;
if (process.env.DASHCADDY_ENCRYPTION_KEY) {
encryptionKey = Buffer.from(process.env.DASHCADDY_ENCRYPTION_KEY, 'hex');
return encryptionKey;
}
encryptionKey = crypto.randomBytes(KEY_LENGTH);
console.warn('[ConfigManager] Using ephemeral encryption key - credentials will not persist across restarts');
return encryptionKey;
}
cryptoUtils = {
encrypt: (data) => {
const key = loadOrCreateKey();
const iv = crypto.randomBytes(IV_LENGTH);
const plaintext = typeof data === 'object' ? JSON.stringify(data) : String(data);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
let encrypted = cipher.update(plaintext, 'utf8', 'base64');
encrypted += cipher.final('base64');
const authTag = cipher.getAuthTag();
return `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted}`;
},
decrypt: (encryptedData) => {
const key = loadOrCreateKey();
const parts = encryptedData.split(':');
if (parts.length !== 3) throw new Error('Invalid encrypted data format');
const iv = Buffer.from(parts[0], 'base64');
const authTag = Buffer.from(parts[1], 'base64');
const ciphertext = parts[2];
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(ciphertext, 'base64', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
},
isEncrypted: (data) => {
if (typeof data !== 'string') return false;
const parts = data.split(':');
return parts.length === 3;
}
};
}
class ConfigManager {
/**
* Saves installation configuration to disk
* @param {Object} config - Configuration object
* @param {string} installPath - Installation directory path
* @returns {Promise<Object>} { success: boolean, path: string }
*/
async saveConfig(config, installPath) {
try {
const configPath = path.join(installPath, 'config.json');
// Add metadata
const configWithMetadata = {
...config,
version: config.version || '1.0.0',
lastModified: new Date().toISOString()
};
// Write config file
await fs.writeFile(
configPath,
JSON.stringify(configWithMetadata, null, 2),
'utf8'
);
return {
success: true,
path: configPath
};
} catch (error) {
return {
success: false,
error: error.message
};
}
}
/**
* Loads installation configuration from disk
* @param {string} installPath - Installation directory path
* @returns {Promise<Object>} { config: object, exists: boolean }
*/
async loadConfig(installPath) {
try {
const configPath = path.join(installPath, 'config.json');
// Check if config exists
try {
await fs.access(configPath);
} catch {
return {
config: null,
exists: false
};
}
// Read and parse config
const configData = await fs.readFile(configPath, 'utf8');
const config = JSON.parse(configData);
return {
config: config,
exists: true
};
} catch (error) {
return {
config: null,
exists: false,
error: error.message
};
}
}
/**
* Creates required directory structure
* @param {string} installPath - Installation directory path
* @returns {Promise<Object>} { success: boolean, paths: array }
*/
async createDirectories(installPath) {
try {
const createdPaths = [];
// Create main installation directory
await fs.mkdir(installPath, { recursive: true });
createdPaths.push(installPath);
// Create required subdirectories
for (const dir of REQUIRED_DIRS) {
const dirPath = path.join(installPath, dir);
await fs.mkdir(dirPath, { recursive: true });
createdPaths.push(dirPath);
}
return {
success: true,
paths: createdPaths
};
} catch (error) {
return {
success: false,
error: error.message,
paths: []
};
}
}
/**
* Validates that a path is writable
* @param {string} testPath - Path to validate
* @returns {Promise<Object>} { valid: boolean, message: string }
*/
async validatePath(testPath) {
try {
// Try to create the directory if it doesn't exist
await fs.mkdir(testPath, { recursive: true });
// Try to write a test file
const testFile = path.join(testPath, '.dashcaddy-test');
await fs.writeFile(testFile, 'test', 'utf8');
// Clean up test file
await fs.unlink(testFile);
return {
valid: true,
message: 'Path is writable'
};
} catch (error) {
let message = 'Path is not writable';
if (error.code === 'EACCES') {
message = 'Permission denied. Please choose a different location or run as administrator.';
} else if (error.code === 'ENOENT') {
message = 'Path does not exist and cannot be created.';
} else if (error.code === 'ENOSPC') {
message = 'Not enough disk space.';
}
return {
valid: false,
message: message,
error: error.message
};
}
}
/**
* Checks if an installation exists at the given path
* @param {string} installPath - Path to check
* @returns {Promise<boolean>} True if installation exists
*/
async installationExists(installPath) {
try {
const configPath = path.join(installPath, 'config.json');
await fs.access(configPath);
return true;
} catch {
return false;
}
}
/**
* Gets disk space information for a path
* @param {string} testPath - Path to check
* @returns {Promise<Object>} Disk space info
*/
async getDiskSpace(testPath) {
// Note: This is a simplified version. In production, you'd use a library like 'check-disk-space'
try {
const stats = await fs.stat(testPath);
return {
available: true,
path: testPath
};
} catch (error) {
return {
available: false,
error: error.message
};
}
}
/**
* Saves DNS credentials securely with encryption
* @param {Object} credentials - DNS credentials
* @param {string} installPath - Installation directory path
* @returns {Promise<Object>} { success: boolean, path: string }
*/
async saveDNSCredentials(credentials, installPath) {
try {
const credPath = path.join(installPath, 'dns-credentials.json');
// Encrypt sensitive fields
const credentialsToSave = {
server: credentials.server,
username: credentials.username,
// Encrypt password and token
password: credentials.password ? cryptoUtils.encrypt(credentials.password) : null,
token: credentials.token ? cryptoUtils.encrypt(credentials.token) : null,
apiKey: credentials.apiKey ? cryptoUtils.encrypt(credentials.apiKey) : null,
_encrypted: true,
_encryptedFields: ['password', 'token', 'apiKey'],
savedAt: new Date().toISOString()
};
await fs.writeFile(
credPath,
JSON.stringify(credentialsToSave, null, 2),
'utf8'
);
// Set restrictive permissions (Unix-like systems)
try {
await fs.chmod(credPath, 0o600); // Read/write for owner only
} catch {
// Windows doesn't support chmod, that's okay
}
console.log('[ConfigManager] DNS credentials saved with encryption');
return {
success: true,
path: credPath
};
} catch (error) {
console.error('[ConfigManager] Failed to save DNS credentials:', error.message);
return {
success: false,
error: error.message
};
}
}
/**
* Loads DNS credentials and decrypts them
* @param {string} installPath - Installation directory path
* @returns {Promise<Object>} { credentials: object, exists: boolean }
*/
async loadDNSCredentials(installPath) {
try {
const credPath = path.join(installPath, 'dns-credentials.json');
// Check if credentials exist
try {
await fs.access(credPath);
} catch {
return {
credentials: null,
exists: false
};
}
// Read and parse credentials
const credData = await fs.readFile(credPath, 'utf8');
const credentials = JSON.parse(credData);
// Decrypt sensitive fields if encrypted
if (credentials._encrypted) {
const decrypted = {
server: credentials.server,
username: credentials.username,
password: credentials.password && cryptoUtils.isEncrypted(credentials.password)
? cryptoUtils.decrypt(credentials.password)
: credentials.password,
token: credentials.token && cryptoUtils.isEncrypted(credentials.token)
? cryptoUtils.decrypt(credentials.token)
: credentials.token,
apiKey: credentials.apiKey && cryptoUtils.isEncrypted(credentials.apiKey)
? cryptoUtils.decrypt(credentials.apiKey)
: credentials.apiKey,
savedAt: credentials.savedAt
};
console.log('[ConfigManager] DNS credentials loaded and decrypted');
return {
credentials: decrypted,
exists: true
};
}
// Legacy plaintext credentials - migrate on next save
console.warn('[ConfigManager] Found plaintext DNS credentials - will encrypt on next save');
return {
credentials: credentials,
exists: true,
needsMigration: true
};
} catch (error) {
console.error('[ConfigManager] Failed to load DNS credentials:', error.message);
return {
credentials: null,
exists: false,
error: error.message
};
}
}
/**
* Files that constitute "user settings" — preserved on uninstall if requested.
* These are config/credential files the user would want to keep for a reinstall.
*/
static get USER_SETTINGS_FILES() {
return [
'config.json',
'dns-credentials.json',
'credentials.json',
'branding.json',
'services.json',
'.encryption-key',
'notifications.json',
'backup-config.json'
];
}
/**
* CA certificate paths (relative to installPath) to preserve on uninstall.
* Caddy's built-in PKI stores root + intermediate certs and keys here.
* Regenerating these would invalidate every device that trusted the old root.
*/
static get CA_CERT_PATHS() {
return [
'certs/pki/authorities/local/root.crt',
'certs/pki/authorities/local/root.key',
'certs/pki/authorities/local/intermediate.crt',
'certs/pki/authorities/local/intermediate.key'
];
}
/**
* Copies a list of files (relative paths) from installPath into backupDir,
* recreating subdirectory structure as needed.
* @param {string} installPath - Source root
* @param {string} backupDir - Destination root
* @param {string[]} relativePaths - Files to copy
* @returns {Promise<string[]>} List of files that were actually copied
*/
async _copyFilesToBackup(installPath, backupDir, relativePaths) {
const copied = [];
for (const relPath of relativePaths) {
const src = path.join(installPath, relPath);
const dest = path.join(backupDir, relPath);
try {
await fs.access(src);
await fs.mkdir(path.dirname(dest), { recursive: true });
await fs.copyFile(src, dest);
copied.push(relPath);
} catch {
// File doesn't exist, skip
}
}
return copied;
}
/**
* Removes all installation files and directories
* @param {string} installPath - Installation directory path
* @param {Object} options - Uninstall options
* @param {boolean} options.preserveSettings - Keep user config/credentials for reinstall
* @param {boolean} options.preserveCA - Keep CA certificates (root + intermediate keys)
* @returns {Promise<Object>} { success: boolean, message: string, settingsPath?: string }
*/
async removeInstallation(installPath, options = {}) {
try {
// Verify this is a DashCaddy installation before removing
const exists = await this.installationExists(installPath);
if (!exists) {
return {
success: false,
message: 'No DashCaddy installation found at this path'
};
}
const needsBackup = options.preserveSettings || options.preserveCA;
let settingsBackupPath = null;
if (needsBackup) {
// Back up to a temp location outside the install dir so rm -rf doesn't eat it
const os = require('os');
const tempBackupDir = path.join(os.tmpdir(), 'dashcaddy-settings-backup');
// Clean any previous leftover
try { await fs.rm(tempBackupDir, { recursive: true, force: true }); } catch {}
await fs.mkdir(tempBackupDir, { recursive: true });
if (options.preserveSettings) {
await this._copyFilesToBackup(installPath, tempBackupDir, ConfigManager.USER_SETTINGS_FILES);
// Also grab custom logos
const assetsDir = path.join(installPath, 'sites', 'status', 'assets');
try {
const entries = await fs.readdir(assetsDir);
const customLogos = entries.filter(f => f.startsWith('custom-logo'));
await this._copyFilesToBackup(
installPath,
tempBackupDir,
customLogos.map(f => path.join('sites', 'status', 'assets', f))
);
} catch {}
}
if (options.preserveCA) {
await this._copyFilesToBackup(installPath, tempBackupDir, ConfigManager.CA_CERT_PATHS);
}
// Nuke the install directory
await fs.rm(installPath, { recursive: true, force: true });
// Recreate install dir and move preserved files back
const settingsDir = path.join(installPath, '.dashcaddy-settings');
await fs.mkdir(settingsDir, { recursive: true });
// Recursively copy everything from temp back
await this._restoreFromTemp(tempBackupDir, settingsDir);
// Clean up temp
await fs.rm(tempBackupDir, { recursive: true, force: true });
settingsBackupPath = settingsDir;
} else {
// Remove everything
await fs.rm(installPath, { recursive: true, force: true });
}
const parts = [];
if (options.preserveSettings) parts.push('settings');
if (options.preserveCA) parts.push('CA certificates');
return {
success: true,
message: parts.length > 0
? `Installation removed. Preserved: ${parts.join(' and ')}.`
: 'Installation removed successfully',
settingsPath: settingsBackupPath
};
} catch (error) {
return {
success: false,
message: 'Failed to remove installation',
error: error.message
};
}
}
/**
* Recursively copies all files from src directory to dest directory
* @param {string} src - Source directory
* @param {string} dest - Destination directory
*/
async _restoreFromTemp(src, dest) {
const entries = await fs.readdir(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
await fs.mkdir(destPath, { recursive: true });
await this._restoreFromTemp(srcPath, destPath);
} else {
await fs.mkdir(path.dirname(destPath), { recursive: true });
await fs.copyFile(srcPath, destPath);
}
}
}
/**
* Restores user settings and CA certs from a previous uninstall.
* The backup in .dashcaddy-settings mirrors the original directory structure.
* @param {string} installPath - Installation directory path
* @returns {Promise<Object>} { success: boolean, restored: string[] }
*/
async restoreUserSettings(installPath) {
try {
const settingsDir = path.join(installPath, '.dashcaddy-settings');
try {
await fs.access(settingsDir);
} catch {
return { success: false, restored: [], message: 'No saved settings found' };
}
const restored = [];
// Recursively restore all files back to installPath
const restoreDir = async (srcDir, destDir) => {
const entries = await fs.readdir(srcDir, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(srcDir, entry.name);
const destPath = path.join(destDir, entry.name);
if (entry.isDirectory()) {
await fs.mkdir(destPath, { recursive: true });
await restoreDir(srcPath, destPath);
} else {
await fs.mkdir(path.dirname(destPath), { recursive: true });
await fs.copyFile(srcPath, destPath);
restored.push(path.relative(installPath, destPath));
}
}
};
await restoreDir(settingsDir, installPath);
// Clean up the settings backup dir
await fs.rm(settingsDir, { recursive: true, force: true });
return { success: true, restored };
} catch (error) {
return { success: false, restored: [], error: error.message };
}
}
/**
* Lists all files in the installation directory
* @param {string} installPath - Installation directory path
* @returns {Promise<Array>} List of files
*/
async listInstallationFiles(installPath) {
try {
const files = [];
async function scanDirectory(dir) {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await scanDirectory(fullPath);
} else {
files.push(fullPath);
}
}
}
await scanDirectory(installPath);
return files;
} catch (error) {
return [];
}
}
/**
* Saves branding configuration
* @param {Object} branding - Branding configuration
* @param {string} installPath - Installation directory path
* @returns {Promise<Object>} { success: boolean }
*/
async saveBranding(branding, installPath) {
try {
const brandingPath = path.join(installPath, 'branding.json');
const assetsPath = path.join(installPath, 'sites', 'status', 'assets');
// Ensure directories exist
await fs.mkdir(path.dirname(brandingPath), { recursive: true });
await fs.mkdir(assetsPath, { recursive: true });
// If a logo source path is provided, copy it to assets
if (branding.logoSourcePath) {
const logoExt = path.extname(branding.logoSourcePath);
const logoDestName = `custom-logo${logoExt}`;
const logoDestPath = path.join(assetsPath, logoDestName);
await fs.copyFile(branding.logoSourcePath, logoDestPath);
branding.logoUrl = `/assets/${logoDestName}`;
delete branding.logoSourcePath; // Don't save source path
}
// Default branding values
const brandingConfig = {
name: branding.name || 'DashCaddy',
title: branding.title || 'DashCaddy Dashboard',
logoUrl: branding.logoUrl || '/assets/dashcaddy-logo.png',
faviconUrl: branding.faviconUrl || '/assets/favicon.ico',
primaryColor: branding.primaryColor || '#6366f1',
showWatermark: branding.showWatermark !== false,
updatedAt: new Date().toISOString()
};
await fs.writeFile(
brandingPath,
JSON.stringify(brandingConfig, null, 2),
'utf8'
);
return {
success: true,
path: brandingPath,
branding: brandingConfig
};
} catch (error) {
return {
success: false,
error: error.message
};
}
}
/**
* Loads branding configuration
* @param {string} installPath - Installation directory path
* @returns {Promise<Object>} { branding: object, exists: boolean }
*/
async loadBranding(installPath) {
try {
const brandingPath = path.join(installPath, 'branding.json');
try {
await fs.access(brandingPath);
} catch {
// Return default branding if file doesn't exist
return {
branding: {
name: 'DashCaddy',
title: 'DashCaddy Dashboard',
logoUrl: '/assets/dashcaddy-logo.png',
faviconUrl: '/assets/favicon.ico',
primaryColor: '#6366f1',
showWatermark: true
},
exists: false
};
}
const brandingData = await fs.readFile(brandingPath, 'utf8');
const branding = JSON.parse(brandingData);
return {
branding,
exists: true
};
} catch (error) {
return {
branding: null,
exists: false,
error: error.message
};
}
}
/**
* Creates seed JSON files that DashCaddy needs to start.
* Called after createDirectories() during installation.
* @param {string} installPath - Installation directory path
* @returns {Promise<Object>} { success: boolean, files: string[] }
*/
async seedFiles(installPath) {
try {
const crypto = require('crypto');
const createdFiles = [];
const seedData = {
'services.json': '[]',
'dns-credentials.json': '{}',
'credentials.json': '{}',
'notifications.json': '{}'
};
for (const [filename, content] of Object.entries(seedData)) {
const filePath = path.join(installPath, filename);
// Only create if doesn't already exist
try {
await fs.access(filePath);
} catch {
await fs.writeFile(filePath, content, 'utf8');
createdFiles.push(filename);
}
}
// Create .encryption-key (random 32-byte hex) if missing
const keyPath = path.join(installPath, '.encryption-key');
try {
await fs.access(keyPath);
} catch {
const key = crypto.randomBytes(32).toString('hex');
await fs.writeFile(keyPath, key, 'utf8');
// Restrict permissions on key file
try { await fs.chmod(keyPath, 0o600); } catch {}
createdFiles.push('.encryption-key');
}
return { success: true, files: createdFiles };
} catch (error) {
return { success: false, error: error.message };
}
}
/**
* Copies default DashCaddy logo to assets folder
* @param {string} installPath - Installation directory path
* @param {string} sourceLogo - Path to source logo file
* @returns {Promise<Object>} { success: boolean }
*/
async setupDefaultBranding(installPath, sourceLogo) {
try {
const assetsPath = path.join(installPath, 'sites', 'status', 'assets');
await fs.mkdir(assetsPath, { recursive: true });
// If source logo provided, copy it
if (sourceLogo) {
const logoDestPath = path.join(assetsPath, 'dashcaddy-logo.png');
await fs.copyFile(sourceLogo, logoDestPath);
}
// Create default branding config
await this.saveBranding({
name: 'DashCaddy',
title: 'DashCaddy Dashboard',
logoUrl: '/assets/dashcaddy-logo.png'
}, installPath);
return { success: true };
} catch (error) {
return {
success: false,
error: error.message
};
}
}
}
module.exports = ConfigManager;

View File

@@ -0,0 +1,837 @@
const fc = require('fast-check');
const ConfigManager = require('./config-manager');
const fs = require('fs').promises;
const path = require('path');
const os = require('os');
/**
* Property-based tests for ConfigManager
* These tests validate universal properties across all valid inputs
*/
describe('ConfigManager Property Tests', () => {
let manager;
let testDir;
beforeEach(async () => {
manager = new ConfigManager();
testDir = path.join(os.tmpdir(), `dashcaddy-prop-test-${Date.now()}`);
});
afterEach(async () => {
try {
await fs.rm(testDir, { recursive: true, force: true });
} catch (error) {
// Ignore cleanup errors
}
});
/**
* Feature: dashcaddy-installer, Property 5: Configuration Persistence
* For any installation configuration, saving and then loading the configuration
* should produce an equivalent configuration object.
* Validates: Requirements 2.6, 10.1, 10.2
*/
describe('Property 5: Configuration Persistence', () => {
// Generator for valid configuration objects
const configArbitrary = () => fc.record({
installPath: fc.constant(testDir),
tier: fc.constantFrom('basic', 'intermediate', 'advanced'),
dashboardName: fc.string({ minLength: 1, maxLength: 50 }),
customTLD: fc.option(fc.string({ minLength: 2, maxLength: 10 }).map(s => '.' + s)),
caddyAdminUrl: fc.constant('http://localhost:2021'),
apiPort: fc.integer({ min: 1024, max: 65535 }),
dnsEnabled: fc.boolean(),
tailscaleEnabled: fc.boolean()
});
test('configuration round-trips correctly', async () => {
await fc.assert(
fc.asyncProperty(
configArbitrary(),
async (config) => {
// Save configuration
const saveResult = await manager.saveConfig(config, testDir);
if (!saveResult.success) return true; // Skip if save failed
// Load configuration
const loadResult = await manager.loadConfig(testDir);
if (!loadResult.exists) return false;
// Verify all original fields are preserved
const loaded = loadResult.config;
return (
loaded.installPath === config.installPath &&
loaded.tier === config.tier &&
loaded.dashboardName === config.dashboardName &&
loaded.apiPort === config.apiPort &&
loaded.dnsEnabled === config.dnsEnabled &&
loaded.tailscaleEnabled === config.tailscaleEnabled
);
}
),
{ numRuns: 100 }
);
});
test('saved config always includes metadata', async () => {
await fc.assert(
fc.asyncProperty(
configArbitrary(),
async (config) => {
await manager.saveConfig(config, testDir);
const result = await manager.loadConfig(testDir);
if (!result.exists) return true; // Skip if load failed
return (
typeof result.config.version === 'string' &&
typeof result.config.lastModified === 'string'
);
}
),
{ numRuns: 100 }
);
});
test('loading non-existent config always returns exists=false', async () => {
await fc.assert(
fc.asyncProperty(
fc.string({ minLength: 1, maxLength: 20 }),
async (randomPath) => {
const nonExistentPath = path.join(testDir, randomPath);
const result = await manager.loadConfig(nonExistentPath);
return result.exists === false && result.config === null;
}
),
{ numRuns: 50 }
);
});
});
/**
* Feature: dashcaddy-installer, Property 3: Installation Path Validation
* For any selected folder path, the installer should correctly determine
* if the path is writable before proceeding with installation.
* Validates: Requirements 2.3, 2.4
*/
describe('Property 3: Installation Path Validation', () => {
test('validatePath returns consistent structure', async () => {
await fc.assert(
fc.asyncProperty(
fc.string({ minLength: 1, maxLength: 50 }),
async (pathSegment) => {
const testPath = path.join(testDir, pathSegment);
const result = await manager.validatePath(testPath);
return (
typeof result === 'object' &&
typeof result.valid === 'boolean' &&
typeof result.message === 'string'
);
}
),
{ numRuns: 50 }
);
});
test('valid paths are consistently validated', async () => {
await fc.assert(
fc.asyncProperty(
fc.string({ minLength: 1, maxLength: 20 }),
async (pathSegment) => {
const testPath = path.join(testDir, pathSegment);
// Validate twice
const result1 = await manager.validatePath(testPath);
const result2 = await manager.validatePath(testPath);
// Results should be consistent
return result1.valid === result2.valid;
}
),
{ numRuns: 30 }
);
});
});
/**
* Feature: dashcaddy-installer, Property 4: Directory Structure Creation
* For any valid installation path, the installer should create all required
* subdirectories (config, data, logs, caddyfile) and verify their existence.
* Validates: Requirements 2.5
*/
describe('Property 4: Directory Structure Creation', () => {
test('createDirectories creates all required directories', async () => {
await fc.assert(
fc.asyncProperty(
fc.string({ minLength: 1, maxLength: 20 }),
async (pathSegment) => {
const installPath = path.join(testDir, pathSegment);
const result = await manager.createDirectories(installPath);
if (!result.success) return true; // Skip if creation failed
// Verify all required directories exist
const requiredDirs = ['config', 'data', 'logs', 'caddyfile'];
const checks = await Promise.all(
requiredDirs.map(async (dir) => {
const dirPath = path.join(installPath, dir);
try {
await fs.access(dirPath);
return true;
} catch {
return false;
}
})
);
return checks.every(check => check === true);
}
),
{ numRuns: 50 }
);
});
test('createDirectories is idempotent', async () => {
await fc.assert(
fc.asyncProperty(
fc.string({ minLength: 1, maxLength: 20 }).filter(s => !s.includes('\0') && !s.includes('|')),
async (pathSegment) => {
const installPath = path.join(testDir, pathSegment);
// Create directories twice
const result1 = await manager.createDirectories(installPath);
const result2 = await manager.createDirectories(installPath);
// Both should succeed (or both fail for invalid paths)
return result1.success === result2.success;
}
),
{ numRuns: 30 }
);
});
});
/**
* Feature: dashcaddy-installer, Property 8: DNS Credential Security
* For any DNS credentials, saving and then loading the credentials should
* preserve all fields, and the password should be encrypted in storage.
* Validates: Requirements 4.8, 10.3
*/
describe('Property 8: DNS Credential Security', () => {
const credentialsArbitrary = () => fc.record({
server: fc.ipV4(),
username: fc.string({ minLength: 1, maxLength: 20 }),
password: fc.string({ minLength: 8, maxLength: 50 }),
tld: fc.string({ minLength: 2, maxLength: 10 }).map(s => '.' + s)
});
test('DNS credentials round-trip correctly', async () => {
await fc.assert(
fc.asyncProperty(
credentialsArbitrary(),
async (credentials) => {
// Save credentials
const saveResult = await manager.saveDNSCredentials(credentials, testDir);
if (!saveResult.success) return true; // Skip if save failed
// Load credentials
const loadResult = await manager.loadDNSCredentials(testDir);
if (!loadResult.exists) return false;
// Verify all fields are preserved
const loaded = loadResult.credentials;
return (
loaded.server === credentials.server &&
loaded.username === credentials.username &&
loaded.password === credentials.password &&
loaded.tld === credentials.tld
);
}
),
{ numRuns: 100 }
);
});
test('saved credentials always include timestamp', async () => {
await fc.assert(
fc.asyncProperty(
credentialsArbitrary(),
async (credentials) => {
await manager.saveDNSCredentials(credentials, testDir);
const result = await manager.loadDNSCredentials(testDir);
if (!result.exists) return true; // Skip if load failed
return typeof result.credentials.savedAt === 'string';
}
),
{ numRuns: 100 }
);
});
});
/**
* Feature: dashcaddy-installer, Property 6: Disk Space Verification
* For any valid path, the installer should correctly determine available disk space
* and report consistent results across multiple checks.
* Validates: Requirements 2.4
*/
describe('Property 6: Disk Space Verification', () => {
test('getDiskSpace returns consistent structure', async () => {
await fc.assert(
fc.asyncProperty(
fc.string({ minLength: 1, maxLength: 20 }).filter(s => !s.includes('\0') && !s.includes('|')),
async (pathSegment) => {
const testPath = path.join(testDir, pathSegment);
const result = await manager.getDiskSpace(testPath);
return (
typeof result === 'object' &&
typeof result.available === 'boolean' &&
(result.available ? typeof result.path === 'string' : typeof result.error === 'string')
);
}
),
{ numRuns: 50 }
);
});
test('getDiskSpace is idempotent for same path', async () => {
await fc.assert(
fc.asyncProperty(
fc.string({ minLength: 1, maxLength: 20 }).filter(s => !s.includes('\0') && !s.includes('|')),
async (pathSegment) => {
const testPath = path.join(testDir, pathSegment);
// Check twice
const result1 = await manager.getDiskSpace(testPath);
const result2 = await manager.getDiskSpace(testPath);
// Results should be consistent
return result1.available === result2.available;
}
),
{ numRuns: 30 }
);
});
test('getDiskSpace returns available=true for existing writable paths', async () => {
await fc.assert(
fc.asyncProperty(
fc.string({ minLength: 1, maxLength: 15 }).filter(s => /^[a-zA-Z0-9_-]+$/.test(s)),
async (pathSegment) => {
const testPath = path.join(testDir, pathSegment);
// Create the directory first
await fs.mkdir(testPath, { recursive: true });
const result = await manager.getDiskSpace(testPath);
return result.available === true;
}
),
{ numRuns: 30 }
);
});
});
/**
* Feature: dashcaddy-installer, Property 7: Tier Validation
* For any configuration, only valid tier values (basic, intermediate, advanced)
* should be accepted and persisted correctly.
* Validates: Requirements 3.1, 3.2, 3.3
*/
describe('Property 7: Tier Validation', () => {
const validTiers = ['basic', 'intermediate', 'advanced'];
test('valid tiers are preserved in config round-trip', async () => {
await fc.assert(
fc.asyncProperty(
fc.constantFrom(...validTiers),
fc.string({ minLength: 1, maxLength: 20 }),
async (tier, dashboardName) => {
const config = {
installPath: testDir,
tier: tier,
dashboardName: dashboardName,
apiPort: 3001,
dnsEnabled: false,
tailscaleEnabled: false
};
await manager.saveConfig(config, testDir);
const result = await manager.loadConfig(testDir);
if (!result.exists) return true; // Skip if save/load failed
return result.config.tier === tier;
}
),
{ numRuns: 50 }
);
});
test('tier value is always one of valid options after load', async () => {
await fc.assert(
fc.asyncProperty(
fc.constantFrom(...validTiers),
async (tier) => {
const config = {
installPath: testDir,
tier: tier,
dashboardName: 'Test',
apiPort: 3001,
dnsEnabled: false,
tailscaleEnabled: false
};
await manager.saveConfig(config, testDir);
const result = await manager.loadConfig(testDir);
if (!result.exists) return true;
return validTiers.includes(result.config.tier);
}
),
{ numRuns: 30 }
);
});
test('tier determines expected feature availability', async () => {
await fc.assert(
fc.asyncProperty(
fc.constantFrom(...validTiers),
async (tier) => {
const config = {
installPath: testDir,
tier: tier,
dashboardName: 'Test',
apiPort: 3001,
dnsEnabled: tier === 'advanced',
tailscaleEnabled: tier === 'advanced'
};
await manager.saveConfig(config, testDir);
const result = await manager.loadConfig(testDir);
if (!result.exists) return true;
// Advanced tier should support DNS and Tailscale
if (tier === 'advanced') {
return result.config.dnsEnabled === true && result.config.tailscaleEnabled === true;
}
// Non-advanced tiers should have these disabled
return result.config.dnsEnabled === false && result.config.tailscaleEnabled === false;
}
),
{ numRuns: 30 }
);
});
});
/**
* Additional property tests for installation management
*/
describe('Installation Management Properties', () => {
// Generator for valid configuration objects (reused from above)
const configArbitrary = () => fc.record({
installPath: fc.constant(testDir),
tier: fc.constantFrom('basic', 'intermediate', 'advanced'),
dashboardName: fc.string({ minLength: 1, maxLength: 50 }),
apiPort: fc.integer({ min: 1024, max: 65535 }),
dnsEnabled: fc.boolean(),
tailscaleEnabled: fc.boolean()
});
test('installationExists is consistent with config file presence', async () => {
await fc.assert(
fc.asyncProperty(
configArbitrary(),
async (config) => {
// Use a unique directory for this test
const uniqueDir = path.join(testDir, `test-${Date.now()}-${Math.random()}`);
config.installPath = uniqueDir;
// Before saving, installation should not exist
const existsBefore = await manager.installationExists(uniqueDir);
// Save config
await manager.saveConfig(config, uniqueDir);
// After saving, installation should exist
const existsAfter = await manager.installationExists(uniqueDir);
return !existsBefore && existsAfter;
}
),
{ numRuns: 50 }
);
});
test('removeInstallation removes all files', async () => {
await fc.assert(
fc.asyncProperty(
configArbitrary(),
async (config) => {
// Create installation
await manager.createDirectories(testDir);
await manager.saveConfig(config, testDir);
// Verify it exists
const existsBefore = await manager.installationExists(testDir);
if (!existsBefore) return true; // Skip if creation failed
// Remove installation
const removeResult = await manager.removeInstallation(testDir);
if (!removeResult.success) return true; // Skip if removal failed
// Verify it no longer exists
const existsAfter = await manager.installationExists(testDir);
return !existsAfter;
}
),
{ numRuns: 30 }
);
});
test('listInstallationFiles returns array', async () => {
await fc.assert(
fc.asyncProperty(
fc.string({ minLength: 1, maxLength: 20 }),
async (pathSegment) => {
const installPath = path.join(testDir, pathSegment);
const files = await manager.listInstallationFiles(installPath);
return Array.isArray(files);
}
),
{ numRuns: 50 }
);
});
});
/**
* Configuration Edge Cases
* Tests for special characters, boundary conditions, and data integrity
*/
describe('Configuration Edge Cases', () => {
test('special characters in dashboardName are preserved', async () => {
// Generator for strings with special characters
const specialCharArbitrary = fc.string({
unit: fc.constantFrom(
'a', 'Z', '0', ' ', '-', '_', '.', '!', '@', '#',
'é', 'ñ', '中', '日', '한', 'α', 'β'
),
minLength: 1,
maxLength: 30
});
await fc.assert(
fc.asyncProperty(
specialCharArbitrary,
async (dashboardName) => {
const config = {
installPath: testDir,
tier: 'basic',
dashboardName: dashboardName,
apiPort: 3001,
dnsEnabled: false,
tailscaleEnabled: false
};
await manager.saveConfig(config, testDir);
const result = await manager.loadConfig(testDir);
if (!result.exists) return true;
return result.config.dashboardName === dashboardName;
}
),
{ numRuns: 100 }
);
});
test('port values at boundaries are handled correctly', async () => {
const boundaryPorts = fc.constantFrom(1024, 1025, 3000, 3001, 8080, 49151, 65534, 65535);
await fc.assert(
fc.asyncProperty(
boundaryPorts,
async (port) => {
const config = {
installPath: testDir,
tier: 'basic',
dashboardName: 'Test',
apiPort: port,
dnsEnabled: false,
tailscaleEnabled: false
};
await manager.saveConfig(config, testDir);
const result = await manager.loadConfig(testDir);
if (!result.exists) return true;
return result.config.apiPort === port;
}
),
{ numRuns: 20 }
);
});
test('customTLD with various formats preserved correctly', async () => {
const tldArbitrary = fc.oneof(
fc.constant(undefined),
fc.constant(null),
fc.string({ minLength: 2, maxLength: 10 }).map(s => '.' + s.replace(/[^a-zA-Z]/g, 'x')),
fc.constantFrom('.local', '.home', '.lan', '.sami', '.test')
);
await fc.assert(
fc.asyncProperty(
tldArbitrary,
async (customTLD) => {
const config = {
installPath: testDir,
tier: 'basic',
dashboardName: 'Test',
customTLD: customTLD,
apiPort: 3001,
dnsEnabled: false,
tailscaleEnabled: false
};
await manager.saveConfig(config, testDir);
const result = await manager.loadConfig(testDir);
if (!result.exists) return true;
// Both null/undefined should be equivalent after round-trip
if (customTLD === null || customTLD === undefined) {
return result.config.customTLD === null ||
result.config.customTLD === undefined ||
result.config.customTLD === customTLD;
}
return result.config.customTLD === customTLD;
}
),
{ numRuns: 50 }
);
});
test('boolean fields remain booleans after round-trip', async () => {
await fc.assert(
fc.asyncProperty(
fc.boolean(),
fc.boolean(),
async (dnsEnabled, tailscaleEnabled) => {
const config = {
installPath: testDir,
tier: 'basic',
dashboardName: 'Test',
apiPort: 3001,
dnsEnabled: dnsEnabled,
tailscaleEnabled: tailscaleEnabled
};
await manager.saveConfig(config, testDir);
const result = await manager.loadConfig(testDir);
if (!result.exists) return true;
return (
typeof result.config.dnsEnabled === 'boolean' &&
typeof result.config.tailscaleEnabled === 'boolean' &&
result.config.dnsEnabled === dnsEnabled &&
result.config.tailscaleEnabled === tailscaleEnabled
);
}
),
{ numRuns: 50 }
);
});
test('config version is valid semver format', async () => {
const semverRegex = /^\d+\.\d+\.\d+$/;
await fc.assert(
fc.asyncProperty(
fc.constantFrom('basic', 'intermediate', 'advanced'),
async (tier) => {
const config = {
installPath: testDir,
tier: tier,
dashboardName: 'Test',
apiPort: 3001,
dnsEnabled: false,
tailscaleEnabled: false
};
await manager.saveConfig(config, testDir);
const result = await manager.loadConfig(testDir);
if (!result.exists) return true;
return semverRegex.test(result.config.version);
}
),
{ numRuns: 30 }
);
});
test('lastModified is valid ISO date string', async () => {
await fc.assert(
fc.asyncProperty(
fc.constantFrom('basic', 'intermediate', 'advanced'),
async (tier) => {
const config = {
installPath: testDir,
tier: tier,
dashboardName: 'Test',
apiPort: 3001,
dnsEnabled: false,
tailscaleEnabled: false
};
await manager.saveConfig(config, testDir);
const result = await manager.loadConfig(testDir);
if (!result.exists) return true;
// Should be parseable as a date and not NaN
const date = new Date(result.config.lastModified);
return !isNaN(date.getTime());
}
),
{ numRuns: 30 }
);
});
test('multiple rapid saves preserve final state', async () => {
await fc.assert(
fc.asyncProperty(
fc.array(fc.string({ minLength: 1, maxLength: 20 }), { minLength: 2, maxLength: 5 }),
async (dashboardNames) => {
// Rapidly save multiple configs
for (const name of dashboardNames) {
await manager.saveConfig({
installPath: testDir,
tier: 'basic',
dashboardName: name,
apiPort: 3001,
dnsEnabled: false,
tailscaleEnabled: false
}, testDir);
}
const result = await manager.loadConfig(testDir);
if (!result.exists) return true;
// Final saved name should be the last one
return result.config.dashboardName === dashboardNames[dashboardNames.length - 1];
}
),
{ numRuns: 30 }
);
});
});
/**
* DNS Credential Edge Cases
*/
describe('DNS Credential Edge Cases', () => {
test('passwords with special characters are preserved', async () => {
const specialPasswordArbitrary = fc.string({
unit: fc.constantFrom(
'a', 'Z', '0', '!', '@', '#', '$', '%', '^', '&', '*',
'(', ')', '-', '_', '=', '+', '[', ']', '{', '}', '|',
';', ':', "'", '"', '<', '>', ',', '.', '/', '?', '`', '~'
),
minLength: 8,
maxLength: 50
});
await fc.assert(
fc.asyncProperty(
specialPasswordArbitrary,
async (password) => {
const credentials = {
server: '192.168.1.1',
username: 'admin',
password: password,
tld: '.local'
};
await manager.saveDNSCredentials(credentials, testDir);
const result = await manager.loadDNSCredentials(testDir);
if (!result.exists) return true;
return result.credentials.password === password;
}
),
{ numRuns: 100 }
);
});
test('various IP address formats in server field', async () => {
await fc.assert(
fc.asyncProperty(
fc.ipV4(),
async (server) => {
const credentials = {
server: server,
username: 'admin',
password: 'password123',
tld: '.local'
};
await manager.saveDNSCredentials(credentials, testDir);
const result = await manager.loadDNSCredentials(testDir);
if (!result.exists) return true;
return result.credentials.server === server;
}
),
{ numRuns: 50 }
);
});
test('username with edge case lengths', async () => {
const usernameArbitrary = fc.string({
unit: fc.constantFrom('a', 'b', 'c', '1', '2', '_', '-'),
minLength: 1,
maxLength: 100
});
await fc.assert(
fc.asyncProperty(
usernameArbitrary,
async (username) => {
const credentials = {
server: '192.168.1.1',
username: username,
password: 'password123',
tld: '.local'
};
await manager.saveDNSCredentials(credentials, testDir);
const result = await manager.loadDNSCredentials(testDir);
if (!result.exists) return true;
return result.credentials.username === username;
}
),
{ numRuns: 50 }
);
});
});
});

View File

@@ -0,0 +1,288 @@
const ConfigManager = require('./config-manager');
const fs = require('fs').promises;
const path = require('path');
const os = require('os');
describe('ConfigManager', () => {
let manager;
let testDir;
beforeEach(async () => {
manager = new ConfigManager();
// Create a unique test directory for each test
testDir = path.join(os.tmpdir(), `dashcaddy-test-${Date.now()}`);
});
afterEach(async () => {
// Clean up test directory
try {
await fs.rm(testDir, { recursive: true, force: true });
} catch (error) {
// Ignore cleanup errors
}
});
describe('saveConfig', () => {
test('saves configuration to disk', async () => {
const config = {
installPath: testDir,
tier: 'basic',
dashboardName: 'Test Dashboard'
};
const result = await manager.saveConfig(config, testDir);
expect(result.success).toBe(true);
expect(result.path).toContain('config.json');
// Verify file was created
const configPath = path.join(testDir, 'config', 'config.json');
const exists = await fs.access(configPath).then(() => true).catch(() => false);
expect(exists).toBe(true);
});
test('adds metadata to saved config', async () => {
const config = {
installPath: testDir,
tier: 'basic'
};
await manager.saveConfig(config, testDir);
const loaded = await manager.loadConfig(testDir);
expect(loaded.config.version).toBeDefined();
expect(loaded.config.lastModified).toBeDefined();
});
test('handles save errors gracefully', async () => {
// Use a truly invalid path that will fail on all platforms
const config = {
installPath: '\0invalid' // Null character in path is invalid on all platforms
};
const result = await manager.saveConfig(config, '\0invalid');
// On some systems this might succeed with recursive mkdir, so we just verify structure
expect(result).toHaveProperty('success');
if (!result.success) {
expect(result.error).toBeDefined();
}
});
});
describe('loadConfig', () => {
test('loads existing configuration', async () => {
const config = {
installPath: testDir,
tier: 'advanced',
dashboardName: 'My Dashboard'
};
await manager.saveConfig(config, testDir);
const result = await manager.loadConfig(testDir);
expect(result.exists).toBe(true);
expect(result.config.tier).toBe('advanced');
expect(result.config.dashboardName).toBe('My Dashboard');
});
test('returns exists=false for non-existent config', async () => {
const result = await manager.loadConfig(testDir);
expect(result.exists).toBe(false);
expect(result.config).toBeNull();
});
test('handles corrupted config files', async () => {
// Create a corrupted config file
const configPath = path.join(testDir, 'config', 'config.json');
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(configPath, 'invalid json{', 'utf8');
const result = await manager.loadConfig(testDir);
expect(result.exists).toBe(false);
expect(result.error).toBeDefined();
});
});
describe('createDirectories', () => {
test('creates all required directories', async () => {
const result = await manager.createDirectories(testDir);
expect(result.success).toBe(true);
expect(result.paths.length).toBeGreaterThan(0);
// Verify directories were created
const configDir = path.join(testDir, 'config');
const dataDir = path.join(testDir, 'data');
const logsDir = path.join(testDir, 'logs');
const caddyfileDir = path.join(testDir, 'caddyfile');
const configExists = await fs.access(configDir).then(() => true).catch(() => false);
const dataExists = await fs.access(dataDir).then(() => true).catch(() => false);
const logsExists = await fs.access(logsDir).then(() => true).catch(() => false);
const caddyfileExists = await fs.access(caddyfileDir).then(() => true).catch(() => false);
expect(configExists).toBe(true);
expect(dataExists).toBe(true);
expect(logsExists).toBe(true);
expect(caddyfileExists).toBe(true);
});
test('handles directory creation errors', async () => {
// Use a truly invalid path
const result = await manager.createDirectories('\0invalid');
// On some systems this might succeed, so we just verify structure
expect(result).toHaveProperty('success');
if (!result.success) {
expect(result.error).toBeDefined();
}
});
});
describe('validatePath', () => {
test('validates writable paths', async () => {
const result = await manager.validatePath(testDir);
expect(result.valid).toBe(true);
expect(result.message).toContain('writable');
});
test('rejects non-writable paths', async () => {
// Use a truly invalid path
const result = await manager.validatePath('\0invalid');
// On some systems this might succeed, so we just verify structure
expect(result).toHaveProperty('valid');
expect(result).toHaveProperty('message');
});
test('creates directory if it doesn\'t exist', async () => {
const newDir = path.join(testDir, 'new-directory');
const result = await manager.validatePath(newDir);
expect(result.valid).toBe(true);
// Verify directory was created
const exists = await fs.access(newDir).then(() => true).catch(() => false);
expect(exists).toBe(true);
});
});
describe('installationExists', () => {
test('returns true for existing installations', async () => {
const config = { installPath: testDir };
await manager.saveConfig(config, testDir);
const exists = await manager.installationExists(testDir);
expect(exists).toBe(true);
});
test('returns false for non-existent installations', async () => {
const exists = await manager.installationExists(testDir);
expect(exists).toBe(false);
});
});
describe('saveDNSCredentials', () => {
test('saves DNS credentials', async () => {
const credentials = {
server: '192.168.1.1',
username: 'admin',
password: 'secret',
tld: '.sami'
};
const result = await manager.saveDNSCredentials(credentials, testDir);
expect(result.success).toBe(true);
expect(result.path).toContain('dns-credentials.json');
});
test('adds timestamp to saved credentials', async () => {
const credentials = {
server: '192.168.1.1',
username: 'admin',
password: 'secret'
};
await manager.saveDNSCredentials(credentials, testDir);
const loaded = await manager.loadDNSCredentials(testDir);
expect(loaded.credentials.savedAt).toBeDefined();
});
});
describe('loadDNSCredentials', () => {
test('loads existing credentials', async () => {
const credentials = {
server: '192.168.1.1',
username: 'admin',
password: 'secret',
tld: '.sami'
};
await manager.saveDNSCredentials(credentials, testDir);
const result = await manager.loadDNSCredentials(testDir);
expect(result.exists).toBe(true);
expect(result.credentials.server).toBe('192.168.1.1');
expect(result.credentials.username).toBe('admin');
});
test('returns exists=false for non-existent credentials', async () => {
const result = await manager.loadDNSCredentials(testDir);
expect(result.exists).toBe(false);
expect(result.credentials).toBeNull();
});
});
describe('removeInstallation', () => {
test('removes existing installation', async () => {
// Create an installation
const config = { installPath: testDir };
await manager.saveConfig(config, testDir);
const result = await manager.removeInstallation(testDir);
expect(result.success).toBe(true);
// Verify directory was removed
const exists = await fs.access(testDir).then(() => true).catch(() => false);
expect(exists).toBe(false);
});
test('fails to remove non-existent installation', async () => {
const result = await manager.removeInstallation(testDir);
expect(result.success).toBe(false);
expect(result.message).toContain('No DashCaddy installation found');
});
});
describe('listInstallationFiles', () => {
test('lists all files in installation', async () => {
// Create some files
await manager.createDirectories(testDir);
await manager.saveConfig({ installPath: testDir }, testDir);
const files = await manager.listInstallationFiles(testDir);
expect(Array.isArray(files)).toBe(true);
expect(files.length).toBeGreaterThan(0);
expect(files.some(f => f.includes('config.json'))).toBe(true);
});
test('returns empty array for non-existent directory', async () => {
const files = await manager.listInstallationFiles('/non/existent/path');
expect(Array.isArray(files)).toBe(true);
expect(files.length).toBe(0);
});
});
});

View File

@@ -0,0 +1,792 @@
const { exec, spawn } = require('child_process');
const { promisify } = require('util');
const path = require('path');
const platformUtils = require('../shared/platform-utils');
const DownloadManager = require('./download-manager');
const execAsync = promisify(exec);
class DependencyChecker {
/**
* Checks if Docker is installed and running
* @returns {Promise<Object>} { installed: boolean, running: boolean, version: string }
*/
async checkDocker() {
try {
// Check if Docker is installed
const versionResult = await this.executeCommand('docker --version');
if (!versionResult.success) {
return {
installed: false,
running: false,
version: null
};
}
// Extract version from output (e.g., "Docker version 24.0.7, build afdd53b")
const versionMatch = versionResult.stdout.match(/Docker version ([\d.]+)/);
const version = versionMatch ? versionMatch[1] : 'unknown';
// Check if Docker daemon is running
const infoResult = await this.executeCommand('docker info');
return {
installed: true,
running: infoResult.success,
version: version
};
} catch (error) {
return {
installed: false,
running: false,
version: null,
error: error.message
};
}
}
/**
* Checks if Caddy is installed
* @param {string[]} [additionalPaths] - Additional paths to search for caddy binary
* @returns {Promise<Object>} { installed: boolean, version: string, path: string }
*/
async checkCaddy(additionalPaths) {
try {
// First try caddy in PATH
const result = await this.executeCommand('caddy version');
if (result.success) {
const versionMatch = result.stdout.match(/v([\d.]+)/);
const version = versionMatch ? versionMatch[1] : 'unknown';
// Try to get Caddy path
let caddyPath = null;
if (platformUtils.isWindows()) {
const whereResult = await this.executeCommand('where caddy');
if (whereResult.success) {
caddyPath = whereResult.stdout.trim().split('\n')[0];
}
} else {
const whichResult = await this.executeCommand('which caddy');
if (whichResult.success) {
caddyPath = whichResult.stdout.trim();
}
}
return { installed: true, version, path: caddyPath };
}
// Not in PATH - search common locations
const searchResult = await this.findCaddyBinary(additionalPaths);
if (searchResult) {
return searchResult;
}
return { installed: false, version: null, path: null };
} catch (error) {
return {
installed: false,
version: null,
path: null,
error: error.message
};
}
}
/**
* Searches common locations for the Caddy binary
* @param {string[]} [additionalPaths] - Additional paths to search
* @returns {Promise<Object|null>} Caddy info if found, null otherwise
*/
async findCaddyBinary(additionalPaths) {
const fs = require('fs');
const binaryName = platformUtils.isWindows() ? 'caddy.exe' : 'caddy';
// Build list of candidate paths
const searchPaths = [];
if (platformUtils.isWindows()) {
searchPaths.push(
'C:\\caddy',
'C:\\Program Files\\Caddy',
'C:\\Program Files (x86)\\Caddy',
path.join(process.env.LOCALAPPDATA || '', 'Caddy'),
path.join(process.env.USERPROFILE || '', 'caddy'),
'C:\\DashCaddy\\caddy'
);
} else {
searchPaths.push(
'/usr/local/bin',
'/usr/bin',
'/opt/caddy',
'/opt/dashcaddy/caddy',
path.join(process.env.HOME || '', '.local/bin')
);
}
// Add any user-specified paths
if (additionalPaths) {
searchPaths.push(...additionalPaths);
}
for (const dir of searchPaths) {
if (!dir) continue;
const fullPath = path.join(dir, binaryName);
try {
fs.accessSync(fullPath, fs.constants.X_OK);
// Found the binary - verify it works
const result = await this.executeCommand(`"${fullPath}" version`);
if (result.success) {
const versionMatch = result.stdout.match(/v([\d.]+)/);
const version = versionMatch ? versionMatch[1] : 'unknown';
return { installed: true, version, path: fullPath };
}
} catch {
// Not found here, continue searching
}
}
return null;
}
/**
* Checks platform information
* @returns {Promise<Object>} Platform details
*/
async checkPlatform() {
return platformUtils.getPlatformInfo();
}
/**
* Provides installation instructions for Docker
* @param {string} platform - Platform identifier (windows, macos, linux)
* @returns {Object} Installation instructions
*/
getDockerInstallInstructions(platform) {
const instructions = {
windows: {
title: 'Install Docker Desktop for Windows',
steps: [
'Download Docker Desktop from https://www.docker.com/products/docker-desktop',
'Run the installer and follow the setup wizard',
'Restart your computer if prompted',
'Launch Docker Desktop from the Start menu',
'Wait for Docker to start (you\'ll see the whale icon in the system tray)',
'Click "Retry" in the installer once Docker is running'
],
url: 'https://docs.docker.com/desktop/install/windows-install/',
requiresWSL2: true,
wsl2Instructions: [
'Docker Desktop requires WSL 2',
'The installer will guide you through enabling WSL 2 if needed',
'You may need to enable virtualization in your BIOS'
]
},
macos: {
title: 'Install Docker Desktop for Mac',
steps: [
'Download Docker Desktop from https://www.docker.com/products/docker-desktop',
'Open the .dmg file and drag Docker to Applications',
'Launch Docker from Applications',
'Grant necessary permissions when prompted',
'Wait for Docker to start (you\'ll see the whale icon in the menu bar)',
'Click "Retry" in the installer once Docker is running'
],
url: 'https://docs.docker.com/desktop/install/mac-install/',
requiresWSL2: false
},
linux: {
title: 'Install Docker Engine for Linux',
steps: [
'Open a terminal',
'Run: curl -fsSL https://get.docker.com -o get-docker.sh',
'Run: sudo sh get-docker.sh',
'Run: sudo systemctl start docker',
'Run: sudo systemctl enable docker',
'Run: sudo usermod -aG docker $USER',
'Log out and back in for group changes to take effect',
'Click "Retry" in the installer'
],
url: 'https://docs.docker.com/engine/install/',
requiresWSL2: false,
packageManagers: {
ubuntu: 'sudo apt-get update && sudo apt-get install docker.io',
fedora: 'sudo dnf install docker',
arch: 'sudo pacman -S docker'
}
}
};
return instructions[platform] || instructions.linux;
}
/**
* Provides installation instructions for Caddy
* @param {string} platform - Platform identifier (windows, macos, linux)
* @returns {Object} Installation instructions
*/
getCaddyInstallInstructions(platform) {
const instructions = {
windows: {
title: 'Install Caddy for Windows',
steps: [
'Download Caddy from https://caddyserver.com/download',
'Extract the caddy.exe file',
'Move caddy.exe to C:\\Windows\\System32 (requires admin)',
'Or add the Caddy folder to your PATH environment variable',
'Open a new PowerShell window',
'Run: caddy version',
'Click "Retry" in the installer'
],
url: 'https://caddyserver.com/docs/install#windows',
automated: false
},
macos: {
title: 'Install Caddy for macOS',
steps: [
'Open Terminal',
'Install Homebrew if not already installed: /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"',
'Run: brew install caddy',
'Run: caddy version',
'Click "Retry" in the installer'
],
url: 'https://caddyserver.com/docs/install#mac',
automated: true,
command: 'brew install caddy'
},
linux: {
title: 'Install Caddy for Linux',
steps: [
'Open a terminal',
'Run the appropriate command for your distribution (see below)',
'Run: caddy version',
'Click "Retry" in the installer'
],
url: 'https://caddyserver.com/docs/install#debian-ubuntu-raspbian',
automated: true,
packageManagers: {
ubuntu: 'sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https && curl -1sLf "https://dl.cloudsmith.io/public/caddy/stable/gpg.key" | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg && curl -1sLf "https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt" | sudo tee /etc/apt/sources.list.d/caddy-stable.list && sudo apt update && sudo apt install caddy',
fedora: 'dnf install "dnf-command(copr)" && dnf copr enable @caddy/caddy && dnf install caddy',
arch: 'pacman -S caddy'
}
}
};
return instructions[platform] || instructions.linux;
}
/**
* Attempts automated installation of Docker (platform-specific)
* @param {string} platform - Platform identifier
* @param {Function} progressCallback - Progress callback
* @returns {Promise<Object>} Installation result
*/
async installDocker(platform, progressCallback) {
const downloadManager = new DownloadManager();
const platformInfo = platformUtils.getPlatformInfo();
try {
// Download Docker Desktop
progressCallback?.({ status: 'downloading', message: 'Downloading Docker Desktop...' });
const downloadResult = await downloadManager.downloadDocker(
platform,
platformInfo.arch,
progressCallback
);
if (!downloadResult.success) {
// Fallback to instructions if download fails
return {
success: false,
automated: false,
message: downloadResult.message || 'Download failed',
instructions: this.getDockerInstallInstructions(platform)
};
}
// Run the installer
progressCallback?.({ status: 'installing', message: 'Running Docker installer...' });
if (platform === 'windows') {
// Run Docker Desktop installer silently
const installerPath = downloadResult.path;
try {
// The Docker Desktop installer supports silent install
await this.runInstallerWindows(installerPath);
// Wait for Docker to be ready
progressCallback?.({ status: 'waiting', message: 'Waiting for Docker to start...' });
await this.waitForDocker(120000); // 2 minute timeout
// Verify installation
const verification = await this.checkDocker();
if (verification.installed) {
await downloadManager.cleanup();
return {
success: true,
automated: true,
message: 'Docker Desktop installed successfully',
version: verification.version
};
}
} catch (err) {
// Installation might require user interaction or admin
return {
success: false,
automated: true,
message: 'Docker installer launched. Please complete the installation and restart.',
instructions: this.getDockerInstallInstructions(platform)
};
}
} else if (platform === 'macos') {
// Mount and install DMG
const dmgPath = downloadResult.path;
try {
await this.runInstallerMacOS(dmgPath);
progressCallback?.({ status: 'waiting', message: 'Waiting for Docker to start...' });
await this.waitForDocker(120000);
const verification = await this.checkDocker();
if (verification.installed) {
await downloadManager.cleanup();
return {
success: true,
automated: true,
message: 'Docker Desktop installed successfully',
version: verification.version
};
}
} catch (err) {
return {
success: false,
automated: true,
message: 'Docker installation started. Please complete any prompts.',
instructions: this.getDockerInstallInstructions(platform)
};
}
} else {
// Linux - use package manager
return await this.installDockerLinux(progressCallback);
}
return {
success: false,
automated: false,
message: 'Docker installation requires manual completion',
instructions: this.getDockerInstallInstructions(platform)
};
} catch (error) {
return {
success: false,
automated: false,
message: error.message,
instructions: this.getDockerInstallInstructions(platform)
};
}
}
/**
* Run Docker installer on Windows
*/
async runInstallerWindows(installerPath) {
return new Promise((resolve, reject) => {
// Start installer with install flag
const installer = spawn(installerPath, ['install', '--quiet', '--accept-license'], {
detached: true,
stdio: 'ignore'
});
installer.unref();
// Give the installer time to start
setTimeout(() => resolve(), 5000);
});
}
/**
* Run Docker installer on macOS
*/
async runInstallerMacOS(dmgPath) {
// Mount the DMG
await this.executeCommand(`hdiutil attach "${dmgPath}" -nobrowse`);
// Copy to Applications
await this.executeCommand('cp -R "/Volumes/Docker/Docker.app" /Applications/');
// Unmount
await this.executeCommand('hdiutil detach "/Volumes/Docker"');
// Launch Docker
await this.executeCommand('open /Applications/Docker.app');
}
/**
* Install Docker on Linux using package manager
*/
async installDockerLinux(progressCallback) {
const distro = await this.detectLinuxDistro();
progressCallback?.({ status: 'installing', message: `Installing Docker for ${distro}...` });
try {
if (distro === 'ubuntu' || distro === 'debian') {
await this.executeCommand('curl -fsSL https://get.docker.com -o /tmp/get-docker.sh');
await this.executeCommand('sudo sh /tmp/get-docker.sh');
await this.executeCommand('sudo systemctl enable docker');
await this.executeCommand('sudo systemctl start docker');
} else if (distro === 'fedora') {
await this.executeCommand('sudo dnf install -y docker-ce docker-ce-cli containerd.io');
await this.executeCommand('sudo systemctl enable docker');
await this.executeCommand('sudo systemctl start docker');
} else if (distro === 'arch') {
await this.executeCommand('sudo pacman -S --noconfirm docker');
await this.executeCommand('sudo systemctl enable docker');
await this.executeCommand('sudo systemctl start docker');
} else {
return {
success: false,
automated: false,
message: 'Unsupported Linux distribution',
instructions: this.getDockerInstallInstructions('linux')
};
}
// Add user to docker group
await this.executeCommand('sudo usermod -aG docker $USER');
const verification = await this.checkDocker();
return {
success: verification.installed,
automated: true,
message: verification.installed ?
'Docker installed successfully. You may need to log out and back in for group changes.' :
'Docker installed but may require restart',
version: verification.version
};
} catch (error) {
return {
success: false,
automated: true,
message: error.message,
instructions: this.getDockerInstallInstructions('linux')
};
}
}
/**
* Wait for Docker to become available
*/
async waitForDocker(timeout = 60000) {
const startTime = Date.now();
const pollInterval = 3000;
while (Date.now() - startTime < timeout) {
const status = await this.checkDocker();
if (status.installed && status.running) {
return true;
}
await new Promise(resolve => setTimeout(resolve, pollInterval));
}
throw new Error('Docker did not become available within timeout');
}
/**
* Attempts automated installation of Caddy (platform-specific)
* @param {string} platform - Platform identifier
* @param {Function} progressCallback - Progress callback
* @param {string} targetPath - Optional target path for Caddy binary
* @returns {Promise<Object>} Installation result
*/
async installCaddy(platform, progressCallback, targetPath) {
const downloadManager = new DownloadManager();
const platformInfo = platformUtils.getPlatformInfo();
const instructions = this.getCaddyInstallInstructions(platform);
try {
// Download Caddy from GitHub
progressCallback?.({ status: 'downloading', message: 'Downloading Caddy...' });
const downloadResult = await downloadManager.downloadCaddy(
platform,
platformInfo.arch,
progressCallback
);
if (!downloadResult.success) {
// Try package manager as fallback
return await this.installCaddyWithPackageManager(platform, progressCallback);
}
// Extract the archive
progressCallback?.({ status: 'extracting', message: 'Extracting Caddy...' });
// Determine target directory
let extractTarget;
if (targetPath) {
extractTarget = targetPath;
} else if (platform === 'windows') {
extractTarget = 'C:\\Program Files\\Caddy';
} else {
extractTarget = '/usr/local/bin';
}
const extractResult = await downloadManager.extractCaddy(
downloadResult.path,
extractTarget,
platform
);
if (!extractResult.success) {
return {
success: false,
automated: true,
message: 'Failed to extract Caddy',
error: extractResult.error,
instructions
};
}
// On Windows, add to PATH if needed
if (platform === 'windows') {
progressCallback?.({ status: 'configuring', message: 'Adding Caddy to PATH...' });
await this.addToWindowsPath(extractTarget);
}
// Verify installation
progressCallback?.({ status: 'verifying', message: 'Verifying installation...' });
// Give a moment for PATH changes to take effect
await new Promise(resolve => setTimeout(resolve, 1000));
const verification = await this.checkCaddy([extractTarget]);
await downloadManager.cleanup();
if (verification.installed) {
return {
success: true,
automated: true,
message: 'Caddy installed successfully',
version: downloadResult.version || verification.version,
path: verification.path || extractResult.binaryPath
};
} else {
// Binary exists but not in PATH yet
return {
success: true,
automated: true,
message: 'Caddy installed. Restart terminal for PATH changes.',
version: downloadResult.version,
path: extractResult.binaryPath
};
}
} catch (error) {
// Fallback to package manager
return await this.installCaddyWithPackageManager(platform, progressCallback);
}
}
/**
* Install Caddy using system package manager
*/
async installCaddyWithPackageManager(platform, progressCallback) {
const instructions = this.getCaddyInstallInstructions(platform);
try {
let command;
if (platform === 'macos') {
// Check if Homebrew is available
const brewCheck = await this.executeCommand('which brew');
if (brewCheck.success) {
progressCallback?.({ status: 'installing', message: 'Installing Caddy via Homebrew...' });
command = 'brew install caddy';
} else {
return {
success: false,
automated: false,
message: 'Homebrew not found. Please install Homebrew first.',
instructions
};
}
} else if (platform === 'linux') {
const distro = await this.detectLinuxDistro();
progressCallback?.({ status: 'installing', message: `Installing Caddy for ${distro}...` });
if (distro === 'ubuntu' || distro === 'debian') {
// Add Caddy repository and install
await this.executeCommand('sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https');
await this.executeCommand('curl -1sLf "https://dl.cloudsmith.io/public/caddy/stable/gpg.key" | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg');
await this.executeCommand('curl -1sLf "https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt" | sudo tee /etc/apt/sources.list.d/caddy-stable.list');
await this.executeCommand('sudo apt update');
command = 'sudo apt install -y caddy';
} else if (distro === 'fedora') {
await this.executeCommand('sudo dnf install -y "dnf-command(copr)"');
await this.executeCommand('sudo dnf copr enable -y @caddy/caddy');
command = 'sudo dnf install -y caddy';
} else if (distro === 'arch') {
command = 'sudo pacman -S --noconfirm caddy';
} else {
return {
success: false,
automated: false,
message: 'Unsupported Linux distribution',
instructions
};
}
} else {
return {
success: false,
automated: false,
message: 'Automated installation not supported',
instructions
};
}
const result = await this.executeCommand(command);
if (result.success) {
const verification = await this.checkCaddy();
return {
success: verification.installed,
automated: true,
message: verification.installed ? 'Caddy installed successfully' : 'Installation completed but verification failed',
version: verification.version
};
} else {
return {
success: false,
automated: true,
message: 'Installation command failed',
error: result.stderr,
instructions
};
}
} catch (error) {
return {
success: false,
automated: true,
message: 'Installation failed',
error: error.message,
instructions
};
}
}
/**
* Add directory to Windows PATH
*/
async addToWindowsPath(directory) {
try {
// Add to user PATH using PowerShell
const script = `
$currentPath = [Environment]::GetEnvironmentVariable('Path', 'User')
if ($currentPath -notlike '*${directory.replace(/\\/g, '\\\\')}*') {
[Environment]::SetEnvironmentVariable('Path', "$currentPath;${directory.replace(/\\/g, '\\\\')}", 'User')
}
`;
await this.executeCommand(`powershell -Command "${script}"`);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
}
/**
* Detects Linux distribution
* @returns {Promise<string>} Distribution name (ubuntu, fedora, arch, etc.)
*/
async detectLinuxDistro() {
try {
const result = await this.executeCommand('cat /etc/os-release');
if (result.success) {
const output = result.stdout.toLowerCase();
if (output.includes('ubuntu') || output.includes('debian')) {
return 'ubuntu';
} else if (output.includes('fedora') || output.includes('rhel') || output.includes('centos')) {
return 'fedora';
} else if (output.includes('arch')) {
return 'arch';
}
}
return 'unknown';
} catch (error) {
return 'unknown';
}
}
/**
* Executes a shell command
* @param {string} command - Command to execute
* @returns {Promise<Object>} { success: boolean, stdout: string, stderr: string }
*/
async executeCommand(command) {
try {
const { stdout, stderr } = await execAsync(command, {
timeout: 30000, // 30 second timeout
maxBuffer: 1024 * 1024 // 1MB buffer
});
return {
success: true,
stdout: stdout,
stderr: stderr
};
} catch (error) {
return {
success: false,
stdout: error.stdout || '',
stderr: error.stderr || error.message,
error: error.message
};
}
}
/**
* Checks if WSL2 is available on Windows
* @returns {Promise<Object>} WSL2 status
*/
async checkWSL2() {
if (!platformUtils.isWindows()) {
return {
available: false,
required: false,
message: 'WSL2 is only relevant on Windows'
};
}
try {
const result = await this.executeCommand('wsl --status');
return {
available: result.success,
required: true,
message: result.success ? 'WSL2 is available' : 'WSL2 is not installed',
details: result.stdout
};
} catch (error) {
return {
available: false,
required: true,
message: 'WSL2 is not installed or not accessible',
error: error.message
};
}
}
}
module.exports = DependencyChecker;

View File

@@ -0,0 +1,269 @@
const fc = require('fast-check');
const DependencyChecker = require('./dependency-checker');
/**
* Feature: dashcaddy-installer, Property 2: Dependency Verification
* For any system state, the installer should accurately report whether Docker
* is installed and running, and whether Caddy is installed.
* Validates: Requirements 1.2, 1.3
*/
describe('Property 2: Dependency Verification', () => {
let checker;
beforeEach(() => {
checker = new DependencyChecker();
});
test('checkDocker always returns valid structure', async () => {
// Skip if Docker commands are too slow
const quickCheck = await checker.checkDocker();
if (!quickCheck.installed) {
// If Docker not installed, just verify the structure is correct
expect(quickCheck.installed).toBe(false);
expect(quickCheck.running).toBe(false);
return;
}
await fc.assert(
fc.asyncProperty(
fc.constant(null),
async () => {
const result = await checker.checkDocker();
// Must have required fields
const hasRequiredFields =
typeof result.installed === 'boolean' &&
typeof result.running === 'boolean' &&
(result.version === null || typeof result.version === 'string');
// If installed, version should be present
const versionConsistent = !result.installed || result.version !== null;
// If not installed, cannot be running
const runningConsistent = !result.running || result.installed;
return hasRequiredFields && versionConsistent && runningConsistent;
}
),
{ numRuns: 5 } // Very few runs since Docker commands are slow
);
}, 60000); // 60 second timeout
test('checkCaddy always returns valid structure', async () => {
await fc.assert(
fc.asyncProperty(
fc.constant(null),
async () => {
const result = await checker.checkCaddy();
// Must have required fields
const hasRequiredFields =
typeof result.installed === 'boolean' &&
(result.version === null || typeof result.version === 'string') &&
(result.path === null || typeof result.path === 'string');
// If installed, version should be present
const versionConsistent = !result.installed || result.version !== null;
return hasRequiredFields && versionConsistent;
}
),
{ numRuns: 50 }
);
});
test('dependency check results are deterministic', async () => {
// Skip if Docker commands are too slow
const quickCheck = await checker.checkDocker();
if (!quickCheck.installed) {
// If Docker not installed, just verify consistency
const check2 = await checker.checkDocker();
expect(check2.installed).toBe(false);
return;
}
await fc.assert(
fc.asyncProperty(
fc.constant(null),
async () => {
const result1 = await checker.checkDocker();
const result2 = await checker.checkDocker();
// Same system state should return same results
return (
result1.installed === result2.installed &&
result1.running === result2.running &&
result1.version === result2.version
);
}
),
{ numRuns: 3 } // Very few runs since this is expensive
);
}, 60000); // 60 second timeout
test('getDockerInstallInstructions returns valid structure for any platform', () => {
fc.assert(
fc.property(
fc.constantFrom('windows', 'macos', 'linux', 'unknown'),
(platform) => {
const instructions = checker.getDockerInstallInstructions(platform);
return (
typeof instructions === 'object' &&
typeof instructions.title === 'string' &&
Array.isArray(instructions.steps) &&
instructions.steps.length > 0 &&
typeof instructions.url === 'string' &&
typeof instructions.requiresWSL2 === 'boolean'
);
}
),
{ numRuns: 100 }
);
});
test('getCaddyInstallInstructions returns valid structure for any platform', () => {
fc.assert(
fc.property(
fc.constantFrom('windows', 'macos', 'linux', 'unknown'),
(platform) => {
const instructions = checker.getCaddyInstallInstructions(platform);
return (
typeof instructions === 'object' &&
typeof instructions.title === 'string' &&
Array.isArray(instructions.steps) &&
instructions.steps.length > 0 &&
typeof instructions.url === 'string' &&
typeof instructions.automated === 'boolean'
);
}
),
{ numRuns: 100 }
);
});
test('Windows Docker instructions always mention WSL2', () => {
const instructions = checker.getDockerInstallInstructions('windows');
expect(instructions.requiresWSL2).toBe(true);
expect(instructions.wsl2Instructions).toBeDefined();
expect(Array.isArray(instructions.wsl2Instructions)).toBe(true);
});
test('macOS Caddy instructions support automation', () => {
const instructions = checker.getCaddyInstallInstructions('macos');
expect(instructions.automated).toBe(true);
expect(instructions.command).toBeDefined();
expect(instructions.command).toContain('brew');
});
test('Linux instructions include package manager options', () => {
fc.assert(
fc.property(
fc.constantFrom('docker', 'caddy'),
(tool) => {
const instructions = tool === 'docker'
? checker.getDockerInstallInstructions('linux')
: checker.getCaddyInstallInstructions('linux');
return (
typeof instructions.packageManagers === 'object' &&
Object.keys(instructions.packageManagers).length > 0
);
}
),
{ numRuns: 100 }
);
});
test('executeCommand handles any command string', async () => {
await fc.assert(
fc.asyncProperty(
fc.string({ minLength: 1, maxLength: 50 }),
async (command) => {
try {
const result = await checker.executeCommand(command);
// Must return valid structure
return (
typeof result === 'object' &&
typeof result.success === 'boolean' &&
typeof result.stdout === 'string' &&
typeof result.stderr === 'string'
);
} catch (error) {
// Some commands might fail, that's okay
return true;
}
}
),
{ numRuns: 50 } // Reduced since this executes actual commands
);
});
test('detectLinuxDistro returns valid distro name', async () => {
await fc.assert(
fc.asyncProperty(
fc.constant(null),
async () => {
const distro = await checker.detectLinuxDistro();
const validDistros = ['ubuntu', 'fedora', 'arch', 'unknown'];
return (
typeof distro === 'string' &&
validDistros.includes(distro)
);
}
),
{ numRuns: 50 }
);
});
test('installDocker always returns result with instructions', async () => {
await fc.assert(
fc.asyncProperty(
fc.constantFrom('windows', 'macos', 'linux'),
async (platform) => {
const result = await checker.installDocker(platform);
return (
typeof result === 'object' &&
typeof result.success === 'boolean' &&
typeof result.automated === 'boolean' &&
typeof result.message === 'string' &&
typeof result.instructions === 'object'
);
}
),
{ numRuns: 100 }
);
});
test('installCaddy returns appropriate result for platform', async () => {
await fc.assert(
fc.asyncProperty(
fc.constantFrom('windows', 'macos', 'linux'),
async (platform) => {
const result = await checker.installCaddy(platform);
const hasRequiredFields =
typeof result === 'object' &&
typeof result.success === 'boolean' &&
typeof result.automated === 'boolean' &&
typeof result.message === 'string';
// Windows should not be automated
const windowsCorrect = platform !== 'windows' || result.automated === false;
// macOS should attempt automation (Linux may or may not depending on distro detection)
const macCorrect = platform !== 'macos' || result.automated === true;
return hasRequiredFields && windowsCorrect && macCorrect;
}
),
{ numRuns: 100 }
);
}, 30000); // 30 second timeout
});

View File

@@ -0,0 +1,263 @@
const DependencyChecker = require('./dependency-checker');
const { exec } = require('child_process');
// Mock child_process
jest.mock('child_process');
describe('DependencyChecker', () => {
let checker;
beforeEach(() => {
checker = new DependencyChecker();
jest.clearAllMocks();
// Default: commands fail (prevents hanging when unmocked calls occur)
exec.mockImplementation((cmd, opts, callback) => {
callback(new Error('command not found'), { stdout: '', stderr: 'command not found' });
});
});
describe('checkDocker', () => {
test('detects Docker installed and running', async () => {
// Mock successful docker --version
exec.mockImplementationOnce((cmd, opts, callback) => {
callback(null, { stdout: 'Docker version 24.0.7, build afdd53b', stderr: '' });
});
// Mock successful docker info
exec.mockImplementationOnce((cmd, opts, callback) => {
callback(null, { stdout: 'Server Version: 24.0.7', stderr: '' });
});
const result = await checker.checkDocker();
expect(result.installed).toBe(true);
expect(result.running).toBe(true);
expect(result.version).toBe('24.0.7');
});
test('detects Docker installed but not running', async () => {
// Mock successful docker --version
exec.mockImplementationOnce((cmd, opts, callback) => {
callback(null, { stdout: 'Docker version 24.0.7, build afdd53b', stderr: '' });
});
// Mock failed docker info
exec.mockImplementationOnce((cmd, opts, callback) => {
callback(new Error('Cannot connect to Docker daemon'), { stdout: '', stderr: 'Cannot connect' });
});
const result = await checker.checkDocker();
expect(result.installed).toBe(true);
expect(result.running).toBe(false);
expect(result.version).toBe('24.0.7');
});
test('detects Docker not installed', async () => {
// Mock failed docker --version
exec.mockImplementationOnce((cmd, opts, callback) => {
callback(new Error('command not found'), { stdout: '', stderr: 'command not found' });
});
const result = await checker.checkDocker();
expect(result.installed).toBe(false);
expect(result.running).toBe(false);
expect(result.version).toBeNull();
});
});
describe('checkCaddy', () => {
test('detects Caddy installed', async () => {
// Mock successful caddy version
exec.mockImplementationOnce((cmd, opts, callback) => {
callback(null, { stdout: 'v2.7.6 h1:w0NymbG2m9PcvKWsrXO6EEkY9Ru4FJK8uQbYcev1p3A=', stderr: '' });
});
// Mock successful which/where caddy
exec.mockImplementationOnce((cmd, opts, callback) => {
callback(null, { stdout: '/usr/bin/caddy', stderr: '' });
});
const result = await checker.checkCaddy();
expect(result.installed).toBe(true);
expect(result.version).toBe('2.7.6');
expect(result.path).toBe('/usr/bin/caddy');
});
test('detects Caddy not installed', async () => {
// Mock failed caddy version
exec.mockImplementationOnce((cmd, opts, callback) => {
callback(new Error('command not found'), { stdout: '', stderr: 'command not found' });
});
const result = await checker.checkCaddy();
expect(result.installed).toBe(false);
expect(result.version).toBeNull();
expect(result.path).toBeNull();
});
});
describe('getDockerInstallInstructions', () => {
test('returns Windows instructions', () => {
const instructions = checker.getDockerInstallInstructions('windows');
expect(instructions.title).toContain('Windows');
expect(instructions.steps).toBeInstanceOf(Array);
expect(instructions.steps.length).toBeGreaterThan(0);
expect(instructions.requiresWSL2).toBe(true);
expect(instructions.url).toContain('docker.com');
});
test('returns macOS instructions', () => {
const instructions = checker.getDockerInstallInstructions('macos');
expect(instructions.title).toContain('Mac');
expect(instructions.steps).toBeInstanceOf(Array);
expect(instructions.requiresWSL2).toBe(false);
expect(instructions.url).toContain('docker.com');
});
test('returns Linux instructions', () => {
const instructions = checker.getDockerInstallInstructions('linux');
expect(instructions.title).toContain('Linux');
expect(instructions.steps).toBeInstanceOf(Array);
expect(instructions.packageManagers).toBeDefined();
expect(instructions.packageManagers.ubuntu).toBeDefined();
});
});
describe('getCaddyInstallInstructions', () => {
test('returns Windows instructions', () => {
const instructions = checker.getCaddyInstallInstructions('windows');
expect(instructions.title).toContain('Windows');
expect(instructions.steps).toBeInstanceOf(Array);
expect(instructions.automated).toBe(false);
expect(instructions.url).toContain('caddyserver.com');
});
test('returns macOS instructions with automation', () => {
const instructions = checker.getCaddyInstallInstructions('macos');
expect(instructions.title).toContain('macOS');
expect(instructions.automated).toBe(true);
expect(instructions.command).toBe('brew install caddy');
});
test('returns Linux instructions with package managers', () => {
const instructions = checker.getCaddyInstallInstructions('linux');
expect(instructions.title).toContain('Linux');
expect(instructions.automated).toBe(true);
expect(instructions.packageManagers).toBeDefined();
expect(instructions.packageManagers.ubuntu).toBeDefined();
expect(instructions.packageManagers.fedora).toBeDefined();
});
});
describe('installDocker', () => {
test('returns manual installation instructions', async () => {
const result = await checker.installDocker('windows');
expect(result.success).toBe(false);
expect(result.automated).toBe(false);
expect(result.instructions).toBeDefined();
expect(result.message).toContain('manual');
});
});
describe('installCaddy', () => {
test('returns manual instructions for Windows', async () => {
const result = await checker.installCaddy('windows');
expect(result.success).toBe(false);
expect(result.automated).toBe(false);
expect(result.instructions).toBeDefined();
});
test('attempts automated installation on macOS', async () => {
// Mock successful brew install
exec.mockImplementationOnce((cmd, opts, callback) => {
callback(null, { stdout: 'Caddy installed', stderr: '' });
});
// Mock successful caddy version check
exec.mockImplementationOnce((cmd, opts, callback) => {
callback(null, { stdout: 'v2.7.6', stderr: '' });
});
// Mock successful which caddy
exec.mockImplementationOnce((cmd, opts, callback) => {
callback(null, { stdout: '/usr/local/bin/caddy', stderr: '' });
});
const result = await checker.installCaddy('macos');
expect(result.automated).toBe(true);
// Note: success depends on mocked execution
});
});
describe('executeCommand', () => {
test('executes command successfully', async () => {
exec.mockImplementationOnce((cmd, opts, callback) => {
callback(null, { stdout: 'success', stderr: '' });
});
const result = await checker.executeCommand('test command');
expect(result.success).toBe(true);
expect(result.stdout).toBe('success');
});
test('handles command failure', async () => {
const error = new Error('command failed');
error.stderr = 'error message';
exec.mockImplementationOnce((cmd, opts, callback) => {
callback(error);
});
const result = await checker.executeCommand('test command');
expect(result.success).toBe(false);
expect(result.stderr).toContain('error');
});
});
describe('detectLinuxDistro', () => {
test('detects Ubuntu', async () => {
exec.mockImplementationOnce((cmd, opts, callback) => {
callback(null, { stdout: 'ID=ubuntu\nNAME="Ubuntu"', stderr: '' });
});
const distro = await checker.detectLinuxDistro();
expect(distro).toBe('ubuntu');
});
test('detects Fedora', async () => {
exec.mockImplementationOnce((cmd, opts, callback) => {
callback(null, { stdout: 'ID=fedora\nNAME="Fedora"', stderr: '' });
});
const distro = await checker.detectLinuxDistro();
expect(distro).toBe('fedora');
});
test('returns unknown for unrecognized distro', async () => {
exec.mockImplementationOnce((cmd, opts, callback) => {
callback(null, { stdout: 'ID=unknown', stderr: '' });
});
const distro = await checker.detectLinuxDistro();
expect(distro).toBe('unknown');
});
});
});

View File

@@ -0,0 +1,357 @@
/**
* Download Manager
* Handles downloading Docker Desktop and Caddy binaries
*/
const https = require('https');
const http = require('http');
const fs = require('fs');
const path = require('path');
const { URL } = require('url');
class DownloadManager {
constructor() {
// Docker Desktop download URLs
this.dockerUrls = {
windows: 'https://desktop.docker.com/win/main/amd64/Docker%20Desktop%20Installer.exe',
macos_amd64: 'https://desktop.docker.com/mac/main/amd64/Docker.dmg',
macos_arm64: 'https://desktop.docker.com/mac/main/arm64/Docker.dmg'
};
// Caddy releases API
this.caddyReleasesUrl = 'https://api.github.com/repos/caddyserver/caddy/releases/latest';
// Temporary download directory
this.tempDir = path.join(require('os').tmpdir(), 'dashcaddy-installer');
}
/**
* Ensure temp directory exists
*/
async ensureTempDir() {
try {
await fs.promises.mkdir(this.tempDir, { recursive: true });
} catch (err) {
if (err.code !== 'EEXIST') throw err;
}
}
/**
* Download a file with progress tracking
*/
downloadFile(url, destPath, progressCallback) {
return new Promise((resolve, reject) => {
const urlObj = new URL(url);
const protocol = urlObj.protocol === 'https:' ? https : http;
const request = protocol.get(url, {
headers: {
'User-Agent': 'DashCaddy-Installer/1.0'
}
}, (response) => {
// Handle redirects
if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
return this.downloadFile(response.headers.location, destPath, progressCallback)
.then(resolve)
.catch(reject);
}
if (response.statusCode !== 200) {
reject(new Error(`Download failed: HTTP ${response.statusCode}`));
return;
}
const totalSize = parseInt(response.headers['content-length'], 10) || 0;
let downloadedSize = 0;
const fileStream = fs.createWriteStream(destPath);
response.on('data', (chunk) => {
downloadedSize += chunk.length;
if (progressCallback && totalSize > 0) {
progressCallback({
downloaded: downloadedSize,
total: totalSize,
percent: Math.round((downloadedSize / totalSize) * 100)
});
}
});
response.pipe(fileStream);
fileStream.on('finish', () => {
fileStream.close();
resolve({
success: true,
path: destPath,
size: downloadedSize
});
});
fileStream.on('error', (err) => {
fs.unlink(destPath, () => {}); // Clean up partial file
reject(err);
});
});
request.on('error', reject);
request.setTimeout(60000, () => {
request.destroy();
reject(new Error('Download timeout'));
});
});
}
/**
* Get Docker Desktop download URL for platform
*/
getDockerDownloadUrl(platform, arch) {
if (platform === 'windows') {
return this.dockerUrls.windows;
} else if (platform === 'macos') {
return arch === 'arm64' ? this.dockerUrls.macos_arm64 : this.dockerUrls.macos_amd64;
} else {
// Linux uses package managers, return null
return null;
}
}
/**
* Download Docker Desktop
*/
async downloadDocker(platform, arch, progressCallback) {
await this.ensureTempDir();
const url = this.getDockerDownloadUrl(platform, arch);
if (!url) {
return {
success: false,
automated: false,
message: 'Docker installation on Linux uses package managers',
instructions: this.getLinuxDockerInstructions()
};
}
const filename = platform === 'windows' ? 'DockerDesktopInstaller.exe' : 'Docker.dmg';
const destPath = path.join(this.tempDir, filename);
try {
progressCallback?.({ status: 'downloading', message: 'Downloading Docker Desktop...' });
const result = await this.downloadFile(url, destPath, (progress) => {
progressCallback?.({
status: 'downloading',
message: `Downloading Docker Desktop... ${progress.percent}%`,
...progress
});
});
return {
success: true,
path: result.path,
size: result.size
};
} catch (err) {
return {
success: false,
error: err.message
};
}
}
/**
* Get latest Caddy release info from GitHub
*/
async getCaddyLatestRelease() {
return new Promise((resolve, reject) => {
const urlObj = new URL(this.caddyReleasesUrl);
const request = https.get({
hostname: urlObj.hostname,
path: urlObj.pathname,
headers: {
'User-Agent': 'DashCaddy-Installer/1.0',
'Accept': 'application/vnd.github.v3+json'
}
}, (response) => {
let data = '';
response.on('data', chunk => {
data += chunk;
});
response.on('end', () => {
try {
const release = JSON.parse(data);
resolve(release);
} catch (err) {
reject(new Error('Failed to parse GitHub API response'));
}
});
});
request.on('error', reject);
request.setTimeout(30000, () => {
request.destroy();
reject(new Error('GitHub API timeout'));
});
});
}
/**
* Get Caddy download URL for platform/arch
*/
getCaddyAssetName(platform, arch) {
const archMap = {
'x64': 'amd64',
'arm64': 'arm64',
'ia32': '386'
};
const mappedArch = archMap[arch] || 'amd64';
if (platform === 'windows') {
return `caddy_*_windows_${mappedArch}.zip`;
} else if (platform === 'macos') {
return `caddy_*_darwin_${mappedArch}.tar.gz`;
} else {
return `caddy_*_linux_${mappedArch}.tar.gz`;
}
}
/**
* Find matching asset in release
*/
findCaddyAsset(release, platform, arch) {
const pattern = this.getCaddyAssetName(platform, arch);
const regex = new RegExp(pattern.replace('*', '.*'));
return release.assets?.find(asset => regex.test(asset.name));
}
/**
* Download Caddy binary
*/
async downloadCaddy(platform, arch, progressCallback) {
await this.ensureTempDir();
try {
// Get latest release info
progressCallback?.({ status: 'fetching', message: 'Fetching latest Caddy version...' });
const release = await this.getCaddyLatestRelease();
// Find the right asset
const asset = this.findCaddyAsset(release, platform, arch);
if (!asset) {
return {
success: false,
error: `No Caddy binary found for ${platform} ${arch}`
};
}
const destPath = path.join(this.tempDir, asset.name);
// Download the asset
progressCallback?.({ status: 'downloading', message: `Downloading Caddy ${release.tag_name}...` });
const result = await this.downloadFile(asset.browser_download_url, destPath, (progress) => {
progressCallback?.({
status: 'downloading',
message: `Downloading Caddy ${release.tag_name}... ${progress.percent}%`,
...progress
});
});
return {
success: true,
path: result.path,
version: release.tag_name,
size: result.size
};
} catch (err) {
return {
success: false,
error: err.message
};
}
}
/**
* Extract Caddy from archive
*/
async extractCaddy(archivePath, targetDir, platform) {
const { exec } = require('child_process');
const util = require('util');
const execAsync = util.promisify(exec);
await fs.promises.mkdir(targetDir, { recursive: true });
try {
if (platform === 'windows') {
// Use PowerShell to extract zip
await execAsync(`powershell -Command "Expand-Archive -Path '${archivePath}' -DestinationPath '${targetDir}' -Force"`);
return {
success: true,
binaryPath: path.join(targetDir, 'caddy.exe')
};
} else {
// Use tar on Unix
await execAsync(`tar -xzf "${archivePath}" -C "${targetDir}"`);
const binaryPath = path.join(targetDir, 'caddy');
// Make executable
await execAsync(`chmod +x "${binaryPath}"`);
return {
success: true,
binaryPath
};
}
} catch (err) {
return {
success: false,
error: err.message
};
}
}
/**
* Clean up temp files
*/
async cleanup() {
try {
const files = await fs.promises.readdir(this.tempDir);
for (const file of files) {
await fs.promises.unlink(path.join(this.tempDir, file));
}
} catch (err) {
// Ignore cleanup errors
}
}
/**
* Linux Docker installation instructions
*/
getLinuxDockerInstructions() {
return `
Linux Docker Installation:
For Ubuntu/Debian:
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo usermod -aG docker $USER
For Fedora:
sudo dnf install docker-ce docker-ce-cli containerd.io
sudo systemctl enable --now docker
For Arch Linux:
sudo pacman -S docker
sudo systemctl enable --now docker
`;
}
}
module.exports = DownloadManager;

View File

@@ -0,0 +1,230 @@
const fs = require('fs');
const path = require('path');
const { promisify } = require('util');
const copyFile = promisify(fs.copyFile);
const mkdir = promisify(fs.mkdir);
const readdir = promisify(fs.readdir);
/**
* FileDeployer - Handles copying dashboard and API files to installation directory
*
* In dev mode: reads from sibling directories (../../../status, ../../../dashcaddy-api)
* In packaged exe: reads from extraResources (process.resourcesPath/status, .../dashcaddy-api)
*/
class FileDeployer {
constructor() {
this._resolveSourcePaths();
}
_resolveSourcePaths() {
let app;
try {
app = require('electron').app;
} catch {
// Not in Electron context
}
const isPackaged = app?.isPackaged || false;
if (isPackaged) {
// Packaged exe: files are in extraResources
this.dashboardSource = path.join(process.resourcesPath, 'status');
this.apiSource = path.join(process.resourcesPath, 'dashcaddy-api');
} else {
// Dev mode: sibling directories in the project tree
this.dashboardSource = path.join(__dirname, '../../../status');
this.apiSource = path.join(__dirname, '../../../dashcaddy-api');
}
}
/**
* Copy directory recursively, skipping node_modules, .git, and test files
*/
async copyDirectory(src, dest, progressCallback = null) {
try {
await mkdir(dest, { recursive: true });
const entries = await readdir(src, { withFileTypes: true });
let copiedCount = 0;
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
// Skip unnecessary directories and files
if (['node_modules', '.git', '__tests__', 'test', '.nyc_output', 'coverage'].includes(entry.name)) {
continue;
}
// Skip test files
if (entry.name.endsWith('.test.js') || entry.name.endsWith('.spec.js')) {
continue;
}
if (entry.isDirectory()) {
const subResult = await this.copyDirectory(srcPath, destPath, progressCallback);
if (subResult.success) copiedCount += subResult.filesCopied;
} else {
await copyFile(srcPath, destPath);
copiedCount++;
if (progressCallback) {
progressCallback({
type: 'file',
source: srcPath,
destination: destPath,
count: copiedCount
});
}
}
}
return { success: true, filesCopied: copiedCount };
} catch (error) {
return { success: false, error: error.message };
}
}
/**
* Deploy dashboard files to installation directory
*/
async deployDashboard(installPath, progressCallback = null) {
try {
const dashboardDest = path.join(installPath, 'sites', 'status');
if (progressCallback) {
progressCallback({
type: 'start',
message: 'Deploying dashboard files...',
source: this.dashboardSource,
destination: dashboardDest
});
}
// Verify source exists
if (!fs.existsSync(this.dashboardSource)) {
throw new Error(`Dashboard source not found at: ${this.dashboardSource}`);
}
const result = await this.copyDirectory(
this.dashboardSource,
dashboardDest,
progressCallback
);
if (!result.success) {
throw new Error(result.error);
}
if (progressCallback) {
progressCallback({
type: 'complete',
message: `Dashboard deployed (${result.filesCopied} files)`,
filesCopied: result.filesCopied
});
}
return {
success: true,
path: dashboardDest,
filesCopied: result.filesCopied
};
} catch (error) {
return { success: false, error: error.message };
}
}
/**
* Deploy API server files to installation directory
*/
async deployAPI(installPath, progressCallback = null) {
try {
const apiDest = path.join(installPath, 'sites', 'dashcaddy-api');
if (progressCallback) {
progressCallback({
type: 'start',
message: 'Deploying API server files...',
source: this.apiSource,
destination: apiDest
});
}
if (!fs.existsSync(this.apiSource)) {
throw new Error(`API source not found at: ${this.apiSource}`);
}
const result = await this.copyDirectory(
this.apiSource,
apiDest,
progressCallback
);
if (!result.success) {
throw new Error(result.error);
}
if (progressCallback) {
progressCallback({
type: 'complete',
message: `API server deployed (${result.filesCopied} files)`,
filesCopied: result.filesCopied
});
}
return {
success: true,
path: apiDest,
filesCopied: result.filesCopied
};
} catch (error) {
return { success: false, error: error.message };
}
}
/**
* Deploy both dashboard and API files
*/
async deployComplete(installPath, progressCallback = null) {
try {
const dashboardResult = await this.deployDashboard(installPath, progressCallback);
if (!dashboardResult.success) {
throw new Error(`Dashboard deployment failed: ${dashboardResult.error}`);
}
const apiResult = await this.deployAPI(installPath, progressCallback);
if (!apiResult.success) {
throw new Error(`API deployment failed: ${apiResult.error}`);
}
return {
success: true,
dashboard: dashboardResult,
api: apiResult
};
} catch (error) {
return { success: false, error: error.message };
}
}
/**
* Check if source directories exist
*/
async validateSources() {
const checks = {
dashboard: fs.existsSync(this.dashboardSource),
api: fs.existsSync(this.apiSource)
};
return {
valid: checks.dashboard && checks.api,
checks,
paths: {
dashboard: this.dashboardSource,
api: this.apiSource
}
};
}
}
module.exports = FileDeployer;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,392 @@
/**
* Service Manager
* Handles starting, stopping, and managing Caddy and Docker services
*/
const { exec, spawn } = require('child_process');
const { promisify } = require('util');
const path = require('path');
const fs = require('fs').promises;
const platformUtils = require('../shared/platform-utils');
const execAsync = promisify(exec);
class ServiceManager {
constructor() {
this.platform = platformUtils.getPlatformInfo().os;
}
/**
* Start Caddy with the specified Caddyfile
* @param {string} caddyfilePath - Path to Caddyfile
* @param {string} caddyBinaryPath - Optional path to Caddy binary
* @returns {Promise<Object>} { success: boolean, message: string }
*/
async startCaddy(caddyfilePath, caddyBinaryPath) {
try {
const caddyCmd = caddyBinaryPath || 'caddy';
// Check if Caddyfile exists
try {
await fs.access(caddyfilePath);
} catch {
return {
success: false,
error: 'Caddyfile not found'
};
}
if (this.platform === 'windows') {
// On Windows, start Caddy in background
const caddy = spawn(caddyCmd, ['run', '--config', caddyfilePath], {
detached: true,
stdio: 'ignore',
windowsHide: true
});
caddy.unref();
// Wait a moment for Caddy to start
await new Promise(resolve => setTimeout(resolve, 2000));
// Check if Caddy is running
const status = await this.checkCaddyStatus();
return {
success: status.running,
message: status.running ? 'Caddy started successfully' : 'Caddy may have failed to start',
pid: caddy.pid
};
} else {
// On Unix, use systemd if available, otherwise start directly
const systemdAvailable = await this.isSystemdAvailable();
if (systemdAvailable) {
// Create systemd service file
await this.createCaddySystemdService(caddyfilePath, caddyBinaryPath);
// Start the service
await execAsync('sudo systemctl daemon-reload');
await execAsync('sudo systemctl enable caddy');
await execAsync('sudo systemctl start caddy');
return {
success: true,
message: 'Caddy started via systemd'
};
} else {
// Start directly
const caddy = spawn(caddyCmd, ['run', '--config', caddyfilePath], {
detached: true,
stdio: 'ignore'
});
caddy.unref();
await new Promise(resolve => setTimeout(resolve, 2000));
return {
success: true,
message: 'Caddy started in background',
pid: caddy.pid
};
}
}
} catch (error) {
return {
success: false,
error: error.message
};
}
}
/**
* Stop Caddy
* @returns {Promise<Object>} { success: boolean, message: string }
*/
async stopCaddy() {
try {
if (this.platform === 'windows') {
await execAsync('taskkill /IM caddy.exe /F');
} else {
const systemdAvailable = await this.isSystemdAvailable();
if (systemdAvailable) {
await execAsync('sudo systemctl stop caddy');
} else {
await execAsync('pkill caddy');
}
}
return {
success: true,
message: 'Caddy stopped'
};
} catch (error) {
return {
success: false,
error: error.message
};
}
}
/**
* Check if Caddy is running
* @returns {Promise<Object>} { running: boolean }
*/
async checkCaddyStatus() {
try {
if (this.platform === 'windows') {
const result = await execAsync('tasklist /FI "IMAGENAME eq caddy.exe"');
const running = result.stdout.includes('caddy.exe');
return { running };
} else {
const result = await execAsync('pgrep caddy');
return { running: result.stdout.trim().length > 0 };
}
} catch {
return { running: false };
}
}
/**
* Start Docker Compose services
* @param {string} installPath - Installation directory containing docker-compose.yml
* @returns {Promise<Object>} { success: boolean, message: string }
*/
async startDockerCompose(installPath) {
try {
const composePath = path.join(installPath, 'sites', 'dashcaddy-api', 'docker-compose.yml');
// Check if docker-compose.yml exists
try {
await fs.access(composePath);
} catch {
return {
success: false,
error: 'docker-compose.yml not found'
};
}
// Start containers (cwd must be the compose directory for build context)
const composeDir = path.dirname(composePath);
await execAsync(`docker compose -f "${composePath}" up -d --build`, {
cwd: composeDir,
timeout: 300000 // 5 minute timeout for initial build
});
return {
success: true,
message: 'Docker containers started'
};
} catch (error) {
return {
success: false,
error: error.message
};
}
}
/**
* Stop Docker Compose services
* @param {string} installPath - Installation directory containing docker-compose.yml
* @returns {Promise<Object>} { success: boolean, message: string }
*/
async stopDockerCompose(installPath) {
try {
const composePath = path.join(installPath, 'sites', 'dashcaddy-api', 'docker-compose.yml');
await execAsync(`docker compose -f "${composePath}" down`, {
cwd: path.dirname(composePath),
timeout: 60000
});
return {
success: true,
message: 'Docker containers stopped'
};
} catch (error) {
return {
success: false,
error: error.message
};
}
}
/**
* Check Docker Compose status
* @param {string} installPath - Installation directory
* @returns {Promise<Object>} { running: boolean, containers: array }
*/
async checkDockerComposeStatus(installPath) {
try {
const composePath = path.join(installPath, 'sites', 'dashcaddy-api', 'docker-compose.yml');
const result = await execAsync(`docker compose -f "${composePath}" ps --format json`, {
cwd: path.dirname(composePath)
});
const containers = result.stdout.trim()
.split('\n')
.filter(line => line.trim())
.map(line => {
try {
return JSON.parse(line);
} catch {
return null;
}
})
.filter(Boolean);
return {
running: containers.some(c => c.State === 'running'),
containers
};
} catch {
return {
running: false,
containers: []
};
}
}
/**
* Start all services
* @param {string} installPath - Installation directory
* @param {Object} options - Options including caddyfilePath
* @returns {Promise<Object>} { success: boolean, services: object }
*/
async startAll(installPath, options = {}) {
const results = {
caddy: null,
docker: null
};
// Start Caddy
const caddyfilePath = options.caddyfilePath || path.join(installPath, 'Caddyfile');
results.caddy = await this.startCaddy(caddyfilePath, options.caddyBinaryPath);
// Start Docker Compose
results.docker = await this.startDockerCompose(installPath);
return {
success: results.caddy.success && results.docker.success,
services: results
};
}
/**
* Stop all services
* @param {string} installPath - Installation directory
* @returns {Promise<Object>} { success: boolean, services: object }
*/
async stopAll(installPath) {
const results = {
caddy: null,
docker: null
};
results.caddy = await this.stopCaddy();
results.docker = await this.stopDockerCompose(installPath);
return {
success: results.caddy.success && results.docker.success,
services: results
};
}
/**
* Check if systemd is available
* @returns {Promise<boolean>}
*/
async isSystemdAvailable() {
try {
await execAsync('systemctl --version');
return true;
} catch {
return false;
}
}
/**
* Create systemd service file for Caddy
* @param {string} caddyfilePath - Path to Caddyfile
* @param {string} caddyBinaryPath - Path to Caddy binary
*/
async createCaddySystemdService(caddyfilePath, caddyBinaryPath) {
const caddyCmd = caddyBinaryPath || '/usr/bin/caddy';
const serviceContent = `[Unit]
Description=Caddy Web Server (DashCaddy)
After=network.target network-online.target
Wants=network-online.target
[Service]
Type=simple
User=root
ExecStart=${caddyCmd} run --config ${caddyfilePath}
ExecReload=${caddyCmd} reload --config ${caddyfilePath}
Restart=on-failure
RestartSec=5
LimitNOFILE=1048576
LimitNPROC=512
[Install]
WantedBy=multi-user.target
`;
const servicePath = '/etc/systemd/system/caddy.service';
// Write service file (requires sudo)
await execAsync(`echo '${serviceContent}' | sudo tee ${servicePath}`);
}
/**
* Check if Docker daemon is running
* @returns {Promise<boolean>}
*/
async isDockerRunning() {
try {
await execAsync('docker info');
return true;
} catch {
return false;
}
}
/**
* Wait for a service to become available
* @param {string} url - URL to check
* @param {number} timeout - Timeout in milliseconds
* @returns {Promise<boolean>}
*/
async waitForService(url, timeout = 30000) {
const startTime = Date.now();
const http = require('http');
const https = require('https');
while (Date.now() - startTime < timeout) {
try {
await new Promise((resolve, reject) => {
const protocol = url.startsWith('https') ? https : http;
const req = protocol.get(url, (res) => {
resolve(res.statusCode);
});
req.on('error', reject);
req.setTimeout(5000, () => {
req.destroy();
reject(new Error('Timeout'));
});
});
return true;
} catch {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
return false;
}
}
module.exports = ServiceManager;

View File

@@ -0,0 +1,102 @@
const { contextBridge, ipcRenderer } = require('electron');
// Expose protected methods that allow the renderer process to use
// the ipcRenderer without exposing the entire object
contextBridge.exposeInMainWorld('electronAPI', {
// Platform detection
checkPlatform: () => ipcRenderer.invoke('check-platform'),
// Dependency checking
checkDependencies: (options) => ipcRenderer.invoke('check-dependencies', options),
checkDocker: () => ipcRenderer.invoke('check-docker'),
checkCaddy: (options) => ipcRenderer.invoke('check-caddy', options),
installDocker: () => ipcRenderer.invoke('install-docker'),
installCaddy: (options) => ipcRenderer.invoke('install-caddy', options),
// Configuration
validatePath: (path) => ipcRenderer.invoke('validate-path', path),
selectFolder: () => ipcRenderer.invoke('select-folder'),
saveConfig: (config) => ipcRenderer.invoke('save-config', config),
loadConfig: (path) => ipcRenderer.invoke('load-config', path),
// File deployment
validateSources: () => ipcRenderer.invoke('validate-sources'),
deployDashboard: (installPath) => ipcRenderer.invoke('deploy-dashboard', installPath),
deployAPI: (installPath) => ipcRenderer.invoke('deploy-api', installPath),
deployComplete: (installPath) => ipcRenderer.invoke('deploy-complete', installPath),
// Caddyfile generation
createCaddyfile: (installPath, options) => ipcRenderer.invoke('create-caddyfile', installPath, options),
createDockerCompose: (installPath) => ipcRenderer.invoke('create-docker-compose', installPath),
// Browser launcher
openDashboard: (port, hostname) => ipcRenderer.invoke('open-dashboard', port, hostname),
// Installation
runInstallation: (config) => ipcRenderer.invoke('run-installation', config),
// DNS
testDNSConnection: (credentials) => ipcRenderer.invoke('test-dns-connection', credentials),
// Network detection
detectNetwork: () => ipcRenderer.invoke('detect-network'),
// Health check
healthCheck: (config) => ipcRenderer.invoke('health-check', config),
// Uninstallation
detectInstallation: () => ipcRenderer.invoke('detect-installation'),
checkPreservedSettings: (installPath) => ipcRenderer.invoke('check-preserved-settings', installPath),
restorePreservedSettings: (installPath) => ipcRenderer.invoke('restore-preserved-settings', installPath),
runUninstallation: (options) => ipcRenderer.invoke('run-uninstallation', options),
// Service management
startServices: (config) => ipcRenderer.invoke('start-services', config),
stopServices: (config) => ipcRenderer.invoke('stop-services', config),
checkServiceStatus: (config) => ipcRenderer.invoke('check-service-status', config),
// DNS credentials
saveDNSCredentials: (credentials, installPath) =>
ipcRenderer.invoke('save-dns-credentials', credentials, installPath),
// Branding
saveBranding: (branding, installPath) =>
ipcRenderer.invoke('save-branding', branding, installPath),
// File selection (for logo, etc.)
selectFile: (options) => ipcRenderer.invoke('select-file', options),
// Event listeners
onStepProgress: (callback) => {
ipcRenderer.on('step-progress', (event, data) => callback(data));
},
onStepComplete: (callback) => {
ipcRenderer.on('step-complete', (event, data) => callback(data));
},
onStepError: (callback) => {
ipcRenderer.on('step-error', (event, data) => callback(data));
},
onInstallationComplete: (callback) => {
ipcRenderer.on('installation-complete', (event, data) => callback(data));
},
onDeploymentProgress: (callback) => {
ipcRenderer.on('deployment-progress', (event, data) => callback(data));
},
onUninstallProgress: (callback) => {
ipcRenderer.on('uninstall-progress', (event, data) => callback(data));
},
onUninstallStepComplete: (callback) => {
ipcRenderer.on('uninstall-step-complete', (event, data) => callback(data));
},
onUninstallComplete: (callback) => {
ipcRenderer.on('uninstall-complete', (event, data) => callback(data));
},
onUninstallError: (callback) => {
ipcRenderer.on('uninstall-error', (event, data) => callback(data));
},
// Remove listeners
removeListener: (channel) => {
ipcRenderer.removeAllListeners(channel);
}
});

View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: file:">
<title>DashCaddy Installer</title>
<link rel="stylesheet" href="./styles.css">
</head>
<body>
<div id="root">
<!-- Loading state while wizard initializes -->
<div class="installer-container" style="display: flex; justify-content: center; align-items: center;">
<div class="loading">
<div class="spinner"></div>
<p>Initializing DashCaddy Installer...</p>
</div>
</div>
</div>
<script src="./wizard.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
// Installation tiers
const TIERS = {
BASIC: 'basic',
INTERMEDIATE: 'intermediate',
ADVANCED: 'advanced'
};
// Default installation paths by platform
const DEFAULT_PATHS = {
win32: 'C:\\Program Files\\DashCaddy',
darwin: '/Applications/DashCaddy',
linux: '/opt/dashcaddy'
};
// Required subdirectories (matches production layout)
const REQUIRED_DIRS = ['sites', 'sites/status', 'sites/status/assets', 'sites/dashcaddy-api'];
// Default ports
const DEFAULT_PORTS = {
API: 3001,
CADDY_ADMIN: 2019
};
// Error categories
const ERROR_CATEGORIES = {
CRITICAL: 'critical',
RECOVERABLE: 'recoverable',
NON_CRITICAL: 'non-critical',
USER_ERROR: 'user-error'
};
// Installation steps
const INSTALLATION_STEPS = [
{ id: 'welcome', label: 'Welcome' },
{ id: 'dependencies', label: 'Dependencies' },
{ id: 'folder', label: 'Installation Path' },
{ id: 'tier', label: 'Deployment Tier' },
{ id: 'dns', label: 'DNS Configuration' },
{ id: 'dashboard', label: 'Dashboard Setup' },
{ id: 'install', label: 'Installation' },
{ id: 'complete', label: 'Complete' }
];
module.exports = {
TIERS,
DEFAULT_PATHS,
REQUIRED_DIRS,
DEFAULT_PORTS,
ERROR_CATEGORIES,
INSTALLATION_STEPS
};

View File

@@ -0,0 +1,174 @@
const os = require('os');
const path = require('path');
/**
* Detects the current operating system
* @returns {string} 'windows', 'macos', or 'linux'
*/
function detectOS() {
const platform = process.platform;
switch (platform) {
case 'win32':
return 'windows';
case 'darwin':
return 'macos';
case 'linux':
return 'linux';
default:
return 'unknown';
}
}
/**
* Detects the system architecture
* @returns {string} 'x64', 'arm64', etc.
*/
function detectArchitecture() {
return process.arch;
}
/**
* Detects the appropriate shell for the platform
* @returns {string} 'powershell', 'bash', or 'zsh'
*/
function detectShell() {
const platform = process.platform;
if (platform === 'win32') {
return 'powershell';
}
// Check for zsh on macOS (default since Catalina)
if (platform === 'darwin') {
const shell = process.env.SHELL || '';
if (shell.includes('zsh')) {
return 'zsh';
}
return 'bash';
}
// Linux typically uses bash
return 'bash';
}
/**
* Gets the default installation path for the current platform
* @returns {string} Default installation path
*/
function getDefaultInstallPath() {
const platform = process.platform;
switch (platform) {
case 'win32':
return 'C:\\Program Files\\DashCaddy';
case 'darwin':
return '/Applications/DashCaddy';
case 'linux':
// Use /opt for system-wide or home directory for user install
return '/opt/dashcaddy';
default:
return path.join(os.homedir(), 'DashCaddy');
}
}
/**
* Gets the user's home directory installation path
* @returns {string} User home installation path
*/
function getUserInstallPath() {
return path.join(os.homedir(), 'DashCaddy');
}
/**
* Normalizes a file path for the current platform
* @param {string} filePath - Path to normalize
* @returns {string} Normalized path
*/
function normalizePath(filePath) {
return path.normalize(filePath);
}
/**
* Joins path segments using the correct separator for the platform
* @param {...string} segments - Path segments to join
* @returns {string} Joined path
*/
function joinPath(...segments) {
return path.join(...segments);
}
/**
* Gets platform-specific information
* @returns {Object} Platform information
*/
function getPlatformInfo() {
return {
os: detectOS(),
arch: detectArchitecture(),
shell: detectShell(),
platform: process.platform,
hostname: os.hostname(),
homedir: os.homedir(),
defaultInstallPath: getDefaultInstallPath(),
userInstallPath: getUserInstallPath()
};
}
/**
* Checks if running on Windows
* @returns {boolean}
*/
function isWindows() {
return process.platform === 'win32';
}
/**
* Checks if running on macOS
* @returns {boolean}
*/
function isMacOS() {
return process.platform === 'darwin';
}
/**
* Checks if running on Linux
* @returns {boolean}
*/
function isLinux() {
return process.platform === 'linux';
}
/**
* Gets the command to execute based on platform
* @param {string} command - Base command
* @returns {Object} Command execution details
*/
function getCommandExecution(command) {
if (isWindows()) {
return {
shell: 'powershell.exe',
args: ['-Command', command]
};
}
return {
shell: detectShell(),
args: ['-c', command]
};
}
module.exports = {
detectOS,
detectArchitecture,
detectShell,
getDefaultInstallPath,
getUserInstallPath,
normalizePath,
joinPath,
getPlatformInfo,
isWindows,
isMacOS,
isLinux,
getCommandExecution
};

View File

@@ -0,0 +1,289 @@
const fc = require('fast-check');
const platformUtils = require('./platform-utils');
/**
* Feature: dashcaddy-installer, Property 1: OS Detection Accuracy
* For any supported platform (Windows, macOS, Linux), the installer should
* correctly detect the operating system and architecture.
* Validates: Requirements 1.1, 14.1, 14.2, 14.3
*/
describe('Property 1: OS Detection Accuracy', () => {
test('detectOS returns valid OS for any platform value', () => {
fc.assert(
fc.property(
fc.constantFrom('win32', 'darwin', 'linux', 'freebsd', 'openbsd', 'sunos', 'aix'),
(platform) => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: platform, configurable: true });
const detectedOS = platformUtils.detectOS();
const validOSes = ['windows', 'macos', 'linux', 'unknown'];
const isValid = validOSes.includes(detectedOS);
// Restore original platform
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
return isValid;
}
),
{ numRuns: 100 }
);
});
test('detectOS maps supported platforms correctly', () => {
const platformMappings = [
{ platform: 'win32', expected: 'windows' },
{ platform: 'darwin', expected: 'macos' },
{ platform: 'linux', expected: 'linux' }
];
platformMappings.forEach(({ platform, expected }) => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: platform, configurable: true });
expect(platformUtils.detectOS()).toBe(expected);
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
});
});
test('detectArchitecture always returns a non-empty string', () => {
fc.assert(
fc.property(
fc.constant(null), // We don't need to generate anything, just run the test
() => {
const arch = platformUtils.detectArchitecture();
return typeof arch === 'string' && arch.length > 0;
}
),
{ numRuns: 100 }
);
});
test('getPlatformInfo returns consistent data structure', () => {
fc.assert(
fc.property(
fc.constant(null),
() => {
const info = platformUtils.getPlatformInfo();
// Check all required fields exist
const hasAllFields =
typeof info.os === 'string' &&
typeof info.arch === 'string' &&
typeof info.shell === 'string' &&
typeof info.platform === 'string' &&
typeof info.hostname === 'string' &&
typeof info.homedir === 'string' &&
typeof info.defaultInstallPath === 'string' &&
typeof info.userInstallPath === 'string';
// Check that OS is valid
const validOSes = ['windows', 'macos', 'linux', 'unknown'];
const hasValidOS = validOSes.includes(info.os);
// Check that shell is valid
const validShells = ['powershell', 'bash', 'zsh'];
const hasValidShell = validShells.includes(info.shell);
return hasAllFields && hasValidOS && hasValidShell;
}
),
{ numRuns: 100 }
);
});
test('platform detection is deterministic', () => {
fc.assert(
fc.property(
fc.constant(null),
() => {
const info1 = platformUtils.getPlatformInfo();
const info2 = platformUtils.getPlatformInfo();
// Same platform should return same results
return (
info1.os === info2.os &&
info1.arch === info2.arch &&
info1.shell === info2.shell &&
info1.platform === info2.platform
);
}
),
{ numRuns: 100 }
);
});
});
/**
* Feature: dashcaddy-installer, Property 18: Platform-Specific Path Handling
* For any detected operating system, the installer should use the correct
* default installation path and path separator for that platform.
* Validates: Requirements 2.1, 14.1, 14.2, 14.3, 14.6
*/
describe('Property 18: Platform-Specific Path Handling', () => {
test('getDefaultInstallPath returns platform-appropriate paths', () => {
const testCases = [
{ platform: 'win32', shouldContain: 'C:\\', shouldNotContain: '/' },
{ platform: 'darwin', shouldContain: '/Applications', shouldNotContain: '\\' },
{ platform: 'linux', shouldContain: '/opt', shouldNotContain: '\\' }
];
testCases.forEach(({ platform, shouldContain, shouldNotContain }) => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: platform, configurable: true });
const path = platformUtils.getDefaultInstallPath();
expect(path).toContain(shouldContain);
if (shouldNotContain) {
expect(path).not.toContain(shouldNotContain);
}
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
});
});
test('normalizePath handles any path string', () => {
fc.assert(
fc.property(
fc.string(),
(pathStr) => {
try {
const normalized = platformUtils.normalizePath(pathStr);
return typeof normalized === 'string';
} catch (error) {
// Some strings might be invalid paths, that's okay
return true;
}
}
),
{ numRuns: 100 }
);
});
test('joinPath produces valid paths for any segments', () => {
fc.assert(
fc.property(
fc.array(fc.string({ minLength: 1, maxLength: 20 }), { minLength: 1, maxLength: 5 }),
(segments) => {
try {
const joined = platformUtils.joinPath(...segments);
return typeof joined === 'string' && joined.length > 0;
} catch (error) {
// Some combinations might be invalid, that's okay
return true;
}
}
),
{ numRuns: 100 }
);
});
test('getUserInstallPath always includes DashCaddy', () => {
fc.assert(
fc.property(
fc.constant(null),
() => {
const userPath = platformUtils.getUserInstallPath();
return userPath.includes('DashCaddy');
}
),
{ numRuns: 100 }
);
});
});
/**
* Feature: dashcaddy-installer, Property 19: Platform-Specific Shell Selection
* For any detected operating system, the installer should use the appropriate
* shell (PowerShell on Windows, bash/zsh on Unix-like systems) for executing commands.
* Validates: Requirements 14.7
*/
describe('Property 19: Platform-Specific Shell Selection', () => {
test('detectShell returns valid shell for any platform', () => {
fc.assert(
fc.property(
fc.constantFrom('win32', 'darwin', 'linux'),
(platform) => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: platform, configurable: true });
const shell = platformUtils.detectShell();
const validShells = ['powershell', 'bash', 'zsh'];
const isValid = validShells.includes(shell);
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
return isValid;
}
),
{ numRuns: 100 }
);
});
test('Windows always uses PowerShell', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
expect(platformUtils.detectShell()).toBe('powershell');
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
});
test('Unix-like systems use bash or zsh', () => {
const unixPlatforms = ['darwin', 'linux'];
unixPlatforms.forEach(platform => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: platform, configurable: true });
const shell = platformUtils.detectShell();
expect(['bash', 'zsh']).toContain(shell);
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
});
});
test('getCommandExecution returns valid structure for any command', () => {
fc.assert(
fc.property(
fc.string({ minLength: 1, maxLength: 100 }),
(command) => {
const exec = platformUtils.getCommandExecution(command);
return (
typeof exec === 'object' &&
typeof exec.shell === 'string' &&
Array.isArray(exec.args) &&
exec.args.length > 0 &&
exec.args.includes(command)
);
}
),
{ numRuns: 100 }
);
});
test('getCommandExecution uses correct shell for platform', () => {
const testCases = [
{ platform: 'win32', expectedShell: 'powershell.exe' },
{ platform: 'darwin', expectedShells: ['bash', 'zsh'] },
{ platform: 'linux', expectedShells: ['bash', 'zsh'] }
];
testCases.forEach(({ platform, expectedShell, expectedShells }) => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: platform, configurable: true });
const exec = platformUtils.getCommandExecution('test');
if (expectedShell) {
expect(exec.shell).toBe(expectedShell);
} else if (expectedShells) {
expect(expectedShells).toContain(exec.shell);
}
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
});
});
});

View File

@@ -0,0 +1,195 @@
const platformUtils = require('./platform-utils');
describe('Platform Detection', () => {
describe('detectOS', () => {
test('detects Windows correctly', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'win32' });
expect(platformUtils.detectOS()).toBe('windows');
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
test('detects macOS correctly', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'darwin' });
expect(platformUtils.detectOS()).toBe('macos');
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
test('detects Linux correctly', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'linux' });
expect(platformUtils.detectOS()).toBe('linux');
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
test('returns unknown for unsupported platforms', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'freebsd' });
expect(platformUtils.detectOS()).toBe('unknown');
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
});
describe('detectArchitecture', () => {
test('returns current architecture', () => {
const arch = platformUtils.detectArchitecture();
expect(typeof arch).toBe('string');
expect(arch.length).toBeGreaterThan(0);
});
});
describe('detectShell', () => {
test('returns powershell for Windows', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'win32' });
expect(platformUtils.detectShell()).toBe('powershell');
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
test('returns bash or zsh for macOS', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'darwin' });
const shell = platformUtils.detectShell();
expect(['bash', 'zsh']).toContain(shell);
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
test('returns bash for Linux', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'linux' });
expect(platformUtils.detectShell()).toBe('bash');
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
});
describe('getDefaultInstallPath', () => {
test('returns Windows path for Windows', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'win32' });
expect(platformUtils.getDefaultInstallPath()).toBe('C:\\Program Files\\DashCaddy');
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
test('returns macOS path for macOS', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'darwin' });
expect(platformUtils.getDefaultInstallPath()).toBe('/Applications/DashCaddy');
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
test('returns Linux path for Linux', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'linux' });
expect(platformUtils.getDefaultInstallPath()).toBe('/opt/dashcaddy');
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
});
describe('Platform checks', () => {
test('isWindows returns boolean', () => {
expect(typeof platformUtils.isWindows()).toBe('boolean');
});
test('isMacOS returns boolean', () => {
expect(typeof platformUtils.isMacOS()).toBe('boolean');
});
test('isLinux returns boolean', () => {
expect(typeof platformUtils.isLinux()).toBe('boolean');
});
test('exactly one platform check returns true', () => {
const checks = [
platformUtils.isWindows(),
platformUtils.isMacOS(),
platformUtils.isLinux()
];
const trueCount = checks.filter(Boolean).length;
expect(trueCount).toBe(1);
});
});
describe('getPlatformInfo', () => {
test('returns complete platform information', () => {
const info = platformUtils.getPlatformInfo();
expect(info).toHaveProperty('os');
expect(info).toHaveProperty('arch');
expect(info).toHaveProperty('shell');
expect(info).toHaveProperty('platform');
expect(info).toHaveProperty('hostname');
expect(info).toHaveProperty('homedir');
expect(info).toHaveProperty('defaultInstallPath');
expect(info).toHaveProperty('userInstallPath');
expect(typeof info.os).toBe('string');
expect(typeof info.arch).toBe('string');
expect(typeof info.shell).toBe('string');
expect(typeof info.hostname).toBe('string');
expect(typeof info.homedir).toBe('string');
});
});
describe('Path utilities', () => {
test('normalizePath normalizes paths', () => {
const normalized = platformUtils.normalizePath('/path//to///file');
expect(normalized).not.toContain('//');
});
test('joinPath joins path segments', () => {
const joined = platformUtils.joinPath('path', 'to', 'file');
expect(joined).toContain('path');
expect(joined).toContain('file');
});
test('getUserInstallPath returns path in home directory', () => {
const userPath = platformUtils.getUserInstallPath();
expect(userPath).toContain('DashCaddy');
});
});
describe('getCommandExecution', () => {
test('returns PowerShell execution for Windows', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'win32' });
const exec = platformUtils.getCommandExecution('test command');
expect(exec.shell).toBe('powershell.exe');
expect(exec.args).toEqual(['-Command', 'test command']);
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
test('returns shell execution for Unix-like systems', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'linux' });
const exec = platformUtils.getCommandExecution('test command');
expect(['bash', 'zsh']).toContain(exec.shell);
expect(exec.args).toEqual(['-c', 'test command']);
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
});
});

View File

@@ -0,0 +1,23 @@
# DashCaddy Caddyfile
# Generated by DashCaddy Installer
# Global options
{
admin localhost:2019
auto_https off
}
# Dashboard
:{{PORT}} {
root * {{DASHBOARD_PATH}}
file_server
encode gzip
# API proxy
handle /api/* {
reverse_proxy localhost:{{API_PORT}}
}
# SPA fallback
try_files {path} /index.html
}

View File

@@ -0,0 +1,21 @@
# DashCaddy Installer Templates
This directory contains template files used by the installer to generate configuration files.
## Templates
- **Caddyfile.template** - Template for generating Caddyfile configuration
- **docker-compose.template.yml** - Template for generating docker-compose.yml
## Template Variables
Templates use `{{VARIABLE}}` syntax for variable substitution:
### Caddyfile.template
- `{{PORT}}` - Dashboard port (default: 8080)
- `{{DASHBOARD_PATH}}` - Path to dashboard files
- `{{API_PORT}}` - API server port (default: 3001)
### docker-compose.template.yml
- `{{API_PATH}}` - Path to API server directory
- `{{API_PORT}}` - API server port (default: 3001)

View File

@@ -0,0 +1,21 @@
services:
dashcaddy-api:
build: {{API_PATH}}
container_name: dashcaddy-api
ports:
- "{{API_PORT}}:{{API_PORT}}"
volumes:
- {{API_PATH}}:/app
- /var/run/docker.sock:/var/run/docker.sock
environment:
- NODE_ENV=production
- PORT={{API_PORT}}
- SERVICES_FILE=/app/services.json
- CADDY_ADMIN_URL=http://host.docker.internal:2019
restart: unless-stopped
networks:
- dashcaddy
networks:
dashcaddy:
driver: bridge