From 87752492d0dc7df3cf78011d5ce315a3eb0cad51 Mon Sep 17 00:00:00 2001 From: bndw Date: Fri, 23 Jan 2026 21:52:50 -0800 Subject: Restructure CLI with Cobra Replace custom switch-based routing with Cobra for cleaner command hierarchy. Reorganize commands into logical groups: - Root command handles deployment (--binary, --static, --domain, etc.) - App management at top level: list, logs, status, restart, remove - env subcommand group: list, set, unset - host subcommand group: init, status, update, ssh - Standalone: ui (renamed from webui), version Add version command with ldflags support for build info. --- cmd/deploy/deploy.go | 482 ---------------------------------------------- cmd/deploy/env.go | 170 ---------------- 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 | 80 ++++++++ cmd/deploy/init.go | 154 --------------- cmd/deploy/list.go | 36 ++-- cmd/deploy/logs.go | 75 ++++++++ cmd/deploy/main.go | 130 +++++-------- cmd/deploy/manage.go | 306 ----------------------------- cmd/deploy/remove.go | 83 ++++++++ cmd/deploy/restart.go | 57 ++++++ cmd/deploy/root.go | 377 ++++++++++++++++++++++++++++++++++++ cmd/deploy/status.go | 60 ++++++ cmd/deploy/ui.go | 199 +++++++++++++++++++ cmd/deploy/version.go | 17 ++ cmd/deploy/vps.go | 229 ---------------------- cmd/deploy/webui.go | 228 ---------------------- go.mod | 11 +- go.sum | 10 + 26 files changed, 1653 insertions(+), 1669 deletions(-) delete mode 100644 cmd/deploy/deploy.go delete mode 100644 cmd/deploy/env.go create mode 100644 cmd/deploy/env/env.go create mode 100644 cmd/deploy/env/list.go create mode 100644 cmd/deploy/env/set.go create mode 100644 cmd/deploy/env/unset.go create mode 100644 cmd/deploy/host/host.go create mode 100644 cmd/deploy/host/init.go create mode 100644 cmd/deploy/host/ssh.go create mode 100644 cmd/deploy/host/status.go create mode 100644 cmd/deploy/host/update.go delete mode 100644 cmd/deploy/init.go create mode 100644 cmd/deploy/logs.go delete mode 100644 cmd/deploy/manage.go create mode 100644 cmd/deploy/remove.go create mode 100644 cmd/deploy/restart.go create mode 100644 cmd/deploy/root.go create mode 100644 cmd/deploy/status.go create mode 100644 cmd/deploy/ui.go create mode 100644 cmd/deploy/version.go delete mode 100644 cmd/deploy/vps.go delete mode 100644 cmd/deploy/webui.go diff --git a/cmd/deploy/deploy.go b/cmd/deploy/deploy.go deleted file mode 100644 index 31aabe5..0000000 --- a/cmd/deploy/deploy.go +++ /dev/null @@ -1,482 +0,0 @@ -package main - -import ( - "bufio" - "flag" - "fmt" - "os" - "path/filepath" - "strconv" - "strings" - - "github.com/bdw/deploy/internal/ssh" - "github.com/bdw/deploy/internal/state" - "github.com/bdw/deploy/internal/templates" -) - -type envFlags []string - -func (e *envFlags) String() string { - return strings.Join(*e, ",") -} - -func (e *envFlags) Set(value string) error { - *e = append(*e, value) - return nil -} - -type fileFlags []string - -func (f *fileFlags) String() string { - return strings.Join(*f, ",") -} - -func (f *fileFlags) Set(value string) error { - *f = append(*f, value) - return nil -} - -func runDeploy(args []string) { - fs := flag.NewFlagSet("deploy", flag.ExitOnError) - host := fs.String("host", "", "VPS host (SSH config alias or user@host)") - domain := fs.String("domain", "", "Domain name (required)") - name := fs.String("name", "", "App name (default: inferred from binary or directory)") - binary := fs.String("binary", "", "Path to Go binary (for app deployment)") - static := fs.Bool("static", false, "Deploy as static site") - dir := fs.String("dir", ".", "Directory to deploy (for static sites)") - port := fs.Int("port", 0, "Port override (default: auto-allocate)") - var envVars envFlags - fs.Var(&envVars, "env", "Environment variable (KEY=VALUE, can be specified multiple times)") - envFile := fs.String("env-file", "", "Path to .env file") - binaryArgs := fs.String("args", "", "Arguments to pass to binary") - var files fileFlags - fs.Var(&files, "file", "Config file to upload to working directory (can be specified multiple times)") - - fs.Parse(args) - - // Get host from flag or state default - if *host == "" { - st, err := state.Load() - if err != nil { - fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) - os.Exit(1) - } - *host = st.GetDefaultHost() - } - - if *host == "" || *domain == "" { - fmt.Fprintf(os.Stderr, "Error: --host and --domain are required\n") - fs.Usage() - os.Exit(1) - } - - if *static { - deployStatic(*host, *domain, *name, *dir) - } else { - 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) { - // Require binary path - if binaryPath == "" { - fmt.Fprintf(os.Stderr, "Error: --binary is required\n") - os.Exit(1) - } - - // Determine app name from binary if not specified - if name == "" { - name = filepath.Base(binaryPath) - } - - // Verify binary exists - if _, err := os.Stat(binaryPath); err != nil { - fmt.Fprintf(os.Stderr, "Error: binary not found: %s\n", binaryPath) - os.Exit(1) - } - - fmt.Printf("Deploying app: %s\n", name) - fmt.Printf(" Domain: %s\n", domain) - fmt.Printf(" Binary: %s\n", binaryPath) - - // Load state - st, err := state.Load() - if err != nil { - fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) - os.Exit(1) - } - - // Check if app already exists (update) or new deployment - 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) - } - - // Parse environment variables - env := make(map[string]string) - if existingApp != nil { - // Preserve existing env vars - for k, v := range existingApp.Env { - env[k] = v - } - // Preserve existing args if not provided - if args == "" && existingApp.Args != "" { - args = existingApp.Args - } - // Preserve existing files if not provided - if len(files) == 0 && len(existingApp.Files) > 0 { - files = existingApp.Files - } - } - - // Add/override from flags - for _, e := range envVars { - parts := strings.SplitN(e, "=", 2) - if len(parts) == 2 { - env[parts[0]] = parts[1] - } - } - - // Add/override from file - if envFile != "" { - fileEnv, err := parseEnvFile(envFile) - if err != nil { - fmt.Fprintf(os.Stderr, "Error reading env file: %v\n", err) - os.Exit(1) - } - for k, v := range fileEnv { - env[k] = v - } - } - - // Always set PORT - env["PORT"] = strconv.Itoa(port) - - // Connect to VPS - client, err := ssh.Connect(host) - if err != nil { - fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err) - os.Exit(1) - } - defer client.Close() - - // Upload binary - fmt.Println("→ Uploading binary...") - remoteTmpPath := fmt.Sprintf("/tmp/%s", name) - if err := client.Upload(binaryPath, remoteTmpPath); err != nil { - fmt.Fprintf(os.Stderr, "Error uploading binary: %v\n", err) - os.Exit(1) - } - - // Create user (ignore error if already exists) - fmt.Println("→ Creating system user...") - client.RunSudo(fmt.Sprintf("useradd -r -s /bin/false %s", name)) - - // Create working directory - fmt.Println("→ Setting up directories...") - workDir := fmt.Sprintf("/var/lib/%s", name) - if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", workDir)); err != nil { - fmt.Fprintf(os.Stderr, "Error creating work directory: %v\n", err) - os.Exit(1) - } - if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, workDir)); err != nil { - fmt.Fprintf(os.Stderr, "Error setting work directory ownership: %v\n", err) - os.Exit(1) - } - - // Move binary to /usr/local/bin - 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 { - fmt.Fprintf(os.Stderr, "Error moving binary: %v\n", err) - os.Exit(1) - } - if _, err := client.RunSudo(fmt.Sprintf("chmod +x %s", binaryDest)); err != nil { - fmt.Fprintf(os.Stderr, "Error making binary executable: %v\n", err) - os.Exit(1) - } - - // Upload config files to working directory - if len(files) > 0 { - fmt.Println("→ Uploading config files...") - for _, file := range files { - // Verify file exists locally - if _, err := os.Stat(file); err != nil { - fmt.Fprintf(os.Stderr, "Error: config file not found: %s\n", file) - os.Exit(1) - } - - // Determine remote path (preserve filename) - remotePath := fmt.Sprintf("%s/%s", workDir, filepath.Base(file)) - remoteTmpPath := fmt.Sprintf("/tmp/%s_%s", name, filepath.Base(file)) - - // Upload to tmp first - if err := client.Upload(file, remoteTmpPath); err != nil { - fmt.Fprintf(os.Stderr, "Error uploading config file %s: %v\n", file, err) - os.Exit(1) - } - - // Move to working directory - if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", remoteTmpPath, remotePath)); err != nil { - fmt.Fprintf(os.Stderr, "Error moving config file %s: %v\n", file, err) - os.Exit(1) - } - - // Set ownership - if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, remotePath)); err != nil { - fmt.Fprintf(os.Stderr, "Error setting config file ownership %s: %v\n", file, err) - os.Exit(1) - } - - fmt.Printf(" Uploaded: %s\n", file) - } - } - - // Create env 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 { - fmt.Fprintf(os.Stderr, "Error creating env file: %v\n", err) - os.Exit(1) - } - if _, err := client.RunSudo(fmt.Sprintf("chmod 600 %s", envFilePath)); err != nil { - fmt.Fprintf(os.Stderr, "Error setting env file permissions: %v\n", err) - os.Exit(1) - } - if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, envFilePath)); err != nil { - fmt.Fprintf(os.Stderr, "Error setting env file ownership: %v\n", err) - os.Exit(1) - } - - // Generate systemd unit - 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 { - fmt.Fprintf(os.Stderr, "Error generating systemd unit: %v\n", err) - os.Exit(1) - } - - servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name) - if err := client.WriteSudoFile(servicePath, serviceContent); err != nil { - fmt.Fprintf(os.Stderr, "Error creating systemd unit: %v\n", err) - os.Exit(1) - } - - // Generate Caddy config - fmt.Println("→ Configuring Caddy...") - caddyContent, err := templates.AppCaddy(map[string]string{ - "Domain": domain, - "Port": strconv.Itoa(port), - }) - if err != nil { - fmt.Fprintf(os.Stderr, "Error generating Caddy config: %v\n", err) - os.Exit(1) - } - - caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) - if err := client.WriteSudoFile(caddyPath, caddyContent); err != nil { - fmt.Fprintf(os.Stderr, "Error creating Caddy config: %v\n", err) - os.Exit(1) - } - - // Reload systemd - fmt.Println("→ Reloading systemd...") - if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { - fmt.Fprintf(os.Stderr, "Error reloading systemd: %v\n", err) - os.Exit(1) - } - - // Enable and start service - fmt.Println("→ Starting service...") - if _, err := client.RunSudo(fmt.Sprintf("systemctl enable %s", name)); err != nil { - fmt.Fprintf(os.Stderr, "Error enabling service: %v\n", err) - os.Exit(1) - } - if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { - fmt.Fprintf(os.Stderr, "Error starting service: %v\n", err) - os.Exit(1) - } - - // Reload Caddy - fmt.Println("→ Reloading Caddy...") - if _, err := client.RunSudo("systemctl reload caddy"); err != nil { - fmt.Fprintf(os.Stderr, "Error reloading Caddy: %v\n", err) - os.Exit(1) - } - - // Update state - st.AddApp(host, name, &state.App{ - Type: "app", - Domain: domain, - Port: port, - Env: env, - Args: args, - Files: files, - }) - if err := st.Save(); err != nil { - fmt.Fprintf(os.Stderr, "Error saving state: %v\n", err) - os.Exit(1) - } - - fmt.Printf("\n✓ App deployed successfully!\n") - fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", domain) -} - -func deployStatic(host, domain, name, dir string) { - // Determine site name (default to domain to avoid conflicts) - if name == "" { - name = domain - } - - // Verify directory exists - if _, err := os.Stat(dir); err != nil { - fmt.Fprintf(os.Stderr, "Error: directory not found: %s\n", dir) - os.Exit(1) - } - - fmt.Printf("Deploying static site: %s\n", name) - fmt.Printf(" Domain: %s\n", domain) - fmt.Printf(" Directory: %s\n", dir) - - // Load state - st, err := state.Load() - if err != nil { - fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) - os.Exit(1) - } - - // Connect to VPS - client, err := ssh.Connect(host) - if err != nil { - fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err) - os.Exit(1) - } - defer client.Close() - - // Create remote directory - remoteDir := fmt.Sprintf("/var/www/%s", name) - fmt.Println("→ Creating remote directory...") - if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", remoteDir)); err != nil { - fmt.Fprintf(os.Stderr, "Error creating remote directory: %v\n", err) - os.Exit(1) - } - - // Get current user for temporary ownership during upload - currentUser, err := client.Run("whoami") - if err != nil { - fmt.Fprintf(os.Stderr, "Error getting current user: %v\n", err) - os.Exit(1) - } - currentUser = strings.TrimSpace(currentUser) - - // Set ownership to current user for upload - if _, err := client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", currentUser, currentUser, remoteDir)); err != nil { - fmt.Fprintf(os.Stderr, "Error setting temporary ownership: %v\n", err) - os.Exit(1) - } - - // Upload files - fmt.Println("→ Uploading files...") - if err := client.UploadDir(dir, remoteDir); err != nil { - fmt.Fprintf(os.Stderr, "Error uploading files: %v\n", err) - os.Exit(1) - } - - // Set ownership and permissions - fmt.Println("→ Setting permissions...") - if _, err := client.RunSudo(fmt.Sprintf("chown -R www-data:www-data %s", remoteDir)); err != nil { - fmt.Fprintf(os.Stderr, "Error setting ownership: %v\n", err) - os.Exit(1) - } - // Make files readable by all (755 for dirs, 644 for files) - if _, err := client.RunSudo(fmt.Sprintf("chmod -R 755 %s", remoteDir)); err != nil { - fmt.Fprintf(os.Stderr, "Error setting directory permissions: %v\n", err) - os.Exit(1) - } - if _, err := client.RunSudo(fmt.Sprintf("find %s -type f -exec chmod 644 {} \\;", remoteDir)); err != nil { - fmt.Fprintf(os.Stderr, "Error setting file permissions: %v\n", err) - os.Exit(1) - } - - // Generate Caddy config - fmt.Println("→ Configuring Caddy...") - caddyContent, err := templates.StaticCaddy(map[string]string{ - "Domain": domain, - "RootDir": remoteDir, - }) - if err != nil { - fmt.Fprintf(os.Stderr, "Error generating Caddy config: %v\n", err) - os.Exit(1) - } - - caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) - if err := client.WriteSudoFile(caddyPath, caddyContent); err != nil { - fmt.Fprintf(os.Stderr, "Error creating Caddy config: %v\n", err) - os.Exit(1) - } - - // Reload Caddy - fmt.Println("→ Reloading Caddy...") - if _, err := client.RunSudo("systemctl reload caddy"); err != nil { - fmt.Fprintf(os.Stderr, "Error reloading Caddy: %v\n", err) - os.Exit(1) - } - - // Update state - st.AddApp(host, name, &state.App{ - Type: "static", - Domain: domain, - }) - if err := st.Save(); err != nil { - fmt.Fprintf(os.Stderr, "Error saving state: %v\n", err) - os.Exit(1) - } - - fmt.Printf("\n✓ Static site deployed successfully!\n") - fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", domain) -} - -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.go b/cmd/deploy/env.go deleted file mode 100644 index a43cd6a..0000000 --- a/cmd/deploy/env.go +++ /dev/null @@ -1,170 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "os" - "strings" - - "github.com/bdw/deploy/internal/ssh" - "github.com/bdw/deploy/internal/state" -) - -func runEnv(args []string) { - fs := flag.NewFlagSet("env", flag.ExitOnError) - host := fs.String("host", "", "VPS host (SSH config alias or user@host)") - var setVars envFlags - fs.Var(&setVars, "set", "Set environment variable (KEY=VALUE, can be specified multiple times)") - var unsetVars envFlags - fs.Var(&unsetVars, "unset", "Unset environment variable (KEY, can be specified multiple times)") - envFile := fs.String("file", "", "Load environment from file") - fs.Parse(args) - - if len(fs.Args()) == 0 { - fmt.Fprintf(os.Stderr, "Error: app name is required\n") - fmt.Fprintf(os.Stderr, "Usage: deploy env [--set KEY=VALUE] [--unset KEY] [--file .env] --host user@vps-ip\n") - os.Exit(1) - } - - name := fs.Args()[0] - - // Load state - st, err := state.Load() - if err != nil { - fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) - os.Exit(1) - } - - // Get host from flag or state default - if *host == "" { - *host = st.GetDefaultHost() - } - - if *host == "" { - fmt.Fprintf(os.Stderr, "Error: --host is required\n") - fs.Usage() - os.Exit(1) - } - - // Get app info - app, err := st.GetApp(*host, name) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - - if app.Type != "app" { - fmt.Fprintf(os.Stderr, "Error: env is only available for apps, not static sites\n") - os.Exit(1) - } - - // If no flags, just display current env (masked) - if len(setVars) == 0 && len(unsetVars) == 0 && *envFile == "" { - fmt.Printf("Environment variables for %s:\n\n", name) - if len(app.Env) == 0 { - fmt.Println(" (none)") - } else { - for k, v := range app.Env { - // Mask sensitive-looking values - display := v - if isSensitive(k) { - display = "***" - } - fmt.Printf(" %s=%s\n", k, display) - } - } - return - } - - // Initialize env if nil - if app.Env == nil { - app.Env = make(map[string]string) - } - - // Apply changes - changed := false - - // Unset variables - for _, key := range unsetVars { - if _, exists := app.Env[key]; exists { - delete(app.Env, key) - changed = true - fmt.Printf("Unset %s\n", key) - } - } - - // Set variables from flags - for _, e := range setVars { - parts := strings.SplitN(e, "=", 2) - if len(parts) == 2 { - app.Env[parts[0]] = parts[1] - changed = true - fmt.Printf("Set %s\n", parts[0]) - } - } - - // Set variables from file - if *envFile != "" { - fileEnv, err := parseEnvFile(*envFile) - if err != nil { - fmt.Fprintf(os.Stderr, "Error reading env file: %v\n", err) - os.Exit(1) - } - for k, v := range fileEnv { - app.Env[k] = v - changed = true - fmt.Printf("Set %s\n", k) - } - } - - if !changed { - fmt.Println("No changes made") - return - } - - // Save state - if err := st.Save(); err != nil { - fmt.Fprintf(os.Stderr, "Error saving state: %v\n", err) - os.Exit(1) - } - - // Connect to VPS and update env file - client, err := ssh.Connect(*host) - if err != nil { - fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err) - os.Exit(1) - } - defer client.Close() - - // Regenerate env file - 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 { - fmt.Fprintf(os.Stderr, "Error updating env file: %v\n", err) - os.Exit(1) - } - - // Restart service to pick up new env - fmt.Println("→ Restarting service...") - if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { - fmt.Fprintf(os.Stderr, "Error restarting service: %v\n", err) - os.Exit(1) - } - - fmt.Println("✓ Environment variables updated successfully") -} - -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/env.go b/cmd/deploy/env/env.go new file mode 100644 index 0000000..489353a --- /dev/null +++ b/cmd/deploy/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/deploy/env/list.go b/cmd/deploy/env/list.go new file mode 100644 index 0000000..af92171 --- /dev/null +++ b/cmd/deploy/env/list.go @@ -0,0 +1,69 @@ +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 new file mode 100644 index 0000000..35d77ff --- /dev/null +++ b/cmd/deploy/env/set.go @@ -0,0 +1,132 @@ +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 new file mode 100644 index 0000000..65a8986 --- /dev/null +++ b/cmd/deploy/env/unset.go @@ -0,0 +1,92 @@ +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 new file mode 100644 index 0000000..603a946 --- /dev/null +++ b/cmd/deploy/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/deploy/host/init.go b/cmd/deploy/host/init.go new file mode 100644 index 0000000..984e5d3 --- /dev/null +++ b/cmd/deploy/host/init.go @@ -0,0 +1,137 @@ +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 new file mode 100644 index 0000000..a33986f --- /dev/null +++ b/cmd/deploy/host/ssh.go @@ -0,0 +1,45 @@ +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 new file mode 100644 index 0000000..bdd9c31 --- /dev/null +++ b/cmd/deploy/host/status.go @@ -0,0 +1,108 @@ +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 new file mode 100644 index 0000000..6f1b43b --- /dev/null +++ b/cmd/deploy/host/update.go @@ -0,0 +1,80 @@ +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 != "y" && response != "Y" { + 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 output == "yes\n" { + fmt.Println("Note: A reboot is required to complete the update.") + } + } + + fmt.Println("Update complete") + return nil +} diff --git a/cmd/deploy/init.go b/cmd/deploy/init.go deleted file mode 100644 index 1713879..0000000 --- a/cmd/deploy/init.go +++ /dev/null @@ -1,154 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "os" - "strings" - - "github.com/bdw/deploy/internal/ssh" - "github.com/bdw/deploy/internal/state" -) - -func runInit(args []string) { - fs := flag.NewFlagSet("init", flag.ExitOnError) - host := fs.String("host", "", "VPS host (SSH config alias or user@host)") - fs.Parse(args) - - // Load state - st, err := state.Load() - if err != nil { - fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) - os.Exit(1) - } - - // Get host from flag or state default - if *host == "" { - *host = st.GetDefaultHost() - } - - if *host == "" { - fmt.Fprintf(os.Stderr, "Error: --host is required\n") - fs.Usage() - os.Exit(1) - } - - fmt.Printf("Initializing VPS: %s\n", *host) - - // Connect to VPS - client, err := ssh.Connect(*host) - if err != nil { - fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err) - os.Exit(1) - } - defer client.Close() - - // Detect OS - fmt.Println("→ Detecting OS...") - osRelease, err := client.Run("cat /etc/os-release") - if err != nil { - fmt.Fprintf(os.Stderr, "Error detecting OS: %v\n", err) - os.Exit(1) - } - - if !strings.Contains(osRelease, "Ubuntu") && !strings.Contains(osRelease, "Debian") { - fmt.Fprintf(os.Stderr, "Error: Unsupported OS (only Ubuntu and Debian are supported)\n") - os.Exit(1) - } - fmt.Println(" ✓ Detected Ubuntu/Debian") - - // Check if Caddy is already installed - fmt.Println("→ Checking for Caddy...") - _, err = client.Run("which caddy") - if err == nil { - fmt.Println(" ✓ Caddy already installed") - } else { - // Install Caddy - fmt.Println(" Installing Caddy...") - installCaddy(client) - fmt.Println(" ✓ Caddy installed") - } - - // Create Caddyfile - fmt.Println("→ Configuring Caddy...") - caddyfile := `{ - email admin@example.com -} - -import /etc/caddy/sites-enabled/* -` - if err := client.WriteSudoFile("/etc/caddy/Caddyfile", caddyfile); err != nil { - fmt.Fprintf(os.Stderr, "Error creating Caddyfile: %v\n", err) - os.Exit(1) - } - fmt.Println(" ✓ Caddyfile created") - - // Create directories - fmt.Println("→ Creating directories...") - if _, err := client.RunSudo("mkdir -p /etc/deploy/env"); err != nil { - fmt.Fprintf(os.Stderr, "Error creating /etc/deploy/env: %v\n", err) - os.Exit(1) - } - if _, err := client.RunSudo("mkdir -p /etc/caddy/sites-enabled"); err != nil { - fmt.Fprintf(os.Stderr, "Error creating /etc/caddy/sites-enabled: %v\n", err) - os.Exit(1) - } - fmt.Println(" ✓ Directories created") - - // Enable and start Caddy - fmt.Println("→ Starting Caddy...") - if _, err := client.RunSudo("systemctl enable caddy"); err != nil { - fmt.Fprintf(os.Stderr, "Error enabling Caddy: %v\n", err) - os.Exit(1) - } - if _, err := client.RunSudo("systemctl restart caddy"); err != nil { - fmt.Fprintf(os.Stderr, "Error starting Caddy: %v\n", err) - os.Exit(1) - } - fmt.Println(" ✓ Caddy started") - - // Verify Caddy is running - fmt.Println("→ Verifying installation...") - output, err := client.RunSudo("systemctl is-active caddy") - if err != nil || strings.TrimSpace(output) != "active" { - fmt.Fprintf(os.Stderr, "Warning: Caddy may not be running properly\n") - } else { - fmt.Println(" ✓ Caddy is active") - } - - // Update state - st.GetHost(*host) // Ensure host exists in state - if st.GetDefaultHost() == "" { - st.SetDefaultHost(*host) - fmt.Printf(" Set %s as default host\n", *host) - } - if err := st.Save(); err != nil { - fmt.Fprintf(os.Stderr, "Error saving state: %v\n", err) - os.Exit(1) - } - - fmt.Println("\n✓ VPS initialized successfully!") - fmt.Println("\nNext steps:") - fmt.Println(" 1. Deploy a Go app:") - fmt.Printf(" deploy deploy --host %s --binary ./myapp --domain api.example.com\n", *host) - fmt.Println(" 2. Deploy a static site:") - fmt.Printf(" deploy deploy --host %s --static --dir ./dist --domain example.com\n", *host) -} - -func installCaddy(client *ssh.Client) { - 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 { - fmt.Fprintf(os.Stderr, "Error running: %s\nError: %v\n", cmd, err) - os.Exit(1) - } - } -} diff --git a/cmd/deploy/list.go b/cmd/deploy/list.go index ce1605b..ab19a12 100644 --- a/cmd/deploy/list.go +++ b/cmd/deploy/list.go @@ -1,44 +1,41 @@ package main import ( - "flag" "fmt" "os" "text/tabwriter" "github.com/bdw/deploy/internal/state" + "github.com/spf13/cobra" ) -func runList(args []string) { - fs := flag.NewFlagSet("list", flag.ExitOnError) - host := fs.String("host", "", "VPS host (SSH config alias or user@host)") - fs.Parse(args) +var listCmd = &cobra.Command{ + Use: "list", + Short: "List all deployed apps and sites", + RunE: runList, +} - // Load state +func runList(cmd *cobra.Command, args []string) error { st, err := state.Load() if err != nil { - fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) - os.Exit(1) + return fmt.Errorf("error loading state: %w", err) } - // Get host from flag or state default - if *host == "" { - *host = st.GetDefaultHost() + host := hostFlag + if host == "" { + host = st.GetDefaultHost() } - if *host == "" { - fmt.Fprintf(os.Stderr, "Error: --host is required\n") - fs.Usage() - os.Exit(1) + if host == "" { + return fmt.Errorf("--host is required") } - apps := st.ListApps(*host) + apps := st.ListApps(host) if len(apps) == 0 { - fmt.Printf("No deployments found for %s\n", *host) - return + fmt.Printf("No deployments found for %s\n", host) + return nil } - // Print table w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) fmt.Fprintln(w, "NAME\tTYPE\tDOMAIN\tPORT") for name, app := range apps { @@ -49,4 +46,5 @@ func runList(args []string) { 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 new file mode 100644 index 0000000..2b016b8 --- /dev/null +++ b/cmd/deploy/logs.go @@ -0,0 +1,75 @@ +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 index 51439dd..ad61523 100644 --- a/cmd/deploy/main.go +++ b/cmd/deploy/main.go @@ -1,93 +1,65 @@ package main import ( - "fmt" "os" -) - -func main() { - if len(os.Args) < 2 { - printUsage() - os.Exit(1) - } - - command := os.Args[1] - - switch command { - case "init": - runInit(os.Args[2:]) - case "list": - runList(os.Args[2:]) - case "rm", "remove": - runRemove(os.Args[2:]) - case "logs": - runLogs(os.Args[2:]) - case "status": - runStatus(os.Args[2:]) - case "restart": - runRestart(os.Args[2:]) - case "env": - runEnv(os.Args[2:]) - case "webui": - runWebUI(os.Args[2:]) - case "vps": - runVPS(os.Args[2:]) - case "vps-update": - runUpdate(os.Args[2:]) - case "vps-ssh": - runSSH(os.Args[2:]) - case "help", "--help", "-h": - printUsage() - default: - // Default action is deploy - pass all args including the first one - runDeploy(os.Args[1:]) - } -} -func printUsage() { - usage := `deploy - Deploy Go apps and static sites to a VPS with automatic HTTPS - -USAGE: - deploy [flags] Deploy an app or static site - deploy [flags] Run a subcommand - -COMMANDS: - init Initialize a fresh VPS (one-time setup) - list List all deployed apps and sites - rm Remove a deployment - logs View logs for a deployment - status Check status of a deployment - restart Restart a deployment - env Manage environment variables - vps Show VPS health (uptime, disk, memory, load) - vps-update Update VPS packages (apt update && upgrade) - vps-ssh Open an interactive SSH session - webui Launch web UI to manage deployments + "github.com/bdw/deploy/cmd/deploy/env" + "github.com/bdw/deploy/cmd/deploy/host" + "github.com/spf13/cobra" +) -FLAGS: - Run 'deploy -h' or 'deploy -h' for flags +var ( + // Persistent flags + hostFlag string -EXAMPLES: - # Initialize VPS (sets it as default host) - deploy init --host user@vps-ip + // Version info (set via ldflags) + version = "dev" + commit = "none" + date = "unknown" +) - # Deploy Go app - deploy --binary ./myapp --domain api.example.com +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 - # Deploy static site - deploy --static --dir ./dist --domain example.com +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, +} - # List deployments - deploy list +func init() { + // Persistent flags available to all subcommands + rootCmd.PersistentFlags().StringVar(&hostFlag, "host", "", "VPS host (SSH config alias or user@host)") - # View logs - deploy logs myapp + // 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)") - # Check VPS health - deploy vps + // 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) +} - # Update VPS packages - deploy vps-update -` - fmt.Fprint(os.Stderr, usage) +func main() { + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } } diff --git a/cmd/deploy/manage.go b/cmd/deploy/manage.go deleted file mode 100644 index 1f52b92..0000000 --- a/cmd/deploy/manage.go +++ /dev/null @@ -1,306 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "os" - - "github.com/bdw/deploy/internal/ssh" - "github.com/bdw/deploy/internal/state" -) - -func runRemove(args []string) { - fs := flag.NewFlagSet("remove", flag.ExitOnError) - host := fs.String("host", "", "VPS host (SSH config alias or user@host)") - fs.Parse(args) - - if len(fs.Args()) == 0 { - fmt.Fprintf(os.Stderr, "Error: app name is required\n") - fmt.Fprintf(os.Stderr, "Usage: deploy remove --host user@vps-ip\n") - os.Exit(1) - } - - name := fs.Args()[0] - - // Load state - st, err := state.Load() - if err != nil { - fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) - os.Exit(1) - } - - // Get host from flag or state default - if *host == "" { - *host = st.GetDefaultHost() - } - - if *host == "" { - fmt.Fprintf(os.Stderr, "Error: --host is required\n") - fs.Usage() - os.Exit(1) - } - - // Get app info - app, err := st.GetApp(*host, name) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - - fmt.Printf("Removing deployment: %s\n", name) - - // Connect to VPS - client, err := ssh.Connect(*host) - if err != nil { - fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err) - os.Exit(1) - } - defer client.Close() - - if app.Type == "app" { - // Stop and disable service - fmt.Println("→ Stopping service...") - client.RunSudo(fmt.Sprintf("systemctl stop %s", name)) - client.RunSudo(fmt.Sprintf("systemctl disable %s", name)) - - // Remove systemd unit - client.RunSudo(fmt.Sprintf("rm -f /etc/systemd/system/%s.service", name)) - client.RunSudo("systemctl daemon-reload") - - // Remove binary - client.RunSudo(fmt.Sprintf("rm -f /usr/local/bin/%s", name)) - - // Remove working directory - client.RunSudo(fmt.Sprintf("rm -rf /var/lib/%s", name)) - - // Remove env file - client.RunSudo(fmt.Sprintf("rm -f /etc/deploy/env/%s.env", name)) - - // Remove user - client.RunSudo(fmt.Sprintf("userdel %s", name)) - } else { - // Remove static site files - fmt.Println("→ Removing files...") - client.RunSudo(fmt.Sprintf("rm -rf /var/www/%s", name)) - } - - // Remove Caddy config - fmt.Println("→ Removing Caddy config...") - client.RunSudo(fmt.Sprintf("rm -f /etc/caddy/sites-enabled/%s.caddy", name)) - - // Reload Caddy - fmt.Println("→ Reloading Caddy...") - if _, err := client.RunSudo("systemctl reload caddy"); err != nil { - fmt.Fprintf(os.Stderr, "Warning: Error reloading Caddy: %v\n", err) - } - - // Update state - if err := st.RemoveApp(*host, name); err != nil { - fmt.Fprintf(os.Stderr, "Error updating state: %v\n", err) - os.Exit(1) - } - if err := st.Save(); err != nil { - fmt.Fprintf(os.Stderr, "Error saving state: %v\n", err) - os.Exit(1) - } - - fmt.Printf("✓ Deployment removed successfully\n") -} - -func runLogs(args []string) { - fs := flag.NewFlagSet("logs", flag.ExitOnError) - host := fs.String("host", "", "VPS host (SSH config alias or user@host)") - follow := fs.Bool("f", false, "Follow logs") - lines := fs.Int("n", 50, "Number of lines to show") - fs.Parse(args) - - if len(fs.Args()) == 0 { - fmt.Fprintf(os.Stderr, "Error: app name is required\n") - fmt.Fprintf(os.Stderr, "Usage: deploy logs --host user@vps-ip\n") - os.Exit(1) - } - - name := fs.Args()[0] - - // Load state - st, err := state.Load() - if err != nil { - fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) - os.Exit(1) - } - - // Get host from flag or state default - if *host == "" { - *host = st.GetDefaultHost() - } - - if *host == "" { - fmt.Fprintf(os.Stderr, "Error: --host is required\n") - fs.Usage() - os.Exit(1) - } - - app, err := st.GetApp(*host, name) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - - if app.Type != "app" { - fmt.Fprintf(os.Stderr, "Error: logs are only available for apps, not static sites\n") - os.Exit(1) - } - - // Connect to VPS - client, err := ssh.Connect(*host) - if err != nil { - fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err) - os.Exit(1) - } - defer client.Close() - - // Build journalctl command - cmd := fmt.Sprintf("journalctl -u %s -n %d", name, *lines) - if *follow { - cmd += " -f" - } - - // Run command - if *follow { - // Stream output for follow mode (no sudo needed for journalctl) - if err := client.RunStream(cmd); err != nil { - fmt.Fprintf(os.Stderr, "Error fetching logs: %v\n", err) - os.Exit(1) - } - } else { - // Buffer output for non-follow mode (no sudo needed for journalctl) - output, err := client.Run(cmd) - if err != nil { - fmt.Fprintf(os.Stderr, "Error fetching logs: %v\n", err) - os.Exit(1) - } - fmt.Print(output) - } -} - -func runStatus(args []string) { - fs := flag.NewFlagSet("status", flag.ExitOnError) - host := fs.String("host", "", "VPS host (SSH config alias or user@host)") - fs.Parse(args) - - if len(fs.Args()) == 0 { - fmt.Fprintf(os.Stderr, "Error: app name is required\n") - fmt.Fprintf(os.Stderr, "Usage: deploy status --host user@vps-ip\n") - os.Exit(1) - } - - name := fs.Args()[0] - - // Load state - st, err := state.Load() - if err != nil { - fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) - os.Exit(1) - } - - // Get host from flag or state default - if *host == "" { - *host = st.GetDefaultHost() - } - - if *host == "" { - fmt.Fprintf(os.Stderr, "Error: --host is required\n") - fs.Usage() - os.Exit(1) - } - - app, err := st.GetApp(*host, name) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - - if app.Type != "app" { - fmt.Fprintf(os.Stderr, "Error: status is only available for apps, not static sites\n") - os.Exit(1) - } - - // Connect to VPS - client, err := ssh.Connect(*host) - if err != nil { - fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err) - os.Exit(1) - } - defer client.Close() - - // Get status - 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 - } - - fmt.Print(output) -} - -func runRestart(args []string) { - fs := flag.NewFlagSet("restart", flag.ExitOnError) - host := fs.String("host", "", "VPS host (SSH config alias or user@host)") - fs.Parse(args) - - if len(fs.Args()) == 0 { - fmt.Fprintf(os.Stderr, "Error: app name is required\n") - fmt.Fprintf(os.Stderr, "Usage: deploy restart --host user@vps-ip\n") - os.Exit(1) - } - - name := fs.Args()[0] - - // Load state - st, err := state.Load() - if err != nil { - fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) - os.Exit(1) - } - - // Get host from flag or state default - if *host == "" { - *host = st.GetDefaultHost() - } - - if *host == "" { - fmt.Fprintf(os.Stderr, "Error: --host is required\n") - fs.Usage() - os.Exit(1) - } - - app, err := st.GetApp(*host, name) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - - if app.Type != "app" { - fmt.Fprintf(os.Stderr, "Error: restart is only available for apps, not static sites\n") - os.Exit(1) - } - - // Connect to VPS - client, err := ssh.Connect(*host) - if err != nil { - fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err) - os.Exit(1) - } - defer client.Close() - - // Restart service - fmt.Printf("Restarting %s...\n", name) - if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { - fmt.Fprintf(os.Stderr, "Error restarting service: %v\n", err) - os.Exit(1) - } - - fmt.Println("✓ Service restarted successfully") -} diff --git a/cmd/deploy/remove.go b/cmd/deploy/remove.go new file mode 100644 index 0000000..5a98bf3 --- /dev/null +++ b/cmd/deploy/remove.go @@ -0,0 +1,83 @@ +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 new file mode 100644 index 0000000..d1cfa86 --- /dev/null +++ b/cmd/deploy/restart.go @@ -0,0 +1,57 @@ +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 new file mode 100644 index 0000000..adbc7c8 --- /dev/null +++ b/cmd/deploy/root.go @@ -0,0 +1,377 @@ +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 new file mode 100644 index 0000000..4bcfc68 --- /dev/null +++ b/cmd/deploy/status.go @@ -0,0 +1,60 @@ +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/ui.go b/cmd/deploy/ui.go new file mode 100644 index 0000000..2ca88e0 --- /dev/null +++ b/cmd/deploy/ui.go @@ -0,0 +1,199 @@ +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 new file mode 100644 index 0000000..d2cd430 --- /dev/null +++ b/cmd/deploy/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("deploy version %s\n", version) + fmt.Printf(" commit: %s\n", commit) + fmt.Printf(" built: %s\n", date) + }, +} diff --git a/cmd/deploy/vps.go b/cmd/deploy/vps.go deleted file mode 100644 index bd6278b..0000000 --- a/cmd/deploy/vps.go +++ /dev/null @@ -1,229 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "os" - "os/exec" - - "github.com/bdw/deploy/internal/ssh" - "github.com/bdw/deploy/internal/state" -) - -func runVPS(args []string) { - fs := flag.NewFlagSet("vps", flag.ExitOnError) - host := fs.String("host", "", "VPS host (SSH config alias or user@host)") - fs.Parse(args) - - // Load state - st, err := state.Load() - if err != nil { - fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) - os.Exit(1) - } - - // Get host from flag or state default - if *host == "" { - *host = st.GetDefaultHost() - } - - if *host == "" { - fmt.Fprintf(os.Stderr, "Error: --host is required (no default host set)\n") - fs.Usage() - os.Exit(1) - } - - fmt.Printf("Connecting to %s...\n\n", *host) - - // Connect to VPS - client, err := ssh.Connect(*host) - if err != nil { - fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err) - os.Exit(1) - } - defer client.Close() - - // Uptime - 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() - - // Load average - 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() - - // Memory - 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() - - // Disk - 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() - - // Updates available - 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() - - // Services - fmt.Println("SERVICES") - if output, err := client.Run("systemctl is-active caddy 2>/dev/null && echo ' Caddy: active' || echo ' Caddy: inactive'"); err == nil { - // Parse the output - if output == "active\n" { - fmt.Println(" Caddy: active") - } else { - fmt.Println(" Caddy: inactive") - } - } - - // Count deployed apps that are running - 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) - } -} - -func runUpdate(args []string) { - fs := flag.NewFlagSet("vps-update", flag.ExitOnError) - host := fs.String("host", "", "VPS host (SSH config alias or user@host)") - yes := fs.Bool("y", false, "Skip confirmation prompt") - fs.Parse(args) - - // Load state - st, err := state.Load() - if err != nil { - fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) - os.Exit(1) - } - - // Get host from flag or state default - if *host == "" { - *host = st.GetDefaultHost() - } - - if *host == "" { - fmt.Fprintf(os.Stderr, "Error: --host is required (no default host set)\n") - fs.Usage() - os.Exit(1) - } - - // Confirm unless -y flag is set - if !*yes { - fmt.Printf("This will run apt update && apt upgrade on %s\n", *host) - fmt.Print("Continue? [y/N]: ") - var response string - fmt.Scanln(&response) - if response != "y" && response != "Y" { - fmt.Println("Aborted.") - return - } - } - - fmt.Printf("Connecting to %s...\n", *host) - - // Connect to VPS - client, err := ssh.Connect(*host) - if err != nil { - fmt.Fprintf(os.Stderr, "Error connecting to VPS: %v\n", err) - os.Exit(1) - } - defer client.Close() - - // Run apt update - fmt.Println("\n→ Running apt update...") - if err := client.RunSudoStream("apt update"); err != nil { - fmt.Fprintf(os.Stderr, "Error running apt update: %v\n", err) - os.Exit(1) - } - - // Run apt upgrade - fmt.Println("\n→ Running apt upgrade...") - if err := client.RunSudoStream("DEBIAN_FRONTEND=noninteractive apt upgrade -y"); err != nil { - fmt.Fprintf(os.Stderr, "Error running apt upgrade: %v\n", err) - os.Exit(1) - } - - // Check if reboot is required - fmt.Println() - if output, err := client.Run("[ -f /var/run/reboot-required ] && echo 'yes' || echo 'no'"); err == nil { - if output == "yes\n" { - fmt.Println("Note: A reboot is required to complete the update.") - } - } - - fmt.Println("✓ Update complete") -} - -func runSSH(args []string) { - fs := flag.NewFlagSet("vps-ssh", flag.ExitOnError) - host := fs.String("host", "", "VPS host (SSH config alias or user@host)") - fs.Parse(args) - - // Load state - st, err := state.Load() - if err != nil { - fmt.Fprintf(os.Stderr, "Error loading state: %v\n", err) - os.Exit(1) - } - - // Get host from flag or state default - if *host == "" { - *host = st.GetDefaultHost() - } - - if *host == "" { - fmt.Fprintf(os.Stderr, "Error: --host is required (no default host set)\n") - fs.Usage() - os.Exit(1) - } - - // Launch interactive SSH session - cmd := exec.Command("ssh", *host) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - if err := cmd.Run(); err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - os.Exit(exitErr.ExitCode()) - } - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } -} diff --git a/cmd/deploy/webui.go b/cmd/deploy/webui.go deleted file mode 100644 index f57400e..0000000 --- a/cmd/deploy/webui.go +++ /dev/null @@ -1,228 +0,0 @@ -package main - -import ( - "embed" - "encoding/json" - "flag" - "fmt" - "html/template" - "net/http" - "os" - "sort" - "strconv" - - "github.com/bdw/deploy/internal/state" - "github.com/bdw/deploy/internal/templates" -) - -//go:embed templates/*.html -var templatesFS embed.FS - -func runWebUI(args []string) { - fs := flag.NewFlagSet("webui", flag.ExitOnError) - port := fs.String("port", "8080", "Port to run the web UI on") - help := fs.Bool("h", false, "Show help") - - fs.Parse(args) - - if *help { - fmt.Fprintf(os.Stderr, `Usage: deploy webui [flags] - -Launch a web interface to view and manage deployments. - -FLAGS: - -port string - Port to run the web UI on (default "8080") - -h Show this help message - -EXAMPLE: - # Launch web UI on default port (8080) - deploy webui - - # Launch on custom port - deploy webui -port 3000 -`) - os.Exit(0) - } - - // Parse template - tmpl, err := template.ParseFS(templatesFS, "templates/webui.html") - if err != nil { - fmt.Fprintf(os.Stderr, "Error parsing template: %v\n", err) - os.Exit(1) - } - - // Handler for the main page - http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - // Reload state on each request to show latest changes - st, err := state.Load() - if err != nil { - http.Error(w, fmt.Sprintf("Error loading state: %v", err), http.StatusInternalServerError) - return - } - - // Prepare data for template - 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 apps by name - sort.Slice(apps, func(i, j int) bool { - return apps[i].Name < apps[j].Name - }) - - hosts = append(hosts, HostData{ - Host: hostName, - Apps: apps, - }) - } - - // Sort hosts by name - 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 - } - }) - - // API endpoint to get state as JSON - 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) - }) - - // API endpoint to get rendered configs for an app - 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) - - // Render environment file - 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) - } - - // Render configs based on app type - if app.Type == "app" { - // Render systemd service - 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) - - // Render Caddy config - 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" { - // Render Caddy config for static site - 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 { - fmt.Fprintf(os.Stderr, "Error starting server: %v\n", err) - os.Exit(1) - } -} diff --git a/go.mod b/go.mod index 0d29deb..b048ba7 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,13 @@ module github.com/bdw/deploy go 1.21 -require golang.org/x/crypto v0.31.0 +require ( + github.com/spf13/cobra v1.10.2 + golang.org/x/crypto v0.31.0 +) -require golang.org/x/sys v0.28.0 // indirect +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/sys v0.28.0 // indirect +) diff --git a/go.sum b/go.sum index 7fa4005..42e3e06 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,16 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -- cgit v1.2.3