diff options
| author | bndw <ben@bdw.to> | 2026-01-23 20:54:46 -0800 |
|---|---|---|
| committer | bndw <ben@bdw.to> | 2026-01-23 20:54:46 -0800 |
| commit | 98b9af372025595e8a4255538e2836e019311474 (patch) | |
| tree | 0a26fa5a8a19ea8565da6d63e1f19c21fc170d12 /cmd | |
| parent | 7fcb9dfa87310e91b527829ece9989decb6fda64 (diff) | |
Add deploy command and fix static site naming
Static sites now default to using the domain as the name instead of
the source directory basename, preventing conflicts when multiple
sites use the same directory name (e.g., dist).
Also fixes .gitignore to not exclude cmd/deploy/ directory.
Diffstat (limited to 'cmd')
| -rw-r--r-- | cmd/deploy/deploy.go | 494 | ||||
| -rw-r--r-- | cmd/deploy/env.go | 176 | ||||
| -rw-r--r-- | cmd/deploy/init.go | 154 | ||||
| -rw-r--r-- | cmd/deploy/list.go | 58 | ||||
| -rw-r--r-- | cmd/deploy/main.go | 82 | ||||
| -rw-r--r-- | cmd/deploy/manage.go | 327 | ||||
| -rw-r--r-- | cmd/deploy/templates/webui.html | 440 | ||||
| -rw-r--r-- | cmd/deploy/webui.go | 228 |
8 files changed, 1959 insertions, 0 deletions
diff --git a/cmd/deploy/deploy.go b/cmd/deploy/deploy.go new file mode 100644 index 0000000..2b3ab4a --- /dev/null +++ b/cmd/deploy/deploy.go | |||
| @@ -0,0 +1,494 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bufio" | ||
| 5 | "flag" | ||
| 6 | "fmt" | ||
| 7 | "os" | ||
| 8 | "path/filepath" | ||
| 9 | "strconv" | ||
| 10 | "strings" | ||
| 11 | |||
| 12 | "github.com/bdw/deploy/internal/config" | ||
| 13 | "github.com/bdw/deploy/internal/ssh" | ||
| 14 | "github.com/bdw/deploy/internal/state" | ||
| 15 | "github.com/bdw/deploy/internal/templates" | ||
| 16 | ) | ||
| 17 | |||
| 18 | type envFlags []string | ||
| 19 | |||
| 20 | func (e *envFlags) String() string { | ||
| 21 | return strings.Join(*e, ",") | ||
| 22 | } | ||
| 23 | |||
| 24 | func (e *envFlags) Set(value string) error { | ||
| 25 | *e = append(*e, value) | ||
| 26 | return nil | ||
| 27 | } | ||
| 28 | |||
| 29 | type fileFlags []string | ||
| 30 | |||
| 31 | func (f *fileFlags) String() string { | ||
| 32 | return strings.Join(*f, ",") | ||
| 33 | } | ||
| 34 | |||
| 35 | func (f *fileFlags) Set(value string) error { | ||
| 36 | *f = append(*f, value) | ||
| 37 | return nil | ||
| 38 | } | ||
| 39 | |||
| 40 | func runDeploy(args []string) { | ||
| 41 | fs := flag.NewFlagSet("deploy", flag.ExitOnError) | ||
| 42 | host := fs.String("host", "", "VPS host (SSH config alias or user@host)") | ||
| 43 | domain := fs.String("domain", "", "Domain name (required)") | ||
| 44 | name := fs.String("name", "", "App name (default: inferred from binary or directory)") | ||
| 45 | binary := fs.String("binary", "", "Path to Go binary (for app deployment)") | ||
| 46 | static := fs.Bool("static", false, "Deploy as static site") | ||
| 47 | dir := fs.String("dir", ".", "Directory to deploy (for static sites)") | ||
| 48 | port := fs.Int("port", 0, "Port override (default: auto-allocate)") | ||
| 49 | var envVars envFlags | ||
| 50 | fs.Var(&envVars, "env", "Environment variable (KEY=VALUE, can be specified multiple times)") | ||
| 51 | envFile := fs.String("env-file", "", "Path to .env file") | ||
| 52 | binaryArgs := fs.String("args", "", "Arguments to pass to binary") | ||
| 53 | var files fileFlags | ||
| 54 | fs.Var(&files, "file", "Config file to upload to working directory (can be specified multiple times)") | ||
| 55 | |||
| 56 | fs.Parse(args) | ||
| 57 | |||
| 58 | // Get host from flag or config | ||
| 59 | if *host == "" { | ||
| 60 | cfg, err := config.Load() | ||
| 61 | if err != nil { | ||
| 62 | fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err) | ||
| 63 | os.Exit(1) | ||
| 64 | } | ||
| 65 | *host = cfg.Host | ||
| 66 | } | ||
| 67 | |||
| 68 | if *host == "" || *domain == "" { | ||
| 69 | fmt.Fprintf(os.Stderr, "Error: --host and --domain are required\n") | ||
| 70 | fs.Usage() | ||
| 71 | os.Exit(1) | ||
| 72 | } | ||
| 73 | |||
| 74 | if *static { | ||
| 75 | deployStatic(*host, *domain, *name, *dir) | ||
| 76 | } else { | ||
| 77 | deployApp(*host, *domain, *name, *binary, *port, envVars, *envFile, *binaryArgs, files) | ||
| 78 | } | ||
| 79 | } | ||
| 80 | |||
| 81 | func deployApp(host, domain, name, binaryPath string, portOverride int, envVars []string, envFile, args string, files []string) { | ||
| 82 | // Determine app name | ||
| 83 | if name == "" { | ||
| 84 | if binaryPath != "" { | ||
| 85 | name = filepath.Base(binaryPath) | ||
| 86 | } else { | ||
| 87 | // Try to find a binary in current directory | ||
| 88 | cwd, _ := os.Getwd() | ||
| 89 | name = filepath.Base(cwd) | ||
| 90 | } | ||
| 91 | } | ||
| 92 | |||
| 93 | // Find binary if not specified | ||
| 94 | if binaryPath == "" { | ||
| 95 | // Look for binary with same name as directory | ||
| 96 | if _, err := os.Stat(name); err == nil { | ||
| 97 | binaryPath = name | ||
| 98 | } else { | ||
| 99 | fmt.Fprintf(os.Stderr, "Error: --binary is required (could not find binary in current directory)\n") | ||
| 100 | os.Exit(1) | ||
| 101 | } | ||
| 102 | } | ||
| 103 | |||
| 104 | // Verify binary exists | ||
| 105 | if _, err := os.Stat(binaryPath); err != nil { | ||
| 106 | fmt.Fprintf(os.Stderr, "Error: binary not found: %s\n", binaryPath) | ||
| 107 | os.Exit(1) | ||
| 108 | } | ||
| 109 | |||
| 110 | fmt.Printf("Deploying app: %s\n", name) | ||
| 111 | fmt.Printf(" Domain: %s\n", domain) | ||
| 112 | fmt.Printf(" Binary: %s\n", binaryPath) | ||
| 113 | |||
| 114 | // Load state | ||
| 115 | st, err := state.Load() | ||
| 116 | if err != nil { | ||
| 117 | fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) | ||
| 118 | os.Exit(1) | ||
| 119 | } | ||
| 120 | |||
| 121 | // Check if app already exists (update) or new deployment | ||
| 122 | existingApp, _ := st.GetApp(host, name) | ||
| 123 | var port int | ||
| 124 | if existingApp != nil { | ||
| 125 | port = existingApp.Port | ||
| 126 | fmt.Printf(" Updating existing deployment (port %d)\n", port) | ||
| 127 | } else { | ||
| 128 | if portOverride > 0 { | ||
| 129 | port = portOverride | ||
| 130 | } else { | ||
| 131 | port = st.AllocatePort(host) | ||
| 132 | } | ||
| 133 | fmt.Printf(" Allocated port: %d\n", port) | ||
| 134 | } | ||
| 135 | |||
| 136 | // Parse environment variables | ||
| 137 | env := make(map[string]string) | ||
| 138 | if existingApp != nil { | ||
| 139 | // Preserve existing env vars | ||
| 140 | for k, v := range existingApp.Env { | ||
| 141 | env[k] = v | ||
| 142 | } | ||
| 143 | // Preserve existing args if not provided | ||
| 144 | if args == "" && existingApp.Args != "" { | ||
| 145 | args = existingApp.Args | ||
| 146 | } | ||
| 147 | // Preserve existing files if not provided | ||
| 148 | if len(files) == 0 && len(existingApp.Files) > 0 { | ||
| 149 | files = existingApp.Files | ||
| 150 | } | ||
| 151 | } | ||
| 152 | |||
| 153 | // Add/override from flags | ||
| 154 | for _, e := range envVars { | ||
| 155 | parts := strings.SplitN(e, "=", 2) | ||
| 156 | if len(parts) == 2 { | ||
| 157 | env[parts[0]] = parts[1] | ||
| 158 | } | ||
| 159 | } | ||
| 160 | |||
| 161 | // Add/override from file | ||
| 162 | if envFile != "" { | ||
| 163 | fileEnv, err := parseEnvFile(envFile) | ||
| 164 | if err != nil { | ||
| 165 | fmt.Fprintf(os.Stderr, "Error reading env file: %v\n", err) | ||
| 166 | os.Exit(1) | ||
| 167 | } | ||
| 168 | for k, v := range fileEnv { | ||
| 169 | env[k] = v | ||
| 170 | } | ||
| 171 | } | ||
| 172 | |||
| 173 | // Always set PORT | ||
| 174 | env["PORT"] = strconv.Itoa(port) | ||
| 175 | |||
| 176 | // Connect to VPS | ||
| 177 | client, err := ssh.Connect(host) | ||
| 178 | if err != nil { | ||
| 179 | fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err) | ||
| 180 | os.Exit(1) | ||
| 181 | } | ||
| 182 | defer client.Close() | ||
| 183 | |||
| 184 | // Upload binary | ||
| 185 | fmt.Println("→ Uploading binary...") | ||
| 186 | remoteTmpPath := fmt.Sprintf("/tmp/%s", name) | ||
| 187 | if err := client.Upload(binaryPath, remoteTmpPath); err != nil { | ||
| 188 | fmt.Fprintf(os.Stderr, "Error uploading binary: %v\n", err) | ||
| 189 | os.Exit(1) | ||
| 190 | } | ||
| 191 | |||
| 192 | // Create user (ignore error if already exists) | ||
| 193 | fmt.Println("→ Creating system user...") | ||
| 194 | client.RunSudo(fmt.Sprintf("useradd -r -s /bin/false %s", name)) | ||
| 195 | |||
| 196 | // Create working directory | ||
| 197 | fmt.Println("→ Setting up directories...") | ||
| 198 | workDir := fmt.Sprintf("/var/lib/%s", name) | ||
| 199 | if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", workDir)); err != nil { | ||
| 200 | fmt.Fprintf(os.Stderr, "Error creating work directory: %v\n", err) | ||
| 201 | os.Exit(1) | ||
| 202 | } | ||
| 203 | if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, workDir)); err != nil { | ||
| 204 | fmt.Fprintf(os.Stderr, "Error setting work directory ownership: %v\n", err) | ||
| 205 | os.Exit(1) | ||
| 206 | } | ||
| 207 | |||
| 208 | // Move binary to /usr/local/bin | ||
| 209 | fmt.Println("→ Installing binary...") | ||
| 210 | binaryDest := fmt.Sprintf("/usr/local/bin/%s", name) | ||
| 211 | if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", remoteTmpPath, binaryDest)); err != nil { | ||
| 212 | fmt.Fprintf(os.Stderr, "Error moving binary: %v\n", err) | ||
| 213 | os.Exit(1) | ||
| 214 | } | ||
| 215 | if _, err := client.RunSudo(fmt.Sprintf("chmod +x %s", binaryDest)); err != nil { | ||
| 216 | fmt.Fprintf(os.Stderr, "Error making binary executable: %v\n", err) | ||
| 217 | os.Exit(1) | ||
| 218 | } | ||
| 219 | |||
| 220 | // Upload config files to working directory | ||
| 221 | if len(files) > 0 { | ||
| 222 | fmt.Println("→ Uploading config files...") | ||
| 223 | for _, file := range files { | ||
| 224 | // Verify file exists locally | ||
| 225 | if _, err := os.Stat(file); err != nil { | ||
| 226 | fmt.Fprintf(os.Stderr, "Error: config file not found: %s\n", file) | ||
| 227 | os.Exit(1) | ||
| 228 | } | ||
| 229 | |||
| 230 | // Determine remote path (preserve filename) | ||
| 231 | remotePath := fmt.Sprintf("%s/%s", workDir, filepath.Base(file)) | ||
| 232 | remoteTmpPath := fmt.Sprintf("/tmp/%s_%s", name, filepath.Base(file)) | ||
| 233 | |||
| 234 | // Upload to tmp first | ||
| 235 | if err := client.Upload(file, remoteTmpPath); err != nil { | ||
| 236 | fmt.Fprintf(os.Stderr, "Error uploading config file %s: %v\n", file, err) | ||
| 237 | os.Exit(1) | ||
| 238 | } | ||
| 239 | |||
| 240 | // Move to working directory | ||
| 241 | if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", remoteTmpPath, remotePath)); err != nil { | ||
| 242 | fmt.Fprintf(os.Stderr, "Error moving config file %s: %v\n", file, err) | ||
| 243 | os.Exit(1) | ||
| 244 | } | ||
| 245 | |||
| 246 | // Set ownership | ||
| 247 | if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, remotePath)); err != nil { | ||
| 248 | fmt.Fprintf(os.Stderr, "Error setting config file ownership %s: %v\n", file, err) | ||
| 249 | os.Exit(1) | ||
| 250 | } | ||
| 251 | |||
| 252 | fmt.Printf(" Uploaded: %s\n", file) | ||
| 253 | } | ||
| 254 | } | ||
| 255 | |||
| 256 | // Create env file | ||
| 257 | fmt.Println("→ Creating environment file...") | ||
| 258 | envFilePath := fmt.Sprintf("/etc/deploy/env/%s.env", name) | ||
| 259 | envContent := "" | ||
| 260 | for k, v := range env { | ||
| 261 | envContent += fmt.Sprintf("%s=%s\n", k, v) | ||
| 262 | } | ||
| 263 | if err := client.WriteSudoFile(envFilePath, envContent); err != nil { | ||
| 264 | fmt.Fprintf(os.Stderr, "Error creating env file: %v\n", err) | ||
| 265 | os.Exit(1) | ||
| 266 | } | ||
| 267 | if _, err := client.RunSudo(fmt.Sprintf("chmod 600 %s", envFilePath)); err != nil { | ||
| 268 | fmt.Fprintf(os.Stderr, "Error setting env file permissions: %v\n", err) | ||
| 269 | os.Exit(1) | ||
| 270 | } | ||
| 271 | if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, envFilePath)); err != nil { | ||
| 272 | fmt.Fprintf(os.Stderr, "Error setting env file ownership: %v\n", err) | ||
| 273 | os.Exit(1) | ||
| 274 | } | ||
| 275 | |||
| 276 | // Generate systemd unit | ||
| 277 | fmt.Println("→ Creating systemd service...") | ||
| 278 | serviceContent, err := templates.SystemdService(map[string]string{ | ||
| 279 | "Name": name, | ||
| 280 | "User": name, | ||
| 281 | "WorkDir": workDir, | ||
| 282 | "BinaryPath": binaryDest, | ||
| 283 | "Port": strconv.Itoa(port), | ||
| 284 | "EnvFile": envFilePath, | ||
| 285 | "Args": args, | ||
| 286 | }) | ||
| 287 | if err != nil { | ||
| 288 | fmt.Fprintf(os.Stderr, "Error generating systemd unit: %v\n", err) | ||
| 289 | os.Exit(1) | ||
| 290 | } | ||
| 291 | |||
| 292 | servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name) | ||
| 293 | if err := client.WriteSudoFile(servicePath, serviceContent); err != nil { | ||
| 294 | fmt.Fprintf(os.Stderr, "Error creating systemd unit: %v\n", err) | ||
| 295 | os.Exit(1) | ||
| 296 | } | ||
| 297 | |||
| 298 | // Generate Caddy config | ||
| 299 | fmt.Println("→ Configuring Caddy...") | ||
| 300 | caddyContent, err := templates.AppCaddy(map[string]string{ | ||
| 301 | "Domain": domain, | ||
| 302 | "Port": strconv.Itoa(port), | ||
| 303 | }) | ||
| 304 | if err != nil { | ||
| 305 | fmt.Fprintf(os.Stderr, "Error generating Caddy config: %v\n", err) | ||
| 306 | os.Exit(1) | ||
| 307 | } | ||
| 308 | |||
| 309 | caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) | ||
| 310 | if err := client.WriteSudoFile(caddyPath, caddyContent); err != nil { | ||
| 311 | fmt.Fprintf(os.Stderr, "Error creating Caddy config: %v\n", err) | ||
| 312 | os.Exit(1) | ||
| 313 | } | ||
| 314 | |||
| 315 | // Reload systemd | ||
| 316 | fmt.Println("→ Reloading systemd...") | ||
| 317 | if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { | ||
| 318 | fmt.Fprintf(os.Stderr, "Error reloading systemd: %v\n", err) | ||
| 319 | os.Exit(1) | ||
| 320 | } | ||
| 321 | |||
| 322 | // Enable and start service | ||
| 323 | fmt.Println("→ Starting service...") | ||
| 324 | if _, err := client.RunSudo(fmt.Sprintf("systemctl enable %s", name)); err != nil { | ||
| 325 | fmt.Fprintf(os.Stderr, "Error enabling service: %v\n", err) | ||
| 326 | os.Exit(1) | ||
| 327 | } | ||
| 328 | if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { | ||
| 329 | fmt.Fprintf(os.Stderr, "Error starting service: %v\n", err) | ||
| 330 | os.Exit(1) | ||
| 331 | } | ||
| 332 | |||
| 333 | // Reload Caddy | ||
| 334 | fmt.Println("→ Reloading Caddy...") | ||
| 335 | if _, err := client.RunSudo("systemctl reload caddy"); err != nil { | ||
| 336 | fmt.Fprintf(os.Stderr, "Error reloading Caddy: %v\n", err) | ||
| 337 | os.Exit(1) | ||
| 338 | } | ||
| 339 | |||
| 340 | // Update state | ||
| 341 | st.AddApp(host, name, &state.App{ | ||
| 342 | Type: "app", | ||
| 343 | Domain: domain, | ||
| 344 | Port: port, | ||
| 345 | Env: env, | ||
| 346 | Args: args, | ||
| 347 | Files: files, | ||
| 348 | }) | ||
| 349 | if err := st.Save(); err != nil { | ||
| 350 | fmt.Fprintf(os.Stderr, "Error saving state: %v\n", err) | ||
| 351 | os.Exit(1) | ||
| 352 | } | ||
| 353 | |||
| 354 | fmt.Printf("\n✓ App deployed successfully!\n") | ||
| 355 | fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", domain) | ||
| 356 | } | ||
| 357 | |||
| 358 | func deployStatic(host, domain, name, dir string) { | ||
| 359 | // Determine site name (default to domain to avoid conflicts) | ||
| 360 | if name == "" { | ||
| 361 | name = domain | ||
| 362 | } | ||
| 363 | |||
| 364 | // Verify directory exists | ||
| 365 | if _, err := os.Stat(dir); err != nil { | ||
| 366 | fmt.Fprintf(os.Stderr, "Error: directory not found: %s\n", dir) | ||
| 367 | os.Exit(1) | ||
| 368 | } | ||
| 369 | |||
| 370 | fmt.Printf("Deploying static site: %s\n", name) | ||
| 371 | fmt.Printf(" Domain: %s\n", domain) | ||
| 372 | fmt.Printf(" Directory: %s\n", dir) | ||
| 373 | |||
| 374 | // Load state | ||
| 375 | st, err := state.Load() | ||
| 376 | if err != nil { | ||
| 377 | fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) | ||
| 378 | os.Exit(1) | ||
| 379 | } | ||
| 380 | |||
| 381 | // Connect to VPS | ||
| 382 | client, err := ssh.Connect(host) | ||
| 383 | if err != nil { | ||
| 384 | fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err) | ||
| 385 | os.Exit(1) | ||
| 386 | } | ||
| 387 | defer client.Close() | ||
| 388 | |||
| 389 | // Create remote directory | ||
| 390 | remoteDir := fmt.Sprintf("/var/www/%s", name) | ||
| 391 | fmt.Println("→ Creating remote directory...") | ||
| 392 | if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", remoteDir)); err != nil { | ||
| 393 | fmt.Fprintf(os.Stderr, "Error creating remote directory: %v\n", err) | ||
| 394 | os.Exit(1) | ||
| 395 | } | ||
| 396 | |||
| 397 | // Get current user for temporary ownership during upload | ||
| 398 | currentUser, err := client.Run("whoami") | ||
| 399 | if err != nil { | ||
| 400 | fmt.Fprintf(os.Stderr, "Error getting current user: %v\n", err) | ||
| 401 | os.Exit(1) | ||
| 402 | } | ||
| 403 | currentUser = strings.TrimSpace(currentUser) | ||
| 404 | |||
| 405 | // Set ownership to current user for upload | ||
| 406 | if _, err := client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", currentUser, currentUser, remoteDir)); err != nil { | ||
| 407 | fmt.Fprintf(os.Stderr, "Error setting temporary ownership: %v\n", err) | ||
| 408 | os.Exit(1) | ||
| 409 | } | ||
| 410 | |||
| 411 | // Upload files | ||
| 412 | fmt.Println("→ Uploading files...") | ||
| 413 | if err := client.UploadDir(dir, remoteDir); err != nil { | ||
| 414 | fmt.Fprintf(os.Stderr, "Error uploading files: %v\n", err) | ||
| 415 | os.Exit(1) | ||
| 416 | } | ||
| 417 | |||
| 418 | // Set ownership and permissions | ||
| 419 | fmt.Println("→ Setting permissions...") | ||
| 420 | if _, err := client.RunSudo(fmt.Sprintf("chown -R www-data:www-data %s", remoteDir)); err != nil { | ||
| 421 | fmt.Fprintf(os.Stderr, "Error setting ownership: %v\n", err) | ||
| 422 | os.Exit(1) | ||
| 423 | } | ||
| 424 | // Make files readable by all (755 for dirs, 644 for files) | ||
| 425 | if _, err := client.RunSudo(fmt.Sprintf("chmod -R 755 %s", remoteDir)); err != nil { | ||
| 426 | fmt.Fprintf(os.Stderr, "Error setting directory permissions: %v\n", err) | ||
| 427 | os.Exit(1) | ||
| 428 | } | ||
| 429 | if _, err := client.RunSudo(fmt.Sprintf("find %s -type f -exec chmod 644 {} \\;", remoteDir)); err != nil { | ||
| 430 | fmt.Fprintf(os.Stderr, "Error setting file permissions: %v\n", err) | ||
| 431 | os.Exit(1) | ||
| 432 | } | ||
| 433 | |||
| 434 | // Generate Caddy config | ||
| 435 | fmt.Println("→ Configuring Caddy...") | ||
| 436 | caddyContent, err := templates.StaticCaddy(map[string]string{ | ||
| 437 | "Domain": domain, | ||
| 438 | "RootDir": remoteDir, | ||
| 439 | }) | ||
| 440 | if err != nil { | ||
| 441 | fmt.Fprintf(os.Stderr, "Error generating Caddy config: %v\n", err) | ||
| 442 | os.Exit(1) | ||
| 443 | } | ||
| 444 | |||
| 445 | caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) | ||
| 446 | if err := client.WriteSudoFile(caddyPath, caddyContent); err != nil { | ||
| 447 | fmt.Fprintf(os.Stderr, "Error creating Caddy config: %v\n", err) | ||
| 448 | os.Exit(1) | ||
| 449 | } | ||
| 450 | |||
| 451 | // Reload Caddy | ||
| 452 | fmt.Println("→ Reloading Caddy...") | ||
| 453 | if _, err := client.RunSudo("systemctl reload caddy"); err != nil { | ||
| 454 | fmt.Fprintf(os.Stderr, "Error reloading Caddy: %v\n", err) | ||
| 455 | os.Exit(1) | ||
| 456 | } | ||
| 457 | |||
| 458 | // Update state | ||
| 459 | st.AddApp(host, name, &state.App{ | ||
| 460 | Type: "static", | ||
| 461 | Domain: domain, | ||
| 462 | }) | ||
| 463 | if err := st.Save(); err != nil { | ||
| 464 | fmt.Fprintf(os.Stderr, "Error saving state: %v\n", err) | ||
| 465 | os.Exit(1) | ||
| 466 | } | ||
| 467 | |||
| 468 | fmt.Printf("\n✓ Static site deployed successfully!\n") | ||
| 469 | fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", domain) | ||
| 470 | } | ||
| 471 | |||
| 472 | func parseEnvFile(path string) (map[string]string, error) { | ||
| 473 | file, err := os.Open(path) | ||
| 474 | if err != nil { | ||
| 475 | return nil, err | ||
| 476 | } | ||
| 477 | defer file.Close() | ||
| 478 | |||
| 479 | env := make(map[string]string) | ||
| 480 | scanner := bufio.NewScanner(file) | ||
| 481 | for scanner.Scan() { | ||
| 482 | line := strings.TrimSpace(scanner.Text()) | ||
| 483 | if line == "" || strings.HasPrefix(line, "#") { | ||
| 484 | continue | ||
| 485 | } | ||
| 486 | |||
| 487 | parts := strings.SplitN(line, "=", 2) | ||
| 488 | if len(parts) == 2 { | ||
| 489 | env[parts[0]] = parts[1] | ||
| 490 | } | ||
| 491 | } | ||
| 492 | |||
| 493 | return env, scanner.Err() | ||
| 494 | } | ||
diff --git a/cmd/deploy/env.go b/cmd/deploy/env.go new file mode 100644 index 0000000..135fb77 --- /dev/null +++ b/cmd/deploy/env.go | |||
| @@ -0,0 +1,176 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "flag" | ||
| 5 | "fmt" | ||
| 6 | "os" | ||
| 7 | "strings" | ||
| 8 | |||
| 9 | "github.com/bdw/deploy/internal/config" | ||
| 10 | "github.com/bdw/deploy/internal/ssh" | ||
| 11 | "github.com/bdw/deploy/internal/state" | ||
| 12 | ) | ||
| 13 | |||
| 14 | func runEnv(args []string) { | ||
| 15 | fs := flag.NewFlagSet("env", flag.ExitOnError) | ||
| 16 | host := fs.String("host", "", "VPS host (SSH config alias or user@host)") | ||
| 17 | var setVars envFlags | ||
| 18 | fs.Var(&setVars, "set", "Set environment variable (KEY=VALUE, can be specified multiple times)") | ||
| 19 | var unsetVars envFlags | ||
| 20 | fs.Var(&unsetVars, "unset", "Unset environment variable (KEY, can be specified multiple times)") | ||
| 21 | envFile := fs.String("file", "", "Load environment from file") | ||
| 22 | fs.Parse(args) | ||
| 23 | |||
| 24 | if len(fs.Args()) == 0 { | ||
| 25 | fmt.Fprintf(os.Stderr, "Error: app name is required\n") | ||
| 26 | fmt.Fprintf(os.Stderr, "Usage: deploy env <app-name> [--set KEY=VALUE] [--unset KEY] [--file .env] --host user@vps-ip\n") | ||
| 27 | os.Exit(1) | ||
| 28 | } | ||
| 29 | |||
| 30 | name := fs.Args()[0] | ||
| 31 | |||
| 32 | // Get host from flag or config | ||
| 33 | if *host == "" { | ||
| 34 | cfg, err := config.Load() | ||
| 35 | if err != nil { | ||
| 36 | fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err) | ||
| 37 | os.Exit(1) | ||
| 38 | } | ||
| 39 | *host = cfg.Host | ||
| 40 | } | ||
| 41 | |||
| 42 | if *host == "" { | ||
| 43 | fmt.Fprintf(os.Stderr, "Error: --host is required\n") | ||
| 44 | fs.Usage() | ||
| 45 | os.Exit(1) | ||
| 46 | } | ||
| 47 | |||
| 48 | // Load state | ||
| 49 | st, err := state.Load() | ||
| 50 | if err != nil { | ||
| 51 | fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) | ||
| 52 | os.Exit(1) | ||
| 53 | } | ||
| 54 | |||
| 55 | // Get app info | ||
| 56 | app, err := st.GetApp(*host, name) | ||
| 57 | if err != nil { | ||
| 58 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) | ||
| 59 | os.Exit(1) | ||
| 60 | } | ||
| 61 | |||
| 62 | if app.Type != "app" { | ||
| 63 | fmt.Fprintf(os.Stderr, "Error: env is only available for apps, not static sites\n") | ||
| 64 | os.Exit(1) | ||
| 65 | } | ||
| 66 | |||
| 67 | // If no flags, just display current env (masked) | ||
| 68 | if len(setVars) == 0 && len(unsetVars) == 0 && *envFile == "" { | ||
| 69 | fmt.Printf("Environment variables for %s:\n\n", name) | ||
| 70 | if len(app.Env) == 0 { | ||
| 71 | fmt.Println(" (none)") | ||
| 72 | } else { | ||
| 73 | for k, v := range app.Env { | ||
| 74 | // Mask sensitive-looking values | ||
| 75 | display := v | ||
| 76 | if isSensitive(k) { | ||
| 77 | display = "***" | ||
| 78 | } | ||
| 79 | fmt.Printf(" %s=%s\n", k, display) | ||
| 80 | } | ||
| 81 | } | ||
| 82 | return | ||
| 83 | } | ||
| 84 | |||
| 85 | // Initialize env if nil | ||
| 86 | if app.Env == nil { | ||
| 87 | app.Env = make(map[string]string) | ||
| 88 | } | ||
| 89 | |||
| 90 | // Apply changes | ||
| 91 | changed := false | ||
| 92 | |||
| 93 | // Unset variables | ||
| 94 | for _, key := range unsetVars { | ||
| 95 | if _, exists := app.Env[key]; exists { | ||
| 96 | delete(app.Env, key) | ||
| 97 | changed = true | ||
| 98 | fmt.Printf("Unset %s\n", key) | ||
| 99 | } | ||
| 100 | } | ||
| 101 | |||
| 102 | // Set variables from flags | ||
| 103 | for _, e := range setVars { | ||
| 104 | parts := strings.SplitN(e, "=", 2) | ||
| 105 | if len(parts) == 2 { | ||
| 106 | app.Env[parts[0]] = parts[1] | ||
| 107 | changed = true | ||
| 108 | fmt.Printf("Set %s\n", parts[0]) | ||
| 109 | } | ||
| 110 | } | ||
| 111 | |||
| 112 | // Set variables from file | ||
| 113 | if *envFile != "" { | ||
| 114 | fileEnv, err := parseEnvFile(*envFile) | ||
| 115 | if err != nil { | ||
| 116 | fmt.Fprintf(os.Stderr, "Error reading env file: %v\n", err) | ||
| 117 | os.Exit(1) | ||
| 118 | } | ||
| 119 | for k, v := range fileEnv { | ||
| 120 | app.Env[k] = v | ||
| 121 | changed = true | ||
| 122 | fmt.Printf("Set %s\n", k) | ||
| 123 | } | ||
| 124 | } | ||
| 125 | |||
| 126 | if !changed { | ||
| 127 | fmt.Println("No changes made") | ||
| 128 | return | ||
| 129 | } | ||
| 130 | |||
| 131 | // Save state | ||
| 132 | if err := st.Save(); err != nil { | ||
| 133 | fmt.Fprintf(os.Stderr, "Error saving state: %v\n", err) | ||
| 134 | os.Exit(1) | ||
| 135 | } | ||
| 136 | |||
| 137 | // Connect to VPS and update env file | ||
| 138 | client, err := ssh.Connect(*host) | ||
| 139 | if err != nil { | ||
| 140 | fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err) | ||
| 141 | os.Exit(1) | ||
| 142 | } | ||
| 143 | defer client.Close() | ||
| 144 | |||
| 145 | // Regenerate env file | ||
| 146 | fmt.Println("→ Updating environment file on VPS...") | ||
| 147 | envFilePath := fmt.Sprintf("/etc/deploy/env/%s.env", name) | ||
| 148 | envContent := "" | ||
| 149 | for k, v := range app.Env { | ||
| 150 | envContent += fmt.Sprintf("%s=%s\n", k, v) | ||
| 151 | } | ||
| 152 | if err := client.WriteSudoFile(envFilePath, envContent); err != nil { | ||
| 153 | fmt.Fprintf(os.Stderr, "Error updating env file: %v\n", err) | ||
| 154 | os.Exit(1) | ||
| 155 | } | ||
| 156 | |||
| 157 | // Restart service to pick up new env | ||
| 158 | fmt.Println("→ Restarting service...") | ||
| 159 | if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { | ||
| 160 | fmt.Fprintf(os.Stderr, "Error restarting service: %v\n", err) | ||
| 161 | os.Exit(1) | ||
| 162 | } | ||
| 163 | |||
| 164 | fmt.Println("✓ Environment variables updated successfully") | ||
| 165 | } | ||
| 166 | |||
| 167 | func isSensitive(key string) bool { | ||
| 168 | key = strings.ToLower(key) | ||
| 169 | sensitiveWords := []string{"key", "secret", "password", "token", "api"} | ||
| 170 | for _, word := range sensitiveWords { | ||
| 171 | if strings.Contains(key, word) { | ||
| 172 | return true | ||
| 173 | } | ||
| 174 | } | ||
| 175 | return false | ||
| 176 | } | ||
diff --git a/cmd/deploy/init.go b/cmd/deploy/init.go new file mode 100644 index 0000000..72c7d53 --- /dev/null +++ b/cmd/deploy/init.go | |||
| @@ -0,0 +1,154 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "flag" | ||
| 5 | "fmt" | ||
| 6 | "os" | ||
| 7 | "strings" | ||
| 8 | |||
| 9 | "github.com/bdw/deploy/internal/config" | ||
| 10 | "github.com/bdw/deploy/internal/ssh" | ||
| 11 | "github.com/bdw/deploy/internal/state" | ||
| 12 | ) | ||
| 13 | |||
| 14 | func runInit(args []string) { | ||
| 15 | fs := flag.NewFlagSet("init", flag.ExitOnError) | ||
| 16 | host := fs.String("host", "", "VPS host (SSH config alias or user@host)") | ||
| 17 | fs.Parse(args) | ||
| 18 | |||
| 19 | // Get host from flag or config | ||
| 20 | if *host == "" { | ||
| 21 | cfg, err := config.Load() | ||
| 22 | if err != nil { | ||
| 23 | fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err) | ||
| 24 | os.Exit(1) | ||
| 25 | } | ||
| 26 | *host = cfg.Host | ||
| 27 | } | ||
| 28 | |||
| 29 | if *host == "" { | ||
| 30 | fmt.Fprintf(os.Stderr, "Error: --host is required\n") | ||
| 31 | fs.Usage() | ||
| 32 | os.Exit(1) | ||
| 33 | } | ||
| 34 | |||
| 35 | fmt.Printf("Initializing VPS: %s\n", *host) | ||
| 36 | |||
| 37 | // Connect to VPS | ||
| 38 | client, err := ssh.Connect(*host) | ||
| 39 | if err != nil { | ||
| 40 | fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err) | ||
| 41 | os.Exit(1) | ||
| 42 | } | ||
| 43 | defer client.Close() | ||
| 44 | |||
| 45 | // Detect OS | ||
| 46 | fmt.Println("→ Detecting OS...") | ||
| 47 | osRelease, err := client.Run("cat /etc/os-release") | ||
| 48 | if err != nil { | ||
| 49 | fmt.Fprintf(os.Stderr, "Error detecting OS: %v\n", err) | ||
| 50 | os.Exit(1) | ||
| 51 | } | ||
| 52 | |||
| 53 | if !strings.Contains(osRelease, "Ubuntu") && !strings.Contains(osRelease, "Debian") { | ||
| 54 | fmt.Fprintf(os.Stderr, "Error: Unsupported OS (only Ubuntu and Debian are supported)\n") | ||
| 55 | os.Exit(1) | ||
| 56 | } | ||
| 57 | fmt.Println(" ✓ Detected Ubuntu/Debian") | ||
| 58 | |||
| 59 | // Check if Caddy is already installed | ||
| 60 | fmt.Println("→ Checking for Caddy...") | ||
| 61 | _, err = client.Run("which caddy") | ||
| 62 | if err == nil { | ||
| 63 | fmt.Println(" ✓ Caddy already installed") | ||
| 64 | } else { | ||
| 65 | // Install Caddy | ||
| 66 | fmt.Println(" Installing Caddy...") | ||
| 67 | installCaddy(client) | ||
| 68 | fmt.Println(" ✓ Caddy installed") | ||
| 69 | } | ||
| 70 | |||
| 71 | // Create Caddyfile | ||
| 72 | fmt.Println("→ Configuring Caddy...") | ||
| 73 | caddyfile := `{ | ||
| 74 | email admin@example.com | ||
| 75 | } | ||
| 76 | |||
| 77 | import /etc/caddy/sites-enabled/* | ||
| 78 | ` | ||
| 79 | if err := client.WriteSudoFile("/etc/caddy/Caddyfile", caddyfile); err != nil { | ||
| 80 | fmt.Fprintf(os.Stderr, "Error creating Caddyfile: %v\n", err) | ||
| 81 | os.Exit(1) | ||
| 82 | } | ||
| 83 | fmt.Println(" ✓ Caddyfile created") | ||
| 84 | |||
| 85 | // Create directories | ||
| 86 | fmt.Println("→ Creating directories...") | ||
| 87 | if _, err := client.RunSudo("mkdir -p /etc/deploy/env"); err != nil { | ||
| 88 | fmt.Fprintf(os.Stderr, "Error creating /etc/deploy/env: %v\n", err) | ||
| 89 | os.Exit(1) | ||
| 90 | } | ||
| 91 | if _, err := client.RunSudo("mkdir -p /etc/caddy/sites-enabled"); err != nil { | ||
| 92 | fmt.Fprintf(os.Stderr, "Error creating /etc/caddy/sites-enabled: %v\n", err) | ||
| 93 | os.Exit(1) | ||
| 94 | } | ||
| 95 | fmt.Println(" ✓ Directories created") | ||
| 96 | |||
| 97 | // Enable and start Caddy | ||
| 98 | fmt.Println("→ Starting Caddy...") | ||
| 99 | if _, err := client.RunSudo("systemctl enable caddy"); err != nil { | ||
| 100 | fmt.Fprintf(os.Stderr, "Error enabling Caddy: %v\n", err) | ||
| 101 | os.Exit(1) | ||
| 102 | } | ||
| 103 | if _, err := client.RunSudo("systemctl restart caddy"); err != nil { | ||
| 104 | fmt.Fprintf(os.Stderr, "Error starting Caddy: %v\n", err) | ||
| 105 | os.Exit(1) | ||
| 106 | } | ||
| 107 | fmt.Println(" ✓ Caddy started") | ||
| 108 | |||
| 109 | // Verify Caddy is running | ||
| 110 | fmt.Println("→ Verifying installation...") | ||
| 111 | output, err := client.RunSudo("systemctl is-active caddy") | ||
| 112 | if err != nil || strings.TrimSpace(output) != "active" { | ||
| 113 | fmt.Fprintf(os.Stderr, "Warning: Caddy may not be running properly\n") | ||
| 114 | } else { | ||
| 115 | fmt.Println(" ✓ Caddy is active") | ||
| 116 | } | ||
| 117 | |||
| 118 | // Initialize local state if needed | ||
| 119 | st, err := state.Load() | ||
| 120 | if err != nil { | ||
| 121 | fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) | ||
| 122 | os.Exit(1) | ||
| 123 | } | ||
| 124 | st.GetHost(*host) // Ensure host exists in state | ||
| 125 | if err := st.Save(); err != nil { | ||
| 126 | fmt.Fprintf(os.Stderr, "Error saving state: %v\n", err) | ||
| 127 | os.Exit(1) | ||
| 128 | } | ||
| 129 | |||
| 130 | fmt.Println("\n✓ VPS initialized successfully!") | ||
| 131 | fmt.Println("\nNext steps:") | ||
| 132 | fmt.Println(" 1. Deploy a Go app:") | ||
| 133 | fmt.Printf(" deploy deploy --host %s --binary ./myapp --domain api.example.com\n", *host) | ||
| 134 | fmt.Println(" 2. Deploy a static site:") | ||
| 135 | fmt.Printf(" deploy deploy --host %s --static --dir ./dist --domain example.com\n", *host) | ||
| 136 | } | ||
| 137 | |||
| 138 | func installCaddy(client *ssh.Client) { | ||
| 139 | commands := []string{ | ||
| 140 | "apt-get update", | ||
| 141 | "apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl", | ||
| 142 | "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg", | ||
| 143 | "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list", | ||
| 144 | "apt-get update", | ||
| 145 | "apt-get install -y caddy", | ||
| 146 | } | ||
| 147 | |||
| 148 | for _, cmd := range commands { | ||
| 149 | if _, err := client.RunSudo(cmd); err != nil { | ||
| 150 | fmt.Fprintf(os.Stderr, "Error running: %s\nError: %v\n", cmd, err) | ||
| 151 | os.Exit(1) | ||
| 152 | } | ||
| 153 | } | ||
| 154 | } | ||
diff --git a/cmd/deploy/list.go b/cmd/deploy/list.go new file mode 100644 index 0000000..b74cf35 --- /dev/null +++ b/cmd/deploy/list.go | |||
| @@ -0,0 +1,58 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "flag" | ||
| 5 | "fmt" | ||
| 6 | "os" | ||
| 7 | "text/tabwriter" | ||
| 8 | |||
| 9 | "github.com/bdw/deploy/internal/config" | ||
| 10 | "github.com/bdw/deploy/internal/state" | ||
| 11 | ) | ||
| 12 | |||
| 13 | func runList(args []string) { | ||
| 14 | fs := flag.NewFlagSet("list", flag.ExitOnError) | ||
| 15 | host := fs.String("host", "", "VPS host (SSH config alias or user@host)") | ||
| 16 | fs.Parse(args) | ||
| 17 | |||
| 18 | // Get host from flag or config | ||
| 19 | if *host == "" { | ||
| 20 | cfg, err := config.Load() | ||
| 21 | if err != nil { | ||
| 22 | fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err) | ||
| 23 | os.Exit(1) | ||
| 24 | } | ||
| 25 | *host = cfg.Host | ||
| 26 | } | ||
| 27 | |||
| 28 | if *host == "" { | ||
| 29 | fmt.Fprintf(os.Stderr, "Error: --host is required\n") | ||
| 30 | fs.Usage() | ||
| 31 | os.Exit(1) | ||
| 32 | } | ||
| 33 | |||
| 34 | // Load state | ||
| 35 | st, err := state.Load() | ||
| 36 | if err != nil { | ||
| 37 | fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) | ||
| 38 | os.Exit(1) | ||
| 39 | } | ||
| 40 | |||
| 41 | apps := st.ListApps(*host) | ||
| 42 | if len(apps) == 0 { | ||
| 43 | fmt.Printf("No deployments found for %s\n", *host) | ||
| 44 | return | ||
| 45 | } | ||
| 46 | |||
| 47 | // Print table | ||
| 48 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) | ||
| 49 | fmt.Fprintln(w, "NAME\tTYPE\tDOMAIN\tPORT") | ||
| 50 | for name, app := range apps { | ||
| 51 | port := "" | ||
| 52 | if app.Type == "app" { | ||
| 53 | port = fmt.Sprintf(":%d", app.Port) | ||
| 54 | } | ||
| 55 | fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", name, app.Type, app.Domain, port) | ||
| 56 | } | ||
| 57 | w.Flush() | ||
| 58 | } | ||
diff --git a/cmd/deploy/main.go b/cmd/deploy/main.go new file mode 100644 index 0000000..1589af3 --- /dev/null +++ b/cmd/deploy/main.go | |||
| @@ -0,0 +1,82 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "os" | ||
| 6 | ) | ||
| 7 | |||
| 8 | func main() { | ||
| 9 | if len(os.Args) < 2 { | ||
| 10 | printUsage() | ||
| 11 | os.Exit(1) | ||
| 12 | } | ||
| 13 | |||
| 14 | command := os.Args[1] | ||
| 15 | |||
| 16 | switch command { | ||
| 17 | case "init": | ||
| 18 | runInit(os.Args[2:]) | ||
| 19 | case "deploy": | ||
| 20 | runDeploy(os.Args[2:]) | ||
| 21 | case "list": | ||
| 22 | runList(os.Args[2:]) | ||
| 23 | case "remove": | ||
| 24 | runRemove(os.Args[2:]) | ||
| 25 | case "logs": | ||
| 26 | runLogs(os.Args[2:]) | ||
| 27 | case "status": | ||
| 28 | runStatus(os.Args[2:]) | ||
| 29 | case "restart": | ||
| 30 | runRestart(os.Args[2:]) | ||
| 31 | case "env": | ||
| 32 | runEnv(os.Args[2:]) | ||
| 33 | case "webui": | ||
| 34 | runWebUI(os.Args[2:]) | ||
| 35 | case "help", "--help", "-h": | ||
| 36 | printUsage() | ||
| 37 | default: | ||
| 38 | fmt.Fprintf(os.Stderr, "Unknown command: %s\n\n", command) | ||
| 39 | printUsage() | ||
| 40 | os.Exit(1) | ||
| 41 | } | ||
| 42 | } | ||
| 43 | |||
| 44 | func printUsage() { | ||
| 45 | usage := `deploy - Deploy Go apps and static sites to a VPS with automatic HTTPS | ||
| 46 | |||
| 47 | USAGE: | ||
| 48 | deploy <command> [flags] | ||
| 49 | |||
| 50 | COMMANDS: | ||
| 51 | init Initialize a fresh VPS (one-time setup) | ||
| 52 | deploy Deploy a Go app or static site | ||
| 53 | list List all deployed apps and sites | ||
| 54 | remove Remove a deployment | ||
| 55 | logs View logs for a deployment | ||
| 56 | status Check status of a deployment | ||
| 57 | restart Restart a deployment | ||
| 58 | env Manage environment variables | ||
| 59 | webui Launch web UI to manage deployments | ||
| 60 | |||
| 61 | FLAGS: | ||
| 62 | Run 'deploy <command> -h' for command-specific flags | ||
| 63 | |||
| 64 | EXAMPLES: | ||
| 65 | # Initialize VPS | ||
| 66 | deploy init --host user@vps-ip | ||
| 67 | |||
| 68 | # Deploy Go app | ||
| 69 | deploy deploy --host user@vps-ip --binary ./myapp --domain api.example.com | ||
| 70 | |||
| 71 | # Deploy static site | ||
| 72 | deploy deploy --host user@vps-ip --static --dir ./dist --domain example.com | ||
| 73 | |||
| 74 | # List deployments | ||
| 75 | deploy list --host user@vps-ip | ||
| 76 | |||
| 77 | CONFIG FILE: | ||
| 78 | Create ~/.config/deploy/config to set default host: | ||
| 79 | host: user@your-vps-ip | ||
| 80 | ` | ||
| 81 | fmt.Fprint(os.Stderr, usage) | ||
| 82 | } | ||
diff --git a/cmd/deploy/manage.go b/cmd/deploy/manage.go new file mode 100644 index 0000000..3cee1f4 --- /dev/null +++ b/cmd/deploy/manage.go | |||
| @@ -0,0 +1,327 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "flag" | ||
| 5 | "fmt" | ||
| 6 | "os" | ||
| 7 | |||
| 8 | "github.com/bdw/deploy/internal/config" | ||
| 9 | "github.com/bdw/deploy/internal/ssh" | ||
| 10 | "github.com/bdw/deploy/internal/state" | ||
| 11 | ) | ||
| 12 | |||
| 13 | func runRemove(args []string) { | ||
| 14 | fs := flag.NewFlagSet("remove", flag.ExitOnError) | ||
| 15 | host := fs.String("host", "", "VPS host (SSH config alias or user@host)") | ||
| 16 | fs.Parse(args) | ||
| 17 | |||
| 18 | if len(fs.Args()) == 0 { | ||
| 19 | fmt.Fprintf(os.Stderr, "Error: app name is required\n") | ||
| 20 | fmt.Fprintf(os.Stderr, "Usage: deploy remove <app-name> --host user@vps-ip\n") | ||
| 21 | os.Exit(1) | ||
| 22 | } | ||
| 23 | |||
| 24 | name := fs.Args()[0] | ||
| 25 | |||
| 26 | // Get host from flag or config | ||
| 27 | if *host == "" { | ||
| 28 | cfg, err := config.Load() | ||
| 29 | if err != nil { | ||
| 30 | fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err) | ||
| 31 | os.Exit(1) | ||
| 32 | } | ||
| 33 | *host = cfg.Host | ||
| 34 | } | ||
| 35 | |||
| 36 | if *host == "" { | ||
| 37 | fmt.Fprintf(os.Stderr, "Error: --host is required\n") | ||
| 38 | fs.Usage() | ||
| 39 | os.Exit(1) | ||
| 40 | } | ||
| 41 | |||
| 42 | // Load state | ||
| 43 | st, err := state.Load() | ||
| 44 | if err != nil { | ||
| 45 | fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) | ||
| 46 | os.Exit(1) | ||
| 47 | } | ||
| 48 | |||
| 49 | // Get app info | ||
| 50 | app, err := st.GetApp(*host, name) | ||
| 51 | if err != nil { | ||
| 52 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) | ||
| 53 | os.Exit(1) | ||
| 54 | } | ||
| 55 | |||
| 56 | fmt.Printf("Removing deployment: %s\n", name) | ||
| 57 | |||
| 58 | // Connect to VPS | ||
| 59 | client, err := ssh.Connect(*host) | ||
| 60 | if err != nil { | ||
| 61 | fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err) | ||
| 62 | os.Exit(1) | ||
| 63 | } | ||
| 64 | defer client.Close() | ||
| 65 | |||
| 66 | if app.Type == "app" { | ||
| 67 | // Stop and disable service | ||
| 68 | fmt.Println("→ Stopping service...") | ||
| 69 | client.RunSudo(fmt.Sprintf("systemctl stop %s", name)) | ||
| 70 | client.RunSudo(fmt.Sprintf("systemctl disable %s", name)) | ||
| 71 | |||
| 72 | // Remove systemd unit | ||
| 73 | client.RunSudo(fmt.Sprintf("rm -f /etc/systemd/system/%s.service", name)) | ||
| 74 | client.RunSudo("systemctl daemon-reload") | ||
| 75 | |||
| 76 | // Remove binary | ||
| 77 | client.RunSudo(fmt.Sprintf("rm -f /usr/local/bin/%s", name)) | ||
| 78 | |||
| 79 | // Remove working directory | ||
| 80 | client.RunSudo(fmt.Sprintf("rm -rf /var/lib/%s", name)) | ||
| 81 | |||
| 82 | // Remove env file | ||
| 83 | client.RunSudo(fmt.Sprintf("rm -f /etc/deploy/env/%s.env", name)) | ||
| 84 | |||
| 85 | // Remove user | ||
| 86 | client.RunSudo(fmt.Sprintf("userdel %s", name)) | ||
| 87 | } else { | ||
| 88 | // Remove static site files | ||
| 89 | fmt.Println("→ Removing files...") | ||
| 90 | client.RunSudo(fmt.Sprintf("rm -rf /var/www/%s", name)) | ||
| 91 | } | ||
| 92 | |||
| 93 | // Remove Caddy config | ||
| 94 | fmt.Println("→ Removing Caddy config...") | ||
| 95 | client.RunSudo(fmt.Sprintf("rm -f /etc/caddy/sites-enabled/%s.caddy", name)) | ||
| 96 | |||
| 97 | // Reload Caddy | ||
| 98 | fmt.Println("→ Reloading Caddy...") | ||
| 99 | if _, err := client.RunSudo("systemctl reload caddy"); err != nil { | ||
| 100 | fmt.Fprintf(os.Stderr, "Warning: Error reloading Caddy: %v\n", err) | ||
| 101 | } | ||
| 102 | |||
| 103 | // Update state | ||
| 104 | if err := st.RemoveApp(*host, name); err != nil { | ||
| 105 | fmt.Fprintf(os.Stderr, "Error updating state: %v\n", err) | ||
| 106 | os.Exit(1) | ||
| 107 | } | ||
| 108 | if err := st.Save(); err != nil { | ||
| 109 | fmt.Fprintf(os.Stderr, "Error saving state: %v\n", err) | ||
| 110 | os.Exit(1) | ||
| 111 | } | ||
| 112 | |||
| 113 | fmt.Printf("✓ Deployment removed successfully\n") | ||
| 114 | } | ||
| 115 | |||
| 116 | func runLogs(args []string) { | ||
| 117 | fs := flag.NewFlagSet("logs", flag.ExitOnError) | ||
| 118 | host := fs.String("host", "", "VPS host (SSH config alias or user@host)") | ||
| 119 | follow := fs.Bool("f", false, "Follow logs") | ||
| 120 | lines := fs.Int("n", 50, "Number of lines to show") | ||
| 121 | fs.Parse(args) | ||
| 122 | |||
| 123 | if len(fs.Args()) == 0 { | ||
| 124 | fmt.Fprintf(os.Stderr, "Error: app name is required\n") | ||
| 125 | fmt.Fprintf(os.Stderr, "Usage: deploy logs <app-name> --host user@vps-ip\n") | ||
| 126 | os.Exit(1) | ||
| 127 | } | ||
| 128 | |||
| 129 | name := fs.Args()[0] | ||
| 130 | |||
| 131 | // Get host from flag or config | ||
| 132 | if *host == "" { | ||
| 133 | cfg, err := config.Load() | ||
| 134 | if err != nil { | ||
| 135 | fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err) | ||
| 136 | os.Exit(1) | ||
| 137 | } | ||
| 138 | *host = cfg.Host | ||
| 139 | } | ||
| 140 | |||
| 141 | if *host == "" { | ||
| 142 | fmt.Fprintf(os.Stderr, "Error: --host is required\n") | ||
| 143 | fs.Usage() | ||
| 144 | os.Exit(1) | ||
| 145 | } | ||
| 146 | |||
| 147 | // Load state to verify app exists | ||
| 148 | st, err := state.Load() | ||
| 149 | if err != nil { | ||
| 150 | fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) | ||
| 151 | os.Exit(1) | ||
| 152 | } | ||
| 153 | |||
| 154 | app, err := st.GetApp(*host, name) | ||
| 155 | if err != nil { | ||
| 156 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) | ||
| 157 | os.Exit(1) | ||
| 158 | } | ||
| 159 | |||
| 160 | if app.Type != "app" { | ||
| 161 | fmt.Fprintf(os.Stderr, "Error: logs are only available for apps, not static sites\n") | ||
| 162 | os.Exit(1) | ||
| 163 | } | ||
| 164 | |||
| 165 | // Connect to VPS | ||
| 166 | client, err := ssh.Connect(*host) | ||
| 167 | if err != nil { | ||
| 168 | fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err) | ||
| 169 | os.Exit(1) | ||
| 170 | } | ||
| 171 | defer client.Close() | ||
| 172 | |||
| 173 | // Build journalctl command | ||
| 174 | cmd := fmt.Sprintf("journalctl -u %s -n %d", name, *lines) | ||
| 175 | if *follow { | ||
| 176 | cmd += " -f" | ||
| 177 | } | ||
| 178 | |||
| 179 | // Run command | ||
| 180 | if *follow { | ||
| 181 | // Stream output for follow mode (no sudo needed for journalctl) | ||
| 182 | if err := client.RunStream(cmd); err != nil { | ||
| 183 | fmt.Fprintf(os.Stderr, "Error fetching logs: %v\n", err) | ||
| 184 | os.Exit(1) | ||
| 185 | } | ||
| 186 | } else { | ||
| 187 | // Buffer output for non-follow mode (no sudo needed for journalctl) | ||
| 188 | output, err := client.Run(cmd) | ||
| 189 | if err != nil { | ||
| 190 | fmt.Fprintf(os.Stderr, "Error fetching logs: %v\n", err) | ||
| 191 | os.Exit(1) | ||
| 192 | } | ||
| 193 | fmt.Print(output) | ||
| 194 | } | ||
| 195 | } | ||
| 196 | |||
| 197 | func runStatus(args []string) { | ||
| 198 | fs := flag.NewFlagSet("status", flag.ExitOnError) | ||
| 199 | host := fs.String("host", "", "VPS host (SSH config alias or user@host)") | ||
| 200 | fs.Parse(args) | ||
| 201 | |||
| 202 | if len(fs.Args()) == 0 { | ||
| 203 | fmt.Fprintf(os.Stderr, "Error: app name is required\n") | ||
| 204 | fmt.Fprintf(os.Stderr, "Usage: deploy status <app-name> --host user@vps-ip\n") | ||
| 205 | os.Exit(1) | ||
| 206 | } | ||
| 207 | |||
| 208 | name := fs.Args()[0] | ||
| 209 | |||
| 210 | // Get host from flag or config | ||
| 211 | if *host == "" { | ||
| 212 | cfg, err := config.Load() | ||
| 213 | if err != nil { | ||
| 214 | fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err) | ||
| 215 | os.Exit(1) | ||
| 216 | } | ||
| 217 | *host = cfg.Host | ||
| 218 | } | ||
| 219 | |||
| 220 | if *host == "" { | ||
| 221 | fmt.Fprintf(os.Stderr, "Error: --host is required\n") | ||
| 222 | fs.Usage() | ||
| 223 | os.Exit(1) | ||
| 224 | } | ||
| 225 | |||
| 226 | // Load state to verify app exists | ||
| 227 | st, err := state.Load() | ||
| 228 | if err != nil { | ||
| 229 | fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) | ||
| 230 | os.Exit(1) | ||
| 231 | } | ||
| 232 | |||
| 233 | app, err := st.GetApp(*host, name) | ||
| 234 | if err != nil { | ||
| 235 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) | ||
| 236 | os.Exit(1) | ||
| 237 | } | ||
| 238 | |||
| 239 | if app.Type != "app" { | ||
| 240 | fmt.Fprintf(os.Stderr, "Error: status is only available for apps, not static sites\n") | ||
| 241 | os.Exit(1) | ||
| 242 | } | ||
| 243 | |||
| 244 | // Connect to VPS | ||
| 245 | client, err := ssh.Connect(*host) | ||
| 246 | if err != nil { | ||
| 247 | fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err) | ||
| 248 | os.Exit(1) | ||
| 249 | } | ||
| 250 | defer client.Close() | ||
| 251 | |||
| 252 | // Get status | ||
| 253 | output, err := client.RunSudo(fmt.Sprintf("systemctl status %s", name)) | ||
| 254 | if err != nil { | ||
| 255 | // systemctl status returns non-zero for non-active services | ||
| 256 | // but we still want to show the output | ||
| 257 | fmt.Print(output) | ||
| 258 | return | ||
| 259 | } | ||
| 260 | |||
| 261 | fmt.Print(output) | ||
| 262 | } | ||
| 263 | |||
| 264 | func runRestart(args []string) { | ||
| 265 | fs := flag.NewFlagSet("restart", flag.ExitOnError) | ||
| 266 | host := fs.String("host", "", "VPS host (SSH config alias or user@host)") | ||
| 267 | fs.Parse(args) | ||
| 268 | |||
| 269 | if len(fs.Args()) == 0 { | ||
| 270 | fmt.Fprintf(os.Stderr, "Error: app name is required\n") | ||
| 271 | fmt.Fprintf(os.Stderr, "Usage: deploy restart <app-name> --host user@vps-ip\n") | ||
| 272 | os.Exit(1) | ||
| 273 | } | ||
| 274 | |||
| 275 | name := fs.Args()[0] | ||
| 276 | |||
| 277 | // Get host from flag or config | ||
| 278 | if *host == "" { | ||
| 279 | cfg, err := config.Load() | ||
| 280 | if err != nil { | ||
| 281 | fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err) | ||
| 282 | os.Exit(1) | ||
| 283 | } | ||
| 284 | *host = cfg.Host | ||
| 285 | } | ||
| 286 | |||
| 287 | if *host == "" { | ||
| 288 | fmt.Fprintf(os.Stderr, "Error: --host is required\n") | ||
| 289 | fs.Usage() | ||
| 290 | os.Exit(1) | ||
| 291 | } | ||
| 292 | |||
| 293 | // Load state to verify app exists | ||
| 294 | st, err := state.Load() | ||
| 295 | if err != nil { | ||
| 296 | fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) | ||
| 297 | os.Exit(1) | ||
| 298 | } | ||
| 299 | |||
| 300 | app, err := st.GetApp(*host, name) | ||
| 301 | if err != nil { | ||
| 302 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) | ||
| 303 | os.Exit(1) | ||
| 304 | } | ||
| 305 | |||
| 306 | if app.Type != "app" { | ||
| 307 | fmt.Fprintf(os.Stderr, "Error: restart is only available for apps, not static sites\n") | ||
| 308 | os.Exit(1) | ||
| 309 | } | ||
| 310 | |||
| 311 | // Connect to VPS | ||
| 312 | client, err := ssh.Connect(*host) | ||
| 313 | if err != nil { | ||
| 314 | fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err) | ||
| 315 | os.Exit(1) | ||
| 316 | } | ||
| 317 | defer client.Close() | ||
| 318 | |||
| 319 | // Restart service | ||
| 320 | fmt.Printf("Restarting %s...\n", name) | ||
| 321 | if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { | ||
| 322 | fmt.Fprintf(os.Stderr, "Error restarting service: %v\n", err) | ||
| 323 | os.Exit(1) | ||
| 324 | } | ||
| 325 | |||
| 326 | fmt.Println("✓ Service restarted successfully") | ||
| 327 | } | ||
diff --git a/cmd/deploy/templates/webui.html b/cmd/deploy/templates/webui.html new file mode 100644 index 0000000..052d599 --- /dev/null +++ b/cmd/deploy/templates/webui.html | |||
| @@ -0,0 +1,440 @@ | |||
| 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>Deploy - Web UI</title> | ||
| 7 | <style> | ||
| 8 | * { | ||
| 9 | margin: 0; | ||
| 10 | padding: 0; | ||
| 11 | box-sizing: border-box; | ||
| 12 | } | ||
| 13 | |||
| 14 | body { | ||
| 15 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; | ||
| 16 | background: #f5f5f5; | ||
| 17 | color: #333; | ||
| 18 | line-height: 1.6; | ||
| 19 | } | ||
| 20 | |||
| 21 | header { | ||
| 22 | background: #2c3e50; | ||
| 23 | color: white; | ||
| 24 | padding: 1.5rem 2rem; | ||
| 25 | box-shadow: 0 2px 4px rgba(0,0,0,0.1); | ||
| 26 | } | ||
| 27 | |||
| 28 | header h1 { | ||
| 29 | font-size: 1.8rem; | ||
| 30 | font-weight: 600; | ||
| 31 | } | ||
| 32 | |||
| 33 | header p { | ||
| 34 | color: #bdc3c7; | ||
| 35 | margin-top: 0.25rem; | ||
| 36 | font-size: 0.9rem; | ||
| 37 | } | ||
| 38 | |||
| 39 | .container { | ||
| 40 | max-width: 1200px; | ||
| 41 | margin: 2rem auto; | ||
| 42 | padding: 0 2rem; | ||
| 43 | } | ||
| 44 | |||
| 45 | .empty-state { | ||
| 46 | text-align: center; | ||
| 47 | padding: 4rem 2rem; | ||
| 48 | background: white; | ||
| 49 | border-radius: 8px; | ||
| 50 | box-shadow: 0 2px 4px rgba(0,0,0,0.1); | ||
| 51 | } | ||
| 52 | |||
| 53 | .empty-state h2 { | ||
| 54 | color: #7f8c8d; | ||
| 55 | font-weight: 500; | ||
| 56 | margin-bottom: 0.5rem; | ||
| 57 | } | ||
| 58 | |||
| 59 | .empty-state p { | ||
| 60 | color: #95a5a6; | ||
| 61 | } | ||
| 62 | |||
| 63 | .host-section { | ||
| 64 | margin-bottom: 2rem; | ||
| 65 | } | ||
| 66 | |||
| 67 | .host-header { | ||
| 68 | background: white; | ||
| 69 | padding: 1rem 1.5rem; | ||
| 70 | border-radius: 8px 8px 0 0; | ||
| 71 | border-left: 4px solid #3498db; | ||
| 72 | box-shadow: 0 2px 4px rgba(0,0,0,0.1); | ||
| 73 | } | ||
| 74 | |||
| 75 | .host-header h2 { | ||
| 76 | font-size: 1.3rem; | ||
| 77 | color: #2c3e50; | ||
| 78 | font-weight: 600; | ||
| 79 | } | ||
| 80 | |||
| 81 | .apps-grid { | ||
| 82 | display: grid; | ||
| 83 | grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); | ||
| 84 | gap: 1rem; | ||
| 85 | padding: 1rem; | ||
| 86 | background: #ecf0f1; | ||
| 87 | border-radius: 0 0 8px 8px; | ||
| 88 | } | ||
| 89 | |||
| 90 | .app-card { | ||
| 91 | background: white; | ||
| 92 | padding: 1.5rem; | ||
| 93 | border-radius: 6px; | ||
| 94 | box-shadow: 0 1px 3px rgba(0,0,0,0.1); | ||
| 95 | transition: transform 0.2s, box-shadow 0.2s; | ||
| 96 | } | ||
| 97 | |||
| 98 | .app-card:hover { | ||
| 99 | transform: translateY(-2px); | ||
| 100 | box-shadow: 0 4px 8px rgba(0,0,0,0.15); | ||
| 101 | } | ||
| 102 | |||
| 103 | .app-header { | ||
| 104 | display: flex; | ||
| 105 | justify-content: space-between; | ||
| 106 | align-items: center; | ||
| 107 | margin-bottom: 1rem; | ||
| 108 | } | ||
| 109 | |||
| 110 | .app-name { | ||
| 111 | font-size: 1.2rem; | ||
| 112 | font-weight: 600; | ||
| 113 | color: #2c3e50; | ||
| 114 | } | ||
| 115 | |||
| 116 | .app-type { | ||
| 117 | padding: 0.25rem 0.75rem; | ||
| 118 | border-radius: 12px; | ||
| 119 | font-size: 0.75rem; | ||
| 120 | font-weight: 500; | ||
| 121 | text-transform: uppercase; | ||
| 122 | } | ||
| 123 | |||
| 124 | .app-type.app { | ||
| 125 | background: #3498db; | ||
| 126 | color: white; | ||
| 127 | } | ||
| 128 | |||
| 129 | .app-type.static { | ||
| 130 | background: #2ecc71; | ||
| 131 | color: white; | ||
| 132 | } | ||
| 133 | |||
| 134 | .app-info { | ||
| 135 | margin-bottom: 0.5rem; | ||
| 136 | } | ||
| 137 | |||
| 138 | .app-info-label { | ||
| 139 | color: #7f8c8d; | ||
| 140 | font-size: 0.85rem; | ||
| 141 | font-weight: 500; | ||
| 142 | margin-bottom: 0.25rem; | ||
| 143 | } | ||
| 144 | |||
| 145 | .app-info-value { | ||
| 146 | color: #2c3e50; | ||
| 147 | font-family: 'Monaco', 'Courier New', monospace; | ||
| 148 | font-size: 0.9rem; | ||
| 149 | word-break: break-all; | ||
| 150 | } | ||
| 151 | |||
| 152 | .app-info-value a { | ||
| 153 | color: #3498db; | ||
| 154 | text-decoration: none; | ||
| 155 | } | ||
| 156 | |||
| 157 | .app-info-value a:hover { | ||
| 158 | text-decoration: underline; | ||
| 159 | } | ||
| 160 | |||
| 161 | .config-buttons { | ||
| 162 | margin-top: 1rem; | ||
| 163 | padding-top: 1rem; | ||
| 164 | border-top: 1px solid #ecf0f1; | ||
| 165 | display: flex; | ||
| 166 | gap: 0.5rem; | ||
| 167 | flex-wrap: wrap; | ||
| 168 | } | ||
| 169 | |||
| 170 | .config-btn { | ||
| 171 | padding: 0.4rem 0.8rem; | ||
| 172 | background: #3498db; | ||
| 173 | color: white; | ||
| 174 | border: none; | ||
| 175 | border-radius: 4px; | ||
| 176 | font-size: 0.8rem; | ||
| 177 | cursor: pointer; | ||
| 178 | transition: background 0.2s; | ||
| 179 | } | ||
| 180 | |||
| 181 | .config-btn:hover { | ||
| 182 | background: #2980b9; | ||
| 183 | } | ||
| 184 | |||
| 185 | .config-btn.secondary { | ||
| 186 | background: #95a5a6; | ||
| 187 | } | ||
| 188 | |||
| 189 | .config-btn.secondary:hover { | ||
| 190 | background: #7f8c8d; | ||
| 191 | } | ||
| 192 | |||
| 193 | .modal { | ||
| 194 | display: none; | ||
| 195 | position: fixed; | ||
| 196 | z-index: 1000; | ||
| 197 | left: 0; | ||
| 198 | top: 0; | ||
| 199 | width: 100%; | ||
| 200 | height: 100%; | ||
| 201 | overflow: auto; | ||
| 202 | background-color: rgba(0,0,0,0.6); | ||
| 203 | } | ||
| 204 | |||
| 205 | .modal.active { | ||
| 206 | display: block; | ||
| 207 | } | ||
| 208 | |||
| 209 | .modal-content { | ||
| 210 | background-color: #fefefe; | ||
| 211 | margin: 5% auto; | ||
| 212 | padding: 0; | ||
| 213 | border-radius: 8px; | ||
| 214 | width: 90%; | ||
| 215 | max-width: 900px; | ||
| 216 | max-height: 80vh; | ||
| 217 | display: flex; | ||
| 218 | flex-direction: column; | ||
| 219 | box-shadow: 0 4px 20px rgba(0,0,0,0.3); | ||
| 220 | } | ||
| 221 | |||
| 222 | .modal-header { | ||
| 223 | padding: 1.5rem; | ||
| 224 | border-bottom: 1px solid #ecf0f1; | ||
| 225 | display: flex; | ||
| 226 | justify-content: space-between; | ||
| 227 | align-items: center; | ||
| 228 | } | ||
| 229 | |||
| 230 | .modal-header h3 { | ||
| 231 | margin: 0; | ||
| 232 | color: #2c3e50; | ||
| 233 | } | ||
| 234 | |||
| 235 | .modal-path { | ||
| 236 | font-family: 'Monaco', 'Courier New', monospace; | ||
| 237 | font-size: 0.85rem; | ||
| 238 | color: #7f8c8d; | ||
| 239 | margin-top: 0.25rem; | ||
| 240 | } | ||
| 241 | |||
| 242 | .close { | ||
| 243 | color: #aaa; | ||
| 244 | font-size: 28px; | ||
| 245 | font-weight: bold; | ||
| 246 | cursor: pointer; | ||
| 247 | line-height: 1; | ||
| 248 | } | ||
| 249 | |||
| 250 | .close:hover { | ||
| 251 | color: #000; | ||
| 252 | } | ||
| 253 | |||
| 254 | .modal-body { | ||
| 255 | padding: 1.5rem; | ||
| 256 | overflow: auto; | ||
| 257 | flex: 1; | ||
| 258 | } | ||
| 259 | |||
| 260 | .config-content { | ||
| 261 | background: #282c34; | ||
| 262 | color: #abb2bf; | ||
| 263 | padding: 1rem; | ||
| 264 | border-radius: 4px; | ||
| 265 | font-family: 'Monaco', 'Courier New', monospace; | ||
| 266 | font-size: 0.85rem; | ||
| 267 | line-height: 1.5; | ||
| 268 | white-space: pre-wrap; | ||
| 269 | word-wrap: break-word; | ||
| 270 | overflow-x: auto; | ||
| 271 | text-align: left; | ||
| 272 | } | ||
| 273 | |||
| 274 | .loading { | ||
| 275 | text-align: center; | ||
| 276 | padding: 2rem; | ||
| 277 | color: #7f8c8d; | ||
| 278 | } | ||
| 279 | |||
| 280 | .refresh-info { | ||
| 281 | text-align: center; | ||
| 282 | color: #7f8c8d; | ||
| 283 | font-size: 0.9rem; | ||
| 284 | margin-top: 2rem; | ||
| 285 | padding: 1rem; | ||
| 286 | } | ||
| 287 | </style> | ||
| 288 | </head> | ||
| 289 | <body> | ||
| 290 | <header> | ||
| 291 | <h1>Deploy Web UI</h1> | ||
| 292 | <p>Manage your VPS deployments</p> | ||
| 293 | </header> | ||
| 294 | |||
| 295 | <div class="container"> | ||
| 296 | {{if not .Hosts}} | ||
| 297 | <div class="empty-state"> | ||
| 298 | <h2>No deployments found</h2> | ||
| 299 | <p>Use the CLI to deploy your first app or static site</p> | ||
| 300 | </div> | ||
| 301 | {{else}} | ||
| 302 | {{range .Hosts}} | ||
| 303 | <div class="host-section"> | ||
| 304 | <div class="host-header"> | ||
| 305 | <h2>{{.Host}}</h2> | ||
| 306 | </div> | ||
| 307 | <div class="apps-grid"> | ||
| 308 | {{range .Apps}} | ||
| 309 | <div class="app-card"> | ||
| 310 | <div class="app-header"> | ||
| 311 | <div class="app-name">{{.Name}}</div> | ||
| 312 | <div class="app-type {{.Type}}">{{.Type}}</div> | ||
| 313 | </div> | ||
| 314 | |||
| 315 | <div class="app-info"> | ||
| 316 | <div class="app-info-label">Domain</div> | ||
| 317 | <div class="app-info-value"> | ||
| 318 | <a href="https://{{.Domain}}" target="_blank">{{.Domain}}</a> | ||
| 319 | </div> | ||
| 320 | </div> | ||
| 321 | |||
| 322 | {{if eq .Type "app"}} | ||
| 323 | <div class="app-info"> | ||
| 324 | <div class="app-info-label">Port</div> | ||
| 325 | <div class="app-info-value">{{.Port}}</div> | ||
| 326 | </div> | ||
| 327 | {{end}} | ||
| 328 | |||
| 329 | <div class="config-buttons"> | ||
| 330 | {{if eq .Type "app"}} | ||
| 331 | <button class="config-btn" onclick="showConfig('{{.Host}}', '{{.Name}}', 'systemd')">Systemd Unit</button> | ||
| 332 | {{end}} | ||
| 333 | <button class="config-btn" onclick="showConfig('{{.Host}}', '{{.Name}}', 'caddy')">Caddy Config</button> | ||
| 334 | {{if .Env}} | ||
| 335 | <button class="config-btn secondary" onclick="showConfig('{{.Host}}', '{{.Name}}', 'env')">Environment</button> | ||
| 336 | {{end}} | ||
| 337 | </div> | ||
| 338 | </div> | ||
| 339 | {{end}} | ||
| 340 | </div> | ||
| 341 | </div> | ||
| 342 | {{end}} | ||
| 343 | {{end}} | ||
| 344 | |||
| 345 | <div class="refresh-info"> | ||
| 346 | Refresh the page to see latest changes | ||
| 347 | </div> | ||
| 348 | </div> | ||
| 349 | |||
| 350 | <!-- Modal --> | ||
| 351 | <div id="configModal" class="modal"> | ||
| 352 | <div class="modal-content"> | ||
| 353 | <div class="modal-header"> | ||
| 354 | <div> | ||
| 355 | <h3 id="modalTitle">Configuration</h3> | ||
| 356 | <div class="modal-path" id="modalPath"></div> | ||
| 357 | </div> | ||
| 358 | <span class="close" onclick="closeModal()">×</span> | ||
| 359 | </div> | ||
| 360 | <div class="modal-body"> | ||
| 361 | <div id="modalContent" class="loading">Loading...</div> | ||
| 362 | </div> | ||
| 363 | </div> | ||
| 364 | </div> | ||
| 365 | |||
| 366 | <script> | ||
| 367 | const modal = document.getElementById('configModal'); | ||
| 368 | const modalTitle = document.getElementById('modalTitle'); | ||
| 369 | const modalPath = document.getElementById('modalPath'); | ||
| 370 | const modalContent = document.getElementById('modalContent'); | ||
| 371 | |||
| 372 | function closeModal() { | ||
| 373 | modal.classList.remove('active'); | ||
| 374 | } | ||
| 375 | |||
| 376 | window.onclick = function(event) { | ||
| 377 | if (event.target == modal) { | ||
| 378 | closeModal(); | ||
| 379 | } | ||
| 380 | } | ||
| 381 | |||
| 382 | async function showConfig(host, app, type) { | ||
| 383 | modal.classList.add('active'); | ||
| 384 | modalContent.innerHTML = '<div class="loading">Loading...</div>'; | ||
| 385 | |||
| 386 | const titles = { | ||
| 387 | 'systemd': 'Systemd Service Unit', | ||
| 388 | 'caddy': 'Caddy Configuration', | ||
| 389 | 'env': 'Environment Variables' | ||
| 390 | }; | ||
| 391 | |||
| 392 | modalTitle.textContent = titles[type]; | ||
| 393 | |||
| 394 | try { | ||
| 395 | const response = await fetch(`/api/configs?host=${encodeURIComponent(host)}&app=${encodeURIComponent(app)}`); | ||
| 396 | if (!response.ok) { | ||
| 397 | throw new Error(`HTTP error! status: ${response.status}`); | ||
| 398 | } | ||
| 399 | const configs = await response.json(); | ||
| 400 | |||
| 401 | let content = ''; | ||
| 402 | let path = ''; | ||
| 403 | |||
| 404 | switch(type) { | ||
| 405 | case 'systemd': | ||
| 406 | content = configs.systemd || 'No systemd config available'; | ||
| 407 | path = configs.systemdPath || ''; | ||
| 408 | break; | ||
| 409 | case 'caddy': | ||
| 410 | content = configs.caddy || 'No Caddy config available'; | ||
| 411 | path = configs.caddyPath || ''; | ||
| 412 | break; | ||
| 413 | case 'env': | ||
| 414 | content = configs.env || 'No environment variables'; | ||
| 415 | path = configs.envPath || ''; | ||
| 416 | break; | ||
| 417 | } | ||
| 418 | |||
| 419 | modalPath.textContent = path; | ||
| 420 | modalContent.innerHTML = `<div class="config-content">${escapeHtml(content)}</div>`; | ||
| 421 | } catch (error) { | ||
| 422 | modalContent.innerHTML = `<div class="loading">Error loading config: ${error.message}</div>`; | ||
| 423 | } | ||
| 424 | } | ||
| 425 | |||
| 426 | function escapeHtml(text) { | ||
| 427 | const div = document.createElement('div'); | ||
| 428 | div.textContent = text; | ||
| 429 | return div.innerHTML; | ||
| 430 | } | ||
| 431 | |||
| 432 | // Close modal with Escape key | ||
| 433 | document.addEventListener('keydown', function(event) { | ||
| 434 | if (event.key === 'Escape') { | ||
| 435 | closeModal(); | ||
| 436 | } | ||
| 437 | }); | ||
| 438 | </script> | ||
| 439 | </body> | ||
| 440 | </html> | ||
diff --git a/cmd/deploy/webui.go b/cmd/deploy/webui.go new file mode 100644 index 0000000..f57400e --- /dev/null +++ b/cmd/deploy/webui.go | |||
| @@ -0,0 +1,228 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "embed" | ||
| 5 | "encoding/json" | ||
| 6 | "flag" | ||
| 7 | "fmt" | ||
| 8 | "html/template" | ||
| 9 | "net/http" | ||
| 10 | "os" | ||
| 11 | "sort" | ||
| 12 | "strconv" | ||
| 13 | |||
| 14 | "github.com/bdw/deploy/internal/state" | ||
| 15 | "github.com/bdw/deploy/internal/templates" | ||
| 16 | ) | ||
| 17 | |||
| 18 | //go:embed templates/*.html | ||
| 19 | var templatesFS embed.FS | ||
| 20 | |||
| 21 | func runWebUI(args []string) { | ||
| 22 | fs := flag.NewFlagSet("webui", flag.ExitOnError) | ||
| 23 | port := fs.String("port", "8080", "Port to run the web UI on") | ||
| 24 | help := fs.Bool("h", false, "Show help") | ||
| 25 | |||
| 26 | fs.Parse(args) | ||
| 27 | |||
| 28 | if *help { | ||
| 29 | fmt.Fprintf(os.Stderr, `Usage: deploy webui [flags] | ||
| 30 | |||
| 31 | Launch a web interface to view and manage deployments. | ||
| 32 | |||
| 33 | FLAGS: | ||
| 34 | -port string | ||
| 35 | Port to run the web UI on (default "8080") | ||
| 36 | -h Show this help message | ||
| 37 | |||
| 38 | EXAMPLE: | ||
| 39 | # Launch web UI on default port (8080) | ||
| 40 | deploy webui | ||
| 41 | |||
| 42 | # Launch on custom port | ||
| 43 | deploy webui -port 3000 | ||
| 44 | `) | ||
| 45 | os.Exit(0) | ||
| 46 | } | ||
| 47 | |||
| 48 | // Parse template | ||
| 49 | tmpl, err := template.ParseFS(templatesFS, "templates/webui.html") | ||
| 50 | if err != nil { | ||
| 51 | fmt.Fprintf(os.Stderr, "Error parsing template: %v\n", err) | ||
| 52 | os.Exit(1) | ||
| 53 | } | ||
| 54 | |||
| 55 | // Handler for the main page | ||
| 56 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { | ||
| 57 | // Reload state on each request to show latest changes | ||
| 58 | st, err := state.Load() | ||
| 59 | if err != nil { | ||
| 60 | http.Error(w, fmt.Sprintf("Error loading state: %v", err), http.StatusInternalServerError) | ||
| 61 | return | ||
| 62 | } | ||
| 63 | |||
| 64 | // Prepare data for template | ||
| 65 | type AppData struct { | ||
| 66 | Name string | ||
| 67 | Type string | ||
| 68 | Domain string | ||
| 69 | Port int | ||
| 70 | Env map[string]string | ||
| 71 | Host string | ||
| 72 | } | ||
| 73 | |||
| 74 | type HostData struct { | ||
| 75 | Host string | ||
| 76 | Apps []AppData | ||
| 77 | } | ||
| 78 | |||
| 79 | var hosts []HostData | ||
| 80 | for hostName, host := range st.Hosts { | ||
| 81 | var apps []AppData | ||
| 82 | for appName, app := range host.Apps { | ||
| 83 | apps = append(apps, AppData{ | ||
| 84 | Name: appName, | ||
| 85 | Type: app.Type, | ||
| 86 | Domain: app.Domain, | ||
| 87 | Port: app.Port, | ||
| 88 | Env: app.Env, | ||
| 89 | Host: hostName, | ||
| 90 | }) | ||
| 91 | } | ||
| 92 | |||
| 93 | // Sort apps by name | ||
| 94 | sort.Slice(apps, func(i, j int) bool { | ||
| 95 | return apps[i].Name < apps[j].Name | ||
| 96 | }) | ||
| 97 | |||
| 98 | hosts = append(hosts, HostData{ | ||
| 99 | Host: hostName, | ||
| 100 | Apps: apps, | ||
| 101 | }) | ||
| 102 | } | ||
| 103 | |||
| 104 | // Sort hosts by name | ||
| 105 | sort.Slice(hosts, func(i, j int) bool { | ||
| 106 | return hosts[i].Host < hosts[j].Host | ||
| 107 | }) | ||
| 108 | |||
| 109 | data := struct { | ||
| 110 | Hosts []HostData | ||
| 111 | }{ | ||
| 112 | Hosts: hosts, | ||
| 113 | } | ||
| 114 | |||
| 115 | if err := tmpl.Execute(w, data); err != nil { | ||
| 116 | http.Error(w, fmt.Sprintf("Error rendering template: %v", err), http.StatusInternalServerError) | ||
| 117 | return | ||
| 118 | } | ||
| 119 | }) | ||
| 120 | |||
| 121 | // API endpoint to get state as JSON | ||
| 122 | http.HandleFunc("/api/state", func(w http.ResponseWriter, r *http.Request) { | ||
| 123 | st, err := state.Load() | ||
| 124 | if err != nil { | ||
| 125 | http.Error(w, fmt.Sprintf("Error loading state: %v", err), http.StatusInternalServerError) | ||
| 126 | return | ||
| 127 | } | ||
| 128 | |||
| 129 | w.Header().Set("Content-Type", "application/json") | ||
| 130 | json.NewEncoder(w).Encode(st) | ||
| 131 | }) | ||
| 132 | |||
| 133 | // API endpoint to get rendered configs for an app | ||
| 134 | http.HandleFunc("/api/configs", func(w http.ResponseWriter, r *http.Request) { | ||
| 135 | host := r.URL.Query().Get("host") | ||
| 136 | appName := r.URL.Query().Get("app") | ||
| 137 | |||
| 138 | if host == "" || appName == "" { | ||
| 139 | http.Error(w, "Missing host or app parameter", http.StatusBadRequest) | ||
| 140 | return | ||
| 141 | } | ||
| 142 | |||
| 143 | st, err := state.Load() | ||
| 144 | if err != nil { | ||
| 145 | http.Error(w, fmt.Sprintf("Error loading state: %v", err), http.StatusInternalServerError) | ||
| 146 | return | ||
| 147 | } | ||
| 148 | |||
| 149 | app, err := st.GetApp(host, appName) | ||
| 150 | if err != nil { | ||
| 151 | http.Error(w, fmt.Sprintf("App not found: %v", err), http.StatusNotFound) | ||
| 152 | return | ||
| 153 | } | ||
| 154 | |||
| 155 | configs := make(map[string]string) | ||
| 156 | |||
| 157 | // Render environment file | ||
| 158 | if app.Env != nil && len(app.Env) > 0 { | ||
| 159 | envContent := "" | ||
| 160 | for k, v := range app.Env { | ||
| 161 | envContent += fmt.Sprintf("%s=%s\n", k, v) | ||
| 162 | } | ||
| 163 | configs["env"] = envContent | ||
| 164 | configs["envPath"] = fmt.Sprintf("/etc/deploy/env/%s.env", appName) | ||
| 165 | } | ||
| 166 | |||
| 167 | // Render configs based on app type | ||
| 168 | if app.Type == "app" { | ||
| 169 | // Render systemd service | ||
| 170 | workDir := fmt.Sprintf("/var/lib/%s", appName) | ||
| 171 | binaryPath := fmt.Sprintf("/usr/local/bin/%s", appName) | ||
| 172 | envFilePath := fmt.Sprintf("/etc/deploy/env/%s.env", appName) | ||
| 173 | |||
| 174 | serviceContent, err := templates.SystemdService(map[string]string{ | ||
| 175 | "Name": appName, | ||
| 176 | "User": appName, | ||
| 177 | "WorkDir": workDir, | ||
| 178 | "BinaryPath": binaryPath, | ||
| 179 | "Port": strconv.Itoa(app.Port), | ||
| 180 | "EnvFile": envFilePath, | ||
| 181 | "Args": app.Args, | ||
| 182 | }) | ||
| 183 | if err != nil { | ||
| 184 | http.Error(w, fmt.Sprintf("Error rendering systemd service: %v", err), http.StatusInternalServerError) | ||
| 185 | return | ||
| 186 | } | ||
| 187 | configs["systemd"] = serviceContent | ||
| 188 | configs["systemdPath"] = fmt.Sprintf("/etc/systemd/system/%s.service", appName) | ||
| 189 | |||
| 190 | // Render Caddy config | ||
| 191 | caddyContent, err := templates.AppCaddy(map[string]string{ | ||
| 192 | "Domain": app.Domain, | ||
| 193 | "Port": strconv.Itoa(app.Port), | ||
| 194 | }) | ||
| 195 | if err != nil { | ||
| 196 | http.Error(w, fmt.Sprintf("Error rendering Caddy config: %v", err), http.StatusInternalServerError) | ||
| 197 | return | ||
| 198 | } | ||
| 199 | configs["caddy"] = caddyContent | ||
| 200 | configs["caddyPath"] = fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", appName) | ||
| 201 | } else if app.Type == "static" { | ||
| 202 | // Render Caddy config for static site | ||
| 203 | remoteDir := fmt.Sprintf("/var/www/%s", appName) | ||
| 204 | caddyContent, err := templates.StaticCaddy(map[string]string{ | ||
| 205 | "Domain": app.Domain, | ||
| 206 | "RootDir": remoteDir, | ||
| 207 | }) | ||
| 208 | if err != nil { | ||
| 209 | http.Error(w, fmt.Sprintf("Error rendering Caddy config: %v", err), http.StatusInternalServerError) | ||
| 210 | return | ||
| 211 | } | ||
| 212 | configs["caddy"] = caddyContent | ||
| 213 | configs["caddyPath"] = fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", appName) | ||
| 214 | } | ||
| 215 | |||
| 216 | w.Header().Set("Content-Type", "application/json") | ||
| 217 | json.NewEncoder(w).Encode(configs) | ||
| 218 | }) | ||
| 219 | |||
| 220 | addr := fmt.Sprintf("localhost:%s", *port) | ||
| 221 | fmt.Printf("Starting web UI on http://%s\n", addr) | ||
| 222 | fmt.Printf("Press Ctrl+C to stop\n") | ||
| 223 | |||
| 224 | if err := http.ListenAndServe(addr, nil); err != nil { | ||
| 225 | fmt.Fprintf(os.Stderr, "Error starting server: %v\n", err) | ||
| 226 | os.Exit(1) | ||
| 227 | } | ||
| 228 | } | ||
