From 6b2c04728cd914f27ae62c1df0bf5df24ac9a628 Mon Sep 17 00:00:00 2001 From: Clawd Date: Tue, 17 Feb 2026 07:54:26 -0800 Subject: Remove v1 code, simplify state to just base_domain - Delete all v1 commands (deploy, init, list, status, remove, etc.) - Delete v1 env/ and host/ subcommand directories - Simplify state.go: remove NextPort, Apps, AllocatePort, etc. - Local state now only tracks default_host + base_domain per host - Ports and deploys are tracked on the server (/etc/ship/ports/) - host init now creates minimal state.json --- cmd/ship/host/host.go | 21 --- cmd/ship/host/init.go | 316 -------------------------------------------- cmd/ship/host/set_domain.go | 76 ----------- cmd/ship/host/ssh.go | 45 ------- cmd/ship/host/status.go | 108 --------------- cmd/ship/host/update.go | 93 ------------- 6 files changed, 659 deletions(-) delete mode 100644 cmd/ship/host/host.go delete mode 100644 cmd/ship/host/init.go delete mode 100644 cmd/ship/host/set_domain.go delete mode 100644 cmd/ship/host/ssh.go delete mode 100644 cmd/ship/host/status.go delete mode 100644 cmd/ship/host/update.go (limited to 'cmd/ship/host') diff --git a/cmd/ship/host/host.go b/cmd/ship/host/host.go deleted file mode 100644 index 81403f9..0000000 --- a/cmd/ship/host/host.go +++ /dev/null @@ -1,21 +0,0 @@ -package host - -import ( - "github.com/spf13/cobra" -) - -var Cmd = &cobra.Command{ - Use: "host", - Short: "Manage VPS host", - Long: "Commands for managing and monitoring the VPS host", -} - -func init() { - Cmd.AddCommand(initCmd) - Cmd.AddCommand(statusCmd) - Cmd.AddCommand(updateCmd) - Cmd.AddCommand(sshCmd) - Cmd.AddCommand(setDomainCmd) - - initCmd.Flags().String("base-domain", "", "Base domain for auto-generated subdomains (e.g., apps.example.com)") -} diff --git a/cmd/ship/host/init.go b/cmd/ship/host/init.go deleted file mode 100644 index cfa2795..0000000 --- a/cmd/ship/host/init.go +++ /dev/null @@ -1,316 +0,0 @@ -package host - -import ( - "fmt" - "strings" - - "github.com/bdw/ship/internal/ssh" - "github.com/bdw/ship/internal/state" - "github.com/bdw/ship/internal/templates" - "github.com/spf13/cobra" -) - -var initCmd = &cobra.Command{ - Use: "init", - Short: "Initialize VPS (one-time setup)", - Long: "Set up a fresh VPS with Caddy for automatic HTTPS and required directories", - RunE: runInit, -} - -func runInit(cmd *cobra.Command, args []string) error { - st, err := state.Load() - if err != nil { - return fmt.Errorf("error loading state: %w", err) - } - - host, _ := cmd.Flags().GetString("host") - if host == "" { - host = st.GetDefaultHost() - } - baseDomain, _ := cmd.Flags().GetString("base-domain") - - if host == "" { - return fmt.Errorf("--host is required") - } - - fmt.Printf("Initializing VPS: %s\n", host) - - client, err := ssh.Connect(host) - if err != nil { - return fmt.Errorf("error connecting to VPS: %w", err) - } - defer client.Close() - - fmt.Println("-> Detecting OS...") - osRelease, err := client.Run("cat /etc/os-release") - if err != nil { - return fmt.Errorf("error detecting OS: %w", err) - } - - if !strings.Contains(osRelease, "Ubuntu") && !strings.Contains(osRelease, "Debian") { - return fmt.Errorf("unsupported OS (only Ubuntu and Debian are supported)") - } - fmt.Println(" Detected Ubuntu/Debian") - - fmt.Println("-> Checking for Caddy...") - _, err = client.Run("which caddy") - if err == nil { - fmt.Println(" Caddy already installed") - } else { - fmt.Println(" Installing Caddy...") - if err := installCaddy(client); err != nil { - return err - } - fmt.Println(" Caddy installed") - } - - fmt.Println("-> Configuring Caddy...") - caddyfile := `{ -} - -import /etc/caddy/sites-enabled/* -` - if err := client.WriteSudoFile("/etc/caddy/Caddyfile", caddyfile); err != nil { - return fmt.Errorf("error creating Caddyfile: %w", err) - } - fmt.Println(" Caddyfile created") - - fmt.Println("-> Creating directories...") - if _, err := client.RunSudo("mkdir -p /etc/ship/env"); err != nil { - return fmt.Errorf("error creating /etc/ship/env: %w", err) - } - if _, err := client.RunSudo("mkdir -p /etc/caddy/sites-enabled"); err != nil { - return fmt.Errorf("error creating /etc/caddy/sites-enabled: %w", err) - } - fmt.Println(" Directories created") - - fmt.Println("-> Starting Caddy...") - if _, err := client.RunSudo("systemctl enable caddy"); err != nil { - return fmt.Errorf("error enabling Caddy: %w", err) - } - if _, err := client.RunSudo("systemctl restart caddy"); err != nil { - return fmt.Errorf("error starting Caddy: %w", err) - } - fmt.Println(" Caddy started") - - fmt.Println("-> Verifying installation...") - output, err := client.RunSudo("systemctl is-active caddy") - if err != nil || strings.TrimSpace(output) != "active" { - fmt.Println(" Warning: Caddy may not be running properly") - } else { - fmt.Println(" Caddy is active") - } - - hostState := st.GetHost(host) - if baseDomain != "" { - hostState.BaseDomain = baseDomain - fmt.Printf(" Base domain: %s\n", baseDomain) - } - - // Git-centric deployment setup (gated on base domain) - if baseDomain != "" { - if err := setupGitDeploy(client, baseDomain, hostState); err != nil { - return err - } - } - - if st.GetDefaultHost() == "" { - st.SetDefaultHost(host) - fmt.Printf(" Set %s as default host\n", host) - } - if err := st.Save(); err != nil { - return fmt.Errorf("error saving state: %w", err) - } - - fmt.Println("\nVPS initialized successfully!") - fmt.Println("\nNext steps:") - fmt.Println(" 1. Deploy an app:") - fmt.Printf(" ship --binary ./myapp --domain api.example.com\n") - fmt.Println(" 2. Deploy a static site:") - fmt.Printf(" ship --static --dir ./dist --domain example.com\n") - if baseDomain != "" { - fmt.Println(" 3. Initialize a git-deployed app:") - fmt.Printf(" ship init myapp\n") - } - return nil -} - -func setupGitDeploy(client *ssh.Client, baseDomain string, hostState *state.Host) error { - fmt.Println("-> Installing Docker...") - dockerCommands := []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 dockerCommands { - if _, err := client.RunSudo(cmd); err != nil { - return fmt.Errorf("error installing Docker: %w", err) - } - } - fmt.Println(" Docker installed") - - fmt.Println("-> Installing git, fcgiwrap, and cgit...") - if _, err := client.RunSudo("apt-get install -y git fcgiwrap cgit"); err != nil { - return fmt.Errorf("error installing git/fcgiwrap/cgit: %w", err) - } - // Allow git-http-backend (runs as www-data) to access repos owned by git. - // Scoped to www-data only, not system-wide, to preserve CVE-2022-24765 protection. - // www-data's home is /var/www; ensure it can write .gitconfig there. - client.RunSudo("mkdir -p /var/www") - client.RunSudo("chown www-data:www-data /var/www") - if _, err := client.RunSudo("sudo -u www-data git config --global --add safe.directory '*'"); err != nil { - return fmt.Errorf("error setting git safe.directory: %w", err) - } - fmt.Println(" git, fcgiwrap, and cgit installed") - - fmt.Println("-> Creating git user...") - // Create git user (ignore error if already exists) - client.RunSudo("useradd -r -m -d /home/git -s $(which git-shell) git") - if _, err := client.RunSudo("usermod -aG docker git"); err != nil { - return fmt.Errorf("error adding git user to docker group: %w", err) - } - // www-data needs to read git repos for git-http-backend - if _, err := client.RunSudo("usermod -aG git www-data"); err != nil { - return fmt.Errorf("error adding www-data to git group: %w", err) - } - // caddy needs to connect to fcgiwrap socket (owned by www-data) - if _, err := client.RunSudo("usermod -aG www-data caddy"); err != nil { - return fmt.Errorf("error adding caddy to www-data group: %w", err) - } - fmt.Println(" git user created") - - fmt.Println("-> Copying SSH keys to git user...") - 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 fmt.Errorf("error copying SSH keys: %w", err) - } - } - fmt.Println(" SSH keys copied") - - fmt.Println("-> Creating /srv/git...") - if _, err := client.RunSudo("mkdir -p /srv/git"); err != nil { - return fmt.Errorf("error creating /srv/git: %w", err) - } - if _, err := client.RunSudo("chown git:git /srv/git"); err != nil { - return fmt.Errorf("error setting /srv/git ownership: %w", err) - } - fmt.Println(" /srv/git created") - - fmt.Println("-> Writing sudoers for git user...") - sudoersContent := `# Ship git-deploy: allow post-receive hooks to install configs and manage services. -# App names are validated to [a-z][a-z0-9-] before reaching this point. -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 fmt.Errorf("error writing sudoers: %w", err) - } - if _, err := client.RunSudo("chmod 440 /etc/sudoers.d/ship-git"); err != nil { - return fmt.Errorf("error setting sudoers permissions: %w", err) - } - fmt.Println(" sudoers configured") - - fmt.Println("-> Writing vanity import template...") - vanityHTML := ` - -{{$path := trimPrefix "/" (placeholder "http.request.orig_uri.path")}} -{{$parts := splitList "/" $path}} -{{$module := first $parts}} - - -go get {{.Host}}/{{$module}} - -` - if _, err := client.RunSudo("mkdir -p /opt/ship/vanity"); err != nil { - return fmt.Errorf("error creating vanity directory: %w", err) - } - if err := client.WriteSudoFile("/opt/ship/vanity/index.html", vanityHTML); err != nil { - return fmt.Errorf("error writing vanity template: %w", err) - } - fmt.Println(" vanity template written") - - fmt.Println("-> Writing base domain Caddy config...") - codeCaddyContent, err := templates.CodeCaddy(map[string]string{ - "BaseDomain": baseDomain, - }) - if err != nil { - return fmt.Errorf("error generating code caddy config: %w", err) - } - if err := client.WriteSudoFile("/etc/caddy/sites-enabled/ship-code.caddy", codeCaddyContent); err != nil { - return fmt.Errorf("error writing code caddy config: %w", err) - } - fmt.Println(" base domain Caddy config written") - - fmt.Println("-> Writing cgit config...") - cgitrcContent, err := templates.CgitRC(map[string]string{ - "BaseDomain": baseDomain, - }) - if err != nil { - return fmt.Errorf("error generating cgitrc: %w", err) - } - if err := client.WriteSudoFile("/etc/cgitrc", cgitrcContent); err != nil { - return fmt.Errorf("error writing cgitrc: %w", err) - } - if err := client.WriteSudoFile("/opt/ship/cgit-header.html", templates.CgitHeader()); err != nil { - return fmt.Errorf("error writing cgit header: %w", err) - } - fmt.Println(" cgit config written") - - fmt.Println("-> Starting Docker and fcgiwrap...") - if _, err := client.RunSudo("systemctl enable docker fcgiwrap"); err != nil { - return fmt.Errorf("error enabling services: %w", err) - } - if _, err := client.RunSudo("systemctl restart docker fcgiwrap"); err != nil { - return fmt.Errorf("error starting services: %w", err) - } - fmt.Println(" Docker and fcgiwrap started") - - fmt.Println("-> Restarting Caddy...") - if _, err := client.RunSudo("systemctl restart caddy"); err != nil { - return fmt.Errorf("error restarting Caddy: %w", err) - } - fmt.Println(" Caddy restarted") - - hostState.GitSetup = true - fmt.Println(" Git deployment setup complete") - return nil -} - -func installCaddy(client *ssh.Client) error { - commands := []string{ - "apt-get update", - "apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl", - "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg", - "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /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("error running: %s: %w", cmd, err) - } - } - return nil -} diff --git a/cmd/ship/host/set_domain.go b/cmd/ship/host/set_domain.go deleted file mode 100644 index fed3b31..0000000 --- a/cmd/ship/host/set_domain.go +++ /dev/null @@ -1,76 +0,0 @@ -package host - -import ( - "fmt" - - "github.com/bdw/ship/internal/state" - "github.com/spf13/cobra" -) - -var setDomainCmd = &cobra.Command{ - Use: "set-domain [domain]", - Short: "Set base domain for auto-generated subdomains", - Long: `Set the base domain used to auto-generate subdomains for deployments. - -When a base domain is configured (e.g., apps.example.com), every deployment -will automatically get a subdomain ({name}.apps.example.com). - -Examples: - ship host set-domain apps.example.com # Set base domain - ship host set-domain --clear # Remove base domain`, - RunE: runSetDomain, -} - -func init() { - setDomainCmd.Flags().Bool("clear", false, "Clear the base domain") -} - -func runSetDomain(cmd *cobra.Command, args []string) error { - st, err := state.Load() - if err != nil { - return fmt.Errorf("error loading state: %w", err) - } - - host, _ := cmd.Flags().GetString("host") - if host == "" { - host = st.GetDefaultHost() - } - - if host == "" { - return fmt.Errorf("--host is required") - } - - clear, _ := cmd.Flags().GetBool("clear") - - if !clear && len(args) == 0 { - // Show current base domain - hostState := st.GetHost(host) - if hostState.BaseDomain == "" { - fmt.Printf("No base domain configured for %s\n", host) - } else { - fmt.Printf("Base domain for %s: %s\n", host, hostState.BaseDomain) - } - return nil - } - - hostState := st.GetHost(host) - - if clear { - hostState.BaseDomain = "" - if err := st.Save(); err != nil { - return fmt.Errorf("error saving state: %w", err) - } - fmt.Printf("Cleared base domain for %s\n", host) - return nil - } - - hostState.BaseDomain = args[0] - if err := st.Save(); err != nil { - return fmt.Errorf("error saving state: %w", err) - } - - fmt.Printf("Set base domain for %s: %s\n", host, args[0]) - fmt.Println("\nNew deployments will automatically use subdomains like:") - fmt.Printf(" myapp.%s\n", args[0]) - return nil -} diff --git a/cmd/ship/host/ssh.go b/cmd/ship/host/ssh.go deleted file mode 100644 index e480e47..0000000 --- a/cmd/ship/host/ssh.go +++ /dev/null @@ -1,45 +0,0 @@ -package host - -import ( - "fmt" - "os" - "os/exec" - - "github.com/bdw/ship/internal/state" - "github.com/spf13/cobra" -) - -var sshCmd = &cobra.Command{ - Use: "ssh", - Short: "Open interactive SSH session", - RunE: runSSH, -} - -func runSSH(cmd *cobra.Command, args []string) error { - st, err := state.Load() - if err != nil { - return fmt.Errorf("error loading state: %w", err) - } - - host, _ := cmd.Flags().GetString("host") - if host == "" { - host = st.GetDefaultHost() - } - - if host == "" { - return fmt.Errorf("--host is required (no default host set)") - } - - sshCmd := exec.Command("ssh", host) - sshCmd.Stdin = os.Stdin - sshCmd.Stdout = os.Stdout - sshCmd.Stderr = os.Stderr - - if err := sshCmd.Run(); err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - os.Exit(exitErr.ExitCode()) - } - return err - } - return nil -} diff --git a/cmd/ship/host/status.go b/cmd/ship/host/status.go deleted file mode 100644 index eb2de53..0000000 --- a/cmd/ship/host/status.go +++ /dev/null @@ -1,108 +0,0 @@ -package host - -import ( - "fmt" - - "github.com/bdw/ship/internal/ssh" - "github.com/bdw/ship/internal/state" - "github.com/spf13/cobra" -) - -var statusCmd = &cobra.Command{ - Use: "status", - Short: "Show VPS health (uptime, disk, memory)", - RunE: runStatus, -} - -func runStatus(cmd *cobra.Command, args []string) error { - st, err := state.Load() - if err != nil { - return fmt.Errorf("error loading state: %w", err) - } - - host, _ := cmd.Flags().GetString("host") - if host == "" { - host = st.GetDefaultHost() - } - - if host == "" { - return fmt.Errorf("--host is required (no default host set)") - } - - fmt.Printf("Connecting to %s...\n\n", host) - - client, err := ssh.Connect(host) - if err != nil { - return fmt.Errorf("error connecting to VPS: %w", err) - } - defer client.Close() - - fmt.Println("UPTIME") - if output, err := client.Run("uptime -p"); err == nil { - fmt.Printf(" %s", output) - } - if output, err := client.Run("uptime -s"); err == nil { - fmt.Printf(" Since: %s", output) - } - fmt.Println() - - fmt.Println("LOAD") - if output, err := client.Run("cat /proc/loadavg | awk '{print $1, $2, $3}'"); err == nil { - fmt.Printf(" 1m, 5m, 15m: %s", output) - } - fmt.Println() - - fmt.Println("MEMORY") - if output, err := client.Run("free -h | awk 'NR==2 {print \" Used: \" $3 \" / \" $2}'"); err == nil { - fmt.Print(output) - } - if output, err := client.Run("free -h | awk 'NR==2 {printf \" Available: %s\\n\", $7}'"); err == nil { - fmt.Print(output) - } - fmt.Println() - - fmt.Println("DISK") - if output, err := client.Run("df -h / | awk 'NR==2 {print \" Used: \" $3 \" / \" $2 \" (\" $5 \")\"}'"); err == nil { - fmt.Print(output) - } - if output, err := client.Run("df -h / | awk 'NR==2 {print \" Available: \" $4}'"); err == nil { - fmt.Print(output) - } - fmt.Println() - - fmt.Println("UPDATES") - if output, err := client.Run("[ -f /var/lib/update-notifier/updates-available ] && cat /var/lib/update-notifier/updates-available | head -2 || echo ' (update info not available)'"); err == nil { - fmt.Print(output) - } - fmt.Println() - - fmt.Println("SERVICES") - if output, err := client.Run("systemctl is-active caddy 2>/dev/null && echo ' Caddy: active' || echo ' Caddy: inactive'"); err == nil { - if output == "active\n" { - fmt.Println(" Caddy: active") - } else { - fmt.Println(" Caddy: inactive") - } - } - - hostState := st.GetHost(host) - if hostState != nil && len(hostState.Apps) > 0 { - activeCount := 0 - for name, app := range hostState.Apps { - if app.Type == "app" { - if output, err := client.Run(fmt.Sprintf("systemctl is-active %s 2>/dev/null", name)); err == nil && output == "active\n" { - activeCount++ - } - } - } - appCount := 0 - for _, app := range hostState.Apps { - if app.Type == "app" { - appCount++ - } - } - fmt.Printf(" Deployed apps: %d/%d active\n", activeCount, appCount) - } - - return nil -} diff --git a/cmd/ship/host/update.go b/cmd/ship/host/update.go deleted file mode 100644 index 5f838b6..0000000 --- a/cmd/ship/host/update.go +++ /dev/null @@ -1,93 +0,0 @@ -package host - -import ( - "bufio" - "fmt" - "os" - "strings" - - "github.com/bdw/ship/internal/ssh" - "github.com/bdw/ship/internal/state" - "github.com/spf13/cobra" -) - -var updateCmd = &cobra.Command{ - Use: "update", - Short: "Update VPS packages", - Long: "Run apt update && apt upgrade on the VPS", - RunE: runUpdate, -} - -func init() { - updateCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") -} - -func runUpdate(cmd *cobra.Command, args []string) error { - st, err := state.Load() - if err != nil { - return fmt.Errorf("error loading state: %w", err) - } - - host, _ := cmd.Flags().GetString("host") - if host == "" { - host = st.GetDefaultHost() - } - - if host == "" { - return fmt.Errorf("--host is required (no default host set)") - } - - yes, _ := cmd.Flags().GetBool("yes") - if !yes { - fmt.Printf("This will run apt update && apt upgrade on %s\n", host) - fmt.Print("Continue? [Y/n]: ") - reader := bufio.NewReader(os.Stdin) - response, _ := reader.ReadString('\n') - response = strings.TrimSpace(response) - if response == "n" || response == "N" { - fmt.Println("Aborted.") - return nil - } - } - - fmt.Printf("Connecting to %s...\n", host) - - client, err := ssh.Connect(host) - if err != nil { - return fmt.Errorf("error connecting to VPS: %w", err) - } - defer client.Close() - - fmt.Println("\n-> Running apt update...") - if err := client.RunSudoStream("apt update"); err != nil { - return fmt.Errorf("error running apt update: %w", err) - } - - fmt.Println("\n-> Running apt upgrade...") - if err := client.RunSudoStream("DEBIAN_FRONTEND=noninteractive apt upgrade -y"); err != nil { - return fmt.Errorf("error running apt upgrade: %w", err) - } - - fmt.Println() - if output, err := client.Run("[ -f /var/run/reboot-required ] && echo 'yes' || echo 'no'"); err == nil { - if strings.TrimSpace(output) == "yes" { - fmt.Print("A reboot is required to complete the update. Reboot now? [Y/n]: ") - reader := bufio.NewReader(os.Stdin) - response, _ := reader.ReadString('\n') - response = strings.TrimSpace(response) - if response == "" || response == "y" || response == "Y" { - fmt.Println("Rebooting...") - if _, err := client.RunSudo("reboot"); err != nil { - // reboot command often returns an error as connection drops - // this is expected behavior - } - fmt.Println("Reboot initiated. The host will be back online shortly.") - return nil - } - fmt.Println("Skipping reboot. Run 'sudo reboot' manually when ready.") - } - } - - fmt.Println("Update complete") - return nil -} -- cgit v1.2.3