Initial commit: DashCaddy v1.0
Full codebase including API server (32 modules + routes), dashboard frontend, DashCA certificate distribution, installer script, and deployment skills.
260
dashcaddy-installer/BUILD_GUIDE.md
Normal 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
|
||||
184
dashcaddy-installer/QUICK_START.md
Normal 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
|
||||
230
dashcaddy-installer/README.md
Normal 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
|
||||
BIN
dashcaddy-installer/assets/DashCaddy logo dark.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
dashcaddy-installer/assets/dashcaddy logo blue.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
dashcaddy-installer/assets/dashcaddy logo icon.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
dashcaddy-installer/assets/dashcaddy logo light.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
dashcaddy-installer/assets/dashcaddy logo.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
dashcaddy-installer/assets/dashcaddy-logo.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
dashcaddy-installer/assets/favicon.ico
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
dashcaddy-installer/assets/icon.ico
Normal file
|
After Width: | Height: | Size: 23 KiB |
960
dashcaddy-installer/install.sh
Normal 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 "$@"
|
||||
87
dashcaddy-installer/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
111
dashcaddy-installer/src/main/browser-launcher.js
Normal 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;
|
||||
375
dashcaddy-installer/src/main/caddyfile-generator.js
Normal 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;
|
||||
781
dashcaddy-installer/src/main/config-manager.js
Normal 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;
|
||||
837
dashcaddy-installer/src/main/config-manager.property.test.js
Normal 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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
288
dashcaddy-installer/src/main/config-manager.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
792
dashcaddy-installer/src/main/dependency-checker.js
Normal 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;
|
||||
269
dashcaddy-installer/src/main/dependency-checker.property.test.js
Normal 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
|
||||
});
|
||||
263
dashcaddy-installer/src/main/dependency-checker.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
357
dashcaddy-installer/src/main/download-manager.js
Normal 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;
|
||||
230
dashcaddy-installer/src/main/file-deployer.js
Normal 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;
|
||||
1047
dashcaddy-installer/src/main/index.js
Normal file
392
dashcaddy-installer/src/main/service-manager.js
Normal 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;
|
||||
102
dashcaddy-installer/src/preload/index.js
Normal 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);
|
||||
}
|
||||
});
|
||||
23
dashcaddy-installer/src/renderer/index.html
Normal 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>
|
||||
1036
dashcaddy-installer/src/renderer/styles.css
Normal file
1414
dashcaddy-installer/src/renderer/wizard.js
Normal file
51
dashcaddy-installer/src/shared/constants.js
Normal 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
|
||||
};
|
||||
174
dashcaddy-installer/src/shared/platform-utils.js
Normal 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
|
||||
};
|
||||
289
dashcaddy-installer/src/shared/platform-utils.property.test.js
Normal 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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
195
dashcaddy-installer/src/shared/platform-utils.test.js
Normal 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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
23
dashcaddy-installer/templates/Caddyfile.template
Normal 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
|
||||
}
|
||||
21
dashcaddy-installer/templates/README.md
Normal 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)
|
||||
21
dashcaddy-installer/templates/docker-compose.template.yml
Normal 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
|
||||