summaryrefslogtreecommitdiffstats
path: root/cmd/ship
diff options
context:
space:
mode:
Diffstat (limited to 'cmd/ship')
-rw-r--r--cmd/ship/env/env.go17
-rw-r--r--cmd/ship/env/list.go69
-rw-r--r--cmd/ship/env/set.go132
-rw-r--r--cmd/ship/env/unset.go92
-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
-rw-r--r--cmd/ship/list.go50
-rw-r--r--cmd/ship/logs.go75
-rw-r--r--cmd/ship/main.go65
-rw-r--r--cmd/ship/remove.go83
-rw-r--r--cmd/ship/restart.go57
-rw-r--r--cmd/ship/root.go377
-rw-r--r--cmd/ship/status.go60
-rw-r--r--cmd/ship/templates/webui.html440
-rw-r--r--cmd/ship/ui.go199
-rw-r--r--cmd/ship/version.go17
19 files changed, 2134 insertions, 0 deletions
diff --git a/cmd/ship/env/env.go b/cmd/ship/env/env.go
new file mode 100644
index 0000000..489353a
--- /dev/null
+++ b/cmd/ship/env/env.go
@@ -0,0 +1,17 @@
1package env
2
3import (
4 "github.com/spf13/cobra"
5)
6
7var Cmd = &cobra.Command{
8 Use: "env",
9 Short: "Manage environment variables",
10 Long: "Manage environment variables for deployed applications",
11}
12
13func init() {
14 Cmd.AddCommand(listCmd)
15 Cmd.AddCommand(setCmd)
16 Cmd.AddCommand(unsetCmd)
17}
diff --git a/cmd/ship/env/list.go b/cmd/ship/env/list.go
new file mode 100644
index 0000000..ad76eb6
--- /dev/null
+++ b/cmd/ship/env/list.go
@@ -0,0 +1,69 @@
1package env
2
3import (
4 "fmt"
5 "strings"
6
7 "github.com/bdw/ship/internal/state"
8 "github.com/spf13/cobra"
9)
10
11var listCmd = &cobra.Command{
12 Use: "list <app>",
13 Short: "List environment variables for an app",
14 Args: cobra.ExactArgs(1),
15 RunE: runList,
16}
17
18func runList(cmd *cobra.Command, args []string) error {
19 name := args[0]
20
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
31 if host == "" {
32 return fmt.Errorf("--host is required")
33 }
34
35 app, err := st.GetApp(host, name)
36 if err != nil {
37 return err
38 }
39
40 if app.Type != "app" {
41 return fmt.Errorf("env is only available for apps, not static sites")
42 }
43
44 fmt.Printf("Environment variables for %s:\n\n", name)
45 if len(app.Env) == 0 {
46 fmt.Println(" (none)")
47 } else {
48 for k, v := range app.Env {
49 display := v
50 if isSensitive(k) {
51 display = "***"
52 }
53 fmt.Printf(" %s=%s\n", k, display)
54 }
55 }
56
57 return nil
58}
59
60func isSensitive(key string) bool {
61 key = strings.ToLower(key)
62 sensitiveWords := []string{"key", "secret", "password", "token", "api"}
63 for _, word := range sensitiveWords {
64 if strings.Contains(key, word) {
65 return true
66 }
67 }
68 return false
69}
diff --git a/cmd/ship/env/set.go b/cmd/ship/env/set.go
new file mode 100644
index 0000000..e11d2c9
--- /dev/null
+++ b/cmd/ship/env/set.go
@@ -0,0 +1,132 @@
1package env
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 setCmd = &cobra.Command{
15 Use: "set <app> KEY=VALUE...",
16 Short: "Set environment variable(s)",
17 Long: "Set one or more environment variables for an app. Variables are specified as KEY=VALUE pairs.",
18 Args: cobra.MinimumNArgs(2),
19 RunE: runSet,
20}
21
22func init() {
23 setCmd.Flags().StringP("file", "f", "", "Load environment from file")
24}
25
26func runSet(cmd *cobra.Command, args []string) error {
27 name := args[0]
28 envVars := args[1:]
29
30 st, err := state.Load()
31 if err != nil {
32 return fmt.Errorf("error loading state: %w", err)
33 }
34
35 host, _ := cmd.Flags().GetString("host")
36 if host == "" {
37 host = st.GetDefaultHost()
38 }
39
40 if host == "" {
41 return fmt.Errorf("--host is required")
42 }
43
44 app, err := st.GetApp(host, name)
45 if err != nil {
46 return err
47 }
48
49 if app.Type != "app" {
50 return fmt.Errorf("env is only available for apps, not static sites")
51 }
52
53 if app.Env == nil {
54 app.Env = make(map[string]string)
55 }
56
57 // Set variables from args
58 for _, e := range envVars {
59 parts := strings.SplitN(e, "=", 2)
60 if len(parts) == 2 {
61 app.Env[parts[0]] = parts[1]
62 fmt.Printf("Set %s\n", parts[0])
63 } else {
64 return fmt.Errorf("invalid format: %s (expected KEY=VALUE)", e)
65 }
66 }
67
68 // Set variables from file if provided
69 envFile, _ := cmd.Flags().GetString("file")
70 if envFile != "" {
71 fileEnv, err := parseEnvFile(envFile)
72 if err != nil {
73 return fmt.Errorf("error reading env file: %w", err)
74 }
75 for k, v := range fileEnv {
76 app.Env[k] = v
77 fmt.Printf("Set %s\n", k)
78 }
79 }
80
81 if err := st.Save(); err != nil {
82 return fmt.Errorf("error saving state: %w", err)
83 }
84
85 client, err := ssh.Connect(host)
86 if err != nil {
87 return fmt.Errorf("error connecting to VPS: %w", err)
88 }
89 defer client.Close()
90
91 fmt.Println("-> Updating environment file on VPS...")
92 envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", name)
93 envContent := ""
94 for k, v := range app.Env {
95 envContent += fmt.Sprintf("%s=%s\n", k, v)
96 }
97 if err := client.WriteSudoFile(envFilePath, envContent); err != nil {
98 return fmt.Errorf("error updating env file: %w", err)
99 }
100
101 fmt.Println("-> Restarting service...")
102 if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil {
103 return fmt.Errorf("error restarting service: %w", err)
104 }
105
106 fmt.Println("Environment variables updated successfully")
107 return nil
108}
109
110func parseEnvFile(path string) (map[string]string, error) {
111 file, err := os.Open(path)
112 if err != nil {
113 return nil, err
114 }
115 defer file.Close()
116
117 env := make(map[string]string)
118 scanner := bufio.NewScanner(file)
119 for scanner.Scan() {
120 line := strings.TrimSpace(scanner.Text())
121 if line == "" || strings.HasPrefix(line, "#") {
122 continue
123 }
124
125 parts := strings.SplitN(line, "=", 2)
126 if len(parts) == 2 {
127 env[parts[0]] = parts[1]
128 }
129 }
130
131 return env, scanner.Err()
132}
diff --git a/cmd/ship/env/unset.go b/cmd/ship/env/unset.go
new file mode 100644
index 0000000..7d9a141
--- /dev/null
+++ b/cmd/ship/env/unset.go
@@ -0,0 +1,92 @@
1package env
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 unsetCmd = &cobra.Command{
12 Use: "unset <app> KEY...",
13 Short: "Unset environment variable(s)",
14 Long: "Remove one or more environment variables from an app.",
15 Args: cobra.MinimumNArgs(2),
16 RunE: runUnset,
17}
18
19func runUnset(cmd *cobra.Command, args []string) error {
20 name := args[0]
21 keys := args[1:]
22
23 st, err := state.Load()
24 if err != nil {
25 return fmt.Errorf("error loading state: %w", err)
26 }
27
28 host, _ := cmd.Flags().GetString("host")
29 if host == "" {
30 host = st.GetDefaultHost()
31 }
32
33 if host == "" {
34 return fmt.Errorf("--host is required")
35 }
36
37 app, err := st.GetApp(host, name)
38 if err != nil {
39 return err
40 }
41
42 if app.Type != "app" {
43 return fmt.Errorf("env is only available for apps, not static sites")
44 }
45
46 if app.Env == nil {
47 return fmt.Errorf("no environment variables set")
48 }
49
50 changed := false
51 for _, key := range keys {
52 if _, exists := app.Env[key]; exists {
53 delete(app.Env, key)
54 changed = true
55 fmt.Printf("Unset %s\n", key)
56 } else {
57 fmt.Printf("Warning: %s not found\n", key)
58 }
59 }
60
61 if !changed {
62 return nil
63 }
64
65 if err := st.Save(); err != nil {
66 return fmt.Errorf("error saving state: %w", err)
67 }
68
69 client, err := ssh.Connect(host)
70 if err != nil {
71 return fmt.Errorf("error connecting to VPS: %w", err)
72 }
73 defer client.Close()
74
75 fmt.Println("-> Updating environment file on VPS...")
76 envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", name)
77 envContent := ""
78 for k, v := range app.Env {
79 envContent += fmt.Sprintf("%s=%s\n", k, v)
80 }
81 if err := client.WriteSudoFile(envFilePath, envContent); err != nil {
82 return fmt.Errorf("error updating env file: %w", err)
83 }
84
85 fmt.Println("-> Restarting service...")
86 if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil {
87 return fmt.Errorf("error restarting service: %w", err)
88 }
89
90 fmt.Println("Environment variables updated successfully")
91 return nil
92}
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}
diff --git a/cmd/ship/list.go b/cmd/ship/list.go
new file mode 100644
index 0000000..a5b8df3
--- /dev/null
+++ b/cmd/ship/list.go
@@ -0,0 +1,50 @@
1package main
2
3import (
4 "fmt"
5 "os"
6 "text/tabwriter"
7
8 "github.com/bdw/ship/internal/state"
9 "github.com/spf13/cobra"
10)
11
12var listCmd = &cobra.Command{
13 Use: "list",
14 Short: "List all deployed apps and sites",
15 RunE: runList,
16}
17
18func runList(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 := hostFlag
25 if host == "" {
26 host = st.GetDefaultHost()
27 }
28
29 if host == "" {
30 return fmt.Errorf("--host is required")
31 }
32
33 apps := st.ListApps(host)
34 if len(apps) == 0 {
35 fmt.Printf("No deployments found for %s\n", host)
36 return nil
37 }
38
39 w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
40 fmt.Fprintln(w, "NAME\tTYPE\tDOMAIN\tPORT")
41 for name, app := range apps {
42 port := ""
43 if app.Type == "app" {
44 port = fmt.Sprintf(":%d", app.Port)
45 }
46 fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", name, app.Type, app.Domain, port)
47 }
48 w.Flush()
49 return nil
50}
diff --git a/cmd/ship/logs.go b/cmd/ship/logs.go
new file mode 100644
index 0000000..1932c18
--- /dev/null
+++ b/cmd/ship/logs.go
@@ -0,0 +1,75 @@
1package main
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 logsCmd = &cobra.Command{
12 Use: "logs <app>",
13 Short: "View logs for a deployment",
14 Args: cobra.ExactArgs(1),
15 RunE: runLogs,
16}
17
18func init() {
19 logsCmd.Flags().BoolP("follow", "f", false, "Follow logs")
20 logsCmd.Flags().IntP("lines", "n", 50, "Number of lines to show")
21}
22
23func runLogs(cmd *cobra.Command, args []string) error {
24 name := args[0]
25 follow, _ := cmd.Flags().GetBool("follow")
26 lines, _ := cmd.Flags().GetInt("lines")
27
28 st, err := state.Load()
29 if err != nil {
30 return fmt.Errorf("error loading state: %w", err)
31 }
32
33 host := hostFlag
34 if host == "" {
35 host = st.GetDefaultHost()
36 }
37
38 if host == "" {
39 return fmt.Errorf("--host is required")
40 }
41
42 app, err := st.GetApp(host, name)
43 if err != nil {
44 return err
45 }
46
47 if app.Type != "app" {
48 return fmt.Errorf("logs are only available for apps, not static sites")
49 }
50
51 client, err := ssh.Connect(host)
52 if err != nil {
53 return fmt.Errorf("error connecting to VPS: %w", err)
54 }
55 defer client.Close()
56
57 journalCmd := fmt.Sprintf("journalctl -u %s -n %d", name, lines)
58 if follow {
59 journalCmd += " -f"
60 }
61
62 if follow {
63 if err := client.RunStream(journalCmd); err != nil {
64 return fmt.Errorf("error fetching logs: %w", err)
65 }
66 } else {
67 output, err := client.Run(journalCmd)
68 if err != nil {
69 return fmt.Errorf("error fetching logs: %w", err)
70 }
71 fmt.Print(output)
72 }
73
74 return nil
75}
diff --git a/cmd/ship/main.go b/cmd/ship/main.go
new file mode 100644
index 0000000..680ac58
--- /dev/null
+++ b/cmd/ship/main.go
@@ -0,0 +1,65 @@
1package main
2
3import (
4 "os"
5
6 "github.com/bdw/ship/cmd/ship/env"
7 "github.com/bdw/ship/cmd/ship/host"
8 "github.com/spf13/cobra"
9)
10
11var (
12 // Persistent flags
13 hostFlag string
14
15 // Version info (set via ldflags)
16 version = "dev"
17 commit = "none"
18 date = "unknown"
19)
20
21var rootCmd = &cobra.Command{
22 Use: "ship",
23 Short: "Ship apps and static sites to a VPS with automatic HTTPS",
24 Long: `ship - Ship apps and static sites to a VPS with automatic HTTPS
25
26A CLI tool for deploying applications and static sites to a VPS.
27Uses Caddy for automatic HTTPS and systemd for service management.`,
28 RunE: runDeploy,
29 SilenceUsage: true,
30 SilenceErrors: true,
31}
32
33func init() {
34 // Persistent flags available to all subcommands
35 rootCmd.PersistentFlags().StringVar(&hostFlag, "host", "", "VPS host (SSH config alias or user@host)")
36
37 // Root command (deploy) flags
38 rootCmd.Flags().String("binary", "", "Path to Go binary (for app deployment)")
39 rootCmd.Flags().Bool("static", false, "Deploy as static site")
40 rootCmd.Flags().String("dir", ".", "Directory to deploy (for static sites)")
41 rootCmd.Flags().String("domain", "", "Domain name (required)")
42 rootCmd.Flags().String("name", "", "App name (default: inferred from binary or directory)")
43 rootCmd.Flags().Int("port", 0, "Port override (default: auto-allocate)")
44 rootCmd.Flags().StringArray("env", nil, "Environment variable (KEY=VALUE, can be specified multiple times)")
45 rootCmd.Flags().String("env-file", "", "Path to .env file")
46 rootCmd.Flags().String("args", "", "Arguments to pass to binary")
47 rootCmd.Flags().StringArray("file", nil, "Config file to upload to working directory (can be specified multiple times)")
48
49 // Add subcommands
50 rootCmd.AddCommand(listCmd)
51 rootCmd.AddCommand(logsCmd)
52 rootCmd.AddCommand(statusCmd)
53 rootCmd.AddCommand(restartCmd)
54 rootCmd.AddCommand(removeCmd)
55 rootCmd.AddCommand(env.Cmd)
56 rootCmd.AddCommand(host.Cmd)
57 rootCmd.AddCommand(uiCmd)
58 rootCmd.AddCommand(versionCmd)
59}
60
61func main() {
62 if err := rootCmd.Execute(); err != nil {
63 os.Exit(1)
64 }
65}
diff --git a/cmd/ship/remove.go b/cmd/ship/remove.go
new file mode 100644
index 0000000..922eb8f
--- /dev/null
+++ b/cmd/ship/remove.go
@@ -0,0 +1,83 @@
1package main
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 removeCmd = &cobra.Command{
12 Use: "remove <app>",
13 Aliases: []string{"rm"},
14 Short: "Remove a deployment",
15 Args: cobra.ExactArgs(1),
16 RunE: runRemove,
17}
18
19func runRemove(cmd *cobra.Command, args []string) error {
20 name := args[0]
21
22 st, err := state.Load()
23 if err != nil {
24 return fmt.Errorf("error loading state: %w", err)
25 }
26
27 host := hostFlag
28 if host == "" {
29 host = st.GetDefaultHost()
30 }
31
32 if host == "" {
33 return fmt.Errorf("--host is required")
34 }
35
36 app, err := st.GetApp(host, name)
37 if err != nil {
38 return err
39 }
40
41 fmt.Printf("Removing deployment: %s\n", name)
42
43 client, err := ssh.Connect(host)
44 if err != nil {
45 return fmt.Errorf("error connecting to VPS: %w", err)
46 }
47 defer client.Close()
48
49 if app.Type == "app" {
50 fmt.Println("-> Stopping service...")
51 client.RunSudo(fmt.Sprintf("systemctl stop %s", name))
52 client.RunSudo(fmt.Sprintf("systemctl disable %s", name))
53
54 client.RunSudo(fmt.Sprintf("rm -f /etc/systemd/system/%s.service", name))
55 client.RunSudo("systemctl daemon-reload")
56
57 client.RunSudo(fmt.Sprintf("rm -f /usr/local/bin/%s", name))
58 client.RunSudo(fmt.Sprintf("rm -rf /var/lib/%s", name))
59 client.RunSudo(fmt.Sprintf("rm -f /etc/ship/env/%s.env", name))
60 client.RunSudo(fmt.Sprintf("userdel %s", name))
61 } else {
62 fmt.Println("-> Removing files...")
63 client.RunSudo(fmt.Sprintf("rm -rf /var/www/%s", name))
64 }
65
66 fmt.Println("-> Removing Caddy config...")
67 client.RunSudo(fmt.Sprintf("rm -f /etc/caddy/sites-enabled/%s.caddy", name))
68
69 fmt.Println("-> Reloading Caddy...")
70 if _, err := client.RunSudo("systemctl reload caddy"); err != nil {
71 fmt.Printf("Warning: Error reloading Caddy: %v\n", err)
72 }
73
74 if err := st.RemoveApp(host, name); err != nil {
75 return fmt.Errorf("error updating state: %w", err)
76 }
77 if err := st.Save(); err != nil {
78 return fmt.Errorf("error saving state: %w", err)
79 }
80
81 fmt.Println("Deployment removed successfully")
82 return nil
83}
diff --git a/cmd/ship/restart.go b/cmd/ship/restart.go
new file mode 100644
index 0000000..2c74c62
--- /dev/null
+++ b/cmd/ship/restart.go
@@ -0,0 +1,57 @@
1package main
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 restartCmd = &cobra.Command{
12 Use: "restart <app>",
13 Short: "Restart a deployment",
14 Args: cobra.ExactArgs(1),
15 RunE: runRestart,
16}
17
18func runRestart(cmd *cobra.Command, args []string) error {
19 name := args[0]
20
21 st, err := state.Load()
22 if err != nil {
23 return fmt.Errorf("error loading state: %w", err)
24 }
25
26 host := hostFlag
27 if host == "" {
28 host = st.GetDefaultHost()
29 }
30
31 if host == "" {
32 return fmt.Errorf("--host is required")
33 }
34
35 app, err := st.GetApp(host, name)
36 if err != nil {
37 return err
38 }
39
40 if app.Type != "app" {
41 return fmt.Errorf("restart is only available for apps, not static sites")
42 }
43
44 client, err := ssh.Connect(host)
45 if err != nil {
46 return fmt.Errorf("error connecting to VPS: %w", err)
47 }
48 defer client.Close()
49
50 fmt.Printf("Restarting %s...\n", name)
51 if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil {
52 return fmt.Errorf("error restarting service: %w", err)
53 }
54
55 fmt.Println("Service restarted successfully")
56 return nil
57}
diff --git a/cmd/ship/root.go b/cmd/ship/root.go
new file mode 100644
index 0000000..e5d6753
--- /dev/null
+++ b/cmd/ship/root.go
@@ -0,0 +1,377 @@
1package main
2
3import (
4 "bufio"
5 "fmt"
6 "os"
7 "path/filepath"
8 "strconv"
9 "strings"
10
11 "github.com/bdw/ship/internal/ssh"
12 "github.com/bdw/ship/internal/state"
13 "github.com/bdw/ship/internal/templates"
14 "github.com/spf13/cobra"
15)
16
17func runDeploy(cmd *cobra.Command, args []string) error {
18 flags := cmd.Flags()
19
20 binary, _ := flags.GetString("binary")
21 static, _ := flags.GetBool("static")
22 dir, _ := flags.GetString("dir")
23 domain, _ := flags.GetString("domain")
24 name, _ := flags.GetString("name")
25 port, _ := flags.GetInt("port")
26 envVars, _ := flags.GetStringArray("env")
27 envFile, _ := flags.GetString("env-file")
28 binaryArgs, _ := flags.GetString("args")
29 files, _ := flags.GetStringArray("file")
30
31 // Get host from flag or state default
32 host := hostFlag
33 if host == "" {
34 st, err := state.Load()
35 if err != nil {
36 return fmt.Errorf("error loading state: %w", err)
37 }
38 host = st.GetDefaultHost()
39 }
40
41 // If no flags provided, show help
42 if domain == "" && binary == "" && !static {
43 return cmd.Help()
44 }
45
46 if host == "" || domain == "" {
47 return fmt.Errorf("--host and --domain are required")
48 }
49
50 if static {
51 return deployStatic(host, domain, name, dir)
52 }
53 return deployApp(host, domain, name, binary, port, envVars, envFile, binaryArgs, files)
54}
55
56func deployApp(host, domain, name, binaryPath string, portOverride int, envVars []string, envFile, args string, files []string) error {
57 if binaryPath == "" {
58 return fmt.Errorf("--binary is required")
59 }
60
61 if name == "" {
62 name = filepath.Base(binaryPath)
63 }
64
65 if _, err := os.Stat(binaryPath); err != nil {
66 return fmt.Errorf("binary not found: %s", binaryPath)
67 }
68
69 fmt.Printf("Deploying app: %s\n", name)
70 fmt.Printf(" Domain: %s\n", domain)
71 fmt.Printf(" Binary: %s\n", binaryPath)
72
73 st, err := state.Load()
74 if err != nil {
75 return fmt.Errorf("error loading state: %w", err)
76 }
77
78 existingApp, _ := st.GetApp(host, name)
79 var port int
80 if existingApp != nil {
81 port = existingApp.Port
82 fmt.Printf(" Updating existing deployment (port %d)\n", port)
83 } else {
84 if portOverride > 0 {
85 port = portOverride
86 } else {
87 port = st.AllocatePort(host)
88 }
89 fmt.Printf(" Allocated port: %d\n", port)
90 }
91
92 env := make(map[string]string)
93 if existingApp != nil {
94 for k, v := range existingApp.Env {
95 env[k] = v
96 }
97 if args == "" && existingApp.Args != "" {
98 args = existingApp.Args
99 }
100 if len(files) == 0 && len(existingApp.Files) > 0 {
101 files = existingApp.Files
102 }
103 }
104
105 for _, e := range envVars {
106 parts := strings.SplitN(e, "=", 2)
107 if len(parts) == 2 {
108 env[parts[0]] = parts[1]
109 }
110 }
111
112 if envFile != "" {
113 fileEnv, err := parseEnvFile(envFile)
114 if err != nil {
115 return fmt.Errorf("error reading env file: %w", err)
116 }
117 for k, v := range fileEnv {
118 env[k] = v
119 }
120 }
121
122 env["PORT"] = strconv.Itoa(port)
123
124 client, err := ssh.Connect(host)
125 if err != nil {
126 return fmt.Errorf("error connecting to VPS: %w", err)
127 }
128 defer client.Close()
129
130 fmt.Println("-> Uploading binary...")
131 remoteTmpPath := fmt.Sprintf("/tmp/%s", name)
132 if err := client.Upload(binaryPath, remoteTmpPath); err != nil {
133 return fmt.Errorf("error uploading binary: %w", err)
134 }
135
136 fmt.Println("-> Creating system user...")
137 client.RunSudo(fmt.Sprintf("useradd -r -s /bin/false %s", name))
138
139 fmt.Println("-> Setting up directories...")
140 workDir := fmt.Sprintf("/var/lib/%s", name)
141 if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", workDir)); err != nil {
142 return fmt.Errorf("error creating work directory: %w", err)
143 }
144 if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, workDir)); err != nil {
145 return fmt.Errorf("error setting work directory ownership: %w", err)
146 }
147
148 fmt.Println("-> Installing binary...")
149 binaryDest := fmt.Sprintf("/usr/local/bin/%s", name)
150 if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", remoteTmpPath, binaryDest)); err != nil {
151 return fmt.Errorf("error moving binary: %w", err)
152 }
153 if _, err := client.RunSudo(fmt.Sprintf("chmod +x %s", binaryDest)); err != nil {
154 return fmt.Errorf("error making binary executable: %w", err)
155 }
156
157 if len(files) > 0 {
158 fmt.Println("-> Uploading config files...")
159 for _, file := range files {
160 if _, err := os.Stat(file); err != nil {
161 return fmt.Errorf("config file not found: %s", file)
162 }
163
164 remotePath := fmt.Sprintf("%s/%s", workDir, filepath.Base(file))
165 remoteTmpPath := fmt.Sprintf("/tmp/%s_%s", name, filepath.Base(file))
166
167 if err := client.Upload(file, remoteTmpPath); err != nil {
168 return fmt.Errorf("error uploading config file %s: %w", file, err)
169 }
170
171 if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", remoteTmpPath, remotePath)); err != nil {
172 return fmt.Errorf("error moving config file %s: %w", file, err)
173 }
174
175 if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, remotePath)); err != nil {
176 return fmt.Errorf("error setting config file ownership %s: %w", file, err)
177 }
178
179 fmt.Printf(" Uploaded: %s\n", file)
180 }
181 }
182
183 fmt.Println("-> Creating environment file...")
184 envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", name)
185 envContent := ""
186 for k, v := range env {
187 envContent += fmt.Sprintf("%s=%s\n", k, v)
188 }
189 if err := client.WriteSudoFile(envFilePath, envContent); err != nil {
190 return fmt.Errorf("error creating env file: %w", err)
191 }
192 if _, err := client.RunSudo(fmt.Sprintf("chmod 600 %s", envFilePath)); err != nil {
193 return fmt.Errorf("error setting env file permissions: %w", err)
194 }
195 if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, envFilePath)); err != nil {
196 return fmt.Errorf("error setting env file ownership: %w", err)
197 }
198
199 fmt.Println("-> Creating systemd service...")
200 serviceContent, err := templates.SystemdService(map[string]string{
201 "Name": name,
202 "User": name,
203 "WorkDir": workDir,
204 "BinaryPath": binaryDest,
205 "Port": strconv.Itoa(port),
206 "EnvFile": envFilePath,
207 "Args": args,
208 })
209 if err != nil {
210 return fmt.Errorf("error generating systemd unit: %w", err)
211 }
212
213 servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name)
214 if err := client.WriteSudoFile(servicePath, serviceContent); err != nil {
215 return fmt.Errorf("error creating systemd unit: %w", err)
216 }
217
218 fmt.Println("-> Configuring Caddy...")
219 caddyContent, err := templates.AppCaddy(map[string]string{
220 "Domain": domain,
221 "Port": strconv.Itoa(port),
222 })
223 if err != nil {
224 return fmt.Errorf("error generating Caddy config: %w", err)
225 }
226
227 caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name)
228 if err := client.WriteSudoFile(caddyPath, caddyContent); err != nil {
229 return fmt.Errorf("error creating Caddy config: %w", err)
230 }
231
232 fmt.Println("-> Reloading systemd...")
233 if _, err := client.RunSudo("systemctl daemon-reload"); err != nil {
234 return fmt.Errorf("error reloading systemd: %w", err)
235 }
236
237 fmt.Println("-> Starting service...")
238 if _, err := client.RunSudo(fmt.Sprintf("systemctl enable %s", name)); err != nil {
239 return fmt.Errorf("error enabling service: %w", err)
240 }
241 if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil {
242 return fmt.Errorf("error starting service: %w", err)
243 }
244
245 fmt.Println("-> Reloading Caddy...")
246 if _, err := client.RunSudo("systemctl reload caddy"); err != nil {
247 return fmt.Errorf("error reloading Caddy: %w", err)
248 }
249
250 st.AddApp(host, name, &state.App{
251 Type: "app",
252 Domain: domain,
253 Port: port,
254 Env: env,
255 Args: args,
256 Files: files,
257 })
258 if err := st.Save(); err != nil {
259 return fmt.Errorf("error saving state: %w", err)
260 }
261
262 fmt.Printf("\n App deployed successfully!\n")
263 fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", domain)
264 return nil
265}
266
267func deployStatic(host, domain, name, dir string) error {
268 if name == "" {
269 name = domain
270 }
271
272 if _, err := os.Stat(dir); err != nil {
273 return fmt.Errorf("directory not found: %s", dir)
274 }
275
276 fmt.Printf("Deploying static site: %s\n", name)
277 fmt.Printf(" Domain: %s\n", domain)
278 fmt.Printf(" Directory: %s\n", dir)
279
280 st, err := state.Load()
281 if err != nil {
282 return fmt.Errorf("error loading state: %w", err)
283 }
284
285 client, err := ssh.Connect(host)
286 if err != nil {
287 return fmt.Errorf("error connecting to VPS: %w", err)
288 }
289 defer client.Close()
290
291 remoteDir := fmt.Sprintf("/var/www/%s", name)
292 fmt.Println("-> Creating remote directory...")
293 if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", remoteDir)); err != nil {
294 return fmt.Errorf("error creating remote directory: %w", err)
295 }
296
297 currentUser, err := client.Run("whoami")
298 if err != nil {
299 return fmt.Errorf("error getting current user: %w", err)
300 }
301 currentUser = strings.TrimSpace(currentUser)
302
303 if _, err := client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", currentUser, currentUser, remoteDir)); err != nil {
304 return fmt.Errorf("error setting temporary ownership: %w", err)
305 }
306
307 fmt.Println("-> Uploading files...")
308 if err := client.UploadDir(dir, remoteDir); err != nil {
309 return fmt.Errorf("error uploading files: %w", err)
310 }
311
312 fmt.Println("-> Setting permissions...")
313 if _, err := client.RunSudo(fmt.Sprintf("chown -R www-data:www-data %s", remoteDir)); err != nil {
314 return fmt.Errorf("error setting ownership: %w", err)
315 }
316 if _, err := client.RunSudo(fmt.Sprintf("chmod -R 755 %s", remoteDir)); err != nil {
317 return fmt.Errorf("error setting directory permissions: %w", err)
318 }
319 if _, err := client.RunSudo(fmt.Sprintf("find %s -type f -exec chmod 644 {} \\;", remoteDir)); err != nil {
320 return fmt.Errorf("error setting file permissions: %w", err)
321 }
322
323 fmt.Println("-> Configuring Caddy...")
324 caddyContent, err := templates.StaticCaddy(map[string]string{
325 "Domain": domain,
326 "RootDir": remoteDir,
327 })
328 if err != nil {
329 return fmt.Errorf("error generating Caddy config: %w", err)
330 }
331
332 caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name)
333 if err := client.WriteSudoFile(caddyPath, caddyContent); err != nil {
334 return fmt.Errorf("error creating Caddy config: %w", err)
335 }
336
337 fmt.Println("-> Reloading Caddy...")
338 if _, err := client.RunSudo("systemctl reload caddy"); err != nil {
339 return fmt.Errorf("error reloading Caddy: %w", err)
340 }
341
342 st.AddApp(host, name, &state.App{
343 Type: "static",
344 Domain: domain,
345 })
346 if err := st.Save(); err != nil {
347 return fmt.Errorf("error saving state: %w", err)
348 }
349
350 fmt.Printf("\n Static site deployed successfully!\n")
351 fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", domain)
352 return nil
353}
354
355func parseEnvFile(path string) (map[string]string, error) {
356 file, err := os.Open(path)
357 if err != nil {
358 return nil, err
359 }
360 defer file.Close()
361
362 env := make(map[string]string)
363 scanner := bufio.NewScanner(file)
364 for scanner.Scan() {
365 line := strings.TrimSpace(scanner.Text())
366 if line == "" || strings.HasPrefix(line, "#") {
367 continue
368 }
369
370 parts := strings.SplitN(line, "=", 2)
371 if len(parts) == 2 {
372 env[parts[0]] = parts[1]
373 }
374 }
375
376 return env, scanner.Err()
377}
diff --git a/cmd/ship/status.go b/cmd/ship/status.go
new file mode 100644
index 0000000..03c548b
--- /dev/null
+++ b/cmd/ship/status.go
@@ -0,0 +1,60 @@
1package main
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 <app>",
13 Short: "Check status of a deployment",
14 Args: cobra.ExactArgs(1),
15 RunE: runStatus,
16}
17
18func runStatus(cmd *cobra.Command, args []string) error {
19 name := args[0]
20
21 st, err := state.Load()
22 if err != nil {
23 return fmt.Errorf("error loading state: %w", err)
24 }
25
26 host := hostFlag
27 if host == "" {
28 host = st.GetDefaultHost()
29 }
30
31 if host == "" {
32 return fmt.Errorf("--host is required")
33 }
34
35 app, err := st.GetApp(host, name)
36 if err != nil {
37 return err
38 }
39
40 if app.Type != "app" {
41 return fmt.Errorf("status is only available for apps, not static sites")
42 }
43
44 client, err := ssh.Connect(host)
45 if err != nil {
46 return fmt.Errorf("error connecting to VPS: %w", err)
47 }
48 defer client.Close()
49
50 output, err := client.RunSudo(fmt.Sprintf("systemctl status %s", name))
51 if err != nil {
52 // systemctl status returns non-zero for non-active services
53 // but we still want to show the output
54 fmt.Print(output)
55 return nil
56 }
57
58 fmt.Print(output)
59 return nil
60}
diff --git a/cmd/ship/templates/webui.html b/cmd/ship/templates/webui.html
new file mode 100644
index 0000000..052d599
--- /dev/null
+++ b/cmd/ship/templates/webui.html
@@ -0,0 +1,440 @@
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>Deploy - Web UI</title>
7 <style>
8 * {
9 margin: 0;
10 padding: 0;
11 box-sizing: border-box;
12 }
13
14 body {
15 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
16 background: #f5f5f5;
17 color: #333;
18 line-height: 1.6;
19 }
20
21 header {
22 background: #2c3e50;
23 color: white;
24 padding: 1.5rem 2rem;
25 box-shadow: 0 2px 4px rgba(0,0,0,0.1);
26 }
27
28 header h1 {
29 font-size: 1.8rem;
30 font-weight: 600;
31 }
32
33 header p {
34 color: #bdc3c7;
35 margin-top: 0.25rem;
36 font-size: 0.9rem;
37 }
38
39 .container {
40 max-width: 1200px;
41 margin: 2rem auto;
42 padding: 0 2rem;
43 }
44
45 .empty-state {
46 text-align: center;
47 padding: 4rem 2rem;
48 background: white;
49 border-radius: 8px;
50 box-shadow: 0 2px 4px rgba(0,0,0,0.1);
51 }
52
53 .empty-state h2 {
54 color: #7f8c8d;
55 font-weight: 500;
56 margin-bottom: 0.5rem;
57 }
58
59 .empty-state p {
60 color: #95a5a6;
61 }
62
63 .host-section {
64 margin-bottom: 2rem;
65 }
66
67 .host-header {
68 background: white;
69 padding: 1rem 1.5rem;
70 border-radius: 8px 8px 0 0;
71 border-left: 4px solid #3498db;
72 box-shadow: 0 2px 4px rgba(0,0,0,0.1);
73 }
74
75 .host-header h2 {
76 font-size: 1.3rem;
77 color: #2c3e50;
78 font-weight: 600;
79 }
80
81 .apps-grid {
82 display: grid;
83 grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
84 gap: 1rem;
85 padding: 1rem;
86 background: #ecf0f1;
87 border-radius: 0 0 8px 8px;
88 }
89
90 .app-card {
91 background: white;
92 padding: 1.5rem;
93 border-radius: 6px;
94 box-shadow: 0 1px 3px rgba(0,0,0,0.1);
95 transition: transform 0.2s, box-shadow 0.2s;
96 }
97
98 .app-card:hover {
99 transform: translateY(-2px);
100 box-shadow: 0 4px 8px rgba(0,0,0,0.15);
101 }
102
103 .app-header {
104 display: flex;
105 justify-content: space-between;
106 align-items: center;
107 margin-bottom: 1rem;
108 }
109
110 .app-name {
111 font-size: 1.2rem;
112 font-weight: 600;
113 color: #2c3e50;
114 }
115
116 .app-type {
117 padding: 0.25rem 0.75rem;
118 border-radius: 12px;
119 font-size: 0.75rem;
120 font-weight: 500;
121 text-transform: uppercase;
122 }
123
124 .app-type.app {
125 background: #3498db;
126 color: white;
127 }
128
129 .app-type.static {
130 background: #2ecc71;
131 color: white;
132 }
133
134 .app-info {
135 margin-bottom: 0.5rem;
136 }
137
138 .app-info-label {
139 color: #7f8c8d;
140 font-size: 0.85rem;
141 font-weight: 500;
142 margin-bottom: 0.25rem;
143 }
144
145 .app-info-value {
146 color: #2c3e50;
147 font-family: 'Monaco', 'Courier New', monospace;
148 font-size: 0.9rem;
149 word-break: break-all;
150 }
151
152 .app-info-value a {
153 color: #3498db;
154 text-decoration: none;
155 }
156
157 .app-info-value a:hover {
158 text-decoration: underline;
159 }
160
161 .config-buttons {
162 margin-top: 1rem;
163 padding-top: 1rem;
164 border-top: 1px solid #ecf0f1;
165 display: flex;
166 gap: 0.5rem;
167 flex-wrap: wrap;
168 }
169
170 .config-btn {
171 padding: 0.4rem 0.8rem;
172 background: #3498db;
173 color: white;
174 border: none;
175 border-radius: 4px;
176 font-size: 0.8rem;
177 cursor: pointer;
178 transition: background 0.2s;
179 }
180
181 .config-btn:hover {
182 background: #2980b9;
183 }
184
185 .config-btn.secondary {
186 background: #95a5a6;
187 }
188
189 .config-btn.secondary:hover {
190 background: #7f8c8d;
191 }
192
193 .modal {
194 display: none;
195 position: fixed;
196 z-index: 1000;
197 left: 0;
198 top: 0;
199 width: 100%;
200 height: 100%;
201 overflow: auto;
202 background-color: rgba(0,0,0,0.6);
203 }
204
205 .modal.active {
206 display: block;
207 }
208
209 .modal-content {
210 background-color: #fefefe;
211 margin: 5% auto;
212 padding: 0;
213 border-radius: 8px;
214 width: 90%;
215 max-width: 900px;
216 max-height: 80vh;
217 display: flex;
218 flex-direction: column;
219 box-shadow: 0 4px 20px rgba(0,0,0,0.3);
220 }
221
222 .modal-header {
223 padding: 1.5rem;
224 border-bottom: 1px solid #ecf0f1;
225 display: flex;
226 justify-content: space-between;
227 align-items: center;
228 }
229
230 .modal-header h3 {
231 margin: 0;
232 color: #2c3e50;
233 }
234
235 .modal-path {
236 font-family: 'Monaco', 'Courier New', monospace;
237 font-size: 0.85rem;
238 color: #7f8c8d;
239 margin-top: 0.25rem;
240 }
241
242 .close {
243 color: #aaa;
244 font-size: 28px;
245 font-weight: bold;
246 cursor: pointer;
247 line-height: 1;
248 }
249
250 .close:hover {
251 color: #000;
252 }
253
254 .modal-body {
255 padding: 1.5rem;
256 overflow: auto;
257 flex: 1;
258 }
259
260 .config-content {
261 background: #282c34;
262 color: #abb2bf;
263 padding: 1rem;
264 border-radius: 4px;
265 font-family: 'Monaco', 'Courier New', monospace;
266 font-size: 0.85rem;
267 line-height: 1.5;
268 white-space: pre-wrap;
269 word-wrap: break-word;
270 overflow-x: auto;
271 text-align: left;
272 }
273
274 .loading {
275 text-align: center;
276 padding: 2rem;
277 color: #7f8c8d;
278 }
279
280 .refresh-info {
281 text-align: center;
282 color: #7f8c8d;
283 font-size: 0.9rem;
284 margin-top: 2rem;
285 padding: 1rem;
286 }
287 </style>
288</head>
289<body>
290 <header>
291 <h1>Deploy Web UI</h1>
292 <p>Manage your VPS deployments</p>
293 </header>
294
295 <div class="container">
296 {{if not .Hosts}}
297 <div class="empty-state">
298 <h2>No deployments found</h2>
299 <p>Use the CLI to deploy your first app or static site</p>
300 </div>
301 {{else}}
302 {{range .Hosts}}
303 <div class="host-section">
304 <div class="host-header">
305 <h2>{{.Host}}</h2>
306 </div>
307 <div class="apps-grid">
308 {{range .Apps}}
309 <div class="app-card">
310 <div class="app-header">
311 <div class="app-name">{{.Name}}</div>
312 <div class="app-type {{.Type}}">{{.Type}}</div>
313 </div>
314
315 <div class="app-info">
316 <div class="app-info-label">Domain</div>
317 <div class="app-info-value">
318 <a href="https://{{.Domain}}" target="_blank">{{.Domain}}</a>
319 </div>
320 </div>
321
322 {{if eq .Type "app"}}
323 <div class="app-info">
324 <div class="app-info-label">Port</div>
325 <div class="app-info-value">{{.Port}}</div>
326 </div>
327 {{end}}
328
329 <div class="config-buttons">
330 {{if eq .Type "app"}}
331 <button class="config-btn" onclick="showConfig('{{.Host}}', '{{.Name}}', 'systemd')">Systemd Unit</button>
332 {{end}}
333 <button class="config-btn" onclick="showConfig('{{.Host}}', '{{.Name}}', 'caddy')">Caddy Config</button>
334 {{if .Env}}
335 <button class="config-btn secondary" onclick="showConfig('{{.Host}}', '{{.Name}}', 'env')">Environment</button>
336 {{end}}
337 </div>
338 </div>
339 {{end}}
340 </div>
341 </div>
342 {{end}}
343 {{end}}
344
345 <div class="refresh-info">
346 Refresh the page to see latest changes
347 </div>
348 </div>
349
350 <!-- Modal -->
351 <div id="configModal" class="modal">
352 <div class="modal-content">
353 <div class="modal-header">
354 <div>
355 <h3 id="modalTitle">Configuration</h3>
356 <div class="modal-path" id="modalPath"></div>
357 </div>
358 <span class="close" onclick="closeModal()">&times;</span>
359 </div>
360 <div class="modal-body">
361 <div id="modalContent" class="loading">Loading...</div>
362 </div>
363 </div>
364 </div>
365
366 <script>
367 const modal = document.getElementById('configModal');
368 const modalTitle = document.getElementById('modalTitle');
369 const modalPath = document.getElementById('modalPath');
370 const modalContent = document.getElementById('modalContent');
371
372 function closeModal() {
373 modal.classList.remove('active');
374 }
375
376 window.onclick = function(event) {
377 if (event.target == modal) {
378 closeModal();
379 }
380 }
381
382 async function showConfig(host, app, type) {
383 modal.classList.add('active');
384 modalContent.innerHTML = '<div class="loading">Loading...</div>';
385
386 const titles = {
387 'systemd': 'Systemd Service Unit',
388 'caddy': 'Caddy Configuration',
389 'env': 'Environment Variables'
390 };
391
392 modalTitle.textContent = titles[type];
393
394 try {
395 const response = await fetch(`/api/configs?host=${encodeURIComponent(host)}&app=${encodeURIComponent(app)}`);
396 if (!response.ok) {
397 throw new Error(`HTTP error! status: ${response.status}`);
398 }
399 const configs = await response.json();
400
401 let content = '';
402 let path = '';
403
404 switch(type) {
405 case 'systemd':
406 content = configs.systemd || 'No systemd config available';
407 path = configs.systemdPath || '';
408 break;
409 case 'caddy':
410 content = configs.caddy || 'No Caddy config available';
411 path = configs.caddyPath || '';
412 break;
413 case 'env':
414 content = configs.env || 'No environment variables';
415 path = configs.envPath || '';
416 break;
417 }
418
419 modalPath.textContent = path;
420 modalContent.innerHTML = `<div class="config-content">${escapeHtml(content)}</div>`;
421 } catch (error) {
422 modalContent.innerHTML = `<div class="loading">Error loading config: ${error.message}</div>`;
423 }
424 }
425
426 function escapeHtml(text) {
427 const div = document.createElement('div');
428 div.textContent = text;
429 return div.innerHTML;
430 }
431
432 // Close modal with Escape key
433 document.addEventListener('keydown', function(event) {
434 if (event.key === 'Escape') {
435 closeModal();
436 }
437 });
438 </script>
439</body>
440</html>
diff --git a/cmd/ship/ui.go b/cmd/ship/ui.go
new file mode 100644
index 0000000..cfaea08
--- /dev/null
+++ b/cmd/ship/ui.go
@@ -0,0 +1,199 @@
1package main
2
3import (
4 "embed"
5 "encoding/json"
6 "fmt"
7 "html/template"
8 "net/http"
9 "sort"
10 "strconv"
11
12 "github.com/bdw/ship/internal/state"
13 "github.com/bdw/ship/internal/templates"
14 "github.com/spf13/cobra"
15)
16
17//go:embed templates/*.html
18var templatesFS embed.FS
19
20var uiCmd = &cobra.Command{
21 Use: "ui",
22 Short: "Launch web management UI",
23 RunE: runUI,
24}
25
26func init() {
27 uiCmd.Flags().StringP("port", "p", "8080", "Port to run the web UI on")
28}
29
30func runUI(cmd *cobra.Command, args []string) error {
31 port, _ := cmd.Flags().GetString("port")
32
33 tmpl, err := template.ParseFS(templatesFS, "templates/webui.html")
34 if err != nil {
35 return fmt.Errorf("error parsing template: %w", err)
36 }
37
38 http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
39 st, err := state.Load()
40 if err != nil {
41 http.Error(w, fmt.Sprintf("Error loading state: %v", err), http.StatusInternalServerError)
42 return
43 }
44
45 type AppData struct {
46 Name string
47 Type string
48 Domain string
49 Port int
50 Env map[string]string
51 Host string
52 }
53
54 type HostData struct {
55 Host string
56 Apps []AppData
57 }
58
59 var hosts []HostData
60 for hostName, host := range st.Hosts {
61 var apps []AppData
62 for appName, app := range host.Apps {
63 apps = append(apps, AppData{
64 Name: appName,
65 Type: app.Type,
66 Domain: app.Domain,
67 Port: app.Port,
68 Env: app.Env,
69 Host: hostName,
70 })
71 }
72
73 sort.Slice(apps, func(i, j int) bool {
74 return apps[i].Name < apps[j].Name
75 })
76
77 hosts = append(hosts, HostData{
78 Host: hostName,
79 Apps: apps,
80 })
81 }
82
83 sort.Slice(hosts, func(i, j int) bool {
84 return hosts[i].Host < hosts[j].Host
85 })
86
87 data := struct {
88 Hosts []HostData
89 }{
90 Hosts: hosts,
91 }
92
93 if err := tmpl.Execute(w, data); err != nil {
94 http.Error(w, fmt.Sprintf("Error rendering template: %v", err), http.StatusInternalServerError)
95 return
96 }
97 })
98
99 http.HandleFunc("/api/state", func(w http.ResponseWriter, r *http.Request) {
100 st, err := state.Load()
101 if err != nil {
102 http.Error(w, fmt.Sprintf("Error loading state: %v", err), http.StatusInternalServerError)
103 return
104 }
105
106 w.Header().Set("Content-Type", "application/json")
107 json.NewEncoder(w).Encode(st)
108 })
109
110 http.HandleFunc("/api/configs", func(w http.ResponseWriter, r *http.Request) {
111 host := r.URL.Query().Get("host")
112 appName := r.URL.Query().Get("app")
113
114 if host == "" || appName == "" {
115 http.Error(w, "Missing host or app parameter", http.StatusBadRequest)
116 return
117 }
118
119 st, err := state.Load()
120 if err != nil {
121 http.Error(w, fmt.Sprintf("Error loading state: %v", err), http.StatusInternalServerError)
122 return
123 }
124
125 app, err := st.GetApp(host, appName)
126 if err != nil {
127 http.Error(w, fmt.Sprintf("App not found: %v", err), http.StatusNotFound)
128 return
129 }
130
131 configs := make(map[string]string)
132
133 if app.Env != nil && len(app.Env) > 0 {
134 envContent := ""
135 for k, v := range app.Env {
136 envContent += fmt.Sprintf("%s=%s\n", k, v)
137 }
138 configs["env"] = envContent
139 configs["envPath"] = fmt.Sprintf("/etc/ship/env/%s.env", appName)
140 }
141
142 if app.Type == "app" {
143 workDir := fmt.Sprintf("/var/lib/%s", appName)
144 binaryPath := fmt.Sprintf("/usr/local/bin/%s", appName)
145 envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", appName)
146
147 serviceContent, err := templates.SystemdService(map[string]string{
148 "Name": appName,
149 "User": appName,
150 "WorkDir": workDir,
151 "BinaryPath": binaryPath,
152 "Port": strconv.Itoa(app.Port),
153 "EnvFile": envFilePath,
154 "Args": app.Args,
155 })
156 if err != nil {
157 http.Error(w, fmt.Sprintf("Error rendering systemd service: %v", err), http.StatusInternalServerError)
158 return
159 }
160 configs["systemd"] = serviceContent
161 configs["systemdPath"] = fmt.Sprintf("/etc/systemd/system/%s.service", appName)
162
163 caddyContent, err := templates.AppCaddy(map[string]string{
164 "Domain": app.Domain,
165 "Port": strconv.Itoa(app.Port),
166 })
167 if err != nil {
168 http.Error(w, fmt.Sprintf("Error rendering Caddy config: %v", err), http.StatusInternalServerError)
169 return
170 }
171 configs["caddy"] = caddyContent
172 configs["caddyPath"] = fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", appName)
173 } else if app.Type == "static" {
174 remoteDir := fmt.Sprintf("/var/www/%s", appName)
175 caddyContent, err := templates.StaticCaddy(map[string]string{
176 "Domain": app.Domain,
177 "RootDir": remoteDir,
178 })
179 if err != nil {
180 http.Error(w, fmt.Sprintf("Error rendering Caddy config: %v", err), http.StatusInternalServerError)
181 return
182 }
183 configs["caddy"] = caddyContent
184 configs["caddyPath"] = fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", appName)
185 }
186
187 w.Header().Set("Content-Type", "application/json")
188 json.NewEncoder(w).Encode(configs)
189 })
190
191 addr := fmt.Sprintf("localhost:%s", port)
192 fmt.Printf("Starting web UI on http://%s\n", addr)
193 fmt.Printf("Press Ctrl+C to stop\n")
194
195 if err := http.ListenAndServe(addr, nil); err != nil {
196 return fmt.Errorf("error starting server: %w", err)
197 }
198 return nil
199}
diff --git a/cmd/ship/version.go b/cmd/ship/version.go
new file mode 100644
index 0000000..6e4314a
--- /dev/null
+++ b/cmd/ship/version.go
@@ -0,0 +1,17 @@
1package main
2
3import (
4 "fmt"
5
6 "github.com/spf13/cobra"
7)
8
9var versionCmd = &cobra.Command{
10 Use: "version",
11 Short: "Show version information",
12 Run: func(cmd *cobra.Command, args []string) {
13 fmt.Printf("ship version %s\n", version)
14 fmt.Printf(" commit: %s\n", commit)
15 fmt.Printf(" built: %s\n", date)
16 },
17}