From 5548b36e0953c17dbe30f6b63c892b7c83196b20 Mon Sep 17 00:00:00 2001 From: Clawd Date: Thu, 19 Feb 2026 08:10:45 -0800 Subject: Clean up: drop v2 suffix, remove webui --- cmd/ship/commands.go | 365 ++++++++++++++++++++++++++++++++++ cmd/ship/commands_v2.go | 365 ---------------------------------- cmd/ship/deploy.go | 210 ++++++++++++++++++++ cmd/ship/deploy_impl.go | 394 +++++++++++++++++++++++++++++++++++++ cmd/ship/deploy_impl_v2.go | 394 ------------------------------------- cmd/ship/deploy_v2.go | 210 -------------------- cmd/ship/host.go | 445 ++++++++++++++++++++++++++++++++++++++++++ cmd/ship/host_v2.go | 445 ------------------------------------------ cmd/ship/root.go | 98 ++++++++++ cmd/ship/root_v2.go | 98 ---------- cmd/ship/templates/webui.html | 440 ----------------------------------------- 11 files changed, 1512 insertions(+), 1952 deletions(-) create mode 100644 cmd/ship/commands.go delete mode 100644 cmd/ship/commands_v2.go create mode 100644 cmd/ship/deploy.go create mode 100644 cmd/ship/deploy_impl.go delete mode 100644 cmd/ship/deploy_impl_v2.go delete mode 100644 cmd/ship/deploy_v2.go create mode 100644 cmd/ship/host.go delete mode 100644 cmd/ship/host_v2.go create mode 100644 cmd/ship/root.go delete mode 100644 cmd/ship/root_v2.go delete mode 100644 cmd/ship/templates/webui.html diff --git a/cmd/ship/commands.go b/cmd/ship/commands.go new file mode 100644 index 0000000..1b0d09c --- /dev/null +++ b/cmd/ship/commands.go @@ -0,0 +1,365 @@ +package main + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/bdw/ship/internal/output" + "github.com/bdw/ship/internal/ssh" + "github.com/bdw/ship/internal/state" + "github.com/spf13/cobra" +) + +// listV2Cmd lists all deployments +var listV2Cmd = &cobra.Command{ + Use: "list", + Short: "List all deployments", + RunE: runListV2, +} + +func runListV2(cmd *cobra.Command, args []string) error { + st, err := state.Load() + if err != nil { + output.PrintAndExit(output.Err(output.ErrHostNotConfigured, err.Error())) + } + + hostName := hostFlag + if hostName == "" { + hostName = st.DefaultHost + } + if hostName == "" { + output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified")) + } + + hostConfig := st.GetHost(hostName) + + client, err := ssh.Connect(hostName) + if err != nil { + output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error())) + } + defer client.Close() + + var deploys []output.DeployInfo + + // Get all deployed services by checking /etc/ship/ports and /var/www + // Check ports (apps and docker) + portsOut, _ := client.Run("ls /etc/ship/ports/ 2>/dev/null || true") + for _, name := range strings.Fields(portsOut) { + if name == "" { + continue + } + + // Get actual domain from Caddyfile (first word of first line) + domain := fmt.Sprintf("%s.%s", name, hostConfig.BaseDomain) + caddyOut, _ := client.Run(fmt.Sprintf("head -1 /etc/caddy/sites-enabled/%s.caddy 2>/dev/null | awk '{print $1}'", name)) + if d := strings.TrimSpace(caddyOut); d != "" && d != "{" { + domain = d + } + + info := output.DeployInfo{ + Name: name, + URL: fmt.Sprintf("https://%s", domain), + } + + // Check if it's docker or binary + dockerOut, _ := client.Run(fmt.Sprintf("docker inspect %s 2>/dev/null && echo docker", name)) + if strings.Contains(dockerOut, "docker") { + info.Type = "docker" + } else { + info.Type = "binary" + } + + // Check if running + statusOut, _ := client.RunSudo(fmt.Sprintf("systemctl is-active %s 2>/dev/null || echo inactive", name)) + info.Running = strings.TrimSpace(statusOut) == "active" + + // Check TTL + ttlOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ttl/%s 2>/dev/null || true", name)) + if ttlOut != "" { + if expires, err := strconv.ParseInt(strings.TrimSpace(ttlOut), 10, 64); err == nil { + info.Expires = time.Unix(expires, 0).UTC().Format(time.RFC3339) + } + } + + deploys = append(deploys, info) + } + + // Check static sites in /var/www + wwwOut, _ := client.Run("ls -d /var/www/*/ 2>/dev/null | xargs -n1 basename 2>/dev/null || true") + for _, name := range strings.Fields(wwwOut) { + if name == "" || name == "html" { + continue + } + + // Skip if already in ports (would be an app, not static) + found := false + for _, d := range deploys { + if d.Name == name { + found = true + break + } + } + if found { + continue + } + + // Get actual domain from Caddyfile + domain := fmt.Sprintf("%s.%s", name, hostConfig.BaseDomain) + caddyOut, _ := client.Run(fmt.Sprintf("head -1 /etc/caddy/sites-enabled/%s.caddy 2>/dev/null | awk '{print $1}'", name)) + if d := strings.TrimSpace(caddyOut); d != "" && d != "{" { + domain = d + } + + info := output.DeployInfo{ + Name: name, + URL: fmt.Sprintf("https://%s", domain), + Type: "static", + Running: true, // Static sites are always "running" + } + + // Check TTL + ttlOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ttl/%s 2>/dev/null || true", name)) + if ttlOut != "" { + if expires, err := strconv.ParseInt(strings.TrimSpace(ttlOut), 10, 64); err == nil { + info.Expires = time.Unix(expires, 0).UTC().Format(time.RFC3339) + } + } + + deploys = append(deploys, info) + } + + output.PrintAndExit(&output.ListResponse{ + Status: "ok", + Deploys: deploys, + }) + return nil +} + +// statusV2Cmd shows status for a single deployment +var statusV2Cmd = &cobra.Command{ + Use: "status NAME", + Short: "Check status of a deployment", + Args: cobra.ExactArgs(1), + RunE: runStatusV2, +} + +func runStatusV2(cmd *cobra.Command, args []string) error { + name := args[0] + + st, err := state.Load() + if err != nil { + output.PrintAndExit(output.Err(output.ErrHostNotConfigured, err.Error())) + } + + hostName := hostFlag + if hostName == "" { + hostName = st.DefaultHost + } + if hostName == "" { + output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified")) + } + + hostConfig := st.GetHost(hostName) + + client, err := ssh.Connect(hostName) + if err != nil { + output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error())) + } + defer client.Close() + + // Check if deployment exists + portOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ports/%s 2>/dev/null || true", name)) + wwwExists, _ := client.Run(fmt.Sprintf("test -d /var/www/%s && echo exists || true", name)) + + if strings.TrimSpace(portOut) == "" && strings.TrimSpace(wwwExists) == "" { + output.PrintAndExit(output.ErrWithName(output.ErrNotFound, "deployment not found", name)) + } + + // Get actual domain from Caddyfile + domain := fmt.Sprintf("%s.%s", name, hostConfig.BaseDomain) + caddyOut, _ := client.Run(fmt.Sprintf("head -1 /etc/caddy/sites-enabled/%s.caddy 2>/dev/null | awk '{print $1}'", name)) + if d := strings.TrimSpace(caddyOut); d != "" && d != "{" { + domain = d + } + + resp := &output.StatusResponse{ + Status: "ok", + Name: name, + URL: fmt.Sprintf("https://%s", domain), + } + + // Determine type and get details + if portOut != "" { + port, _ := strconv.Atoi(strings.TrimSpace(portOut)) + resp.Port = port + + // Check if docker + dockerOut, _ := client.Run(fmt.Sprintf("docker inspect %s 2>/dev/null && echo docker", name)) + if strings.Contains(dockerOut, "docker") { + resp.Type = "docker" + } else { + resp.Type = "binary" + } + + // Check if running + statusOut, _ := client.RunSudo(fmt.Sprintf("systemctl is-active %s 2>/dev/null || echo inactive", name)) + resp.Running = strings.TrimSpace(statusOut) == "active" + } else { + resp.Type = "static" + resp.Running = true + } + + // Check TTL + ttlOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ttl/%s 2>/dev/null || true", name)) + if ttlOut != "" { + if expires, err := strconv.ParseInt(strings.TrimSpace(ttlOut), 10, 64); err == nil { + resp.Expires = time.Unix(expires, 0).UTC().Format(time.RFC3339) + } + } + + output.PrintAndExit(resp) + return nil +} + +// logsV2Cmd shows logs for a deployment +var logsV2Cmd = &cobra.Command{ + Use: "logs NAME", + Short: "View logs for a deployment", + Args: cobra.ExactArgs(1), + RunE: runLogsV2, +} + +func init() { + logsV2Cmd.Flags().Int("lines", 50, "Number of log lines to show") +} + +func runLogsV2(cmd *cobra.Command, args []string) error { + name := args[0] + lines, _ := cmd.Flags().GetInt("lines") + + st, err := state.Load() + if err != nil { + output.PrintAndExit(output.Err(output.ErrHostNotConfigured, err.Error())) + } + + hostName := hostFlag + if hostName == "" { + hostName = st.DefaultHost + } + if hostName == "" { + output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified")) + } + + client, err := ssh.Connect(hostName) + if err != nil { + output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error())) + } + defer client.Close() + + // Check if it's a static site (no logs) + portOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ports/%s 2>/dev/null || true", name)) + if strings.TrimSpace(portOut) == "" { + // Check if static site exists + wwwExists, _ := client.Run(fmt.Sprintf("test -d /var/www/%s && echo exists || true", name)) + if strings.TrimSpace(wwwExists) == "" { + output.PrintAndExit(output.ErrWithName(output.ErrNotFound, "deployment not found", name)) + } + // Static site - check Caddy access logs + 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)) + if err != nil { + output.PrintAndExit(output.Err(output.ErrServiceFailed, err.Error())) + } + logLines := strings.Split(strings.TrimSpace(logsOut), "\n") + output.PrintAndExit(&output.LogsResponse{ + Status: "ok", + Name: name, + Lines: logLines, + }) + return nil + } + + // Get journalctl logs + logsOut, err := client.RunSudo(fmt.Sprintf("journalctl -u %s -n %d --no-pager 2>/dev/null || echo 'No logs found'", name, lines)) + if err != nil { + output.PrintAndExit(output.Err(output.ErrServiceFailed, err.Error())) + } + + logLines := strings.Split(strings.TrimSpace(logsOut), "\n") + + output.PrintAndExit(&output.LogsResponse{ + Status: "ok", + Name: name, + Lines: logLines, + }) + return nil +} + +// removeV2Cmd removes a deployment +var removeV2Cmd = &cobra.Command{ + Use: "remove NAME", + Short: "Remove a deployment", + Args: cobra.ExactArgs(1), + RunE: runRemoveV2, +} + +func runRemoveV2(cmd *cobra.Command, args []string) error { + name := args[0] + + st, err := state.Load() + if err != nil { + output.PrintAndExit(output.Err(output.ErrHostNotConfigured, err.Error())) + } + + hostName := hostFlag + if hostName == "" { + hostName = st.DefaultHost + } + if hostName == "" { + output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified")) + } + + client, err := ssh.Connect(hostName) + if err != nil { + output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error())) + } + defer client.Close() + + // Check if deployment exists + portOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ports/%s 2>/dev/null || true", name)) + wwwExists, _ := client.Run(fmt.Sprintf("test -d /var/www/%s && echo exists || true", name)) + + if strings.TrimSpace(portOut) == "" && strings.TrimSpace(wwwExists) == "" { + output.PrintAndExit(output.ErrWithName(output.ErrNotFound, "deployment not found", name)) + } + + // Stop and disable service + client.RunSudo(fmt.Sprintf("systemctl stop %s 2>/dev/null || true", name)) + client.RunSudo(fmt.Sprintf("systemctl disable %s 2>/dev/null || true", name)) + + // Remove files + client.RunSudo(fmt.Sprintf("rm -f /etc/systemd/system/%s.service", name)) + client.RunSudo(fmt.Sprintf("rm -f /etc/caddy/sites-enabled/%s.caddy", name)) + client.RunSudo(fmt.Sprintf("rm -rf /var/www/%s", name)) + client.RunSudo(fmt.Sprintf("rm -rf /var/lib/%s", name)) + client.RunSudo(fmt.Sprintf("rm -f /usr/local/bin/%s", name)) + client.RunSudo(fmt.Sprintf("rm -f /etc/ship/env/%s.env", name)) + client.RunSudo(fmt.Sprintf("rm -f /etc/ship/ports/%s", name)) + client.RunSudo(fmt.Sprintf("rm -f /etc/ship/ttl/%s", name)) + + // Remove docker container and image + client.Run(fmt.Sprintf("docker rm -f %s 2>/dev/null || true", name)) + client.Run(fmt.Sprintf("docker rmi %s 2>/dev/null || true", name)) + + // Reload services + client.RunSudo("systemctl daemon-reload") + client.RunSudo("systemctl reload caddy") + + output.PrintAndExit(&output.RemoveResponse{ + Status: "ok", + Name: name, + Removed: true, + }) + return nil +} diff --git a/cmd/ship/commands_v2.go b/cmd/ship/commands_v2.go deleted file mode 100644 index 1b0d09c..0000000 --- a/cmd/ship/commands_v2.go +++ /dev/null @@ -1,365 +0,0 @@ -package main - -import ( - "fmt" - "strconv" - "strings" - "time" - - "github.com/bdw/ship/internal/output" - "github.com/bdw/ship/internal/ssh" - "github.com/bdw/ship/internal/state" - "github.com/spf13/cobra" -) - -// listV2Cmd lists all deployments -var listV2Cmd = &cobra.Command{ - Use: "list", - Short: "List all deployments", - RunE: runListV2, -} - -func runListV2(cmd *cobra.Command, args []string) error { - st, err := state.Load() - if err != nil { - output.PrintAndExit(output.Err(output.ErrHostNotConfigured, err.Error())) - } - - hostName := hostFlag - if hostName == "" { - hostName = st.DefaultHost - } - if hostName == "" { - output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified")) - } - - hostConfig := st.GetHost(hostName) - - client, err := ssh.Connect(hostName) - if err != nil { - output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error())) - } - defer client.Close() - - var deploys []output.DeployInfo - - // Get all deployed services by checking /etc/ship/ports and /var/www - // Check ports (apps and docker) - portsOut, _ := client.Run("ls /etc/ship/ports/ 2>/dev/null || true") - for _, name := range strings.Fields(portsOut) { - if name == "" { - continue - } - - // Get actual domain from Caddyfile (first word of first line) - domain := fmt.Sprintf("%s.%s", name, hostConfig.BaseDomain) - caddyOut, _ := client.Run(fmt.Sprintf("head -1 /etc/caddy/sites-enabled/%s.caddy 2>/dev/null | awk '{print $1}'", name)) - if d := strings.TrimSpace(caddyOut); d != "" && d != "{" { - domain = d - } - - info := output.DeployInfo{ - Name: name, - URL: fmt.Sprintf("https://%s", domain), - } - - // Check if it's docker or binary - dockerOut, _ := client.Run(fmt.Sprintf("docker inspect %s 2>/dev/null && echo docker", name)) - if strings.Contains(dockerOut, "docker") { - info.Type = "docker" - } else { - info.Type = "binary" - } - - // Check if running - statusOut, _ := client.RunSudo(fmt.Sprintf("systemctl is-active %s 2>/dev/null || echo inactive", name)) - info.Running = strings.TrimSpace(statusOut) == "active" - - // Check TTL - ttlOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ttl/%s 2>/dev/null || true", name)) - if ttlOut != "" { - if expires, err := strconv.ParseInt(strings.TrimSpace(ttlOut), 10, 64); err == nil { - info.Expires = time.Unix(expires, 0).UTC().Format(time.RFC3339) - } - } - - deploys = append(deploys, info) - } - - // Check static sites in /var/www - wwwOut, _ := client.Run("ls -d /var/www/*/ 2>/dev/null | xargs -n1 basename 2>/dev/null || true") - for _, name := range strings.Fields(wwwOut) { - if name == "" || name == "html" { - continue - } - - // Skip if already in ports (would be an app, not static) - found := false - for _, d := range deploys { - if d.Name == name { - found = true - break - } - } - if found { - continue - } - - // Get actual domain from Caddyfile - domain := fmt.Sprintf("%s.%s", name, hostConfig.BaseDomain) - caddyOut, _ := client.Run(fmt.Sprintf("head -1 /etc/caddy/sites-enabled/%s.caddy 2>/dev/null | awk '{print $1}'", name)) - if d := strings.TrimSpace(caddyOut); d != "" && d != "{" { - domain = d - } - - info := output.DeployInfo{ - Name: name, - URL: fmt.Sprintf("https://%s", domain), - Type: "static", - Running: true, // Static sites are always "running" - } - - // Check TTL - ttlOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ttl/%s 2>/dev/null || true", name)) - if ttlOut != "" { - if expires, err := strconv.ParseInt(strings.TrimSpace(ttlOut), 10, 64); err == nil { - info.Expires = time.Unix(expires, 0).UTC().Format(time.RFC3339) - } - } - - deploys = append(deploys, info) - } - - output.PrintAndExit(&output.ListResponse{ - Status: "ok", - Deploys: deploys, - }) - return nil -} - -// statusV2Cmd shows status for a single deployment -var statusV2Cmd = &cobra.Command{ - Use: "status NAME", - Short: "Check status of a deployment", - Args: cobra.ExactArgs(1), - RunE: runStatusV2, -} - -func runStatusV2(cmd *cobra.Command, args []string) error { - name := args[0] - - st, err := state.Load() - if err != nil { - output.PrintAndExit(output.Err(output.ErrHostNotConfigured, err.Error())) - } - - hostName := hostFlag - if hostName == "" { - hostName = st.DefaultHost - } - if hostName == "" { - output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified")) - } - - hostConfig := st.GetHost(hostName) - - client, err := ssh.Connect(hostName) - if err != nil { - output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error())) - } - defer client.Close() - - // Check if deployment exists - portOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ports/%s 2>/dev/null || true", name)) - wwwExists, _ := client.Run(fmt.Sprintf("test -d /var/www/%s && echo exists || true", name)) - - if strings.TrimSpace(portOut) == "" && strings.TrimSpace(wwwExists) == "" { - output.PrintAndExit(output.ErrWithName(output.ErrNotFound, "deployment not found", name)) - } - - // Get actual domain from Caddyfile - domain := fmt.Sprintf("%s.%s", name, hostConfig.BaseDomain) - caddyOut, _ := client.Run(fmt.Sprintf("head -1 /etc/caddy/sites-enabled/%s.caddy 2>/dev/null | awk '{print $1}'", name)) - if d := strings.TrimSpace(caddyOut); d != "" && d != "{" { - domain = d - } - - resp := &output.StatusResponse{ - Status: "ok", - Name: name, - URL: fmt.Sprintf("https://%s", domain), - } - - // Determine type and get details - if portOut != "" { - port, _ := strconv.Atoi(strings.TrimSpace(portOut)) - resp.Port = port - - // Check if docker - dockerOut, _ := client.Run(fmt.Sprintf("docker inspect %s 2>/dev/null && echo docker", name)) - if strings.Contains(dockerOut, "docker") { - resp.Type = "docker" - } else { - resp.Type = "binary" - } - - // Check if running - statusOut, _ := client.RunSudo(fmt.Sprintf("systemctl is-active %s 2>/dev/null || echo inactive", name)) - resp.Running = strings.TrimSpace(statusOut) == "active" - } else { - resp.Type = "static" - resp.Running = true - } - - // Check TTL - ttlOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ttl/%s 2>/dev/null || true", name)) - if ttlOut != "" { - if expires, err := strconv.ParseInt(strings.TrimSpace(ttlOut), 10, 64); err == nil { - resp.Expires = time.Unix(expires, 0).UTC().Format(time.RFC3339) - } - } - - output.PrintAndExit(resp) - return nil -} - -// logsV2Cmd shows logs for a deployment -var logsV2Cmd = &cobra.Command{ - Use: "logs NAME", - Short: "View logs for a deployment", - Args: cobra.ExactArgs(1), - RunE: runLogsV2, -} - -func init() { - logsV2Cmd.Flags().Int("lines", 50, "Number of log lines to show") -} - -func runLogsV2(cmd *cobra.Command, args []string) error { - name := args[0] - lines, _ := cmd.Flags().GetInt("lines") - - st, err := state.Load() - if err != nil { - output.PrintAndExit(output.Err(output.ErrHostNotConfigured, err.Error())) - } - - hostName := hostFlag - if hostName == "" { - hostName = st.DefaultHost - } - if hostName == "" { - output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified")) - } - - client, err := ssh.Connect(hostName) - if err != nil { - output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error())) - } - defer client.Close() - - // Check if it's a static site (no logs) - portOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ports/%s 2>/dev/null || true", name)) - if strings.TrimSpace(portOut) == "" { - // Check if static site exists - wwwExists, _ := client.Run(fmt.Sprintf("test -d /var/www/%s && echo exists || true", name)) - if strings.TrimSpace(wwwExists) == "" { - output.PrintAndExit(output.ErrWithName(output.ErrNotFound, "deployment not found", name)) - } - // Static site - check Caddy access logs - 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)) - if err != nil { - output.PrintAndExit(output.Err(output.ErrServiceFailed, err.Error())) - } - logLines := strings.Split(strings.TrimSpace(logsOut), "\n") - output.PrintAndExit(&output.LogsResponse{ - Status: "ok", - Name: name, - Lines: logLines, - }) - return nil - } - - // Get journalctl logs - logsOut, err := client.RunSudo(fmt.Sprintf("journalctl -u %s -n %d --no-pager 2>/dev/null || echo 'No logs found'", name, lines)) - if err != nil { - output.PrintAndExit(output.Err(output.ErrServiceFailed, err.Error())) - } - - logLines := strings.Split(strings.TrimSpace(logsOut), "\n") - - output.PrintAndExit(&output.LogsResponse{ - Status: "ok", - Name: name, - Lines: logLines, - }) - return nil -} - -// removeV2Cmd removes a deployment -var removeV2Cmd = &cobra.Command{ - Use: "remove NAME", - Short: "Remove a deployment", - Args: cobra.ExactArgs(1), - RunE: runRemoveV2, -} - -func runRemoveV2(cmd *cobra.Command, args []string) error { - name := args[0] - - st, err := state.Load() - if err != nil { - output.PrintAndExit(output.Err(output.ErrHostNotConfigured, err.Error())) - } - - hostName := hostFlag - if hostName == "" { - hostName = st.DefaultHost - } - if hostName == "" { - output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified")) - } - - client, err := ssh.Connect(hostName) - if err != nil { - output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error())) - } - defer client.Close() - - // Check if deployment exists - portOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ports/%s 2>/dev/null || true", name)) - wwwExists, _ := client.Run(fmt.Sprintf("test -d /var/www/%s && echo exists || true", name)) - - if strings.TrimSpace(portOut) == "" && strings.TrimSpace(wwwExists) == "" { - output.PrintAndExit(output.ErrWithName(output.ErrNotFound, "deployment not found", name)) - } - - // Stop and disable service - client.RunSudo(fmt.Sprintf("systemctl stop %s 2>/dev/null || true", name)) - client.RunSudo(fmt.Sprintf("systemctl disable %s 2>/dev/null || true", name)) - - // Remove files - client.RunSudo(fmt.Sprintf("rm -f /etc/systemd/system/%s.service", name)) - client.RunSudo(fmt.Sprintf("rm -f /etc/caddy/sites-enabled/%s.caddy", name)) - client.RunSudo(fmt.Sprintf("rm -rf /var/www/%s", name)) - client.RunSudo(fmt.Sprintf("rm -rf /var/lib/%s", name)) - client.RunSudo(fmt.Sprintf("rm -f /usr/local/bin/%s", name)) - client.RunSudo(fmt.Sprintf("rm -f /etc/ship/env/%s.env", name)) - client.RunSudo(fmt.Sprintf("rm -f /etc/ship/ports/%s", name)) - client.RunSudo(fmt.Sprintf("rm -f /etc/ship/ttl/%s", name)) - - // Remove docker container and image - client.Run(fmt.Sprintf("docker rm -f %s 2>/dev/null || true", name)) - client.Run(fmt.Sprintf("docker rmi %s 2>/dev/null || true", name)) - - // Reload services - client.RunSudo("systemctl daemon-reload") - client.RunSudo("systemctl reload caddy") - - output.PrintAndExit(&output.RemoveResponse{ - Status: "ok", - Name: name, - Removed: true, - }) - return nil -} diff --git a/cmd/ship/deploy.go b/cmd/ship/deploy.go new file mode 100644 index 0000000..7d498b2 --- /dev/null +++ b/cmd/ship/deploy.go @@ -0,0 +1,210 @@ +package main + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "regexp" + "strings" + "time" + + "github.com/bdw/ship/internal/detect" + "github.com/bdw/ship/internal/output" + "github.com/bdw/ship/internal/state" +) + +// deployV2 implements the new agent-first deploy interface. +// Usage: ship [PATH] [FLAGS] +// PATH defaults to "." if not provided. +func deployV2(path string, opts deployV2Options) { + start := time.Now() + + // Validate name if provided + if opts.Name != "" { + if err := validateNameV2(opts.Name); err != nil { + output.PrintAndExit(err) + } + } + + // Parse TTL if provided + var ttlDuration time.Duration + if opts.TTL != "" { + var err error + ttlDuration, err = parseTTL(opts.TTL) + if err != nil { + output.PrintAndExit(output.Err(output.ErrInvalidTTL, err.Error())) + } + } + + // Get host configuration + st, err := state.Load() + if err != nil { + output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "failed to load state: "+err.Error())) + } + + hostName := opts.Host + if hostName == "" { + hostName = st.DefaultHost + } + if hostName == "" { + output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified and no default host configured. Run: ship host init")) + } + + hostConfig := st.GetHost(hostName) + if hostConfig.BaseDomain == "" { + output.PrintAndExit(output.Err(output.ErrHostNotConfigured, fmt.Sprintf("host %q has no base domain configured. Run: ship host init", hostName))) + } + + // Auto-detect project type + result := detect.Detect(path) + if result.Error != nil { + output.PrintAndExit(result.Error) + } + + // Generate name if not provided + name := opts.Name + if name == "" { + name = generateName() + } + + // Build URL: use custom domain if provided, otherwise use subdomain + var url string + if opts.Domain != "" { + url = fmt.Sprintf("https://%s", opts.Domain) + } else { + url = fmt.Sprintf("https://%s.%s", name, hostConfig.BaseDomain) + } + + // Build deploy context + ctx := &deployContext{ + SSHHost: hostName, + HostConfig: hostConfig, + Name: name, + Path: result.Path, + URL: url, + Opts: opts, + } + + // Deploy based on type + var deployErr *output.ErrorResponse + switch result.Type { + case detect.TypeStatic: + deployErr = deployStaticV2(ctx) + case detect.TypeDocker: + deployErr = deployDockerV2(ctx) + case detect.TypeBinary: + deployErr = deployBinaryV2(ctx) + } + + if deployErr != nil { + deployErr.Name = name + deployErr.URL = url + output.PrintAndExit(deployErr) + } + + // Set TTL if specified + if ttlDuration > 0 { + if err := setTTLV2(ctx, ttlDuration); err != nil { + // Non-fatal, deploy succeeded + // TODO: log warning + } + } + + // Health check + var healthResult *output.HealthResult + if opts.Health != "" || result.Type == detect.TypeStatic { + endpoint := opts.Health + if endpoint == "" { + endpoint = "/" + } + healthResult, deployErr = runHealthCheck(url, endpoint) + if deployErr != nil { + deployErr.Name = name + deployErr.URL = url + output.PrintAndExit(deployErr) + } + } + + // Build response + resp := &output.DeployResponse{ + Status: "ok", + Name: name, + URL: url, + Type: string(result.Type), + TookMs: time.Since(start).Milliseconds(), + Health: healthResult, + } + + if ttlDuration > 0 { + resp.Expires = time.Now().Add(ttlDuration).UTC().Format(time.RFC3339) + } + + output.PrintAndExit(resp) +} + +type deployV2Options struct { + Name string + Host string + Domain string + Health string + TTL string + Env []string + EnvFile string + ContainerPort int // Port the container listens on (default 80 for Docker) + Pretty bool +} + +// deployContext holds all info needed for a deploy +type deployContext struct { + SSHHost string // SSH connection string (config alias or user@host) + HostConfig *state.Host // Host configuration + Name string // Deploy name + Path string // Local path to deploy + URL string // Full URL after deploy + Opts deployV2Options +} + +// validateNameV2 checks if name matches allowed pattern +func validateNameV2(name string) *output.ErrorResponse { + // Must be lowercase alphanumeric with hyphens, 1-63 chars + pattern := regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$|^[a-z0-9]$`) + if !pattern.MatchString(name) { + return output.Err(output.ErrInvalidName, + "name must be lowercase alphanumeric with hyphens, 1-63 characters") + } + return nil +} + +// generateName creates a random deploy name +func generateName() string { + bytes := make([]byte, 3) + rand.Read(bytes) + return "ship-" + hex.EncodeToString(bytes) +} + +// parseTTL converts duration strings like "1h", "7d" to time.Duration +func parseTTL(s string) (time.Duration, error) { + s = strings.TrimSpace(s) + if s == "" { + return 0, nil + } + + // Handle days specially (not supported by time.ParseDuration) + if strings.HasSuffix(s, "d") { + days := strings.TrimSuffix(s, "d") + var d int + _, err := fmt.Sscanf(days, "%d", &d) + if err != nil { + return 0, fmt.Errorf("invalid TTL: %s", s) + } + return time.Duration(d) * 24 * time.Hour, nil + } + + d, err := time.ParseDuration(s) + if err != nil { + return 0, fmt.Errorf("invalid TTL: %s", s) + } + return d, nil +} + +// Deploy implementations are in deploy_impl_v2.go diff --git a/cmd/ship/deploy_impl.go b/cmd/ship/deploy_impl.go new file mode 100644 index 0000000..bfec9d3 --- /dev/null +++ b/cmd/ship/deploy_impl.go @@ -0,0 +1,394 @@ +package main + +import ( + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/bdw/ship/internal/output" + "github.com/bdw/ship/internal/ssh" + "github.com/bdw/ship/internal/templates" +) + +// deployStaticV2 deploys a static site +// 1. rsync path to /var/www// +// 2. Generate and upload Caddyfile +// 3. Reload Caddy +func deployStaticV2(ctx *deployContext) *output.ErrorResponse { + client, err := ssh.Connect(ctx.SSHHost) + if err != nil { + return output.Err(output.ErrSSHConnectFailed, err.Error()) + } + defer client.Close() + + name := ctx.Name + remotePath := fmt.Sprintf("/var/www/%s", name) + + // Create directory and set ownership for upload + user, _ := client.Run("whoami") + user = strings.TrimSpace(user) + if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", remotePath)); err != nil { + return output.Err(output.ErrUploadFailed, "failed to create directory: "+err.Error()) + } + if _, err := client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", user, user, remotePath)); err != nil { + return output.Err(output.ErrUploadFailed, "failed to set directory ownership: "+err.Error()) + } + + // Upload files using rsync + if err := client.UploadDir(ctx.Path, remotePath); err != nil { + return output.Err(output.ErrUploadFailed, err.Error()) + } + + // Set ownership back to www-data + if _, err := client.RunSudo(fmt.Sprintf("chown -R www-data:www-data %s", remotePath)); err != nil { + // Non-fatal, continue + } + + // Generate Caddyfile only if it doesn't exist (preserve manual edits) + caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) + caddyExists, _ := client.Run(fmt.Sprintf("test -f %s && echo exists", caddyPath)) + if strings.TrimSpace(caddyExists) != "exists" { + caddyfile, err := templates.StaticCaddy(map[string]string{ + "Domain": ctx.URL[8:], // Strip https:// + "RootDir": remotePath, + "Name": name, + }) + if err != nil { + return output.Err(output.ErrCaddyFailed, "failed to generate Caddyfile: "+err.Error()) + } + + if err := client.WriteSudoFile(caddyPath, caddyfile); err != nil { + return output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error()) + } + } + + // Reload Caddy + if _, err := client.RunSudo("systemctl reload caddy"); err != nil { + return output.Err(output.ErrCaddyFailed, "failed to reload Caddy: "+err.Error()) + } + + return nil +} + +// deployDockerV2 deploys a Docker-based app +// 1. Allocate port +// 2. rsync path to /var/lib//src/ +// 3. docker build +// 4. Generate systemd unit and env file +// 5. Generate Caddyfile +// 6. Start service, reload Caddy +func deployDockerV2(ctx *deployContext) *output.ErrorResponse { + client, err := ssh.Connect(ctx.SSHHost) + if err != nil { + return output.Err(output.ErrSSHConnectFailed, err.Error()) + } + defer client.Close() + + name := ctx.Name + + // Allocate port on server + port, err := allocatePort(client, name) + if err != nil { + return output.Err(output.ErrServiceFailed, "failed to allocate port: "+err.Error()) + } + + srcPath := fmt.Sprintf("/var/lib/%s/src", name) + dataPath := fmt.Sprintf("/var/lib/%s/data", name) + + // Create directories + if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s %s", srcPath, dataPath)); err != nil { + return output.Err(output.ErrUploadFailed, "failed to create directories: "+err.Error()) + } + + // Set ownership for upload + user, _ := client.Run("whoami") + user = strings.TrimSpace(user) + if _, err := client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", user, user, srcPath)); err != nil { + return output.Err(output.ErrUploadFailed, "failed to set directory ownership: "+err.Error()) + } + + // Upload source + if err := client.UploadDir(ctx.Path, srcPath); err != nil { + return output.Err(output.ErrUploadFailed, err.Error()) + } + + // Docker build + buildCmd := fmt.Sprintf("docker build -t %s:latest %s", name, srcPath) + if _, err := client.RunSudo(buildCmd); err != nil { + return output.Err(output.ErrBuildFailed, err.Error()) + } + + // Determine container port + containerPort := ctx.Opts.ContainerPort + if containerPort == 0 { + containerPort = 80 + } + + // Generate and write env file + // Use containerPort so the app listens on the correct port inside the container + envContent := fmt.Sprintf("PORT=%d\nSHIP_NAME=%s\nSHIP_URL=%s\n", containerPort, name, ctx.URL) + for _, e := range ctx.Opts.Env { + envContent += e + "\n" + } + envPath := fmt.Sprintf("/etc/ship/env/%s.env", name) + if _, err := client.RunSudo("mkdir -p /etc/ship/env"); err != nil { + // Continue, directory might exist + } + if err := client.WriteSudoFile(envPath, envContent); err != nil { + return output.Err(output.ErrServiceFailed, "failed to write env file: "+err.Error()) + } + + // Generate systemd unit + service, err := templates.DockerService(map[string]string{ + "Name": name, + "Port": strconv.Itoa(port), + "ContainerPort": strconv.Itoa(containerPort), + }) + if err != nil { + return output.Err(output.ErrServiceFailed, "failed to generate systemd unit: "+err.Error()) + } + + servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name) + if err := client.WriteSudoFile(servicePath, service); err != nil { + return output.Err(output.ErrServiceFailed, "failed to write systemd unit: "+err.Error()) + } + + // Generate Caddyfile only if it doesn't exist (preserve manual edits) + caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) + caddyExists, _ := client.Run(fmt.Sprintf("test -f %s && echo exists", caddyPath)) + if strings.TrimSpace(caddyExists) != "exists" { + caddyfile, err := templates.AppCaddy(map[string]string{ + "Domain": ctx.URL[8:], // Strip https:// + "Port": strconv.Itoa(port), + }) + if err != nil { + return output.Err(output.ErrCaddyFailed, "failed to generate Caddyfile: "+err.Error()) + } + + if err := client.WriteSudoFile(caddyPath, caddyfile); err != nil { + return output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error()) + } + } + + // Reload systemd and start service + if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { + return output.Err(output.ErrServiceFailed, "failed to reload systemd: "+err.Error()) + } + if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { + return output.Err(output.ErrServiceFailed, "failed to start service: "+err.Error()) + } + + // Reload Caddy + if _, err := client.RunSudo("systemctl reload caddy"); err != nil { + return output.Err(output.ErrCaddyFailed, "failed to reload Caddy: "+err.Error()) + } + + return nil +} + +// deployBinaryV2 deploys a pre-built binary +// 1. Allocate port +// 2. scp binary to /usr/local/bin/ +// 3. Create user for service +// 4. Generate systemd unit and env file +// 5. Generate Caddyfile +// 6. Start service, reload Caddy +func deployBinaryV2(ctx *deployContext) *output.ErrorResponse { + client, err := ssh.Connect(ctx.SSHHost) + if err != nil { + return output.Err(output.ErrSSHConnectFailed, err.Error()) + } + defer client.Close() + + name := ctx.Name + + // Allocate port on server + port, err := allocatePort(client, name) + if err != nil { + return output.Err(output.ErrServiceFailed, "failed to allocate port: "+err.Error()) + } + + binaryPath := fmt.Sprintf("/usr/local/bin/%s", name) + workDir := fmt.Sprintf("/var/lib/%s", name) + + // Upload binary + if err := client.Upload(ctx.Path, "/tmp/"+name); err != nil { + return output.Err(output.ErrUploadFailed, err.Error()) + } + + // Move to final location and set permissions + if _, err := client.RunSudo(fmt.Sprintf("mv /tmp/%s %s && chmod +x %s", name, binaryPath, binaryPath)); err != nil { + return output.Err(output.ErrUploadFailed, "failed to install binary: "+err.Error()) + } + + // Create work directory + if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", workDir)); err != nil { + return output.Err(output.ErrServiceFailed, "failed to create work directory: "+err.Error()) + } + + // Create service user (ignore error if exists) + client.RunSudo(fmt.Sprintf("useradd -r -s /bin/false %s 2>/dev/null || true", name)) + client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", name, name, workDir)) + + // Generate and write env file + envContent := fmt.Sprintf("PORT=%d\nSHIP_NAME=%s\nSHIP_URL=%s\n", port, name, ctx.URL) + for _, e := range ctx.Opts.Env { + envContent += e + "\n" + } + envPath := fmt.Sprintf("/etc/ship/env/%s.env", name) + if _, err := client.RunSudo("mkdir -p /etc/ship/env"); err != nil { + // Continue + } + if err := client.WriteSudoFile(envPath, envContent); err != nil { + return output.Err(output.ErrServiceFailed, "failed to write env file: "+err.Error()) + } + + // Generate systemd unit + service, err := templates.SystemdService(map[string]string{ + "Name": name, + "User": name, + "WorkDir": workDir, + "EnvFile": envPath, + "BinaryPath": binaryPath, + "Args": "", + }) + if err != nil { + return output.Err(output.ErrServiceFailed, "failed to generate systemd unit: "+err.Error()) + } + + servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name) + if err := client.WriteSudoFile(servicePath, service); err != nil { + return output.Err(output.ErrServiceFailed, "failed to write systemd unit: "+err.Error()) + } + + // Generate Caddyfile only if it doesn't exist (preserve manual edits) + caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) + caddyExists, _ := client.Run(fmt.Sprintf("test -f %s && echo exists", caddyPath)) + if strings.TrimSpace(caddyExists) != "exists" { + caddyfile, err := templates.AppCaddy(map[string]string{ + "Domain": ctx.URL[8:], // Strip https:// + "Port": strconv.Itoa(port), + }) + if err != nil { + return output.Err(output.ErrCaddyFailed, "failed to generate Caddyfile: "+err.Error()) + } + + if err := client.WriteSudoFile(caddyPath, caddyfile); err != nil { + return output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error()) + } + } + + // Reload systemd and start service + if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { + return output.Err(output.ErrServiceFailed, "failed to reload systemd: "+err.Error()) + } + if _, err := client.RunSudo(fmt.Sprintf("systemctl enable --now %s", name)); err != nil { + return output.Err(output.ErrServiceFailed, "failed to start service: "+err.Error()) + } + + // Reload Caddy + if _, err := client.RunSudo("systemctl reload caddy"); err != nil { + return output.Err(output.ErrCaddyFailed, "failed to reload Caddy: "+err.Error()) + } + + return nil +} + +// allocatePort allocates or retrieves a port for a service +// Uses atomic increment on /etc/ship/next_port to avoid collisions +func allocatePort(client *ssh.Client, name string) (int, error) { + portFile := fmt.Sprintf("/etc/ship/ports/%s", name) + + // Try to read existing port for this app + out, err := client.Run(fmt.Sprintf("cat %s 2>/dev/null || echo ''", portFile)) + if err == nil && out != "" { + out = strings.TrimSpace(out) + if port, err := strconv.Atoi(out); err == nil && port > 0 { + return port, nil + } + } + + // Allocate new port atomically using flock + // Scans existing port files to avoid collisions even if next_port is stale + allocScript := `flock -x /etc/ship/.port.lock sh -c 'mkdir -p /etc/ship/ports; NEXT=$(cat /etc/ship/next_port 2>/dev/null || echo 9000); MAX=8999; for f in /etc/ship/ports/*; do [ -f "$f" ] && P=$(cat "$f" 2>/dev/null) && [ "$P" -gt "$MAX" ] 2>/dev/null && MAX=$P; done; PORT=$(( NEXT > MAX ? NEXT : MAX + 1 )); echo $((PORT + 1)) > /etc/ship/next_port; echo $PORT'` + out, err = client.RunSudo(allocScript) + if err != nil { + return 0, fmt.Errorf("failed to allocate port: %w", err) + } + + port, err := strconv.Atoi(strings.TrimSpace(out)) + if err != nil { + return 0, fmt.Errorf("invalid port allocated: %s", out) + } + + // Write port allocation for this app + if err := client.WriteSudoFile(portFile, strconv.Itoa(port)); err != nil { + return 0, err + } + + return port, nil +} + +// setTTLV2 sets auto-expiry for a deploy +func setTTLV2(ctx *deployContext, ttl time.Duration) error { + client, err := ssh.Connect(ctx.SSHHost) + if err != nil { + return err + } + defer client.Close() + + expires := time.Now().Add(ttl).Unix() + ttlPath := fmt.Sprintf("/etc/ship/ttl/%s", ctx.Name) + + if _, err := client.RunSudo("mkdir -p /etc/ship/ttl"); err != nil { + return err + } + + return client.WriteSudoFile(ttlPath, strconv.FormatInt(expires, 10)) +} + +// runHealthCheck verifies the deploy is responding +func runHealthCheck(url, endpoint string) (*output.HealthResult, *output.ErrorResponse) { + fullURL := url + endpoint + + // Wait for app to start + time.Sleep(2 * time.Second) + + var lastErr error + var lastStatus int + + for i := 0; i < 15; i++ { + start := time.Now() + resp, err := http.Get(fullURL) + latency := time.Since(start).Milliseconds() + + if err != nil { + lastErr = err + time.Sleep(2 * time.Second) + continue + } + resp.Body.Close() + lastStatus = resp.StatusCode + + if resp.StatusCode >= 200 && resp.StatusCode < 400 { + return &output.HealthResult{ + Endpoint: endpoint, + Status: resp.StatusCode, + LatencyMs: latency, + }, nil + } + + time.Sleep(2 * time.Second) + } + + msg := fmt.Sprintf("health check failed after 30s: ") + if lastErr != nil { + msg += lastErr.Error() + } else { + msg += fmt.Sprintf("status %d", lastStatus) + } + + return nil, output.Err(output.ErrHealthCheckFailed, msg) +} diff --git a/cmd/ship/deploy_impl_v2.go b/cmd/ship/deploy_impl_v2.go deleted file mode 100644 index bfec9d3..0000000 --- a/cmd/ship/deploy_impl_v2.go +++ /dev/null @@ -1,394 +0,0 @@ -package main - -import ( - "fmt" - "net/http" - "strconv" - "strings" - "time" - - "github.com/bdw/ship/internal/output" - "github.com/bdw/ship/internal/ssh" - "github.com/bdw/ship/internal/templates" -) - -// deployStaticV2 deploys a static site -// 1. rsync path to /var/www// -// 2. Generate and upload Caddyfile -// 3. Reload Caddy -func deployStaticV2(ctx *deployContext) *output.ErrorResponse { - client, err := ssh.Connect(ctx.SSHHost) - if err != nil { - return output.Err(output.ErrSSHConnectFailed, err.Error()) - } - defer client.Close() - - name := ctx.Name - remotePath := fmt.Sprintf("/var/www/%s", name) - - // Create directory and set ownership for upload - user, _ := client.Run("whoami") - user = strings.TrimSpace(user) - if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", remotePath)); err != nil { - return output.Err(output.ErrUploadFailed, "failed to create directory: "+err.Error()) - } - if _, err := client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", user, user, remotePath)); err != nil { - return output.Err(output.ErrUploadFailed, "failed to set directory ownership: "+err.Error()) - } - - // Upload files using rsync - if err := client.UploadDir(ctx.Path, remotePath); err != nil { - return output.Err(output.ErrUploadFailed, err.Error()) - } - - // Set ownership back to www-data - if _, err := client.RunSudo(fmt.Sprintf("chown -R www-data:www-data %s", remotePath)); err != nil { - // Non-fatal, continue - } - - // Generate Caddyfile only if it doesn't exist (preserve manual edits) - caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) - caddyExists, _ := client.Run(fmt.Sprintf("test -f %s && echo exists", caddyPath)) - if strings.TrimSpace(caddyExists) != "exists" { - caddyfile, err := templates.StaticCaddy(map[string]string{ - "Domain": ctx.URL[8:], // Strip https:// - "RootDir": remotePath, - "Name": name, - }) - if err != nil { - return output.Err(output.ErrCaddyFailed, "failed to generate Caddyfile: "+err.Error()) - } - - if err := client.WriteSudoFile(caddyPath, caddyfile); err != nil { - return output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error()) - } - } - - // Reload Caddy - if _, err := client.RunSudo("systemctl reload caddy"); err != nil { - return output.Err(output.ErrCaddyFailed, "failed to reload Caddy: "+err.Error()) - } - - return nil -} - -// deployDockerV2 deploys a Docker-based app -// 1. Allocate port -// 2. rsync path to /var/lib//src/ -// 3. docker build -// 4. Generate systemd unit and env file -// 5. Generate Caddyfile -// 6. Start service, reload Caddy -func deployDockerV2(ctx *deployContext) *output.ErrorResponse { - client, err := ssh.Connect(ctx.SSHHost) - if err != nil { - return output.Err(output.ErrSSHConnectFailed, err.Error()) - } - defer client.Close() - - name := ctx.Name - - // Allocate port on server - port, err := allocatePort(client, name) - if err != nil { - return output.Err(output.ErrServiceFailed, "failed to allocate port: "+err.Error()) - } - - srcPath := fmt.Sprintf("/var/lib/%s/src", name) - dataPath := fmt.Sprintf("/var/lib/%s/data", name) - - // Create directories - if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s %s", srcPath, dataPath)); err != nil { - return output.Err(output.ErrUploadFailed, "failed to create directories: "+err.Error()) - } - - // Set ownership for upload - user, _ := client.Run("whoami") - user = strings.TrimSpace(user) - if _, err := client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", user, user, srcPath)); err != nil { - return output.Err(output.ErrUploadFailed, "failed to set directory ownership: "+err.Error()) - } - - // Upload source - if err := client.UploadDir(ctx.Path, srcPath); err != nil { - return output.Err(output.ErrUploadFailed, err.Error()) - } - - // Docker build - buildCmd := fmt.Sprintf("docker build -t %s:latest %s", name, srcPath) - if _, err := client.RunSudo(buildCmd); err != nil { - return output.Err(output.ErrBuildFailed, err.Error()) - } - - // Determine container port - containerPort := ctx.Opts.ContainerPort - if containerPort == 0 { - containerPort = 80 - } - - // Generate and write env file - // Use containerPort so the app listens on the correct port inside the container - envContent := fmt.Sprintf("PORT=%d\nSHIP_NAME=%s\nSHIP_URL=%s\n", containerPort, name, ctx.URL) - for _, e := range ctx.Opts.Env { - envContent += e + "\n" - } - envPath := fmt.Sprintf("/etc/ship/env/%s.env", name) - if _, err := client.RunSudo("mkdir -p /etc/ship/env"); err != nil { - // Continue, directory might exist - } - if err := client.WriteSudoFile(envPath, envContent); err != nil { - return output.Err(output.ErrServiceFailed, "failed to write env file: "+err.Error()) - } - - // Generate systemd unit - service, err := templates.DockerService(map[string]string{ - "Name": name, - "Port": strconv.Itoa(port), - "ContainerPort": strconv.Itoa(containerPort), - }) - if err != nil { - return output.Err(output.ErrServiceFailed, "failed to generate systemd unit: "+err.Error()) - } - - servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name) - if err := client.WriteSudoFile(servicePath, service); err != nil { - return output.Err(output.ErrServiceFailed, "failed to write systemd unit: "+err.Error()) - } - - // Generate Caddyfile only if it doesn't exist (preserve manual edits) - caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) - caddyExists, _ := client.Run(fmt.Sprintf("test -f %s && echo exists", caddyPath)) - if strings.TrimSpace(caddyExists) != "exists" { - caddyfile, err := templates.AppCaddy(map[string]string{ - "Domain": ctx.URL[8:], // Strip https:// - "Port": strconv.Itoa(port), - }) - if err != nil { - return output.Err(output.ErrCaddyFailed, "failed to generate Caddyfile: "+err.Error()) - } - - if err := client.WriteSudoFile(caddyPath, caddyfile); err != nil { - return output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error()) - } - } - - // Reload systemd and start service - if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { - return output.Err(output.ErrServiceFailed, "failed to reload systemd: "+err.Error()) - } - if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { - return output.Err(output.ErrServiceFailed, "failed to start service: "+err.Error()) - } - - // Reload Caddy - if _, err := client.RunSudo("systemctl reload caddy"); err != nil { - return output.Err(output.ErrCaddyFailed, "failed to reload Caddy: "+err.Error()) - } - - return nil -} - -// deployBinaryV2 deploys a pre-built binary -// 1. Allocate port -// 2. scp binary to /usr/local/bin/ -// 3. Create user for service -// 4. Generate systemd unit and env file -// 5. Generate Caddyfile -// 6. Start service, reload Caddy -func deployBinaryV2(ctx *deployContext) *output.ErrorResponse { - client, err := ssh.Connect(ctx.SSHHost) - if err != nil { - return output.Err(output.ErrSSHConnectFailed, err.Error()) - } - defer client.Close() - - name := ctx.Name - - // Allocate port on server - port, err := allocatePort(client, name) - if err != nil { - return output.Err(output.ErrServiceFailed, "failed to allocate port: "+err.Error()) - } - - binaryPath := fmt.Sprintf("/usr/local/bin/%s", name) - workDir := fmt.Sprintf("/var/lib/%s", name) - - // Upload binary - if err := client.Upload(ctx.Path, "/tmp/"+name); err != nil { - return output.Err(output.ErrUploadFailed, err.Error()) - } - - // Move to final location and set permissions - if _, err := client.RunSudo(fmt.Sprintf("mv /tmp/%s %s && chmod +x %s", name, binaryPath, binaryPath)); err != nil { - return output.Err(output.ErrUploadFailed, "failed to install binary: "+err.Error()) - } - - // Create work directory - if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", workDir)); err != nil { - return output.Err(output.ErrServiceFailed, "failed to create work directory: "+err.Error()) - } - - // Create service user (ignore error if exists) - client.RunSudo(fmt.Sprintf("useradd -r -s /bin/false %s 2>/dev/null || true", name)) - client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", name, name, workDir)) - - // Generate and write env file - envContent := fmt.Sprintf("PORT=%d\nSHIP_NAME=%s\nSHIP_URL=%s\n", port, name, ctx.URL) - for _, e := range ctx.Opts.Env { - envContent += e + "\n" - } - envPath := fmt.Sprintf("/etc/ship/env/%s.env", name) - if _, err := client.RunSudo("mkdir -p /etc/ship/env"); err != nil { - // Continue - } - if err := client.WriteSudoFile(envPath, envContent); err != nil { - return output.Err(output.ErrServiceFailed, "failed to write env file: "+err.Error()) - } - - // Generate systemd unit - service, err := templates.SystemdService(map[string]string{ - "Name": name, - "User": name, - "WorkDir": workDir, - "EnvFile": envPath, - "BinaryPath": binaryPath, - "Args": "", - }) - if err != nil { - return output.Err(output.ErrServiceFailed, "failed to generate systemd unit: "+err.Error()) - } - - servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name) - if err := client.WriteSudoFile(servicePath, service); err != nil { - return output.Err(output.ErrServiceFailed, "failed to write systemd unit: "+err.Error()) - } - - // Generate Caddyfile only if it doesn't exist (preserve manual edits) - caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) - caddyExists, _ := client.Run(fmt.Sprintf("test -f %s && echo exists", caddyPath)) - if strings.TrimSpace(caddyExists) != "exists" { - caddyfile, err := templates.AppCaddy(map[string]string{ - "Domain": ctx.URL[8:], // Strip https:// - "Port": strconv.Itoa(port), - }) - if err != nil { - return output.Err(output.ErrCaddyFailed, "failed to generate Caddyfile: "+err.Error()) - } - - if err := client.WriteSudoFile(caddyPath, caddyfile); err != nil { - return output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error()) - } - } - - // Reload systemd and start service - if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { - return output.Err(output.ErrServiceFailed, "failed to reload systemd: "+err.Error()) - } - if _, err := client.RunSudo(fmt.Sprintf("systemctl enable --now %s", name)); err != nil { - return output.Err(output.ErrServiceFailed, "failed to start service: "+err.Error()) - } - - // Reload Caddy - if _, err := client.RunSudo("systemctl reload caddy"); err != nil { - return output.Err(output.ErrCaddyFailed, "failed to reload Caddy: "+err.Error()) - } - - return nil -} - -// allocatePort allocates or retrieves a port for a service -// Uses atomic increment on /etc/ship/next_port to avoid collisions -func allocatePort(client *ssh.Client, name string) (int, error) { - portFile := fmt.Sprintf("/etc/ship/ports/%s", name) - - // Try to read existing port for this app - out, err := client.Run(fmt.Sprintf("cat %s 2>/dev/null || echo ''", portFile)) - if err == nil && out != "" { - out = strings.TrimSpace(out) - if port, err := strconv.Atoi(out); err == nil && port > 0 { - return port, nil - } - } - - // Allocate new port atomically using flock - // Scans existing port files to avoid collisions even if next_port is stale - allocScript := `flock -x /etc/ship/.port.lock sh -c 'mkdir -p /etc/ship/ports; NEXT=$(cat /etc/ship/next_port 2>/dev/null || echo 9000); MAX=8999; for f in /etc/ship/ports/*; do [ -f "$f" ] && P=$(cat "$f" 2>/dev/null) && [ "$P" -gt "$MAX" ] 2>/dev/null && MAX=$P; done; PORT=$(( NEXT > MAX ? NEXT : MAX + 1 )); echo $((PORT + 1)) > /etc/ship/next_port; echo $PORT'` - out, err = client.RunSudo(allocScript) - if err != nil { - return 0, fmt.Errorf("failed to allocate port: %w", err) - } - - port, err := strconv.Atoi(strings.TrimSpace(out)) - if err != nil { - return 0, fmt.Errorf("invalid port allocated: %s", out) - } - - // Write port allocation for this app - if err := client.WriteSudoFile(portFile, strconv.Itoa(port)); err != nil { - return 0, err - } - - return port, nil -} - -// setTTLV2 sets auto-expiry for a deploy -func setTTLV2(ctx *deployContext, ttl time.Duration) error { - client, err := ssh.Connect(ctx.SSHHost) - if err != nil { - return err - } - defer client.Close() - - expires := time.Now().Add(ttl).Unix() - ttlPath := fmt.Sprintf("/etc/ship/ttl/%s", ctx.Name) - - if _, err := client.RunSudo("mkdir -p /etc/ship/ttl"); err != nil { - return err - } - - return client.WriteSudoFile(ttlPath, strconv.FormatInt(expires, 10)) -} - -// runHealthCheck verifies the deploy is responding -func runHealthCheck(url, endpoint string) (*output.HealthResult, *output.ErrorResponse) { - fullURL := url + endpoint - - // Wait for app to start - time.Sleep(2 * time.Second) - - var lastErr error - var lastStatus int - - for i := 0; i < 15; i++ { - start := time.Now() - resp, err := http.Get(fullURL) - latency := time.Since(start).Milliseconds() - - if err != nil { - lastErr = err - time.Sleep(2 * time.Second) - continue - } - resp.Body.Close() - lastStatus = resp.StatusCode - - if resp.StatusCode >= 200 && resp.StatusCode < 400 { - return &output.HealthResult{ - Endpoint: endpoint, - Status: resp.StatusCode, - LatencyMs: latency, - }, nil - } - - time.Sleep(2 * time.Second) - } - - msg := fmt.Sprintf("health check failed after 30s: ") - if lastErr != nil { - msg += lastErr.Error() - } else { - msg += fmt.Sprintf("status %d", lastStatus) - } - - return nil, output.Err(output.ErrHealthCheckFailed, msg) -} diff --git a/cmd/ship/deploy_v2.go b/cmd/ship/deploy_v2.go deleted file mode 100644 index 7d498b2..0000000 --- a/cmd/ship/deploy_v2.go +++ /dev/null @@ -1,210 +0,0 @@ -package main - -import ( - "crypto/rand" - "encoding/hex" - "fmt" - "regexp" - "strings" - "time" - - "github.com/bdw/ship/internal/detect" - "github.com/bdw/ship/internal/output" - "github.com/bdw/ship/internal/state" -) - -// deployV2 implements the new agent-first deploy interface. -// Usage: ship [PATH] [FLAGS] -// PATH defaults to "." if not provided. -func deployV2(path string, opts deployV2Options) { - start := time.Now() - - // Validate name if provided - if opts.Name != "" { - if err := validateNameV2(opts.Name); err != nil { - output.PrintAndExit(err) - } - } - - // Parse TTL if provided - var ttlDuration time.Duration - if opts.TTL != "" { - var err error - ttlDuration, err = parseTTL(opts.TTL) - if err != nil { - output.PrintAndExit(output.Err(output.ErrInvalidTTL, err.Error())) - } - } - - // Get host configuration - st, err := state.Load() - if err != nil { - output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "failed to load state: "+err.Error())) - } - - hostName := opts.Host - if hostName == "" { - hostName = st.DefaultHost - } - if hostName == "" { - output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified and no default host configured. Run: ship host init")) - } - - hostConfig := st.GetHost(hostName) - if hostConfig.BaseDomain == "" { - output.PrintAndExit(output.Err(output.ErrHostNotConfigured, fmt.Sprintf("host %q has no base domain configured. Run: ship host init", hostName))) - } - - // Auto-detect project type - result := detect.Detect(path) - if result.Error != nil { - output.PrintAndExit(result.Error) - } - - // Generate name if not provided - name := opts.Name - if name == "" { - name = generateName() - } - - // Build URL: use custom domain if provided, otherwise use subdomain - var url string - if opts.Domain != "" { - url = fmt.Sprintf("https://%s", opts.Domain) - } else { - url = fmt.Sprintf("https://%s.%s", name, hostConfig.BaseDomain) - } - - // Build deploy context - ctx := &deployContext{ - SSHHost: hostName, - HostConfig: hostConfig, - Name: name, - Path: result.Path, - URL: url, - Opts: opts, - } - - // Deploy based on type - var deployErr *output.ErrorResponse - switch result.Type { - case detect.TypeStatic: - deployErr = deployStaticV2(ctx) - case detect.TypeDocker: - deployErr = deployDockerV2(ctx) - case detect.TypeBinary: - deployErr = deployBinaryV2(ctx) - } - - if deployErr != nil { - deployErr.Name = name - deployErr.URL = url - output.PrintAndExit(deployErr) - } - - // Set TTL if specified - if ttlDuration > 0 { - if err := setTTLV2(ctx, ttlDuration); err != nil { - // Non-fatal, deploy succeeded - // TODO: log warning - } - } - - // Health check - var healthResult *output.HealthResult - if opts.Health != "" || result.Type == detect.TypeStatic { - endpoint := opts.Health - if endpoint == "" { - endpoint = "/" - } - healthResult, deployErr = runHealthCheck(url, endpoint) - if deployErr != nil { - deployErr.Name = name - deployErr.URL = url - output.PrintAndExit(deployErr) - } - } - - // Build response - resp := &output.DeployResponse{ - Status: "ok", - Name: name, - URL: url, - Type: string(result.Type), - TookMs: time.Since(start).Milliseconds(), - Health: healthResult, - } - - if ttlDuration > 0 { - resp.Expires = time.Now().Add(ttlDuration).UTC().Format(time.RFC3339) - } - - output.PrintAndExit(resp) -} - -type deployV2Options struct { - Name string - Host string - Domain string - Health string - TTL string - Env []string - EnvFile string - ContainerPort int // Port the container listens on (default 80 for Docker) - Pretty bool -} - -// deployContext holds all info needed for a deploy -type deployContext struct { - SSHHost string // SSH connection string (config alias or user@host) - HostConfig *state.Host // Host configuration - Name string // Deploy name - Path string // Local path to deploy - URL string // Full URL after deploy - Opts deployV2Options -} - -// validateNameV2 checks if name matches allowed pattern -func validateNameV2(name string) *output.ErrorResponse { - // Must be lowercase alphanumeric with hyphens, 1-63 chars - pattern := regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$|^[a-z0-9]$`) - if !pattern.MatchString(name) { - return output.Err(output.ErrInvalidName, - "name must be lowercase alphanumeric with hyphens, 1-63 characters") - } - return nil -} - -// generateName creates a random deploy name -func generateName() string { - bytes := make([]byte, 3) - rand.Read(bytes) - return "ship-" + hex.EncodeToString(bytes) -} - -// parseTTL converts duration strings like "1h", "7d" to time.Duration -func parseTTL(s string) (time.Duration, error) { - s = strings.TrimSpace(s) - if s == "" { - return 0, nil - } - - // Handle days specially (not supported by time.ParseDuration) - if strings.HasSuffix(s, "d") { - days := strings.TrimSuffix(s, "d") - var d int - _, err := fmt.Sscanf(days, "%d", &d) - if err != nil { - return 0, fmt.Errorf("invalid TTL: %s", s) - } - return time.Duration(d) * 24 * time.Hour, nil - } - - d, err := time.ParseDuration(s) - if err != nil { - return 0, fmt.Errorf("invalid TTL: %s", s) - } - return d, nil -} - -// Deploy implementations are in deploy_impl_v2.go diff --git a/cmd/ship/host.go b/cmd/ship/host.go new file mode 100644 index 0000000..b19c376 --- /dev/null +++ b/cmd/ship/host.go @@ -0,0 +1,445 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/bdw/ship/internal/output" + "github.com/bdw/ship/internal/ssh" + "github.com/bdw/ship/internal/state" + "github.com/bdw/ship/internal/templates" + "github.com/spf13/cobra" +) + +func initHostV2() { + hostInitV2Cmd.Flags().String("domain", "", "Base domain for deployments (required)") + hostInitV2Cmd.MarkFlagRequired("domain") + + hostV2Cmd.AddCommand(hostInitV2Cmd) + hostV2Cmd.AddCommand(hostStatusV2Cmd) +} + +var hostInitV2Cmd = &cobra.Command{ + Use: "init USER@HOST --domain DOMAIN", + Short: "Initialize a VPS for deployments", + Long: `Set up a fresh VPS with Caddy, Docker, and required directories. + +Example: + ship host init user@my-vps --domain example.com`, + Args: cobra.ExactArgs(1), + RunE: runHostInitV2, +} + +func runHostInitV2(cmd *cobra.Command, args []string) error { + host := args[0] + domain, _ := cmd.Flags().GetString("domain") + + if domain == "" { + output.PrintAndExit(output.Err(output.ErrInvalidArgs, "--domain is required")) + } + + // Ensure SSH key exists + keyPath, pubkey, err := ensureSSHKey() + if err != nil { + output.PrintAndExit(output.Err(output.ErrSSHAuthFailed, "failed to setup SSH key: "+err.Error())) + } + + // Try to connect first (to verify key is authorized) + client, err := ssh.Connect(host) + if err != nil { + // Connection failed - provide helpful error with pubkey + resp := map[string]interface{}{ + "status": "error", + "code": "SSH_AUTH_FAILED", + "message": "SSH connection failed. Add this public key to your VPS authorized_keys:", + "public_key": pubkey, + "key_path": keyPath, + "host": host, + "setup_command": fmt.Sprintf("ssh-copy-id -i %s %s", keyPath, host), + } + printJSON(resp) + os.Exit(output.ExitSSHFailed) + } + defer client.Close() + + // Detect OS + osRelease, err := client.Run("cat /etc/os-release") + if err != nil { + output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, "failed to detect OS: "+err.Error())) + } + + if !strings.Contains(osRelease, "Ubuntu") && !strings.Contains(osRelease, "Debian") { + output.PrintAndExit(output.Err(output.ErrInvalidArgs, "unsupported OS (only Ubuntu and Debian are supported)")) + } + + var installed []string + + // Install Caddy if needed + if _, err := client.Run("which caddy"); err != nil { + if err := installCaddyV2(client); err != nil { + output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to install Caddy: "+err.Error())) + } + installed = append(installed, "caddy") + } + + // Configure Caddy + caddyfile := `{ +} + +import /etc/caddy/sites-enabled/* +` + if err := client.WriteSudoFile("/etc/caddy/Caddyfile", caddyfile); err != nil { + output.PrintAndExit(output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error())) + } + + // Create directories + dirs := []string{ + "/etc/ship/env", + "/etc/ship/ports", + "/etc/ship/ttl", + "/etc/caddy/sites-enabled", + "/var/www", + } + for _, dir := range dirs { + if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", dir)); err != nil { + output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to create directory: "+err.Error())) + } + } + + // Install Docker + if _, err := client.Run("which docker"); err != nil { + if err := installDockerV2(client); err != nil { + output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to install Docker: "+err.Error())) + } + installed = append(installed, "docker") + } + + // Install cleanup timer for TTL + if err := installCleanupTimer(client); err != nil { + // Non-fatal + } + + // Enable and start services + if _, err := client.RunSudo("systemctl enable --now caddy docker"); err != nil { + output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to enable services: "+err.Error())) + } + + // Save state + st, err := state.Load() + if err != nil { + output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to load state: "+err.Error())) + } + + hostState := st.GetHost(host) + hostState.BaseDomain = domain + + if st.GetDefaultHost() == "" { + st.SetDefaultHost(host) + } + + if err := st.Save(); err != nil { + output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to save state: "+err.Error())) + } + + // Success + output.PrintAndExit(&output.HostInitResponse{ + Status: "ok", + Host: host, + Domain: domain, + Installed: installed, + }) + + return nil +} + +func installCaddyV2(client *ssh.Client) error { + commands := []string{ + "apt-get update", + "apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl gnupg", + "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' -o /tmp/caddy.gpg", + "gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg < /tmp/caddy.gpg", + "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' -o /etc/apt/sources.list.d/caddy-stable.list", + "apt-get update", + "apt-get install -y caddy", + } + + for _, cmd := range commands { + if _, err := client.RunSudo(cmd); err != nil { + return fmt.Errorf("command failed: %s: %w", cmd, err) + } + } + return nil +} + +func installDockerV2(client *ssh.Client) error { + commands := []string{ + "apt-get install -y ca-certificates curl gnupg", + "install -m 0755 -d /etc/apt/keyrings", + "curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc", + "chmod a+r /etc/apt/keyrings/docker.asc", + `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'`, + "apt-get update", + "apt-get install -y docker-ce docker-ce-cli containerd.io", + } + + for _, cmd := range commands { + if _, err := client.RunSudo(cmd); err != nil { + return fmt.Errorf("command failed: %s: %w", cmd, err) + } + } + return nil +} + +func installCleanupTimer(client *ssh.Client) error { + // Cleanup script + script := `#!/bin/bash +now=$(date +%s) +for f in /etc/ship/ttl/*; do + [ -f "$f" ] || continue + name=$(basename "$f") + expires=$(cat "$f") + if [ "$now" -gt "$expires" ]; then + systemctl stop "$name" 2>/dev/null || true + systemctl disable "$name" 2>/dev/null || true + rm -f "/etc/systemd/system/${name}.service" + rm -f "/etc/caddy/sites-enabled/${name}.caddy" + rm -rf "/var/www/${name}" + rm -rf "/var/lib/${name}" + rm -f "/usr/local/bin/${name}" + rm -f "/etc/ship/env/${name}.env" + rm -f "/etc/ship/ports/${name}" + rm -f "/etc/ship/ttl/${name}" + docker rm -f "$name" 2>/dev/null || true + docker rmi "$name" 2>/dev/null || true + fi +done +systemctl daemon-reload +systemctl reload caddy +` + if err := client.WriteSudoFile("/usr/local/bin/ship-cleanup", script); err != nil { + return err + } + if _, err := client.RunSudo("chmod +x /usr/local/bin/ship-cleanup"); err != nil { + return err + } + + // Timer unit + timer := `[Unit] +Description=Ship TTL cleanup timer + +[Timer] +OnCalendar=hourly +Persistent=true + +[Install] +WantedBy=timers.target +` + if err := client.WriteSudoFile("/etc/systemd/system/ship-cleanup.timer", timer); err != nil { + return err + } + + // Service unit + service := `[Unit] +Description=Ship TTL cleanup + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/ship-cleanup +` + if err := client.WriteSudoFile("/etc/systemd/system/ship-cleanup.service", service); err != nil { + return err + } + + // Enable timer + if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { + return err + } + if _, err := client.RunSudo("systemctl enable --now ship-cleanup.timer"); err != nil { + return err + } + + return nil +} + +var hostStatusV2Cmd = &cobra.Command{ + Use: "status", + Short: "Check host status", + RunE: func(cmd *cobra.Command, args []string) error { + st, err := state.Load() + if err != nil { + output.PrintAndExit(output.Err(output.ErrHostNotConfigured, err.Error())) + } + + hostName := hostFlag + if hostName == "" { + hostName = st.DefaultHost + } + if hostName == "" { + output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified")) + } + + hostConfig := st.GetHost(hostName) + + client, err := ssh.Connect(hostName) + if err != nil { + output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error())) + } + defer client.Close() + + // Check services + caddyStatus, _ := client.RunSudo("systemctl is-active caddy") + dockerStatus, _ := client.RunSudo("systemctl is-active docker") + + // Print as JSON directly (custom response type) + fmt.Printf(`{"status":"ok","host":%q,"domain":%q,"caddy":%t,"docker":%t}`+"\n", + hostName, + hostConfig.BaseDomain, + strings.TrimSpace(caddyStatus) == "active", + strings.TrimSpace(dockerStatus) == "active", + ) + return nil + }, +} + +// Preserve git setup functionality from v1 for advanced users +func setupGitDeployV2(client *ssh.Client, baseDomain string, hostState *state.Host) *output.ErrorResponse { + // Install git, fcgiwrap, cgit + if _, err := client.RunSudo("apt-get install -y git fcgiwrap cgit"); err != nil { + return output.Err(output.ErrServiceFailed, "failed to install git tools: "+err.Error()) + } + + // Create git user + client.RunSudo("useradd -r -m -d /home/git -s $(which git-shell) git") + client.RunSudo("usermod -aG docker git") + client.RunSudo("usermod -aG git www-data") + client.RunSudo("usermod -aG www-data caddy") + + // Copy SSH keys + copyKeysCommands := []string{ + "mkdir -p /home/git/.ssh", + "cp ~/.ssh/authorized_keys /home/git/.ssh/authorized_keys", + "chown -R git:git /home/git/.ssh", + "chmod 700 /home/git/.ssh", + "chmod 600 /home/git/.ssh/authorized_keys", + } + for _, cmd := range copyKeysCommands { + if _, err := client.RunSudo(cmd); err != nil { + return output.Err(output.ErrServiceFailed, "failed to setup git SSH: "+err.Error()) + } + } + + // Create /srv/git + client.RunSudo("mkdir -p /srv/git") + client.RunSudo("chown git:git /srv/git") + + // Sudoers + sudoersContent := `git ALL=(ALL) NOPASSWD: \ + /bin/systemctl daemon-reload, \ + /bin/systemctl reload caddy, \ + /bin/systemctl restart [a-z]*, \ + /bin/systemctl enable [a-z]*, \ + /bin/cp /var/lib/*/src/.ship/service /etc/systemd/system/*.service, \ + /bin/cp /var/lib/*/src/.ship/Caddyfile /etc/caddy/sites-enabled/*.caddy, \ + /bin/cp /var/www/*/.ship/Caddyfile /etc/caddy/sites-enabled/*.caddy, \ + /bin/mkdir -p /var/lib/*, \ + /bin/mkdir -p /var/www/*, \ + /bin/chown -R git\:git /var/lib/*, \ + /bin/chown git\:git /var/www/* +` + if err := client.WriteSudoFile("/etc/sudoers.d/ship-git", sudoersContent); err != nil { + return output.Err(output.ErrServiceFailed, "failed to write sudoers: "+err.Error()) + } + client.RunSudo("chmod 440 /etc/sudoers.d/ship-git") + + // Vanity import template + vanityHTML := ` + +{{$path := trimPrefix "/" (placeholder "http.request.orig_uri.path")}} +{{$parts := splitList "/" $path}} +{{$module := first $parts}} + + +go get {{.Host}}/{{$module}} + +` + client.RunSudo("mkdir -p /opt/ship/vanity") + client.WriteSudoFile("/opt/ship/vanity/index.html", vanityHTML) + + // cgit config + codeCaddyContent, _ := templates.CodeCaddy(map[string]string{"BaseDomain": baseDomain}) + client.WriteSudoFile("/etc/caddy/sites-enabled/ship-code.caddy", codeCaddyContent) + + cgitrcContent, _ := templates.CgitRC(map[string]string{"BaseDomain": baseDomain}) + client.WriteSudoFile("/etc/cgitrc", cgitrcContent) + client.WriteSudoFile("/opt/ship/cgit-header.html", templates.CgitHeader()) + + // Start services + client.RunSudo("systemctl enable --now fcgiwrap") + client.RunSudo("systemctl restart caddy") + + hostState.GitSetup = true + return nil +} + +// ensureSSHKey checks for an existing SSH key or generates a new one. +// Returns the key path, public key contents, and any error. +func ensureSSHKey() (keyPath string, pubkey string, err error) { + home, err := os.UserHomeDir() + if err != nil { + return "", "", err + } + + // Check common key locations + keyPaths := []string{ + filepath.Join(home, ".ssh", "id_ed25519"), + filepath.Join(home, ".ssh", "id_rsa"), + filepath.Join(home, ".ssh", "id_ecdsa"), + } + + for _, kp := range keyPaths { + pubPath := kp + ".pub" + if _, err := os.Stat(kp); err == nil { + if _, err := os.Stat(pubPath); err == nil { + // Key exists, read public key + pub, err := os.ReadFile(pubPath) + if err != nil { + continue + } + return kp, strings.TrimSpace(string(pub)), nil + } + } + } + + // No key found, generate one + keyPath = filepath.Join(home, ".ssh", "id_ed25519") + sshDir := filepath.Dir(keyPath) + + // Ensure .ssh directory exists + if err := os.MkdirAll(sshDir, 0700); err != nil { + return "", "", fmt.Errorf("failed to create .ssh directory: %w", err) + } + + // Generate key + cmd := exec.Command("ssh-keygen", "-t", "ed25519", "-f", keyPath, "-N", "", "-C", "ship") + if err := cmd.Run(); err != nil { + return "", "", fmt.Errorf("failed to generate SSH key: %w", err) + } + + // Read public key + pub, err := os.ReadFile(keyPath + ".pub") + if err != nil { + return "", "", fmt.Errorf("failed to read public key: %w", err) + } + + return keyPath, strings.TrimSpace(string(pub)), nil +} + +// printJSON outputs a value as JSON to stdout +func printJSON(v interface{}) { + enc := json.NewEncoder(os.Stdout) + enc.Encode(v) +} diff --git a/cmd/ship/host_v2.go b/cmd/ship/host_v2.go deleted file mode 100644 index b19c376..0000000 --- a/cmd/ship/host_v2.go +++ /dev/null @@ -1,445 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - - "github.com/bdw/ship/internal/output" - "github.com/bdw/ship/internal/ssh" - "github.com/bdw/ship/internal/state" - "github.com/bdw/ship/internal/templates" - "github.com/spf13/cobra" -) - -func initHostV2() { - hostInitV2Cmd.Flags().String("domain", "", "Base domain for deployments (required)") - hostInitV2Cmd.MarkFlagRequired("domain") - - hostV2Cmd.AddCommand(hostInitV2Cmd) - hostV2Cmd.AddCommand(hostStatusV2Cmd) -} - -var hostInitV2Cmd = &cobra.Command{ - Use: "init USER@HOST --domain DOMAIN", - Short: "Initialize a VPS for deployments", - Long: `Set up a fresh VPS with Caddy, Docker, and required directories. - -Example: - ship host init user@my-vps --domain example.com`, - Args: cobra.ExactArgs(1), - RunE: runHostInitV2, -} - -func runHostInitV2(cmd *cobra.Command, args []string) error { - host := args[0] - domain, _ := cmd.Flags().GetString("domain") - - if domain == "" { - output.PrintAndExit(output.Err(output.ErrInvalidArgs, "--domain is required")) - } - - // Ensure SSH key exists - keyPath, pubkey, err := ensureSSHKey() - if err != nil { - output.PrintAndExit(output.Err(output.ErrSSHAuthFailed, "failed to setup SSH key: "+err.Error())) - } - - // Try to connect first (to verify key is authorized) - client, err := ssh.Connect(host) - if err != nil { - // Connection failed - provide helpful error with pubkey - resp := map[string]interface{}{ - "status": "error", - "code": "SSH_AUTH_FAILED", - "message": "SSH connection failed. Add this public key to your VPS authorized_keys:", - "public_key": pubkey, - "key_path": keyPath, - "host": host, - "setup_command": fmt.Sprintf("ssh-copy-id -i %s %s", keyPath, host), - } - printJSON(resp) - os.Exit(output.ExitSSHFailed) - } - defer client.Close() - - // Detect OS - osRelease, err := client.Run("cat /etc/os-release") - if err != nil { - output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, "failed to detect OS: "+err.Error())) - } - - if !strings.Contains(osRelease, "Ubuntu") && !strings.Contains(osRelease, "Debian") { - output.PrintAndExit(output.Err(output.ErrInvalidArgs, "unsupported OS (only Ubuntu and Debian are supported)")) - } - - var installed []string - - // Install Caddy if needed - if _, err := client.Run("which caddy"); err != nil { - if err := installCaddyV2(client); err != nil { - output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to install Caddy: "+err.Error())) - } - installed = append(installed, "caddy") - } - - // Configure Caddy - caddyfile := `{ -} - -import /etc/caddy/sites-enabled/* -` - if err := client.WriteSudoFile("/etc/caddy/Caddyfile", caddyfile); err != nil { - output.PrintAndExit(output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error())) - } - - // Create directories - dirs := []string{ - "/etc/ship/env", - "/etc/ship/ports", - "/etc/ship/ttl", - "/etc/caddy/sites-enabled", - "/var/www", - } - for _, dir := range dirs { - if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", dir)); err != nil { - output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to create directory: "+err.Error())) - } - } - - // Install Docker - if _, err := client.Run("which docker"); err != nil { - if err := installDockerV2(client); err != nil { - output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to install Docker: "+err.Error())) - } - installed = append(installed, "docker") - } - - // Install cleanup timer for TTL - if err := installCleanupTimer(client); err != nil { - // Non-fatal - } - - // Enable and start services - if _, err := client.RunSudo("systemctl enable --now caddy docker"); err != nil { - output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to enable services: "+err.Error())) - } - - // Save state - st, err := state.Load() - if err != nil { - output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to load state: "+err.Error())) - } - - hostState := st.GetHost(host) - hostState.BaseDomain = domain - - if st.GetDefaultHost() == "" { - st.SetDefaultHost(host) - } - - if err := st.Save(); err != nil { - output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to save state: "+err.Error())) - } - - // Success - output.PrintAndExit(&output.HostInitResponse{ - Status: "ok", - Host: host, - Domain: domain, - Installed: installed, - }) - - return nil -} - -func installCaddyV2(client *ssh.Client) error { - commands := []string{ - "apt-get update", - "apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl gnupg", - "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' -o /tmp/caddy.gpg", - "gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg < /tmp/caddy.gpg", - "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' -o /etc/apt/sources.list.d/caddy-stable.list", - "apt-get update", - "apt-get install -y caddy", - } - - for _, cmd := range commands { - if _, err := client.RunSudo(cmd); err != nil { - return fmt.Errorf("command failed: %s: %w", cmd, err) - } - } - return nil -} - -func installDockerV2(client *ssh.Client) error { - commands := []string{ - "apt-get install -y ca-certificates curl gnupg", - "install -m 0755 -d /etc/apt/keyrings", - "curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc", - "chmod a+r /etc/apt/keyrings/docker.asc", - `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'`, - "apt-get update", - "apt-get install -y docker-ce docker-ce-cli containerd.io", - } - - for _, cmd := range commands { - if _, err := client.RunSudo(cmd); err != nil { - return fmt.Errorf("command failed: %s: %w", cmd, err) - } - } - return nil -} - -func installCleanupTimer(client *ssh.Client) error { - // Cleanup script - script := `#!/bin/bash -now=$(date +%s) -for f in /etc/ship/ttl/*; do - [ -f "$f" ] || continue - name=$(basename "$f") - expires=$(cat "$f") - if [ "$now" -gt "$expires" ]; then - systemctl stop "$name" 2>/dev/null || true - systemctl disable "$name" 2>/dev/null || true - rm -f "/etc/systemd/system/${name}.service" - rm -f "/etc/caddy/sites-enabled/${name}.caddy" - rm -rf "/var/www/${name}" - rm -rf "/var/lib/${name}" - rm -f "/usr/local/bin/${name}" - rm -f "/etc/ship/env/${name}.env" - rm -f "/etc/ship/ports/${name}" - rm -f "/etc/ship/ttl/${name}" - docker rm -f "$name" 2>/dev/null || true - docker rmi "$name" 2>/dev/null || true - fi -done -systemctl daemon-reload -systemctl reload caddy -` - if err := client.WriteSudoFile("/usr/local/bin/ship-cleanup", script); err != nil { - return err - } - if _, err := client.RunSudo("chmod +x /usr/local/bin/ship-cleanup"); err != nil { - return err - } - - // Timer unit - timer := `[Unit] -Description=Ship TTL cleanup timer - -[Timer] -OnCalendar=hourly -Persistent=true - -[Install] -WantedBy=timers.target -` - if err := client.WriteSudoFile("/etc/systemd/system/ship-cleanup.timer", timer); err != nil { - return err - } - - // Service unit - service := `[Unit] -Description=Ship TTL cleanup - -[Service] -Type=oneshot -ExecStart=/usr/local/bin/ship-cleanup -` - if err := client.WriteSudoFile("/etc/systemd/system/ship-cleanup.service", service); err != nil { - return err - } - - // Enable timer - if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { - return err - } - if _, err := client.RunSudo("systemctl enable --now ship-cleanup.timer"); err != nil { - return err - } - - return nil -} - -var hostStatusV2Cmd = &cobra.Command{ - Use: "status", - Short: "Check host status", - RunE: func(cmd *cobra.Command, args []string) error { - st, err := state.Load() - if err != nil { - output.PrintAndExit(output.Err(output.ErrHostNotConfigured, err.Error())) - } - - hostName := hostFlag - if hostName == "" { - hostName = st.DefaultHost - } - if hostName == "" { - output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified")) - } - - hostConfig := st.GetHost(hostName) - - client, err := ssh.Connect(hostName) - if err != nil { - output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error())) - } - defer client.Close() - - // Check services - caddyStatus, _ := client.RunSudo("systemctl is-active caddy") - dockerStatus, _ := client.RunSudo("systemctl is-active docker") - - // Print as JSON directly (custom response type) - fmt.Printf(`{"status":"ok","host":%q,"domain":%q,"caddy":%t,"docker":%t}`+"\n", - hostName, - hostConfig.BaseDomain, - strings.TrimSpace(caddyStatus) == "active", - strings.TrimSpace(dockerStatus) == "active", - ) - return nil - }, -} - -// Preserve git setup functionality from v1 for advanced users -func setupGitDeployV2(client *ssh.Client, baseDomain string, hostState *state.Host) *output.ErrorResponse { - // Install git, fcgiwrap, cgit - if _, err := client.RunSudo("apt-get install -y git fcgiwrap cgit"); err != nil { - return output.Err(output.ErrServiceFailed, "failed to install git tools: "+err.Error()) - } - - // Create git user - client.RunSudo("useradd -r -m -d /home/git -s $(which git-shell) git") - client.RunSudo("usermod -aG docker git") - client.RunSudo("usermod -aG git www-data") - client.RunSudo("usermod -aG www-data caddy") - - // Copy SSH keys - copyKeysCommands := []string{ - "mkdir -p /home/git/.ssh", - "cp ~/.ssh/authorized_keys /home/git/.ssh/authorized_keys", - "chown -R git:git /home/git/.ssh", - "chmod 700 /home/git/.ssh", - "chmod 600 /home/git/.ssh/authorized_keys", - } - for _, cmd := range copyKeysCommands { - if _, err := client.RunSudo(cmd); err != nil { - return output.Err(output.ErrServiceFailed, "failed to setup git SSH: "+err.Error()) - } - } - - // Create /srv/git - client.RunSudo("mkdir -p /srv/git") - client.RunSudo("chown git:git /srv/git") - - // Sudoers - sudoersContent := `git ALL=(ALL) NOPASSWD: \ - /bin/systemctl daemon-reload, \ - /bin/systemctl reload caddy, \ - /bin/systemctl restart [a-z]*, \ - /bin/systemctl enable [a-z]*, \ - /bin/cp /var/lib/*/src/.ship/service /etc/systemd/system/*.service, \ - /bin/cp /var/lib/*/src/.ship/Caddyfile /etc/caddy/sites-enabled/*.caddy, \ - /bin/cp /var/www/*/.ship/Caddyfile /etc/caddy/sites-enabled/*.caddy, \ - /bin/mkdir -p /var/lib/*, \ - /bin/mkdir -p /var/www/*, \ - /bin/chown -R git\:git /var/lib/*, \ - /bin/chown git\:git /var/www/* -` - if err := client.WriteSudoFile("/etc/sudoers.d/ship-git", sudoersContent); err != nil { - return output.Err(output.ErrServiceFailed, "failed to write sudoers: "+err.Error()) - } - client.RunSudo("chmod 440 /etc/sudoers.d/ship-git") - - // Vanity import template - vanityHTML := ` - -{{$path := trimPrefix "/" (placeholder "http.request.orig_uri.path")}} -{{$parts := splitList "/" $path}} -{{$module := first $parts}} - - -go get {{.Host}}/{{$module}} - -` - client.RunSudo("mkdir -p /opt/ship/vanity") - client.WriteSudoFile("/opt/ship/vanity/index.html", vanityHTML) - - // cgit config - codeCaddyContent, _ := templates.CodeCaddy(map[string]string{"BaseDomain": baseDomain}) - client.WriteSudoFile("/etc/caddy/sites-enabled/ship-code.caddy", codeCaddyContent) - - cgitrcContent, _ := templates.CgitRC(map[string]string{"BaseDomain": baseDomain}) - client.WriteSudoFile("/etc/cgitrc", cgitrcContent) - client.WriteSudoFile("/opt/ship/cgit-header.html", templates.CgitHeader()) - - // Start services - client.RunSudo("systemctl enable --now fcgiwrap") - client.RunSudo("systemctl restart caddy") - - hostState.GitSetup = true - return nil -} - -// ensureSSHKey checks for an existing SSH key or generates a new one. -// Returns the key path, public key contents, and any error. -func ensureSSHKey() (keyPath string, pubkey string, err error) { - home, err := os.UserHomeDir() - if err != nil { - return "", "", err - } - - // Check common key locations - keyPaths := []string{ - filepath.Join(home, ".ssh", "id_ed25519"), - filepath.Join(home, ".ssh", "id_rsa"), - filepath.Join(home, ".ssh", "id_ecdsa"), - } - - for _, kp := range keyPaths { - pubPath := kp + ".pub" - if _, err := os.Stat(kp); err == nil { - if _, err := os.Stat(pubPath); err == nil { - // Key exists, read public key - pub, err := os.ReadFile(pubPath) - if err != nil { - continue - } - return kp, strings.TrimSpace(string(pub)), nil - } - } - } - - // No key found, generate one - keyPath = filepath.Join(home, ".ssh", "id_ed25519") - sshDir := filepath.Dir(keyPath) - - // Ensure .ssh directory exists - if err := os.MkdirAll(sshDir, 0700); err != nil { - return "", "", fmt.Errorf("failed to create .ssh directory: %w", err) - } - - // Generate key - cmd := exec.Command("ssh-keygen", "-t", "ed25519", "-f", keyPath, "-N", "", "-C", "ship") - if err := cmd.Run(); err != nil { - return "", "", fmt.Errorf("failed to generate SSH key: %w", err) - } - - // Read public key - pub, err := os.ReadFile(keyPath + ".pub") - if err != nil { - return "", "", fmt.Errorf("failed to read public key: %w", err) - } - - return keyPath, strings.TrimSpace(string(pub)), nil -} - -// printJSON outputs a value as JSON to stdout -func printJSON(v interface{}) { - enc := json.NewEncoder(os.Stdout) - enc.Encode(v) -} diff --git a/cmd/ship/root.go b/cmd/ship/root.go new file mode 100644 index 0000000..aa81d1e --- /dev/null +++ b/cmd/ship/root.go @@ -0,0 +1,98 @@ +package main + +import ( + "os" + + "github.com/bdw/ship/internal/output" + "github.com/spf13/cobra" +) + +var hostFlag string + +// This file defines the v2 CLI structure. +// The primary command is: ship [PATH] [FLAGS] +// All output is JSON by default. + +var rootV2Cmd = &cobra.Command{ + Use: "ship [PATH]", + Short: "Deploy code to a VPS. JSON output for agents.", + Long: `Ship deploys code to a VPS. Point it at a directory or binary, get a URL back. + + ship ./myproject # auto-detect and deploy + ship ./site --name docs # deploy with specific name + ship ./api --health /healthz # deploy with health check + ship ./preview --ttl 24h # deploy with auto-expiry + +All output is JSON. Use --pretty for human-readable output.`, + Args: cobra.MaximumNArgs(1), + RunE: runDeployV2, + SilenceUsage: true, + SilenceErrors: true, + DisableAutoGenTag: true, +} + +func initV2() { + // Global flags + rootV2Cmd.PersistentFlags().StringVar(&hostFlag, "host", "", "VPS host (SSH config alias or user@host)") + rootV2Cmd.PersistentFlags().BoolVar(&output.Pretty, "pretty", false, "Human-readable output") + + // Deploy flags + rootV2Cmd.Flags().String("name", "", "Deploy name (becomes subdomain)") + rootV2Cmd.Flags().String("domain", "", "Custom domain for deployment") + rootV2Cmd.Flags().String("health", "", "Health check endpoint (e.g., /healthz)") + rootV2Cmd.Flags().String("ttl", "", "Auto-delete after duration (e.g., 1h, 7d)") + rootV2Cmd.Flags().StringArray("env", nil, "Environment variable (KEY=VALUE)") + rootV2Cmd.Flags().String("env-file", "", "Path to .env file") + rootV2Cmd.Flags().Int("container-port", 80, "Port the container listens on (Docker only)") + + // Check for SHIP_PRETTY env var + if os.Getenv("SHIP_PRETTY") == "1" { + output.Pretty = true + } + + // Add subcommands + rootV2Cmd.AddCommand(listV2Cmd) + rootV2Cmd.AddCommand(statusV2Cmd) + rootV2Cmd.AddCommand(logsV2Cmd) + rootV2Cmd.AddCommand(removeV2Cmd) + rootV2Cmd.AddCommand(hostV2Cmd) + + // Initialize host subcommands (from host_v2.go) + initHostV2() +} + +func runDeployV2(cmd *cobra.Command, args []string) error { + path := "." + if len(args) > 0 { + path = args[0] + } + + opts := deployV2Options{ + Host: hostFlag, + Pretty: output.Pretty, + } + + // Get flag values + opts.Name, _ = cmd.Flags().GetString("name") + opts.Domain, _ = cmd.Flags().GetString("domain") + opts.Health, _ = cmd.Flags().GetString("health") + opts.TTL, _ = cmd.Flags().GetString("ttl") + opts.Env, _ = cmd.Flags().GetStringArray("env") + opts.EnvFile, _ = cmd.Flags().GetString("env-file") + opts.ContainerPort, _ = cmd.Flags().GetInt("container-port") + + // deployV2 handles all output and exits + deployV2(path, opts) + + // Should not reach here (deployV2 calls os.Exit) + return nil +} + +// Subcommands (list, status, logs, remove) are defined in commands_v2.go + +var hostV2Cmd = &cobra.Command{ + Use: "host", + Short: "Manage VPS host", +} + +// hostInitV2Cmd, hostStatusV2Cmd, and initHostV2() are defined in host_v2.go diff --git a/cmd/ship/root_v2.go b/cmd/ship/root_v2.go deleted file mode 100644 index aa81d1e..0000000 --- a/cmd/ship/root_v2.go +++ /dev/null @@ -1,98 +0,0 @@ -package main - -import ( - "os" - - "github.com/bdw/ship/internal/output" - "github.com/spf13/cobra" -) - -var hostFlag string - -// This file defines the v2 CLI structure. -// The primary command is: ship [PATH] [FLAGS] -// All output is JSON by default. - -var rootV2Cmd = &cobra.Command{ - Use: "ship [PATH]", - Short: "Deploy code to a VPS. JSON output for agents.", - Long: `Ship deploys code to a VPS. Point it at a directory or binary, get a URL back. - - ship ./myproject # auto-detect and deploy - ship ./site --name docs # deploy with specific name - ship ./api --health /healthz # deploy with health check - ship ./preview --ttl 24h # deploy with auto-expiry - -All output is JSON. Use --pretty for human-readable output.`, - Args: cobra.MaximumNArgs(1), - RunE: runDeployV2, - SilenceUsage: true, - SilenceErrors: true, - DisableAutoGenTag: true, -} - -func initV2() { - // Global flags - rootV2Cmd.PersistentFlags().StringVar(&hostFlag, "host", "", "VPS host (SSH config alias or user@host)") - rootV2Cmd.PersistentFlags().BoolVar(&output.Pretty, "pretty", false, "Human-readable output") - - // Deploy flags - rootV2Cmd.Flags().String("name", "", "Deploy name (becomes subdomain)") - rootV2Cmd.Flags().String("domain", "", "Custom domain for deployment") - rootV2Cmd.Flags().String("health", "", "Health check endpoint (e.g., /healthz)") - rootV2Cmd.Flags().String("ttl", "", "Auto-delete after duration (e.g., 1h, 7d)") - rootV2Cmd.Flags().StringArray("env", nil, "Environment variable (KEY=VALUE)") - rootV2Cmd.Flags().String("env-file", "", "Path to .env file") - rootV2Cmd.Flags().Int("container-port", 80, "Port the container listens on (Docker only)") - - // Check for SHIP_PRETTY env var - if os.Getenv("SHIP_PRETTY") == "1" { - output.Pretty = true - } - - // Add subcommands - rootV2Cmd.AddCommand(listV2Cmd) - rootV2Cmd.AddCommand(statusV2Cmd) - rootV2Cmd.AddCommand(logsV2Cmd) - rootV2Cmd.AddCommand(removeV2Cmd) - rootV2Cmd.AddCommand(hostV2Cmd) - - // Initialize host subcommands (from host_v2.go) - initHostV2() -} - -func runDeployV2(cmd *cobra.Command, args []string) error { - path := "." - if len(args) > 0 { - path = args[0] - } - - opts := deployV2Options{ - Host: hostFlag, - Pretty: output.Pretty, - } - - // Get flag values - opts.Name, _ = cmd.Flags().GetString("name") - opts.Domain, _ = cmd.Flags().GetString("domain") - opts.Health, _ = cmd.Flags().GetString("health") - opts.TTL, _ = cmd.Flags().GetString("ttl") - opts.Env, _ = cmd.Flags().GetStringArray("env") - opts.EnvFile, _ = cmd.Flags().GetString("env-file") - opts.ContainerPort, _ = cmd.Flags().GetInt("container-port") - - // deployV2 handles all output and exits - deployV2(path, opts) - - // Should not reach here (deployV2 calls os.Exit) - return nil -} - -// Subcommands (list, status, logs, remove) are defined in commands_v2.go - -var hostV2Cmd = &cobra.Command{ - Use: "host", - Short: "Manage VPS host", -} - -// hostInitV2Cmd, hostStatusV2Cmd, and initHostV2() are defined in host_v2.go diff --git a/cmd/ship/templates/webui.html b/cmd/ship/templates/webui.html deleted file mode 100644 index 052d599..0000000 --- a/cmd/ship/templates/webui.html +++ /dev/null @@ -1,440 +0,0 @@ - - - - - - Deploy - Web UI - - - -
-

Deploy Web UI

-

Manage your VPS deployments

-
- -
- {{if not .Hosts}} -
-

No deployments found

-

Use the CLI to deploy your first app or static site

-
- {{else}} - {{range .Hosts}} -
-
-

{{.Host}}

-
-
- {{range .Apps}} -
-
-
{{.Name}}
-
{{.Type}}
-
- -
-
Domain
- -
- - {{if eq .Type "app"}} -
-
Port
-
{{.Port}}
-
- {{end}} - -
- {{if eq .Type "app"}} - - {{end}} - - {{if .Env}} - - {{end}} -
-
- {{end}} -
-
- {{end}} - {{end}} - -
- Refresh the page to see latest changes -
-
- - - - - - - -- cgit v1.2.3