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 Dir string // for static sites Port int Args string Files []string Memory string CPU string Env map[string]string // merged env vars IsUpdate bool } func runDeploy(cmd *cobra.Command, args []string) error { flags := cmd.Flags() // Parse CLI 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") argsFlag, _ := flags.GetString("args") files, _ := flags.GetStringArray("file") memory, _ := flags.GetString("memory") cpu, _ := flags.GetString("cpu") // 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 && name == "" { return cmd.Help() } if host == "" { return fmt.Errorf("--host is required") } // Load state once - this will be used throughout st, err := state.Load() if err != nil { return fmt.Errorf("error loading state: %w", err) } hostState := st.GetHost(host) // Config update mode: --name provided without --binary or --static if name != "" && binary == "" && !static { existingApp, err := st.GetApp(host, name) if err != nil { return fmt.Errorf("app %s not found (use --binary to deploy a new app)", name) } // Build merged config starting from existing app opts := DeployOptions{ Host: host, Name: name, Port: existingApp.Port, Args: existingApp.Args, Files: existingApp.Files, Memory: existingApp.Memory, CPU: existingApp.CPU, Env: make(map[string]string), } for k, v := range existingApp.Env { opts.Env[k] = v } // Override with CLI flags if provided if argsFlag != "" { opts.Args = argsFlag } if len(files) > 0 { opts.Files = files } if memory != "" { opts.Memory = memory } if cpu != "" { opts.CPU = cpu } // Merge env vars (CLI overrides existing) for _, e := range envVars { parts := strings.SplitN(e, "=", 2) if len(parts) == 2 { opts.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 { opts.Env[k] = v } } return updateAppConfig(st, opts) } // Infer name early so we can use it for subdomain generation and existing app lookup if name == "" { if static { name = domain if name == "" && hostState.BaseDomain != "" { name = filepath.Base(dir) } } else { name = filepath.Base(binary) } } // Check if this is an update to an existing app/site existingApp, _ := st.GetApp(host, name) isUpdate := existingApp != nil // For new deployments, require domain or base domain if !isUpdate && domain == "" && hostState.BaseDomain == "" { return fmt.Errorf("--domain required (or configure base domain with 'ship host init --base-domain')") } // Build merged config, starting from existing app if updating opts := DeployOptions{ Host: host, Name: name, Binary: binary, Dir: dir, IsUpdate: isUpdate, } // Merge domain: auto-subdomain + (user-provided or existing custom domain) var domains []string if hostState.BaseDomain != "" { domains = append(domains, name+"."+hostState.BaseDomain) } if domain != "" { domains = append(domains, domain) } else if isUpdate && existingApp.Domain != "" { for _, d := range strings.Split(existingApp.Domain, ",") { d = strings.TrimSpace(d) if d != "" && (hostState.BaseDomain == "" || !strings.HasSuffix(d, "."+hostState.BaseDomain)) { domains = append(domains, d) } } } opts.Domain = strings.Join(domains, ", ") // For apps, merge all config fields if !static { // Start with existing values if updating if isUpdate { opts.Port = existingApp.Port opts.Args = existingApp.Args opts.Files = existingApp.Files opts.Memory = existingApp.Memory opts.CPU = existingApp.CPU opts.Env = make(map[string]string) for k, v := range existingApp.Env { opts.Env[k] = v } } else { opts.Port = port opts.Env = make(map[string]string) } // Override with CLI flags if provided if argsFlag != "" { opts.Args = argsFlag } if len(files) > 0 { opts.Files = files } if memory != "" { opts.Memory = memory } if cpu != "" { opts.CPU = cpu } // Merge env vars (CLI overrides existing) for _, e := range envVars { parts := strings.SplitN(e, "=", 2) if len(parts) == 2 { opts.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 { opts.Env[k] = v } } } if static { return deployStatic(st, opts) } return deployApp(st, opts) } func deployApp(st *state.State, 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) // Allocate port for new apps port := opts.Port if opts.IsUpdate { fmt.Printf(" Updating existing deployment (port %d)\n", port) } else { if port == 0 { port = st.AllocatePort(opts.Host) } fmt.Printf(" Allocated port: %d\n", port) } // Add PORT to env opts.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(opts.Files) > 0 { fmt.Println("-> Uploading config files...") for _, file := range opts.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 opts.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": opts.Args, "Memory": opts.Memory, "CPU": opts.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: opts.Env, Args: opts.Args, Files: opts.Files, Memory: opts.Memory, CPU: opts.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(st *state.State, opts DeployOptions) error { 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" && existingApp.Type != "git-app" { return fmt.Errorf("%s is a static site, not an app", opts.Name) } fmt.Printf("Updating config: %s\n", opts.Name) // Add PORT to env opts.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 opts.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) } // For git-app, the systemd unit comes from .ship/service in the repo, // so we only update the env file and restart. if existingApp.Type != "git-app" { // 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": opts.Args, "Memory": opts.Memory, "CPU": opts.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 = opts.Args existingApp.Memory = opts.Memory existingApp.CPU = opts.CPU existingApp.Env = opts.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(st *state.State, opts DeployOptions) error { if _, err := os.Stat(opts.Dir); err != nil { return fmt.Errorf("directory not found: %s", opts.Dir) } fmt.Printf("Deploying static site: %s\n", opts.Name) fmt.Printf(" Domain(s): %s\n", opts.Domain) fmt.Printf(" Directory: %s\n", opts.Dir) if opts.IsUpdate { fmt.Println(" Updating existing deployment") } client, err := ssh.Connect(opts.Host) if err != nil { return fmt.Errorf("error connecting to VPS: %w", err) } defer client.Close() remoteDir := fmt.Sprintf("/var/www/%s", opts.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(opts.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": opts.Domain, "RootDir": remoteDir, }) 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 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: "static", Domain: opts.Domain, }) if err := st.Save(); err != nil { return fmt.Errorf("error saving state: %w", err) } fmt.Printf("\n Static site deployed successfully!\n") 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 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() }