summaryrefslogtreecommitdiffstats
path: root/cmd/ship/host
diff options
context:
space:
mode:
authorbndw <ben@bdw.to>2026-01-24 09:48:34 -0800
committerbndw <ben@bdw.to>2026-01-24 09:48:34 -0800
commit5861e465a2ccf31d87ea25ac145770786f9cc96e (patch)
tree4ac6b57a06b46d8492717b235909f9e0db0b4f22 /cmd/ship/host
parentef37850c7090493cf2b26d2e565511fe23cc9bfc (diff)
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
Diffstat (limited to 'cmd/ship/host')
-rw-r--r--cmd/ship/host/host.go18
-rw-r--r--cmd/ship/host/init.go137
-rw-r--r--cmd/ship/host/ssh.go45
-rw-r--r--cmd/ship/host/status.go108
-rw-r--r--cmd/ship/host/update.go93
5 files changed, 401 insertions, 0 deletions
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 @@
1package host
2
3import (
4 "github.com/spf13/cobra"
5)
6
7var Cmd = &cobra.Command{
8 Use: "host",
9 Short: "Manage VPS host",
10 Long: "Commands for managing and monitoring the VPS host",
11}
12
13func init() {
14 Cmd.AddCommand(initCmd)
15 Cmd.AddCommand(statusCmd)
16 Cmd.AddCommand(updateCmd)
17 Cmd.AddCommand(sshCmd)
18}
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 @@
1package host
2
3import (
4 "fmt"
5 "strings"
6
7 "github.com/bdw/ship/internal/ssh"
8 "github.com/bdw/ship/internal/state"
9 "github.com/spf13/cobra"
10)
11
12var initCmd = &cobra.Command{
13 Use: "init",
14 Short: "Initialize VPS (one-time setup)",
15 Long: "Set up a fresh VPS with Caddy for automatic HTTPS and required directories",
16 RunE: runInit,
17}
18
19func runInit(cmd *cobra.Command, args []string) error {
20 st, err := state.Load()
21 if err != nil {
22 return fmt.Errorf("error loading state: %w", err)
23 }
24
25 host, _ := cmd.Flags().GetString("host")
26 if host == "" {
27 host = st.GetDefaultHost()
28 }
29
30 if host == "" {
31 return fmt.Errorf("--host is required")
32 }
33
34 fmt.Printf("Initializing VPS: %s\n", host)
35
36 client, err := ssh.Connect(host)
37 if err != nil {
38 return fmt.Errorf("error connecting to VPS: %w", err)
39 }
40 defer client.Close()
41
42 fmt.Println("-> Detecting OS...")
43 osRelease, err := client.Run("cat /etc/os-release")
44 if err != nil {
45 return fmt.Errorf("error detecting OS: %w", err)
46 }
47
48 if !strings.Contains(osRelease, "Ubuntu") && !strings.Contains(osRelease, "Debian") {
49 return fmt.Errorf("unsupported OS (only Ubuntu and Debian are supported)")
50 }
51 fmt.Println(" Detected Ubuntu/Debian")
52
53 fmt.Println("-> Checking for Caddy...")
54 _, err = client.Run("which caddy")
55 if err == nil {
56 fmt.Println(" Caddy already installed")
57 } else {
58 fmt.Println(" Installing Caddy...")
59 if err := installCaddy(client); err != nil {
60 return err
61 }
62 fmt.Println(" Caddy installed")
63 }
64
65 fmt.Println("-> Configuring Caddy...")
66 caddyfile := `{
67 email admin@example.com
68}
69
70import /etc/caddy/sites-enabled/*
71`
72 if err := client.WriteSudoFile("/etc/caddy/Caddyfile", caddyfile); err != nil {
73 return fmt.Errorf("error creating Caddyfile: %w", err)
74 }
75 fmt.Println(" Caddyfile created")
76
77 fmt.Println("-> Creating directories...")
78 if _, err := client.RunSudo("mkdir -p /etc/ship/env"); err != nil {
79 return fmt.Errorf("error creating /etc/ship/env: %w", err)
80 }
81 if _, err := client.RunSudo("mkdir -p /etc/caddy/sites-enabled"); err != nil {
82 return fmt.Errorf("error creating /etc/caddy/sites-enabled: %w", err)
83 }
84 fmt.Println(" Directories created")
85
86 fmt.Println("-> Starting Caddy...")
87 if _, err := client.RunSudo("systemctl enable caddy"); err != nil {
88 return fmt.Errorf("error enabling Caddy: %w", err)
89 }
90 if _, err := client.RunSudo("systemctl restart caddy"); err != nil {
91 return fmt.Errorf("error starting Caddy: %w", err)
92 }
93 fmt.Println(" Caddy started")
94
95 fmt.Println("-> Verifying installation...")
96 output, err := client.RunSudo("systemctl is-active caddy")
97 if err != nil || strings.TrimSpace(output) != "active" {
98 fmt.Println(" Warning: Caddy may not be running properly")
99 } else {
100 fmt.Println(" Caddy is active")
101 }
102
103 st.GetHost(host)
104 if st.GetDefaultHost() == "" {
105 st.SetDefaultHost(host)
106 fmt.Printf(" Set %s as default host\n", host)
107 }
108 if err := st.Save(); err != nil {
109 return fmt.Errorf("error saving state: %w", err)
110 }
111
112 fmt.Println("\nVPS initialized successfully!")
113 fmt.Println("\nNext steps:")
114 fmt.Println(" 1. Deploy an app:")
115 fmt.Printf(" ship --binary ./myapp --domain api.example.com\n")
116 fmt.Println(" 2. Deploy a static site:")
117 fmt.Printf(" ship --static --dir ./dist --domain example.com\n")
118 return nil
119}
120
121func installCaddy(client *ssh.Client) error {
122 commands := []string{
123 "apt-get update",
124 "apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl",
125 "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg",
126 "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list",
127 "apt-get update",
128 "apt-get install -y caddy",
129 }
130
131 for _, cmd := range commands {
132 if _, err := client.RunSudo(cmd); err != nil {
133 return fmt.Errorf("error running: %s: %w", cmd, err)
134 }
135 }
136 return nil
137}
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 @@
1package host
2
3import (
4 "fmt"
5 "os"
6 "os/exec"
7
8 "github.com/bdw/ship/internal/state"
9 "github.com/spf13/cobra"
10)
11
12var sshCmd = &cobra.Command{
13 Use: "ssh",
14 Short: "Open interactive SSH session",
15 RunE: runSSH,
16}
17
18func runSSH(cmd *cobra.Command, args []string) error {
19 st, err := state.Load()
20 if err != nil {
21 return fmt.Errorf("error loading state: %w", err)
22 }
23
24 host, _ := cmd.Flags().GetString("host")
25 if host == "" {
26 host = st.GetDefaultHost()
27 }
28
29 if host == "" {
30 return fmt.Errorf("--host is required (no default host set)")
31 }
32
33 sshCmd := exec.Command("ssh", host)
34 sshCmd.Stdin = os.Stdin
35 sshCmd.Stdout = os.Stdout
36 sshCmd.Stderr = os.Stderr
37
38 if err := sshCmd.Run(); err != nil {
39 if exitErr, ok := err.(*exec.ExitError); ok {
40 os.Exit(exitErr.ExitCode())
41 }
42 return err
43 }
44 return nil
45}
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 @@
1package host
2
3import (
4 "fmt"
5
6 "github.com/bdw/ship/internal/ssh"
7 "github.com/bdw/ship/internal/state"
8 "github.com/spf13/cobra"
9)
10
11var statusCmd = &cobra.Command{
12 Use: "status",
13 Short: "Show VPS health (uptime, disk, memory)",
14 RunE: runStatus,
15}
16
17func runStatus(cmd *cobra.Command, args []string) error {
18 st, err := state.Load()
19 if err != nil {
20 return fmt.Errorf("error loading state: %w", err)
21 }
22
23 host, _ := cmd.Flags().GetString("host")
24 if host == "" {
25 host = st.GetDefaultHost()
26 }
27
28 if host == "" {
29 return fmt.Errorf("--host is required (no default host set)")
30 }
31
32 fmt.Printf("Connecting to %s...\n\n", host)
33
34 client, err := ssh.Connect(host)
35 if err != nil {
36 return fmt.Errorf("error connecting to VPS: %w", err)
37 }
38 defer client.Close()
39
40 fmt.Println("UPTIME")
41 if output, err := client.Run("uptime -p"); err == nil {
42 fmt.Printf(" %s", output)
43 }
44 if output, err := client.Run("uptime -s"); err == nil {
45 fmt.Printf(" Since: %s", output)
46 }
47 fmt.Println()
48
49 fmt.Println("LOAD")
50 if output, err := client.Run("cat /proc/loadavg | awk '{print $1, $2, $3}'"); err == nil {
51 fmt.Printf(" 1m, 5m, 15m: %s", output)
52 }
53 fmt.Println()
54
55 fmt.Println("MEMORY")
56 if output, err := client.Run("free -h | awk 'NR==2 {print \" Used: \" $3 \" / \" $2}'"); err == nil {
57 fmt.Print(output)
58 }
59 if output, err := client.Run("free -h | awk 'NR==2 {printf \" Available: %s\\n\", $7}'"); err == nil {
60 fmt.Print(output)
61 }
62 fmt.Println()
63
64 fmt.Println("DISK")
65 if output, err := client.Run("df -h / | awk 'NR==2 {print \" Used: \" $3 \" / \" $2 \" (\" $5 \")\"}'"); err == nil {
66 fmt.Print(output)
67 }
68 if output, err := client.Run("df -h / | awk 'NR==2 {print \" Available: \" $4}'"); err == nil {
69 fmt.Print(output)
70 }
71 fmt.Println()
72
73 fmt.Println("UPDATES")
74 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 {
75 fmt.Print(output)
76 }
77 fmt.Println()
78
79 fmt.Println("SERVICES")
80 if output, err := client.Run("systemctl is-active caddy 2>/dev/null && echo ' Caddy: active' || echo ' Caddy: inactive'"); err == nil {
81 if output == "active\n" {
82 fmt.Println(" Caddy: active")
83 } else {
84 fmt.Println(" Caddy: inactive")
85 }
86 }
87
88 hostState := st.GetHost(host)
89 if hostState != nil && len(hostState.Apps) > 0 {
90 activeCount := 0
91 for name, app := range hostState.Apps {
92 if app.Type == "app" {
93 if output, err := client.Run(fmt.Sprintf("systemctl is-active %s 2>/dev/null", name)); err == nil && output == "active\n" {
94 activeCount++
95 }
96 }
97 }
98 appCount := 0
99 for _, app := range hostState.Apps {
100 if app.Type == "app" {
101 appCount++
102 }
103 }
104 fmt.Printf(" Deployed apps: %d/%d active\n", activeCount, appCount)
105 }
106
107 return nil
108}
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 @@
1package host
2
3import (
4 "bufio"
5 "fmt"
6 "os"
7 "strings"
8
9 "github.com/bdw/ship/internal/ssh"
10 "github.com/bdw/ship/internal/state"
11 "github.com/spf13/cobra"
12)
13
14var updateCmd = &cobra.Command{
15 Use: "update",
16 Short: "Update VPS packages",
17 Long: "Run apt update && apt upgrade on the VPS",
18 RunE: runUpdate,
19}
20
21func init() {
22 updateCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
23}
24
25func runUpdate(cmd *cobra.Command, args []string) error {
26 st, err := state.Load()
27 if err != nil {
28 return fmt.Errorf("error loading state: %w", err)
29 }
30
31 host, _ := cmd.Flags().GetString("host")
32 if host == "" {
33 host = st.GetDefaultHost()
34 }
35
36 if host == "" {
37 return fmt.Errorf("--host is required (no default host set)")
38 }
39
40 yes, _ := cmd.Flags().GetBool("yes")
41 if !yes {
42 fmt.Printf("This will run apt update && apt upgrade on %s\n", host)
43 fmt.Print("Continue? [Y/n]: ")
44 reader := bufio.NewReader(os.Stdin)
45 response, _ := reader.ReadString('\n')
46 response = strings.TrimSpace(response)
47 if response == "n" || response == "N" {
48 fmt.Println("Aborted.")
49 return nil
50 }
51 }
52
53 fmt.Printf("Connecting to %s...\n", host)
54
55 client, err := ssh.Connect(host)
56 if err != nil {
57 return fmt.Errorf("error connecting to VPS: %w", err)
58 }
59 defer client.Close()
60
61 fmt.Println("\n-> Running apt update...")
62 if err := client.RunSudoStream("apt update"); err != nil {
63 return fmt.Errorf("error running apt update: %w", err)
64 }
65
66 fmt.Println("\n-> Running apt upgrade...")
67 if err := client.RunSudoStream("DEBIAN_FRONTEND=noninteractive apt upgrade -y"); err != nil {
68 return fmt.Errorf("error running apt upgrade: %w", err)
69 }
70
71 fmt.Println()
72 if output, err := client.Run("[ -f /var/run/reboot-required ] && echo 'yes' || echo 'no'"); err == nil {
73 if strings.TrimSpace(output) == "yes" {
74 fmt.Print("A reboot is required to complete the update. Reboot now? [Y/n]: ")
75 reader := bufio.NewReader(os.Stdin)
76 response, _ := reader.ReadString('\n')
77 response = strings.TrimSpace(response)
78 if response == "" || response == "y" || response == "Y" {
79 fmt.Println("Rebooting...")
80 if _, err := client.RunSudo("reboot"); err != nil {
81 // reboot command often returns an error as connection drops
82 // this is expected behavior
83 }
84 fmt.Println("Reboot initiated. The host will be back online shortly.")
85 return nil
86 }
87 fmt.Println("Skipping reboot. Run 'sudo reboot' manually when ready.")
88 }
89 }
90
91 fmt.Println("Update complete")
92 return nil
93}