diff options
| author | bndw <ben@bdw.to> | 2025-12-28 09:21:08 -0800 |
|---|---|---|
| committer | bndw <ben@bdw.to> | 2025-12-28 09:21:08 -0800 |
| commit | 13c2f9cffa624fdf498f3b61fab9d809b92e026e (patch) | |
| tree | 4b25205ccd05e9e887376c10edb2f4069ea1d9d4 | |
init
| -rw-r--r-- | .gitignore | 19 | ||||
| -rw-r--r-- | DESIGN_SPEC.md | 586 | ||||
| -rw-r--r-- | README.md | 221 | ||||
| -rw-r--r-- | go.mod | 7 | ||||
| -rw-r--r-- | go.sum | 6 | ||||
| -rw-r--r-- | internal/config/config.go | 65 | ||||
| -rw-r--r-- | internal/ssh/client.go | 357 | ||||
| -rw-r--r-- | internal/state/state.go | 146 | ||||
| -rw-r--r-- | internal/templates/templates.go | 82 | ||||
| -rw-r--r-- | templates/app.caddy.tmpl | 3 | ||||
| -rw-r--r-- | templates/service.tmpl | 17 | ||||
| -rw-r--r-- | templates/static.caddy.tmpl | 5 | ||||
| -rw-r--r-- | test/example-website/about.html | 42 | ||||
| -rw-r--r-- | test/example-website/index.html | 46 | ||||
| -rw-r--r-- | test/example-website/style.css | 156 |
15 files changed, 1758 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c23b850 --- /dev/null +++ b/.gitignore | |||
| @@ -0,0 +1,19 @@ | |||
| 1 | # Binaries | ||
| 2 | deploy | ||
| 3 | *.exe | ||
| 4 | *.dll | ||
| 5 | *.so | ||
| 6 | *.dylib | ||
| 7 | |||
| 8 | # Test binary | ||
| 9 | *.test | ||
| 10 | |||
| 11 | # Go workspace file | ||
| 12 | go.work | ||
| 13 | |||
| 14 | # IDE | ||
| 15 | .vscode/ | ||
| 16 | .idea/ | ||
| 17 | *.swp | ||
| 18 | *.swo | ||
| 19 | *~ | ||
diff --git a/DESIGN_SPEC.md b/DESIGN_SPEC.md new file mode 100644 index 0000000..e8bb197 --- /dev/null +++ b/DESIGN_SPEC.md | |||
| @@ -0,0 +1,586 @@ | |||
| 1 | # VPS Deployment CLI - Design Spec | ||
| 2 | |||
| 3 | ## Overview | ||
| 4 | Standalone CLI tool for deploying Go apps and static sites to a VPS with automatic HTTPS via Caddy. | ||
| 5 | |||
| 6 | ## Server Setup (One-time) | ||
| 7 | |||
| 8 | ### Build and Install the Deploy CLI on Your Laptop | ||
| 9 | ```bash | ||
| 10 | # Clone and build the deploy CLI | ||
| 11 | git clone <this-repo> deploy | ||
| 12 | cd deploy | ||
| 13 | go build -o ~/bin/deploy ./cmd/deploy | ||
| 14 | # Or: go install ./cmd/deploy (if ~/go/bin is in your PATH) | ||
| 15 | |||
| 16 | # Initialize a fresh VPS (one-time) | ||
| 17 | deploy init --host user@your-vps-ip | ||
| 18 | |||
| 19 | # The CLI will SSH into the VPS and: | ||
| 20 | # - Detect OS (Ubuntu/Debian supported) | ||
| 21 | # - Install Caddy from official repository | ||
| 22 | # - Configure Caddy to import `/etc/caddy/sites-enabled/*` | ||
| 23 | # - Create `/etc/deploy/env/` directory for env files | ||
| 24 | # - Create `/etc/caddy/sites-enabled/` directory | ||
| 25 | # - Enable and start Caddy service | ||
| 26 | # - Verify installation | ||
| 27 | # | ||
| 28 | # State is stored locally at ~/.config/deploy/state.json | ||
| 29 | ``` | ||
| 30 | |||
| 31 | ## CLI Commands | ||
| 32 | |||
| 33 | All commands run from your laptop and use `--host` to specify the VPS. | ||
| 34 | |||
| 35 | ### Initialize VPS (One-time Setup) | ||
| 36 | ```bash | ||
| 37 | # On a fresh VPS, run this once to install all dependencies | ||
| 38 | deploy init --host user@your-vps-ip | ||
| 39 | |||
| 40 | # This will SSH to the VPS and: | ||
| 41 | # - Install Caddy | ||
| 42 | # - Configure Caddy to use sites-enabled pattern | ||
| 43 | # - Create /etc/deploy/env/ directory for env files | ||
| 44 | # - Enable and start Caddy | ||
| 45 | # | ||
| 46 | # State is stored locally at ~/.config/deploy/state.json | ||
| 47 | ``` | ||
| 48 | |||
| 49 | ### Deploy Go App | ||
| 50 | ```bash | ||
| 51 | # Build locally, then deploy | ||
| 52 | deploy --host user@vps-ip --domain api.example.com | ||
| 53 | |||
| 54 | # With custom name (defaults to directory name or binary name) | ||
| 55 | deploy --host user@vps-ip --name myapi --domain api.example.com | ||
| 56 | |||
| 57 | # Deploy specific binary | ||
| 58 | deploy --host user@vps-ip --binary ./myapp --domain api.example.com | ||
| 59 | |||
| 60 | # With environment variables | ||
| 61 | deploy --host user@vps-ip --domain api.example.com --env DB_HOST=localhost --env API_KEY=secret | ||
| 62 | |||
| 63 | # Or from env file | ||
| 64 | deploy --host user@vps-ip --domain api.example.com --env-file .env.production | ||
| 65 | ``` | ||
| 66 | |||
| 67 | ### Deploy Static Site | ||
| 68 | ```bash | ||
| 69 | # Deploy current directory | ||
| 70 | deploy --host user@vps-ip --static --domain example.com | ||
| 71 | |||
| 72 | # Deploy specific directory | ||
| 73 | deploy --host user@vps-ip --static --dir ./dist --domain example.com | ||
| 74 | |||
| 75 | # With custom name | ||
| 76 | deploy --host user@vps-ip --static --name mysite --dir ./build --domain example.com | ||
| 77 | ``` | ||
| 78 | |||
| 79 | ### Management Commands | ||
| 80 | ```bash | ||
| 81 | # List all deployed apps/sites | ||
| 82 | deploy list --host user@vps-ip | ||
| 83 | |||
| 84 | # Remove deployment | ||
| 85 | deploy remove myapp --host user@vps-ip | ||
| 86 | |||
| 87 | # View logs | ||
| 88 | deploy logs myapp --host user@vps-ip | ||
| 89 | |||
| 90 | # Restart app | ||
| 91 | deploy restart myapp --host user@vps-ip | ||
| 92 | |||
| 93 | # View status | ||
| 94 | deploy status myapp --host user@vps-ip | ||
| 95 | |||
| 96 | # Set/update environment variables | ||
| 97 | deploy env myapi --host user@vps-ip --set DB_HOST=localhost --set API_KEY=secret | ||
| 98 | |||
| 99 | # View environment variables (secrets are masked) | ||
| 100 | deploy env myapi --host user@vps-ip | ||
| 101 | |||
| 102 | # Load from file | ||
| 103 | deploy env myapi --host user@vps-ip --file .env.production | ||
| 104 | |||
| 105 | # Remove specific env var | ||
| 106 | deploy env myapi --host user@vps-ip --unset API_KEY | ||
| 107 | ``` | ||
| 108 | |||
| 109 | ### Optional: Config File | ||
| 110 | ```bash | ||
| 111 | # Create ~/.config/deploy/config to avoid typing --host every time | ||
| 112 | mkdir -p ~/.config/deploy | ||
| 113 | cat > ~/.config/deploy/config <<EOF | ||
| 114 | host: user@your-vps-ip | ||
| 115 | EOF | ||
| 116 | |||
| 117 | # Now you can omit --host flag | ||
| 118 | deploy --domain api.example.com | ||
| 119 | deploy list | ||
| 120 | ``` | ||
| 121 | |||
| 122 | ## Deployment Workflow | ||
| 123 | |||
| 124 | ### Initial VPS Setup | ||
| 125 | ```bash | ||
| 126 | # 1. Build the deploy CLI (in this repo) | ||
| 127 | cd /path/to/deploy-repo | ||
| 128 | go build -o ~/bin/deploy ./cmd/deploy | ||
| 129 | |||
| 130 | # 2. Initialize fresh VPS from your laptop | ||
| 131 | deploy init --host user@your-vps-ip | ||
| 132 | # This SSHs to VPS, installs Caddy, creates directories | ||
| 133 | |||
| 134 | # 3. Optional: Create config file to avoid typing --host | ||
| 135 | mkdir -p ~/.config/deploy | ||
| 136 | echo "host: user@your-vps-ip" > ~/.config/deploy/config | ||
| 137 | |||
| 138 | # Done! VPS is ready for deployments | ||
| 139 | ``` | ||
| 140 | |||
| 141 | ### Go Apps | ||
| 142 | ```bash | ||
| 143 | # 1. Build locally | ||
| 144 | GOOS=linux GOARCH=amd64 go build -o myapp | ||
| 145 | |||
| 146 | # 2. Deploy from laptop (with config file) | ||
| 147 | deploy --binary ./myapp --domain api.example.com | ||
| 148 | |||
| 149 | # Or specify host explicitly | ||
| 150 | deploy --host user@vps-ip --binary ./myapp --domain api.example.com | ||
| 151 | # CLI will: scp binary to VPS, set up systemd, configure Caddy, start service | ||
| 152 | ``` | ||
| 153 | |||
| 154 | ### Static Sites | ||
| 155 | ```bash | ||
| 156 | # 1. Build locally | ||
| 157 | npm run build | ||
| 158 | |||
| 159 | # 2. Deploy from laptop | ||
| 160 | deploy --static --dir ./dist --domain example.com | ||
| 161 | # CLI will: rsync files to VPS, configure Caddy | ||
| 162 | ``` | ||
| 163 | |||
| 164 | ## App Requirements | ||
| 165 | |||
| 166 | ### Go Apps Must: | ||
| 167 | - Listen on HTTP (standard `http.ListenAndServe`) | ||
| 168 | - Accept port via env var `PORT` or flag `--port` | ||
| 169 | - Bind to `localhost` or `127.0.0.1` only (Caddy will handle external traffic) | ||
| 170 | |||
| 171 | **Example main.go:** | ||
| 172 | ```go | ||
| 173 | package main | ||
| 174 | |||
| 175 | import ( | ||
| 176 | "flag" | ||
| 177 | "fmt" | ||
| 178 | "net/http" | ||
| 179 | "os" | ||
| 180 | ) | ||
| 181 | |||
| 182 | func main() { | ||
| 183 | port := flag.String("port", os.Getenv("PORT"), "port to listen on") | ||
| 184 | flag.Parse() | ||
| 185 | |||
| 186 | if *port == "" { | ||
| 187 | *port = "8080" // fallback for local dev | ||
| 188 | } | ||
| 189 | |||
| 190 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { | ||
| 191 | w.Write([]byte("Hello World")) | ||
| 192 | }) | ||
| 193 | |||
| 194 | addr := "127.0.0.1:" + *port | ||
| 195 | fmt.Printf("Listening on %s\n", addr) | ||
| 196 | http.ListenAndServe(addr, nil) | ||
| 197 | } | ||
| 198 | ``` | ||
| 199 | |||
| 200 | ## CLI Implementation | ||
| 201 | |||
| 202 | ### How It Works | ||
| 203 | The CLI is a single binary that runs on your laptop and orchestrates deployments via SSH: | ||
| 204 | |||
| 205 | 1. **Connect**: Uses SSH to connect to VPS (via `--host` flag or config file) | ||
| 206 | 2. **Transfer**: Copies files to VPS using scp (binaries) or rsync (static sites) | ||
| 207 | 3. **Execute**: Runs commands on VPS over SSH to: | ||
| 208 | - Create system users | ||
| 209 | - Install systemd units | ||
| 210 | - Generate Caddy configs | ||
| 211 | - Manage services | ||
| 212 | 4. **Verify**: Checks deployment status and returns results | ||
| 213 | |||
| 214 | ### Package Structure | ||
| 215 | ``` | ||
| 216 | deploy/ | ||
| 217 | ├── cmd/deploy/ | ||
| 218 | │ └── main.go # CLI entry point | ||
| 219 | ├── internal/ | ||
| 220 | │ ├── ssh/ | ||
| 221 | │ │ └── ssh.go # SSH connection & command execution | ||
| 222 | │ ├── init/ | ||
| 223 | │ │ └── init.go # VPS initialization logic | ||
| 224 | │ ├── app/ | ||
| 225 | │ │ └── app.go # Go app deployment logic | ||
| 226 | │ ├── static/ | ||
| 227 | │ │ └── static.go # Static site deployment logic | ||
| 228 | │ ├── systemd/ | ||
| 229 | │ │ └── systemd.go # Systemd operations | ||
| 230 | │ ├── caddy/ | ||
| 231 | │ │ └── caddy.go # Caddy config generation | ||
| 232 | │ └── config/ | ||
| 233 | │ └── config.go # Deployment metadata & port allocation | ||
| 234 | └── templates/ | ||
| 235 | ├── service.tmpl # Systemd unit template | ||
| 236 | ├── app.caddy.tmpl # Caddy config for apps | ||
| 237 | └── static.caddy.tmpl # Caddy config for static sites | ||
| 238 | ``` | ||
| 239 | |||
| 240 | ### Deployment State (Laptop) | ||
| 241 | All deployment state stored locally at `~/.config/deploy/state.json`: | ||
| 242 | ```json | ||
| 243 | { | ||
| 244 | "hosts": { | ||
| 245 | "user@vps-ip": { | ||
| 246 | "next_port": 8003, | ||
| 247 | "apps": { | ||
| 248 | "myapi": { | ||
| 249 | "type": "app", | ||
| 250 | "domain": "api.example.com", | ||
| 251 | "port": 8001, | ||
| 252 | "env": { | ||
| 253 | "DB_HOST": "localhost", | ||
| 254 | "DB_PORT": "5432", | ||
| 255 | "API_KEY": "secret123", | ||
| 256 | "ENVIRONMENT": "production" | ||
| 257 | } | ||
| 258 | }, | ||
| 259 | "anotherapp": { | ||
| 260 | "type": "app", | ||
| 261 | "domain": "another.example.com", | ||
| 262 | "port": 8002, | ||
| 263 | "env": {} | ||
| 264 | }, | ||
| 265 | "mysite": { | ||
| 266 | "type": "static", | ||
| 267 | "domain": "example.com" | ||
| 268 | } | ||
| 269 | } | ||
| 270 | } | ||
| 271 | } | ||
| 272 | } | ||
| 273 | ``` | ||
| 274 | |||
| 275 | ### Environment Files (VPS) | ||
| 276 | Environment variables written to `/etc/deploy/env/{appname}.env` on VPS for systemd to read: | ||
| 277 | ```bash | ||
| 278 | # /etc/deploy/env/myapi.env (generated from state.json) | ||
| 279 | PORT=8001 | ||
| 280 | DB_HOST=localhost | ||
| 281 | DB_PORT=5432 | ||
| 282 | API_KEY=secret123 | ||
| 283 | ENVIRONMENT=production | ||
| 284 | ``` | ||
| 285 | |||
| 286 | ### CLI Behavior | ||
| 287 | |||
| 288 | **Host Resolution:** | ||
| 289 | - CLI runs on your laptop, connects to VPS via SSH | ||
| 290 | - Host specified via `--host user@vps-ip` flag | ||
| 291 | - Or read from `~/.config/deploy/config` file if present | ||
| 292 | - All operations execute on remote VPS over SSH | ||
| 293 | - Files transferred via scp/rsync | ||
| 294 | |||
| 295 | **Init Command:** | ||
| 296 | - Detects OS (Ubuntu 20.04+, Debian 11+) | ||
| 297 | - Checks if Caddy is already installed (skip if present) | ||
| 298 | - Installs Caddy via official APT repository | ||
| 299 | - Creates `/etc/caddy/Caddyfile` with `import /etc/caddy/sites-enabled/*` | ||
| 300 | - Creates directory structure: `/etc/deploy/env/`, `/etc/caddy/sites-enabled/` | ||
| 301 | - Enables and starts Caddy | ||
| 302 | - Runs health check (verify Caddy is running) | ||
| 303 | - Initializes local state file at `~/.config/deploy/state.json` if not present | ||
| 304 | - Outputs success message with next steps | ||
| 305 | |||
| 306 | **Smart Defaults:** | ||
| 307 | - App name: infer from `--name` flag, or binary filename, or current directory | ||
| 308 | - Binary: use `--binary` flag to specify path, or look for binary in current directory | ||
| 309 | - Port: auto-allocate next available port (starting from 8001) | ||
| 310 | - Working directory: `/var/lib/{appname}` | ||
| 311 | - System user: `{appname}` | ||
| 312 | - Binary location: `/usr/local/bin/{appname}` | ||
| 313 | |||
| 314 | **Port Management:** | ||
| 315 | - Automatically allocates next available port from 8001+ | ||
| 316 | - Tracks allocations in local `~/.config/deploy/state.json` | ||
| 317 | - User can override with `--port` flag if desired | ||
| 318 | - Prevents port conflicts | ||
| 319 | |||
| 320 | **Validation:** | ||
| 321 | - Check if domain is already in use (check local state) | ||
| 322 | - Verify binary exists and is executable (for apps) | ||
| 323 | - Confirm Caddy is installed and running | ||
| 324 | - Check for port conflicts (check local state) | ||
| 325 | - Require root/sudo on VPS | ||
| 326 | |||
| 327 | **State Management:** | ||
| 328 | - All deployment state lives on laptop at `~/.config/deploy/state.json` | ||
| 329 | - VPS is "dumb" - just runs Caddy, systemd, and deployed apps | ||
| 330 | - `deploy list` is instant (no SSH needed, reads local state) | ||
| 331 | - Easy to recreate VPS from scratch by redeploying all apps from local state | ||
| 332 | - Can manage multiple VPSes from one laptop | ||
| 333 | - State backed up with your laptop backups | ||
| 334 | - Trade-off: State can drift if VPS is manually modified (user should use CLI only) | ||
| 335 | |||
| 336 | ### Installation Steps (Go Apps) | ||
| 337 | |||
| 338 | All steps executed remotely on VPS via SSH: | ||
| 339 | |||
| 340 | 1. Detect app name from `--name` flag, or binary filename, or current directory | ||
| 341 | 2. SCP binary from laptop to VPS temp directory | ||
| 342 | 3. Allocate port (auto-assign next available or use `--port` override) | ||
| 343 | 4. Create system user (e.g., `myapp`) | ||
| 344 | 5. Create working directory (`/var/lib/myapp`) | ||
| 345 | 6. Copy binary to `/usr/local/bin/myapp` | ||
| 346 | 7. Create env file at `/etc/deploy/env/myapp.env` with PORT and any user-provided vars | ||
| 347 | 8. Set env file permissions (0600, owned by app user) | ||
| 348 | 9. Generate systemd unit at `/etc/systemd/system/myapp.service` with EnvironmentFile | ||
| 349 | 10. Generate Caddy config at `/etc/caddy/sites-enabled/myapp.caddy` pointing to localhost:port | ||
| 350 | 11. Update local state at `~/.config/deploy/state.json` (on laptop) | ||
| 351 | 12. Enable and start service | ||
| 352 | 13. Reload Caddy | ||
| 353 | |||
| 354 | ### Installation Steps (Static Sites) | ||
| 355 | |||
| 356 | All steps executed remotely on VPS via SSH: | ||
| 357 | |||
| 358 | 1. Detect site name from `--name` flag or directory name | ||
| 359 | 2. Rsync files from laptop to VPS `/var/www/{sitename}` | ||
| 360 | 3. Set ownership to `www-data:www-data` | ||
| 361 | 4. Generate Caddy config at `/etc/caddy/sites-enabled/{sitename}.caddy` | ||
| 362 | 5. Update local state at `~/.config/deploy/state.json` (on laptop) | ||
| 363 | 6. Reload Caddy | ||
| 364 | |||
| 365 | ## File Structure | ||
| 366 | |||
| 367 | ### On Laptop | ||
| 368 | ``` | ||
| 369 | ~/.config/deploy/state.json # All deployment state (apps, ports, env vars) | ||
| 370 | ~/.config/deploy/config # Optional: default host configuration | ||
| 371 | ``` | ||
| 372 | |||
| 373 | ### On VPS | ||
| 374 | ``` | ||
| 375 | /usr/local/bin/myapp # Go binary | ||
| 376 | /var/lib/myapp/ # Working directory | ||
| 377 | /etc/systemd/system/myapp.service # Systemd unit | ||
| 378 | /etc/caddy/sites-enabled/myapp.caddy # Caddy config | ||
| 379 | /etc/deploy/env/myapp.env # Environment variables (0600 permissions) | ||
| 380 | |||
| 381 | /var/www/mysite/ # Static site files | ||
| 382 | /etc/caddy/sites-enabled/mysite.caddy # Caddy config | ||
| 383 | ``` | ||
| 384 | |||
| 385 | ## Templates | ||
| 386 | |||
| 387 | ### systemd unit (`service.tmpl`) | ||
| 388 | ```ini | ||
| 389 | [Unit] | ||
| 390 | Description={{.Name}} | ||
| 391 | After=network.target | ||
| 392 | |||
| 393 | [Service] | ||
| 394 | Type=simple | ||
| 395 | User={{.User}} | ||
| 396 | WorkingDirectory={{.WorkDir}} | ||
| 397 | EnvironmentFile={{.EnvFile}} | ||
| 398 | ExecStart={{.BinaryPath}} --port={{.Port}} | ||
| 399 | Restart=always | ||
| 400 | RestartSec=5s | ||
| 401 | NoNewPrivileges=true | ||
| 402 | PrivateTmp=true | ||
| 403 | |||
| 404 | [Install] | ||
| 405 | WantedBy=multi-user.target | ||
| 406 | ``` | ||
| 407 | |||
| 408 | ### Caddy config for Go apps (`app.caddy.tmpl`) | ||
| 409 | ``` | ||
| 410 | {{.Domain}} { | ||
| 411 | reverse_proxy 127.0.0.1:{{.Port}} | ||
| 412 | } | ||
| 413 | ``` | ||
| 414 | |||
| 415 | ### Caddy config for static sites (`static.caddy.tmpl`) | ||
| 416 | ``` | ||
| 417 | {{.Domain}} { | ||
| 418 | root * {{.RootDir}} | ||
| 419 | file_server | ||
| 420 | encode gzip | ||
| 421 | } | ||
| 422 | ``` | ||
| 423 | |||
| 424 | ## Optional Helper Module | ||
| 425 | |||
| 426 | For apps that want minimal boilerplate: | ||
| 427 | |||
| 428 | ```go | ||
| 429 | // github.com/yourusername/deploy/httpserver | ||
| 430 | |||
| 431 | package httpserver | ||
| 432 | |||
| 433 | import ( | ||
| 434 | "flag" | ||
| 435 | "fmt" | ||
| 436 | "net/http" | ||
| 437 | "os" | ||
| 438 | ) | ||
| 439 | |||
| 440 | // ListenAndServe starts HTTP server on PORT env var or --port flag | ||
| 441 | func ListenAndServe(handler http.Handler) error { | ||
| 442 | port := flag.String("port", os.Getenv("PORT"), "port to listen on") | ||
| 443 | flag.Parse() | ||
| 444 | |||
| 445 | if *port == "" { | ||
| 446 | *port = "8080" // fallback for local dev | ||
| 447 | } | ||
| 448 | |||
| 449 | addr := "127.0.0.1:" + *port | ||
| 450 | fmt.Printf("Listening on %s\n", addr) | ||
| 451 | return http.ListenAndServe(addr, handler) | ||
| 452 | } | ||
| 453 | ``` | ||
| 454 | |||
| 455 | ## Example Usage Scenarios | ||
| 456 | |||
| 457 | ### Scenario 1: New Go API (Fresh VPS) | ||
| 458 | ```bash | ||
| 459 | # ONE-TIME: Initialize fresh VPS from your laptop | ||
| 460 | deploy init --host user@your-vps-ip | ||
| 461 | # Installs Caddy, sets up directory structure | ||
| 462 | |||
| 463 | # Optional: Save host to config | ||
| 464 | mkdir -p ~/.config/deploy | ||
| 465 | echo "host: user@your-vps-ip" > ~/.config/deploy/config | ||
| 466 | |||
| 467 | # Develop locally | ||
| 468 | mkdir myapi && cd myapi | ||
| 469 | go mod init myapi | ||
| 470 | |||
| 471 | # Write standard HTTP app | ||
| 472 | cat > main.go <<'EOF' | ||
| 473 | package main | ||
| 474 | import ( | ||
| 475 | "flag" | ||
| 476 | "fmt" | ||
| 477 | "net/http" | ||
| 478 | "os" | ||
| 479 | ) | ||
| 480 | func main() { | ||
| 481 | port := flag.String("port", os.Getenv("PORT"), "port to listen on") | ||
| 482 | flag.Parse() | ||
| 483 | if *port == "" { | ||
| 484 | *port = "8080" | ||
| 485 | } | ||
| 486 | |||
| 487 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { | ||
| 488 | w.Write([]byte("Hello World")) | ||
| 489 | }) | ||
| 490 | |||
| 491 | addr := "127.0.0.1:" + *port | ||
| 492 | fmt.Printf("Listening on %s\n", addr) | ||
| 493 | http.ListenAndServe(addr, nil) | ||
| 494 | } | ||
| 495 | EOF | ||
| 496 | |||
| 497 | # Build and deploy from laptop | ||
| 498 | GOOS=linux GOARCH=amd64 go build | ||
| 499 | deploy --binary ./myapi --domain api.example.com | ||
| 500 | # CLI: SCPs binary to VPS, creates systemd service, configures Caddy | ||
| 501 | |||
| 502 | # Later, add environment variables from laptop | ||
| 503 | deploy env myapi --set DB_HOST=localhost --set DB_PORT=5432 | ||
| 504 | # Service automatically restarts to pick up new env vars | ||
| 505 | ``` | ||
| 506 | |||
| 507 | ### Scenario 2: React/Vue/etc Static Site | ||
| 508 | ```bash | ||
| 509 | # Build | ||
| 510 | npm run build | ||
| 511 | |||
| 512 | # Deploy from laptop | ||
| 513 | deploy --static --dir ./dist --domain example.com --name mysite | ||
| 514 | # CLI rsyncs files to VPS, configures Caddy | ||
| 515 | ``` | ||
| 516 | |||
| 517 | ### Scenario 3: Update Existing Deployment | ||
| 518 | ```bash | ||
| 519 | # Rebuild | ||
| 520 | GOOS=linux GOARCH=amd64 go build | ||
| 521 | |||
| 522 | # Redeploy (same command, it will update) | ||
| 523 | deploy --binary ./myapi --domain api.example.com | ||
| 524 | # CLI detects existing deployment, stops service, updates binary, restarts | ||
| 525 | # Keeps same port allocation | ||
| 526 | ``` | ||
| 527 | |||
| 528 | ### Scenario 4: Multiple Apps | ||
| 529 | ```bash | ||
| 530 | # Deploy first app from laptop | ||
| 531 | deploy --binary api1 --domain api1.example.com # Gets port 8001 | ||
| 532 | |||
| 533 | # Deploy second app | ||
| 534 | deploy --binary api2 --domain api2.example.com # Gets port 8002 | ||
| 535 | |||
| 536 | # Deploy third app | ||
| 537 | deploy --binary api3 --domain api3.example.com # Gets port 8003 | ||
| 538 | |||
| 539 | # View all deployments from laptop | ||
| 540 | deploy list | ||
| 541 | # Output: | ||
| 542 | # api1 app api1.example.com :8001 running | ||
| 543 | # api2 app api2.example.com :8002 running | ||
| 544 | # api3 app api3.example.com :8003 running | ||
| 545 | ``` | ||
| 546 | |||
| 547 | ## Security Considerations | ||
| 548 | |||
| 549 | - Run each Go app as dedicated system user | ||
| 550 | - Use systemd security hardening (NoNewPrivileges, PrivateTmp, ProtectSystem) | ||
| 551 | - Static sites served as www-data | ||
| 552 | - Caddy automatically handles TLS cert management | ||
| 553 | - Environment files stored at `/etc/deploy/env/{app}.env` with 0600 permissions | ||
| 554 | - Env files owned by the app's system user | ||
| 555 | - `deploy env` command masks sensitive values when displaying (shows `API_KEY=***`) | ||
| 556 | - Consider using external secret management for production (out of scope for v1) | ||
| 557 | |||
| 558 | ## Features | ||
| 559 | |||
| 560 | ✓ Single command deployment from anywhere | ||
| 561 | ✓ Automatic HTTPS via Caddy + Let's Encrypt | ||
| 562 | ✓ Automatic port allocation (no manual tracking needed) | ||
| 563 | ✓ Standard HTTP - write apps the normal way | ||
| 564 | ✓ Environment variable management (set via CLI or .env files) | ||
| 565 | ✓ Secure env file storage (0600 permissions) | ||
| 566 | ✓ Systemd process management with auto-restart | ||
| 567 | ✓ Support for multiple apps/sites on one VPS | ||
| 568 | ✓ Manage multiple VPSes from one laptop | ||
| 569 | ✓ State stored locally (VPS is stateless and easily recreatable) | ||
| 570 | ✓ Security hardening by default | ||
| 571 | ✓ Update existing deployments with same command | ||
| 572 | ✓ Clean removal with `deploy remove` | ||
| 573 | ✓ View logs/status of any deployment | ||
| 574 | ✓ Minimal app requirements (just accept PORT env/flag) | ||
| 575 | |||
| 576 | ## Out of Scope (for v1) | ||
| 577 | |||
| 578 | - Database setup/management | ||
| 579 | - Secrets encryption at rest (env files are protected by file permissions) | ||
| 580 | - Secret rotation automation | ||
| 581 | - Log rotation (systemd journald handles this) | ||
| 582 | - Monitoring/alerting | ||
| 583 | - Blue/green or zero-downtime deployments | ||
| 584 | - Automatic updates/CI integration | ||
| 585 | - Custom systemd unit modifications | ||
| 586 | - Non-Caddy web servers | ||
diff --git a/README.md b/README.md new file mode 100644 index 0000000..8125bc9 --- /dev/null +++ b/README.md | |||
| @@ -0,0 +1,221 @@ | |||
| 1 | # Deploy - VPS Deployment CLI | ||
| 2 | |||
| 3 | Simple CLI tool for deploying Go apps and static sites to a VPS with automatic HTTPS via Caddy. | ||
| 4 | |||
| 5 | ## Features | ||
| 6 | |||
| 7 | - Single command deployment from your laptop | ||
| 8 | - Automatic HTTPS via Caddy + Let's Encrypt | ||
| 9 | - Automatic port allocation (no manual tracking) | ||
| 10 | - Environment variable management | ||
| 11 | - Systemd process management with auto-restart | ||
| 12 | - Support for multiple apps/sites on one VPS | ||
| 13 | - State stored locally (VPS is stateless and easily recreatable) | ||
| 14 | - Zero dependencies on VPS (just installs Caddy) | ||
| 15 | |||
| 16 | ## Installation | ||
| 17 | |||
| 18 | ```bash | ||
| 19 | # Build the CLI | ||
| 20 | go build -o ~/bin/deploy ./cmd/deploy | ||
| 21 | |||
| 22 | # Or install to GOPATH | ||
| 23 | go install ./cmd/deploy | ||
| 24 | ``` | ||
| 25 | |||
| 26 | ## Quick Start | ||
| 27 | |||
| 28 | ### 1. Initialize Your VPS (One-time) | ||
| 29 | |||
| 30 | ```bash | ||
| 31 | # Initialize a fresh VPS | ||
| 32 | deploy init --host user@your-vps-ip | ||
| 33 | ``` | ||
| 34 | |||
| 35 | This will: | ||
| 36 | - Install Caddy | ||
| 37 | - Configure Caddy for automatic HTTPS | ||
| 38 | - Create necessary directories | ||
| 39 | - Set up the VPS for deployments | ||
| 40 | |||
| 41 | ### 2. Deploy a Go App | ||
| 42 | |||
| 43 | ```bash | ||
| 44 | # Build your app for Linux | ||
| 45 | GOOS=linux GOARCH=amd64 go build -o myapp | ||
| 46 | |||
| 47 | # Deploy it | ||
| 48 | deploy deploy --host user@vps-ip --binary ./myapp --domain api.example.com | ||
| 49 | |||
| 50 | # With environment variables | ||
| 51 | deploy deploy --host user@vps-ip --binary ./myapp --domain api.example.com \ | ||
| 52 | --env DB_HOST=localhost \ | ||
| 53 | --env API_KEY=secret | ||
| 54 | |||
| 55 | # Or from an env file | ||
| 56 | deploy deploy --host user@vps-ip --binary ./myapp --domain api.example.com \ | ||
| 57 | --env-file .env.production | ||
| 58 | ``` | ||
| 59 | |||
| 60 | ### 3. Deploy a Static Site | ||
| 61 | |||
| 62 | ```bash | ||
| 63 | # Build your site | ||
| 64 | npm run build | ||
| 65 | |||
| 66 | # Deploy it | ||
| 67 | deploy deploy --host user@vps-ip --static --dir ./dist --domain example.com | ||
| 68 | ``` | ||
| 69 | |||
| 70 | ## App Requirements | ||
| 71 | |||
| 72 | Your Go app must: | ||
| 73 | 1. Listen on HTTP (not HTTPS - Caddy handles that) | ||
| 74 | 2. Accept port via `--port` flag or `PORT` environment variable | ||
| 75 | 3. Bind to `localhost` or `127.0.0.1` only | ||
| 76 | |||
| 77 | Example: | ||
| 78 | |||
| 79 | ```go | ||
| 80 | package main | ||
| 81 | |||
| 82 | import ( | ||
| 83 | "flag" | ||
| 84 | "fmt" | ||
| 85 | "net/http" | ||
| 86 | "os" | ||
| 87 | ) | ||
| 88 | |||
| 89 | func main() { | ||
| 90 | port := flag.String("port", os.Getenv("PORT"), "port to listen on") | ||
| 91 | flag.Parse() | ||
| 92 | |||
| 93 | if *port == "" { | ||
| 94 | *port = "8080" // fallback for local dev | ||
| 95 | } | ||
| 96 | |||
| 97 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { | ||
| 98 | w.Write([]byte("Hello World")) | ||
| 99 | }) | ||
| 100 | |||
| 101 | addr := "127.0.0.1:" + *port | ||
| 102 | fmt.Printf("Listening on %s\n", addr) | ||
| 103 | http.ListenAndServe(addr, nil) | ||
| 104 | } | ||
| 105 | ``` | ||
| 106 | |||
| 107 | ## Commands | ||
| 108 | |||
| 109 | ### Initialize VPS | ||
| 110 | ```bash | ||
| 111 | deploy init --host user@vps-ip | ||
| 112 | ``` | ||
| 113 | |||
| 114 | ### Deploy App/Site | ||
| 115 | ```bash | ||
| 116 | # Go app | ||
| 117 | deploy deploy --host user@vps-ip --binary ./myapp --domain api.example.com | ||
| 118 | |||
| 119 | # Static site | ||
| 120 | deploy deploy --host user@vps-ip --static --dir ./dist --domain example.com | ||
| 121 | |||
| 122 | # Custom name (defaults to binary/directory name) | ||
| 123 | deploy deploy --host user@vps-ip --name myapi --binary ./myapp --domain api.example.com | ||
| 124 | ``` | ||
| 125 | |||
| 126 | ### List Deployments | ||
| 127 | ```bash | ||
| 128 | deploy list --host user@vps-ip | ||
| 129 | ``` | ||
| 130 | |||
| 131 | ### Manage Deployments | ||
| 132 | ```bash | ||
| 133 | # View logs | ||
| 134 | deploy logs myapp --host user@vps-ip | ||
| 135 | |||
| 136 | # View status | ||
| 137 | deploy status myapp --host user@vps-ip | ||
| 138 | |||
| 139 | # Restart app | ||
| 140 | deploy restart myapp --host user@vps-ip | ||
| 141 | |||
| 142 | # Remove deployment | ||
| 143 | deploy remove myapp --host user@vps-ip | ||
| 144 | ``` | ||
| 145 | |||
| 146 | ### Environment Variables | ||
| 147 | ```bash | ||
| 148 | # View current env vars (secrets are masked) | ||
| 149 | deploy env myapi --host user@vps-ip | ||
| 150 | |||
| 151 | # Set env vars | ||
| 152 | deploy env myapi --host user@vps-ip --set DB_HOST=localhost --set API_KEY=secret | ||
| 153 | |||
| 154 | # Load from file | ||
| 155 | deploy env myapi --host user@vps-ip --file .env.production | ||
| 156 | |||
| 157 | # Unset env var | ||
| 158 | deploy env myapi --host user@vps-ip --unset API_KEY | ||
| 159 | ``` | ||
| 160 | |||
| 161 | ## Configuration | ||
| 162 | |||
| 163 | Create `~/.config/deploy/config` to avoid typing `--host` every time: | ||
| 164 | |||
| 165 | ``` | ||
| 166 | host: user@your-vps-ip | ||
| 167 | ``` | ||
| 168 | |||
| 169 | Then you can omit the `--host` flag: | ||
| 170 | |||
| 171 | ```bash | ||
| 172 | deploy list | ||
| 173 | deploy deploy --binary ./myapp --domain api.example.com | ||
| 174 | ``` | ||
| 175 | |||
| 176 | ## How It Works | ||
| 177 | |||
| 178 | 1. **State on Laptop**: All deployment state lives at `~/.config/deploy/state.json` on your laptop | ||
| 179 | 2. **SSH Orchestration**: The CLI uses SSH to run commands on your VPS | ||
| 180 | 3. **File Transfer**: Binaries transferred via SCP, static sites via rsync | ||
| 181 | 4. **Caddy for HTTPS**: Caddy automatically handles HTTPS certificates | ||
| 182 | 5. **Systemd for Processes**: Apps run as systemd services with auto-restart | ||
| 183 | 6. **Dumb VPS**: The VPS is stateless - you can recreate it by redeploying from local state | ||
| 184 | |||
| 185 | ## File Structure | ||
| 186 | |||
| 187 | ### On Laptop | ||
| 188 | ``` | ||
| 189 | ~/.config/deploy/state.json # All deployment state | ||
| 190 | ~/.config/deploy/config # Optional: default host | ||
| 191 | ``` | ||
| 192 | |||
| 193 | ### On VPS | ||
| 194 | ``` | ||
| 195 | /usr/local/bin/myapp # Go binary | ||
| 196 | /var/lib/myapp/ # Working directory | ||
| 197 | /etc/systemd/system/myapp.service # Systemd unit | ||
| 198 | /etc/caddy/sites-enabled/myapp.caddy # Caddy config | ||
| 199 | /etc/deploy/env/myapp.env # Environment variables | ||
| 200 | |||
| 201 | /var/www/mysite/ # Static site files | ||
| 202 | /etc/caddy/sites-enabled/mysite.caddy # Caddy config | ||
| 203 | ``` | ||
| 204 | |||
| 205 | ## Security | ||
| 206 | |||
| 207 | - Each Go app runs as dedicated system user | ||
| 208 | - Systemd security hardening enabled (NoNewPrivileges, PrivateTmp) | ||
| 209 | - Static sites served as www-data | ||
| 210 | - Caddy automatically manages TLS certificates | ||
| 211 | - Environment files stored with 0600 permissions | ||
| 212 | - Secrets masked when displaying environment variables | ||
| 213 | |||
| 214 | ## Supported OS | ||
| 215 | |||
| 216 | - Ubuntu 20.04+ | ||
| 217 | - Debian 11+ | ||
| 218 | |||
| 219 | ## License | ||
| 220 | |||
| 221 | MIT | ||
| @@ -0,0 +1,7 @@ | |||
| 1 | module github.com/bdw/deploy | ||
| 2 | |||
| 3 | go 1.21 | ||
| 4 | |||
| 5 | require golang.org/x/crypto v0.31.0 | ||
| 6 | |||
| 7 | require golang.org/x/sys v0.28.0 // indirect | ||
| @@ -0,0 +1,6 @@ | |||
| 1 | golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= | ||
| 2 | golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= | ||
| 3 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= | ||
| 4 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= | ||
| 5 | golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= | ||
| 6 | golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= | ||
diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..8651aa8 --- /dev/null +++ b/internal/config/config.go | |||
| @@ -0,0 +1,65 @@ | |||
| 1 | package config | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bufio" | ||
| 5 | "os" | ||
| 6 | "path/filepath" | ||
| 7 | "strings" | ||
| 8 | ) | ||
| 9 | |||
| 10 | // Config represents the user's configuration | ||
| 11 | type Config struct { | ||
| 12 | Host string | ||
| 13 | } | ||
| 14 | |||
| 15 | // Load reads config from ~/.config/deploy/config | ||
| 16 | func Load() (*Config, error) { | ||
| 17 | path := configPath() | ||
| 18 | |||
| 19 | // If file doesn't exist, return empty config | ||
| 20 | if _, err := os.Stat(path); os.IsNotExist(err) { | ||
| 21 | return &Config{}, nil | ||
| 22 | } | ||
| 23 | |||
| 24 | file, err := os.Open(path) | ||
| 25 | if err != nil { | ||
| 26 | return nil, err | ||
| 27 | } | ||
| 28 | defer file.Close() | ||
| 29 | |||
| 30 | cfg := &Config{} | ||
| 31 | scanner := bufio.NewScanner(file) | ||
| 32 | for scanner.Scan() { | ||
| 33 | line := strings.TrimSpace(scanner.Text()) | ||
| 34 | if line == "" || strings.HasPrefix(line, "#") { | ||
| 35 | continue | ||
| 36 | } | ||
| 37 | |||
| 38 | parts := strings.SplitN(line, ":", 2) | ||
| 39 | if len(parts) != 2 { | ||
| 40 | continue | ||
| 41 | } | ||
| 42 | |||
| 43 | key := strings.TrimSpace(parts[0]) | ||
| 44 | value := strings.TrimSpace(parts[1]) | ||
| 45 | |||
| 46 | switch key { | ||
| 47 | case "host": | ||
| 48 | cfg.Host = value | ||
| 49 | } | ||
| 50 | } | ||
| 51 | |||
| 52 | if err := scanner.Err(); err != nil { | ||
| 53 | return nil, err | ||
| 54 | } | ||
| 55 | |||
| 56 | return cfg, nil | ||
| 57 | } | ||
| 58 | |||
| 59 | func configPath() string { | ||
| 60 | home, err := os.UserHomeDir() | ||
| 61 | if err != nil { | ||
| 62 | return ".deploy-config" | ||
| 63 | } | ||
| 64 | return filepath.Join(home, ".config", "deploy", "config") | ||
| 65 | } | ||
diff --git a/internal/ssh/client.go b/internal/ssh/client.go new file mode 100644 index 0000000..1cd336c --- /dev/null +++ b/internal/ssh/client.go | |||
| @@ -0,0 +1,357 @@ | |||
| 1 | package ssh | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bufio" | ||
| 5 | "bytes" | ||
| 6 | "fmt" | ||
| 7 | "net" | ||
| 8 | "os" | ||
| 9 | "os/exec" | ||
| 10 | "path/filepath" | ||
| 11 | "strings" | ||
| 12 | |||
| 13 | "golang.org/x/crypto/ssh" | ||
| 14 | "golang.org/x/crypto/ssh/agent" | ||
| 15 | ) | ||
| 16 | |||
| 17 | // Client represents an SSH connection to a remote host | ||
| 18 | type Client struct { | ||
| 19 | host string | ||
| 20 | client *ssh.Client | ||
| 21 | } | ||
| 22 | |||
| 23 | // sshConfig holds SSH configuration for a host | ||
| 24 | type sshConfig struct { | ||
| 25 | Host string | ||
| 26 | HostName string | ||
| 27 | User string | ||
| 28 | Port string | ||
| 29 | IdentityFile string | ||
| 30 | } | ||
| 31 | |||
| 32 | // Connect establishes an SSH connection to the remote host | ||
| 33 | // Supports both SSH config aliases (e.g., "myserver") and user@host format | ||
| 34 | func Connect(host string) (*Client, error) { | ||
| 35 | var user, addr string | ||
| 36 | var identityFile string | ||
| 37 | |||
| 38 | // Try to read SSH config first | ||
| 39 | cfg, err := readSSHConfig(host) | ||
| 40 | if err == nil && cfg.HostName != "" { | ||
| 41 | // Use SSH config | ||
| 42 | user = cfg.User | ||
| 43 | addr = cfg.HostName | ||
| 44 | if cfg.Port != "" { | ||
| 45 | addr = addr + ":" + cfg.Port | ||
| 46 | } else { | ||
| 47 | addr = addr + ":22" | ||
| 48 | } | ||
| 49 | identityFile = cfg.IdentityFile | ||
| 50 | } else { | ||
| 51 | // Fall back to parsing user@host format | ||
| 52 | parts := strings.SplitN(host, "@", 2) | ||
| 53 | if len(parts) != 2 { | ||
| 54 | return nil, fmt.Errorf("host '%s' not found in SSH config and not in user@host format", host) | ||
| 55 | } | ||
| 56 | user = parts[0] | ||
| 57 | addr = parts[1] | ||
| 58 | |||
| 59 | // Add default port if not specified | ||
| 60 | if !strings.Contains(addr, ":") { | ||
| 61 | addr = addr + ":22" | ||
| 62 | } | ||
| 63 | } | ||
| 64 | |||
| 65 | // Build authentication methods | ||
| 66 | var authMethods []ssh.AuthMethod | ||
| 67 | |||
| 68 | // Try identity file from SSH config first | ||
| 69 | if identityFile != "" { | ||
| 70 | if authMethod, err := publicKeyFromFile(identityFile); err == nil { | ||
| 71 | authMethods = append(authMethods, authMethod) | ||
| 72 | } | ||
| 73 | } | ||
| 74 | |||
| 75 | // Try SSH agent | ||
| 76 | if authMethod, err := sshAgent(); err == nil { | ||
| 77 | authMethods = append(authMethods, authMethod) | ||
| 78 | } | ||
| 79 | |||
| 80 | // Try default key files | ||
| 81 | if authMethod, err := publicKeyFile(); err == nil { | ||
| 82 | authMethods = append(authMethods, authMethod) | ||
| 83 | } | ||
| 84 | |||
| 85 | if len(authMethods) == 0 { | ||
| 86 | return nil, fmt.Errorf("no SSH authentication method available") | ||
| 87 | } | ||
| 88 | |||
| 89 | config := &ssh.ClientConfig{ | ||
| 90 | User: user, | ||
| 91 | Auth: authMethods, | ||
| 92 | HostKeyCallback: ssh.InsecureIgnoreHostKey(), // TODO: Consider using known_hosts | ||
| 93 | } | ||
| 94 | |||
| 95 | client, err := ssh.Dial("tcp", addr, config) | ||
| 96 | if err != nil { | ||
| 97 | return nil, fmt.Errorf("failed to connect to %s: %w", host, err) | ||
| 98 | } | ||
| 99 | |||
| 100 | return &Client{ | ||
| 101 | host: host, | ||
| 102 | client: client, | ||
| 103 | }, nil | ||
| 104 | } | ||
| 105 | |||
| 106 | // Close closes the SSH connection | ||
| 107 | func (c *Client) Close() error { | ||
| 108 | return c.client.Close() | ||
| 109 | } | ||
| 110 | |||
| 111 | // Run executes a command on the remote host and returns the output | ||
| 112 | func (c *Client) Run(cmd string) (string, error) { | ||
| 113 | session, err := c.client.NewSession() | ||
| 114 | if err != nil { | ||
| 115 | return "", err | ||
| 116 | } | ||
| 117 | defer session.Close() | ||
| 118 | |||
| 119 | var stdout, stderr bytes.Buffer | ||
| 120 | session.Stdout = &stdout | ||
| 121 | session.Stderr = &stderr | ||
| 122 | |||
| 123 | if err := session.Run(cmd); err != nil { | ||
| 124 | return "", fmt.Errorf("command failed: %w\nstderr: %s", err, stderr.String()) | ||
| 125 | } | ||
| 126 | |||
| 127 | return stdout.String(), nil | ||
| 128 | } | ||
| 129 | |||
| 130 | // RunSudo executes a command with sudo on the remote host | ||
| 131 | func (c *Client) RunSudo(cmd string) (string, error) { | ||
| 132 | return c.Run("sudo " + cmd) | ||
| 133 | } | ||
| 134 | |||
| 135 | // Upload copies a local file to the remote host using scp | ||
| 136 | func (c *Client) Upload(localPath, remotePath string) error { | ||
| 137 | // Use external scp command for simplicity | ||
| 138 | // Format: scp -o StrictHostKeyChecking=no localPath user@host:remotePath | ||
| 139 | cmd := exec.Command("scp", "-o", "StrictHostKeyChecking=no", localPath, c.host+":"+remotePath) | ||
| 140 | |||
| 141 | var stderr bytes.Buffer | ||
| 142 | cmd.Stderr = &stderr | ||
| 143 | |||
| 144 | if err := cmd.Run(); err != nil { | ||
| 145 | return fmt.Errorf("scp failed: %w\nstderr: %s", err, stderr.String()) | ||
| 146 | } | ||
| 147 | |||
| 148 | return nil | ||
| 149 | } | ||
| 150 | |||
| 151 | // UploadDir copies a local directory to the remote host using rsync | ||
| 152 | func (c *Client) UploadDir(localDir, remoteDir string) error { | ||
| 153 | // Use rsync for directory uploads | ||
| 154 | // Format: rsync -avz --delete localDir/ user@host:remoteDir/ | ||
| 155 | localDir = strings.TrimSuffix(localDir, "/") + "/" | ||
| 156 | remoteDir = strings.TrimSuffix(remoteDir, "/") + "/" | ||
| 157 | |||
| 158 | cmd := exec.Command("rsync", "-avz", "--delete", | ||
| 159 | "-e", "ssh -o StrictHostKeyChecking=no", | ||
| 160 | localDir, c.host+":"+remoteDir) | ||
| 161 | |||
| 162 | var stderr bytes.Buffer | ||
| 163 | cmd.Stderr = &stderr | ||
| 164 | |||
| 165 | if err := cmd.Run(); err != nil { | ||
| 166 | return fmt.Errorf("rsync failed: %w\nstderr: %s", err, stderr.String()) | ||
| 167 | } | ||
| 168 | |||
| 169 | return nil | ||
| 170 | } | ||
| 171 | |||
| 172 | // WriteFile creates a file with the given content on the remote host | ||
| 173 | func (c *Client) WriteFile(remotePath, content string) error { | ||
| 174 | session, err := c.client.NewSession() | ||
| 175 | if err != nil { | ||
| 176 | return err | ||
| 177 | } | ||
| 178 | defer session.Close() | ||
| 179 | |||
| 180 | // Use cat to write content to file | ||
| 181 | cmd := fmt.Sprintf("cat > %s", remotePath) | ||
| 182 | session.Stdin = strings.NewReader(content) | ||
| 183 | |||
| 184 | var stderr bytes.Buffer | ||
| 185 | session.Stderr = &stderr | ||
| 186 | |||
| 187 | if err := session.Run(cmd); err != nil { | ||
| 188 | return fmt.Errorf("write file failed: %w\nstderr: %s", err, stderr.String()) | ||
| 189 | } | ||
| 190 | |||
| 191 | return nil | ||
| 192 | } | ||
| 193 | |||
| 194 | // WriteSudoFile creates a file with the given content using sudo | ||
| 195 | func (c *Client) WriteSudoFile(remotePath, content string) error { | ||
| 196 | session, err := c.client.NewSession() | ||
| 197 | if err != nil { | ||
| 198 | return err | ||
| 199 | } | ||
| 200 | defer session.Close() | ||
| 201 | |||
| 202 | // Use sudo tee to write content to file | ||
| 203 | cmd := fmt.Sprintf("sudo tee %s > /dev/null", remotePath) | ||
| 204 | session.Stdin = strings.NewReader(content) | ||
| 205 | |||
| 206 | var stderr bytes.Buffer | ||
| 207 | session.Stderr = &stderr | ||
| 208 | |||
| 209 | if err := session.Run(cmd); err != nil { | ||
| 210 | return fmt.Errorf("write file with sudo failed: %w\nstderr: %s", err, stderr.String()) | ||
| 211 | } | ||
| 212 | |||
| 213 | return nil | ||
| 214 | } | ||
| 215 | |||
| 216 | // readSSHConfig reads and parses the SSH config file for a given host | ||
| 217 | func readSSHConfig(host string) (*sshConfig, error) { | ||
| 218 | home, err := os.UserHomeDir() | ||
| 219 | if err != nil { | ||
| 220 | return nil, err | ||
| 221 | } | ||
| 222 | |||
| 223 | configPath := filepath.Join(home, ".ssh", "config") | ||
| 224 | file, err := os.Open(configPath) | ||
| 225 | if err != nil { | ||
| 226 | return nil, err | ||
| 227 | } | ||
| 228 | defer file.Close() | ||
| 229 | |||
| 230 | cfg := &sshConfig{} | ||
| 231 | var currentHost string | ||
| 232 | var matchedHost bool | ||
| 233 | |||
| 234 | scanner := bufio.NewScanner(file) | ||
| 235 | for scanner.Scan() { | ||
| 236 | line := strings.TrimSpace(scanner.Text()) | ||
| 237 | |||
| 238 | // Skip comments and empty lines | ||
| 239 | if line == "" || strings.HasPrefix(line, "#") { | ||
| 240 | continue | ||
| 241 | } | ||
| 242 | |||
| 243 | fields := strings.Fields(line) | ||
| 244 | if len(fields) < 2 { | ||
| 245 | continue | ||
| 246 | } | ||
| 247 | |||
| 248 | key := strings.ToLower(fields[0]) | ||
| 249 | value := fields[1] | ||
| 250 | |||
| 251 | // Expand ~ in paths | ||
| 252 | if strings.HasPrefix(value, "~/") { | ||
| 253 | value = filepath.Join(home, value[2:]) | ||
| 254 | } | ||
| 255 | |||
| 256 | switch key { | ||
| 257 | case "host": | ||
| 258 | currentHost = value | ||
| 259 | if currentHost == host { | ||
| 260 | matchedHost = true | ||
| 261 | cfg.Host = host | ||
| 262 | } else { | ||
| 263 | matchedHost = false | ||
| 264 | } | ||
| 265 | case "hostname": | ||
| 266 | if matchedHost { | ||
| 267 | cfg.HostName = value | ||
| 268 | } | ||
| 269 | case "user": | ||
| 270 | if matchedHost { | ||
| 271 | cfg.User = value | ||
| 272 | } | ||
| 273 | case "port": | ||
| 274 | if matchedHost { | ||
| 275 | cfg.Port = value | ||
| 276 | } | ||
| 277 | case "identityfile": | ||
| 278 | if matchedHost { | ||
| 279 | cfg.IdentityFile = value | ||
| 280 | } | ||
| 281 | } | ||
| 282 | } | ||
| 283 | |||
| 284 | if err := scanner.Err(); err != nil { | ||
| 285 | return nil, err | ||
| 286 | } | ||
| 287 | |||
| 288 | if cfg.Host == "" { | ||
| 289 | return nil, fmt.Errorf("host %s not found in SSH config", host) | ||
| 290 | } | ||
| 291 | |||
| 292 | return cfg, nil | ||
| 293 | } | ||
| 294 | |||
| 295 | // sshAgent returns an auth method using SSH agent | ||
| 296 | func sshAgent() (ssh.AuthMethod, error) { | ||
| 297 | socket := os.Getenv("SSH_AUTH_SOCK") | ||
| 298 | if socket == "" { | ||
| 299 | return nil, fmt.Errorf("SSH_AUTH_SOCK not set") | ||
| 300 | } | ||
| 301 | |||
| 302 | conn, err := net.Dial("unix", socket) | ||
| 303 | if err != nil { | ||
| 304 | return nil, fmt.Errorf("failed to connect to SSH agent: %w", err) | ||
| 305 | } | ||
| 306 | |||
| 307 | agentClient := agent.NewClient(conn) | ||
| 308 | return ssh.PublicKeysCallback(agentClient.Signers), nil | ||
| 309 | } | ||
| 310 | |||
| 311 | // publicKeyFromFile returns an auth method from a specific private key file | ||
| 312 | func publicKeyFromFile(keyPath string) (ssh.AuthMethod, error) { | ||
| 313 | key, err := os.ReadFile(keyPath) | ||
| 314 | if err != nil { | ||
| 315 | return nil, err | ||
| 316 | } | ||
| 317 | |||
| 318 | signer, err := ssh.ParsePrivateKey(key) | ||
| 319 | if err != nil { | ||
| 320 | return nil, err | ||
| 321 | } | ||
| 322 | |||
| 323 | return ssh.PublicKeys(signer), nil | ||
| 324 | } | ||
| 325 | |||
| 326 | // publicKeyFile returns an auth method using a private key file | ||
| 327 | func publicKeyFile() (ssh.AuthMethod, error) { | ||
| 328 | home, err := os.UserHomeDir() | ||
| 329 | if err != nil { | ||
| 330 | return nil, err | ||
| 331 | } | ||
| 332 | |||
| 333 | // Try common key locations | ||
| 334 | keyPaths := []string{ | ||
| 335 | filepath.Join(home, ".ssh", "id_rsa"), | ||
| 336 | filepath.Join(home, ".ssh", "id_ed25519"), | ||
| 337 | filepath.Join(home, ".ssh", "id_ecdsa"), | ||
| 338 | } | ||
| 339 | |||
| 340 | for _, keyPath := range keyPaths { | ||
| 341 | if _, err := os.Stat(keyPath); err == nil { | ||
| 342 | key, err := os.ReadFile(keyPath) | ||
| 343 | if err != nil { | ||
| 344 | continue | ||
| 345 | } | ||
| 346 | |||
| 347 | signer, err := ssh.ParsePrivateKey(key) | ||
| 348 | if err != nil { | ||
| 349 | continue | ||
| 350 | } | ||
| 351 | |||
| 352 | return ssh.PublicKeys(signer), nil | ||
| 353 | } | ||
| 354 | } | ||
| 355 | |||
| 356 | return nil, fmt.Errorf("no SSH private key found") | ||
| 357 | } | ||
diff --git a/internal/state/state.go b/internal/state/state.go new file mode 100644 index 0000000..c7c7dd4 --- /dev/null +++ b/internal/state/state.go | |||
| @@ -0,0 +1,146 @@ | |||
| 1 | package state | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "encoding/json" | ||
| 5 | "fmt" | ||
| 6 | "os" | ||
| 7 | "path/filepath" | ||
| 8 | ) | ||
| 9 | |||
| 10 | // State represents the entire local deployment state | ||
| 11 | type State struct { | ||
| 12 | Hosts map[string]*Host `json:"hosts"` | ||
| 13 | } | ||
| 14 | |||
| 15 | // Host represents deployment state for a single VPS | ||
| 16 | type Host struct { | ||
| 17 | NextPort int `json:"next_port"` | ||
| 18 | Apps map[string]*App `json:"apps"` | ||
| 19 | } | ||
| 20 | |||
| 21 | // App represents a deployed application or static site | ||
| 22 | type App struct { | ||
| 23 | Type string `json:"type"` // "app" or "static" | ||
| 24 | Domain string `json:"domain"` | ||
| 25 | Port int `json:"port,omitempty"` // only for type="app" | ||
| 26 | Env map[string]string `json:"env,omitempty"` // only for type="app" | ||
| 27 | } | ||
| 28 | |||
| 29 | const ( | ||
| 30 | startPort = 8001 | ||
| 31 | ) | ||
| 32 | |||
| 33 | // Load reads state from ~/.config/deploy/state.json | ||
| 34 | func Load() (*State, error) { | ||
| 35 | path := statePath() | ||
| 36 | |||
| 37 | // If file doesn't exist, return empty state | ||
| 38 | if _, err := os.Stat(path); os.IsNotExist(err) { | ||
| 39 | return &State{ | ||
| 40 | Hosts: make(map[string]*Host), | ||
| 41 | }, nil | ||
| 42 | } | ||
| 43 | |||
| 44 | data, err := os.ReadFile(path) | ||
| 45 | if err != nil { | ||
| 46 | return nil, fmt.Errorf("failed to read state file: %w", err) | ||
| 47 | } | ||
| 48 | |||
| 49 | var state State | ||
| 50 | if err := json.Unmarshal(data, &state); err != nil { | ||
| 51 | return nil, fmt.Errorf("failed to parse state file: %w", err) | ||
| 52 | } | ||
| 53 | |||
| 54 | // Initialize maps if nil | ||
| 55 | if state.Hosts == nil { | ||
| 56 | state.Hosts = make(map[string]*Host) | ||
| 57 | } | ||
| 58 | |||
| 59 | return &state, nil | ||
| 60 | } | ||
| 61 | |||
| 62 | // Save writes state to ~/.config/deploy/state.json | ||
| 63 | func (s *State) Save() error { | ||
| 64 | path := statePath() | ||
| 65 | |||
| 66 | // Ensure directory exists | ||
| 67 | dir := filepath.Dir(path) | ||
| 68 | if err := os.MkdirAll(dir, 0755); err != nil { | ||
| 69 | return fmt.Errorf("failed to create config directory: %w", err) | ||
| 70 | } | ||
| 71 | |||
| 72 | data, err := json.MarshalIndent(s, "", " ") | ||
| 73 | if err != nil { | ||
| 74 | return fmt.Errorf("failed to marshal state: %w", err) | ||
| 75 | } | ||
| 76 | |||
| 77 | if err := os.WriteFile(path, data, 0600); err != nil { | ||
| 78 | return fmt.Errorf("failed to write state file: %w", err) | ||
| 79 | } | ||
| 80 | |||
| 81 | return nil | ||
| 82 | } | ||
| 83 | |||
| 84 | // GetHost returns the host state, creating it if it doesn't exist | ||
| 85 | func (s *State) GetHost(host string) *Host { | ||
| 86 | if s.Hosts[host] == nil { | ||
| 87 | s.Hosts[host] = &Host{ | ||
| 88 | NextPort: startPort, | ||
| 89 | Apps: make(map[string]*App), | ||
| 90 | } | ||
| 91 | } | ||
| 92 | if s.Hosts[host].Apps == nil { | ||
| 93 | s.Hosts[host].Apps = make(map[string]*App) | ||
| 94 | } | ||
| 95 | return s.Hosts[host] | ||
| 96 | } | ||
| 97 | |||
| 98 | // AllocatePort returns the next available port for a host | ||
| 99 | func (s *State) AllocatePort(host string) int { | ||
| 100 | h := s.GetHost(host) | ||
| 101 | port := h.NextPort | ||
| 102 | h.NextPort++ | ||
| 103 | return port | ||
| 104 | } | ||
| 105 | |||
| 106 | // AddApp adds or updates an app in the state | ||
| 107 | func (s *State) AddApp(host, name string, app *App) { | ||
| 108 | h := s.GetHost(host) | ||
| 109 | h.Apps[name] = app | ||
| 110 | } | ||
| 111 | |||
| 112 | // RemoveApp removes an app from the state | ||
| 113 | func (s *State) RemoveApp(host, name string) error { | ||
| 114 | h := s.GetHost(host) | ||
| 115 | if _, exists := h.Apps[name]; !exists { | ||
| 116 | return fmt.Errorf("app %s not found", name) | ||
| 117 | } | ||
| 118 | delete(h.Apps, name) | ||
| 119 | return nil | ||
| 120 | } | ||
| 121 | |||
| 122 | // GetApp returns an app from the state | ||
| 123 | func (s *State) GetApp(host, name string) (*App, error) { | ||
| 124 | h := s.GetHost(host) | ||
| 125 | app, exists := h.Apps[name] | ||
| 126 | if !exists { | ||
| 127 | return nil, fmt.Errorf("app %s not found", name) | ||
| 128 | } | ||
| 129 | return app, nil | ||
| 130 | } | ||
| 131 | |||
| 132 | // ListApps returns all apps for a host | ||
| 133 | func (s *State) ListApps(host string) map[string]*App { | ||
| 134 | h := s.GetHost(host) | ||
| 135 | return h.Apps | ||
| 136 | } | ||
| 137 | |||
| 138 | // statePath returns the path to the state file | ||
| 139 | func statePath() string { | ||
| 140 | home, err := os.UserHomeDir() | ||
| 141 | if err != nil { | ||
| 142 | // Fallback to current directory (should rarely happen) | ||
| 143 | return ".deploy-state.json" | ||
| 144 | } | ||
| 145 | return filepath.Join(home, ".config", "deploy", "state.json") | ||
| 146 | } | ||
diff --git a/internal/templates/templates.go b/internal/templates/templates.go new file mode 100644 index 0000000..d90163d --- /dev/null +++ b/internal/templates/templates.go | |||
| @@ -0,0 +1,82 @@ | |||
| 1 | package templates | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "text/template" | ||
| 6 | ) | ||
| 7 | |||
| 8 | var serviceTemplate = `[Unit] | ||
| 9 | Description={{.Name}} | ||
| 10 | After=network.target | ||
| 11 | |||
| 12 | [Service] | ||
| 13 | Type=simple | ||
| 14 | User={{.User}} | ||
| 15 | WorkingDirectory={{.WorkDir}} | ||
| 16 | EnvironmentFile={{.EnvFile}} | ||
| 17 | ExecStart={{.BinaryPath}} --port={{.Port}} | ||
| 18 | Restart=always | ||
| 19 | RestartSec=5s | ||
| 20 | NoNewPrivileges=true | ||
| 21 | PrivateTmp=true | ||
| 22 | |||
| 23 | [Install] | ||
| 24 | WantedBy=multi-user.target | ||
| 25 | ` | ||
| 26 | |||
| 27 | var appCaddyTemplate = `{{.Domain}} { | ||
| 28 | reverse_proxy 127.0.0.1:{{.Port}} | ||
| 29 | } | ||
| 30 | ` | ||
| 31 | |||
| 32 | var staticCaddyTemplate = `{{.Domain}} { | ||
| 33 | root * {{.RootDir}} | ||
| 34 | file_server | ||
| 35 | encode gzip | ||
| 36 | } | ||
| 37 | ` | ||
| 38 | |||
| 39 | // SystemdService generates a systemd service unit file | ||
| 40 | func SystemdService(data map[string]string) (string, error) { | ||
| 41 | tmpl, err := template.New("service").Parse(serviceTemplate) | ||
| 42 | if err != nil { | ||
| 43 | return "", err | ||
| 44 | } | ||
| 45 | |||
| 46 | var buf bytes.Buffer | ||
| 47 | if err := tmpl.Execute(&buf, data); err != nil { | ||
| 48 | return "", err | ||
| 49 | } | ||
| 50 | |||
| 51 | return buf.String(), nil | ||
| 52 | } | ||
| 53 | |||
| 54 | // AppCaddy generates a Caddy config for a Go app | ||
| 55 | func AppCaddy(data map[string]string) (string, error) { | ||
| 56 | tmpl, err := template.New("caddy").Parse(appCaddyTemplate) | ||
| 57 | if err != nil { | ||
| 58 | return "", err | ||
| 59 | } | ||
| 60 | |||
| 61 | var buf bytes.Buffer | ||
| 62 | if err := tmpl.Execute(&buf, data); err != nil { | ||
| 63 | return "", err | ||
| 64 | } | ||
| 65 | |||
| 66 | return buf.String(), nil | ||
| 67 | } | ||
| 68 | |||
| 69 | // StaticCaddy generates a Caddy config for a static site | ||
| 70 | func StaticCaddy(data map[string]string) (string, error) { | ||
| 71 | tmpl, err := template.New("caddy").Parse(staticCaddyTemplate) | ||
| 72 | if err != nil { | ||
| 73 | return "", err | ||
| 74 | } | ||
| 75 | |||
| 76 | var buf bytes.Buffer | ||
| 77 | if err := tmpl.Execute(&buf, data); err != nil { | ||
| 78 | return "", err | ||
| 79 | } | ||
| 80 | |||
| 81 | return buf.String(), nil | ||
| 82 | } | ||
diff --git a/templates/app.caddy.tmpl b/templates/app.caddy.tmpl new file mode 100644 index 0000000..505d1d9 --- /dev/null +++ b/templates/app.caddy.tmpl | |||
| @@ -0,0 +1,3 @@ | |||
| 1 | {{.Domain}} { | ||
| 2 | reverse_proxy 127.0.0.1:{{.Port}} | ||
| 3 | } | ||
diff --git a/templates/service.tmpl b/templates/service.tmpl new file mode 100644 index 0000000..87389f0 --- /dev/null +++ b/templates/service.tmpl | |||
| @@ -0,0 +1,17 @@ | |||
| 1 | [Unit] | ||
| 2 | Description={{.Name}} | ||
| 3 | After=network.target | ||
| 4 | |||
| 5 | [Service] | ||
| 6 | Type=simple | ||
| 7 | User={{.User}} | ||
| 8 | WorkingDirectory={{.WorkDir}} | ||
| 9 | EnvironmentFile={{.EnvFile}} | ||
| 10 | ExecStart={{.BinaryPath}} --port={{.Port}} | ||
| 11 | Restart=always | ||
| 12 | RestartSec=5s | ||
| 13 | NoNewPrivileges=true | ||
| 14 | PrivateTmp=true | ||
| 15 | |||
| 16 | [Install] | ||
| 17 | WantedBy=multi-user.target | ||
diff --git a/templates/static.caddy.tmpl b/templates/static.caddy.tmpl new file mode 100644 index 0000000..d04f6b0 --- /dev/null +++ b/templates/static.caddy.tmpl | |||
| @@ -0,0 +1,5 @@ | |||
| 1 | {{.Domain}} { | ||
| 2 | root * {{.RootDir}} | ||
| 3 | file_server | ||
| 4 | encode gzip | ||
| 5 | } | ||
diff --git a/test/example-website/about.html b/test/example-website/about.html new file mode 100644 index 0000000..93cba92 --- /dev/null +++ b/test/example-website/about.html | |||
| @@ -0,0 +1,42 @@ | |||
| 1 | <!DOCTYPE html> | ||
| 2 | <html lang="en"> | ||
| 3 | <head> | ||
| 4 | <meta charset="UTF-8"> | ||
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||
| 6 | <title>About - Deploy Test</title> | ||
| 7 | <link rel="stylesheet" href="style.css"> | ||
| 8 | </head> | ||
| 9 | <body> | ||
| 10 | <header> | ||
| 11 | <nav> | ||
| 12 | <h1>Deploy Test</h1> | ||
| 13 | <ul> | ||
| 14 | <li><a href="index.html">Home</a></li> | ||
| 15 | <li><a href="about.html">About</a></li> | ||
| 16 | </ul> | ||
| 17 | </nav> | ||
| 18 | </header> | ||
| 19 | |||
| 20 | <main> | ||
| 21 | <section class="about"> | ||
| 22 | <h2>About This Site</h2> | ||
| 23 | <p>This is a test website created to demonstrate the deploy tool's static site deployment capabilities.</p> | ||
| 24 | |||
| 25 | <h3>Features</h3> | ||
| 26 | <ul> | ||
| 27 | <li>Simple HTML/CSS structure</li> | ||
| 28 | <li>Multiple pages for testing navigation</li> | ||
| 29 | <li>Responsive design</li> | ||
| 30 | <li>Clean and minimal styling</li> | ||
| 31 | </ul> | ||
| 32 | |||
| 33 | <h3>Deployment Command</h3> | ||
| 34 | <pre><code>./deploy deploy --host peerfile --static --dir ./test/example-website --domain example.com</code></pre> | ||
| 35 | </section> | ||
| 36 | </main> | ||
| 37 | |||
| 38 | <footer> | ||
| 39 | <p>© 2025 Deploy Test. Built for testing purposes.</p> | ||
| 40 | </footer> | ||
| 41 | </body> | ||
| 42 | </html> | ||
diff --git a/test/example-website/index.html b/test/example-website/index.html new file mode 100644 index 0000000..735ae73 --- /dev/null +++ b/test/example-website/index.html | |||
| @@ -0,0 +1,46 @@ | |||
| 1 | <!DOCTYPE html> | ||
| 2 | <html lang="en"> | ||
| 3 | <head> | ||
| 4 | <meta charset="UTF-8"> | ||
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||
| 6 | <title>Example Website - Deploy Test</title> | ||
| 7 | <link rel="stylesheet" href="style.css"> | ||
| 8 | </head> | ||
| 9 | <body> | ||
| 10 | <header> | ||
| 11 | <nav> | ||
| 12 | <h1>Deploy Test</h1> | ||
| 13 | <ul> | ||
| 14 | <li><a href="index.html">Home</a></li> | ||
| 15 | <li><a href="about.html">About</a></li> | ||
| 16 | </ul> | ||
| 17 | </nav> | ||
| 18 | </header> | ||
| 19 | |||
| 20 | <main> | ||
| 21 | <section class="hero"> | ||
| 22 | <h2>Welcome to the Example Website</h2> | ||
| 23 | <p>This is a simple static website for testing the deploy tool.</p> | ||
| 24 | </section> | ||
| 25 | |||
| 26 | <section class="features"> | ||
| 27 | <div class="feature"> | ||
| 28 | <h3>Fast Deployment</h3> | ||
| 29 | <p>Deploy your static sites in seconds with a single command.</p> | ||
| 30 | </div> | ||
| 31 | <div class="feature"> | ||
| 32 | <h3>HTTPS Enabled</h3> | ||
| 33 | <p>Automatic SSL certificates with Caddy.</p> | ||
| 34 | </div> | ||
| 35 | <div class="feature"> | ||
| 36 | <h3>Simple Management</h3> | ||
| 37 | <p>Easy-to-use CLI for managing your deployments.</p> | ||
| 38 | </div> | ||
| 39 | </section> | ||
| 40 | </main> | ||
| 41 | |||
| 42 | <footer> | ||
| 43 | <p>© 2025 Deploy Test. Built for testing purposes.</p> | ||
| 44 | </footer> | ||
| 45 | </body> | ||
| 46 | </html> | ||
diff --git a/test/example-website/style.css b/test/example-website/style.css new file mode 100644 index 0000000..da7fd1c --- /dev/null +++ b/test/example-website/style.css | |||
| @@ -0,0 +1,156 @@ | |||
| 1 | * { | ||
| 2 | margin: 0; | ||
| 3 | padding: 0; | ||
| 4 | box-sizing: border-box; | ||
| 5 | } | ||
| 6 | |||
| 7 | body { | ||
| 8 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; | ||
| 9 | line-height: 1.6; | ||
| 10 | color: #333; | ||
| 11 | background: #f5f5f5; | ||
| 12 | } | ||
| 13 | |||
| 14 | header { | ||
| 15 | background: #2c3e50; | ||
| 16 | color: white; | ||
| 17 | padding: 1rem 0; | ||
| 18 | box-shadow: 0 2px 5px rgba(0,0,0,0.1); | ||
| 19 | } | ||
| 20 | |||
| 21 | nav { | ||
| 22 | max-width: 1200px; | ||
| 23 | margin: 0 auto; | ||
| 24 | padding: 0 2rem; | ||
| 25 | display: flex; | ||
| 26 | justify-content: space-between; | ||
| 27 | align-items: center; | ||
| 28 | } | ||
| 29 | |||
| 30 | nav h1 { | ||
| 31 | font-size: 1.5rem; | ||
| 32 | } | ||
| 33 | |||
| 34 | nav ul { | ||
| 35 | list-style: none; | ||
| 36 | display: flex; | ||
| 37 | gap: 2rem; | ||
| 38 | } | ||
| 39 | |||
| 40 | nav a { | ||
| 41 | color: white; | ||
| 42 | text-decoration: none; | ||
| 43 | transition: opacity 0.3s; | ||
| 44 | } | ||
| 45 | |||
| 46 | nav a:hover { | ||
| 47 | opacity: 0.8; | ||
| 48 | } | ||
| 49 | |||
| 50 | main { | ||
| 51 | max-width: 1200px; | ||
| 52 | margin: 2rem auto; | ||
| 53 | padding: 0 2rem; | ||
| 54 | } | ||
| 55 | |||
| 56 | .hero { | ||
| 57 | background: white; | ||
| 58 | padding: 3rem; | ||
| 59 | border-radius: 8px; | ||
| 60 | box-shadow: 0 2px 10px rgba(0,0,0,0.1); | ||
| 61 | text-align: center; | ||
| 62 | margin-bottom: 2rem; | ||
| 63 | } | ||
| 64 | |||
| 65 | .hero h2 { | ||
| 66 | font-size: 2.5rem; | ||
| 67 | margin-bottom: 1rem; | ||
| 68 | color: #2c3e50; | ||
| 69 | } | ||
| 70 | |||
| 71 | .hero p { | ||
| 72 | font-size: 1.2rem; | ||
| 73 | color: #666; | ||
| 74 | } | ||
| 75 | |||
| 76 | .features { | ||
| 77 | display: grid; | ||
| 78 | grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); | ||
| 79 | gap: 2rem; | ||
| 80 | margin: 2rem 0; | ||
| 81 | } | ||
| 82 | |||
| 83 | .feature { | ||
| 84 | background: white; | ||
| 85 | padding: 2rem; | ||
| 86 | border-radius: 8px; | ||
| 87 | box-shadow: 0 2px 10px rgba(0,0,0,0.1); | ||
| 88 | } | ||
| 89 | |||
| 90 | .feature h3 { | ||
| 91 | color: #2c3e50; | ||
| 92 | margin-bottom: 0.5rem; | ||
| 93 | } | ||
| 94 | |||
| 95 | .feature p { | ||
| 96 | color: #666; | ||
| 97 | } | ||
| 98 | |||
| 99 | .about { | ||
| 100 | background: white; | ||
| 101 | padding: 3rem; | ||
| 102 | border-radius: 8px; | ||
| 103 | box-shadow: 0 2px 10px rgba(0,0,0,0.1); | ||
| 104 | } | ||
| 105 | |||
| 106 | .about h2 { | ||
| 107 | color: #2c3e50; | ||
| 108 | margin-bottom: 1rem; | ||
| 109 | } | ||
| 110 | |||
| 111 | .about h3 { | ||
| 112 | color: #34495e; | ||
| 113 | margin-top: 2rem; | ||
| 114 | margin-bottom: 0.5rem; | ||
| 115 | } | ||
| 116 | |||
| 117 | .about ul { | ||
| 118 | margin-left: 2rem; | ||
| 119 | margin-bottom: 1rem; | ||
| 120 | } | ||
| 121 | |||
| 122 | .about pre { | ||
| 123 | background: #f5f5f5; | ||
| 124 | padding: 1rem; | ||
| 125 | border-radius: 4px; | ||
| 126 | overflow-x: auto; | ||
| 127 | margin-top: 1rem; | ||
| 128 | } | ||
| 129 | |||
| 130 | .about code { | ||
| 131 | font-family: 'Courier New', monospace; | ||
| 132 | font-size: 0.9rem; | ||
| 133 | } | ||
| 134 | |||
| 135 | footer { | ||
| 136 | background: #2c3e50; | ||
| 137 | color: white; | ||
| 138 | text-align: center; | ||
| 139 | padding: 2rem; | ||
| 140 | margin-top: 4rem; | ||
| 141 | } | ||
| 142 | |||
| 143 | @media (max-width: 768px) { | ||
| 144 | nav { | ||
| 145 | flex-direction: column; | ||
| 146 | gap: 1rem; | ||
| 147 | } | ||
| 148 | |||
| 149 | .hero h2 { | ||
| 150 | font-size: 2rem; | ||
| 151 | } | ||
| 152 | |||
| 153 | .features { | ||
| 154 | grid-template-columns: 1fr; | ||
| 155 | } | ||
| 156 | } | ||
