From 5861e465a2ccf31d87ea25ac145770786f9cc96e Mon Sep 17 00:00:00 2001 From: bndw Date: Sat, 24 Jan 2026 09:48:34 -0800 Subject: Rename project from deploy to ship - Rename module to github.com/bdw/ship - Rename cmd/deploy to cmd/ship - Update all import paths - Update config path from ~/.config/deploy to ~/.config/ship - Update VPS env path from /etc/deploy to /etc/ship - Update README, Makefile, and docs --- cmd/ship/host/host.go | 18 +++++++ cmd/ship/host/init.go | 137 ++++++++++++++++++++++++++++++++++++++++++++++++ cmd/ship/host/ssh.go | 45 ++++++++++++++++ cmd/ship/host/status.go | 108 ++++++++++++++++++++++++++++++++++++++ cmd/ship/host/update.go | 93 ++++++++++++++++++++++++++++++++ 5 files changed, 401 insertions(+) create mode 100644 cmd/ship/host/host.go create mode 100644 cmd/ship/host/init.go create mode 100644 cmd/ship/host/ssh.go create mode 100644 cmd/ship/host/status.go create mode 100644 cmd/ship/host/update.go (limited to 'cmd/ship/host') diff --git a/cmd/ship/host/host.go b/cmd/ship/host/host.go new file mode 100644 index 0000000..603a946 --- /dev/null +++ b/cmd/ship/host/host.go @@ -0,0 +1,18 @@ +package host + +import ( + "github.com/spf13/cobra" +) + +var Cmd = &cobra.Command{ + Use: "host", + Short: "Manage VPS host", + Long: "Commands for managing and monitoring the VPS host", +} + +func init() { + Cmd.AddCommand(initCmd) + Cmd.AddCommand(statusCmd) + Cmd.AddCommand(updateCmd) + Cmd.AddCommand(sshCmd) +} diff --git a/cmd/ship/host/init.go b/cmd/ship/host/init.go new file mode 100644 index 0000000..ea25922 --- /dev/null +++ b/cmd/ship/host/init.go @@ -0,0 +1,137 @@ +package host + +import ( + "fmt" + "strings" + + "github.com/bdw/ship/internal/ssh" + "github.com/bdw/ship/internal/state" + "github.com/spf13/cobra" +) + +var initCmd = &cobra.Command{ + Use: "init", + Short: "Initialize VPS (one-time setup)", + Long: "Set up a fresh VPS with Caddy for automatic HTTPS and required directories", + RunE: runInit, +} + +func runInit(cmd *cobra.Command, args []string) error { + st, err := state.Load() + if err != nil { + return fmt.Errorf("error loading state: %w", err) + } + + host, _ := cmd.Flags().GetString("host") + if host == "" { + host = st.GetDefaultHost() + } + + if host == "" { + return fmt.Errorf("--host is required") + } + + fmt.Printf("Initializing VPS: %s\n", host) + + client, err := ssh.Connect(host) + if err != nil { + return fmt.Errorf("error connecting to VPS: %w", err) + } + defer client.Close() + + fmt.Println("-> Detecting OS...") + osRelease, err := client.Run("cat /etc/os-release") + if err != nil { + return fmt.Errorf("error detecting OS: %w", err) + } + + if !strings.Contains(osRelease, "Ubuntu") && !strings.Contains(osRelease, "Debian") { + return fmt.Errorf("unsupported OS (only Ubuntu and Debian are supported)") + } + fmt.Println(" Detected Ubuntu/Debian") + + fmt.Println("-> Checking for Caddy...") + _, err = client.Run("which caddy") + if err == nil { + fmt.Println(" Caddy already installed") + } else { + fmt.Println(" Installing Caddy...") + if err := installCaddy(client); err != nil { + return err + } + fmt.Println(" Caddy installed") + } + + fmt.Println("-> Configuring Caddy...") + caddyfile := `{ + email admin@example.com +} + +import /etc/caddy/sites-enabled/* +` + if err := client.WriteSudoFile("/etc/caddy/Caddyfile", caddyfile); err != nil { + return fmt.Errorf("error creating Caddyfile: %w", err) + } + fmt.Println(" Caddyfile created") + + fmt.Println("-> Creating directories...") + if _, err := client.RunSudo("mkdir -p /etc/ship/env"); err != nil { + return fmt.Errorf("error creating /etc/ship/env: %w", err) + } + if _, err := client.RunSudo("mkdir -p /etc/caddy/sites-enabled"); err != nil { + return fmt.Errorf("error creating /etc/caddy/sites-enabled: %w", err) + } + fmt.Println(" Directories created") + + fmt.Println("-> Starting Caddy...") + if _, err := client.RunSudo("systemctl enable caddy"); err != nil { + return fmt.Errorf("error enabling Caddy: %w", err) + } + if _, err := client.RunSudo("systemctl restart caddy"); err != nil { + return fmt.Errorf("error starting Caddy: %w", err) + } + fmt.Println(" Caddy started") + + fmt.Println("-> Verifying installation...") + output, err := client.RunSudo("systemctl is-active caddy") + if err != nil || strings.TrimSpace(output) != "active" { + fmt.Println(" Warning: Caddy may not be running properly") + } else { + fmt.Println(" Caddy is active") + } + + st.GetHost(host) + if st.GetDefaultHost() == "" { + st.SetDefaultHost(host) + fmt.Printf(" Set %s as default host\n", host) + } + if err := st.Save(); err != nil { + return fmt.Errorf("error saving state: %w", err) + } + + fmt.Println("\nVPS initialized successfully!") + fmt.Println("\nNext steps:") + fmt.Println(" 1. Deploy an app:") + fmt.Printf(" ship --binary ./myapp --domain api.example.com\n") + fmt.Println(" 2. Deploy a static site:") + fmt.Printf(" ship --static --dir ./dist --domain example.com\n") + return nil +} + +func installCaddy(client *ssh.Client) error { + commands := []string{ + "apt-get update", + "apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl", + "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg", + "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list", + "apt-get update", + "apt-get install -y caddy", + } + + for _, cmd := range commands { + if _, err := client.RunSudo(cmd); err != nil { + return fmt.Errorf("error running: %s: %w", cmd, err) + } + } + return nil +} diff --git a/cmd/ship/host/ssh.go b/cmd/ship/host/ssh.go new file mode 100644 index 0000000..e480e47 --- /dev/null +++ b/cmd/ship/host/ssh.go @@ -0,0 +1,45 @@ +package host + +import ( + "fmt" + "os" + "os/exec" + + "github.com/bdw/ship/internal/state" + "github.com/spf13/cobra" +) + +var sshCmd = &cobra.Command{ + Use: "ssh", + Short: "Open interactive SSH session", + RunE: runSSH, +} + +func runSSH(cmd *cobra.Command, args []string) error { + st, err := state.Load() + if err != nil { + return fmt.Errorf("error loading state: %w", err) + } + + host, _ := cmd.Flags().GetString("host") + if host == "" { + host = st.GetDefaultHost() + } + + if host == "" { + return fmt.Errorf("--host is required (no default host set)") + } + + sshCmd := exec.Command("ssh", host) + sshCmd.Stdin = os.Stdin + sshCmd.Stdout = os.Stdout + sshCmd.Stderr = os.Stderr + + if err := sshCmd.Run(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + os.Exit(exitErr.ExitCode()) + } + return err + } + return nil +} diff --git a/cmd/ship/host/status.go b/cmd/ship/host/status.go new file mode 100644 index 0000000..eb2de53 --- /dev/null +++ b/cmd/ship/host/status.go @@ -0,0 +1,108 @@ +package host + +import ( + "fmt" + + "github.com/bdw/ship/internal/ssh" + "github.com/bdw/ship/internal/state" + "github.com/spf13/cobra" +) + +var statusCmd = &cobra.Command{ + Use: "status", + Short: "Show VPS health (uptime, disk, memory)", + RunE: runStatus, +} + +func runStatus(cmd *cobra.Command, args []string) error { + st, err := state.Load() + if err != nil { + return fmt.Errorf("error loading state: %w", err) + } + + host, _ := cmd.Flags().GetString("host") + if host == "" { + host = st.GetDefaultHost() + } + + if host == "" { + return fmt.Errorf("--host is required (no default host set)") + } + + fmt.Printf("Connecting to %s...\n\n", host) + + client, err := ssh.Connect(host) + if err != nil { + return fmt.Errorf("error connecting to VPS: %w", err) + } + defer client.Close() + + fmt.Println("UPTIME") + if output, err := client.Run("uptime -p"); err == nil { + fmt.Printf(" %s", output) + } + if output, err := client.Run("uptime -s"); err == nil { + fmt.Printf(" Since: %s", output) + } + fmt.Println() + + fmt.Println("LOAD") + if output, err := client.Run("cat /proc/loadavg | awk '{print $1, $2, $3}'"); err == nil { + fmt.Printf(" 1m, 5m, 15m: %s", output) + } + fmt.Println() + + fmt.Println("MEMORY") + if output, err := client.Run("free -h | awk 'NR==2 {print \" Used: \" $3 \" / \" $2}'"); err == nil { + fmt.Print(output) + } + if output, err := client.Run("free -h | awk 'NR==2 {printf \" Available: %s\\n\", $7}'"); err == nil { + fmt.Print(output) + } + fmt.Println() + + fmt.Println("DISK") + if output, err := client.Run("df -h / | awk 'NR==2 {print \" Used: \" $3 \" / \" $2 \" (\" $5 \")\"}'"); err == nil { + fmt.Print(output) + } + if output, err := client.Run("df -h / | awk 'NR==2 {print \" Available: \" $4}'"); err == nil { + fmt.Print(output) + } + fmt.Println() + + fmt.Println("UPDATES") + if output, err := client.Run("[ -f /var/lib/update-notifier/updates-available ] && cat /var/lib/update-notifier/updates-available | head -2 || echo ' (update info not available)'"); err == nil { + fmt.Print(output) + } + fmt.Println() + + fmt.Println("SERVICES") + if output, err := client.Run("systemctl is-active caddy 2>/dev/null && echo ' Caddy: active' || echo ' Caddy: inactive'"); err == nil { + if output == "active\n" { + fmt.Println(" Caddy: active") + } else { + fmt.Println(" Caddy: inactive") + } + } + + hostState := st.GetHost(host) + if hostState != nil && len(hostState.Apps) > 0 { + activeCount := 0 + for name, app := range hostState.Apps { + if app.Type == "app" { + if output, err := client.Run(fmt.Sprintf("systemctl is-active %s 2>/dev/null", name)); err == nil && output == "active\n" { + activeCount++ + } + } + } + appCount := 0 + for _, app := range hostState.Apps { + if app.Type == "app" { + appCount++ + } + } + fmt.Printf(" Deployed apps: %d/%d active\n", activeCount, appCount) + } + + return nil +} diff --git a/cmd/ship/host/update.go b/cmd/ship/host/update.go new file mode 100644 index 0000000..5f838b6 --- /dev/null +++ b/cmd/ship/host/update.go @@ -0,0 +1,93 @@ +package host + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/bdw/ship/internal/ssh" + "github.com/bdw/ship/internal/state" + "github.com/spf13/cobra" +) + +var updateCmd = &cobra.Command{ + Use: "update", + Short: "Update VPS packages", + Long: "Run apt update && apt upgrade on the VPS", + RunE: runUpdate, +} + +func init() { + updateCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") +} + +func runUpdate(cmd *cobra.Command, args []string) error { + st, err := state.Load() + if err != nil { + return fmt.Errorf("error loading state: %w", err) + } + + host, _ := cmd.Flags().GetString("host") + if host == "" { + host = st.GetDefaultHost() + } + + if host == "" { + return fmt.Errorf("--host is required (no default host set)") + } + + yes, _ := cmd.Flags().GetBool("yes") + if !yes { + fmt.Printf("This will run apt update && apt upgrade on %s\n", host) + fmt.Print("Continue? [Y/n]: ") + reader := bufio.NewReader(os.Stdin) + response, _ := reader.ReadString('\n') + response = strings.TrimSpace(response) + if response == "n" || response == "N" { + fmt.Println("Aborted.") + return nil + } + } + + fmt.Printf("Connecting to %s...\n", host) + + client, err := ssh.Connect(host) + if err != nil { + return fmt.Errorf("error connecting to VPS: %w", err) + } + defer client.Close() + + fmt.Println("\n-> Running apt update...") + if err := client.RunSudoStream("apt update"); err != nil { + return fmt.Errorf("error running apt update: %w", err) + } + + fmt.Println("\n-> Running apt upgrade...") + if err := client.RunSudoStream("DEBIAN_FRONTEND=noninteractive apt upgrade -y"); err != nil { + return fmt.Errorf("error running apt upgrade: %w", err) + } + + fmt.Println() + if output, err := client.Run("[ -f /var/run/reboot-required ] && echo 'yes' || echo 'no'"); err == nil { + if strings.TrimSpace(output) == "yes" { + fmt.Print("A reboot is required to complete the update. Reboot now? [Y/n]: ") + reader := bufio.NewReader(os.Stdin) + response, _ := reader.ReadString('\n') + response = strings.TrimSpace(response) + if response == "" || response == "y" || response == "Y" { + fmt.Println("Rebooting...") + if _, err := client.RunSudo("reboot"); err != nil { + // reboot command often returns an error as connection drops + // this is expected behavior + } + fmt.Println("Reboot initiated. The host will be back online shortly.") + return nil + } + fmt.Println("Skipping reboot. Run 'sudo reboot' manually when ready.") + } + } + + fmt.Println("Update complete") + return nil +} -- cgit v1.2.3