package main import ( "bufio" "fmt" "os" "path/filepath" "strconv" "strings" "github.com/bdw/ship/internal/ssh" "github.com/bdw/ship/internal/state" "github.com/bdw/ship/internal/templates" "github.com/spf13/cobra" ) func runDeploy(cmd *cobra.Command, args []string) error { flags := cmd.Flags() binary, _ := flags.GetString("binary") static, _ := flags.GetBool("static") dir, _ := flags.GetString("dir") domain, _ := flags.GetString("domain") name, _ := flags.GetString("name") port, _ := flags.GetInt("port") envVars, _ := flags.GetStringArray("env") envFile, _ := flags.GetString("env-file") binaryArgs, _ := flags.GetString("args") files, _ := flags.GetStringArray("file") // Get host from flag or state default host := hostFlag if host == "" { st, err := state.Load() if err != nil { return fmt.Errorf("error loading state: %w", err) } host = st.GetDefaultHost() } // If no flags provided, show help if domain == "" && binary == "" && !static { return cmd.Help() } if host == "" { return fmt.Errorf("--host is required") } // Load state to check base domain st, err := state.Load() if err != nil { return fmt.Errorf("error loading state: %w", err) } hostState := st.GetHost(host) if domain == "" && hostState.BaseDomain == "" { return fmt.Errorf("--domain required (or configure base domain with 'ship host init --base-domain')") } // Infer name early so we can use it for subdomain generation inferredName := name if inferredName == "" { if static { inferredName = domain if inferredName == "" && hostState.BaseDomain != "" { inferredName = filepath.Base(dir) } } else { inferredName = filepath.Base(binary) } } // Generate subdomain if base domain configured var domains []string if hostState.BaseDomain != "" { domains = append(domains, inferredName+"."+hostState.BaseDomain) } if domain != "" { domains = append(domains, domain) } combinedDomains := strings.Join(domains, ", ") if static { return deployStatic(host, combinedDomains, inferredName, dir) } return deployApp(host, combinedDomains, inferredName, binary, port, envVars, envFile, binaryArgs, files) } func deployApp(host, domain, name, binaryPath string, portOverride int, envVars []string, envFile, args string, files []string) error { if binaryPath == "" { return fmt.Errorf("--binary is required") } if _, err := os.Stat(binaryPath); err != nil { return fmt.Errorf("binary not found: %s", binaryPath) } fmt.Printf("Deploying app: %s\n", name) fmt.Printf(" Domain(s): %s\n", domain) fmt.Printf(" Binary: %s\n", binaryPath) st, err := state.Load() if err != nil { return fmt.Errorf("error loading state: %w", err) } 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) } env := make(map[string]string) if existingApp != nil { for k, v := range existingApp.Env { env[k] = v } if args == "" && existingApp.Args != "" { args = existingApp.Args } if len(files) == 0 && len(existingApp.Files) > 0 { files = existingApp.Files } } for _, e := range envVars { parts := strings.SplitN(e, "=", 2) if len(parts) == 2 { env[parts[0]] = parts[1] } } if envFile != "" { fileEnv, err := parseEnvFile(envFile) if err != nil { return fmt.Errorf("error reading env file: %w", err) } for k, v := range fileEnv { env[k] = v } } env["PORT"] = strconv.Itoa(port) client, err := ssh.Connect(host) if err != nil { return fmt.Errorf("error connecting to VPS: %w", err) } defer client.Close() fmt.Println("-> Uploading binary...") remoteTmpPath := fmt.Sprintf("/tmp/%s", name) if err := client.Upload(binaryPath, remoteTmpPath); err != nil { return fmt.Errorf("error uploading binary: %w", err) } fmt.Println("-> Creating system user...") client.RunSudo(fmt.Sprintf("useradd -r -s /bin/false %s", name)) fmt.Println("-> Setting up directories...") workDir := fmt.Sprintf("/var/lib/%s", name) if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", workDir)); err != nil { return fmt.Errorf("error creating work directory: %w", err) } if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, workDir)); err != nil { return fmt.Errorf("error setting work directory ownership: %w", err) } 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 { return fmt.Errorf("error moving binary: %w", err) } if _, err := client.RunSudo(fmt.Sprintf("chmod +x %s", binaryDest)); err != nil { return fmt.Errorf("error making binary executable: %w", err) } if len(files) > 0 { fmt.Println("-> Uploading config files...") for _, file := range files { if _, err := os.Stat(file); err != nil { return fmt.Errorf("config file not found: %s", file) } remotePath := fmt.Sprintf("%s/%s", workDir, filepath.Base(file)) remoteTmpPath := fmt.Sprintf("/tmp/%s_%s", name, filepath.Base(file)) if err := client.Upload(file, remoteTmpPath); err != nil { return fmt.Errorf("error uploading config file %s: %w", file, err) } if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", remoteTmpPath, remotePath)); err != nil { return fmt.Errorf("error moving config file %s: %w", file, err) } if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, remotePath)); err != nil { return fmt.Errorf("error setting config file ownership %s: %w", file, err) } fmt.Printf(" Uploaded: %s\n", file) } } fmt.Println("-> Creating environment file...") envFilePath := fmt.Sprintf("/etc/ship/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 { return fmt.Errorf("error creating env file: %w", err) } if _, err := client.RunSudo(fmt.Sprintf("chmod 600 %s", envFilePath)); err != nil { return fmt.Errorf("error setting env file permissions: %w", err) } if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, envFilePath)); err != nil { return fmt.Errorf("error setting env file ownership: %w", err) } 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 { return fmt.Errorf("error generating systemd unit: %w", err) } servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name) if err := client.WriteSudoFile(servicePath, serviceContent); err != nil { return fmt.Errorf("error creating systemd unit: %w", err) } fmt.Println("-> Configuring Caddy...") caddyContent, err := templates.AppCaddy(map[string]string{ "Domain": domain, "Port": strconv.Itoa(port), }) if err != nil { return fmt.Errorf("error generating Caddy config: %w", err) } caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) if err := client.WriteSudoFile(caddyPath, caddyContent); err != nil { return fmt.Errorf("error creating Caddy config: %w", err) } fmt.Println("-> Reloading systemd...") if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { return fmt.Errorf("error reloading systemd: %w", err) } fmt.Println("-> Starting service...") if _, err := client.RunSudo(fmt.Sprintf("systemctl enable %s", name)); err != nil { return fmt.Errorf("error enabling service: %w", err) } if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { return fmt.Errorf("error starting service: %w", err) } fmt.Println("-> Reloading Caddy...") if _, err := client.RunSudo("systemctl reload caddy"); err != nil { return fmt.Errorf("error reloading Caddy: %w", err) } st.AddApp(host, name, &state.App{ Type: "app", Domain: domain, Port: port, Env: env, Args: args, Files: files, }) if err := st.Save(); err != nil { return fmt.Errorf("error saving state: %w", err) } fmt.Printf("\n App deployed successfully!\n") // Show first domain in the URL message primaryDomain := strings.Split(domain, ",")[0] primaryDomain = strings.TrimSpace(primaryDomain) fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", primaryDomain) return nil } func deployStatic(host, domain, name, dir string) error { if _, err := os.Stat(dir); err != nil { return fmt.Errorf("directory not found: %s", dir) } fmt.Printf("Deploying static site: %s\n", name) fmt.Printf(" Domain(s): %s\n", domain) fmt.Printf(" Directory: %s\n", dir) st, err := state.Load() if err != nil { return fmt.Errorf("error loading state: %w", err) } client, err := ssh.Connect(host) if err != nil { return fmt.Errorf("error connecting to VPS: %w", err) } defer client.Close() remoteDir := fmt.Sprintf("/var/www/%s", name) fmt.Println("-> Creating remote directory...") if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", remoteDir)); err != nil { return fmt.Errorf("error creating remote directory: %w", err) } currentUser, err := client.Run("whoami") if err != nil { return fmt.Errorf("error getting current user: %w", err) } currentUser = strings.TrimSpace(currentUser) if _, err := client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", currentUser, currentUser, remoteDir)); err != nil { return fmt.Errorf("error setting temporary ownership: %w", err) } fmt.Println("-> Uploading files...") if err := client.UploadDir(dir, remoteDir); err != nil { return fmt.Errorf("error uploading files: %w", err) } fmt.Println("-> Setting permissions...") if _, err := client.RunSudo(fmt.Sprintf("chown -R www-data:www-data %s", remoteDir)); err != nil { return fmt.Errorf("error setting ownership: %w", err) } if _, err := client.RunSudo(fmt.Sprintf("chmod -R 755 %s", remoteDir)); err != nil { return fmt.Errorf("error setting directory permissions: %w", err) } if _, err := client.RunSudo(fmt.Sprintf("find %s -type f -exec chmod 644 {} \\;", remoteDir)); err != nil { return fmt.Errorf("error setting file permissions: %w", err) } fmt.Println("-> Configuring Caddy...") caddyContent, err := templates.StaticCaddy(map[string]string{ "Domain": domain, "RootDir": remoteDir, }) if err != nil { return fmt.Errorf("error generating Caddy config: %w", err) } caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) if err := client.WriteSudoFile(caddyPath, caddyContent); err != nil { return fmt.Errorf("error creating Caddy config: %w", err) } fmt.Println("-> Reloading Caddy...") if _, err := client.RunSudo("systemctl reload caddy"); err != nil { return fmt.Errorf("error reloading Caddy: %w", err) } st.AddApp(host, name, &state.App{ Type: "static", Domain: domain, }) if err := st.Save(); err != nil { return fmt.Errorf("error saving state: %w", err) } fmt.Printf("\n Static site deployed successfully!\n") // Show first domain in the URL message primaryDomain := strings.Split(domain, ",")[0] primaryDomain = strings.TrimSpace(primaryDomain) fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", primaryDomain) return nil } 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() }