package main import ( "bufio" "flag" "fmt" "os" "path/filepath" "strconv" "strings" "github.com/bdw/deploy/internal/ssh" "github.com/bdw/deploy/internal/state" "github.com/bdw/deploy/internal/templates" ) type envFlags []string func (e *envFlags) String() string { return strings.Join(*e, ",") } func (e *envFlags) Set(value string) error { *e = append(*e, value) return nil } type fileFlags []string func (f *fileFlags) String() string { return strings.Join(*f, ",") } func (f *fileFlags) Set(value string) error { *f = append(*f, value) return nil } func runDeploy(args []string) { fs := flag.NewFlagSet("deploy", flag.ExitOnError) host := fs.String("host", "", "VPS host (SSH config alias or user@host)") domain := fs.String("domain", "", "Domain name (required)") name := fs.String("name", "", "App name (default: inferred from binary or directory)") binary := fs.String("binary", "", "Path to Go binary (for app deployment)") static := fs.Bool("static", false, "Deploy as static site") dir := fs.String("dir", ".", "Directory to deploy (for static sites)") port := fs.Int("port", 0, "Port override (default: auto-allocate)") var envVars envFlags fs.Var(&envVars, "env", "Environment variable (KEY=VALUE, can be specified multiple times)") envFile := fs.String("env-file", "", "Path to .env file") binaryArgs := fs.String("args", "", "Arguments to pass to binary") var files fileFlags fs.Var(&files, "file", "Config file to upload to working directory (can be specified multiple times)") fs.Parse(args) // Get host from flag or state default if *host == "" { st, err := state.Load() if err != nil { fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) os.Exit(1) } *host = st.GetDefaultHost() } if *host == "" || *domain == "" { fmt.Fprintf(os.Stderr, "Error: --host and --domain are required\n") fs.Usage() os.Exit(1) } if *static { deployStatic(*host, *domain, *name, *dir) } else { deployApp(*host, *domain, *name, *binary, *port, envVars, *envFile, *binaryArgs, files) } } func deployApp(host, domain, name, binaryPath string, portOverride int, envVars []string, envFile, args string, files []string) { // Require binary path if binaryPath == "" { fmt.Fprintf(os.Stderr, "Error: --binary is required\n") os.Exit(1) } // Determine app name from binary if not specified if name == "" { name = filepath.Base(binaryPath) } // Verify binary exists if _, err := os.Stat(binaryPath); err != nil { fmt.Fprintf(os.Stderr, "Error: binary not found: %s\n", binaryPath) os.Exit(1) } fmt.Printf("Deploying app: %s\n", name) fmt.Printf(" Domain: %s\n", domain) fmt.Printf(" Binary: %s\n", binaryPath) // Load state st, err := state.Load() if err != nil { fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) os.Exit(1) } // Check if app already exists (update) or new deployment existingApp, _ := st.GetApp(host, name) var port int if existingApp != nil { port = existingApp.Port fmt.Printf(" Updating existing deployment (port %d)\n", port) } else { if portOverride > 0 { port = portOverride } else { port = st.AllocatePort(host) } fmt.Printf(" Allocated port: %d\n", port) } // Parse environment variables env := make(map[string]string) if existingApp != nil { // Preserve existing env vars for k, v := range existingApp.Env { env[k] = v } // Preserve existing args if not provided if args == "" && existingApp.Args != "" { args = existingApp.Args } // Preserve existing files if not provided if len(files) == 0 && len(existingApp.Files) > 0 { files = existingApp.Files } } // Add/override from flags for _, e := range envVars { parts := strings.SplitN(e, "=", 2) if len(parts) == 2 { env[parts[0]] = parts[1] } } // Add/override from file if envFile != "" { fileEnv, err := parseEnvFile(envFile) if err != nil { fmt.Fprintf(os.Stderr, "Error reading env file: %v\n", err) os.Exit(1) } for k, v := range fileEnv { env[k] = v } } // Always set PORT env["PORT"] = strconv.Itoa(port) // Connect to VPS client, err := ssh.Connect(host) if err != nil { fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err) os.Exit(1) } defer client.Close() // Upload binary fmt.Println("→ Uploading binary...") remoteTmpPath := fmt.Sprintf("/tmp/%s", name) if err := client.Upload(binaryPath, remoteTmpPath); err != nil { fmt.Fprintf(os.Stderr, "Error uploading binary: %v\n", err) os.Exit(1) } // Create user (ignore error if already exists) fmt.Println("→ Creating system user...") client.RunSudo(fmt.Sprintf("useradd -r -s /bin/false %s", name)) // Create working directory fmt.Println("→ Setting up directories...") workDir := fmt.Sprintf("/var/lib/%s", name) if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", workDir)); err != nil { fmt.Fprintf(os.Stderr, "Error creating work directory: %v\n", err) os.Exit(1) } if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, workDir)); err != nil { fmt.Fprintf(os.Stderr, "Error setting work directory ownership: %v\n", err) os.Exit(1) } // Move binary to /usr/local/bin fmt.Println("→ Installing binary...") binaryDest := fmt.Sprintf("/usr/local/bin/%s", name) if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", remoteTmpPath, binaryDest)); err != nil { fmt.Fprintf(os.Stderr, "Error moving binary: %v\n", err) os.Exit(1) } if _, err := client.RunSudo(fmt.Sprintf("chmod +x %s", binaryDest)); err != nil { fmt.Fprintf(os.Stderr, "Error making binary executable: %v\n", err) os.Exit(1) } // Upload config files to working directory if len(files) > 0 { fmt.Println("→ Uploading config files...") for _, file := range files { // Verify file exists locally if _, err := os.Stat(file); err != nil { fmt.Fprintf(os.Stderr, "Error: config file not found: %s\n", file) os.Exit(1) } // Determine remote path (preserve filename) remotePath := fmt.Sprintf("%s/%s", workDir, filepath.Base(file)) remoteTmpPath := fmt.Sprintf("/tmp/%s_%s", name, filepath.Base(file)) // Upload to tmp first if err := client.Upload(file, remoteTmpPath); err != nil { fmt.Fprintf(os.Stderr, "Error uploading config file %s: %v\n", file, err) os.Exit(1) } // Move to working directory if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", remoteTmpPath, remotePath)); err != nil { fmt.Fprintf(os.Stderr, "Error moving config file %s: %v\n", file, err) os.Exit(1) } // Set ownership if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, remotePath)); err != nil { fmt.Fprintf(os.Stderr, "Error setting config file ownership %s: %v\n", file, err) os.Exit(1) } fmt.Printf(" Uploaded: %s\n", file) } } // Create env file fmt.Println("→ Creating environment file...") envFilePath := fmt.Sprintf("/etc/deploy/env/%s.env", name) envContent := "" for k, v := range env { envContent += fmt.Sprintf("%s=%s\n", k, v) } if err := client.WriteSudoFile(envFilePath, envContent); err != nil { fmt.Fprintf(os.Stderr, "Error creating env file: %v\n", err) os.Exit(1) } if _, err := client.RunSudo(fmt.Sprintf("chmod 600 %s", envFilePath)); err != nil { fmt.Fprintf(os.Stderr, "Error setting env file permissions: %v\n", err) os.Exit(1) } if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, envFilePath)); err != nil { fmt.Fprintf(os.Stderr, "Error setting env file ownership: %v\n", err) os.Exit(1) } // Generate systemd unit fmt.Println("→ Creating systemd service...") serviceContent, err := templates.SystemdService(map[string]string{ "Name": name, "User": name, "WorkDir": workDir, "BinaryPath": binaryDest, "Port": strconv.Itoa(port), "EnvFile": envFilePath, "Args": args, }) if err != nil { fmt.Fprintf(os.Stderr, "Error generating systemd unit: %v\n", err) os.Exit(1) } servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name) if err := client.WriteSudoFile(servicePath, serviceContent); err != nil { fmt.Fprintf(os.Stderr, "Error creating systemd unit: %v\n", err) os.Exit(1) } // Generate Caddy config fmt.Println("→ Configuring Caddy...") caddyContent, err := templates.AppCaddy(map[string]string{ "Domain": domain, "Port": strconv.Itoa(port), }) if err != nil { fmt.Fprintf(os.Stderr, "Error generating Caddy config: %v\n", err) os.Exit(1) } caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) if err := client.WriteSudoFile(caddyPath, caddyContent); err != nil { fmt.Fprintf(os.Stderr, "Error creating Caddy config: %v\n", err) os.Exit(1) } // Reload systemd fmt.Println("→ Reloading systemd...") if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { fmt.Fprintf(os.Stderr, "Error reloading systemd: %v\n", err) os.Exit(1) } // Enable and start service fmt.Println("→ Starting service...") if _, err := client.RunSudo(fmt.Sprintf("systemctl enable %s", name)); err != nil { fmt.Fprintf(os.Stderr, "Error enabling service: %v\n", err) os.Exit(1) } if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { fmt.Fprintf(os.Stderr, "Error starting service: %v\n", err) os.Exit(1) } // Reload Caddy fmt.Println("→ Reloading Caddy...") if _, err := client.RunSudo("systemctl reload caddy"); err != nil { fmt.Fprintf(os.Stderr, "Error reloading Caddy: %v\n", err) os.Exit(1) } // Update state st.AddApp(host, name, &state.App{ Type: "app", Domain: domain, Port: port, Env: env, Args: args, Files: files, }) if err := st.Save(); err != nil { fmt.Fprintf(os.Stderr, "Error saving state: %v\n", err) os.Exit(1) } fmt.Printf("\n✓ App deployed successfully!\n") fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", domain) } func deployStatic(host, domain, name, dir string) { // Determine site name (default to domain to avoid conflicts) if name == "" { name = domain } // Verify directory exists if _, err := os.Stat(dir); err != nil { fmt.Fprintf(os.Stderr, "Error: directory not found: %s\n", dir) os.Exit(1) } fmt.Printf("Deploying static site: %s\n", name) fmt.Printf(" Domain: %s\n", domain) fmt.Printf(" Directory: %s\n", dir) // Load state st, err := state.Load() if err != nil { fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) os.Exit(1) } // Connect to VPS client, err := ssh.Connect(host) if err != nil { fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err) os.Exit(1) } defer client.Close() // Create remote directory remoteDir := fmt.Sprintf("/var/www/%s", name) fmt.Println("→ Creating remote directory...") if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", remoteDir)); err != nil { fmt.Fprintf(os.Stderr, "Error creating remote directory: %v\n", err) os.Exit(1) } // Get current user for temporary ownership during upload currentUser, err := client.Run("whoami") if err != nil { fmt.Fprintf(os.Stderr, "Error getting current user: %v\n", err) os.Exit(1) } currentUser = strings.TrimSpace(currentUser) // Set ownership to current user for upload if _, err := client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", currentUser, currentUser, remoteDir)); err != nil { fmt.Fprintf(os.Stderr, "Error setting temporary ownership: %v\n", err) os.Exit(1) } // Upload files fmt.Println("→ Uploading files...") if err := client.UploadDir(dir, remoteDir); err != nil { fmt.Fprintf(os.Stderr, "Error uploading files: %v\n", err) os.Exit(1) } // Set ownership and permissions fmt.Println("→ Setting permissions...") if _, err := client.RunSudo(fmt.Sprintf("chown -R www-data:www-data %s", remoteDir)); err != nil { fmt.Fprintf(os.Stderr, "Error setting ownership: %v\n", err) os.Exit(1) } // Make files readable by all (755 for dirs, 644 for files) if _, err := client.RunSudo(fmt.Sprintf("chmod -R 755 %s", remoteDir)); err != nil { fmt.Fprintf(os.Stderr, "Error setting directory permissions: %v\n", err) os.Exit(1) } if _, err := client.RunSudo(fmt.Sprintf("find %s -type f -exec chmod 644 {} \\;", remoteDir)); err != nil { fmt.Fprintf(os.Stderr, "Error setting file permissions: %v\n", err) os.Exit(1) } // Generate Caddy config fmt.Println("→ Configuring Caddy...") caddyContent, err := templates.StaticCaddy(map[string]string{ "Domain": domain, "RootDir": remoteDir, }) if err != nil { fmt.Fprintf(os.Stderr, "Error generating Caddy config: %v\n", err) os.Exit(1) } caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) if err := client.WriteSudoFile(caddyPath, caddyContent); err != nil { fmt.Fprintf(os.Stderr, "Error creating Caddy config: %v\n", err) os.Exit(1) } // Reload Caddy fmt.Println("→ Reloading Caddy...") if _, err := client.RunSudo("systemctl reload caddy"); err != nil { fmt.Fprintf(os.Stderr, "Error reloading Caddy: %v\n", err) os.Exit(1) } // Update state st.AddApp(host, name, &state.App{ Type: "static", Domain: domain, }) if err := st.Save(); err != nil { fmt.Fprintf(os.Stderr, "Error saving state: %v\n", err) os.Exit(1) } fmt.Printf("\n✓ Static site deployed successfully!\n") fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", domain) } func parseEnvFile(path string) (map[string]string, error) { file, err := os.Open(path) if err != nil { return nil, err } defer file.Close() env := make(map[string]string) scanner := bufio.NewScanner(file) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } parts := strings.SplitN(line, "=", 2) if len(parts) == 2 { env[parts[0]] = parts[1] } } return env, scanner.Err() }