From 5861e465a2ccf31d87ea25ac145770786f9cc96e Mon Sep 17 00:00:00 2001 From: bndw Date: Sat, 24 Jan 2026 09:48:34 -0800 Subject: Rename project from deploy to ship - Rename module to github.com/bdw/ship - Rename cmd/deploy to cmd/ship - Update all import paths - Update config path from ~/.config/deploy to ~/.config/ship - Update VPS env path from /etc/deploy to /etc/ship - Update README, Makefile, and docs --- .gitignore | 2 +- DESIGN_SPEC.md | 16 +- Makefile | 4 +- README.md | 56 ++--- cmd/deploy/env/env.go | 17 -- cmd/deploy/env/list.go | 69 ------- cmd/deploy/env/set.go | 132 ------------ cmd/deploy/env/unset.go | 92 --------- cmd/deploy/host/host.go | 18 -- cmd/deploy/host/init.go | 137 ------------- cmd/deploy/host/ssh.go | 45 ---- cmd/deploy/host/status.go | 108 ---------- cmd/deploy/host/update.go | 93 --------- cmd/deploy/list.go | 50 ----- cmd/deploy/logs.go | 75 ------- cmd/deploy/main.go | 65 ------ cmd/deploy/remove.go | 83 -------- cmd/deploy/restart.go | 57 ------ cmd/deploy/root.go | 377 ---------------------------------- cmd/deploy/status.go | 60 ------ cmd/deploy/templates/webui.html | 440 ---------------------------------------- cmd/deploy/ui.go | 199 ------------------ cmd/deploy/version.go | 17 -- cmd/ship/env/env.go | 17 ++ cmd/ship/env/list.go | 69 +++++++ cmd/ship/env/set.go | 132 ++++++++++++ cmd/ship/env/unset.go | 92 +++++++++ cmd/ship/host/host.go | 18 ++ cmd/ship/host/init.go | 137 +++++++++++++ cmd/ship/host/ssh.go | 45 ++++ cmd/ship/host/status.go | 108 ++++++++++ cmd/ship/host/update.go | 93 +++++++++ cmd/ship/list.go | 50 +++++ cmd/ship/logs.go | 75 +++++++ cmd/ship/main.go | 65 ++++++ cmd/ship/remove.go | 83 ++++++++ cmd/ship/restart.go | 57 ++++++ cmd/ship/root.go | 377 ++++++++++++++++++++++++++++++++++ cmd/ship/status.go | 60 ++++++ cmd/ship/templates/webui.html | 440 ++++++++++++++++++++++++++++++++++++++++ cmd/ship/ui.go | 199 ++++++++++++++++++ cmd/ship/version.go | 17 ++ go.mod | 2 +- internal/state/state.go | 8 +- 44 files changed, 2178 insertions(+), 2178 deletions(-) delete mode 100644 cmd/deploy/env/env.go delete mode 100644 cmd/deploy/env/list.go delete mode 100644 cmd/deploy/env/set.go delete mode 100644 cmd/deploy/env/unset.go delete mode 100644 cmd/deploy/host/host.go delete mode 100644 cmd/deploy/host/init.go delete mode 100644 cmd/deploy/host/ssh.go delete mode 100644 cmd/deploy/host/status.go delete mode 100644 cmd/deploy/host/update.go delete mode 100644 cmd/deploy/list.go delete mode 100644 cmd/deploy/logs.go delete mode 100644 cmd/deploy/main.go delete mode 100644 cmd/deploy/remove.go delete mode 100644 cmd/deploy/restart.go delete mode 100644 cmd/deploy/root.go delete mode 100644 cmd/deploy/status.go delete mode 100644 cmd/deploy/templates/webui.html delete mode 100644 cmd/deploy/ui.go delete mode 100644 cmd/deploy/version.go create mode 100644 cmd/ship/env/env.go create mode 100644 cmd/ship/env/list.go create mode 100644 cmd/ship/env/set.go create mode 100644 cmd/ship/env/unset.go create mode 100644 cmd/ship/host/host.go create mode 100644 cmd/ship/host/init.go create mode 100644 cmd/ship/host/ssh.go create mode 100644 cmd/ship/host/status.go create mode 100644 cmd/ship/host/update.go create mode 100644 cmd/ship/list.go create mode 100644 cmd/ship/logs.go create mode 100644 cmd/ship/main.go create mode 100644 cmd/ship/remove.go create mode 100644 cmd/ship/restart.go create mode 100644 cmd/ship/root.go create mode 100644 cmd/ship/status.go create mode 100644 cmd/ship/templates/webui.html create mode 100644 cmd/ship/ui.go create mode 100644 cmd/ship/version.go diff --git a/.gitignore b/.gitignore index e5cae05..112275b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # Binaries -/deploy +/ship *.exe *.dll *.so diff --git a/DESIGN_SPEC.md b/DESIGN_SPEC.md index e8bb197..51342d4 100644 --- a/DESIGN_SPEC.md +++ b/DESIGN_SPEC.md @@ -20,7 +20,7 @@ deploy init --host user@your-vps-ip # - Detect OS (Ubuntu/Debian supported) # - Install Caddy from official repository # - Configure Caddy to import `/etc/caddy/sites-enabled/*` -# - Create `/etc/deploy/env/` directory for env files +# - Create `/etc/ship/env/` directory for env files # - Create `/etc/caddy/sites-enabled/` directory # - Enable and start Caddy service # - Verify installation @@ -40,7 +40,7 @@ deploy init --host user@your-vps-ip # This will SSH to the VPS and: # - Install Caddy # - Configure Caddy to use sites-enabled pattern -# - Create /etc/deploy/env/ directory for env files +# - Create /etc/ship/env/ directory for env files # - Enable and start Caddy # # State is stored locally at ~/.config/deploy/state.json @@ -273,9 +273,9 @@ All deployment state stored locally at `~/.config/deploy/state.json`: ``` ### Environment Files (VPS) -Environment variables written to `/etc/deploy/env/{appname}.env` on VPS for systemd to read: +Environment variables written to `/etc/ship/env/{appname}.env` on VPS for systemd to read: ```bash -# /etc/deploy/env/myapi.env (generated from state.json) +# /etc/ship/env/myapi.env (generated from state.json) PORT=8001 DB_HOST=localhost DB_PORT=5432 @@ -297,7 +297,7 @@ ENVIRONMENT=production - Checks if Caddy is already installed (skip if present) - Installs Caddy via official APT repository - Creates `/etc/caddy/Caddyfile` with `import /etc/caddy/sites-enabled/*` -- Creates directory structure: `/etc/deploy/env/`, `/etc/caddy/sites-enabled/` +- Creates directory structure: `/etc/ship/env/`, `/etc/caddy/sites-enabled/` - Enables and starts Caddy - Runs health check (verify Caddy is running) - Initializes local state file at `~/.config/deploy/state.json` if not present @@ -343,7 +343,7 @@ All steps executed remotely on VPS via SSH: 4. Create system user (e.g., `myapp`) 5. Create working directory (`/var/lib/myapp`) 6. Copy binary to `/usr/local/bin/myapp` -7. Create env file at `/etc/deploy/env/myapp.env` with PORT and any user-provided vars +7. Create env file at `/etc/ship/env/myapp.env` with PORT and any user-provided vars 8. Set env file permissions (0600, owned by app user) 9. Generate systemd unit at `/etc/systemd/system/myapp.service` with EnvironmentFile 10. Generate Caddy config at `/etc/caddy/sites-enabled/myapp.caddy` pointing to localhost:port @@ -376,7 +376,7 @@ All steps executed remotely on VPS via SSH: /var/lib/myapp/ # Working directory /etc/systemd/system/myapp.service # Systemd unit /etc/caddy/sites-enabled/myapp.caddy # Caddy config -/etc/deploy/env/myapp.env # Environment variables (0600 permissions) +/etc/ship/env/myapp.env # Environment variables (0600 permissions) /var/www/mysite/ # Static site files /etc/caddy/sites-enabled/mysite.caddy # Caddy config @@ -550,7 +550,7 @@ deploy list - Use systemd security hardening (NoNewPrivileges, PrivateTmp, ProtectSystem) - Static sites served as www-data - Caddy automatically handles TLS cert management -- Environment files stored at `/etc/deploy/env/{app}.env` with 0600 permissions +- Environment files stored at `/etc/ship/env/{app}.env` with 0600 permissions - Env files owned by the app's system user - `deploy env` command masks sensitive values when displaying (shows `API_KEY=***`) - Consider using external secret management for production (out of scope for v1) diff --git a/Makefile b/Makefile index ccdc96e..13f2835 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ build: - go build -o ./bin/deploy ./cmd/deploy + go build -o ./bin/ship ./cmd/ship install: - cp ./bin/deploy /usr/local/bin/ + cp ./bin/ship /usr/local/bin/ diff --git a/README.md b/README.md index a2253c1..4394e35 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Deploy - VPS Deployment CLI +# Ship - VPS Deployment CLI Simple CLI tool for deploying Go apps and static sites to a VPS with automatic HTTPS via Caddy. @@ -17,10 +17,10 @@ Simple CLI tool for deploying Go apps and static sites to a VPS with automatic H ```bash # Build the CLI -go build -o ~/bin/deploy ./cmd/deploy +go build -o ~/bin/ship ./cmd/ship # Or install to GOPATH -go install ./cmd/deploy +go install ./cmd/ship ``` ## Quick Start @@ -29,7 +29,7 @@ go install ./cmd/deploy ```bash # Initialize a fresh VPS (this sets it as the default host) -deploy host init user@your-vps-ip +ship host init user@your-vps-ip ``` This will: @@ -45,15 +45,15 @@ This will: GOOS=linux GOARCH=amd64 go build -o myapp # Deploy it -deploy --binary ./myapp --domain api.example.com +ship --binary ./myapp --domain api.example.com # With environment variables -deploy --binary ./myapp --domain api.example.com \ +ship --binary ./myapp --domain api.example.com \ --env DB_HOST=localhost \ --env API_KEY=secret # Or from an env file -deploy --binary ./myapp --domain api.example.com \ +ship --binary ./myapp --domain api.example.com \ --env-file .env.production ``` @@ -64,7 +64,7 @@ deploy --binary ./myapp --domain api.example.com \ npm run build # Deploy it -deploy --static --dir ./dist --domain example.com +ship --static --dir ./dist --domain example.com ``` ## App Requirements @@ -110,72 +110,72 @@ func main() { ```bash # Initialize a fresh VPS (one-time setup, sets as default) -deploy host init user@vps-ip +ship host init user@vps-ip # Update system packages (apt update && apt upgrade) -deploy host update +ship host update # Check host status -deploy host status +ship host status # SSH into the host -deploy host ssh +ship host ssh ``` ### Deploy App/Site ```bash # Go app -deploy --binary ./myapp --domain api.example.com +ship --binary ./myapp --domain api.example.com # Static site -deploy --static --dir ./dist --domain example.com +ship --static --dir ./dist --domain example.com # Custom name (defaults to binary/directory name) -deploy --name myapi --binary ./myapp --domain api.example.com +ship --name myapi --binary ./myapp --domain api.example.com ``` ### List Deployments ```bash -deploy list +ship list ``` ### Manage Deployments ```bash # View logs -deploy logs myapp +ship logs myapp # View status -deploy status myapp +ship status myapp # Restart app -deploy restart myapp +ship restart myapp # Remove deployment -deploy remove myapp +ship remove myapp ``` ### Environment Variables ```bash # View current env vars (secrets are masked) -deploy env list myapi +ship env list myapi # Set env vars -deploy env set myapi DB_HOST=localhost API_KEY=secret +ship env set myapi DB_HOST=localhost API_KEY=secret # Load from file -deploy env set myapi -f .env.production +ship env set myapi -f .env.production # Unset env var -deploy env unset myapi API_KEY +ship env unset myapi API_KEY ``` ## Configuration -The host you initialize becomes the default, so you don't need to specify `--host` for every command. The default host is stored in `~/.config/deploy/state.json`. +The host you initialize becomes the default, so you don't need to specify `--host` for every command. The default host is stored in `~/.config/ship/state.json`. ## How It Works -1. **State on Laptop**: All deployment state lives at `~/.config/deploy/state.json` on your laptop +1. **State on Laptop**: All deployment state lives at `~/.config/ship/state.json` on your laptop 2. **SSH Orchestration**: The CLI uses SSH to run commands on your VPS 3. **File Transfer**: Binaries transferred via SCP, static sites via rsync 4. **Caddy for HTTPS**: Caddy automatically handles HTTPS certificates @@ -186,7 +186,7 @@ The host you initialize becomes the default, so you don't need to specify `--hos ### On Laptop ``` -~/.config/deploy/state.json # All deployment state (including default host) +~/.config/ship/state.json # All deployment state (including default host) ``` ### On VPS @@ -195,7 +195,7 @@ The host you initialize becomes the default, so you don't need to specify `--hos /var/lib/myapp/ # Working directory /etc/systemd/system/myapp.service # Systemd unit /etc/caddy/sites-enabled/myapp.caddy # Caddy config -/etc/deploy/env/myapp.env # Environment variables +/etc/ship/env/myapp.env # Environment variables /var/www/mysite/ # Static site files /etc/caddy/sites-enabled/mysite.caddy # Caddy config diff --git a/cmd/deploy/env/env.go b/cmd/deploy/env/env.go deleted file mode 100644 index 489353a..0000000 --- a/cmd/deploy/env/env.go +++ /dev/null @@ -1,17 +0,0 @@ -package env - -import ( - "github.com/spf13/cobra" -) - -var Cmd = &cobra.Command{ - Use: "env", - Short: "Manage environment variables", - Long: "Manage environment variables for deployed applications", -} - -func init() { - Cmd.AddCommand(listCmd) - Cmd.AddCommand(setCmd) - Cmd.AddCommand(unsetCmd) -} diff --git a/cmd/deploy/env/list.go b/cmd/deploy/env/list.go deleted file mode 100644 index af92171..0000000 --- a/cmd/deploy/env/list.go +++ /dev/null @@ -1,69 +0,0 @@ -package env - -import ( - "fmt" - "strings" - - "github.com/bdw/deploy/internal/state" - "github.com/spf13/cobra" -) - -var listCmd = &cobra.Command{ - Use: "list ", - Short: "List environment variables for an app", - Args: cobra.ExactArgs(1), - RunE: runList, -} - -func runList(cmd *cobra.Command, args []string) error { - name := args[0] - - 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") - } - - app, err := st.GetApp(host, name) - if err != nil { - return err - } - - if app.Type != "app" { - return fmt.Errorf("env is only available for apps, not static sites") - } - - fmt.Printf("Environment variables for %s:\n\n", name) - if len(app.Env) == 0 { - fmt.Println(" (none)") - } else { - for k, v := range app.Env { - display := v - if isSensitive(k) { - display = "***" - } - fmt.Printf(" %s=%s\n", k, display) - } - } - - return nil -} - -func isSensitive(key string) bool { - key = strings.ToLower(key) - sensitiveWords := []string{"key", "secret", "password", "token", "api"} - for _, word := range sensitiveWords { - if strings.Contains(key, word) { - return true - } - } - return false -} diff --git a/cmd/deploy/env/set.go b/cmd/deploy/env/set.go deleted file mode 100644 index 35d77ff..0000000 --- a/cmd/deploy/env/set.go +++ /dev/null @@ -1,132 +0,0 @@ -package env - -import ( - "bufio" - "fmt" - "os" - "strings" - - "github.com/bdw/deploy/internal/ssh" - "github.com/bdw/deploy/internal/state" - "github.com/spf13/cobra" -) - -var setCmd = &cobra.Command{ - Use: "set KEY=VALUE...", - Short: "Set environment variable(s)", - Long: "Set one or more environment variables for an app. Variables are specified as KEY=VALUE pairs.", - Args: cobra.MinimumNArgs(2), - RunE: runSet, -} - -func init() { - setCmd.Flags().StringP("file", "f", "", "Load environment from file") -} - -func runSet(cmd *cobra.Command, args []string) error { - name := args[0] - envVars := args[1:] - - 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") - } - - app, err := st.GetApp(host, name) - if err != nil { - return err - } - - if app.Type != "app" { - return fmt.Errorf("env is only available for apps, not static sites") - } - - if app.Env == nil { - app.Env = make(map[string]string) - } - - // Set variables from args - for _, e := range envVars { - parts := strings.SplitN(e, "=", 2) - if len(parts) == 2 { - app.Env[parts[0]] = parts[1] - fmt.Printf("Set %s\n", parts[0]) - } else { - return fmt.Errorf("invalid format: %s (expected KEY=VALUE)", e) - } - } - - // Set variables from file if provided - envFile, _ := cmd.Flags().GetString("file") - if envFile != "" { - fileEnv, err := parseEnvFile(envFile) - if err != nil { - return fmt.Errorf("error reading env file: %w", err) - } - for k, v := range fileEnv { - app.Env[k] = v - fmt.Printf("Set %s\n", k) - } - } - - if err := st.Save(); err != nil { - return fmt.Errorf("error saving state: %w", err) - } - - client, err := ssh.Connect(host) - if err != nil { - return fmt.Errorf("error connecting to VPS: %w", err) - } - defer client.Close() - - fmt.Println("-> Updating environment file on VPS...") - envFilePath := fmt.Sprintf("/etc/deploy/env/%s.env", name) - envContent := "" - for k, v := range app.Env { - envContent += fmt.Sprintf("%s=%s\n", k, v) - } - if err := client.WriteSudoFile(envFilePath, envContent); err != nil { - return fmt.Errorf("error updating env file: %w", err) - } - - fmt.Println("-> Restarting service...") - if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { - return fmt.Errorf("error restarting service: %w", err) - } - - fmt.Println("Environment variables updated successfully") - return nil -} - -func parseEnvFile(path string) (map[string]string, error) { - file, err := os.Open(path) - if err != nil { - return nil, err - } - defer file.Close() - - env := make(map[string]string) - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - - parts := strings.SplitN(line, "=", 2) - if len(parts) == 2 { - env[parts[0]] = parts[1] - } - } - - return env, scanner.Err() -} diff --git a/cmd/deploy/env/unset.go b/cmd/deploy/env/unset.go deleted file mode 100644 index 65a8986..0000000 --- a/cmd/deploy/env/unset.go +++ /dev/null @@ -1,92 +0,0 @@ -package env - -import ( - "fmt" - - "github.com/bdw/deploy/internal/ssh" - "github.com/bdw/deploy/internal/state" - "github.com/spf13/cobra" -) - -var unsetCmd = &cobra.Command{ - Use: "unset KEY...", - Short: "Unset environment variable(s)", - Long: "Remove one or more environment variables from an app.", - Args: cobra.MinimumNArgs(2), - RunE: runUnset, -} - -func runUnset(cmd *cobra.Command, args []string) error { - name := args[0] - keys := args[1:] - - 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") - } - - app, err := st.GetApp(host, name) - if err != nil { - return err - } - - if app.Type != "app" { - return fmt.Errorf("env is only available for apps, not static sites") - } - - if app.Env == nil { - return fmt.Errorf("no environment variables set") - } - - changed := false - for _, key := range keys { - if _, exists := app.Env[key]; exists { - delete(app.Env, key) - changed = true - fmt.Printf("Unset %s\n", key) - } else { - fmt.Printf("Warning: %s not found\n", key) - } - } - - if !changed { - return nil - } - - if err := st.Save(); err != nil { - return fmt.Errorf("error saving state: %w", err) - } - - client, err := ssh.Connect(host) - if err != nil { - return fmt.Errorf("error connecting to VPS: %w", err) - } - defer client.Close() - - fmt.Println("-> Updating environment file on VPS...") - envFilePath := fmt.Sprintf("/etc/deploy/env/%s.env", name) - envContent := "" - for k, v := range app.Env { - envContent += fmt.Sprintf("%s=%s\n", k, v) - } - if err := client.WriteSudoFile(envFilePath, envContent); err != nil { - return fmt.Errorf("error updating env file: %w", err) - } - - fmt.Println("-> Restarting service...") - if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { - return fmt.Errorf("error restarting service: %w", err) - } - - fmt.Println("Environment variables updated successfully") - return nil -} diff --git a/cmd/deploy/host/host.go b/cmd/deploy/host/host.go deleted file mode 100644 index 603a946..0000000 --- a/cmd/deploy/host/host.go +++ /dev/null @@ -1,18 +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) -} diff --git a/cmd/deploy/host/init.go b/cmd/deploy/host/init.go deleted file mode 100644 index 984e5d3..0000000 --- a/cmd/deploy/host/init.go +++ /dev/null @@ -1,137 +0,0 @@ -package host - -import ( - "fmt" - "strings" - - "github.com/bdw/deploy/internal/ssh" - "github.com/bdw/deploy/internal/state" - "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() - } - - 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 := `{ - email admin@example.com -} - -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/deploy/env"); err != nil { - return fmt.Errorf("error creating /etc/deploy/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") - } - - st.GetHost(host) - 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 a Go app:") - fmt.Printf(" deploy --binary ./myapp --domain api.example.com\n") - fmt.Println(" 2. Deploy a static site:") - fmt.Printf(" deploy --static --dir ./dist --domain example.com\n") - 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/deploy/host/ssh.go b/cmd/deploy/host/ssh.go deleted file mode 100644 index a33986f..0000000 --- a/cmd/deploy/host/ssh.go +++ /dev/null @@ -1,45 +0,0 @@ -package host - -import ( - "fmt" - "os" - "os/exec" - - "github.com/bdw/deploy/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/deploy/host/status.go b/cmd/deploy/host/status.go deleted file mode 100644 index bdd9c31..0000000 --- a/cmd/deploy/host/status.go +++ /dev/null @@ -1,108 +0,0 @@ -package host - -import ( - "fmt" - - "github.com/bdw/deploy/internal/ssh" - "github.com/bdw/deploy/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/deploy/host/update.go b/cmd/deploy/host/update.go deleted file mode 100644 index aa47ed8..0000000 --- a/cmd/deploy/host/update.go +++ /dev/null @@ -1,93 +0,0 @@ -package host - -import ( - "bufio" - "fmt" - "os" - "strings" - - "github.com/bdw/deploy/internal/ssh" - "github.com/bdw/deploy/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 -} diff --git a/cmd/deploy/list.go b/cmd/deploy/list.go deleted file mode 100644 index ab19a12..0000000 --- a/cmd/deploy/list.go +++ /dev/null @@ -1,50 +0,0 @@ -package main - -import ( - "fmt" - "os" - "text/tabwriter" - - "github.com/bdw/deploy/internal/state" - "github.com/spf13/cobra" -) - -var listCmd = &cobra.Command{ - Use: "list", - Short: "List all deployed apps and sites", - RunE: runList, -} - -func runList(cmd *cobra.Command, args []string) error { - st, err := state.Load() - if err != nil { - return fmt.Errorf("error loading state: %w", err) - } - - host := hostFlag - if host == "" { - host = st.GetDefaultHost() - } - - if host == "" { - return fmt.Errorf("--host is required") - } - - apps := st.ListApps(host) - if len(apps) == 0 { - fmt.Printf("No deployments found for %s\n", host) - return nil - } - - w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) - fmt.Fprintln(w, "NAME\tTYPE\tDOMAIN\tPORT") - for name, app := range apps { - port := "" - if app.Type == "app" { - port = fmt.Sprintf(":%d", app.Port) - } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", name, app.Type, app.Domain, port) - } - w.Flush() - return nil -} diff --git a/cmd/deploy/logs.go b/cmd/deploy/logs.go deleted file mode 100644 index 2b016b8..0000000 --- a/cmd/deploy/logs.go +++ /dev/null @@ -1,75 +0,0 @@ -package main - -import ( - "fmt" - - "github.com/bdw/deploy/internal/ssh" - "github.com/bdw/deploy/internal/state" - "github.com/spf13/cobra" -) - -var logsCmd = &cobra.Command{ - Use: "logs ", - Short: "View logs for a deployment", - Args: cobra.ExactArgs(1), - RunE: runLogs, -} - -func init() { - logsCmd.Flags().BoolP("follow", "f", false, "Follow logs") - logsCmd.Flags().IntP("lines", "n", 50, "Number of lines to show") -} - -func runLogs(cmd *cobra.Command, args []string) error { - name := args[0] - follow, _ := cmd.Flags().GetBool("follow") - lines, _ := cmd.Flags().GetInt("lines") - - st, err := state.Load() - if err != nil { - return fmt.Errorf("error loading state: %w", err) - } - - host := hostFlag - if host == "" { - host = st.GetDefaultHost() - } - - if host == "" { - return fmt.Errorf("--host is required") - } - - app, err := st.GetApp(host, name) - if err != nil { - return err - } - - if app.Type != "app" { - return fmt.Errorf("logs are only available for apps, not static sites") - } - - client, err := ssh.Connect(host) - if err != nil { - return fmt.Errorf("error connecting to VPS: %w", err) - } - defer client.Close() - - journalCmd := fmt.Sprintf("journalctl -u %s -n %d", name, lines) - if follow { - journalCmd += " -f" - } - - if follow { - if err := client.RunStream(journalCmd); err != nil { - return fmt.Errorf("error fetching logs: %w", err) - } - } else { - output, err := client.Run(journalCmd) - if err != nil { - return fmt.Errorf("error fetching logs: %w", err) - } - fmt.Print(output) - } - - return nil -} diff --git a/cmd/deploy/main.go b/cmd/deploy/main.go deleted file mode 100644 index ad61523..0000000 --- a/cmd/deploy/main.go +++ /dev/null @@ -1,65 +0,0 @@ -package main - -import ( - "os" - - "github.com/bdw/deploy/cmd/deploy/env" - "github.com/bdw/deploy/cmd/deploy/host" - "github.com/spf13/cobra" -) - -var ( - // Persistent flags - hostFlag string - - // Version info (set via ldflags) - version = "dev" - commit = "none" - date = "unknown" -) - -var rootCmd = &cobra.Command{ - Use: "deploy", - Short: "Deploy Go apps and static sites to a VPS with automatic HTTPS", - Long: `deploy - Deploy Go apps and static sites to a VPS with automatic HTTPS - -A CLI tool for deploying applications and static sites to a VPS. -Uses Caddy for automatic HTTPS and systemd for service management.`, - RunE: runDeploy, - SilenceUsage: true, - SilenceErrors: true, -} - -func init() { - // Persistent flags available to all subcommands - rootCmd.PersistentFlags().StringVar(&hostFlag, "host", "", "VPS host (SSH config alias or user@host)") - - // Root command (deploy) flags - rootCmd.Flags().String("binary", "", "Path to Go binary (for app deployment)") - rootCmd.Flags().Bool("static", false, "Deploy as static site") - rootCmd.Flags().String("dir", ".", "Directory to deploy (for static sites)") - rootCmd.Flags().String("domain", "", "Domain name (required)") - rootCmd.Flags().String("name", "", "App name (default: inferred from binary or directory)") - rootCmd.Flags().Int("port", 0, "Port override (default: auto-allocate)") - rootCmd.Flags().StringArray("env", nil, "Environment variable (KEY=VALUE, can be specified multiple times)") - rootCmd.Flags().String("env-file", "", "Path to .env file") - rootCmd.Flags().String("args", "", "Arguments to pass to binary") - rootCmd.Flags().StringArray("file", nil, "Config file to upload to working directory (can be specified multiple times)") - - // Add subcommands - rootCmd.AddCommand(listCmd) - rootCmd.AddCommand(logsCmd) - rootCmd.AddCommand(statusCmd) - rootCmd.AddCommand(restartCmd) - rootCmd.AddCommand(removeCmd) - rootCmd.AddCommand(env.Cmd) - rootCmd.AddCommand(host.Cmd) - rootCmd.AddCommand(uiCmd) - rootCmd.AddCommand(versionCmd) -} - -func main() { - if err := rootCmd.Execute(); err != nil { - os.Exit(1) - } -} diff --git a/cmd/deploy/remove.go b/cmd/deploy/remove.go deleted file mode 100644 index 5a98bf3..0000000 --- a/cmd/deploy/remove.go +++ /dev/null @@ -1,83 +0,0 @@ -package main - -import ( - "fmt" - - "github.com/bdw/deploy/internal/ssh" - "github.com/bdw/deploy/internal/state" - "github.com/spf13/cobra" -) - -var removeCmd = &cobra.Command{ - Use: "remove ", - Aliases: []string{"rm"}, - Short: "Remove a deployment", - Args: cobra.ExactArgs(1), - RunE: runRemove, -} - -func runRemove(cmd *cobra.Command, args []string) error { - name := args[0] - - st, err := state.Load() - if err != nil { - return fmt.Errorf("error loading state: %w", err) - } - - host := hostFlag - if host == "" { - host = st.GetDefaultHost() - } - - if host == "" { - return fmt.Errorf("--host is required") - } - - app, err := st.GetApp(host, name) - if err != nil { - return err - } - - fmt.Printf("Removing deployment: %s\n", name) - - client, err := ssh.Connect(host) - if err != nil { - return fmt.Errorf("error connecting to VPS: %w", err) - } - defer client.Close() - - if app.Type == "app" { - fmt.Println("-> Stopping service...") - client.RunSudo(fmt.Sprintf("systemctl stop %s", name)) - client.RunSudo(fmt.Sprintf("systemctl disable %s", name)) - - client.RunSudo(fmt.Sprintf("rm -f /etc/systemd/system/%s.service", name)) - client.RunSudo("systemctl daemon-reload") - - client.RunSudo(fmt.Sprintf("rm -f /usr/local/bin/%s", name)) - client.RunSudo(fmt.Sprintf("rm -rf /var/lib/%s", name)) - client.RunSudo(fmt.Sprintf("rm -f /etc/deploy/env/%s.env", name)) - client.RunSudo(fmt.Sprintf("userdel %s", name)) - } else { - fmt.Println("-> Removing files...") - client.RunSudo(fmt.Sprintf("rm -rf /var/www/%s", name)) - } - - fmt.Println("-> Removing Caddy config...") - client.RunSudo(fmt.Sprintf("rm -f /etc/caddy/sites-enabled/%s.caddy", name)) - - fmt.Println("-> Reloading Caddy...") - if _, err := client.RunSudo("systemctl reload caddy"); err != nil { - fmt.Printf("Warning: Error reloading Caddy: %v\n", err) - } - - if err := st.RemoveApp(host, name); err != nil { - return fmt.Errorf("error updating state: %w", err) - } - if err := st.Save(); err != nil { - return fmt.Errorf("error saving state: %w", err) - } - - fmt.Println("Deployment removed successfully") - return nil -} diff --git a/cmd/deploy/restart.go b/cmd/deploy/restart.go deleted file mode 100644 index d1cfa86..0000000 --- a/cmd/deploy/restart.go +++ /dev/null @@ -1,57 +0,0 @@ -package main - -import ( - "fmt" - - "github.com/bdw/deploy/internal/ssh" - "github.com/bdw/deploy/internal/state" - "github.com/spf13/cobra" -) - -var restartCmd = &cobra.Command{ - Use: "restart ", - Short: "Restart a deployment", - Args: cobra.ExactArgs(1), - RunE: runRestart, -} - -func runRestart(cmd *cobra.Command, args []string) error { - name := args[0] - - st, err := state.Load() - if err != nil { - return fmt.Errorf("error loading state: %w", err) - } - - host := hostFlag - if host == "" { - host = st.GetDefaultHost() - } - - if host == "" { - return fmt.Errorf("--host is required") - } - - app, err := st.GetApp(host, name) - if err != nil { - return err - } - - if app.Type != "app" { - return fmt.Errorf("restart is only available for apps, not static sites") - } - - client, err := ssh.Connect(host) - if err != nil { - return fmt.Errorf("error connecting to VPS: %w", err) - } - defer client.Close() - - fmt.Printf("Restarting %s...\n", name) - if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { - return fmt.Errorf("error restarting service: %w", err) - } - - fmt.Println("Service restarted successfully") - return nil -} diff --git a/cmd/deploy/root.go b/cmd/deploy/root.go deleted file mode 100644 index adbc7c8..0000000 --- a/cmd/deploy/root.go +++ /dev/null @@ -1,377 +0,0 @@ -package main - -import ( - "bufio" - "fmt" - "os" - "path/filepath" - "strconv" - "strings" - - "github.com/bdw/deploy/internal/ssh" - "github.com/bdw/deploy/internal/state" - "github.com/bdw/deploy/internal/templates" - "github.com/spf13/cobra" -) - -func runDeploy(cmd *cobra.Command, args []string) error { - flags := cmd.Flags() - - binary, _ := flags.GetString("binary") - static, _ := flags.GetBool("static") - dir, _ := flags.GetString("dir") - domain, _ := flags.GetString("domain") - name, _ := flags.GetString("name") - port, _ := flags.GetInt("port") - envVars, _ := flags.GetStringArray("env") - envFile, _ := flags.GetString("env-file") - binaryArgs, _ := flags.GetString("args") - files, _ := flags.GetStringArray("file") - - // Get host from flag or state default - host := hostFlag - if host == "" { - st, err := state.Load() - if err != nil { - return fmt.Errorf("error loading state: %w", err) - } - host = st.GetDefaultHost() - } - - // If no flags provided, show help - if domain == "" && binary == "" && !static { - return cmd.Help() - } - - if host == "" || domain == "" { - return fmt.Errorf("--host and --domain are required") - } - - if static { - return deployStatic(host, domain, name, dir) - } - return deployApp(host, domain, name, binary, port, envVars, envFile, binaryArgs, files) -} - -func deployApp(host, domain, name, binaryPath string, portOverride int, envVars []string, envFile, args string, files []string) error { - if binaryPath == "" { - return fmt.Errorf("--binary is required") - } - - if name == "" { - name = filepath.Base(binaryPath) - } - - if _, err := os.Stat(binaryPath); err != nil { - return fmt.Errorf("binary not found: %s", binaryPath) - } - - fmt.Printf("Deploying app: %s\n", name) - fmt.Printf(" Domain: %s\n", domain) - fmt.Printf(" Binary: %s\n", binaryPath) - - st, err := state.Load() - if err != nil { - return fmt.Errorf("error loading state: %w", err) - } - - existingApp, _ := st.GetApp(host, name) - var port int - if existingApp != nil { - port = existingApp.Port - fmt.Printf(" Updating existing deployment (port %d)\n", port) - } else { - if portOverride > 0 { - port = portOverride - } else { - port = st.AllocatePort(host) - } - fmt.Printf(" Allocated port: %d\n", port) - } - - env := make(map[string]string) - if existingApp != nil { - for k, v := range existingApp.Env { - env[k] = v - } - if args == "" && existingApp.Args != "" { - args = existingApp.Args - } - if len(files) == 0 && len(existingApp.Files) > 0 { - files = existingApp.Files - } - } - - for _, e := range envVars { - parts := strings.SplitN(e, "=", 2) - if len(parts) == 2 { - env[parts[0]] = parts[1] - } - } - - if envFile != "" { - fileEnv, err := parseEnvFile(envFile) - if err != nil { - return fmt.Errorf("error reading env file: %w", err) - } - for k, v := range fileEnv { - env[k] = v - } - } - - env["PORT"] = strconv.Itoa(port) - - client, err := ssh.Connect(host) - if err != nil { - return fmt.Errorf("error connecting to VPS: %w", err) - } - defer client.Close() - - fmt.Println("-> Uploading binary...") - remoteTmpPath := fmt.Sprintf("/tmp/%s", name) - if err := client.Upload(binaryPath, remoteTmpPath); err != nil { - return fmt.Errorf("error uploading binary: %w", err) - } - - fmt.Println("-> Creating system user...") - client.RunSudo(fmt.Sprintf("useradd -r -s /bin/false %s", name)) - - fmt.Println("-> Setting up directories...") - workDir := fmt.Sprintf("/var/lib/%s", name) - if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", workDir)); err != nil { - return fmt.Errorf("error creating work directory: %w", err) - } - if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, workDir)); err != nil { - return fmt.Errorf("error setting work directory ownership: %w", err) - } - - fmt.Println("-> Installing binary...") - binaryDest := fmt.Sprintf("/usr/local/bin/%s", name) - if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", remoteTmpPath, binaryDest)); err != nil { - return fmt.Errorf("error moving binary: %w", err) - } - if _, err := client.RunSudo(fmt.Sprintf("chmod +x %s", binaryDest)); err != nil { - return fmt.Errorf("error making binary executable: %w", err) - } - - if len(files) > 0 { - fmt.Println("-> Uploading config files...") - for _, file := range files { - if _, err := os.Stat(file); err != nil { - return fmt.Errorf("config file not found: %s", file) - } - - remotePath := fmt.Sprintf("%s/%s", workDir, filepath.Base(file)) - remoteTmpPath := fmt.Sprintf("/tmp/%s_%s", name, filepath.Base(file)) - - if err := client.Upload(file, remoteTmpPath); err != nil { - return fmt.Errorf("error uploading config file %s: %w", file, err) - } - - if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", remoteTmpPath, remotePath)); err != nil { - return fmt.Errorf("error moving config file %s: %w", file, err) - } - - if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, remotePath)); err != nil { - return fmt.Errorf("error setting config file ownership %s: %w", file, err) - } - - fmt.Printf(" Uploaded: %s\n", file) - } - } - - fmt.Println("-> Creating environment file...") - envFilePath := fmt.Sprintf("/etc/deploy/env/%s.env", name) - envContent := "" - for k, v := range env { - envContent += fmt.Sprintf("%s=%s\n", k, v) - } - if err := client.WriteSudoFile(envFilePath, envContent); err != nil { - return fmt.Errorf("error creating env file: %w", err) - } - if _, err := client.RunSudo(fmt.Sprintf("chmod 600 %s", envFilePath)); err != nil { - return fmt.Errorf("error setting env file permissions: %w", err) - } - if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, envFilePath)); err != nil { - return fmt.Errorf("error setting env file ownership: %w", err) - } - - fmt.Println("-> Creating systemd service...") - serviceContent, err := templates.SystemdService(map[string]string{ - "Name": name, - "User": name, - "WorkDir": workDir, - "BinaryPath": binaryDest, - "Port": strconv.Itoa(port), - "EnvFile": envFilePath, - "Args": args, - }) - if err != nil { - return fmt.Errorf("error generating systemd unit: %w", err) - } - - servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name) - if err := client.WriteSudoFile(servicePath, serviceContent); err != nil { - return fmt.Errorf("error creating systemd unit: %w", err) - } - - fmt.Println("-> Configuring Caddy...") - caddyContent, err := templates.AppCaddy(map[string]string{ - "Domain": domain, - "Port": strconv.Itoa(port), - }) - if err != nil { - return fmt.Errorf("error generating Caddy config: %w", err) - } - - caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) - if err := client.WriteSudoFile(caddyPath, caddyContent); err != nil { - return fmt.Errorf("error creating Caddy config: %w", err) - } - - fmt.Println("-> Reloading systemd...") - if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { - return fmt.Errorf("error reloading systemd: %w", err) - } - - fmt.Println("-> Starting service...") - if _, err := client.RunSudo(fmt.Sprintf("systemctl enable %s", name)); err != nil { - return fmt.Errorf("error enabling service: %w", err) - } - if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { - return fmt.Errorf("error starting service: %w", err) - } - - fmt.Println("-> Reloading Caddy...") - if _, err := client.RunSudo("systemctl reload caddy"); err != nil { - return fmt.Errorf("error reloading Caddy: %w", err) - } - - st.AddApp(host, name, &state.App{ - Type: "app", - Domain: domain, - Port: port, - Env: env, - Args: args, - Files: files, - }) - if err := st.Save(); err != nil { - return fmt.Errorf("error saving state: %w", err) - } - - fmt.Printf("\n App deployed successfully!\n") - fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", domain) - return nil -} - -func deployStatic(host, domain, name, dir string) error { - if name == "" { - name = domain - } - - if _, err := os.Stat(dir); err != nil { - return fmt.Errorf("directory not found: %s", dir) - } - - fmt.Printf("Deploying static site: %s\n", name) - fmt.Printf(" Domain: %s\n", domain) - fmt.Printf(" Directory: %s\n", dir) - - st, err := state.Load() - if err != nil { - return fmt.Errorf("error loading state: %w", err) - } - - client, err := ssh.Connect(host) - if err != nil { - return fmt.Errorf("error connecting to VPS: %w", err) - } - defer client.Close() - - remoteDir := fmt.Sprintf("/var/www/%s", name) - fmt.Println("-> Creating remote directory...") - if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", remoteDir)); err != nil { - return fmt.Errorf("error creating remote directory: %w", err) - } - - currentUser, err := client.Run("whoami") - if err != nil { - return fmt.Errorf("error getting current user: %w", err) - } - currentUser = strings.TrimSpace(currentUser) - - if _, err := client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", currentUser, currentUser, remoteDir)); err != nil { - return fmt.Errorf("error setting temporary ownership: %w", err) - } - - fmt.Println("-> Uploading files...") - if err := client.UploadDir(dir, remoteDir); err != nil { - return fmt.Errorf("error uploading files: %w", err) - } - - fmt.Println("-> Setting permissions...") - if _, err := client.RunSudo(fmt.Sprintf("chown -R www-data:www-data %s", remoteDir)); err != nil { - return fmt.Errorf("error setting ownership: %w", err) - } - if _, err := client.RunSudo(fmt.Sprintf("chmod -R 755 %s", remoteDir)); err != nil { - return fmt.Errorf("error setting directory permissions: %w", err) - } - if _, err := client.RunSudo(fmt.Sprintf("find %s -type f -exec chmod 644 {} \\;", remoteDir)); err != nil { - return fmt.Errorf("error setting file permissions: %w", err) - } - - fmt.Println("-> Configuring Caddy...") - caddyContent, err := templates.StaticCaddy(map[string]string{ - "Domain": domain, - "RootDir": remoteDir, - }) - if err != nil { - return fmt.Errorf("error generating Caddy config: %w", err) - } - - caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) - if err := client.WriteSudoFile(caddyPath, caddyContent); err != nil { - return fmt.Errorf("error creating Caddy config: %w", err) - } - - fmt.Println("-> Reloading Caddy...") - if _, err := client.RunSudo("systemctl reload caddy"); err != nil { - return fmt.Errorf("error reloading Caddy: %w", err) - } - - st.AddApp(host, name, &state.App{ - Type: "static", - Domain: domain, - }) - if err := st.Save(); err != nil { - return fmt.Errorf("error saving state: %w", err) - } - - fmt.Printf("\n Static site deployed successfully!\n") - fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", domain) - return nil -} - -func parseEnvFile(path string) (map[string]string, error) { - file, err := os.Open(path) - if err != nil { - return nil, err - } - defer file.Close() - - env := make(map[string]string) - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - - parts := strings.SplitN(line, "=", 2) - if len(parts) == 2 { - env[parts[0]] = parts[1] - } - } - - return env, scanner.Err() -} diff --git a/cmd/deploy/status.go b/cmd/deploy/status.go deleted file mode 100644 index 4bcfc68..0000000 --- a/cmd/deploy/status.go +++ /dev/null @@ -1,60 +0,0 @@ -package main - -import ( - "fmt" - - "github.com/bdw/deploy/internal/ssh" - "github.com/bdw/deploy/internal/state" - "github.com/spf13/cobra" -) - -var statusCmd = &cobra.Command{ - Use: "status ", - Short: "Check status of a deployment", - Args: cobra.ExactArgs(1), - RunE: runStatus, -} - -func runStatus(cmd *cobra.Command, args []string) error { - name := args[0] - - st, err := state.Load() - if err != nil { - return fmt.Errorf("error loading state: %w", err) - } - - host := hostFlag - if host == "" { - host = st.GetDefaultHost() - } - - if host == "" { - return fmt.Errorf("--host is required") - } - - app, err := st.GetApp(host, name) - if err != nil { - return err - } - - if app.Type != "app" { - return fmt.Errorf("status is only available for apps, not static sites") - } - - client, err := ssh.Connect(host) - if err != nil { - return fmt.Errorf("error connecting to VPS: %w", err) - } - defer client.Close() - - output, err := client.RunSudo(fmt.Sprintf("systemctl status %s", name)) - if err != nil { - // systemctl status returns non-zero for non-active services - // but we still want to show the output - fmt.Print(output) - return nil - } - - fmt.Print(output) - return nil -} diff --git a/cmd/deploy/templates/webui.html b/cmd/deploy/templates/webui.html deleted file mode 100644 index 052d599..0000000 --- a/cmd/deploy/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 -
-
- - - - - - - diff --git a/cmd/deploy/ui.go b/cmd/deploy/ui.go deleted file mode 100644 index 2ca88e0..0000000 --- a/cmd/deploy/ui.go +++ /dev/null @@ -1,199 +0,0 @@ -package main - -import ( - "embed" - "encoding/json" - "fmt" - "html/template" - "net/http" - "sort" - "strconv" - - "github.com/bdw/deploy/internal/state" - "github.com/bdw/deploy/internal/templates" - "github.com/spf13/cobra" -) - -//go:embed templates/*.html -var templatesFS embed.FS - -var uiCmd = &cobra.Command{ - Use: "ui", - Short: "Launch web management UI", - RunE: runUI, -} - -func init() { - uiCmd.Flags().StringP("port", "p", "8080", "Port to run the web UI on") -} - -func runUI(cmd *cobra.Command, args []string) error { - port, _ := cmd.Flags().GetString("port") - - tmpl, err := template.ParseFS(templatesFS, "templates/webui.html") - if err != nil { - return fmt.Errorf("error parsing template: %w", err) - } - - http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - st, err := state.Load() - if err != nil { - http.Error(w, fmt.Sprintf("Error loading state: %v", err), http.StatusInternalServerError) - return - } - - type AppData struct { - Name string - Type string - Domain string - Port int - Env map[string]string - Host string - } - - type HostData struct { - Host string - Apps []AppData - } - - var hosts []HostData - for hostName, host := range st.Hosts { - var apps []AppData - for appName, app := range host.Apps { - apps = append(apps, AppData{ - Name: appName, - Type: app.Type, - Domain: app.Domain, - Port: app.Port, - Env: app.Env, - Host: hostName, - }) - } - - sort.Slice(apps, func(i, j int) bool { - return apps[i].Name < apps[j].Name - }) - - hosts = append(hosts, HostData{ - Host: hostName, - Apps: apps, - }) - } - - sort.Slice(hosts, func(i, j int) bool { - return hosts[i].Host < hosts[j].Host - }) - - data := struct { - Hosts []HostData - }{ - Hosts: hosts, - } - - if err := tmpl.Execute(w, data); err != nil { - http.Error(w, fmt.Sprintf("Error rendering template: %v", err), http.StatusInternalServerError) - return - } - }) - - http.HandleFunc("/api/state", func(w http.ResponseWriter, r *http.Request) { - st, err := state.Load() - if err != nil { - http.Error(w, fmt.Sprintf("Error loading state: %v", err), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(st) - }) - - http.HandleFunc("/api/configs", func(w http.ResponseWriter, r *http.Request) { - host := r.URL.Query().Get("host") - appName := r.URL.Query().Get("app") - - if host == "" || appName == "" { - http.Error(w, "Missing host or app parameter", http.StatusBadRequest) - return - } - - st, err := state.Load() - if err != nil { - http.Error(w, fmt.Sprintf("Error loading state: %v", err), http.StatusInternalServerError) - return - } - - app, err := st.GetApp(host, appName) - if err != nil { - http.Error(w, fmt.Sprintf("App not found: %v", err), http.StatusNotFound) - return - } - - configs := make(map[string]string) - - if app.Env != nil && len(app.Env) > 0 { - envContent := "" - for k, v := range app.Env { - envContent += fmt.Sprintf("%s=%s\n", k, v) - } - configs["env"] = envContent - configs["envPath"] = fmt.Sprintf("/etc/deploy/env/%s.env", appName) - } - - if app.Type == "app" { - workDir := fmt.Sprintf("/var/lib/%s", appName) - binaryPath := fmt.Sprintf("/usr/local/bin/%s", appName) - envFilePath := fmt.Sprintf("/etc/deploy/env/%s.env", appName) - - serviceContent, err := templates.SystemdService(map[string]string{ - "Name": appName, - "User": appName, - "WorkDir": workDir, - "BinaryPath": binaryPath, - "Port": strconv.Itoa(app.Port), - "EnvFile": envFilePath, - "Args": app.Args, - }) - if err != nil { - http.Error(w, fmt.Sprintf("Error rendering systemd service: %v", err), http.StatusInternalServerError) - return - } - configs["systemd"] = serviceContent - configs["systemdPath"] = fmt.Sprintf("/etc/systemd/system/%s.service", appName) - - caddyContent, err := templates.AppCaddy(map[string]string{ - "Domain": app.Domain, - "Port": strconv.Itoa(app.Port), - }) - if err != nil { - http.Error(w, fmt.Sprintf("Error rendering Caddy config: %v", err), http.StatusInternalServerError) - return - } - configs["caddy"] = caddyContent - configs["caddyPath"] = fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", appName) - } else if app.Type == "static" { - remoteDir := fmt.Sprintf("/var/www/%s", appName) - caddyContent, err := templates.StaticCaddy(map[string]string{ - "Domain": app.Domain, - "RootDir": remoteDir, - }) - if err != nil { - http.Error(w, fmt.Sprintf("Error rendering Caddy config: %v", err), http.StatusInternalServerError) - return - } - configs["caddy"] = caddyContent - configs["caddyPath"] = fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", appName) - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(configs) - }) - - addr := fmt.Sprintf("localhost:%s", port) - fmt.Printf("Starting web UI on http://%s\n", addr) - fmt.Printf("Press Ctrl+C to stop\n") - - if err := http.ListenAndServe(addr, nil); err != nil { - return fmt.Errorf("error starting server: %w", err) - } - return nil -} diff --git a/cmd/deploy/version.go b/cmd/deploy/version.go deleted file mode 100644 index d2cd430..0000000 --- a/cmd/deploy/version.go +++ /dev/null @@ -1,17 +0,0 @@ -package main - -import ( - "fmt" - - "github.com/spf13/cobra" -) - -var versionCmd = &cobra.Command{ - Use: "version", - Short: "Show version information", - Run: func(cmd *cobra.Command, args []string) { - fmt.Printf("deploy version %s\n", version) - fmt.Printf(" commit: %s\n", commit) - fmt.Printf(" built: %s\n", date) - }, -} diff --git a/cmd/ship/env/env.go b/cmd/ship/env/env.go new file mode 100644 index 0000000..489353a --- /dev/null +++ b/cmd/ship/env/env.go @@ -0,0 +1,17 @@ +package env + +import ( + "github.com/spf13/cobra" +) + +var Cmd = &cobra.Command{ + Use: "env", + Short: "Manage environment variables", + Long: "Manage environment variables for deployed applications", +} + +func init() { + Cmd.AddCommand(listCmd) + Cmd.AddCommand(setCmd) + Cmd.AddCommand(unsetCmd) +} diff --git a/cmd/ship/env/list.go b/cmd/ship/env/list.go new file mode 100644 index 0000000..ad76eb6 --- /dev/null +++ b/cmd/ship/env/list.go @@ -0,0 +1,69 @@ +package env + +import ( + "fmt" + "strings" + + "github.com/bdw/ship/internal/state" + "github.com/spf13/cobra" +) + +var listCmd = &cobra.Command{ + Use: "list ", + Short: "List environment variables for an app", + Args: cobra.ExactArgs(1), + RunE: runList, +} + +func runList(cmd *cobra.Command, args []string) error { + name := args[0] + + 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") + } + + app, err := st.GetApp(host, name) + if err != nil { + return err + } + + if app.Type != "app" { + return fmt.Errorf("env is only available for apps, not static sites") + } + + fmt.Printf("Environment variables for %s:\n\n", name) + if len(app.Env) == 0 { + fmt.Println(" (none)") + } else { + for k, v := range app.Env { + display := v + if isSensitive(k) { + display = "***" + } + fmt.Printf(" %s=%s\n", k, display) + } + } + + return nil +} + +func isSensitive(key string) bool { + key = strings.ToLower(key) + sensitiveWords := []string{"key", "secret", "password", "token", "api"} + for _, word := range sensitiveWords { + if strings.Contains(key, word) { + return true + } + } + return false +} diff --git a/cmd/ship/env/set.go b/cmd/ship/env/set.go new file mode 100644 index 0000000..e11d2c9 --- /dev/null +++ b/cmd/ship/env/set.go @@ -0,0 +1,132 @@ +package env + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/bdw/ship/internal/ssh" + "github.com/bdw/ship/internal/state" + "github.com/spf13/cobra" +) + +var setCmd = &cobra.Command{ + Use: "set KEY=VALUE...", + Short: "Set environment variable(s)", + Long: "Set one or more environment variables for an app. Variables are specified as KEY=VALUE pairs.", + Args: cobra.MinimumNArgs(2), + RunE: runSet, +} + +func init() { + setCmd.Flags().StringP("file", "f", "", "Load environment from file") +} + +func runSet(cmd *cobra.Command, args []string) error { + name := args[0] + envVars := args[1:] + + 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") + } + + app, err := st.GetApp(host, name) + if err != nil { + return err + } + + if app.Type != "app" { + return fmt.Errorf("env is only available for apps, not static sites") + } + + if app.Env == nil { + app.Env = make(map[string]string) + } + + // Set variables from args + for _, e := range envVars { + parts := strings.SplitN(e, "=", 2) + if len(parts) == 2 { + app.Env[parts[0]] = parts[1] + fmt.Printf("Set %s\n", parts[0]) + } else { + return fmt.Errorf("invalid format: %s (expected KEY=VALUE)", e) + } + } + + // Set variables from file if provided + envFile, _ := cmd.Flags().GetString("file") + if envFile != "" { + fileEnv, err := parseEnvFile(envFile) + if err != nil { + return fmt.Errorf("error reading env file: %w", err) + } + for k, v := range fileEnv { + app.Env[k] = v + fmt.Printf("Set %s\n", k) + } + } + + if err := st.Save(); err != nil { + return fmt.Errorf("error saving state: %w", err) + } + + client, err := ssh.Connect(host) + if err != nil { + return fmt.Errorf("error connecting to VPS: %w", err) + } + defer client.Close() + + fmt.Println("-> Updating environment file on VPS...") + envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", name) + envContent := "" + for k, v := range app.Env { + envContent += fmt.Sprintf("%s=%s\n", k, v) + } + if err := client.WriteSudoFile(envFilePath, envContent); err != nil { + return fmt.Errorf("error updating env file: %w", err) + } + + fmt.Println("-> Restarting service...") + if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { + return fmt.Errorf("error restarting service: %w", err) + } + + fmt.Println("Environment variables updated successfully") + return nil +} + +func parseEnvFile(path string) (map[string]string, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + env := make(map[string]string) + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + env[parts[0]] = parts[1] + } + } + + return env, scanner.Err() +} diff --git a/cmd/ship/env/unset.go b/cmd/ship/env/unset.go new file mode 100644 index 0000000..7d9a141 --- /dev/null +++ b/cmd/ship/env/unset.go @@ -0,0 +1,92 @@ +package env + +import ( + "fmt" + + "github.com/bdw/ship/internal/ssh" + "github.com/bdw/ship/internal/state" + "github.com/spf13/cobra" +) + +var unsetCmd = &cobra.Command{ + Use: "unset KEY...", + Short: "Unset environment variable(s)", + Long: "Remove one or more environment variables from an app.", + Args: cobra.MinimumNArgs(2), + RunE: runUnset, +} + +func runUnset(cmd *cobra.Command, args []string) error { + name := args[0] + keys := args[1:] + + 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") + } + + app, err := st.GetApp(host, name) + if err != nil { + return err + } + + if app.Type != "app" { + return fmt.Errorf("env is only available for apps, not static sites") + } + + if app.Env == nil { + return fmt.Errorf("no environment variables set") + } + + changed := false + for _, key := range keys { + if _, exists := app.Env[key]; exists { + delete(app.Env, key) + changed = true + fmt.Printf("Unset %s\n", key) + } else { + fmt.Printf("Warning: %s not found\n", key) + } + } + + if !changed { + return nil + } + + if err := st.Save(); err != nil { + return fmt.Errorf("error saving state: %w", err) + } + + client, err := ssh.Connect(host) + if err != nil { + return fmt.Errorf("error connecting to VPS: %w", err) + } + defer client.Close() + + fmt.Println("-> Updating environment file on VPS...") + envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", name) + envContent := "" + for k, v := range app.Env { + envContent += fmt.Sprintf("%s=%s\n", k, v) + } + if err := client.WriteSudoFile(envFilePath, envContent); err != nil { + return fmt.Errorf("error updating env file: %w", err) + } + + fmt.Println("-> Restarting service...") + if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { + return fmt.Errorf("error restarting service: %w", err) + } + + fmt.Println("Environment variables updated successfully") + return nil +} diff --git a/cmd/ship/host/host.go b/cmd/ship/host/host.go new file mode 100644 index 0000000..603a946 --- /dev/null +++ b/cmd/ship/host/host.go @@ -0,0 +1,18 @@ +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) +} diff --git a/cmd/ship/host/init.go b/cmd/ship/host/init.go new file mode 100644 index 0000000..ea25922 --- /dev/null +++ b/cmd/ship/host/init.go @@ -0,0 +1,137 @@ +package host + +import ( + "fmt" + "strings" + + "github.com/bdw/ship/internal/ssh" + "github.com/bdw/ship/internal/state" + "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() + } + + 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 := `{ + email admin@example.com +} + +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") + } + + st.GetHost(host) + 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") + 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/ssh.go b/cmd/ship/host/ssh.go new file mode 100644 index 0000000..e480e47 --- /dev/null +++ b/cmd/ship/host/ssh.go @@ -0,0 +1,45 @@ +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 new file mode 100644 index 0000000..eb2de53 --- /dev/null +++ b/cmd/ship/host/status.go @@ -0,0 +1,108 @@ +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 new file mode 100644 index 0000000..5f838b6 --- /dev/null +++ b/cmd/ship/host/update.go @@ -0,0 +1,93 @@ +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 +} diff --git a/cmd/ship/list.go b/cmd/ship/list.go new file mode 100644 index 0000000..a5b8df3 --- /dev/null +++ b/cmd/ship/list.go @@ -0,0 +1,50 @@ +package main + +import ( + "fmt" + "os" + "text/tabwriter" + + "github.com/bdw/ship/internal/state" + "github.com/spf13/cobra" +) + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List all deployed apps and sites", + RunE: runList, +} + +func runList(cmd *cobra.Command, args []string) error { + st, err := state.Load() + if err != nil { + return fmt.Errorf("error loading state: %w", err) + } + + host := hostFlag + if host == "" { + host = st.GetDefaultHost() + } + + if host == "" { + return fmt.Errorf("--host is required") + } + + apps := st.ListApps(host) + if len(apps) == 0 { + fmt.Printf("No deployments found for %s\n", host) + return nil + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) + fmt.Fprintln(w, "NAME\tTYPE\tDOMAIN\tPORT") + for name, app := range apps { + port := "" + if app.Type == "app" { + port = fmt.Sprintf(":%d", app.Port) + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", name, app.Type, app.Domain, port) + } + w.Flush() + return nil +} diff --git a/cmd/ship/logs.go b/cmd/ship/logs.go new file mode 100644 index 0000000..1932c18 --- /dev/null +++ b/cmd/ship/logs.go @@ -0,0 +1,75 @@ +package main + +import ( + "fmt" + + "github.com/bdw/ship/internal/ssh" + "github.com/bdw/ship/internal/state" + "github.com/spf13/cobra" +) + +var logsCmd = &cobra.Command{ + Use: "logs ", + Short: "View logs for a deployment", + Args: cobra.ExactArgs(1), + RunE: runLogs, +} + +func init() { + logsCmd.Flags().BoolP("follow", "f", false, "Follow logs") + logsCmd.Flags().IntP("lines", "n", 50, "Number of lines to show") +} + +func runLogs(cmd *cobra.Command, args []string) error { + name := args[0] + follow, _ := cmd.Flags().GetBool("follow") + lines, _ := cmd.Flags().GetInt("lines") + + st, err := state.Load() + if err != nil { + return fmt.Errorf("error loading state: %w", err) + } + + host := hostFlag + if host == "" { + host = st.GetDefaultHost() + } + + if host == "" { + return fmt.Errorf("--host is required") + } + + app, err := st.GetApp(host, name) + if err != nil { + return err + } + + if app.Type != "app" { + return fmt.Errorf("logs are only available for apps, not static sites") + } + + client, err := ssh.Connect(host) + if err != nil { + return fmt.Errorf("error connecting to VPS: %w", err) + } + defer client.Close() + + journalCmd := fmt.Sprintf("journalctl -u %s -n %d", name, lines) + if follow { + journalCmd += " -f" + } + + if follow { + if err := client.RunStream(journalCmd); err != nil { + return fmt.Errorf("error fetching logs: %w", err) + } + } else { + output, err := client.Run(journalCmd) + if err != nil { + return fmt.Errorf("error fetching logs: %w", err) + } + fmt.Print(output) + } + + return nil +} diff --git a/cmd/ship/main.go b/cmd/ship/main.go new file mode 100644 index 0000000..680ac58 --- /dev/null +++ b/cmd/ship/main.go @@ -0,0 +1,65 @@ +package main + +import ( + "os" + + "github.com/bdw/ship/cmd/ship/env" + "github.com/bdw/ship/cmd/ship/host" + "github.com/spf13/cobra" +) + +var ( + // Persistent flags + hostFlag string + + // Version info (set via ldflags) + version = "dev" + commit = "none" + date = "unknown" +) + +var rootCmd = &cobra.Command{ + Use: "ship", + Short: "Ship apps and static sites to a VPS with automatic HTTPS", + Long: `ship - Ship apps and static sites to a VPS with automatic HTTPS + +A CLI tool for deploying applications and static sites to a VPS. +Uses Caddy for automatic HTTPS and systemd for service management.`, + RunE: runDeploy, + SilenceUsage: true, + SilenceErrors: true, +} + +func init() { + // Persistent flags available to all subcommands + rootCmd.PersistentFlags().StringVar(&hostFlag, "host", "", "VPS host (SSH config alias or user@host)") + + // Root command (deploy) flags + rootCmd.Flags().String("binary", "", "Path to Go binary (for app deployment)") + rootCmd.Flags().Bool("static", false, "Deploy as static site") + rootCmd.Flags().String("dir", ".", "Directory to deploy (for static sites)") + rootCmd.Flags().String("domain", "", "Domain name (required)") + rootCmd.Flags().String("name", "", "App name (default: inferred from binary or directory)") + rootCmd.Flags().Int("port", 0, "Port override (default: auto-allocate)") + rootCmd.Flags().StringArray("env", nil, "Environment variable (KEY=VALUE, can be specified multiple times)") + rootCmd.Flags().String("env-file", "", "Path to .env file") + rootCmd.Flags().String("args", "", "Arguments to pass to binary") + rootCmd.Flags().StringArray("file", nil, "Config file to upload to working directory (can be specified multiple times)") + + // Add subcommands + rootCmd.AddCommand(listCmd) + rootCmd.AddCommand(logsCmd) + rootCmd.AddCommand(statusCmd) + rootCmd.AddCommand(restartCmd) + rootCmd.AddCommand(removeCmd) + rootCmd.AddCommand(env.Cmd) + rootCmd.AddCommand(host.Cmd) + rootCmd.AddCommand(uiCmd) + rootCmd.AddCommand(versionCmd) +} + +func main() { + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/cmd/ship/remove.go b/cmd/ship/remove.go new file mode 100644 index 0000000..922eb8f --- /dev/null +++ b/cmd/ship/remove.go @@ -0,0 +1,83 @@ +package main + +import ( + "fmt" + + "github.com/bdw/ship/internal/ssh" + "github.com/bdw/ship/internal/state" + "github.com/spf13/cobra" +) + +var removeCmd = &cobra.Command{ + Use: "remove ", + Aliases: []string{"rm"}, + Short: "Remove a deployment", + Args: cobra.ExactArgs(1), + RunE: runRemove, +} + +func runRemove(cmd *cobra.Command, args []string) error { + name := args[0] + + st, err := state.Load() + if err != nil { + return fmt.Errorf("error loading state: %w", err) + } + + host := hostFlag + if host == "" { + host = st.GetDefaultHost() + } + + if host == "" { + return fmt.Errorf("--host is required") + } + + app, err := st.GetApp(host, name) + if err != nil { + return err + } + + fmt.Printf("Removing deployment: %s\n", name) + + client, err := ssh.Connect(host) + if err != nil { + return fmt.Errorf("error connecting to VPS: %w", err) + } + defer client.Close() + + if app.Type == "app" { + fmt.Println("-> Stopping service...") + client.RunSudo(fmt.Sprintf("systemctl stop %s", name)) + client.RunSudo(fmt.Sprintf("systemctl disable %s", name)) + + client.RunSudo(fmt.Sprintf("rm -f /etc/systemd/system/%s.service", name)) + client.RunSudo("systemctl daemon-reload") + + client.RunSudo(fmt.Sprintf("rm -f /usr/local/bin/%s", name)) + client.RunSudo(fmt.Sprintf("rm -rf /var/lib/%s", name)) + client.RunSudo(fmt.Sprintf("rm -f /etc/ship/env/%s.env", name)) + client.RunSudo(fmt.Sprintf("userdel %s", name)) + } else { + fmt.Println("-> Removing files...") + client.RunSudo(fmt.Sprintf("rm -rf /var/www/%s", name)) + } + + fmt.Println("-> Removing Caddy config...") + client.RunSudo(fmt.Sprintf("rm -f /etc/caddy/sites-enabled/%s.caddy", name)) + + fmt.Println("-> Reloading Caddy...") + if _, err := client.RunSudo("systemctl reload caddy"); err != nil { + fmt.Printf("Warning: Error reloading Caddy: %v\n", err) + } + + if err := st.RemoveApp(host, name); err != nil { + return fmt.Errorf("error updating state: %w", err) + } + if err := st.Save(); err != nil { + return fmt.Errorf("error saving state: %w", err) + } + + fmt.Println("Deployment removed successfully") + return nil +} diff --git a/cmd/ship/restart.go b/cmd/ship/restart.go new file mode 100644 index 0000000..2c74c62 --- /dev/null +++ b/cmd/ship/restart.go @@ -0,0 +1,57 @@ +package main + +import ( + "fmt" + + "github.com/bdw/ship/internal/ssh" + "github.com/bdw/ship/internal/state" + "github.com/spf13/cobra" +) + +var restartCmd = &cobra.Command{ + Use: "restart ", + Short: "Restart a deployment", + Args: cobra.ExactArgs(1), + RunE: runRestart, +} + +func runRestart(cmd *cobra.Command, args []string) error { + name := args[0] + + st, err := state.Load() + if err != nil { + return fmt.Errorf("error loading state: %w", err) + } + + host := hostFlag + if host == "" { + host = st.GetDefaultHost() + } + + if host == "" { + return fmt.Errorf("--host is required") + } + + app, err := st.GetApp(host, name) + if err != nil { + return err + } + + if app.Type != "app" { + return fmt.Errorf("restart is only available for apps, not static sites") + } + + client, err := ssh.Connect(host) + if err != nil { + return fmt.Errorf("error connecting to VPS: %w", err) + } + defer client.Close() + + fmt.Printf("Restarting %s...\n", name) + if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { + return fmt.Errorf("error restarting service: %w", err) + } + + fmt.Println("Service restarted successfully") + return nil +} diff --git a/cmd/ship/root.go b/cmd/ship/root.go new file mode 100644 index 0000000..e5d6753 --- /dev/null +++ b/cmd/ship/root.go @@ -0,0 +1,377 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/bdw/ship/internal/ssh" + "github.com/bdw/ship/internal/state" + "github.com/bdw/ship/internal/templates" + "github.com/spf13/cobra" +) + +func runDeploy(cmd *cobra.Command, args []string) error { + flags := cmd.Flags() + + binary, _ := flags.GetString("binary") + static, _ := flags.GetBool("static") + dir, _ := flags.GetString("dir") + domain, _ := flags.GetString("domain") + name, _ := flags.GetString("name") + port, _ := flags.GetInt("port") + envVars, _ := flags.GetStringArray("env") + envFile, _ := flags.GetString("env-file") + binaryArgs, _ := flags.GetString("args") + files, _ := flags.GetStringArray("file") + + // Get host from flag or state default + host := hostFlag + if host == "" { + st, err := state.Load() + if err != nil { + return fmt.Errorf("error loading state: %w", err) + } + host = st.GetDefaultHost() + } + + // If no flags provided, show help + if domain == "" && binary == "" && !static { + return cmd.Help() + } + + if host == "" || domain == "" { + return fmt.Errorf("--host and --domain are required") + } + + if static { + return deployStatic(host, domain, name, dir) + } + return deployApp(host, domain, name, binary, port, envVars, envFile, binaryArgs, files) +} + +func deployApp(host, domain, name, binaryPath string, portOverride int, envVars []string, envFile, args string, files []string) error { + if binaryPath == "" { + return fmt.Errorf("--binary is required") + } + + if name == "" { + name = filepath.Base(binaryPath) + } + + if _, err := os.Stat(binaryPath); err != nil { + return fmt.Errorf("binary not found: %s", binaryPath) + } + + fmt.Printf("Deploying app: %s\n", name) + fmt.Printf(" Domain: %s\n", domain) + fmt.Printf(" Binary: %s\n", binaryPath) + + st, err := state.Load() + if err != nil { + return fmt.Errorf("error loading state: %w", err) + } + + existingApp, _ := st.GetApp(host, name) + var port int + if existingApp != nil { + port = existingApp.Port + fmt.Printf(" Updating existing deployment (port %d)\n", port) + } else { + if portOverride > 0 { + port = portOverride + } else { + port = st.AllocatePort(host) + } + fmt.Printf(" Allocated port: %d\n", port) + } + + env := make(map[string]string) + if existingApp != nil { + for k, v := range existingApp.Env { + env[k] = v + } + if args == "" && existingApp.Args != "" { + args = existingApp.Args + } + if len(files) == 0 && len(existingApp.Files) > 0 { + files = existingApp.Files + } + } + + for _, e := range envVars { + parts := strings.SplitN(e, "=", 2) + if len(parts) == 2 { + env[parts[0]] = parts[1] + } + } + + if envFile != "" { + fileEnv, err := parseEnvFile(envFile) + if err != nil { + return fmt.Errorf("error reading env file: %w", err) + } + for k, v := range fileEnv { + env[k] = v + } + } + + env["PORT"] = strconv.Itoa(port) + + client, err := ssh.Connect(host) + if err != nil { + return fmt.Errorf("error connecting to VPS: %w", err) + } + defer client.Close() + + fmt.Println("-> Uploading binary...") + remoteTmpPath := fmt.Sprintf("/tmp/%s", name) + if err := client.Upload(binaryPath, remoteTmpPath); err != nil { + return fmt.Errorf("error uploading binary: %w", err) + } + + fmt.Println("-> Creating system user...") + client.RunSudo(fmt.Sprintf("useradd -r -s /bin/false %s", name)) + + fmt.Println("-> Setting up directories...") + workDir := fmt.Sprintf("/var/lib/%s", name) + if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", workDir)); err != nil { + return fmt.Errorf("error creating work directory: %w", err) + } + if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, workDir)); err != nil { + return fmt.Errorf("error setting work directory ownership: %w", err) + } + + fmt.Println("-> Installing binary...") + binaryDest := fmt.Sprintf("/usr/local/bin/%s", name) + if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", remoteTmpPath, binaryDest)); err != nil { + return fmt.Errorf("error moving binary: %w", err) + } + if _, err := client.RunSudo(fmt.Sprintf("chmod +x %s", binaryDest)); err != nil { + return fmt.Errorf("error making binary executable: %w", err) + } + + if len(files) > 0 { + fmt.Println("-> Uploading config files...") + for _, file := range files { + if _, err := os.Stat(file); err != nil { + return fmt.Errorf("config file not found: %s", file) + } + + remotePath := fmt.Sprintf("%s/%s", workDir, filepath.Base(file)) + remoteTmpPath := fmt.Sprintf("/tmp/%s_%s", name, filepath.Base(file)) + + if err := client.Upload(file, remoteTmpPath); err != nil { + return fmt.Errorf("error uploading config file %s: %w", file, err) + } + + if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", remoteTmpPath, remotePath)); err != nil { + return fmt.Errorf("error moving config file %s: %w", file, err) + } + + if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, remotePath)); err != nil { + return fmt.Errorf("error setting config file ownership %s: %w", file, err) + } + + fmt.Printf(" Uploaded: %s\n", file) + } + } + + fmt.Println("-> Creating environment file...") + envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", name) + envContent := "" + for k, v := range env { + envContent += fmt.Sprintf("%s=%s\n", k, v) + } + if err := client.WriteSudoFile(envFilePath, envContent); err != nil { + return fmt.Errorf("error creating env file: %w", err) + } + if _, err := client.RunSudo(fmt.Sprintf("chmod 600 %s", envFilePath)); err != nil { + return fmt.Errorf("error setting env file permissions: %w", err) + } + if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, envFilePath)); err != nil { + return fmt.Errorf("error setting env file ownership: %w", err) + } + + fmt.Println("-> Creating systemd service...") + serviceContent, err := templates.SystemdService(map[string]string{ + "Name": name, + "User": name, + "WorkDir": workDir, + "BinaryPath": binaryDest, + "Port": strconv.Itoa(port), + "EnvFile": envFilePath, + "Args": args, + }) + if err != nil { + return fmt.Errorf("error generating systemd unit: %w", err) + } + + servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name) + if err := client.WriteSudoFile(servicePath, serviceContent); err != nil { + return fmt.Errorf("error creating systemd unit: %w", err) + } + + fmt.Println("-> Configuring Caddy...") + caddyContent, err := templates.AppCaddy(map[string]string{ + "Domain": domain, + "Port": strconv.Itoa(port), + }) + if err != nil { + return fmt.Errorf("error generating Caddy config: %w", err) + } + + caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) + if err := client.WriteSudoFile(caddyPath, caddyContent); err != nil { + return fmt.Errorf("error creating Caddy config: %w", err) + } + + fmt.Println("-> Reloading systemd...") + if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { + return fmt.Errorf("error reloading systemd: %w", err) + } + + fmt.Println("-> Starting service...") + if _, err := client.RunSudo(fmt.Sprintf("systemctl enable %s", name)); err != nil { + return fmt.Errorf("error enabling service: %w", err) + } + if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { + return fmt.Errorf("error starting service: %w", err) + } + + fmt.Println("-> Reloading Caddy...") + if _, err := client.RunSudo("systemctl reload caddy"); err != nil { + return fmt.Errorf("error reloading Caddy: %w", err) + } + + st.AddApp(host, name, &state.App{ + Type: "app", + Domain: domain, + Port: port, + Env: env, + Args: args, + Files: files, + }) + if err := st.Save(); err != nil { + return fmt.Errorf("error saving state: %w", err) + } + + fmt.Printf("\n App deployed successfully!\n") + fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", domain) + return nil +} + +func deployStatic(host, domain, name, dir string) error { + if name == "" { + name = domain + } + + if _, err := os.Stat(dir); err != nil { + return fmt.Errorf("directory not found: %s", dir) + } + + fmt.Printf("Deploying static site: %s\n", name) + fmt.Printf(" Domain: %s\n", domain) + fmt.Printf(" Directory: %s\n", dir) + + st, err := state.Load() + if err != nil { + return fmt.Errorf("error loading state: %w", err) + } + + client, err := ssh.Connect(host) + if err != nil { + return fmt.Errorf("error connecting to VPS: %w", err) + } + defer client.Close() + + remoteDir := fmt.Sprintf("/var/www/%s", name) + fmt.Println("-> Creating remote directory...") + if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", remoteDir)); err != nil { + return fmt.Errorf("error creating remote directory: %w", err) + } + + currentUser, err := client.Run("whoami") + if err != nil { + return fmt.Errorf("error getting current user: %w", err) + } + currentUser = strings.TrimSpace(currentUser) + + if _, err := client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", currentUser, currentUser, remoteDir)); err != nil { + return fmt.Errorf("error setting temporary ownership: %w", err) + } + + fmt.Println("-> Uploading files...") + if err := client.UploadDir(dir, remoteDir); err != nil { + return fmt.Errorf("error uploading files: %w", err) + } + + fmt.Println("-> Setting permissions...") + if _, err := client.RunSudo(fmt.Sprintf("chown -R www-data:www-data %s", remoteDir)); err != nil { + return fmt.Errorf("error setting ownership: %w", err) + } + if _, err := client.RunSudo(fmt.Sprintf("chmod -R 755 %s", remoteDir)); err != nil { + return fmt.Errorf("error setting directory permissions: %w", err) + } + if _, err := client.RunSudo(fmt.Sprintf("find %s -type f -exec chmod 644 {} \\;", remoteDir)); err != nil { + return fmt.Errorf("error setting file permissions: %w", err) + } + + fmt.Println("-> Configuring Caddy...") + caddyContent, err := templates.StaticCaddy(map[string]string{ + "Domain": domain, + "RootDir": remoteDir, + }) + if err != nil { + return fmt.Errorf("error generating Caddy config: %w", err) + } + + caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) + if err := client.WriteSudoFile(caddyPath, caddyContent); err != nil { + return fmt.Errorf("error creating Caddy config: %w", err) + } + + fmt.Println("-> Reloading Caddy...") + if _, err := client.RunSudo("systemctl reload caddy"); err != nil { + return fmt.Errorf("error reloading Caddy: %w", err) + } + + st.AddApp(host, name, &state.App{ + Type: "static", + Domain: domain, + }) + if err := st.Save(); err != nil { + return fmt.Errorf("error saving state: %w", err) + } + + fmt.Printf("\n Static site deployed successfully!\n") + fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", domain) + return nil +} + +func parseEnvFile(path string) (map[string]string, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + env := make(map[string]string) + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + env[parts[0]] = parts[1] + } + } + + return env, scanner.Err() +} diff --git a/cmd/ship/status.go b/cmd/ship/status.go new file mode 100644 index 0000000..03c548b --- /dev/null +++ b/cmd/ship/status.go @@ -0,0 +1,60 @@ +package main + +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: "Check status of a deployment", + Args: cobra.ExactArgs(1), + RunE: runStatus, +} + +func runStatus(cmd *cobra.Command, args []string) error { + name := args[0] + + st, err := state.Load() + if err != nil { + return fmt.Errorf("error loading state: %w", err) + } + + host := hostFlag + if host == "" { + host = st.GetDefaultHost() + } + + if host == "" { + return fmt.Errorf("--host is required") + } + + app, err := st.GetApp(host, name) + if err != nil { + return err + } + + if app.Type != "app" { + return fmt.Errorf("status is only available for apps, not static sites") + } + + client, err := ssh.Connect(host) + if err != nil { + return fmt.Errorf("error connecting to VPS: %w", err) + } + defer client.Close() + + output, err := client.RunSudo(fmt.Sprintf("systemctl status %s", name)) + if err != nil { + // systemctl status returns non-zero for non-active services + // but we still want to show the output + fmt.Print(output) + return nil + } + + fmt.Print(output) + return nil +} diff --git a/cmd/ship/templates/webui.html b/cmd/ship/templates/webui.html new file mode 100644 index 0000000..052d599 --- /dev/null +++ b/cmd/ship/templates/webui.html @@ -0,0 +1,440 @@ + + + + + + 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 +
+
+ + + + + + + diff --git a/cmd/ship/ui.go b/cmd/ship/ui.go new file mode 100644 index 0000000..cfaea08 --- /dev/null +++ b/cmd/ship/ui.go @@ -0,0 +1,199 @@ +package main + +import ( + "embed" + "encoding/json" + "fmt" + "html/template" + "net/http" + "sort" + "strconv" + + "github.com/bdw/ship/internal/state" + "github.com/bdw/ship/internal/templates" + "github.com/spf13/cobra" +) + +//go:embed templates/*.html +var templatesFS embed.FS + +var uiCmd = &cobra.Command{ + Use: "ui", + Short: "Launch web management UI", + RunE: runUI, +} + +func init() { + uiCmd.Flags().StringP("port", "p", "8080", "Port to run the web UI on") +} + +func runUI(cmd *cobra.Command, args []string) error { + port, _ := cmd.Flags().GetString("port") + + tmpl, err := template.ParseFS(templatesFS, "templates/webui.html") + if err != nil { + return fmt.Errorf("error parsing template: %w", err) + } + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + st, err := state.Load() + if err != nil { + http.Error(w, fmt.Sprintf("Error loading state: %v", err), http.StatusInternalServerError) + return + } + + type AppData struct { + Name string + Type string + Domain string + Port int + Env map[string]string + Host string + } + + type HostData struct { + Host string + Apps []AppData + } + + var hosts []HostData + for hostName, host := range st.Hosts { + var apps []AppData + for appName, app := range host.Apps { + apps = append(apps, AppData{ + Name: appName, + Type: app.Type, + Domain: app.Domain, + Port: app.Port, + Env: app.Env, + Host: hostName, + }) + } + + sort.Slice(apps, func(i, j int) bool { + return apps[i].Name < apps[j].Name + }) + + hosts = append(hosts, HostData{ + Host: hostName, + Apps: apps, + }) + } + + sort.Slice(hosts, func(i, j int) bool { + return hosts[i].Host < hosts[j].Host + }) + + data := struct { + Hosts []HostData + }{ + Hosts: hosts, + } + + if err := tmpl.Execute(w, data); err != nil { + http.Error(w, fmt.Sprintf("Error rendering template: %v", err), http.StatusInternalServerError) + return + } + }) + + http.HandleFunc("/api/state", func(w http.ResponseWriter, r *http.Request) { + st, err := state.Load() + if err != nil { + http.Error(w, fmt.Sprintf("Error loading state: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(st) + }) + + http.HandleFunc("/api/configs", func(w http.ResponseWriter, r *http.Request) { + host := r.URL.Query().Get("host") + appName := r.URL.Query().Get("app") + + if host == "" || appName == "" { + http.Error(w, "Missing host or app parameter", http.StatusBadRequest) + return + } + + st, err := state.Load() + if err != nil { + http.Error(w, fmt.Sprintf("Error loading state: %v", err), http.StatusInternalServerError) + return + } + + app, err := st.GetApp(host, appName) + if err != nil { + http.Error(w, fmt.Sprintf("App not found: %v", err), http.StatusNotFound) + return + } + + configs := make(map[string]string) + + if app.Env != nil && len(app.Env) > 0 { + envContent := "" + for k, v := range app.Env { + envContent += fmt.Sprintf("%s=%s\n", k, v) + } + configs["env"] = envContent + configs["envPath"] = fmt.Sprintf("/etc/ship/env/%s.env", appName) + } + + if app.Type == "app" { + workDir := fmt.Sprintf("/var/lib/%s", appName) + binaryPath := fmt.Sprintf("/usr/local/bin/%s", appName) + envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", appName) + + serviceContent, err := templates.SystemdService(map[string]string{ + "Name": appName, + "User": appName, + "WorkDir": workDir, + "BinaryPath": binaryPath, + "Port": strconv.Itoa(app.Port), + "EnvFile": envFilePath, + "Args": app.Args, + }) + if err != nil { + http.Error(w, fmt.Sprintf("Error rendering systemd service: %v", err), http.StatusInternalServerError) + return + } + configs["systemd"] = serviceContent + configs["systemdPath"] = fmt.Sprintf("/etc/systemd/system/%s.service", appName) + + caddyContent, err := templates.AppCaddy(map[string]string{ + "Domain": app.Domain, + "Port": strconv.Itoa(app.Port), + }) + if err != nil { + http.Error(w, fmt.Sprintf("Error rendering Caddy config: %v", err), http.StatusInternalServerError) + return + } + configs["caddy"] = caddyContent + configs["caddyPath"] = fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", appName) + } else if app.Type == "static" { + remoteDir := fmt.Sprintf("/var/www/%s", appName) + caddyContent, err := templates.StaticCaddy(map[string]string{ + "Domain": app.Domain, + "RootDir": remoteDir, + }) + if err != nil { + http.Error(w, fmt.Sprintf("Error rendering Caddy config: %v", err), http.StatusInternalServerError) + return + } + configs["caddy"] = caddyContent + configs["caddyPath"] = fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", appName) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(configs) + }) + + addr := fmt.Sprintf("localhost:%s", port) + fmt.Printf("Starting web UI on http://%s\n", addr) + fmt.Printf("Press Ctrl+C to stop\n") + + if err := http.ListenAndServe(addr, nil); err != nil { + return fmt.Errorf("error starting server: %w", err) + } + return nil +} diff --git a/cmd/ship/version.go b/cmd/ship/version.go new file mode 100644 index 0000000..6e4314a --- /dev/null +++ b/cmd/ship/version.go @@ -0,0 +1,17 @@ +package main + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Show version information", + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf("ship version %s\n", version) + fmt.Printf(" commit: %s\n", commit) + fmt.Printf(" built: %s\n", date) + }, +} diff --git a/go.mod b/go.mod index b048ba7..cc84806 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/bdw/deploy +module github.com/bdw/ship go 1.21 diff --git a/internal/state/state.go b/internal/state/state.go index def0bcf..b99e4ca 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -33,7 +33,7 @@ const ( startPort = 8001 ) -// Load reads state from ~/.config/deploy/state.json +// Load reads state from ~/.config/ship/state.json func Load() (*State, error) { path := statePath() @@ -62,7 +62,7 @@ func Load() (*State, error) { return &state, nil } -// Save writes state to ~/.config/deploy/state.json +// Save writes state to ~/.config/ship/state.json func (s *State) Save() error { path := statePath() @@ -153,7 +153,7 @@ func statePath() string { home, err := os.UserHomeDir() if err != nil { // Fallback to current directory (should rarely happen) - return ".deploy-state.json" + return ".ship-state.json" } - return filepath.Join(home, ".config", "deploy", "state.json") + return filepath.Join(home, ".config", "ship", "state.json") } -- cgit v1.2.3