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/deploy/deploy.go | |
| 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/deploy/deploy.go')
| -rw-r--r-- | cmd/deploy/deploy.go | 494 |
1 files changed, 494 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 | } | ||
