From d6740a0be72a776db555d4bb6ccfa4a04da6570a Mon Sep 17 00:00:00 2001 From: Clawd Date: Sun, 15 Feb 2026 18:53:55 -0800 Subject: feat(v2): implement list, status, logs, remove commands - commands_v2.go: all subcommand implementations - list: enumerates deploys from /etc/ship/ports and /var/www - status: shows deploy details (type, port, running, TTL) - logs: fetches journalctl logs (or Caddy logs for static) - remove: full cleanup of all artifacts (service, caddy, files, docker) - All commands output JSON with proper error codes All core v2 commands now implemented --- cmd/ship/commands_v2.go | 344 ++++++++++++++++++++++++++++++++++++++++++++++++ cmd/ship/root_v2.go | 48 +------ 2 files changed, 345 insertions(+), 47 deletions(-) create mode 100644 cmd/ship/commands_v2.go (limited to 'cmd/ship') diff --git a/cmd/ship/commands_v2.go b/cmd/ship/commands_v2.go new file mode 100644 index 0000000..26ee1d3 --- /dev/null +++ b/cmd/ship/commands_v2.go @@ -0,0 +1,344 @@ +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 + } + + info := output.DeployInfo{ + Name: name, + URL: fmt.Sprintf("https://%s.%s", name, hostConfig.BaseDomain), + } + + // 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 + } + + info := output.DeployInfo{ + Name: name, + URL: fmt.Sprintf("https://%s.%s", name, hostConfig.BaseDomain), + 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)) + } + + resp := &output.StatusResponse{ + Status: "ok", + Name: name, + URL: fmt.Sprintf("https://%s.%s", name, hostConfig.BaseDomain), + } + + // 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/root_v2.go b/cmd/ship/root_v2.go index 9900e83..e886a7e 100644 --- a/cmd/ship/root_v2.go +++ b/cmd/ship/root_v2.go @@ -82,53 +82,7 @@ func runDeployV2(cmd *cobra.Command, args []string) error { return nil } -// Placeholder subcommands - to be implemented - -var listV2Cmd = &cobra.Command{ - Use: "list", - Short: "List all deployments", - RunE: func(cmd *cobra.Command, args []string) error { - // TODO: implement - output.PrintAndExit(&output.ListResponse{ - Status: "ok", - Deploys: []output.DeployInfo{}, - }) - return nil - }, -} - -var statusV2Cmd = &cobra.Command{ - Use: "status NAME", - Short: "Check status of a deployment", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - // TODO: implement - output.PrintAndExit(output.Err(output.ErrNotFound, "not implemented")) - return nil - }, -} - -var logsV2Cmd = &cobra.Command{ - Use: "logs NAME", - Short: "View logs for a deployment", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - // TODO: implement - output.PrintAndExit(output.Err(output.ErrNotFound, "not implemented")) - return nil - }, -} - -var removeV2Cmd = &cobra.Command{ - Use: "remove NAME", - Short: "Remove a deployment", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - // TODO: implement - output.PrintAndExit(output.Err(output.ErrNotFound, "not implemented")) - return nil - }, -} +// Subcommands (list, status, logs, remove) are defined in commands_v2.go var hostV2Cmd = &cobra.Command{ Use: "host", -- cgit v1.2.3