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" ) // DeployOptions contains all options for deploying or updating an app type DeployOptions struct { Host string Domain string Name string Binary string Port int EnvVars []string EnvFile string Args string Files []string Memory string CPU string } func runDeploy(cmd *cobra.Command, args []string) error { flags := cmd.Flags() opts := DeployOptions{} opts.Binary, _ = flags.GetString("binary") static, _ := flags.GetBool("static") dir, _ := flags.GetString("dir") opts.Domain, _ = flags.GetString("domain") opts.Name, _ = flags.GetString("name") opts.Port, _ = flags.GetInt("port") opts.EnvVars, _ = flags.GetStringArray("env") opts.EnvFile, _ = flags.GetString("env-file") opts.Args, _ = flags.GetString("args") opts.Files, _ = flags.GetStringArray("file") opts.Memory, _ = flags.GetString("memory") opts.CPU, _ = flags.GetString("cpu") // Get host from flag or state default opts.Host = hostFlag if opts.Host == "" { st, err := state.Load() if err != nil { return fmt.Errorf("error loading state: %w", err) } opts.Host = st.GetDefaultHost() } // If no flags provided, show help if opts.Domain == "" && opts.Binary == "" && !static && opts.Name == "" { return cmd.Help() } if opts.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(opts.Host) // Config update mode: --name provided without --binary or --static if opts.Name != "" && opts.Binary == "" && !static { return updateAppConfig(opts) } if opts.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 if opts.Name == "" { if static { opts.Name = opts.Domain if opts.Name == "" && hostState.BaseDomain != "" { opts.Name = filepath.Base(dir) } } else { opts.Name = filepath.Base(opts.Binary) } } // Generate subdomain if base domain configured var domains []string if hostState.BaseDomain != "" { domains = append(domains, opts.Name+"."+hostState.BaseDomain) } if opts.Domain != "" { domains = append(domains, opts.Domain) } opts.Domain = strings.Join(domains, ", ") if static { return deployStatic(opts.Host, opts.Domain, opts.Name, dir) } return deployApp(opts) } func deployApp(opts DeployOptions) error { if opts.Binary == "" { return fmt.Errorf("--binary is required") } if _, err := os.Stat(opts.Binary); err != nil { return fmt.Errorf("binary not found: %s", opts.Binary) } fmt.Printf("Deploying app: %s\n", opts.Name) fmt.Printf(" Domain(s): %s\n", opts.Domain) fmt.Printf(" Binary: %s\n", opts.Binary) st, err := state.Load() if err != nil { return fmt.Errorf("error loading state: %w", err) } existingApp, _ := st.GetApp(opts.Host, opts.Name) var port int if existingApp != nil { port = existingApp.Port fmt.Printf(" Updating existing deployment (port %d)\n", port) } else { if opts.Port > 0 { port = opts.Port } else { port = st.AllocatePort(opts.Host) } fmt.Printf(" Allocated port: %d\n", port) } // Merge with existing config args := opts.Args files := opts.Files memory := opts.Memory cpu := opts.CPU 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 } if memory == "" && existingApp.Memory != "" { memory = existingApp.Memory } if cpu == "" && existingApp.CPU != "" { cpu = existingApp.CPU } } for _, e := range opts.EnvVars { parts := strings.SplitN(e, "=", 2) if len(parts) == 2 { env[parts[0]] = parts[1] } } if opts.EnvFile != "" { fileEnv, err := parseEnvFile(opts.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(opts.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", opts.Name) if err := client.Upload(opts.Binary, 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", opts.Name)) fmt.Println("-> Setting up directories...") workDir := fmt.Sprintf("/var/lib/%s", opts.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", opts.Name, opts.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", opts.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)) fileTmpPath := fmt.Sprintf("/tmp/%s_%s", opts.Name, filepath.Base(file)) if err := client.Upload(file, fileTmpPath); err != nil { return fmt.Errorf("error uploading config file %s: %w", file, err) } if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", fileTmpPath, remotePath)); err != nil { return fmt.Errorf("error moving config file %s: %w", file, err) } if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", opts.Name, opts.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", opts.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", opts.Name, opts.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": opts.Name, "User": opts.Name, "WorkDir": workDir, "BinaryPath": binaryDest, "Port": strconv.Itoa(port), "EnvFile": envFilePath, "Args": args, "Memory": memory, "CPU": cpu, }) if err != nil { return fmt.Errorf("error generating systemd unit: %w", err) } servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", opts.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": opts.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", opts.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", opts.Name)); err != nil { return fmt.Errorf("error enabling service: %w", err) } if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", opts.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(opts.Host, opts.Name, &state.App{ Type: "app", Domain: opts.Domain, Port: port, Env: env, Args: args, Files: files, Memory: memory, CPU: cpu, }) 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(opts.Domain, ",")[0] primaryDomain = strings.TrimSpace(primaryDomain) fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", primaryDomain) return nil } func updateAppConfig(opts DeployOptions) error { st, err := state.Load() if err != nil { return fmt.Errorf("error loading state: %w", err) } existingApp, err := st.GetApp(opts.Host, opts.Name) if err != nil { return fmt.Errorf("app %s not found (use --binary to deploy a new app)", opts.Name) } if existingApp.Type != "app" { return fmt.Errorf("%s is a static site, not an app", opts.Name) } fmt.Printf("Updating config: %s\n", opts.Name) // Merge with existing values args := opts.Args if args == "" { args = existingApp.Args } memory := opts.Memory if memory == "" { memory = existingApp.Memory } cpu := opts.CPU if cpu == "" { cpu = existingApp.CPU } // Merge env vars env := make(map[string]string) for k, v := range existingApp.Env { env[k] = v } for _, e := range opts.EnvVars { parts := strings.SplitN(e, "=", 2) if len(parts) == 2 { env[parts[0]] = parts[1] } } if opts.EnvFile != "" { fileEnv, err := parseEnvFile(opts.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(existingApp.Port) client, err := ssh.Connect(opts.Host) if err != nil { return fmt.Errorf("error connecting to VPS: %w", err) } defer client.Close() // Update env file fmt.Println("-> Updating environment file...") envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", opts.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) } // Regenerate systemd unit fmt.Println("-> Updating systemd service...") workDir := fmt.Sprintf("/var/lib/%s", opts.Name) binaryDest := fmt.Sprintf("/usr/local/bin/%s", opts.Name) serviceContent, err := templates.SystemdService(map[string]string{ "Name": opts.Name, "User": opts.Name, "WorkDir": workDir, "BinaryPath": binaryDest, "Port": strconv.Itoa(existingApp.Port), "EnvFile": envFilePath, "Args": args, "Memory": memory, "CPU": cpu, }) if err != nil { return fmt.Errorf("error generating systemd unit: %w", err) } servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", opts.Name) if err := client.WriteSudoFile(servicePath, serviceContent); err != nil { return fmt.Errorf("error creating systemd unit: %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("-> Restarting service...") if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", opts.Name)); err != nil { return fmt.Errorf("error restarting service: %w", err) } // Update state existingApp.Args = args existingApp.Memory = memory existingApp.CPU = cpu existingApp.Env = env if err := st.Save(); err != nil { return fmt.Errorf("error saving state: %w", err) } fmt.Printf("\n Config updated successfully!\n") 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() }