diff options
| author | Clawd <ai@clawd.bot> | 2026-02-17 08:11:19 -0800 |
|---|---|---|
| committer | Clawd <ai@clawd.bot> | 2026-02-17 08:11:19 -0800 |
| commit | 6f02ec84a8299fc5577f147cc8741c8a4b162b64 (patch) | |
| tree | 020f3690e92732dcba723be0cfaef649f46de137 /cmd | |
| parent | 4b5a2656df13181b637c59c29ff31751e11cf22a (diff) | |
| parent | 05ea98df57599775c1d5bfea336012b075531670 (diff) | |
Merge agent-mode: v2 rewrite complete
- Removed all v1 code (-2800 lines)
- Simplified state to just default_host + base_domain
- Atomic port allocation via flock
- --container-port flag for Docker
- Custom domains shown in ship list
- Caddyfiles preserved on redeploy
- JSON output by default, --pretty for humans
Diffstat (limited to 'cmd')
| -rw-r--r-- | cmd/ship/commands_v2.go | 365 | ||||
| -rw-r--r-- | cmd/ship/deploy.go | 664 | ||||
| -rw-r--r-- | cmd/ship/deploy_cmd.go | 141 | ||||
| -rw-r--r-- | cmd/ship/deploy_impl_v2.go | 397 | ||||
| -rw-r--r-- | cmd/ship/deploy_v2.go | 210 | ||||
| -rw-r--r-- | cmd/ship/env/env.go | 17 | ||||
| -rw-r--r-- | cmd/ship/env/list.go | 72 | ||||
| -rw-r--r-- | cmd/ship/env/set.go | 135 | ||||
| -rw-r--r-- | cmd/ship/env/unset.go | 95 | ||||
| -rw-r--r-- | cmd/ship/host/host.go | 21 | ||||
| -rw-r--r-- | cmd/ship/host/init.go | 316 | ||||
| -rw-r--r-- | cmd/ship/host/set_domain.go | 76 | ||||
| -rw-r--r-- | cmd/ship/host/ssh.go | 45 | ||||
| -rw-r--r-- | cmd/ship/host/status.go | 108 | ||||
| -rw-r--r-- | cmd/ship/host/update.go | 93 | ||||
| -rw-r--r-- | cmd/ship/host_v2.go | 445 | ||||
| -rw-r--r-- | cmd/ship/init.go | 268 | ||||
| -rw-r--r-- | cmd/ship/list.go | 61 | ||||
| -rw-r--r-- | cmd/ship/logs.go | 78 | ||||
| -rw-r--r-- | cmd/ship/main.go | 9 | ||||
| -rw-r--r-- | cmd/ship/remove.go | 109 | ||||
| -rw-r--r-- | cmd/ship/restart.go | 60 | ||||
| -rw-r--r-- | cmd/ship/root.go | 97 | ||||
| -rw-r--r-- | cmd/ship/root_v2.go | 98 | ||||
| -rw-r--r-- | cmd/ship/status.go | 63 | ||||
| -rw-r--r-- | cmd/ship/ui.go | 199 | ||||
| -rw-r--r-- | cmd/ship/validate.go | 9 | ||||
| -rw-r--r-- | cmd/ship/version.go | 17 |
28 files changed, 1518 insertions, 2750 deletions
diff --git a/cmd/ship/commands_v2.go b/cmd/ship/commands_v2.go new file mode 100644 index 0000000..1b0d09c --- /dev/null +++ b/cmd/ship/commands_v2.go | |||
| @@ -0,0 +1,365 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "strconv" | ||
| 6 | "strings" | ||
| 7 | "time" | ||
| 8 | |||
| 9 | "github.com/bdw/ship/internal/output" | ||
| 10 | "github.com/bdw/ship/internal/ssh" | ||
| 11 | "github.com/bdw/ship/internal/state" | ||
| 12 | "github.com/spf13/cobra" | ||
| 13 | ) | ||
| 14 | |||
| 15 | // listV2Cmd lists all deployments | ||
| 16 | var listV2Cmd = &cobra.Command{ | ||
| 17 | Use: "list", | ||
| 18 | Short: "List all deployments", | ||
| 19 | RunE: runListV2, | ||
| 20 | } | ||
| 21 | |||
| 22 | func runListV2(cmd *cobra.Command, args []string) error { | ||
| 23 | st, err := state.Load() | ||
| 24 | if err != nil { | ||
| 25 | output.PrintAndExit(output.Err(output.ErrHostNotConfigured, err.Error())) | ||
| 26 | } | ||
| 27 | |||
| 28 | hostName := hostFlag | ||
| 29 | if hostName == "" { | ||
| 30 | hostName = st.DefaultHost | ||
| 31 | } | ||
| 32 | if hostName == "" { | ||
| 33 | output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified")) | ||
| 34 | } | ||
| 35 | |||
| 36 | hostConfig := st.GetHost(hostName) | ||
| 37 | |||
| 38 | client, err := ssh.Connect(hostName) | ||
| 39 | if err != nil { | ||
| 40 | output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error())) | ||
| 41 | } | ||
| 42 | defer client.Close() | ||
| 43 | |||
| 44 | var deploys []output.DeployInfo | ||
| 45 | |||
| 46 | // Get all deployed services by checking /etc/ship/ports and /var/www | ||
| 47 | // Check ports (apps and docker) | ||
| 48 | portsOut, _ := client.Run("ls /etc/ship/ports/ 2>/dev/null || true") | ||
| 49 | for _, name := range strings.Fields(portsOut) { | ||
| 50 | if name == "" { | ||
| 51 | continue | ||
| 52 | } | ||
| 53 | |||
| 54 | // Get actual domain from Caddyfile (first word of first line) | ||
| 55 | domain := fmt.Sprintf("%s.%s", name, hostConfig.BaseDomain) | ||
| 56 | caddyOut, _ := client.Run(fmt.Sprintf("head -1 /etc/caddy/sites-enabled/%s.caddy 2>/dev/null | awk '{print $1}'", name)) | ||
| 57 | if d := strings.TrimSpace(caddyOut); d != "" && d != "{" { | ||
| 58 | domain = d | ||
| 59 | } | ||
| 60 | |||
| 61 | info := output.DeployInfo{ | ||
| 62 | Name: name, | ||
| 63 | URL: fmt.Sprintf("https://%s", domain), | ||
| 64 | } | ||
| 65 | |||
| 66 | // Check if it's docker or binary | ||
| 67 | dockerOut, _ := client.Run(fmt.Sprintf("docker inspect %s 2>/dev/null && echo docker", name)) | ||
| 68 | if strings.Contains(dockerOut, "docker") { | ||
| 69 | info.Type = "docker" | ||
| 70 | } else { | ||
| 71 | info.Type = "binary" | ||
| 72 | } | ||
| 73 | |||
| 74 | // Check if running | ||
| 75 | statusOut, _ := client.RunSudo(fmt.Sprintf("systemctl is-active %s 2>/dev/null || echo inactive", name)) | ||
| 76 | info.Running = strings.TrimSpace(statusOut) == "active" | ||
| 77 | |||
| 78 | // Check TTL | ||
| 79 | ttlOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ttl/%s 2>/dev/null || true", name)) | ||
| 80 | if ttlOut != "" { | ||
| 81 | if expires, err := strconv.ParseInt(strings.TrimSpace(ttlOut), 10, 64); err == nil { | ||
| 82 | info.Expires = time.Unix(expires, 0).UTC().Format(time.RFC3339) | ||
| 83 | } | ||
| 84 | } | ||
| 85 | |||
| 86 | deploys = append(deploys, info) | ||
| 87 | } | ||
| 88 | |||
| 89 | // Check static sites in /var/www | ||
| 90 | wwwOut, _ := client.Run("ls -d /var/www/*/ 2>/dev/null | xargs -n1 basename 2>/dev/null || true") | ||
| 91 | for _, name := range strings.Fields(wwwOut) { | ||
| 92 | if name == "" || name == "html" { | ||
| 93 | continue | ||
| 94 | } | ||
| 95 | |||
| 96 | // Skip if already in ports (would be an app, not static) | ||
| 97 | found := false | ||
| 98 | for _, d := range deploys { | ||
| 99 | if d.Name == name { | ||
| 100 | found = true | ||
| 101 | break | ||
| 102 | } | ||
| 103 | } | ||
| 104 | if found { | ||
| 105 | continue | ||
| 106 | } | ||
| 107 | |||
| 108 | // Get actual domain from Caddyfile | ||
| 109 | domain := fmt.Sprintf("%s.%s", name, hostConfig.BaseDomain) | ||
| 110 | caddyOut, _ := client.Run(fmt.Sprintf("head -1 /etc/caddy/sites-enabled/%s.caddy 2>/dev/null | awk '{print $1}'", name)) | ||
| 111 | if d := strings.TrimSpace(caddyOut); d != "" && d != "{" { | ||
| 112 | domain = d | ||
| 113 | } | ||
| 114 | |||
| 115 | info := output.DeployInfo{ | ||
| 116 | Name: name, | ||
| 117 | URL: fmt.Sprintf("https://%s", domain), | ||
| 118 | Type: "static", | ||
| 119 | Running: true, // Static sites are always "running" | ||
| 120 | } | ||
| 121 | |||
| 122 | // Check TTL | ||
| 123 | ttlOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ttl/%s 2>/dev/null || true", name)) | ||
| 124 | if ttlOut != "" { | ||
| 125 | if expires, err := strconv.ParseInt(strings.TrimSpace(ttlOut), 10, 64); err == nil { | ||
| 126 | info.Expires = time.Unix(expires, 0).UTC().Format(time.RFC3339) | ||
| 127 | } | ||
| 128 | } | ||
| 129 | |||
| 130 | deploys = append(deploys, info) | ||
| 131 | } | ||
| 132 | |||
| 133 | output.PrintAndExit(&output.ListResponse{ | ||
| 134 | Status: "ok", | ||
| 135 | Deploys: deploys, | ||
| 136 | }) | ||
| 137 | return nil | ||
| 138 | } | ||
| 139 | |||
| 140 | // statusV2Cmd shows status for a single deployment | ||
| 141 | var statusV2Cmd = &cobra.Command{ | ||
| 142 | Use: "status NAME", | ||
| 143 | Short: "Check status of a deployment", | ||
| 144 | Args: cobra.ExactArgs(1), | ||
| 145 | RunE: runStatusV2, | ||
| 146 | } | ||
| 147 | |||
| 148 | func runStatusV2(cmd *cobra.Command, args []string) error { | ||
| 149 | name := args[0] | ||
| 150 | |||
| 151 | st, err := state.Load() | ||
| 152 | if err != nil { | ||
| 153 | output.PrintAndExit(output.Err(output.ErrHostNotConfigured, err.Error())) | ||
| 154 | } | ||
| 155 | |||
| 156 | hostName := hostFlag | ||
| 157 | if hostName == "" { | ||
| 158 | hostName = st.DefaultHost | ||
| 159 | } | ||
| 160 | if hostName == "" { | ||
| 161 | output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified")) | ||
| 162 | } | ||
| 163 | |||
| 164 | hostConfig := st.GetHost(hostName) | ||
| 165 | |||
| 166 | client, err := ssh.Connect(hostName) | ||
| 167 | if err != nil { | ||
| 168 | output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error())) | ||
| 169 | } | ||
| 170 | defer client.Close() | ||
| 171 | |||
| 172 | // Check if deployment exists | ||
| 173 | portOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ports/%s 2>/dev/null || true", name)) | ||
| 174 | wwwExists, _ := client.Run(fmt.Sprintf("test -d /var/www/%s && echo exists || true", name)) | ||
| 175 | |||
| 176 | if strings.TrimSpace(portOut) == "" && strings.TrimSpace(wwwExists) == "" { | ||
| 177 | output.PrintAndExit(output.ErrWithName(output.ErrNotFound, "deployment not found", name)) | ||
| 178 | } | ||
| 179 | |||
| 180 | // Get actual domain from Caddyfile | ||
| 181 | domain := fmt.Sprintf("%s.%s", name, hostConfig.BaseDomain) | ||
| 182 | caddyOut, _ := client.Run(fmt.Sprintf("head -1 /etc/caddy/sites-enabled/%s.caddy 2>/dev/null | awk '{print $1}'", name)) | ||
| 183 | if d := strings.TrimSpace(caddyOut); d != "" && d != "{" { | ||
| 184 | domain = d | ||
| 185 | } | ||
| 186 | |||
| 187 | resp := &output.StatusResponse{ | ||
| 188 | Status: "ok", | ||
| 189 | Name: name, | ||
| 190 | URL: fmt.Sprintf("https://%s", domain), | ||
| 191 | } | ||
| 192 | |||
| 193 | // Determine type and get details | ||
| 194 | if portOut != "" { | ||
| 195 | port, _ := strconv.Atoi(strings.TrimSpace(portOut)) | ||
| 196 | resp.Port = port | ||
| 197 | |||
| 198 | // Check if docker | ||
| 199 | dockerOut, _ := client.Run(fmt.Sprintf("docker inspect %s 2>/dev/null && echo docker", name)) | ||
| 200 | if strings.Contains(dockerOut, "docker") { | ||
| 201 | resp.Type = "docker" | ||
| 202 | } else { | ||
| 203 | resp.Type = "binary" | ||
| 204 | } | ||
| 205 | |||
| 206 | // Check if running | ||
| 207 | statusOut, _ := client.RunSudo(fmt.Sprintf("systemctl is-active %s 2>/dev/null || echo inactive", name)) | ||
| 208 | resp.Running = strings.TrimSpace(statusOut) == "active" | ||
| 209 | } else { | ||
| 210 | resp.Type = "static" | ||
| 211 | resp.Running = true | ||
| 212 | } | ||
| 213 | |||
| 214 | // Check TTL | ||
| 215 | ttlOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ttl/%s 2>/dev/null || true", name)) | ||
| 216 | if ttlOut != "" { | ||
| 217 | if expires, err := strconv.ParseInt(strings.TrimSpace(ttlOut), 10, 64); err == nil { | ||
| 218 | resp.Expires = time.Unix(expires, 0).UTC().Format(time.RFC3339) | ||
| 219 | } | ||
| 220 | } | ||
| 221 | |||
| 222 | output.PrintAndExit(resp) | ||
| 223 | return nil | ||
| 224 | } | ||
| 225 | |||
| 226 | // logsV2Cmd shows logs for a deployment | ||
| 227 | var logsV2Cmd = &cobra.Command{ | ||
| 228 | Use: "logs NAME", | ||
| 229 | Short: "View logs for a deployment", | ||
| 230 | Args: cobra.ExactArgs(1), | ||
| 231 | RunE: runLogsV2, | ||
| 232 | } | ||
| 233 | |||
| 234 | func init() { | ||
| 235 | logsV2Cmd.Flags().Int("lines", 50, "Number of log lines to show") | ||
| 236 | } | ||
| 237 | |||
| 238 | func runLogsV2(cmd *cobra.Command, args []string) error { | ||
| 239 | name := args[0] | ||
| 240 | lines, _ := cmd.Flags().GetInt("lines") | ||
| 241 | |||
| 242 | st, err := state.Load() | ||
| 243 | if err != nil { | ||
| 244 | output.PrintAndExit(output.Err(output.ErrHostNotConfigured, err.Error())) | ||
| 245 | } | ||
| 246 | |||
| 247 | hostName := hostFlag | ||
| 248 | if hostName == "" { | ||
| 249 | hostName = st.DefaultHost | ||
| 250 | } | ||
| 251 | if hostName == "" { | ||
| 252 | output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified")) | ||
| 253 | } | ||
| 254 | |||
| 255 | client, err := ssh.Connect(hostName) | ||
| 256 | if err != nil { | ||
| 257 | output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error())) | ||
| 258 | } | ||
| 259 | defer client.Close() | ||
| 260 | |||
| 261 | // Check if it's a static site (no logs) | ||
| 262 | portOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ports/%s 2>/dev/null || true", name)) | ||
| 263 | if strings.TrimSpace(portOut) == "" { | ||
| 264 | // Check if static site exists | ||
| 265 | wwwExists, _ := client.Run(fmt.Sprintf("test -d /var/www/%s && echo exists || true", name)) | ||
| 266 | if strings.TrimSpace(wwwExists) == "" { | ||
| 267 | output.PrintAndExit(output.ErrWithName(output.ErrNotFound, "deployment not found", name)) | ||
| 268 | } | ||
| 269 | // Static site - check Caddy access logs | ||
| 270 | logsOut, err := client.RunSudo(fmt.Sprintf("journalctl -u caddy -n %d --no-pager 2>/dev/null | grep %s || echo 'No logs found'", lines*2, name)) | ||
| 271 | if err != nil { | ||
| 272 | output.PrintAndExit(output.Err(output.ErrServiceFailed, err.Error())) | ||
| 273 | } | ||
| 274 | logLines := strings.Split(strings.TrimSpace(logsOut), "\n") | ||
| 275 | output.PrintAndExit(&output.LogsResponse{ | ||
| 276 | Status: "ok", | ||
| 277 | Name: name, | ||
| 278 | Lines: logLines, | ||
| 279 | }) | ||
| 280 | return nil | ||
| 281 | } | ||
| 282 | |||
| 283 | // Get journalctl logs | ||
| 284 | logsOut, err := client.RunSudo(fmt.Sprintf("journalctl -u %s -n %d --no-pager 2>/dev/null || echo 'No logs found'", name, lines)) | ||
| 285 | if err != nil { | ||
| 286 | output.PrintAndExit(output.Err(output.ErrServiceFailed, err.Error())) | ||
| 287 | } | ||
| 288 | |||
| 289 | logLines := strings.Split(strings.TrimSpace(logsOut), "\n") | ||
| 290 | |||
| 291 | output.PrintAndExit(&output.LogsResponse{ | ||
| 292 | Status: "ok", | ||
| 293 | Name: name, | ||
| 294 | Lines: logLines, | ||
| 295 | }) | ||
| 296 | return nil | ||
| 297 | } | ||
| 298 | |||
| 299 | // removeV2Cmd removes a deployment | ||
| 300 | var removeV2Cmd = &cobra.Command{ | ||
| 301 | Use: "remove NAME", | ||
| 302 | Short: "Remove a deployment", | ||
| 303 | Args: cobra.ExactArgs(1), | ||
| 304 | RunE: runRemoveV2, | ||
| 305 | } | ||
| 306 | |||
| 307 | func runRemoveV2(cmd *cobra.Command, args []string) error { | ||
| 308 | name := args[0] | ||
| 309 | |||
| 310 | st, err := state.Load() | ||
| 311 | if err != nil { | ||
| 312 | output.PrintAndExit(output.Err(output.ErrHostNotConfigured, err.Error())) | ||
| 313 | } | ||
| 314 | |||
| 315 | hostName := hostFlag | ||
| 316 | if hostName == "" { | ||
| 317 | hostName = st.DefaultHost | ||
| 318 | } | ||
| 319 | if hostName == "" { | ||
| 320 | output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified")) | ||
| 321 | } | ||
| 322 | |||
| 323 | client, err := ssh.Connect(hostName) | ||
| 324 | if err != nil { | ||
| 325 | output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error())) | ||
| 326 | } | ||
| 327 | defer client.Close() | ||
| 328 | |||
| 329 | // Check if deployment exists | ||
| 330 | portOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ports/%s 2>/dev/null || true", name)) | ||
| 331 | wwwExists, _ := client.Run(fmt.Sprintf("test -d /var/www/%s && echo exists || true", name)) | ||
| 332 | |||
| 333 | if strings.TrimSpace(portOut) == "" && strings.TrimSpace(wwwExists) == "" { | ||
| 334 | output.PrintAndExit(output.ErrWithName(output.ErrNotFound, "deployment not found", name)) | ||
| 335 | } | ||
| 336 | |||
| 337 | // Stop and disable service | ||
| 338 | client.RunSudo(fmt.Sprintf("systemctl stop %s 2>/dev/null || true", name)) | ||
| 339 | client.RunSudo(fmt.Sprintf("systemctl disable %s 2>/dev/null || true", name)) | ||
| 340 | |||
| 341 | // Remove files | ||
| 342 | client.RunSudo(fmt.Sprintf("rm -f /etc/systemd/system/%s.service", name)) | ||
| 343 | client.RunSudo(fmt.Sprintf("rm -f /etc/caddy/sites-enabled/%s.caddy", name)) | ||
| 344 | client.RunSudo(fmt.Sprintf("rm -rf /var/www/%s", name)) | ||
| 345 | client.RunSudo(fmt.Sprintf("rm -rf /var/lib/%s", name)) | ||
| 346 | client.RunSudo(fmt.Sprintf("rm -f /usr/local/bin/%s", name)) | ||
| 347 | client.RunSudo(fmt.Sprintf("rm -f /etc/ship/env/%s.env", name)) | ||
| 348 | client.RunSudo(fmt.Sprintf("rm -f /etc/ship/ports/%s", name)) | ||
| 349 | client.RunSudo(fmt.Sprintf("rm -f /etc/ship/ttl/%s", name)) | ||
| 350 | |||
| 351 | // Remove docker container and image | ||
| 352 | client.Run(fmt.Sprintf("docker rm -f %s 2>/dev/null || true", name)) | ||
| 353 | client.Run(fmt.Sprintf("docker rmi %s 2>/dev/null || true", name)) | ||
| 354 | |||
| 355 | // Reload services | ||
| 356 | client.RunSudo("systemctl daemon-reload") | ||
| 357 | client.RunSudo("systemctl reload caddy") | ||
| 358 | |||
| 359 | output.PrintAndExit(&output.RemoveResponse{ | ||
| 360 | Status: "ok", | ||
| 361 | Name: name, | ||
| 362 | Removed: true, | ||
| 363 | }) | ||
| 364 | return nil | ||
| 365 | } | ||
diff --git a/cmd/ship/deploy.go b/cmd/ship/deploy.go deleted file mode 100644 index 414ade5..0000000 --- a/cmd/ship/deploy.go +++ /dev/null | |||
| @@ -1,664 +0,0 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 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 | |||
| 17 | // DeployOptions contains all options for deploying or updating an app | ||
| 18 | type DeployOptions struct { | ||
| 19 | Host string | ||
| 20 | Domain string | ||
| 21 | Name string | ||
| 22 | Binary string | ||
| 23 | Dir string // for static sites | ||
| 24 | Port int | ||
| 25 | Args string | ||
| 26 | Files []string | ||
| 27 | Memory string | ||
| 28 | CPU string | ||
| 29 | Env map[string]string // merged env vars | ||
| 30 | IsUpdate bool | ||
| 31 | } | ||
| 32 | |||
| 33 | func runDeploy(cmd *cobra.Command, args []string) error { | ||
| 34 | flags := cmd.Flags() | ||
| 35 | |||
| 36 | // Parse CLI flags | ||
| 37 | binary, _ := flags.GetString("binary") | ||
| 38 | static, _ := flags.GetBool("static") | ||
| 39 | dir, _ := flags.GetString("dir") | ||
| 40 | domain, _ := flags.GetString("domain") | ||
| 41 | name, _ := flags.GetString("name") | ||
| 42 | port, _ := flags.GetInt("port") | ||
| 43 | envVars, _ := flags.GetStringArray("env") | ||
| 44 | envFile, _ := flags.GetString("env-file") | ||
| 45 | argsFlag, _ := flags.GetString("args") | ||
| 46 | files, _ := flags.GetStringArray("file") | ||
| 47 | memory, _ := flags.GetString("memory") | ||
| 48 | cpu, _ := flags.GetString("cpu") | ||
| 49 | |||
| 50 | // Get host from flag or state default | ||
| 51 | host := hostFlag | ||
| 52 | if host == "" { | ||
| 53 | st, err := state.Load() | ||
| 54 | if err != nil { | ||
| 55 | return fmt.Errorf("error loading state: %w", err) | ||
| 56 | } | ||
| 57 | host = st.GetDefaultHost() | ||
| 58 | } | ||
| 59 | |||
| 60 | // If no flags provided, show help | ||
| 61 | if domain == "" && binary == "" && !static && name == "" { | ||
| 62 | return cmd.Help() | ||
| 63 | } | ||
| 64 | |||
| 65 | if host == "" { | ||
| 66 | return fmt.Errorf("--host is required") | ||
| 67 | } | ||
| 68 | |||
| 69 | // Load state once - this will be used throughout | ||
| 70 | st, err := state.Load() | ||
| 71 | if err != nil { | ||
| 72 | return fmt.Errorf("error loading state: %w", err) | ||
| 73 | } | ||
| 74 | hostState := st.GetHost(host) | ||
| 75 | |||
| 76 | // Config update mode: --name provided without --binary or --static | ||
| 77 | if name != "" && binary == "" && !static { | ||
| 78 | existingApp, err := st.GetApp(host, name) | ||
| 79 | if err != nil { | ||
| 80 | return fmt.Errorf("app %s not found (use --binary to deploy a new app)", name) | ||
| 81 | } | ||
| 82 | |||
| 83 | // Build merged config starting from existing app | ||
| 84 | opts := DeployOptions{ | ||
| 85 | Host: host, | ||
| 86 | Name: name, | ||
| 87 | Port: existingApp.Port, | ||
| 88 | Args: existingApp.Args, | ||
| 89 | Files: existingApp.Files, | ||
| 90 | Memory: existingApp.Memory, | ||
| 91 | CPU: existingApp.CPU, | ||
| 92 | Env: make(map[string]string), | ||
| 93 | } | ||
| 94 | for k, v := range existingApp.Env { | ||
| 95 | opts.Env[k] = v | ||
| 96 | } | ||
| 97 | |||
| 98 | // Override with CLI flags if provided | ||
| 99 | if argsFlag != "" { | ||
| 100 | opts.Args = argsFlag | ||
| 101 | } | ||
| 102 | if len(files) > 0 { | ||
| 103 | opts.Files = files | ||
| 104 | } | ||
| 105 | if memory != "" { | ||
| 106 | opts.Memory = memory | ||
| 107 | } | ||
| 108 | if cpu != "" { | ||
| 109 | opts.CPU = cpu | ||
| 110 | } | ||
| 111 | |||
| 112 | // Merge env vars (CLI overrides existing) | ||
| 113 | for _, e := range envVars { | ||
| 114 | parts := strings.SplitN(e, "=", 2) | ||
| 115 | if len(parts) == 2 { | ||
| 116 | opts.Env[parts[0]] = parts[1] | ||
| 117 | } | ||
| 118 | } | ||
| 119 | if envFile != "" { | ||
| 120 | fileEnv, err := parseEnvFile(envFile) | ||
| 121 | if err != nil { | ||
| 122 | return fmt.Errorf("error reading env file: %w", err) | ||
| 123 | } | ||
| 124 | for k, v := range fileEnv { | ||
| 125 | opts.Env[k] = v | ||
| 126 | } | ||
| 127 | } | ||
| 128 | |||
| 129 | return updateAppConfig(st, opts) | ||
| 130 | } | ||
| 131 | |||
| 132 | // Infer name early so we can use it for subdomain generation and existing app lookup | ||
| 133 | if name == "" { | ||
| 134 | if static { | ||
| 135 | name = domain | ||
| 136 | if name == "" && hostState.BaseDomain != "" { | ||
| 137 | name = filepath.Base(dir) | ||
| 138 | } | ||
| 139 | } else { | ||
| 140 | name = filepath.Base(binary) | ||
| 141 | } | ||
| 142 | } | ||
| 143 | if err := validateName(name); err != nil { | ||
| 144 | return err | ||
| 145 | } | ||
| 146 | |||
| 147 | // Check if this is an update to an existing app/site | ||
| 148 | existingApp, _ := st.GetApp(host, name) | ||
| 149 | isUpdate := existingApp != nil | ||
| 150 | |||
| 151 | // For new deployments, require domain or base domain | ||
| 152 | if !isUpdate && domain == "" && hostState.BaseDomain == "" { | ||
| 153 | return fmt.Errorf("--domain required (or configure base domain with 'ship host init --base-domain')") | ||
| 154 | } | ||
| 155 | |||
| 156 | // Build merged config, starting from existing app if updating | ||
| 157 | opts := DeployOptions{ | ||
| 158 | Host: host, | ||
| 159 | Name: name, | ||
| 160 | Binary: binary, | ||
| 161 | Dir: dir, | ||
| 162 | IsUpdate: isUpdate, | ||
| 163 | } | ||
| 164 | |||
| 165 | // Merge domain: auto-subdomain + (user-provided or existing custom domain) | ||
| 166 | var domains []string | ||
| 167 | if hostState.BaseDomain != "" { | ||
| 168 | domains = append(domains, name+"."+hostState.BaseDomain) | ||
| 169 | } | ||
| 170 | if domain != "" { | ||
| 171 | domains = append(domains, domain) | ||
| 172 | } else if isUpdate && existingApp.Domain != "" { | ||
| 173 | for _, d := range strings.Split(existingApp.Domain, ",") { | ||
| 174 | d = strings.TrimSpace(d) | ||
| 175 | if d != "" && (hostState.BaseDomain == "" || !strings.HasSuffix(d, "."+hostState.BaseDomain)) { | ||
| 176 | domains = append(domains, d) | ||
| 177 | } | ||
| 178 | } | ||
| 179 | } | ||
| 180 | opts.Domain = strings.Join(domains, ", ") | ||
| 181 | |||
| 182 | // For apps, merge all config fields | ||
| 183 | if !static { | ||
| 184 | // Start with existing values if updating | ||
| 185 | if isUpdate { | ||
| 186 | opts.Port = existingApp.Port | ||
| 187 | opts.Args = existingApp.Args | ||
| 188 | opts.Files = existingApp.Files | ||
| 189 | opts.Memory = existingApp.Memory | ||
| 190 | opts.CPU = existingApp.CPU | ||
| 191 | opts.Env = make(map[string]string) | ||
| 192 | for k, v := range existingApp.Env { | ||
| 193 | opts.Env[k] = v | ||
| 194 | } | ||
| 195 | } else { | ||
| 196 | opts.Port = port | ||
| 197 | opts.Env = make(map[string]string) | ||
| 198 | } | ||
| 199 | |||
| 200 | // Override with CLI flags if provided | ||
| 201 | if argsFlag != "" { | ||
| 202 | opts.Args = argsFlag | ||
| 203 | } | ||
| 204 | if len(files) > 0 { | ||
| 205 | opts.Files = files | ||
| 206 | } | ||
| 207 | if memory != "" { | ||
| 208 | opts.Memory = memory | ||
| 209 | } | ||
| 210 | if cpu != "" { | ||
| 211 | opts.CPU = cpu | ||
| 212 | } | ||
| 213 | |||
| 214 | // Merge env vars (CLI overrides existing) | ||
| 215 | for _, e := range envVars { | ||
| 216 | parts := strings.SplitN(e, "=", 2) | ||
| 217 | if len(parts) == 2 { | ||
| 218 | opts.Env[parts[0]] = parts[1] | ||
| 219 | } | ||
| 220 | } | ||
| 221 | if envFile != "" { | ||
| 222 | fileEnv, err := parseEnvFile(envFile) | ||
| 223 | if err != nil { | ||
| 224 | return fmt.Errorf("error reading env file: %w", err) | ||
| 225 | } | ||
| 226 | for k, v := range fileEnv { | ||
| 227 | opts.Env[k] = v | ||
| 228 | } | ||
| 229 | } | ||
| 230 | } | ||
| 231 | |||
| 232 | if static { | ||
| 233 | return deployStatic(st, opts) | ||
| 234 | } | ||
| 235 | return deployApp(st, opts) | ||
| 236 | } | ||
| 237 | |||
| 238 | func deployApp(st *state.State, opts DeployOptions) error { | ||
| 239 | if opts.Binary == "" { | ||
| 240 | return fmt.Errorf("--binary is required") | ||
| 241 | } | ||
| 242 | |||
| 243 | if _, err := os.Stat(opts.Binary); err != nil { | ||
| 244 | return fmt.Errorf("binary not found: %s", opts.Binary) | ||
| 245 | } | ||
| 246 | |||
| 247 | fmt.Printf("Deploying app: %s\n", opts.Name) | ||
| 248 | fmt.Printf(" Domain(s): %s\n", opts.Domain) | ||
| 249 | fmt.Printf(" Binary: %s\n", opts.Binary) | ||
| 250 | |||
| 251 | // Allocate port for new apps | ||
| 252 | port := opts.Port | ||
| 253 | if opts.IsUpdate { | ||
| 254 | fmt.Printf(" Updating existing deployment (port %d)\n", port) | ||
| 255 | } else { | ||
| 256 | if port == 0 { | ||
| 257 | port = st.AllocatePort(opts.Host) | ||
| 258 | } | ||
| 259 | fmt.Printf(" Allocated port: %d\n", port) | ||
| 260 | } | ||
| 261 | |||
| 262 | // Add PORT to env | ||
| 263 | opts.Env["PORT"] = strconv.Itoa(port) | ||
| 264 | |||
| 265 | client, err := ssh.Connect(opts.Host) | ||
| 266 | if err != nil { | ||
| 267 | return fmt.Errorf("error connecting to VPS: %w", err) | ||
| 268 | } | ||
| 269 | defer client.Close() | ||
| 270 | |||
| 271 | fmt.Println("-> Uploading binary...") | ||
| 272 | remoteTmpPath := fmt.Sprintf("/tmp/%s", opts.Name) | ||
| 273 | if err := client.Upload(opts.Binary, remoteTmpPath); err != nil { | ||
| 274 | return fmt.Errorf("error uploading binary: %w", err) | ||
| 275 | } | ||
| 276 | |||
| 277 | fmt.Println("-> Creating system user...") | ||
| 278 | client.RunSudo(fmt.Sprintf("useradd -r -s /bin/false %s", opts.Name)) | ||
| 279 | |||
| 280 | fmt.Println("-> Setting up directories...") | ||
| 281 | workDir := fmt.Sprintf("/var/lib/%s", opts.Name) | ||
| 282 | if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", workDir)); err != nil { | ||
| 283 | return fmt.Errorf("error creating work directory: %w", err) | ||
| 284 | } | ||
| 285 | if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", opts.Name, opts.Name, workDir)); err != nil { | ||
| 286 | return fmt.Errorf("error setting work directory ownership: %w", err) | ||
| 287 | } | ||
| 288 | |||
| 289 | fmt.Println("-> Installing binary...") | ||
| 290 | binaryDest := fmt.Sprintf("/usr/local/bin/%s", opts.Name) | ||
| 291 | if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", remoteTmpPath, binaryDest)); err != nil { | ||
| 292 | return fmt.Errorf("error moving binary: %w", err) | ||
| 293 | } | ||
| 294 | if _, err := client.RunSudo(fmt.Sprintf("chmod +x %s", binaryDest)); err != nil { | ||
| 295 | return fmt.Errorf("error making binary executable: %w", err) | ||
| 296 | } | ||
| 297 | |||
| 298 | if len(opts.Files) > 0 { | ||
| 299 | fmt.Println("-> Uploading config files...") | ||
| 300 | for _, file := range opts.Files { | ||
| 301 | if _, err := os.Stat(file); err != nil { | ||
| 302 | return fmt.Errorf("config file not found: %s", file) | ||
| 303 | } | ||
| 304 | |||
| 305 | remotePath := fmt.Sprintf("%s/%s", workDir, filepath.Base(file)) | ||
| 306 | fileTmpPath := fmt.Sprintf("/tmp/%s_%s", opts.Name, filepath.Base(file)) | ||
| 307 | |||
| 308 | if err := client.Upload(file, fileTmpPath); err != nil { | ||
| 309 | return fmt.Errorf("error uploading config file %s: %w", file, err) | ||
| 310 | } | ||
| 311 | |||
| 312 | if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", fileTmpPath, remotePath)); err != nil { | ||
| 313 | return fmt.Errorf("error moving config file %s: %w", file, err) | ||
| 314 | } | ||
| 315 | |||
| 316 | if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", opts.Name, opts.Name, remotePath)); err != nil { | ||
| 317 | return fmt.Errorf("error setting config file ownership %s: %w", file, err) | ||
| 318 | } | ||
| 319 | |||
| 320 | fmt.Printf(" Uploaded: %s\n", file) | ||
| 321 | } | ||
| 322 | } | ||
| 323 | |||
| 324 | fmt.Println("-> Creating environment file...") | ||
| 325 | envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", opts.Name) | ||
| 326 | envContent := "" | ||
| 327 | for k, v := range opts.Env { | ||
| 328 | envContent += fmt.Sprintf("%s=%s\n", k, v) | ||
| 329 | } | ||
| 330 | if err := client.WriteSudoFile(envFilePath, envContent); err != nil { | ||
| 331 | return fmt.Errorf("error creating env file: %w", err) | ||
| 332 | } | ||
| 333 | if _, err := client.RunSudo(fmt.Sprintf("chmod 600 %s", envFilePath)); err != nil { | ||
| 334 | return fmt.Errorf("error setting env file permissions: %w", err) | ||
| 335 | } | ||
| 336 | if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", opts.Name, opts.Name, envFilePath)); err != nil { | ||
| 337 | return fmt.Errorf("error setting env file ownership: %w", err) | ||
| 338 | } | ||
| 339 | |||
| 340 | // Create local .ship directory for deployment configs if they don't exist | ||
| 341 | // (handles both initial deployment and migration of existing deployments) | ||
| 342 | if _, err := os.Stat(".ship/service"); os.IsNotExist(err) { | ||
| 343 | fmt.Println("-> Creating local .ship directory...") | ||
| 344 | if err := os.MkdirAll(".ship", 0755); err != nil { | ||
| 345 | return fmt.Errorf("error creating .ship directory: %w", err) | ||
| 346 | } | ||
| 347 | |||
| 348 | fmt.Println("-> Generating systemd service...") | ||
| 349 | serviceContent, err := templates.SystemdService(map[string]string{ | ||
| 350 | "Name": opts.Name, | ||
| 351 | "User": opts.Name, | ||
| 352 | "WorkDir": workDir, | ||
| 353 | "BinaryPath": binaryDest, | ||
| 354 | "Port": strconv.Itoa(port), | ||
| 355 | "EnvFile": envFilePath, | ||
| 356 | "Args": opts.Args, | ||
| 357 | "Memory": opts.Memory, | ||
| 358 | "CPU": opts.CPU, | ||
| 359 | }) | ||
| 360 | if err != nil { | ||
| 361 | return fmt.Errorf("error generating systemd unit: %w", err) | ||
| 362 | } | ||
| 363 | if err := os.WriteFile(".ship/service", []byte(serviceContent), 0644); err != nil { | ||
| 364 | return fmt.Errorf("error writing .ship/service: %w", err) | ||
| 365 | } | ||
| 366 | } | ||
| 367 | |||
| 368 | if _, err := os.Stat(".ship/Caddyfile"); os.IsNotExist(err) { | ||
| 369 | fmt.Println("-> Generating Caddyfile...") | ||
| 370 | caddyContent, err := templates.AppCaddy(map[string]string{ | ||
| 371 | "Domain": opts.Domain, | ||
| 372 | "Port": strconv.Itoa(port), | ||
| 373 | }) | ||
| 374 | if err != nil { | ||
| 375 | return fmt.Errorf("error generating Caddy config: %w", err) | ||
| 376 | } | ||
| 377 | if err := os.WriteFile(".ship/Caddyfile", []byte(caddyContent), 0644); err != nil { | ||
| 378 | return fmt.Errorf("error writing .ship/Caddyfile: %w", err) | ||
| 379 | } | ||
| 380 | } | ||
| 381 | |||
| 382 | // Upload systemd service from .ship/service | ||
| 383 | fmt.Println("-> Installing systemd service...") | ||
| 384 | serviceContent, err := os.ReadFile(".ship/service") | ||
| 385 | if err != nil { | ||
| 386 | return fmt.Errorf("error reading .ship/service: %w", err) | ||
| 387 | } | ||
| 388 | servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", opts.Name) | ||
| 389 | if err := client.WriteSudoFile(servicePath, string(serviceContent)); err != nil { | ||
| 390 | return fmt.Errorf("error installing systemd unit: %w", err) | ||
| 391 | } | ||
| 392 | |||
| 393 | // Upload Caddyfile from .ship/Caddyfile | ||
| 394 | fmt.Println("-> Installing Caddy config...") | ||
| 395 | caddyContent, err := os.ReadFile(".ship/Caddyfile") | ||
| 396 | if err != nil { | ||
| 397 | return fmt.Errorf("error reading .ship/Caddyfile: %w", err) | ||
| 398 | } | ||
| 399 | caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", opts.Name) | ||
| 400 | if err := client.WriteSudoFile(caddyPath, string(caddyContent)); err != nil { | ||
| 401 | return fmt.Errorf("error installing Caddy config: %w", err) | ||
| 402 | } | ||
| 403 | |||
| 404 | fmt.Println("-> Reloading systemd...") | ||
| 405 | if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { | ||
| 406 | return fmt.Errorf("error reloading systemd: %w", err) | ||
| 407 | } | ||
| 408 | |||
| 409 | fmt.Println("-> Starting service...") | ||
| 410 | if _, err := client.RunSudo(fmt.Sprintf("systemctl enable %s", opts.Name)); err != nil { | ||
| 411 | return fmt.Errorf("error enabling service: %w", err) | ||
| 412 | } | ||
| 413 | if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", opts.Name)); err != nil { | ||
| 414 | return fmt.Errorf("error starting service: %w", err) | ||
| 415 | } | ||
| 416 | |||
| 417 | fmt.Println("-> Reloading Caddy...") | ||
| 418 | if _, err := client.RunSudo("systemctl reload caddy"); err != nil { | ||
| 419 | return fmt.Errorf("error reloading Caddy: %w", err) | ||
| 420 | } | ||
| 421 | |||
| 422 | st.AddApp(opts.Host, opts.Name, &state.App{ | ||
| 423 | Type: "app", | ||
| 424 | Domain: opts.Domain, | ||
| 425 | Port: port, | ||
| 426 | Env: opts.Env, | ||
| 427 | Args: opts.Args, | ||
| 428 | Files: opts.Files, | ||
| 429 | Memory: opts.Memory, | ||
| 430 | CPU: opts.CPU, | ||
| 431 | }) | ||
| 432 | if err := st.Save(); err != nil { | ||
| 433 | return fmt.Errorf("error saving state: %w", err) | ||
| 434 | } | ||
| 435 | |||
| 436 | fmt.Printf("\n App deployed successfully!\n") | ||
| 437 | // Show first domain in the URL message | ||
| 438 | primaryDomain := strings.Split(opts.Domain, ",")[0] | ||
| 439 | primaryDomain = strings.TrimSpace(primaryDomain) | ||
| 440 | fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", primaryDomain) | ||
| 441 | return nil | ||
| 442 | } | ||
| 443 | |||
| 444 | func updateAppConfig(st *state.State, opts DeployOptions) error { | ||
| 445 | existingApp, err := st.GetApp(opts.Host, opts.Name) | ||
| 446 | if err != nil { | ||
| 447 | return fmt.Errorf("app %s not found (use --binary to deploy a new app)", opts.Name) | ||
| 448 | } | ||
| 449 | |||
| 450 | if existingApp.Type != "app" && existingApp.Type != "git-app" { | ||
| 451 | return fmt.Errorf("%s is a static site, not an app", opts.Name) | ||
| 452 | } | ||
| 453 | |||
| 454 | fmt.Printf("Updating config: %s\n", opts.Name) | ||
| 455 | |||
| 456 | // Add PORT to env | ||
| 457 | opts.Env["PORT"] = strconv.Itoa(existingApp.Port) | ||
| 458 | |||
| 459 | client, err := ssh.Connect(opts.Host) | ||
| 460 | if err != nil { | ||
| 461 | return fmt.Errorf("error connecting to VPS: %w", err) | ||
| 462 | } | ||
| 463 | defer client.Close() | ||
| 464 | |||
| 465 | // Update env file | ||
| 466 | fmt.Println("-> Updating environment file...") | ||
| 467 | envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", opts.Name) | ||
| 468 | envContent := "" | ||
| 469 | for k, v := range opts.Env { | ||
| 470 | envContent += fmt.Sprintf("%s=%s\n", k, v) | ||
| 471 | } | ||
| 472 | if err := client.WriteSudoFile(envFilePath, envContent); err != nil { | ||
| 473 | return fmt.Errorf("error creating env file: %w", err) | ||
| 474 | } | ||
| 475 | |||
| 476 | // For git-app, the systemd unit comes from .ship/service in the repo, | ||
| 477 | // so we only update the env file and restart. | ||
| 478 | if existingApp.Type != "git-app" { | ||
| 479 | // Regenerate systemd unit to .ship/service (resource flags are being updated) | ||
| 480 | fmt.Println("-> Updating systemd service...") | ||
| 481 | workDir := fmt.Sprintf("/var/lib/%s", opts.Name) | ||
| 482 | binaryDest := fmt.Sprintf("/usr/local/bin/%s", opts.Name) | ||
| 483 | serviceContent, err := templates.SystemdService(map[string]string{ | ||
| 484 | "Name": opts.Name, | ||
| 485 | "User": opts.Name, | ||
| 486 | "WorkDir": workDir, | ||
| 487 | "BinaryPath": binaryDest, | ||
| 488 | "Port": strconv.Itoa(existingApp.Port), | ||
| 489 | "EnvFile": envFilePath, | ||
| 490 | "Args": opts.Args, | ||
| 491 | "Memory": opts.Memory, | ||
| 492 | "CPU": opts.CPU, | ||
| 493 | }) | ||
| 494 | if err != nil { | ||
| 495 | return fmt.Errorf("error generating systemd unit: %w", err) | ||
| 496 | } | ||
| 497 | |||
| 498 | // Write to local .ship/service | ||
| 499 | if err := os.MkdirAll(".ship", 0755); err != nil { | ||
| 500 | return fmt.Errorf("error creating .ship directory: %w", err) | ||
| 501 | } | ||
| 502 | if err := os.WriteFile(".ship/service", []byte(serviceContent), 0644); err != nil { | ||
| 503 | return fmt.Errorf("error writing .ship/service: %w", err) | ||
| 504 | } | ||
| 505 | |||
| 506 | // Upload to server | ||
| 507 | servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", opts.Name) | ||
| 508 | if err := client.WriteSudoFile(servicePath, serviceContent); err != nil { | ||
| 509 | return fmt.Errorf("error installing systemd unit: %w", err) | ||
| 510 | } | ||
| 511 | |||
| 512 | fmt.Println("-> Reloading systemd...") | ||
| 513 | if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { | ||
| 514 | return fmt.Errorf("error reloading systemd: %w", err) | ||
| 515 | } | ||
| 516 | } | ||
| 517 | |||
| 518 | fmt.Println("-> Restarting service...") | ||
| 519 | if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", opts.Name)); err != nil { | ||
| 520 | return fmt.Errorf("error restarting service: %w", err) | ||
| 521 | } | ||
| 522 | |||
| 523 | // Update state | ||
| 524 | existingApp.Args = opts.Args | ||
| 525 | existingApp.Memory = opts.Memory | ||
| 526 | existingApp.CPU = opts.CPU | ||
| 527 | existingApp.Env = opts.Env | ||
| 528 | if err := st.Save(); err != nil { | ||
| 529 | return fmt.Errorf("error saving state: %w", err) | ||
| 530 | } | ||
| 531 | |||
| 532 | fmt.Printf("\n Config updated successfully!\n") | ||
| 533 | return nil | ||
| 534 | } | ||
| 535 | |||
| 536 | func deployStatic(st *state.State, opts DeployOptions) error { | ||
| 537 | if _, err := os.Stat(opts.Dir); err != nil { | ||
| 538 | return fmt.Errorf("directory not found: %s", opts.Dir) | ||
| 539 | } | ||
| 540 | |||
| 541 | fmt.Printf("Deploying static site: %s\n", opts.Name) | ||
| 542 | fmt.Printf(" Domain(s): %s\n", opts.Domain) | ||
| 543 | fmt.Printf(" Directory: %s\n", opts.Dir) | ||
| 544 | |||
| 545 | if opts.IsUpdate { | ||
| 546 | fmt.Println(" Updating existing deployment") | ||
| 547 | } | ||
| 548 | |||
| 549 | client, err := ssh.Connect(opts.Host) | ||
| 550 | if err != nil { | ||
| 551 | return fmt.Errorf("error connecting to VPS: %w", err) | ||
| 552 | } | ||
| 553 | defer client.Close() | ||
| 554 | |||
| 555 | remoteDir := fmt.Sprintf("/var/www/%s", opts.Name) | ||
| 556 | fmt.Println("-> Creating remote directory...") | ||
| 557 | if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", remoteDir)); err != nil { | ||
| 558 | return fmt.Errorf("error creating remote directory: %w", err) | ||
| 559 | } | ||
| 560 | |||
| 561 | currentUser, err := client.Run("whoami") | ||
| 562 | if err != nil { | ||
| 563 | return fmt.Errorf("error getting current user: %w", err) | ||
| 564 | } | ||
| 565 | currentUser = strings.TrimSpace(currentUser) | ||
| 566 | |||
| 567 | if _, err := client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", currentUser, currentUser, remoteDir)); err != nil { | ||
| 568 | return fmt.Errorf("error setting temporary ownership: %w", err) | ||
| 569 | } | ||
| 570 | |||
| 571 | fmt.Println("-> Uploading files...") | ||
| 572 | if err := client.UploadDir(opts.Dir, remoteDir); err != nil { | ||
| 573 | return fmt.Errorf("error uploading files: %w", err) | ||
| 574 | } | ||
| 575 | |||
| 576 | fmt.Println("-> Setting permissions...") | ||
| 577 | if _, err := client.RunSudo(fmt.Sprintf("chown -R www-data:www-data %s", remoteDir)); err != nil { | ||
| 578 | return fmt.Errorf("error setting ownership: %w", err) | ||
| 579 | } | ||
| 580 | if _, err := client.RunSudo(fmt.Sprintf("chmod -R 755 %s", remoteDir)); err != nil { | ||
| 581 | return fmt.Errorf("error setting directory permissions: %w", err) | ||
| 582 | } | ||
| 583 | if _, err := client.RunSudo(fmt.Sprintf("find %s -type f -exec chmod 644 {} \\;", remoteDir)); err != nil { | ||
| 584 | return fmt.Errorf("error setting file permissions: %w", err) | ||
| 585 | } | ||
| 586 | |||
| 587 | // Create local .ship directory and Caddyfile for static sites if it doesn't exist | ||
| 588 | // (handles both initial deployment and migration of existing deployments) | ||
| 589 | shipDir := filepath.Join(opts.Dir, ".ship") | ||
| 590 | caddyfilePath := filepath.Join(shipDir, "Caddyfile") | ||
| 591 | |||
| 592 | if _, err := os.Stat(caddyfilePath); os.IsNotExist(err) { | ||
| 593 | fmt.Println("-> Creating local .ship directory...") | ||
| 594 | if err := os.MkdirAll(shipDir, 0755); err != nil { | ||
| 595 | return fmt.Errorf("error creating .ship directory: %w", err) | ||
| 596 | } | ||
| 597 | |||
| 598 | fmt.Println("-> Generating Caddyfile...") | ||
| 599 | caddyContent, err := templates.StaticCaddy(map[string]string{ | ||
| 600 | "Domain": opts.Domain, | ||
| 601 | "RootDir": remoteDir, | ||
| 602 | }) | ||
| 603 | if err != nil { | ||
| 604 | return fmt.Errorf("error generating Caddy config: %w", err) | ||
| 605 | } | ||
| 606 | if err := os.WriteFile(caddyfilePath, []byte(caddyContent), 0644); err != nil { | ||
| 607 | return fmt.Errorf("error writing .ship/Caddyfile: %w", err) | ||
| 608 | } | ||
| 609 | } | ||
| 610 | |||
| 611 | // Upload Caddyfile from .ship/Caddyfile | ||
| 612 | fmt.Println("-> Installing Caddy config...") | ||
| 613 | caddyContent, err := os.ReadFile(caddyfilePath) | ||
| 614 | if err != nil { | ||
| 615 | return fmt.Errorf("error reading .ship/Caddyfile: %w", err) | ||
| 616 | } | ||
| 617 | caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", opts.Name) | ||
| 618 | if err := client.WriteSudoFile(caddyPath, string(caddyContent)); err != nil { | ||
| 619 | return fmt.Errorf("error installing Caddy config: %w", err) | ||
| 620 | } | ||
| 621 | |||
| 622 | fmt.Println("-> Reloading Caddy...") | ||
| 623 | if _, err := client.RunSudo("systemctl reload caddy"); err != nil { | ||
| 624 | return fmt.Errorf("error reloading Caddy: %w", err) | ||
| 625 | } | ||
| 626 | |||
| 627 | st.AddApp(opts.Host, opts.Name, &state.App{ | ||
| 628 | Type: "static", | ||
| 629 | Domain: opts.Domain, | ||
| 630 | }) | ||
| 631 | if err := st.Save(); err != nil { | ||
| 632 | return fmt.Errorf("error saving state: %w", err) | ||
| 633 | } | ||
| 634 | |||
| 635 | fmt.Printf("\n Static site deployed successfully!\n") | ||
| 636 | primaryDomain := strings.Split(opts.Domain, ",")[0] | ||
| 637 | primaryDomain = strings.TrimSpace(primaryDomain) | ||
| 638 | fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", primaryDomain) | ||
| 639 | return nil | ||
| 640 | } | ||
| 641 | |||
| 642 | func parseEnvFile(path string) (map[string]string, error) { | ||
| 643 | file, err := os.Open(path) | ||
| 644 | if err != nil { | ||
| 645 | return nil, err | ||
| 646 | } | ||
| 647 | defer file.Close() | ||
| 648 | |||
| 649 | env := make(map[string]string) | ||
| 650 | scanner := bufio.NewScanner(file) | ||
| 651 | for scanner.Scan() { | ||
| 652 | line := strings.TrimSpace(scanner.Text()) | ||
| 653 | if line == "" || strings.HasPrefix(line, "#") { | ||
| 654 | continue | ||
| 655 | } | ||
| 656 | |||
| 657 | parts := strings.SplitN(line, "=", 2) | ||
| 658 | if len(parts) == 2 { | ||
| 659 | env[parts[0]] = parts[1] | ||
| 660 | } | ||
| 661 | } | ||
| 662 | |||
| 663 | return env, scanner.Err() | ||
| 664 | } | ||
diff --git a/cmd/ship/deploy_cmd.go b/cmd/ship/deploy_cmd.go deleted file mode 100644 index ba45c4f..0000000 --- a/cmd/ship/deploy_cmd.go +++ /dev/null | |||
| @@ -1,141 +0,0 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | |||
| 6 | "github.com/bdw/ship/internal/ssh" | ||
| 7 | "github.com/bdw/ship/internal/state" | ||
| 8 | "github.com/spf13/cobra" | ||
| 9 | ) | ||
| 10 | |||
| 11 | var deployGitCmd = &cobra.Command{ | ||
| 12 | Use: "deploy <name>", | ||
| 13 | Short: "Manually rebuild and deploy a git-deployed app", | ||
| 14 | Long: `Trigger a manual rebuild from the latest code in the git repo. | ||
| 15 | |||
| 16 | This runs the same steps as the post-receive hook: checkout code, | ||
| 17 | install .ship/ configs, docker build (for apps), and restart. | ||
| 18 | |||
| 19 | Examples: | ||
| 20 | ship deploy myapp`, | ||
| 21 | Args: cobra.ExactArgs(1), | ||
| 22 | RunE: runDeployGit, | ||
| 23 | } | ||
| 24 | |||
| 25 | func runDeployGit(cmd *cobra.Command, args []string) error { | ||
| 26 | name := args[0] | ||
| 27 | if err := validateName(name); err != nil { | ||
| 28 | return err | ||
| 29 | } | ||
| 30 | |||
| 31 | st, err := state.Load() | ||
| 32 | if err != nil { | ||
| 33 | return fmt.Errorf("error loading state: %w", err) | ||
| 34 | } | ||
| 35 | |||
| 36 | host := hostFlag | ||
| 37 | if host == "" { | ||
| 38 | host = st.GetDefaultHost() | ||
| 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 != "git-app" && app.Type != "git-static" { | ||
| 50 | return fmt.Errorf("%s is not a git-deployed app (type: %s)", name, app.Type) | ||
| 51 | } | ||
| 52 | |||
| 53 | fmt.Printf("Deploying %s...\n", name) | ||
| 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 | if app.Type == "git-app" { | ||
| 62 | if err := deployGitApp(client, name); err != nil { | ||
| 63 | return err | ||
| 64 | } | ||
| 65 | } else { | ||
| 66 | if err := deployGitStatic(client, name); err != nil { | ||
| 67 | return err | ||
| 68 | } | ||
| 69 | } | ||
| 70 | |||
| 71 | fmt.Println("\nDeploy complete!") | ||
| 72 | return nil | ||
| 73 | } | ||
| 74 | |||
| 75 | func deployGitApp(client *ssh.Client, name string) error { | ||
| 76 | repo := fmt.Sprintf("/srv/git/%s.git", name) | ||
| 77 | src := fmt.Sprintf("/var/lib/%s/src", name) | ||
| 78 | |||
| 79 | fmt.Println("-> Checking out code...") | ||
| 80 | if _, err := client.RunSudo(fmt.Sprintf("git --work-tree=%s --git-dir=%s checkout -f main", src, repo)); err != nil { | ||
| 81 | return fmt.Errorf("error checking out code: %w", err) | ||
| 82 | } | ||
| 83 | |||
| 84 | // Install deployment config from repo | ||
| 85 | serviceSrc := fmt.Sprintf("%s/.ship/service", src) | ||
| 86 | serviceDst := fmt.Sprintf("/etc/systemd/system/%s.service", name) | ||
| 87 | fmt.Println("-> Installing systemd unit...") | ||
| 88 | if _, err := client.RunSudo(fmt.Sprintf("cp %s %s", serviceSrc, serviceDst)); err != nil { | ||
| 89 | fmt.Printf(" Warning: no .ship/service found, skipping\n") | ||
| 90 | } else { | ||
| 91 | if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { | ||
| 92 | return fmt.Errorf("error reloading systemd: %w", err) | ||
| 93 | } | ||
| 94 | } | ||
| 95 | |||
| 96 | caddySrc := fmt.Sprintf("%s/.ship/Caddyfile", src) | ||
| 97 | caddyDst := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) | ||
| 98 | fmt.Println("-> Installing Caddy config...") | ||
| 99 | if _, err := client.RunSudo(fmt.Sprintf("cp %s %s", caddySrc, caddyDst)); err != nil { | ||
| 100 | fmt.Printf(" Warning: no .ship/Caddyfile found, skipping\n") | ||
| 101 | } else { | ||
| 102 | if _, err := client.RunSudo("systemctl reload caddy"); err != nil { | ||
| 103 | return fmt.Errorf("error reloading Caddy: %w", err) | ||
| 104 | } | ||
| 105 | } | ||
| 106 | |||
| 107 | fmt.Println("-> Building Docker image...") | ||
| 108 | if err := client.RunSudoStream(fmt.Sprintf("docker build -t %s:latest %s", name, src)); err != nil { | ||
| 109 | return fmt.Errorf("error building Docker image: %w", err) | ||
| 110 | } | ||
| 111 | |||
| 112 | fmt.Println("-> Restarting service...") | ||
| 113 | if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { | ||
| 114 | return fmt.Errorf("error restarting service: %w", err) | ||
| 115 | } | ||
| 116 | |||
| 117 | return nil | ||
| 118 | } | ||
| 119 | |||
| 120 | func deployGitStatic(client *ssh.Client, name string) error { | ||
| 121 | repo := fmt.Sprintf("/srv/git/%s.git", name) | ||
| 122 | webroot := fmt.Sprintf("/var/www/%s", name) | ||
| 123 | |||
| 124 | fmt.Println("-> Deploying static site...") | ||
| 125 | if _, err := client.RunSudo(fmt.Sprintf("git --work-tree=%s --git-dir=%s checkout -f main", webroot, repo)); err != nil { | ||
| 126 | return fmt.Errorf("error checking out code: %w", err) | ||
| 127 | } | ||
| 128 | |||
| 129 | caddySrc := fmt.Sprintf("%s/.ship/Caddyfile", webroot) | ||
| 130 | caddyDst := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) | ||
| 131 | fmt.Println("-> Installing Caddy config...") | ||
| 132 | if _, err := client.RunSudo(fmt.Sprintf("cp %s %s", caddySrc, caddyDst)); err != nil { | ||
| 133 | fmt.Printf(" Warning: no .ship/Caddyfile found, skipping\n") | ||
| 134 | } else { | ||
| 135 | if _, err := client.RunSudo("systemctl reload caddy"); err != nil { | ||
| 136 | return fmt.Errorf("error reloading Caddy: %w", err) | ||
| 137 | } | ||
| 138 | } | ||
| 139 | |||
| 140 | return nil | ||
| 141 | } | ||
diff --git a/cmd/ship/deploy_impl_v2.go b/cmd/ship/deploy_impl_v2.go new file mode 100644 index 0000000..5b68dc3 --- /dev/null +++ b/cmd/ship/deploy_impl_v2.go | |||
| @@ -0,0 +1,397 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "net/http" | ||
| 6 | "strconv" | ||
| 7 | "strings" | ||
| 8 | "time" | ||
| 9 | |||
| 10 | "github.com/bdw/ship/internal/output" | ||
| 11 | "github.com/bdw/ship/internal/ssh" | ||
| 12 | "github.com/bdw/ship/internal/templates" | ||
| 13 | ) | ||
| 14 | |||
| 15 | // deployStaticV2 deploys a static site | ||
| 16 | // 1. rsync path to /var/www/<name>/ | ||
| 17 | // 2. Generate and upload Caddyfile | ||
| 18 | // 3. Reload Caddy | ||
| 19 | func deployStaticV2(ctx *deployContext) *output.ErrorResponse { | ||
| 20 | client, err := ssh.Connect(ctx.SSHHost) | ||
| 21 | if err != nil { | ||
| 22 | return output.Err(output.ErrSSHConnectFailed, err.Error()) | ||
| 23 | } | ||
| 24 | defer client.Close() | ||
| 25 | |||
| 26 | name := ctx.Name | ||
| 27 | remotePath := fmt.Sprintf("/var/www/%s", name) | ||
| 28 | |||
| 29 | // Create directory and set ownership for upload | ||
| 30 | user, _ := client.Run("whoami") | ||
| 31 | user = strings.TrimSpace(user) | ||
| 32 | if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", remotePath)); err != nil { | ||
| 33 | return output.Err(output.ErrUploadFailed, "failed to create directory: "+err.Error()) | ||
| 34 | } | ||
| 35 | if _, err := client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", user, user, remotePath)); err != nil { | ||
| 36 | return output.Err(output.ErrUploadFailed, "failed to set directory ownership: "+err.Error()) | ||
| 37 | } | ||
| 38 | |||
| 39 | // Upload files using rsync | ||
| 40 | if err := client.UploadDir(ctx.Path, remotePath); err != nil { | ||
| 41 | return output.Err(output.ErrUploadFailed, err.Error()) | ||
| 42 | } | ||
| 43 | |||
| 44 | // Set ownership back to www-data | ||
| 45 | if _, err := client.RunSudo(fmt.Sprintf("chown -R www-data:www-data %s", remotePath)); err != nil { | ||
| 46 | // Non-fatal, continue | ||
| 47 | } | ||
| 48 | |||
| 49 | // Generate Caddyfile only if it doesn't exist (preserve manual edits) | ||
| 50 | caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) | ||
| 51 | caddyExists, _ := client.Run(fmt.Sprintf("test -f %s && echo exists", caddyPath)) | ||
| 52 | if strings.TrimSpace(caddyExists) != "exists" { | ||
| 53 | caddyfile, err := templates.StaticCaddy(map[string]string{ | ||
| 54 | "Domain": ctx.URL[8:], // Strip https:// | ||
| 55 | "RootDir": remotePath, | ||
| 56 | "Name": name, | ||
| 57 | }) | ||
| 58 | if err != nil { | ||
| 59 | return output.Err(output.ErrCaddyFailed, "failed to generate Caddyfile: "+err.Error()) | ||
| 60 | } | ||
| 61 | |||
| 62 | if err := client.WriteSudoFile(caddyPath, caddyfile); err != nil { | ||
| 63 | return output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error()) | ||
| 64 | } | ||
| 65 | } | ||
| 66 | |||
| 67 | // Reload Caddy | ||
| 68 | if _, err := client.RunSudo("systemctl reload caddy"); err != nil { | ||
| 69 | return output.Err(output.ErrCaddyFailed, "failed to reload Caddy: "+err.Error()) | ||
| 70 | } | ||
| 71 | |||
| 72 | return nil | ||
| 73 | } | ||
| 74 | |||
| 75 | // deployDockerV2 deploys a Docker-based app | ||
| 76 | // 1. Allocate port | ||
| 77 | // 2. rsync path to /var/lib/<name>/src/ | ||
| 78 | // 3. docker build | ||
| 79 | // 4. Generate systemd unit and env file | ||
| 80 | // 5. Generate Caddyfile | ||
| 81 | // 6. Start service, reload Caddy | ||
| 82 | func deployDockerV2(ctx *deployContext) *output.ErrorResponse { | ||
| 83 | client, err := ssh.Connect(ctx.SSHHost) | ||
| 84 | if err != nil { | ||
| 85 | return output.Err(output.ErrSSHConnectFailed, err.Error()) | ||
| 86 | } | ||
| 87 | defer client.Close() | ||
| 88 | |||
| 89 | name := ctx.Name | ||
| 90 | |||
| 91 | // Allocate port on server | ||
| 92 | port, err := allocatePort(client, name) | ||
| 93 | if err != nil { | ||
| 94 | return output.Err(output.ErrServiceFailed, "failed to allocate port: "+err.Error()) | ||
| 95 | } | ||
| 96 | |||
| 97 | srcPath := fmt.Sprintf("/var/lib/%s/src", name) | ||
| 98 | dataPath := fmt.Sprintf("/var/lib/%s/data", name) | ||
| 99 | |||
| 100 | // Create directories | ||
| 101 | if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s %s", srcPath, dataPath)); err != nil { | ||
| 102 | return output.Err(output.ErrUploadFailed, "failed to create directories: "+err.Error()) | ||
| 103 | } | ||
| 104 | |||
| 105 | // Set ownership for upload | ||
| 106 | user, _ := client.Run("whoami") | ||
| 107 | user = strings.TrimSpace(user) | ||
| 108 | if _, err := client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", user, user, srcPath)); err != nil { | ||
| 109 | return output.Err(output.ErrUploadFailed, "failed to set directory ownership: "+err.Error()) | ||
| 110 | } | ||
| 111 | |||
| 112 | // Upload source | ||
| 113 | if err := client.UploadDir(ctx.Path, srcPath); err != nil { | ||
| 114 | return output.Err(output.ErrUploadFailed, err.Error()) | ||
| 115 | } | ||
| 116 | |||
| 117 | // Docker build | ||
| 118 | buildCmd := fmt.Sprintf("docker build -t %s:latest %s", name, srcPath) | ||
| 119 | if _, err := client.RunSudo(buildCmd); err != nil { | ||
| 120 | return output.Err(output.ErrBuildFailed, err.Error()) | ||
| 121 | } | ||
| 122 | |||
| 123 | // Generate and write env file | ||
| 124 | envContent := fmt.Sprintf("PORT=%d\nSHIP_NAME=%s\nSHIP_URL=%s\n", port, name, ctx.URL) | ||
| 125 | for _, e := range ctx.Opts.Env { | ||
| 126 | envContent += e + "\n" | ||
| 127 | } | ||
| 128 | envPath := fmt.Sprintf("/etc/ship/env/%s.env", name) | ||
| 129 | if _, err := client.RunSudo("mkdir -p /etc/ship/env"); err != nil { | ||
| 130 | // Continue, directory might exist | ||
| 131 | } | ||
| 132 | if err := client.WriteSudoFile(envPath, envContent); err != nil { | ||
| 133 | return output.Err(output.ErrServiceFailed, "failed to write env file: "+err.Error()) | ||
| 134 | } | ||
| 135 | |||
| 136 | // Generate systemd unit | ||
| 137 | containerPort := ctx.Opts.ContainerPort | ||
| 138 | if containerPort == 0 { | ||
| 139 | containerPort = 80 | ||
| 140 | } | ||
| 141 | service, err := templates.DockerService(map[string]string{ | ||
| 142 | "Name": name, | ||
| 143 | "Port": strconv.Itoa(port), | ||
| 144 | "ContainerPort": strconv.Itoa(containerPort), | ||
| 145 | }) | ||
| 146 | if err != nil { | ||
| 147 | return output.Err(output.ErrServiceFailed, "failed to generate systemd unit: "+err.Error()) | ||
| 148 | } | ||
| 149 | |||
| 150 | servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name) | ||
| 151 | if err := client.WriteSudoFile(servicePath, service); err != nil { | ||
| 152 | return output.Err(output.ErrServiceFailed, "failed to write systemd unit: "+err.Error()) | ||
| 153 | } | ||
| 154 | |||
| 155 | // Generate Caddyfile only if it doesn't exist (preserve manual edits) | ||
| 156 | caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) | ||
| 157 | caddyExists, _ := client.Run(fmt.Sprintf("test -f %s && echo exists", caddyPath)) | ||
| 158 | if strings.TrimSpace(caddyExists) != "exists" { | ||
| 159 | caddyfile, err := templates.AppCaddy(map[string]string{ | ||
| 160 | "Domain": ctx.URL[8:], // Strip https:// | ||
| 161 | "Port": strconv.Itoa(port), | ||
| 162 | }) | ||
| 163 | if err != nil { | ||
| 164 | return output.Err(output.ErrCaddyFailed, "failed to generate Caddyfile: "+err.Error()) | ||
| 165 | } | ||
| 166 | |||
| 167 | if err := client.WriteSudoFile(caddyPath, caddyfile); err != nil { | ||
| 168 | return output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error()) | ||
| 169 | } | ||
| 170 | } | ||
| 171 | |||
| 172 | // Reload systemd and start service | ||
| 173 | if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { | ||
| 174 | return output.Err(output.ErrServiceFailed, "failed to reload systemd: "+err.Error()) | ||
| 175 | } | ||
| 176 | if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { | ||
| 177 | return output.Err(output.ErrServiceFailed, "failed to start service: "+err.Error()) | ||
| 178 | } | ||
| 179 | |||
| 180 | // Reload Caddy | ||
| 181 | if _, err := client.RunSudo("systemctl reload caddy"); err != nil { | ||
| 182 | return output.Err(output.ErrCaddyFailed, "failed to reload Caddy: "+err.Error()) | ||
| 183 | } | ||
| 184 | |||
| 185 | return nil | ||
| 186 | } | ||
| 187 | |||
| 188 | // deployBinaryV2 deploys a pre-built binary | ||
| 189 | // 1. Allocate port | ||
| 190 | // 2. scp binary to /usr/local/bin/<name> | ||
| 191 | // 3. Create user for service | ||
| 192 | // 4. Generate systemd unit and env file | ||
| 193 | // 5. Generate Caddyfile | ||
| 194 | // 6. Start service, reload Caddy | ||
| 195 | func deployBinaryV2(ctx *deployContext) *output.ErrorResponse { | ||
| 196 | client, err := ssh.Connect(ctx.SSHHost) | ||
| 197 | if err != nil { | ||
| 198 | return output.Err(output.ErrSSHConnectFailed, err.Error()) | ||
| 199 | } | ||
| 200 | defer client.Close() | ||
| 201 | |||
| 202 | name := ctx.Name | ||
| 203 | |||
| 204 | // Allocate port on server | ||
| 205 | port, err := allocatePort(client, name) | ||
| 206 | if err != nil { | ||
| 207 | return output.Err(output.ErrServiceFailed, "failed to allocate port: "+err.Error()) | ||
| 208 | } | ||
| 209 | |||
| 210 | binaryPath := fmt.Sprintf("/usr/local/bin/%s", name) | ||
| 211 | workDir := fmt.Sprintf("/var/lib/%s", name) | ||
| 212 | |||
| 213 | // Upload binary | ||
| 214 | if err := client.Upload(ctx.Path, "/tmp/"+name); err != nil { | ||
| 215 | return output.Err(output.ErrUploadFailed, err.Error()) | ||
| 216 | } | ||
| 217 | |||
| 218 | // Move to final location and set permissions | ||
| 219 | if _, err := client.RunSudo(fmt.Sprintf("mv /tmp/%s %s && chmod +x %s", name, binaryPath, binaryPath)); err != nil { | ||
| 220 | return output.Err(output.ErrUploadFailed, "failed to install binary: "+err.Error()) | ||
| 221 | } | ||
| 222 | |||
| 223 | // Create work directory | ||
| 224 | if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", workDir)); err != nil { | ||
| 225 | return output.Err(output.ErrServiceFailed, "failed to create work directory: "+err.Error()) | ||
| 226 | } | ||
| 227 | |||
| 228 | // Create service user (ignore error if exists) | ||
| 229 | client.RunSudo(fmt.Sprintf("useradd -r -s /bin/false %s 2>/dev/null || true", name)) | ||
| 230 | client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", name, name, workDir)) | ||
| 231 | |||
| 232 | // Generate and write env file | ||
| 233 | envContent := fmt.Sprintf("PORT=%d\nSHIP_NAME=%s\nSHIP_URL=%s\n", port, name, ctx.URL) | ||
| 234 | for _, e := range ctx.Opts.Env { | ||
| 235 | envContent += e + "\n" | ||
| 236 | } | ||
| 237 | envPath := fmt.Sprintf("/etc/ship/env/%s.env", name) | ||
| 238 | if _, err := client.RunSudo("mkdir -p /etc/ship/env"); err != nil { | ||
| 239 | // Continue | ||
| 240 | } | ||
| 241 | if err := client.WriteSudoFile(envPath, envContent); err != nil { | ||
| 242 | return output.Err(output.ErrServiceFailed, "failed to write env file: "+err.Error()) | ||
| 243 | } | ||
| 244 | |||
| 245 | // Generate systemd unit | ||
| 246 | service, err := templates.SystemdService(map[string]string{ | ||
| 247 | "Name": name, | ||
| 248 | "User": name, | ||
| 249 | "WorkDir": workDir, | ||
| 250 | "EnvFile": envPath, | ||
| 251 | "BinaryPath": binaryPath, | ||
| 252 | "Args": "", | ||
| 253 | }) | ||
| 254 | if err != nil { | ||
| 255 | return output.Err(output.ErrServiceFailed, "failed to generate systemd unit: "+err.Error()) | ||
| 256 | } | ||
| 257 | |||
| 258 | servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name) | ||
| 259 | if err := client.WriteSudoFile(servicePath, service); err != nil { | ||
| 260 | return output.Err(output.ErrServiceFailed, "failed to write systemd unit: "+err.Error()) | ||
| 261 | } | ||
| 262 | |||
| 263 | // Generate Caddyfile only if it doesn't exist (preserve manual edits) | ||
| 264 | caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) | ||
| 265 | caddyExists, _ := client.Run(fmt.Sprintf("test -f %s && echo exists", caddyPath)) | ||
| 266 | if strings.TrimSpace(caddyExists) != "exists" { | ||
| 267 | caddyfile, err := templates.AppCaddy(map[string]string{ | ||
| 268 | "Domain": ctx.URL[8:], // Strip https:// | ||
| 269 | "Port": strconv.Itoa(port), | ||
| 270 | }) | ||
| 271 | if err != nil { | ||
| 272 | return output.Err(output.ErrCaddyFailed, "failed to generate Caddyfile: "+err.Error()) | ||
| 273 | } | ||
| 274 | |||
| 275 | if err := client.WriteSudoFile(caddyPath, caddyfile); err != nil { | ||
| 276 | return output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error()) | ||
| 277 | } | ||
| 278 | } | ||
| 279 | |||
| 280 | // Reload systemd and start service | ||
| 281 | if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { | ||
| 282 | return output.Err(output.ErrServiceFailed, "failed to reload systemd: "+err.Error()) | ||
| 283 | } | ||
| 284 | if _, err := client.RunSudo(fmt.Sprintf("systemctl enable --now %s", name)); err != nil { | ||
| 285 | return output.Err(output.ErrServiceFailed, "failed to start service: "+err.Error()) | ||
| 286 | } | ||
| 287 | |||
| 288 | // Reload Caddy | ||
| 289 | if _, err := client.RunSudo("systemctl reload caddy"); err != nil { | ||
| 290 | return output.Err(output.ErrCaddyFailed, "failed to reload Caddy: "+err.Error()) | ||
| 291 | } | ||
| 292 | |||
| 293 | return nil | ||
| 294 | } | ||
| 295 | |||
| 296 | // allocatePort allocates or retrieves a port for a service | ||
| 297 | // Uses atomic increment on /etc/ship/next_port to avoid collisions | ||
| 298 | func allocatePort(client *ssh.Client, name string) (int, error) { | ||
| 299 | portFile := fmt.Sprintf("/etc/ship/ports/%s", name) | ||
| 300 | |||
| 301 | // Try to read existing port for this app | ||
| 302 | out, err := client.Run(fmt.Sprintf("cat %s 2>/dev/null || echo ''", portFile)) | ||
| 303 | if err == nil && out != "" { | ||
| 304 | out = strings.TrimSpace(out) | ||
| 305 | if port, err := strconv.Atoi(out); err == nil && port > 0 { | ||
| 306 | return port, nil | ||
| 307 | } | ||
| 308 | } | ||
| 309 | |||
| 310 | // Allocate new port atomically using flock | ||
| 311 | // This reads next_port, increments it, and writes back while holding a lock | ||
| 312 | allocScript := ` | ||
| 313 | flock -x /etc/ship/.port.lock sh -c ' | ||
| 314 | mkdir -p /etc/ship/ports | ||
| 315 | PORT=$(cat /etc/ship/next_port 2>/dev/null || echo 9000) | ||
| 316 | echo $((PORT + 1)) > /etc/ship/next_port | ||
| 317 | echo $PORT | ||
| 318 | '` | ||
| 319 | out, err = client.RunSudo(allocScript) | ||
| 320 | if err != nil { | ||
| 321 | return 0, fmt.Errorf("failed to allocate port: %w", err) | ||
| 322 | } | ||
| 323 | |||
| 324 | port, err := strconv.Atoi(strings.TrimSpace(out)) | ||
| 325 | if err != nil { | ||
| 326 | return 0, fmt.Errorf("invalid port allocated: %s", out) | ||
| 327 | } | ||
| 328 | |||
| 329 | // Write port allocation for this app | ||
| 330 | if err := client.WriteSudoFile(portFile, strconv.Itoa(port)); err != nil { | ||
| 331 | return 0, err | ||
| 332 | } | ||
| 333 | |||
| 334 | return port, nil | ||
| 335 | } | ||
| 336 | |||
| 337 | // setTTLV2 sets auto-expiry for a deploy | ||
| 338 | func setTTLV2(ctx *deployContext, ttl time.Duration) error { | ||
| 339 | client, err := ssh.Connect(ctx.SSHHost) | ||
| 340 | if err != nil { | ||
| 341 | return err | ||
| 342 | } | ||
| 343 | defer client.Close() | ||
| 344 | |||
| 345 | expires := time.Now().Add(ttl).Unix() | ||
| 346 | ttlPath := fmt.Sprintf("/etc/ship/ttl/%s", ctx.Name) | ||
| 347 | |||
| 348 | if _, err := client.RunSudo("mkdir -p /etc/ship/ttl"); err != nil { | ||
| 349 | return err | ||
| 350 | } | ||
| 351 | |||
| 352 | return client.WriteSudoFile(ttlPath, strconv.FormatInt(expires, 10)) | ||
| 353 | } | ||
| 354 | |||
| 355 | // runHealthCheck verifies the deploy is responding | ||
| 356 | func runHealthCheck(url, endpoint string) (*output.HealthResult, *output.ErrorResponse) { | ||
| 357 | fullURL := url + endpoint | ||
| 358 | |||
| 359 | // Wait for app to start | ||
| 360 | time.Sleep(2 * time.Second) | ||
| 361 | |||
| 362 | var lastErr error | ||
| 363 | var lastStatus int | ||
| 364 | |||
| 365 | for i := 0; i < 15; i++ { | ||
| 366 | start := time.Now() | ||
| 367 | resp, err := http.Get(fullURL) | ||
| 368 | latency := time.Since(start).Milliseconds() | ||
| 369 | |||
| 370 | if err != nil { | ||
| 371 | lastErr = err | ||
| 372 | time.Sleep(2 * time.Second) | ||
| 373 | continue | ||
| 374 | } | ||
| 375 | resp.Body.Close() | ||
| 376 | lastStatus = resp.StatusCode | ||
| 377 | |||
| 378 | if resp.StatusCode >= 200 && resp.StatusCode < 400 { | ||
| 379 | return &output.HealthResult{ | ||
| 380 | Endpoint: endpoint, | ||
| 381 | Status: resp.StatusCode, | ||
| 382 | LatencyMs: latency, | ||
| 383 | }, nil | ||
| 384 | } | ||
| 385 | |||
| 386 | time.Sleep(2 * time.Second) | ||
| 387 | } | ||
| 388 | |||
| 389 | msg := fmt.Sprintf("health check failed after 30s: ") | ||
| 390 | if lastErr != nil { | ||
| 391 | msg += lastErr.Error() | ||
| 392 | } else { | ||
| 393 | msg += fmt.Sprintf("status %d", lastStatus) | ||
| 394 | } | ||
| 395 | |||
| 396 | return nil, output.Err(output.ErrHealthCheckFailed, msg) | ||
| 397 | } | ||
diff --git a/cmd/ship/deploy_v2.go b/cmd/ship/deploy_v2.go new file mode 100644 index 0000000..7d498b2 --- /dev/null +++ b/cmd/ship/deploy_v2.go | |||
| @@ -0,0 +1,210 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "crypto/rand" | ||
| 5 | "encoding/hex" | ||
| 6 | "fmt" | ||
| 7 | "regexp" | ||
| 8 | "strings" | ||
| 9 | "time" | ||
| 10 | |||
| 11 | "github.com/bdw/ship/internal/detect" | ||
| 12 | "github.com/bdw/ship/internal/output" | ||
| 13 | "github.com/bdw/ship/internal/state" | ||
| 14 | ) | ||
| 15 | |||
| 16 | // deployV2 implements the new agent-first deploy interface. | ||
| 17 | // Usage: ship [PATH] [FLAGS] | ||
| 18 | // PATH defaults to "." if not provided. | ||
| 19 | func deployV2(path string, opts deployV2Options) { | ||
| 20 | start := time.Now() | ||
| 21 | |||
| 22 | // Validate name if provided | ||
| 23 | if opts.Name != "" { | ||
| 24 | if err := validateNameV2(opts.Name); err != nil { | ||
| 25 | output.PrintAndExit(err) | ||
| 26 | } | ||
| 27 | } | ||
| 28 | |||
| 29 | // Parse TTL if provided | ||
| 30 | var ttlDuration time.Duration | ||
| 31 | if opts.TTL != "" { | ||
| 32 | var err error | ||
| 33 | ttlDuration, err = parseTTL(opts.TTL) | ||
| 34 | if err != nil { | ||
| 35 | output.PrintAndExit(output.Err(output.ErrInvalidTTL, err.Error())) | ||
| 36 | } | ||
| 37 | } | ||
| 38 | |||
| 39 | // Get host configuration | ||
| 40 | st, err := state.Load() | ||
| 41 | if err != nil { | ||
| 42 | output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "failed to load state: "+err.Error())) | ||
| 43 | } | ||
| 44 | |||
| 45 | hostName := opts.Host | ||
| 46 | if hostName == "" { | ||
| 47 | hostName = st.DefaultHost | ||
| 48 | } | ||
| 49 | if hostName == "" { | ||
| 50 | output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified and no default host configured. Run: ship host init")) | ||
| 51 | } | ||
| 52 | |||
| 53 | hostConfig := st.GetHost(hostName) | ||
| 54 | if hostConfig.BaseDomain == "" { | ||
| 55 | output.PrintAndExit(output.Err(output.ErrHostNotConfigured, fmt.Sprintf("host %q has no base domain configured. Run: ship host init", hostName))) | ||
| 56 | } | ||
| 57 | |||
| 58 | // Auto-detect project type | ||
| 59 | result := detect.Detect(path) | ||
| 60 | if result.Error != nil { | ||
| 61 | output.PrintAndExit(result.Error) | ||
| 62 | } | ||
| 63 | |||
| 64 | // Generate name if not provided | ||
| 65 | name := opts.Name | ||
| 66 | if name == "" { | ||
| 67 | name = generateName() | ||
| 68 | } | ||
| 69 | |||
| 70 | // Build URL: use custom domain if provided, otherwise use subdomain | ||
| 71 | var url string | ||
| 72 | if opts.Domain != "" { | ||
| 73 | url = fmt.Sprintf("https://%s", opts.Domain) | ||
| 74 | } else { | ||
| 75 | url = fmt.Sprintf("https://%s.%s", name, hostConfig.BaseDomain) | ||
| 76 | } | ||
| 77 | |||
| 78 | // Build deploy context | ||
| 79 | ctx := &deployContext{ | ||
| 80 | SSHHost: hostName, | ||
| 81 | HostConfig: hostConfig, | ||
| 82 | Name: name, | ||
| 83 | Path: result.Path, | ||
| 84 | URL: url, | ||
| 85 | Opts: opts, | ||
| 86 | } | ||
| 87 | |||
| 88 | // Deploy based on type | ||
| 89 | var deployErr *output.ErrorResponse | ||
| 90 | switch result.Type { | ||
| 91 | case detect.TypeStatic: | ||
| 92 | deployErr = deployStaticV2(ctx) | ||
| 93 | case detect.TypeDocker: | ||
| 94 | deployErr = deployDockerV2(ctx) | ||
| 95 | case detect.TypeBinary: | ||
| 96 | deployErr = deployBinaryV2(ctx) | ||
| 97 | } | ||
| 98 | |||
| 99 | if deployErr != nil { | ||
| 100 | deployErr.Name = name | ||
| 101 | deployErr.URL = url | ||
| 102 | output.PrintAndExit(deployErr) | ||
| 103 | } | ||
| 104 | |||
| 105 | // Set TTL if specified | ||
| 106 | if ttlDuration > 0 { | ||
| 107 | if err := setTTLV2(ctx, ttlDuration); err != nil { | ||
| 108 | // Non-fatal, deploy succeeded | ||
| 109 | // TODO: log warning | ||
| 110 | } | ||
| 111 | } | ||
| 112 | |||
| 113 | // Health check | ||
| 114 | var healthResult *output.HealthResult | ||
| 115 | if opts.Health != "" || result.Type == detect.TypeStatic { | ||
| 116 | endpoint := opts.Health | ||
| 117 | if endpoint == "" { | ||
| 118 | endpoint = "/" | ||
| 119 | } | ||
| 120 | healthResult, deployErr = runHealthCheck(url, endpoint) | ||
| 121 | if deployErr != nil { | ||
| 122 | deployErr.Name = name | ||
| 123 | deployErr.URL = url | ||
| 124 | output.PrintAndExit(deployErr) | ||
| 125 | } | ||
| 126 | } | ||
| 127 | |||
| 128 | // Build response | ||
| 129 | resp := &output.DeployResponse{ | ||
| 130 | Status: "ok", | ||
| 131 | Name: name, | ||
| 132 | URL: url, | ||
| 133 | Type: string(result.Type), | ||
| 134 | TookMs: time.Since(start).Milliseconds(), | ||
| 135 | Health: healthResult, | ||
| 136 | } | ||
| 137 | |||
| 138 | if ttlDuration > 0 { | ||
| 139 | resp.Expires = time.Now().Add(ttlDuration).UTC().Format(time.RFC3339) | ||
| 140 | } | ||
| 141 | |||
| 142 | output.PrintAndExit(resp) | ||
| 143 | } | ||
| 144 | |||
| 145 | type deployV2Options struct { | ||
| 146 | Name string | ||
| 147 | Host string | ||
| 148 | Domain string | ||
| 149 | Health string | ||
| 150 | TTL string | ||
| 151 | Env []string | ||
| 152 | EnvFile string | ||
| 153 | ContainerPort int // Port the container listens on (default 80 for Docker) | ||
| 154 | Pretty bool | ||
| 155 | } | ||
| 156 | |||
| 157 | // deployContext holds all info needed for a deploy | ||
| 158 | type deployContext struct { | ||
| 159 | SSHHost string // SSH connection string (config alias or user@host) | ||
| 160 | HostConfig *state.Host // Host configuration | ||
| 161 | Name string // Deploy name | ||
| 162 | Path string // Local path to deploy | ||
| 163 | URL string // Full URL after deploy | ||
| 164 | Opts deployV2Options | ||
| 165 | } | ||
| 166 | |||
| 167 | // validateNameV2 checks if name matches allowed pattern | ||
| 168 | func validateNameV2(name string) *output.ErrorResponse { | ||
| 169 | // Must be lowercase alphanumeric with hyphens, 1-63 chars | ||
| 170 | pattern := regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$|^[a-z0-9]$`) | ||
| 171 | if !pattern.MatchString(name) { | ||
| 172 | return output.Err(output.ErrInvalidName, | ||
| 173 | "name must be lowercase alphanumeric with hyphens, 1-63 characters") | ||
| 174 | } | ||
| 175 | return nil | ||
| 176 | } | ||
| 177 | |||
| 178 | // generateName creates a random deploy name | ||
| 179 | func generateName() string { | ||
| 180 | bytes := make([]byte, 3) | ||
| 181 | rand.Read(bytes) | ||
| 182 | return "ship-" + hex.EncodeToString(bytes) | ||
| 183 | } | ||
| 184 | |||
| 185 | // parseTTL converts duration strings like "1h", "7d" to time.Duration | ||
| 186 | func parseTTL(s string) (time.Duration, error) { | ||
| 187 | s = strings.TrimSpace(s) | ||
| 188 | if s == "" { | ||
| 189 | return 0, nil | ||
| 190 | } | ||
| 191 | |||
| 192 | // Handle days specially (not supported by time.ParseDuration) | ||
| 193 | if strings.HasSuffix(s, "d") { | ||
| 194 | days := strings.TrimSuffix(s, "d") | ||
| 195 | var d int | ||
| 196 | _, err := fmt.Sscanf(days, "%d", &d) | ||
| 197 | if err != nil { | ||
| 198 | return 0, fmt.Errorf("invalid TTL: %s", s) | ||
| 199 | } | ||
| 200 | return time.Duration(d) * 24 * time.Hour, nil | ||
| 201 | } | ||
| 202 | |||
| 203 | d, err := time.ParseDuration(s) | ||
| 204 | if err != nil { | ||
| 205 | return 0, fmt.Errorf("invalid TTL: %s", s) | ||
| 206 | } | ||
| 207 | return d, nil | ||
| 208 | } | ||
| 209 | |||
| 210 | // Deploy implementations are in deploy_impl_v2.go | ||
diff --git a/cmd/ship/env/env.go b/cmd/ship/env/env.go deleted file mode 100644 index 489353a..0000000 --- a/cmd/ship/env/env.go +++ /dev/null | |||
| @@ -1,17 +0,0 @@ | |||
| 1 | package env | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "github.com/spf13/cobra" | ||
| 5 | ) | ||
| 6 | |||
| 7 | var Cmd = &cobra.Command{ | ||
| 8 | Use: "env", | ||
| 9 | Short: "Manage environment variables", | ||
| 10 | Long: "Manage environment variables for deployed applications", | ||
| 11 | } | ||
| 12 | |||
| 13 | func 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 deleted file mode 100644 index e94b83a..0000000 --- a/cmd/ship/env/list.go +++ /dev/null | |||
| @@ -1,72 +0,0 @@ | |||
| 1 | package env | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "strings" | ||
| 6 | |||
| 7 | "github.com/bdw/ship/internal/state" | ||
| 8 | "github.com/spf13/cobra" | ||
| 9 | ) | ||
| 10 | |||
| 11 | var 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 | |||
| 18 | func runList(cmd *cobra.Command, args []string) error { | ||
| 19 | name := args[0] | ||
| 20 | if err := state.ValidateName(name); err != nil { | ||
| 21 | return err | ||
| 22 | } | ||
| 23 | |||
| 24 | st, err := state.Load() | ||
| 25 | if err != nil { | ||
| 26 | return fmt.Errorf("error loading state: %w", err) | ||
| 27 | } | ||
| 28 | |||
| 29 | host, _ := cmd.Flags().GetString("host") | ||
| 30 | if host == "" { | ||
| 31 | host = st.GetDefaultHost() | ||
| 32 | } | ||
| 33 | |||
| 34 | if host == "" { | ||
| 35 | return fmt.Errorf("--host is required") | ||
| 36 | } | ||
| 37 | |||
| 38 | app, err := st.GetApp(host, name) | ||
| 39 | if err != nil { | ||
| 40 | return err | ||
| 41 | } | ||
| 42 | |||
| 43 | if app.Type != "app" { | ||
| 44 | return fmt.Errorf("env is only available for apps, not static sites") | ||
| 45 | } | ||
| 46 | |||
| 47 | fmt.Printf("Environment variables for %s:\n\n", name) | ||
| 48 | if len(app.Env) == 0 { | ||
| 49 | fmt.Println(" (none)") | ||
| 50 | } else { | ||
| 51 | for k, v := range app.Env { | ||
| 52 | display := v | ||
| 53 | if isSensitive(k) { | ||
| 54 | display = "***" | ||
| 55 | } | ||
| 56 | fmt.Printf(" %s=%s\n", k, display) | ||
| 57 | } | ||
| 58 | } | ||
| 59 | |||
| 60 | return nil | ||
| 61 | } | ||
| 62 | |||
| 63 | func isSensitive(key string) bool { | ||
| 64 | key = strings.ToLower(key) | ||
| 65 | sensitiveWords := []string{"key", "secret", "password", "token", "api"} | ||
| 66 | for _, word := range sensitiveWords { | ||
| 67 | if strings.Contains(key, word) { | ||
| 68 | return true | ||
| 69 | } | ||
| 70 | } | ||
| 71 | return false | ||
| 72 | } | ||
diff --git a/cmd/ship/env/set.go b/cmd/ship/env/set.go deleted file mode 100644 index d4292f3..0000000 --- a/cmd/ship/env/set.go +++ /dev/null | |||
| @@ -1,135 +0,0 @@ | |||
| 1 | package env | ||
| 2 | |||
| 3 | import ( | ||
| 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 | |||
| 14 | var 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 | |||
| 22 | func init() { | ||
| 23 | setCmd.Flags().StringP("file", "f", "", "Load environment from file") | ||
| 24 | } | ||
| 25 | |||
| 26 | func runSet(cmd *cobra.Command, args []string) error { | ||
| 27 | name := args[0] | ||
| 28 | if err := state.ValidateName(name); err != nil { | ||
| 29 | return err | ||
| 30 | } | ||
| 31 | envVars := args[1:] | ||
| 32 | |||
| 33 | st, err := state.Load() | ||
| 34 | if err != nil { | ||
| 35 | return fmt.Errorf("error loading state: %w", err) | ||
| 36 | } | ||
| 37 | |||
| 38 | host, _ := cmd.Flags().GetString("host") | ||
| 39 | if host == "" { | ||
| 40 | host = st.GetDefaultHost() | ||
| 41 | } | ||
| 42 | |||
| 43 | if host == "" { | ||
| 44 | return fmt.Errorf("--host is required") | ||
| 45 | } | ||
| 46 | |||
| 47 | app, err := st.GetApp(host, name) | ||
| 48 | if err != nil { | ||
| 49 | return err | ||
| 50 | } | ||
| 51 | |||
| 52 | if app.Type != "app" { | ||
| 53 | return fmt.Errorf("env is only available for apps, not static sites") | ||
| 54 | } | ||
| 55 | |||
| 56 | if app.Env == nil { | ||
| 57 | app.Env = make(map[string]string) | ||
| 58 | } | ||
| 59 | |||
| 60 | // Set variables from args | ||
| 61 | for _, e := range envVars { | ||
| 62 | parts := strings.SplitN(e, "=", 2) | ||
| 63 | if len(parts) == 2 { | ||
| 64 | app.Env[parts[0]] = parts[1] | ||
| 65 | fmt.Printf("Set %s\n", parts[0]) | ||
| 66 | } else { | ||
| 67 | return fmt.Errorf("invalid format: %s (expected KEY=VALUE)", e) | ||
| 68 | } | ||
| 69 | } | ||
| 70 | |||
| 71 | // Set variables from file if provided | ||
| 72 | envFile, _ := cmd.Flags().GetString("file") | ||
| 73 | if envFile != "" { | ||
| 74 | fileEnv, err := parseEnvFile(envFile) | ||
| 75 | if err != nil { | ||
| 76 | return fmt.Errorf("error reading env file: %w", err) | ||
| 77 | } | ||
| 78 | for k, v := range fileEnv { | ||
| 79 | app.Env[k] = v | ||
| 80 | fmt.Printf("Set %s\n", k) | ||
| 81 | } | ||
| 82 | } | ||
| 83 | |||
| 84 | if err := st.Save(); err != nil { | ||
| 85 | return fmt.Errorf("error saving state: %w", err) | ||
| 86 | } | ||
| 87 | |||
| 88 | client, err := ssh.Connect(host) | ||
| 89 | if err != nil { | ||
| 90 | return fmt.Errorf("error connecting to VPS: %w", err) | ||
| 91 | } | ||
| 92 | defer client.Close() | ||
| 93 | |||
| 94 | fmt.Println("-> Updating environment file on VPS...") | ||
| 95 | envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", name) | ||
| 96 | envContent := "" | ||
| 97 | for k, v := range app.Env { | ||
| 98 | envContent += fmt.Sprintf("%s=%s\n", k, v) | ||
| 99 | } | ||
| 100 | if err := client.WriteSudoFile(envFilePath, envContent); err != nil { | ||
| 101 | return fmt.Errorf("error updating env file: %w", err) | ||
| 102 | } | ||
| 103 | |||
| 104 | fmt.Println("-> Restarting service...") | ||
| 105 | if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { | ||
| 106 | return fmt.Errorf("error restarting service: %w", err) | ||
| 107 | } | ||
| 108 | |||
| 109 | fmt.Println("Environment variables updated successfully") | ||
| 110 | return nil | ||
| 111 | } | ||
| 112 | |||
| 113 | func parseEnvFile(path string) (map[string]string, error) { | ||
| 114 | file, err := os.Open(path) | ||
| 115 | if err != nil { | ||
| 116 | return nil, err | ||
| 117 | } | ||
| 118 | defer file.Close() | ||
| 119 | |||
| 120 | env := make(map[string]string) | ||
| 121 | scanner := bufio.NewScanner(file) | ||
| 122 | for scanner.Scan() { | ||
| 123 | line := strings.TrimSpace(scanner.Text()) | ||
| 124 | if line == "" || strings.HasPrefix(line, "#") { | ||
| 125 | continue | ||
| 126 | } | ||
| 127 | |||
| 128 | parts := strings.SplitN(line, "=", 2) | ||
| 129 | if len(parts) == 2 { | ||
| 130 | env[parts[0]] = parts[1] | ||
| 131 | } | ||
| 132 | } | ||
| 133 | |||
| 134 | return env, scanner.Err() | ||
| 135 | } | ||
diff --git a/cmd/ship/env/unset.go b/cmd/ship/env/unset.go deleted file mode 100644 index 8292f42..0000000 --- a/cmd/ship/env/unset.go +++ /dev/null | |||
| @@ -1,95 +0,0 @@ | |||
| 1 | package env | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | |||
| 6 | "github.com/bdw/ship/internal/ssh" | ||
| 7 | "github.com/bdw/ship/internal/state" | ||
| 8 | "github.com/spf13/cobra" | ||
| 9 | ) | ||
| 10 | |||
| 11 | var 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 | |||
| 19 | func runUnset(cmd *cobra.Command, args []string) error { | ||
| 20 | name := args[0] | ||
| 21 | if err := state.ValidateName(name); err != nil { | ||
| 22 | return err | ||
| 23 | } | ||
| 24 | keys := args[1:] | ||
| 25 | |||
| 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") | ||
| 38 | } | ||
| 39 | |||
| 40 | app, err := st.GetApp(host, name) | ||
| 41 | if err != nil { | ||
| 42 | return err | ||
| 43 | } | ||
| 44 | |||
| 45 | if app.Type != "app" { | ||
| 46 | return fmt.Errorf("env is only available for apps, not static sites") | ||
| 47 | } | ||
| 48 | |||
| 49 | if app.Env == nil { | ||
| 50 | return fmt.Errorf("no environment variables set") | ||
| 51 | } | ||
| 52 | |||
| 53 | changed := false | ||
| 54 | for _, key := range keys { | ||
| 55 | if _, exists := app.Env[key]; exists { | ||
| 56 | delete(app.Env, key) | ||
| 57 | changed = true | ||
| 58 | fmt.Printf("Unset %s\n", key) | ||
| 59 | } else { | ||
| 60 | fmt.Printf("Warning: %s not found\n", key) | ||
| 61 | } | ||
| 62 | } | ||
| 63 | |||
| 64 | if !changed { | ||
| 65 | return nil | ||
| 66 | } | ||
| 67 | |||
| 68 | if err := st.Save(); err != nil { | ||
| 69 | return fmt.Errorf("error saving state: %w", err) | ||
| 70 | } | ||
| 71 | |||
| 72 | client, err := ssh.Connect(host) | ||
| 73 | if err != nil { | ||
| 74 | return fmt.Errorf("error connecting to VPS: %w", err) | ||
| 75 | } | ||
| 76 | defer client.Close() | ||
| 77 | |||
| 78 | fmt.Println("-> Updating environment file on VPS...") | ||
| 79 | envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", name) | ||
| 80 | envContent := "" | ||
| 81 | for k, v := range app.Env { | ||
| 82 | envContent += fmt.Sprintf("%s=%s\n", k, v) | ||
| 83 | } | ||
| 84 | if err := client.WriteSudoFile(envFilePath, envContent); err != nil { | ||
| 85 | return fmt.Errorf("error updating env file: %w", err) | ||
| 86 | } | ||
| 87 | |||
| 88 | fmt.Println("-> Restarting service...") | ||
| 89 | if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { | ||
| 90 | return fmt.Errorf("error restarting service: %w", err) | ||
| 91 | } | ||
| 92 | |||
| 93 | fmt.Println("Environment variables updated successfully") | ||
| 94 | return nil | ||
| 95 | } | ||
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 @@ | |||
| 1 | package host | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "github.com/spf13/cobra" | ||
| 5 | ) | ||
| 6 | |||
| 7 | var Cmd = &cobra.Command{ | ||
| 8 | Use: "host", | ||
| 9 | Short: "Manage VPS host", | ||
| 10 | Long: "Commands for managing and monitoring the VPS host", | ||
| 11 | } | ||
| 12 | |||
| 13 | func 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 @@ | |||
| 1 | package host | ||
| 2 | |||
| 3 | import ( | ||
| 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 | |||
| 13 | var 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 | |||
| 20 | func 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 | |||
| 71 | import /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 | |||
| 138 | func 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. | ||
| 213 | git 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 | |||
| 300 | func 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 @@ | |||
| 1 | package host | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | |||
| 6 | "github.com/bdw/ship/internal/state" | ||
| 7 | "github.com/spf13/cobra" | ||
| 8 | ) | ||
| 9 | |||
| 10 | var 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 | |||
| 15 | When a base domain is configured (e.g., apps.example.com), every deployment | ||
| 16 | will automatically get a subdomain ({name}.apps.example.com). | ||
| 17 | |||
| 18 | Examples: | ||
| 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 | |||
| 24 | func init() { | ||
| 25 | setDomainCmd.Flags().Bool("clear", false, "Clear the base domain") | ||
| 26 | } | ||
| 27 | |||
| 28 | func 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 @@ | |||
| 1 | package host | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "os" | ||
| 6 | "os/exec" | ||
| 7 | |||
| 8 | "github.com/bdw/ship/internal/state" | ||
| 9 | "github.com/spf13/cobra" | ||
| 10 | ) | ||
| 11 | |||
| 12 | var sshCmd = &cobra.Command{ | ||
| 13 | Use: "ssh", | ||
| 14 | Short: "Open interactive SSH session", | ||
| 15 | RunE: runSSH, | ||
| 16 | } | ||
| 17 | |||
| 18 | func 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 @@ | |||
| 1 | package host | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | |||
| 6 | "github.com/bdw/ship/internal/ssh" | ||
| 7 | "github.com/bdw/ship/internal/state" | ||
| 8 | "github.com/spf13/cobra" | ||
| 9 | ) | ||
| 10 | |||
| 11 | var statusCmd = &cobra.Command{ | ||
| 12 | Use: "status", | ||
| 13 | Short: "Show VPS health (uptime, disk, memory)", | ||
| 14 | RunE: runStatus, | ||
| 15 | } | ||
| 16 | |||
| 17 | func 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 @@ | |||
| 1 | package host | ||
| 2 | |||
| 3 | import ( | ||
| 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 | |||
| 14 | var 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 | |||
| 21 | func init() { | ||
| 22 | updateCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") | ||
| 23 | } | ||
| 24 | |||
| 25 | func 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/host_v2.go b/cmd/ship/host_v2.go new file mode 100644 index 0000000..b19c376 --- /dev/null +++ b/cmd/ship/host_v2.go | |||
| @@ -0,0 +1,445 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "encoding/json" | ||
| 5 | "fmt" | ||
| 6 | "os" | ||
| 7 | "os/exec" | ||
| 8 | "path/filepath" | ||
| 9 | "strings" | ||
| 10 | |||
| 11 | "github.com/bdw/ship/internal/output" | ||
| 12 | "github.com/bdw/ship/internal/ssh" | ||
| 13 | "github.com/bdw/ship/internal/state" | ||
| 14 | "github.com/bdw/ship/internal/templates" | ||
| 15 | "github.com/spf13/cobra" | ||
| 16 | ) | ||
| 17 | |||
| 18 | func initHostV2() { | ||
| 19 | hostInitV2Cmd.Flags().String("domain", "", "Base domain for deployments (required)") | ||
| 20 | hostInitV2Cmd.MarkFlagRequired("domain") | ||
| 21 | |||
| 22 | hostV2Cmd.AddCommand(hostInitV2Cmd) | ||
| 23 | hostV2Cmd.AddCommand(hostStatusV2Cmd) | ||
| 24 | } | ||
| 25 | |||
| 26 | var hostInitV2Cmd = &cobra.Command{ | ||
| 27 | Use: "init USER@HOST --domain DOMAIN", | ||
| 28 | Short: "Initialize a VPS for deployments", | ||
| 29 | Long: `Set up a fresh VPS with Caddy, Docker, and required directories. | ||
| 30 | |||
| 31 | Example: | ||
| 32 | ship host init user@my-vps --domain example.com`, | ||
| 33 | Args: cobra.ExactArgs(1), | ||
| 34 | RunE: runHostInitV2, | ||
| 35 | } | ||
| 36 | |||
| 37 | func runHostInitV2(cmd *cobra.Command, args []string) error { | ||
| 38 | host := args[0] | ||
| 39 | domain, _ := cmd.Flags().GetString("domain") | ||
| 40 | |||
| 41 | if domain == "" { | ||
| 42 | output.PrintAndExit(output.Err(output.ErrInvalidArgs, "--domain is required")) | ||
| 43 | } | ||
| 44 | |||
| 45 | // Ensure SSH key exists | ||
| 46 | keyPath, pubkey, err := ensureSSHKey() | ||
| 47 | if err != nil { | ||
| 48 | output.PrintAndExit(output.Err(output.ErrSSHAuthFailed, "failed to setup SSH key: "+err.Error())) | ||
| 49 | } | ||
| 50 | |||
| 51 | // Try to connect first (to verify key is authorized) | ||
| 52 | client, err := ssh.Connect(host) | ||
| 53 | if err != nil { | ||
| 54 | // Connection failed - provide helpful error with pubkey | ||
| 55 | resp := map[string]interface{}{ | ||
| 56 | "status": "error", | ||
| 57 | "code": "SSH_AUTH_FAILED", | ||
| 58 | "message": "SSH connection failed. Add this public key to your VPS authorized_keys:", | ||
| 59 | "public_key": pubkey, | ||
| 60 | "key_path": keyPath, | ||
| 61 | "host": host, | ||
| 62 | "setup_command": fmt.Sprintf("ssh-copy-id -i %s %s", keyPath, host), | ||
| 63 | } | ||
| 64 | printJSON(resp) | ||
| 65 | os.Exit(output.ExitSSHFailed) | ||
| 66 | } | ||
| 67 | defer client.Close() | ||
| 68 | |||
| 69 | // Detect OS | ||
| 70 | osRelease, err := client.Run("cat /etc/os-release") | ||
| 71 | if err != nil { | ||
| 72 | output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, "failed to detect OS: "+err.Error())) | ||
| 73 | } | ||
| 74 | |||
| 75 | if !strings.Contains(osRelease, "Ubuntu") && !strings.Contains(osRelease, "Debian") { | ||
| 76 | output.PrintAndExit(output.Err(output.ErrInvalidArgs, "unsupported OS (only Ubuntu and Debian are supported)")) | ||
| 77 | } | ||
| 78 | |||
| 79 | var installed []string | ||
| 80 | |||
| 81 | // Install Caddy if needed | ||
| 82 | if _, err := client.Run("which caddy"); err != nil { | ||
| 83 | if err := installCaddyV2(client); err != nil { | ||
| 84 | output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to install Caddy: "+err.Error())) | ||
| 85 | } | ||
| 86 | installed = append(installed, "caddy") | ||
| 87 | } | ||
| 88 | |||
| 89 | // Configure Caddy | ||
| 90 | caddyfile := `{ | ||
| 91 | } | ||
| 92 | |||
| 93 | import /etc/caddy/sites-enabled/* | ||
| 94 | ` | ||
| 95 | if err := client.WriteSudoFile("/etc/caddy/Caddyfile", caddyfile); err != nil { | ||
| 96 | output.PrintAndExit(output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error())) | ||
| 97 | } | ||
| 98 | |||
| 99 | // Create directories | ||
| 100 | dirs := []string{ | ||
| 101 | "/etc/ship/env", | ||
| 102 | "/etc/ship/ports", | ||
| 103 | "/etc/ship/ttl", | ||
| 104 | "/etc/caddy/sites-enabled", | ||
| 105 | "/var/www", | ||
| 106 | } | ||
| 107 | for _, dir := range dirs { | ||
| 108 | if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", dir)); err != nil { | ||
| 109 | output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to create directory: "+err.Error())) | ||
| 110 | } | ||
| 111 | } | ||
| 112 | |||
| 113 | // Install Docker | ||
| 114 | if _, err := client.Run("which docker"); err != nil { | ||
| 115 | if err := installDockerV2(client); err != nil { | ||
| 116 | output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to install Docker: "+err.Error())) | ||
| 117 | } | ||
| 118 | installed = append(installed, "docker") | ||
| 119 | } | ||
| 120 | |||
| 121 | // Install cleanup timer for TTL | ||
| 122 | if err := installCleanupTimer(client); err != nil { | ||
| 123 | // Non-fatal | ||
| 124 | } | ||
| 125 | |||
| 126 | // Enable and start services | ||
| 127 | if _, err := client.RunSudo("systemctl enable --now caddy docker"); err != nil { | ||
| 128 | output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to enable services: "+err.Error())) | ||
| 129 | } | ||
| 130 | |||
| 131 | // Save state | ||
| 132 | st, err := state.Load() | ||
| 133 | if err != nil { | ||
| 134 | output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to load state: "+err.Error())) | ||
| 135 | } | ||
| 136 | |||
| 137 | hostState := st.GetHost(host) | ||
| 138 | hostState.BaseDomain = domain | ||
| 139 | |||
| 140 | if st.GetDefaultHost() == "" { | ||
| 141 | st.SetDefaultHost(host) | ||
| 142 | } | ||
| 143 | |||
| 144 | if err := st.Save(); err != nil { | ||
| 145 | output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to save state: "+err.Error())) | ||
| 146 | } | ||
| 147 | |||
| 148 | // Success | ||
| 149 | output.PrintAndExit(&output.HostInitResponse{ | ||
| 150 | Status: "ok", | ||
| 151 | Host: host, | ||
| 152 | Domain: domain, | ||
| 153 | Installed: installed, | ||
| 154 | }) | ||
| 155 | |||
| 156 | return nil | ||
| 157 | } | ||
| 158 | |||
| 159 | func installCaddyV2(client *ssh.Client) error { | ||
| 160 | commands := []string{ | ||
| 161 | "apt-get update", | ||
| 162 | "apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl gnupg", | ||
| 163 | "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' -o /tmp/caddy.gpg", | ||
| 164 | "gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg < /tmp/caddy.gpg", | ||
| 165 | "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' -o /etc/apt/sources.list.d/caddy-stable.list", | ||
| 166 | "apt-get update", | ||
| 167 | "apt-get install -y caddy", | ||
| 168 | } | ||
| 169 | |||
| 170 | for _, cmd := range commands { | ||
| 171 | if _, err := client.RunSudo(cmd); err != nil { | ||
| 172 | return fmt.Errorf("command failed: %s: %w", cmd, err) | ||
| 173 | } | ||
| 174 | } | ||
| 175 | return nil | ||
| 176 | } | ||
| 177 | |||
| 178 | func installDockerV2(client *ssh.Client) error { | ||
| 179 | commands := []string{ | ||
| 180 | "apt-get install -y ca-certificates curl gnupg", | ||
| 181 | "install -m 0755 -d /etc/apt/keyrings", | ||
| 182 | "curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc", | ||
| 183 | "chmod a+r /etc/apt/keyrings/docker.asc", | ||
| 184 | `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'`, | ||
| 185 | "apt-get update", | ||
| 186 | "apt-get install -y docker-ce docker-ce-cli containerd.io", | ||
| 187 | } | ||
| 188 | |||
| 189 | for _, cmd := range commands { | ||
| 190 | if _, err := client.RunSudo(cmd); err != nil { | ||
| 191 | return fmt.Errorf("command failed: %s: %w", cmd, err) | ||
| 192 | } | ||
| 193 | } | ||
| 194 | return nil | ||
| 195 | } | ||
| 196 | |||
| 197 | func installCleanupTimer(client *ssh.Client) error { | ||
| 198 | // Cleanup script | ||
| 199 | script := `#!/bin/bash | ||
| 200 | now=$(date +%s) | ||
| 201 | for f in /etc/ship/ttl/*; do | ||
| 202 | [ -f "$f" ] || continue | ||
| 203 | name=$(basename "$f") | ||
| 204 | expires=$(cat "$f") | ||
| 205 | if [ "$now" -gt "$expires" ]; then | ||
| 206 | systemctl stop "$name" 2>/dev/null || true | ||
| 207 | systemctl disable "$name" 2>/dev/null || true | ||
| 208 | rm -f "/etc/systemd/system/${name}.service" | ||
| 209 | rm -f "/etc/caddy/sites-enabled/${name}.caddy" | ||
| 210 | rm -rf "/var/www/${name}" | ||
| 211 | rm -rf "/var/lib/${name}" | ||
| 212 | rm -f "/usr/local/bin/${name}" | ||
| 213 | rm -f "/etc/ship/env/${name}.env" | ||
| 214 | rm -f "/etc/ship/ports/${name}" | ||
| 215 | rm -f "/etc/ship/ttl/${name}" | ||
| 216 | docker rm -f "$name" 2>/dev/null || true | ||
| 217 | docker rmi "$name" 2>/dev/null || true | ||
| 218 | fi | ||
| 219 | done | ||
| 220 | systemctl daemon-reload | ||
| 221 | systemctl reload caddy | ||
| 222 | ` | ||
| 223 | if err := client.WriteSudoFile("/usr/local/bin/ship-cleanup", script); err != nil { | ||
| 224 | return err | ||
| 225 | } | ||
| 226 | if _, err := client.RunSudo("chmod +x /usr/local/bin/ship-cleanup"); err != nil { | ||
| 227 | return err | ||
| 228 | } | ||
| 229 | |||
| 230 | // Timer unit | ||
| 231 | timer := `[Unit] | ||
| 232 | Description=Ship TTL cleanup timer | ||
| 233 | |||
| 234 | [Timer] | ||
| 235 | OnCalendar=hourly | ||
| 236 | Persistent=true | ||
| 237 | |||
| 238 | [Install] | ||
| 239 | WantedBy=timers.target | ||
| 240 | ` | ||
| 241 | if err := client.WriteSudoFile("/etc/systemd/system/ship-cleanup.timer", timer); err != nil { | ||
| 242 | return err | ||
| 243 | } | ||
| 244 | |||
| 245 | // Service unit | ||
| 246 | service := `[Unit] | ||
| 247 | Description=Ship TTL cleanup | ||
| 248 | |||
| 249 | [Service] | ||
| 250 | Type=oneshot | ||
| 251 | ExecStart=/usr/local/bin/ship-cleanup | ||
| 252 | ` | ||
| 253 | if err := client.WriteSudoFile("/etc/systemd/system/ship-cleanup.service", service); err != nil { | ||
| 254 | return err | ||
| 255 | } | ||
| 256 | |||
| 257 | // Enable timer | ||
| 258 | if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { | ||
| 259 | return err | ||
| 260 | } | ||
| 261 | if _, err := client.RunSudo("systemctl enable --now ship-cleanup.timer"); err != nil { | ||
| 262 | return err | ||
| 263 | } | ||
| 264 | |||
| 265 | return nil | ||
| 266 | } | ||
| 267 | |||
| 268 | var hostStatusV2Cmd = &cobra.Command{ | ||
| 269 | Use: "status", | ||
| 270 | Short: "Check host status", | ||
| 271 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 272 | st, err := state.Load() | ||
| 273 | if err != nil { | ||
| 274 | output.PrintAndExit(output.Err(output.ErrHostNotConfigured, err.Error())) | ||
| 275 | } | ||
| 276 | |||
| 277 | hostName := hostFlag | ||
| 278 | if hostName == "" { | ||
| 279 | hostName = st.DefaultHost | ||
| 280 | } | ||
| 281 | if hostName == "" { | ||
| 282 | output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified")) | ||
| 283 | } | ||
| 284 | |||
| 285 | hostConfig := st.GetHost(hostName) | ||
| 286 | |||
| 287 | client, err := ssh.Connect(hostName) | ||
| 288 | if err != nil { | ||
| 289 | output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error())) | ||
| 290 | } | ||
| 291 | defer client.Close() | ||
| 292 | |||
| 293 | // Check services | ||
| 294 | caddyStatus, _ := client.RunSudo("systemctl is-active caddy") | ||
| 295 | dockerStatus, _ := client.RunSudo("systemctl is-active docker") | ||
| 296 | |||
| 297 | // Print as JSON directly (custom response type) | ||
| 298 | fmt.Printf(`{"status":"ok","host":%q,"domain":%q,"caddy":%t,"docker":%t}`+"\n", | ||
| 299 | hostName, | ||
| 300 | hostConfig.BaseDomain, | ||
| 301 | strings.TrimSpace(caddyStatus) == "active", | ||
| 302 | strings.TrimSpace(dockerStatus) == "active", | ||
| 303 | ) | ||
| 304 | return nil | ||
| 305 | }, | ||
| 306 | } | ||
| 307 | |||
| 308 | // Preserve git setup functionality from v1 for advanced users | ||
| 309 | func setupGitDeployV2(client *ssh.Client, baseDomain string, hostState *state.Host) *output.ErrorResponse { | ||
| 310 | // Install git, fcgiwrap, cgit | ||
| 311 | if _, err := client.RunSudo("apt-get install -y git fcgiwrap cgit"); err != nil { | ||
| 312 | return output.Err(output.ErrServiceFailed, "failed to install git tools: "+err.Error()) | ||
| 313 | } | ||
| 314 | |||
| 315 | // Create git user | ||
| 316 | client.RunSudo("useradd -r -m -d /home/git -s $(which git-shell) git") | ||
| 317 | client.RunSudo("usermod -aG docker git") | ||
| 318 | client.RunSudo("usermod -aG git www-data") | ||
| 319 | client.RunSudo("usermod -aG www-data caddy") | ||
| 320 | |||
| 321 | // Copy SSH keys | ||
| 322 | copyKeysCommands := []string{ | ||
| 323 | "mkdir -p /home/git/.ssh", | ||
| 324 | "cp ~/.ssh/authorized_keys /home/git/.ssh/authorized_keys", | ||
| 325 | "chown -R git:git /home/git/.ssh", | ||
| 326 | "chmod 700 /home/git/.ssh", | ||
| 327 | "chmod 600 /home/git/.ssh/authorized_keys", | ||
| 328 | } | ||
| 329 | for _, cmd := range copyKeysCommands { | ||
| 330 | if _, err := client.RunSudo(cmd); err != nil { | ||
| 331 | return output.Err(output.ErrServiceFailed, "failed to setup git SSH: "+err.Error()) | ||
| 332 | } | ||
| 333 | } | ||
| 334 | |||
| 335 | // Create /srv/git | ||
| 336 | client.RunSudo("mkdir -p /srv/git") | ||
| 337 | client.RunSudo("chown git:git /srv/git") | ||
| 338 | |||
| 339 | // Sudoers | ||
| 340 | sudoersContent := `git ALL=(ALL) NOPASSWD: \ | ||
| 341 | /bin/systemctl daemon-reload, \ | ||
| 342 | /bin/systemctl reload caddy, \ | ||
| 343 | /bin/systemctl restart [a-z]*, \ | ||
| 344 | /bin/systemctl enable [a-z]*, \ | ||
| 345 | /bin/cp /var/lib/*/src/.ship/service /etc/systemd/system/*.service, \ | ||
| 346 | /bin/cp /var/lib/*/src/.ship/Caddyfile /etc/caddy/sites-enabled/*.caddy, \ | ||
| 347 | /bin/cp /var/www/*/.ship/Caddyfile /etc/caddy/sites-enabled/*.caddy, \ | ||
| 348 | /bin/mkdir -p /var/lib/*, \ | ||
| 349 | /bin/mkdir -p /var/www/*, \ | ||
| 350 | /bin/chown -R git\:git /var/lib/*, \ | ||
| 351 | /bin/chown git\:git /var/www/* | ||
| 352 | ` | ||
| 353 | if err := client.WriteSudoFile("/etc/sudoers.d/ship-git", sudoersContent); err != nil { | ||
| 354 | return output.Err(output.ErrServiceFailed, "failed to write sudoers: "+err.Error()) | ||
| 355 | } | ||
| 356 | client.RunSudo("chmod 440 /etc/sudoers.d/ship-git") | ||
| 357 | |||
| 358 | // Vanity import template | ||
| 359 | vanityHTML := `<!DOCTYPE html> | ||
| 360 | <html><head> | ||
| 361 | {{$path := trimPrefix "/" (placeholder "http.request.orig_uri.path")}} | ||
| 362 | {{$parts := splitList "/" $path}} | ||
| 363 | {{$module := first $parts}} | ||
| 364 | <meta name="go-import" content="{{.Host}}/{{$module}} git https://{{.Host}}/{{$module}}.git"> | ||
| 365 | </head> | ||
| 366 | <body>go get {{.Host}}/{{$module}}</body> | ||
| 367 | </html> | ||
| 368 | ` | ||
| 369 | client.RunSudo("mkdir -p /opt/ship/vanity") | ||
| 370 | client.WriteSudoFile("/opt/ship/vanity/index.html", vanityHTML) | ||
| 371 | |||
| 372 | // cgit config | ||
| 373 | codeCaddyContent, _ := templates.CodeCaddy(map[string]string{"BaseDomain": baseDomain}) | ||
| 374 | client.WriteSudoFile("/etc/caddy/sites-enabled/ship-code.caddy", codeCaddyContent) | ||
| 375 | |||
| 376 | cgitrcContent, _ := templates.CgitRC(map[string]string{"BaseDomain": baseDomain}) | ||
| 377 | client.WriteSudoFile("/etc/cgitrc", cgitrcContent) | ||
| 378 | client.WriteSudoFile("/opt/ship/cgit-header.html", templates.CgitHeader()) | ||
| 379 | |||
| 380 | // Start services | ||
| 381 | client.RunSudo("systemctl enable --now fcgiwrap") | ||
| 382 | client.RunSudo("systemctl restart caddy") | ||
| 383 | |||
| 384 | hostState.GitSetup = true | ||
| 385 | return nil | ||
| 386 | } | ||
| 387 | |||
| 388 | // ensureSSHKey checks for an existing SSH key or generates a new one. | ||
| 389 | // Returns the key path, public key contents, and any error. | ||
| 390 | func ensureSSHKey() (keyPath string, pubkey string, err error) { | ||
| 391 | home, err := os.UserHomeDir() | ||
| 392 | if err != nil { | ||
| 393 | return "", "", err | ||
| 394 | } | ||
| 395 | |||
| 396 | // Check common key locations | ||
| 397 | keyPaths := []string{ | ||
| 398 | filepath.Join(home, ".ssh", "id_ed25519"), | ||
| 399 | filepath.Join(home, ".ssh", "id_rsa"), | ||
| 400 | filepath.Join(home, ".ssh", "id_ecdsa"), | ||
| 401 | } | ||
| 402 | |||
| 403 | for _, kp := range keyPaths { | ||
| 404 | pubPath := kp + ".pub" | ||
| 405 | if _, err := os.Stat(kp); err == nil { | ||
| 406 | if _, err := os.Stat(pubPath); err == nil { | ||
| 407 | // Key exists, read public key | ||
| 408 | pub, err := os.ReadFile(pubPath) | ||
| 409 | if err != nil { | ||
| 410 | continue | ||
| 411 | } | ||
| 412 | return kp, strings.TrimSpace(string(pub)), nil | ||
| 413 | } | ||
| 414 | } | ||
| 415 | } | ||
| 416 | |||
| 417 | // No key found, generate one | ||
| 418 | keyPath = filepath.Join(home, ".ssh", "id_ed25519") | ||
| 419 | sshDir := filepath.Dir(keyPath) | ||
| 420 | |||
| 421 | // Ensure .ssh directory exists | ||
| 422 | if err := os.MkdirAll(sshDir, 0700); err != nil { | ||
| 423 | return "", "", fmt.Errorf("failed to create .ssh directory: %w", err) | ||
| 424 | } | ||
| 425 | |||
| 426 | // Generate key | ||
| 427 | cmd := exec.Command("ssh-keygen", "-t", "ed25519", "-f", keyPath, "-N", "", "-C", "ship") | ||
| 428 | if err := cmd.Run(); err != nil { | ||
| 429 | return "", "", fmt.Errorf("failed to generate SSH key: %w", err) | ||
| 430 | } | ||
| 431 | |||
| 432 | // Read public key | ||
| 433 | pub, err := os.ReadFile(keyPath + ".pub") | ||
| 434 | if err != nil { | ||
| 435 | return "", "", fmt.Errorf("failed to read public key: %w", err) | ||
| 436 | } | ||
| 437 | |||
| 438 | return keyPath, strings.TrimSpace(string(pub)), nil | ||
| 439 | } | ||
| 440 | |||
| 441 | // printJSON outputs a value as JSON to stdout | ||
| 442 | func printJSON(v interface{}) { | ||
| 443 | enc := json.NewEncoder(os.Stdout) | ||
| 444 | enc.Encode(v) | ||
| 445 | } | ||
diff --git a/cmd/ship/init.go b/cmd/ship/init.go deleted file mode 100644 index b495702..0000000 --- a/cmd/ship/init.go +++ /dev/null | |||
| @@ -1,268 +0,0 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "os" | ||
| 6 | "os/exec" | ||
| 7 | "path/filepath" | ||
| 8 | "strconv" | ||
| 9 | |||
| 10 | "github.com/bdw/ship/internal/ssh" | ||
| 11 | "github.com/bdw/ship/internal/state" | ||
| 12 | "github.com/bdw/ship/internal/templates" | ||
| 13 | "github.com/spf13/cobra" | ||
| 14 | ) | ||
| 15 | |||
| 16 | var initCmd = &cobra.Command{ | ||
| 17 | Use: "init <name>", | ||
| 18 | Short: "Initialize a git-deployed project", | ||
| 19 | Long: `Create a bare git repo on the VPS and generate local .ship/ config files. | ||
| 20 | |||
| 21 | Pushing to the remote triggers an automatic docker build and deploy (for apps) | ||
| 22 | or a static file checkout (for static sites). If no Dockerfile is present in an | ||
| 23 | app repo, pushes are accepted without triggering a deploy. | ||
| 24 | |||
| 25 | Examples: | ||
| 26 | # Initialize an app (Docker-based) | ||
| 27 | ship init myapp | ||
| 28 | |||
| 29 | # Initialize with a custom domain | ||
| 30 | ship init myapp --domain custom.example.com | ||
| 31 | |||
| 32 | # Initialize a static site | ||
| 33 | ship init mysite --static | ||
| 34 | |||
| 35 | # Initialize a public repo (cloneable via go get / git clone over HTTPS) | ||
| 36 | ship init mylib --public`, | ||
| 37 | Args: cobra.ExactArgs(1), | ||
| 38 | RunE: runInit, | ||
| 39 | } | ||
| 40 | |||
| 41 | func init() { | ||
| 42 | initCmd.Flags().Bool("static", false, "Initialize as static site") | ||
| 43 | initCmd.Flags().Bool("public", false, "Make repo publicly cloneable over HTTPS (for go get)") | ||
| 44 | initCmd.Flags().String("domain", "", "Custom domain (default: name.basedomain)") | ||
| 45 | } | ||
| 46 | |||
| 47 | func runInit(cmd *cobra.Command, args []string) error { | ||
| 48 | name := args[0] | ||
| 49 | if err := validateName(name); err != nil { | ||
| 50 | return err | ||
| 51 | } | ||
| 52 | static, _ := cmd.Flags().GetBool("static") | ||
| 53 | public, _ := cmd.Flags().GetBool("public") | ||
| 54 | domain, _ := cmd.Flags().GetString("domain") | ||
| 55 | |||
| 56 | st, err := state.Load() | ||
| 57 | if err != nil { | ||
| 58 | return fmt.Errorf("error loading state: %w", err) | ||
| 59 | } | ||
| 60 | |||
| 61 | host := hostFlag | ||
| 62 | if host == "" { | ||
| 63 | host = st.GetDefaultHost() | ||
| 64 | } | ||
| 65 | if host == "" { | ||
| 66 | return fmt.Errorf("--host is required") | ||
| 67 | } | ||
| 68 | |||
| 69 | hostState := st.GetHost(host) | ||
| 70 | if !hostState.GitSetup { | ||
| 71 | return fmt.Errorf("git deployment not set up on %s (run 'ship host init --base-domain example.com' first)", host) | ||
| 72 | } | ||
| 73 | |||
| 74 | // Check if app already exists | ||
| 75 | if _, err := st.GetApp(host, name); err == nil { | ||
| 76 | return fmt.Errorf("app %s already exists", name) | ||
| 77 | } | ||
| 78 | |||
| 79 | appType := "git-app" | ||
| 80 | if static { | ||
| 81 | appType = "git-static" | ||
| 82 | } | ||
| 83 | |||
| 84 | // Resolve domain | ||
| 85 | if domain == "" && hostState.BaseDomain != "" { | ||
| 86 | domain = name + "." + hostState.BaseDomain | ||
| 87 | } | ||
| 88 | if domain == "" { | ||
| 89 | return fmt.Errorf("--domain required (or configure base domain)") | ||
| 90 | } | ||
| 91 | |||
| 92 | // Allocate port for apps only | ||
| 93 | port := 0 | ||
| 94 | if !static { | ||
| 95 | port = st.AllocatePort(host) | ||
| 96 | } | ||
| 97 | |||
| 98 | fmt.Printf("Initializing %s: %s\n", appType, name) | ||
| 99 | |||
| 100 | client, err := ssh.Connect(host) | ||
| 101 | if err != nil { | ||
| 102 | return fmt.Errorf("error connecting to VPS: %w", err) | ||
| 103 | } | ||
| 104 | defer client.Close() | ||
| 105 | |||
| 106 | // Create bare repo | ||
| 107 | fmt.Println("-> Creating bare git repo...") | ||
| 108 | repo := fmt.Sprintf("/srv/git/%s.git", name) | ||
| 109 | if _, err := client.RunSudo(fmt.Sprintf("sudo -u git git init --bare -b main %s", repo)); err != nil { | ||
| 110 | return fmt.Errorf("error creating bare repo: %w", err) | ||
| 111 | } | ||
| 112 | |||
| 113 | if public { | ||
| 114 | if _, err := client.RunSudo(fmt.Sprintf("sudo -u git touch %s/git-daemon-export-ok", repo)); err != nil { | ||
| 115 | return fmt.Errorf("error setting repo public: %w", err) | ||
| 116 | } | ||
| 117 | } | ||
| 118 | |||
| 119 | if static { | ||
| 120 | // Create web root | ||
| 121 | fmt.Println("-> Creating web root...") | ||
| 122 | if _, err := client.RunSudo(fmt.Sprintf("mkdir -p /var/www/%s", name)); err != nil { | ||
| 123 | return fmt.Errorf("error creating web root: %w", err) | ||
| 124 | } | ||
| 125 | if _, err := client.RunSudo(fmt.Sprintf("chown git:git /var/www/%s", name)); err != nil { | ||
| 126 | return fmt.Errorf("error setting web root ownership: %w", err) | ||
| 127 | } | ||
| 128 | |||
| 129 | // Write post-receive hook | ||
| 130 | fmt.Println("-> Writing post-receive hook...") | ||
| 131 | hookContent, err := templates.PostReceiveHookStatic(map[string]string{ | ||
| 132 | "Name": name, | ||
| 133 | }) | ||
| 134 | if err != nil { | ||
| 135 | return fmt.Errorf("error generating hook: %w", err) | ||
| 136 | } | ||
| 137 | if err := writeHook(client, repo, hookContent); err != nil { | ||
| 138 | return err | ||
| 139 | } | ||
| 140 | } else { | ||
| 141 | // Create env file | ||
| 142 | fmt.Println("-> Creating environment file...") | ||
| 143 | envContent := fmt.Sprintf("PORT=%d\nDATA_DIR=/data\n", port) | ||
| 144 | envPath := fmt.Sprintf("/etc/ship/env/%s.env", name) | ||
| 145 | if err := client.WriteSudoFile(envPath, envContent); err != nil { | ||
| 146 | return fmt.Errorf("error creating env file: %w", err) | ||
| 147 | } | ||
| 148 | |||
| 149 | // Write post-receive hook (handles dir creation on first push) | ||
| 150 | fmt.Println("-> Writing post-receive hook...") | ||
| 151 | hookContent, err := templates.PostReceiveHook(map[string]string{ | ||
| 152 | "Name": name, | ||
| 153 | }) | ||
| 154 | if err != nil { | ||
| 155 | return fmt.Errorf("error generating hook: %w", err) | ||
| 156 | } | ||
| 157 | if err := writeHook(client, repo, hookContent); err != nil { | ||
| 158 | return err | ||
| 159 | } | ||
| 160 | } | ||
| 161 | |||
| 162 | // Save state | ||
| 163 | st.AddApp(host, name, &state.App{ | ||
| 164 | Type: appType, | ||
| 165 | Domain: domain, | ||
| 166 | Port: port, | ||
| 167 | Repo: repo, | ||
| 168 | Public: public, | ||
| 169 | }) | ||
| 170 | if err := st.Save(); err != nil { | ||
| 171 | return fmt.Errorf("error saving state: %w", err) | ||
| 172 | } | ||
| 173 | |||
| 174 | // Generate local .ship/ files | ||
| 175 | fmt.Println("-> Generating local .ship/ config...") | ||
| 176 | if err := os.MkdirAll(".ship", 0755); err != nil { | ||
| 177 | return fmt.Errorf("error creating .ship directory: %w", err) | ||
| 178 | } | ||
| 179 | |||
| 180 | if static { | ||
| 181 | caddyContent, err := templates.DefaultStaticCaddy(map[string]string{ | ||
| 182 | "Domain": domain, | ||
| 183 | "Name": name, | ||
| 184 | }) | ||
| 185 | if err != nil { | ||
| 186 | return fmt.Errorf("error generating Caddyfile: %w", err) | ||
| 187 | } | ||
| 188 | if err := os.WriteFile(filepath.Join(".ship", "Caddyfile"), []byte(caddyContent), 0644); err != nil { | ||
| 189 | return fmt.Errorf("error writing Caddyfile: %w", err) | ||
| 190 | } | ||
| 191 | } else { | ||
| 192 | caddyContent, err := templates.DefaultAppCaddy(map[string]string{ | ||
| 193 | "Domain": domain, | ||
| 194 | "Port": strconv.Itoa(port), | ||
| 195 | }) | ||
| 196 | if err != nil { | ||
| 197 | return fmt.Errorf("error generating Caddyfile: %w", err) | ||
| 198 | } | ||
| 199 | if err := os.WriteFile(filepath.Join(".ship", "Caddyfile"), []byte(caddyContent), 0644); err != nil { | ||
| 200 | return fmt.Errorf("error writing Caddyfile: %w", err) | ||
| 201 | } | ||
| 202 | |||
| 203 | serviceContent, err := templates.DockerService(map[string]string{ | ||
| 204 | "Name": name, | ||
| 205 | "Port": strconv.Itoa(port), | ||
| 206 | }) | ||
| 207 | if err != nil { | ||
| 208 | return fmt.Errorf("error generating service file: %w", err) | ||
| 209 | } | ||
| 210 | if err := os.WriteFile(filepath.Join(".ship", "service"), []byte(serviceContent), 0644); err != nil { | ||
| 211 | return fmt.Errorf("error writing service file: %w", err) | ||
| 212 | } | ||
| 213 | } | ||
| 214 | |||
| 215 | // Initialize local git repo if needed | ||
| 216 | if _, err := os.Stat(".git"); os.IsNotExist(err) { | ||
| 217 | fmt.Println("-> Initializing git repo...") | ||
| 218 | gitInit := exec.Command("git", "init") | ||
| 219 | gitInit.Stdout = os.Stdout | ||
| 220 | gitInit.Stderr = os.Stderr | ||
| 221 | if err := gitInit.Run(); err != nil { | ||
| 222 | return fmt.Errorf("error initializing git repo: %w", err) | ||
| 223 | } | ||
| 224 | } | ||
| 225 | |||
| 226 | // Add origin remote (replace if it already exists) | ||
| 227 | sshHost := host | ||
| 228 | remoteURL := fmt.Sprintf("git@%s:%s", sshHost, repo) | ||
| 229 | exec.Command("git", "remote", "remove", "origin").Run() // ignore error if not exists | ||
| 230 | addRemote := exec.Command("git", "remote", "add", "origin", remoteURL) | ||
| 231 | if err := addRemote.Run(); err != nil { | ||
| 232 | return fmt.Errorf("error adding git remote: %w", err) | ||
| 233 | } | ||
| 234 | |||
| 235 | fmt.Printf("\nProject initialized: %s\n", name) | ||
| 236 | fmt.Println("\nGenerated:") | ||
| 237 | fmt.Println(" .ship/Caddyfile — Caddy config (edit to customize routing)") | ||
| 238 | if !static { | ||
| 239 | fmt.Println(" .ship/service — systemd unit (edit to customize resources, ports)") | ||
| 240 | } | ||
| 241 | fmt.Println("\nNext steps:") | ||
| 242 | if static { | ||
| 243 | fmt.Println(" git add .ship/") | ||
| 244 | } else { | ||
| 245 | fmt.Println(" git add .ship/ Dockerfile") | ||
| 246 | } | ||
| 247 | fmt.Println(" git commit -m \"initial deploy\"") | ||
| 248 | fmt.Println(" git push origin main") | ||
| 249 | if !static { | ||
| 250 | fmt.Println("\n (No Dockerfile? Just push — deploy is skipped until one is added.)") | ||
| 251 | } | ||
| 252 | |||
| 253 | return nil | ||
| 254 | } | ||
| 255 | |||
| 256 | func writeHook(client *ssh.Client, repo, content string) error { | ||
| 257 | hookPath := fmt.Sprintf("%s/hooks/post-receive", repo) | ||
| 258 | if err := client.WriteSudoFile(hookPath, content); err != nil { | ||
| 259 | return fmt.Errorf("error writing hook: %w", err) | ||
| 260 | } | ||
| 261 | if _, err := client.RunSudo(fmt.Sprintf("chmod +x %s", hookPath)); err != nil { | ||
| 262 | return fmt.Errorf("error making hook executable: %w", err) | ||
| 263 | } | ||
| 264 | if _, err := client.RunSudo(fmt.Sprintf("chown git:git %s", hookPath)); err != nil { | ||
| 265 | return fmt.Errorf("error setting hook ownership: %w", err) | ||
| 266 | } | ||
| 267 | return nil | ||
| 268 | } | ||
diff --git a/cmd/ship/list.go b/cmd/ship/list.go deleted file mode 100644 index af5baf8..0000000 --- a/cmd/ship/list.go +++ /dev/null | |||
| @@ -1,61 +0,0 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "os" | ||
| 6 | "text/tabwriter" | ||
| 7 | |||
| 8 | "github.com/bdw/ship/internal/state" | ||
| 9 | "github.com/spf13/cobra" | ||
| 10 | ) | ||
| 11 | |||
| 12 | var listCmd = &cobra.Command{ | ||
| 13 | Use: "list", | ||
| 14 | Short: "List all deployed apps and sites", | ||
| 15 | RunE: runList, | ||
| 16 | } | ||
| 17 | |||
| 18 | func 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\tVISIBILITY\tDOMAIN\tPORT") | ||
| 41 | for name, app := range apps { | ||
| 42 | port := "" | ||
| 43 | if app.Type == "app" || app.Type == "git-app" { | ||
| 44 | port = fmt.Sprintf(":%d", app.Port) | ||
| 45 | } | ||
| 46 | domain := app.Domain | ||
| 47 | if domain == "" { | ||
| 48 | domain = "-" | ||
| 49 | } | ||
| 50 | visibility := "" | ||
| 51 | if app.Repo != "" { | ||
| 52 | visibility = "private" | ||
| 53 | if app.Public { | ||
| 54 | visibility = "public" | ||
| 55 | } | ||
| 56 | } | ||
| 57 | fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", name, app.Type, visibility, domain, port) | ||
| 58 | } | ||
| 59 | w.Flush() | ||
| 60 | return nil | ||
| 61 | } | ||
diff --git a/cmd/ship/logs.go b/cmd/ship/logs.go deleted file mode 100644 index 4c58a9c..0000000 --- a/cmd/ship/logs.go +++ /dev/null | |||
| @@ -1,78 +0,0 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | |||
| 6 | "github.com/bdw/ship/internal/ssh" | ||
| 7 | "github.com/bdw/ship/internal/state" | ||
| 8 | "github.com/spf13/cobra" | ||
| 9 | ) | ||
| 10 | |||
| 11 | var logsCmd = &cobra.Command{ | ||
| 12 | Use: "logs <app>", | ||
| 13 | Short: "View logs for a deployment", | ||
| 14 | Args: cobra.ExactArgs(1), | ||
| 15 | RunE: runLogs, | ||
| 16 | } | ||
| 17 | |||
| 18 | func init() { | ||
| 19 | logsCmd.Flags().BoolP("follow", "f", false, "Follow logs") | ||
| 20 | logsCmd.Flags().IntP("lines", "n", 50, "Number of lines to show") | ||
| 21 | } | ||
| 22 | |||
| 23 | func runLogs(cmd *cobra.Command, args []string) error { | ||
| 24 | name := args[0] | ||
| 25 | if err := validateName(name); err != nil { | ||
| 26 | return err | ||
| 27 | } | ||
| 28 | follow, _ := cmd.Flags().GetBool("follow") | ||
| 29 | lines, _ := cmd.Flags().GetInt("lines") | ||
| 30 | |||
| 31 | st, err := state.Load() | ||
| 32 | if err != nil { | ||
| 33 | return fmt.Errorf("error loading state: %w", err) | ||
| 34 | } | ||
| 35 | |||
| 36 | host := hostFlag | ||
| 37 | if host == "" { | ||
| 38 | host = st.GetDefaultHost() | ||
| 39 | } | ||
| 40 | |||
| 41 | if host == "" { | ||
| 42 | return fmt.Errorf("--host is required") | ||
| 43 | } | ||
| 44 | |||
| 45 | app, err := st.GetApp(host, name) | ||
| 46 | if err != nil { | ||
| 47 | return err | ||
| 48 | } | ||
| 49 | |||
| 50 | if app.Type != "app" && app.Type != "git-app" { | ||
| 51 | return fmt.Errorf("logs are only available for apps, not static sites") | ||
| 52 | } | ||
| 53 | |||
| 54 | client, err := ssh.Connect(host) | ||
| 55 | if err != nil { | ||
| 56 | return fmt.Errorf("error connecting to VPS: %w", err) | ||
| 57 | } | ||
| 58 | defer client.Close() | ||
| 59 | |||
| 60 | journalCmd := fmt.Sprintf("journalctl -u %s -n %d", name, lines) | ||
| 61 | if follow { | ||
| 62 | journalCmd += " -f" | ||
| 63 | } | ||
| 64 | |||
| 65 | if follow { | ||
| 66 | if err := client.RunStream(journalCmd); err != nil { | ||
| 67 | return fmt.Errorf("error fetching logs: %w", err) | ||
| 68 | } | ||
| 69 | } else { | ||
| 70 | output, err := client.Run(journalCmd) | ||
| 71 | if err != nil { | ||
| 72 | return fmt.Errorf("error fetching logs: %w", err) | ||
| 73 | } | ||
| 74 | fmt.Print(output) | ||
| 75 | } | ||
| 76 | |||
| 77 | return nil | ||
| 78 | } | ||
diff --git a/cmd/ship/main.go b/cmd/ship/main.go index f7d95c1..17516fb 100644 --- a/cmd/ship/main.go +++ b/cmd/ship/main.go | |||
| @@ -1,13 +1,10 @@ | |||
| 1 | package main | 1 | package main |
| 2 | 2 | ||
| 3 | import ( | 3 | import "os" |
| 4 | "fmt" | ||
| 5 | "os" | ||
| 6 | ) | ||
| 7 | 4 | ||
| 8 | func main() { | 5 | func main() { |
| 9 | if err := rootCmd.Execute(); err != nil { | 6 | initV2() |
| 10 | fmt.Fprintf(os.Stderr, "Error: %v\n", err) | 7 | if err := rootV2Cmd.Execute(); err != nil { |
| 11 | os.Exit(1) | 8 | os.Exit(1) |
| 12 | } | 9 | } |
| 13 | } | 10 | } |
diff --git a/cmd/ship/remove.go b/cmd/ship/remove.go deleted file mode 100644 index b55d0c8..0000000 --- a/cmd/ship/remove.go +++ /dev/null | |||
| @@ -1,109 +0,0 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | |||
| 6 | "github.com/bdw/ship/internal/ssh" | ||
| 7 | "github.com/bdw/ship/internal/state" | ||
| 8 | "github.com/spf13/cobra" | ||
| 9 | ) | ||
| 10 | |||
| 11 | var 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 | |||
| 19 | func runRemove(cmd *cobra.Command, args []string) error { | ||
| 20 | name := args[0] | ||
| 21 | if err := validateName(name); err != nil { | ||
| 22 | return err | ||
| 23 | } | ||
| 24 | |||
| 25 | st, err := state.Load() | ||
| 26 | if err != nil { | ||
| 27 | return fmt.Errorf("error loading state: %w", err) | ||
| 28 | } | ||
| 29 | |||
| 30 | host := hostFlag | ||
| 31 | if host == "" { | ||
| 32 | host = st.GetDefaultHost() | ||
| 33 | } | ||
| 34 | |||
| 35 | if host == "" { | ||
| 36 | return fmt.Errorf("--host is required") | ||
| 37 | } | ||
| 38 | |||
| 39 | app, err := st.GetApp(host, name) | ||
| 40 | if err != nil { | ||
| 41 | return err | ||
| 42 | } | ||
| 43 | |||
| 44 | fmt.Printf("Removing deployment: %s\n", name) | ||
| 45 | |||
| 46 | client, err := ssh.Connect(host) | ||
| 47 | if err != nil { | ||
| 48 | return fmt.Errorf("error connecting to VPS: %w", err) | ||
| 49 | } | ||
| 50 | defer client.Close() | ||
| 51 | |||
| 52 | switch app.Type { | ||
| 53 | case "app": | ||
| 54 | fmt.Println("-> Stopping service...") | ||
| 55 | client.RunSudo(fmt.Sprintf("systemctl stop %s", name)) | ||
| 56 | client.RunSudo(fmt.Sprintf("systemctl disable %s", name)) | ||
| 57 | |||
| 58 | client.RunSudo(fmt.Sprintf("rm -f /etc/systemd/system/%s.service", name)) | ||
| 59 | client.RunSudo("systemctl daemon-reload") | ||
| 60 | |||
| 61 | client.RunSudo(fmt.Sprintf("rm -f /usr/local/bin/%s", name)) | ||
| 62 | client.RunSudo(fmt.Sprintf("rm -rf /var/lib/%s", name)) | ||
| 63 | client.RunSudo(fmt.Sprintf("rm -f /etc/ship/env/%s.env", name)) | ||
| 64 | client.RunSudo(fmt.Sprintf("userdel %s", name)) | ||
| 65 | |||
| 66 | case "git-app": | ||
| 67 | fmt.Println("-> Stopping service...") | ||
| 68 | client.RunSudo(fmt.Sprintf("systemctl stop %s", name)) | ||
| 69 | client.RunSudo(fmt.Sprintf("systemctl disable %s", name)) | ||
| 70 | |||
| 71 | client.RunSudo(fmt.Sprintf("rm -f /etc/systemd/system/%s.service", name)) | ||
| 72 | client.RunSudo("systemctl daemon-reload") | ||
| 73 | |||
| 74 | fmt.Println("-> Removing Docker image...") | ||
| 75 | client.RunSudo(fmt.Sprintf("docker rmi %s:latest", name)) | ||
| 76 | |||
| 77 | fmt.Println("-> Removing files...") | ||
| 78 | client.RunSudo(fmt.Sprintf("rm -rf /var/lib/%s", name)) | ||
| 79 | client.RunSudo(fmt.Sprintf("rm -rf /srv/git/%s.git", name)) | ||
| 80 | client.RunSudo(fmt.Sprintf("rm -f /etc/ship/env/%s.env", name)) | ||
| 81 | |||
| 82 | case "git-static": | ||
| 83 | fmt.Println("-> Removing files...") | ||
| 84 | client.RunSudo(fmt.Sprintf("rm -rf /var/www/%s", name)) | ||
| 85 | client.RunSudo(fmt.Sprintf("rm -rf /srv/git/%s.git", name)) | ||
| 86 | |||
| 87 | default: // "static" | ||
| 88 | fmt.Println("-> Removing files...") | ||
| 89 | client.RunSudo(fmt.Sprintf("rm -rf /var/www/%s", name)) | ||
| 90 | } | ||
| 91 | |||
| 92 | fmt.Println("-> Removing Caddy config...") | ||
| 93 | client.RunSudo(fmt.Sprintf("rm -f /etc/caddy/sites-enabled/%s.caddy", name)) | ||
| 94 | |||
| 95 | fmt.Println("-> Reloading Caddy...") | ||
| 96 | if _, err := client.RunSudo("systemctl reload caddy"); err != nil { | ||
| 97 | fmt.Printf("Warning: Error reloading Caddy: %v\n", err) | ||
| 98 | } | ||
| 99 | |||
| 100 | if err := st.RemoveApp(host, name); err != nil { | ||
| 101 | return fmt.Errorf("error updating state: %w", err) | ||
| 102 | } | ||
| 103 | if err := st.Save(); err != nil { | ||
| 104 | return fmt.Errorf("error saving state: %w", err) | ||
| 105 | } | ||
| 106 | |||
| 107 | fmt.Println("Deployment removed successfully") | ||
| 108 | return nil | ||
| 109 | } | ||
diff --git a/cmd/ship/restart.go b/cmd/ship/restart.go deleted file mode 100644 index c902adb..0000000 --- a/cmd/ship/restart.go +++ /dev/null | |||
| @@ -1,60 +0,0 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | |||
| 6 | "github.com/bdw/ship/internal/ssh" | ||
| 7 | "github.com/bdw/ship/internal/state" | ||
| 8 | "github.com/spf13/cobra" | ||
| 9 | ) | ||
| 10 | |||
| 11 | var restartCmd = &cobra.Command{ | ||
| 12 | Use: "restart <app>", | ||
| 13 | Short: "Restart a deployment", | ||
| 14 | Args: cobra.ExactArgs(1), | ||
| 15 | RunE: runRestart, | ||
| 16 | } | ||
| 17 | |||
| 18 | func runRestart(cmd *cobra.Command, args []string) error { | ||
| 19 | name := args[0] | ||
| 20 | if err := validateName(name); err != nil { | ||
| 21 | return err | ||
| 22 | } | ||
| 23 | |||
| 24 | st, err := state.Load() | ||
| 25 | if err != nil { | ||
| 26 | return fmt.Errorf("error loading state: %w", err) | ||
| 27 | } | ||
| 28 | |||
| 29 | host := hostFlag | ||
| 30 | if host == "" { | ||
| 31 | host = st.GetDefaultHost() | ||
| 32 | } | ||
| 33 | |||
| 34 | if host == "" { | ||
| 35 | return fmt.Errorf("--host is required") | ||
| 36 | } | ||
| 37 | |||
| 38 | app, err := st.GetApp(host, name) | ||
| 39 | if err != nil { | ||
| 40 | return err | ||
| 41 | } | ||
| 42 | |||
| 43 | if app.Type != "app" && app.Type != "git-app" { | ||
| 44 | return fmt.Errorf("restart is only available for apps, not static sites") | ||
| 45 | } | ||
| 46 | |||
| 47 | client, err := ssh.Connect(host) | ||
| 48 | if err != nil { | ||
| 49 | return fmt.Errorf("error connecting to VPS: %w", err) | ||
| 50 | } | ||
| 51 | defer client.Close() | ||
| 52 | |||
| 53 | fmt.Printf("Restarting %s...\n", name) | ||
| 54 | if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { | ||
| 55 | return fmt.Errorf("error restarting service: %w", err) | ||
| 56 | } | ||
| 57 | |||
| 58 | fmt.Println("Service restarted successfully") | ||
| 59 | return nil | ||
| 60 | } | ||
diff --git a/cmd/ship/root.go b/cmd/ship/root.go deleted file mode 100644 index 93280f5..0000000 --- a/cmd/ship/root.go +++ /dev/null | |||
| @@ -1,97 +0,0 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "github.com/bdw/ship/cmd/ship/env" | ||
| 5 | "github.com/bdw/ship/cmd/ship/host" | ||
| 6 | "github.com/spf13/cobra" | ||
| 7 | ) | ||
| 8 | |||
| 9 | var ( | ||
| 10 | // Persistent flags | ||
| 11 | hostFlag string | ||
| 12 | |||
| 13 | // Version info (set via ldflags) | ||
| 14 | version = "dev" | ||
| 15 | commit = "none" | ||
| 16 | date = "unknown" | ||
| 17 | ) | ||
| 18 | |||
| 19 | const banner = ` | ||
| 20 | ~ | ||
| 21 | ___|___ | ||
| 22 | | _ | | ||
| 23 | _|__|_|__|_ | ||
| 24 | | SHIP | Ship apps to your VPS | ||
| 25 | \_________/ with automatic HTTPS | ||
| 26 | ~~~~~~~~~ | ||
| 27 | ` | ||
| 28 | |||
| 29 | var rootCmd = &cobra.Command{ | ||
| 30 | Use: "ship", | ||
| 31 | Short: "Ship apps and static sites to a VPS with automatic HTTPS", | ||
| 32 | Long: banner + ` | ||
| 33 | A CLI tool for deploying applications and static sites to a VPS. | ||
| 34 | |||
| 35 | How it works: | ||
| 36 | Ship uses only SSH to deploy - no agents, containers, or external services. | ||
| 37 | It uploads your binary or static website, creates a systemd service, and configures Caddy | ||
| 38 | for automatic HTTPS. Ports are assigned automatically. Your app runs directly on the VPS | ||
| 39 | with minimal overhead. | ||
| 40 | |||
| 41 | Requirements: | ||
| 42 | • A VPS with SSH access (use 'ship host init' to set up a new server) | ||
| 43 | • An SSH config entry or user@host for your server | ||
| 44 | • A domain pointing to your VPS | ||
| 45 | |||
| 46 | Examples: | ||
| 47 | # Deploy a Go binary | ||
| 48 | ship --binary ./myapp --domain api.example.com | ||
| 49 | |||
| 50 | # Deploy with auto-generated subdomain (requires base domain) | ||
| 51 | ship --binary ./myapp --name myapp | ||
| 52 | |||
| 53 | # Deploy a static site | ||
| 54 | ship --static --dir ./dist --domain example.com | ||
| 55 | |||
| 56 | # Update config without redeploying binary | ||
| 57 | ship --name myapp --memory 512M --cpu 50% | ||
| 58 | ship --name myapp --env DEBUG=true | ||
| 59 | |||
| 60 | # Set up a new VPS with base domain | ||
| 61 | ship host init --host user@vps --base-domain apps.example.com`, | ||
| 62 | RunE: runDeploy, | ||
| 63 | SilenceUsage: true, | ||
| 64 | SilenceErrors: true, | ||
| 65 | } | ||
| 66 | |||
| 67 | func init() { | ||
| 68 | // Persistent flags available to all subcommands | ||
| 69 | rootCmd.PersistentFlags().StringVar(&hostFlag, "host", "", "VPS host (SSH config alias or user@host)") | ||
| 70 | |||
| 71 | // Root command (deploy) flags | ||
| 72 | rootCmd.Flags().String("binary", "", "Path to Go binary (for app deployment)") | ||
| 73 | rootCmd.Flags().Bool("static", false, "Deploy as static site") | ||
| 74 | rootCmd.Flags().String("dir", ".", "Directory to deploy (for static sites)") | ||
| 75 | rootCmd.Flags().String("domain", "", "Custom domain (optional if base domain configured)") | ||
| 76 | rootCmd.Flags().String("name", "", "App name (default: inferred from binary or directory)") | ||
| 77 | rootCmd.Flags().Int("port", 0, "Port override (default: auto-allocate)") | ||
| 78 | rootCmd.Flags().StringArray("env", nil, "Environment variable (KEY=VALUE, can be specified multiple times)") | ||
| 79 | rootCmd.Flags().String("env-file", "", "Path to .env file") | ||
| 80 | rootCmd.Flags().String("args", "", "Arguments to pass to binary") | ||
| 81 | rootCmd.Flags().StringArray("file", nil, "Config file to upload to working directory (can be specified multiple times)") | ||
| 82 | rootCmd.Flags().String("memory", "", "Memory limit (e.g., 512M, 1G)") | ||
| 83 | rootCmd.Flags().String("cpu", "", "CPU limit (e.g., 50%, 200% for 2 cores)") | ||
| 84 | |||
| 85 | // Add subcommands | ||
| 86 | rootCmd.AddCommand(listCmd) | ||
| 87 | rootCmd.AddCommand(logsCmd) | ||
| 88 | rootCmd.AddCommand(statusCmd) | ||
| 89 | rootCmd.AddCommand(restartCmd) | ||
| 90 | rootCmd.AddCommand(removeCmd) | ||
| 91 | rootCmd.AddCommand(initCmd) | ||
| 92 | rootCmd.AddCommand(deployGitCmd) | ||
| 93 | rootCmd.AddCommand(env.Cmd) | ||
| 94 | rootCmd.AddCommand(host.Cmd) | ||
| 95 | rootCmd.AddCommand(uiCmd) | ||
| 96 | rootCmd.AddCommand(versionCmd) | ||
| 97 | } | ||
diff --git a/cmd/ship/root_v2.go b/cmd/ship/root_v2.go new file mode 100644 index 0000000..aa81d1e --- /dev/null +++ b/cmd/ship/root_v2.go | |||
| @@ -0,0 +1,98 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "os" | ||
| 5 | |||
| 6 | "github.com/bdw/ship/internal/output" | ||
| 7 | "github.com/spf13/cobra" | ||
| 8 | ) | ||
| 9 | |||
| 10 | var hostFlag string | ||
| 11 | |||
| 12 | // This file defines the v2 CLI structure. | ||
| 13 | // The primary command is: ship [PATH] [FLAGS] | ||
| 14 | // All output is JSON by default. | ||
| 15 | |||
| 16 | var rootV2Cmd = &cobra.Command{ | ||
| 17 | Use: "ship [PATH]", | ||
| 18 | Short: "Deploy code to a VPS. JSON output for agents.", | ||
| 19 | Long: `Ship deploys code to a VPS. Point it at a directory or binary, get a URL back. | ||
| 20 | |||
| 21 | ship ./myproject # auto-detect and deploy | ||
| 22 | ship ./site --name docs # deploy with specific name | ||
| 23 | ship ./api --health /healthz # deploy with health check | ||
| 24 | ship ./preview --ttl 24h # deploy with auto-expiry | ||
| 25 | |||
| 26 | All output is JSON. Use --pretty for human-readable output.`, | ||
| 27 | Args: cobra.MaximumNArgs(1), | ||
| 28 | RunE: runDeployV2, | ||
| 29 | SilenceUsage: true, | ||
| 30 | SilenceErrors: true, | ||
| 31 | DisableAutoGenTag: true, | ||
| 32 | } | ||
| 33 | |||
| 34 | func initV2() { | ||
| 35 | // Global flags | ||
| 36 | rootV2Cmd.PersistentFlags().StringVar(&hostFlag, "host", "", "VPS host (SSH config alias or user@host)") | ||
| 37 | rootV2Cmd.PersistentFlags().BoolVar(&output.Pretty, "pretty", false, "Human-readable output") | ||
| 38 | |||
| 39 | // Deploy flags | ||
| 40 | rootV2Cmd.Flags().String("name", "", "Deploy name (becomes subdomain)") | ||
| 41 | rootV2Cmd.Flags().String("domain", "", "Custom domain for deployment") | ||
| 42 | rootV2Cmd.Flags().String("health", "", "Health check endpoint (e.g., /healthz)") | ||
| 43 | rootV2Cmd.Flags().String("ttl", "", "Auto-delete after duration (e.g., 1h, 7d)") | ||
| 44 | rootV2Cmd.Flags().StringArray("env", nil, "Environment variable (KEY=VALUE)") | ||
| 45 | rootV2Cmd.Flags().String("env-file", "", "Path to .env file") | ||
| 46 | rootV2Cmd.Flags().Int("container-port", 80, "Port the container listens on (Docker only)") | ||
| 47 | |||
| 48 | // Check for SHIP_PRETTY env var | ||
| 49 | if os.Getenv("SHIP_PRETTY") == "1" { | ||
| 50 | output.Pretty = true | ||
| 51 | } | ||
| 52 | |||
| 53 | // Add subcommands | ||
| 54 | rootV2Cmd.AddCommand(listV2Cmd) | ||
| 55 | rootV2Cmd.AddCommand(statusV2Cmd) | ||
| 56 | rootV2Cmd.AddCommand(logsV2Cmd) | ||
| 57 | rootV2Cmd.AddCommand(removeV2Cmd) | ||
| 58 | rootV2Cmd.AddCommand(hostV2Cmd) | ||
| 59 | |||
| 60 | // Initialize host subcommands (from host_v2.go) | ||
| 61 | initHostV2() | ||
| 62 | } | ||
| 63 | |||
| 64 | func runDeployV2(cmd *cobra.Command, args []string) error { | ||
| 65 | path := "." | ||
| 66 | if len(args) > 0 { | ||
| 67 | path = args[0] | ||
| 68 | } | ||
| 69 | |||
| 70 | opts := deployV2Options{ | ||
| 71 | Host: hostFlag, | ||
| 72 | Pretty: output.Pretty, | ||
| 73 | } | ||
| 74 | |||
| 75 | // Get flag values | ||
| 76 | opts.Name, _ = cmd.Flags().GetString("name") | ||
| 77 | opts.Domain, _ = cmd.Flags().GetString("domain") | ||
| 78 | opts.Health, _ = cmd.Flags().GetString("health") | ||
| 79 | opts.TTL, _ = cmd.Flags().GetString("ttl") | ||
| 80 | opts.Env, _ = cmd.Flags().GetStringArray("env") | ||
| 81 | opts.EnvFile, _ = cmd.Flags().GetString("env-file") | ||
| 82 | opts.ContainerPort, _ = cmd.Flags().GetInt("container-port") | ||
| 83 | |||
| 84 | // deployV2 handles all output and exits | ||
| 85 | deployV2(path, opts) | ||
| 86 | |||
| 87 | // Should not reach here (deployV2 calls os.Exit) | ||
| 88 | return nil | ||
| 89 | } | ||
| 90 | |||
| 91 | // Subcommands (list, status, logs, remove) are defined in commands_v2.go | ||
| 92 | |||
| 93 | var hostV2Cmd = &cobra.Command{ | ||
| 94 | Use: "host", | ||
| 95 | Short: "Manage VPS host", | ||
| 96 | } | ||
| 97 | |||
| 98 | // hostInitV2Cmd, hostStatusV2Cmd, and initHostV2() are defined in host_v2.go | ||
diff --git a/cmd/ship/status.go b/cmd/ship/status.go deleted file mode 100644 index 4774fad..0000000 --- a/cmd/ship/status.go +++ /dev/null | |||
| @@ -1,63 +0,0 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | |||
| 6 | "github.com/bdw/ship/internal/ssh" | ||
| 7 | "github.com/bdw/ship/internal/state" | ||
| 8 | "github.com/spf13/cobra" | ||
| 9 | ) | ||
| 10 | |||
| 11 | var statusCmd = &cobra.Command{ | ||
| 12 | Use: "status <app>", | ||
| 13 | Short: "Check status of a deployment", | ||
| 14 | Args: cobra.ExactArgs(1), | ||
| 15 | RunE: runStatus, | ||
| 16 | } | ||
| 17 | |||
| 18 | func runStatus(cmd *cobra.Command, args []string) error { | ||
| 19 | name := args[0] | ||
| 20 | if err := validateName(name); err != nil { | ||
| 21 | return err | ||
| 22 | } | ||
| 23 | |||
| 24 | st, err := state.Load() | ||
| 25 | if err != nil { | ||
| 26 | return fmt.Errorf("error loading state: %w", err) | ||
| 27 | } | ||
| 28 | |||
| 29 | host := hostFlag | ||
| 30 | if host == "" { | ||
| 31 | host = st.GetDefaultHost() | ||
| 32 | } | ||
| 33 | |||
| 34 | if host == "" { | ||
| 35 | return fmt.Errorf("--host is required") | ||
| 36 | } | ||
| 37 | |||
| 38 | app, err := st.GetApp(host, name) | ||
| 39 | if err != nil { | ||
| 40 | return err | ||
| 41 | } | ||
| 42 | |||
| 43 | if app.Type != "app" && app.Type != "git-app" { | ||
| 44 | return fmt.Errorf("status is only available for apps, not static sites") | ||
| 45 | } | ||
| 46 | |||
| 47 | client, err := ssh.Connect(host) | ||
| 48 | if err != nil { | ||
| 49 | return fmt.Errorf("error connecting to VPS: %w", err) | ||
| 50 | } | ||
| 51 | defer client.Close() | ||
| 52 | |||
| 53 | output, err := client.RunSudo(fmt.Sprintf("systemctl status %s", name)) | ||
| 54 | if err != nil { | ||
| 55 | // systemctl status returns non-zero for non-active services | ||
| 56 | // but we still want to show the output | ||
| 57 | fmt.Print(output) | ||
| 58 | return nil | ||
| 59 | } | ||
| 60 | |||
| 61 | fmt.Print(output) | ||
| 62 | return nil | ||
| 63 | } | ||
diff --git a/cmd/ship/ui.go b/cmd/ship/ui.go deleted file mode 100644 index cfaea08..0000000 --- a/cmd/ship/ui.go +++ /dev/null | |||
| @@ -1,199 +0,0 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 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 | ||
| 18 | var templatesFS embed.FS | ||
| 19 | |||
| 20 | var uiCmd = &cobra.Command{ | ||
| 21 | Use: "ui", | ||
| 22 | Short: "Launch web management UI", | ||
| 23 | RunE: runUI, | ||
| 24 | } | ||
| 25 | |||
| 26 | func init() { | ||
| 27 | uiCmd.Flags().StringP("port", "p", "8080", "Port to run the web UI on") | ||
| 28 | } | ||
| 29 | |||
| 30 | func 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/validate.go b/cmd/ship/validate.go deleted file mode 100644 index 00275af..0000000 --- a/cmd/ship/validate.go +++ /dev/null | |||
| @@ -1,9 +0,0 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import "github.com/bdw/ship/internal/state" | ||
| 4 | |||
| 5 | // validateName checks that an app/project name is safe for use in shell | ||
| 6 | // commands, file paths, systemd units, and DNS labels. | ||
| 7 | func validateName(name string) error { | ||
| 8 | return state.ValidateName(name) | ||
| 9 | } | ||
diff --git a/cmd/ship/version.go b/cmd/ship/version.go deleted file mode 100644 index 6e4314a..0000000 --- a/cmd/ship/version.go +++ /dev/null | |||
| @@ -1,17 +0,0 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | |||
| 6 | "github.com/spf13/cobra" | ||
| 7 | ) | ||
| 8 | |||
| 9 | var 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 | } | ||
