summaryrefslogtreecommitdiffstats
path: root/cmd/ship/host
diff options
context:
space:
mode:
authorClawd <ai@clawd.bot>2026-02-17 07:54:26 -0800
committerClawd <ai@clawd.bot>2026-02-17 07:54:26 -0800
commit6b2c04728cd914f27ae62c1df0bf5df24ac9a628 (patch)
tree8a103ac79194a05fae438b0da105589aaa6b78d9 /cmd/ship/host
parent5e5de4ea1aa98f75d470e4a61644d4b9f100c4b0 (diff)
Remove v1 code, simplify state to just base_domain
- Delete all v1 commands (deploy, init, list, status, remove, etc.) - Delete v1 env/ and host/ subcommand directories - Simplify state.go: remove NextPort, Apps, AllocatePort, etc. - Local state now only tracks default_host + base_domain per host - Ports and deploys are tracked on the server (/etc/ship/ports/) - host init now creates minimal state.json
Diffstat (limited to 'cmd/ship/host')
-rw-r--r--cmd/ship/host/host.go21
-rw-r--r--cmd/ship/host/init.go316
-rw-r--r--cmd/ship/host/set_domain.go76
-rw-r--r--cmd/ship/host/ssh.go45
-rw-r--r--cmd/ship/host/status.go108
-rw-r--r--cmd/ship/host/update.go93
6 files changed, 0 insertions, 659 deletions
diff --git a/cmd/ship/host/host.go b/cmd/ship/host/host.go
deleted file mode 100644
index 81403f9..0000000
--- a/cmd/ship/host/host.go
+++ /dev/null
@@ -1,21 +0,0 @@
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 Cmd.AddCommand(setDomainCmd)
19
20 initCmd.Flags().String("base-domain", "", "Base domain for auto-generated subdomains (e.g., apps.example.com)")
21}
diff --git a/cmd/ship/host/init.go b/cmd/ship/host/init.go
deleted file mode 100644
index cfa2795..0000000
--- a/cmd/ship/host/init.go
+++ /dev/null
@@ -1,316 +0,0 @@
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/bdw/ship/internal/templates"
10 "github.com/spf13/cobra"
11)
12
13var initCmd = &cobra.Command{
14 Use: "init",
15 Short: "Initialize VPS (one-time setup)",
16 Long: "Set up a fresh VPS with Caddy for automatic HTTPS and required directories",
17 RunE: runInit,
18}
19
20func runInit(cmd *cobra.Command, args []string) error {
21 st, err := state.Load()
22 if err != nil {
23 return fmt.Errorf("error loading state: %w", err)
24 }
25
26 host, _ := cmd.Flags().GetString("host")
27 if host == "" {
28 host = st.GetDefaultHost()
29 }
30 baseDomain, _ := cmd.Flags().GetString("base-domain")
31
32 if host == "" {
33 return fmt.Errorf("--host is required")
34 }
35
36 fmt.Printf("Initializing VPS: %s\n", host)
37
38 client, err := ssh.Connect(host)
39 if err != nil {
40 return fmt.Errorf("error connecting to VPS: %w", err)
41 }
42 defer client.Close()
43
44 fmt.Println("-> Detecting OS...")
45 osRelease, err := client.Run("cat /etc/os-release")
46 if err != nil {
47 return fmt.Errorf("error detecting OS: %w", err)
48 }
49
50 if !strings.Contains(osRelease, "Ubuntu") && !strings.Contains(osRelease, "Debian") {
51 return fmt.Errorf("unsupported OS (only Ubuntu and Debian are supported)")
52 }
53 fmt.Println(" Detected Ubuntu/Debian")
54
55 fmt.Println("-> Checking for Caddy...")
56 _, err = client.Run("which caddy")
57 if err == nil {
58 fmt.Println(" Caddy already installed")
59 } else {
60 fmt.Println(" Installing Caddy...")
61 if err := installCaddy(client); err != nil {
62 return err
63 }
64 fmt.Println(" Caddy installed")
65 }
66
67 fmt.Println("-> Configuring Caddy...")
68 caddyfile := `{
69}
70
71import /etc/caddy/sites-enabled/*
72`
73 if err := client.WriteSudoFile("/etc/caddy/Caddyfile", caddyfile); err != nil {
74 return fmt.Errorf("error creating Caddyfile: %w", err)
75 }
76 fmt.Println(" Caddyfile created")
77
78 fmt.Println("-> Creating directories...")
79 if _, err := client.RunSudo("mkdir -p /etc/ship/env"); err != nil {
80 return fmt.Errorf("error creating /etc/ship/env: %w", err)
81 }
82 if _, err := client.RunSudo("mkdir -p /etc/caddy/sites-enabled"); err != nil {
83 return fmt.Errorf("error creating /etc/caddy/sites-enabled: %w", err)
84 }
85 fmt.Println(" Directories created")
86
87 fmt.Println("-> Starting Caddy...")
88 if _, err := client.RunSudo("systemctl enable caddy"); err != nil {
89 return fmt.Errorf("error enabling Caddy: %w", err)
90 }
91 if _, err := client.RunSudo("systemctl restart caddy"); err != nil {
92 return fmt.Errorf("error starting Caddy: %w", err)
93 }
94 fmt.Println(" Caddy started")
95
96 fmt.Println("-> Verifying installation...")
97 output, err := client.RunSudo("systemctl is-active caddy")
98 if err != nil || strings.TrimSpace(output) != "active" {
99 fmt.Println(" Warning: Caddy may not be running properly")
100 } else {
101 fmt.Println(" Caddy is active")
102 }
103
104 hostState := st.GetHost(host)
105 if baseDomain != "" {
106 hostState.BaseDomain = baseDomain
107 fmt.Printf(" Base domain: %s\n", baseDomain)
108 }
109
110 // Git-centric deployment setup (gated on base domain)
111 if baseDomain != "" {
112 if err := setupGitDeploy(client, baseDomain, hostState); err != nil {
113 return err
114 }
115 }
116
117 if st.GetDefaultHost() == "" {
118 st.SetDefaultHost(host)
119 fmt.Printf(" Set %s as default host\n", host)
120 }
121 if err := st.Save(); err != nil {
122 return fmt.Errorf("error saving state: %w", err)
123 }
124
125 fmt.Println("\nVPS initialized successfully!")
126 fmt.Println("\nNext steps:")
127 fmt.Println(" 1. Deploy an app:")
128 fmt.Printf(" ship --binary ./myapp --domain api.example.com\n")
129 fmt.Println(" 2. Deploy a static site:")
130 fmt.Printf(" ship --static --dir ./dist --domain example.com\n")
131 if baseDomain != "" {
132 fmt.Println(" 3. Initialize a git-deployed app:")
133 fmt.Printf(" ship init myapp\n")
134 }
135 return nil
136}
137
138func setupGitDeploy(client *ssh.Client, baseDomain string, hostState *state.Host) error {
139 fmt.Println("-> Installing Docker...")
140 dockerCommands := []string{
141 "apt-get install -y ca-certificates curl gnupg",
142 "install -m 0755 -d /etc/apt/keyrings",
143 "curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc",
144 "chmod a+r /etc/apt/keyrings/docker.asc",
145 `sh -c 'echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo ${VERSION_CODENAME}) stable" > /etc/apt/sources.list.d/docker.list'`,
146 "apt-get update",
147 "apt-get install -y docker-ce docker-ce-cli containerd.io",
148 }
149 for _, cmd := range dockerCommands {
150 if _, err := client.RunSudo(cmd); err != nil {
151 return fmt.Errorf("error installing Docker: %w", err)
152 }
153 }
154 fmt.Println(" Docker installed")
155
156 fmt.Println("-> Installing git, fcgiwrap, and cgit...")
157 if _, err := client.RunSudo("apt-get install -y git fcgiwrap cgit"); err != nil {
158 return fmt.Errorf("error installing git/fcgiwrap/cgit: %w", err)
159 }
160 // Allow git-http-backend (runs as www-data) to access repos owned by git.
161 // Scoped to www-data only, not system-wide, to preserve CVE-2022-24765 protection.
162 // www-data's home is /var/www; ensure it can write .gitconfig there.
163 client.RunSudo("mkdir -p /var/www")
164 client.RunSudo("chown www-data:www-data /var/www")
165 if _, err := client.RunSudo("sudo -u www-data git config --global --add safe.directory '*'"); err != nil {
166 return fmt.Errorf("error setting git safe.directory: %w", err)
167 }
168 fmt.Println(" git, fcgiwrap, and cgit installed")
169
170 fmt.Println("-> Creating git user...")
171 // Create git user (ignore error if already exists)
172 client.RunSudo("useradd -r -m -d /home/git -s $(which git-shell) git")
173 if _, err := client.RunSudo("usermod -aG docker git"); err != nil {
174 return fmt.Errorf("error adding git user to docker group: %w", err)
175 }
176 // www-data needs to read git repos for git-http-backend
177 if _, err := client.RunSudo("usermod -aG git www-data"); err != nil {
178 return fmt.Errorf("error adding www-data to git group: %w", err)
179 }
180 // caddy needs to connect to fcgiwrap socket (owned by www-data)
181 if _, err := client.RunSudo("usermod -aG www-data caddy"); err != nil {
182 return fmt.Errorf("error adding caddy to www-data group: %w", err)
183 }
184 fmt.Println(" git user created")
185
186 fmt.Println("-> Copying SSH keys to git user...")
187 copyKeysCommands := []string{
188 "mkdir -p /home/git/.ssh",
189 "cp ~/.ssh/authorized_keys /home/git/.ssh/authorized_keys",
190 "chown -R git:git /home/git/.ssh",
191 "chmod 700 /home/git/.ssh",
192 "chmod 600 /home/git/.ssh/authorized_keys",
193 }
194 for _, cmd := range copyKeysCommands {
195 if _, err := client.RunSudo(cmd); err != nil {
196 return fmt.Errorf("error copying SSH keys: %w", err)
197 }
198 }
199 fmt.Println(" SSH keys copied")
200
201 fmt.Println("-> Creating /srv/git...")
202 if _, err := client.RunSudo("mkdir -p /srv/git"); err != nil {
203 return fmt.Errorf("error creating /srv/git: %w", err)
204 }
205 if _, err := client.RunSudo("chown git:git /srv/git"); err != nil {
206 return fmt.Errorf("error setting /srv/git ownership: %w", err)
207 }
208 fmt.Println(" /srv/git created")
209
210 fmt.Println("-> Writing sudoers for git user...")
211 sudoersContent := `# Ship git-deploy: allow post-receive hooks to install configs and manage services.
212# App names are validated to [a-z][a-z0-9-] before reaching this point.
213git ALL=(ALL) NOPASSWD: \
214 /bin/systemctl daemon-reload, \
215 /bin/systemctl reload caddy, \
216 /bin/systemctl restart [a-z]*, \
217 /bin/systemctl enable [a-z]*, \
218 /bin/cp /var/lib/*/src/.ship/service /etc/systemd/system/*.service, \
219 /bin/cp /var/lib/*/src/.ship/Caddyfile /etc/caddy/sites-enabled/*.caddy, \
220 /bin/cp /var/www/*/.ship/Caddyfile /etc/caddy/sites-enabled/*.caddy, \
221 /bin/mkdir -p /var/lib/*, \
222 /bin/mkdir -p /var/www/*, \
223 /bin/chown -R git\:git /var/lib/*, \
224 /bin/chown git\:git /var/www/*
225`
226 if err := client.WriteSudoFile("/etc/sudoers.d/ship-git", sudoersContent); err != nil {
227 return fmt.Errorf("error writing sudoers: %w", err)
228 }
229 if _, err := client.RunSudo("chmod 440 /etc/sudoers.d/ship-git"); err != nil {
230 return fmt.Errorf("error setting sudoers permissions: %w", err)
231 }
232 fmt.Println(" sudoers configured")
233
234 fmt.Println("-> Writing vanity import template...")
235 vanityHTML := `<!DOCTYPE html>
236<html><head>
237{{$path := trimPrefix "/" (placeholder "http.request.orig_uri.path")}}
238{{$parts := splitList "/" $path}}
239{{$module := first $parts}}
240<meta name="go-import" content="{{.Host}}/{{$module}} git https://{{.Host}}/{{$module}}.git">
241</head>
242<body>go get {{.Host}}/{{$module}}</body>
243</html>
244`
245 if _, err := client.RunSudo("mkdir -p /opt/ship/vanity"); err != nil {
246 return fmt.Errorf("error creating vanity directory: %w", err)
247 }
248 if err := client.WriteSudoFile("/opt/ship/vanity/index.html", vanityHTML); err != nil {
249 return fmt.Errorf("error writing vanity template: %w", err)
250 }
251 fmt.Println(" vanity template written")
252
253 fmt.Println("-> Writing base domain Caddy config...")
254 codeCaddyContent, err := templates.CodeCaddy(map[string]string{
255 "BaseDomain": baseDomain,
256 })
257 if err != nil {
258 return fmt.Errorf("error generating code caddy config: %w", err)
259 }
260 if err := client.WriteSudoFile("/etc/caddy/sites-enabled/ship-code.caddy", codeCaddyContent); err != nil {
261 return fmt.Errorf("error writing code caddy config: %w", err)
262 }
263 fmt.Println(" base domain Caddy config written")
264
265 fmt.Println("-> Writing cgit config...")
266 cgitrcContent, err := templates.CgitRC(map[string]string{
267 "BaseDomain": baseDomain,
268 })
269 if err != nil {
270 return fmt.Errorf("error generating cgitrc: %w", err)
271 }
272 if err := client.WriteSudoFile("/etc/cgitrc", cgitrcContent); err != nil {
273 return fmt.Errorf("error writing cgitrc: %w", err)
274 }
275 if err := client.WriteSudoFile("/opt/ship/cgit-header.html", templates.CgitHeader()); err != nil {
276 return fmt.Errorf("error writing cgit header: %w", err)
277 }
278 fmt.Println(" cgit config written")
279
280 fmt.Println("-> Starting Docker and fcgiwrap...")
281 if _, err := client.RunSudo("systemctl enable docker fcgiwrap"); err != nil {
282 return fmt.Errorf("error enabling services: %w", err)
283 }
284 if _, err := client.RunSudo("systemctl restart docker fcgiwrap"); err != nil {
285 return fmt.Errorf("error starting services: %w", err)
286 }
287 fmt.Println(" Docker and fcgiwrap started")
288
289 fmt.Println("-> Restarting Caddy...")
290 if _, err := client.RunSudo("systemctl restart caddy"); err != nil {
291 return fmt.Errorf("error restarting Caddy: %w", err)
292 }
293 fmt.Println(" Caddy restarted")
294
295 hostState.GitSetup = true
296 fmt.Println(" Git deployment setup complete")
297 return nil
298}
299
300func installCaddy(client *ssh.Client) error {
301 commands := []string{
302 "apt-get update",
303 "apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl",
304 "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg",
305 "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list",
306 "apt-get update",
307 "apt-get install -y caddy",
308 }
309
310 for _, cmd := range commands {
311 if _, err := client.RunSudo(cmd); err != nil {
312 return fmt.Errorf("error running: %s: %w", cmd, err)
313 }
314 }
315 return nil
316}
diff --git a/cmd/ship/host/set_domain.go b/cmd/ship/host/set_domain.go
deleted file mode 100644
index fed3b31..0000000
--- a/cmd/ship/host/set_domain.go
+++ /dev/null
@@ -1,76 +0,0 @@
1package host
2
3import (
4 "fmt"
5
6 "github.com/bdw/ship/internal/state"
7 "github.com/spf13/cobra"
8)
9
10var setDomainCmd = &cobra.Command{
11 Use: "set-domain [domain]",
12 Short: "Set base domain for auto-generated subdomains",
13 Long: `Set the base domain used to auto-generate subdomains for deployments.
14
15When a base domain is configured (e.g., apps.example.com), every deployment
16will automatically get a subdomain ({name}.apps.example.com).
17
18Examples:
19 ship host set-domain apps.example.com # Set base domain
20 ship host set-domain --clear # Remove base domain`,
21 RunE: runSetDomain,
22}
23
24func init() {
25 setDomainCmd.Flags().Bool("clear", false, "Clear the base domain")
26}
27
28func runSetDomain(cmd *cobra.Command, args []string) error {
29 st, err := state.Load()
30 if err != nil {
31 return fmt.Errorf("error loading state: %w", err)
32 }
33
34 host, _ := cmd.Flags().GetString("host")
35 if host == "" {
36 host = st.GetDefaultHost()
37 }
38
39 if host == "" {
40 return fmt.Errorf("--host is required")
41 }
42
43 clear, _ := cmd.Flags().GetBool("clear")
44
45 if !clear && len(args) == 0 {
46 // Show current base domain
47 hostState := st.GetHost(host)
48 if hostState.BaseDomain == "" {
49 fmt.Printf("No base domain configured for %s\n", host)
50 } else {
51 fmt.Printf("Base domain for %s: %s\n", host, hostState.BaseDomain)
52 }
53 return nil
54 }
55
56 hostState := st.GetHost(host)
57
58 if clear {
59 hostState.BaseDomain = ""
60 if err := st.Save(); err != nil {
61 return fmt.Errorf("error saving state: %w", err)
62 }
63 fmt.Printf("Cleared base domain for %s\n", host)
64 return nil
65 }
66
67 hostState.BaseDomain = args[0]
68 if err := st.Save(); err != nil {
69 return fmt.Errorf("error saving state: %w", err)
70 }
71
72 fmt.Printf("Set base domain for %s: %s\n", host, args[0])
73 fmt.Println("\nNew deployments will automatically use subdomains like:")
74 fmt.Printf(" myapp.%s\n", args[0])
75 return nil
76}
diff --git a/cmd/ship/host/ssh.go b/cmd/ship/host/ssh.go
deleted file mode 100644
index e480e47..0000000
--- a/cmd/ship/host/ssh.go
+++ /dev/null
@@ -1,45 +0,0 @@
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
deleted file mode 100644
index eb2de53..0000000
--- a/cmd/ship/host/status.go
+++ /dev/null
@@ -1,108 +0,0 @@
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
deleted file mode 100644
index 5f838b6..0000000
--- a/cmd/ship/host/update.go
+++ /dev/null
@@ -1,93 +0,0 @@
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}