From 5861e465a2ccf31d87ea25ac145770786f9cc96e Mon Sep 17 00:00:00 2001 From: bndw Date: Sat, 24 Jan 2026 09:48:34 -0800 Subject: Rename project from deploy to ship - Rename module to github.com/bdw/ship - Rename cmd/deploy to cmd/ship - Update all import paths - Update config path from ~/.config/deploy to ~/.config/ship - Update VPS env path from /etc/deploy to /etc/ship - Update README, Makefile, and docs --- cmd/ship/root.go | 377 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 377 insertions(+) create mode 100644 cmd/ship/root.go (limited to 'cmd/ship/root.go') diff --git a/cmd/ship/root.go b/cmd/ship/root.go new file mode 100644 index 0000000..e5d6753 --- /dev/null +++ b/cmd/ship/root.go @@ -0,0 +1,377 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/bdw/ship/internal/ssh" + "github.com/bdw/ship/internal/state" + "github.com/bdw/ship/internal/templates" + "github.com/spf13/cobra" +) + +func runDeploy(cmd *cobra.Command, args []string) error { + flags := cmd.Flags() + + binary, _ := flags.GetString("binary") + static, _ := flags.GetBool("static") + dir, _ := flags.GetString("dir") + domain, _ := flags.GetString("domain") + name, _ := flags.GetString("name") + port, _ := flags.GetInt("port") + envVars, _ := flags.GetStringArray("env") + envFile, _ := flags.GetString("env-file") + binaryArgs, _ := flags.GetString("args") + files, _ := flags.GetStringArray("file") + + // Get host from flag or state default + host := hostFlag + if host == "" { + st, err := state.Load() + if err != nil { + return fmt.Errorf("error loading state: %w", err) + } + host = st.GetDefaultHost() + } + + // If no flags provided, show help + if domain == "" && binary == "" && !static { + return cmd.Help() + } + + if host == "" || domain == "" { + return fmt.Errorf("--host and --domain are required") + } + + if static { + return deployStatic(host, domain, name, dir) + } + return deployApp(host, domain, name, binary, port, envVars, envFile, binaryArgs, files) +} + +func deployApp(host, domain, name, binaryPath string, portOverride int, envVars []string, envFile, args string, files []string) error { + if binaryPath == "" { + return fmt.Errorf("--binary is required") + } + + if name == "" { + name = filepath.Base(binaryPath) + } + + if _, err := os.Stat(binaryPath); err != nil { + return fmt.Errorf("binary not found: %s", binaryPath) + } + + fmt.Printf("Deploying app: %s\n", name) + fmt.Printf(" Domain: %s\n", domain) + fmt.Printf(" Binary: %s\n", binaryPath) + + st, err := state.Load() + if err != nil { + return fmt.Errorf("error loading state: %w", err) + } + + existingApp, _ := st.GetApp(host, name) + var port int + if existingApp != nil { + port = existingApp.Port + fmt.Printf(" Updating existing deployment (port %d)\n", port) + } else { + if portOverride > 0 { + port = portOverride + } else { + port = st.AllocatePort(host) + } + fmt.Printf(" Allocated port: %d\n", port) + } + + env := make(map[string]string) + if existingApp != nil { + for k, v := range existingApp.Env { + env[k] = v + } + if args == "" && existingApp.Args != "" { + args = existingApp.Args + } + if len(files) == 0 && len(existingApp.Files) > 0 { + files = existingApp.Files + } + } + + for _, e := range envVars { + parts := strings.SplitN(e, "=", 2) + if len(parts) == 2 { + env[parts[0]] = parts[1] + } + } + + if envFile != "" { + fileEnv, err := parseEnvFile(envFile) + if err != nil { + return fmt.Errorf("error reading env file: %w", err) + } + for k, v := range fileEnv { + env[k] = v + } + } + + env["PORT"] = strconv.Itoa(port) + + client, err := ssh.Connect(host) + if err != nil { + return fmt.Errorf("error connecting to VPS: %w", err) + } + defer client.Close() + + fmt.Println("-> Uploading binary...") + remoteTmpPath := fmt.Sprintf("/tmp/%s", name) + if err := client.Upload(binaryPath, remoteTmpPath); err != nil { + return fmt.Errorf("error uploading binary: %w", err) + } + + fmt.Println("-> Creating system user...") + client.RunSudo(fmt.Sprintf("useradd -r -s /bin/false %s", name)) + + fmt.Println("-> Setting up directories...") + workDir := fmt.Sprintf("/var/lib/%s", name) + if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", workDir)); err != nil { + return fmt.Errorf("error creating work directory: %w", err) + } + if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, workDir)); err != nil { + return fmt.Errorf("error setting work directory ownership: %w", err) + } + + fmt.Println("-> Installing binary...") + binaryDest := fmt.Sprintf("/usr/local/bin/%s", name) + if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", remoteTmpPath, binaryDest)); err != nil { + return fmt.Errorf("error moving binary: %w", err) + } + if _, err := client.RunSudo(fmt.Sprintf("chmod +x %s", binaryDest)); err != nil { + return fmt.Errorf("error making binary executable: %w", err) + } + + if len(files) > 0 { + fmt.Println("-> Uploading config files...") + for _, file := range files { + if _, err := os.Stat(file); err != nil { + return fmt.Errorf("config file not found: %s", file) + } + + remotePath := fmt.Sprintf("%s/%s", workDir, filepath.Base(file)) + remoteTmpPath := fmt.Sprintf("/tmp/%s_%s", name, filepath.Base(file)) + + if err := client.Upload(file, remoteTmpPath); err != nil { + return fmt.Errorf("error uploading config file %s: %w", file, err) + } + + if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", remoteTmpPath, remotePath)); err != nil { + return fmt.Errorf("error moving config file %s: %w", file, err) + } + + if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, remotePath)); err != nil { + return fmt.Errorf("error setting config file ownership %s: %w", file, err) + } + + fmt.Printf(" Uploaded: %s\n", file) + } + } + + fmt.Println("-> Creating environment file...") + envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", name) + envContent := "" + for k, v := range env { + envContent += fmt.Sprintf("%s=%s\n", k, v) + } + if err := client.WriteSudoFile(envFilePath, envContent); err != nil { + return fmt.Errorf("error creating env file: %w", err) + } + if _, err := client.RunSudo(fmt.Sprintf("chmod 600 %s", envFilePath)); err != nil { + return fmt.Errorf("error setting env file permissions: %w", err) + } + if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", name, name, envFilePath)); err != nil { + return fmt.Errorf("error setting env file ownership: %w", err) + } + + fmt.Println("-> Creating systemd service...") + serviceContent, err := templates.SystemdService(map[string]string{ + "Name": name, + "User": name, + "WorkDir": workDir, + "BinaryPath": binaryDest, + "Port": strconv.Itoa(port), + "EnvFile": envFilePath, + "Args": args, + }) + if err != nil { + return fmt.Errorf("error generating systemd unit: %w", err) + } + + servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name) + if err := client.WriteSudoFile(servicePath, serviceContent); err != nil { + return fmt.Errorf("error creating systemd unit: %w", err) + } + + fmt.Println("-> Configuring Caddy...") + caddyContent, err := templates.AppCaddy(map[string]string{ + "Domain": domain, + "Port": strconv.Itoa(port), + }) + if err != nil { + return fmt.Errorf("error generating Caddy config: %w", err) + } + + caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) + if err := client.WriteSudoFile(caddyPath, caddyContent); err != nil { + return fmt.Errorf("error creating Caddy config: %w", err) + } + + fmt.Println("-> Reloading systemd...") + if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { + return fmt.Errorf("error reloading systemd: %w", err) + } + + fmt.Println("-> Starting service...") + if _, err := client.RunSudo(fmt.Sprintf("systemctl enable %s", name)); err != nil { + return fmt.Errorf("error enabling service: %w", err) + } + if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { + return fmt.Errorf("error starting service: %w", err) + } + + fmt.Println("-> Reloading Caddy...") + if _, err := client.RunSudo("systemctl reload caddy"); err != nil { + return fmt.Errorf("error reloading Caddy: %w", err) + } + + st.AddApp(host, name, &state.App{ + Type: "app", + Domain: domain, + Port: port, + Env: env, + Args: args, + Files: files, + }) + if err := st.Save(); err != nil { + return fmt.Errorf("error saving state: %w", err) + } + + fmt.Printf("\n App deployed successfully!\n") + fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", domain) + return nil +} + +func deployStatic(host, domain, name, dir string) error { + if name == "" { + name = domain + } + + if _, err := os.Stat(dir); err != nil { + return fmt.Errorf("directory not found: %s", dir) + } + + fmt.Printf("Deploying static site: %s\n", name) + fmt.Printf(" Domain: %s\n", domain) + fmt.Printf(" Directory: %s\n", dir) + + st, err := state.Load() + if err != nil { + return fmt.Errorf("error loading state: %w", err) + } + + client, err := ssh.Connect(host) + if err != nil { + return fmt.Errorf("error connecting to VPS: %w", err) + } + defer client.Close() + + remoteDir := fmt.Sprintf("/var/www/%s", name) + fmt.Println("-> Creating remote directory...") + if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", remoteDir)); err != nil { + return fmt.Errorf("error creating remote directory: %w", err) + } + + currentUser, err := client.Run("whoami") + if err != nil { + return fmt.Errorf("error getting current user: %w", err) + } + currentUser = strings.TrimSpace(currentUser) + + if _, err := client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", currentUser, currentUser, remoteDir)); err != nil { + return fmt.Errorf("error setting temporary ownership: %w", err) + } + + fmt.Println("-> Uploading files...") + if err := client.UploadDir(dir, remoteDir); err != nil { + return fmt.Errorf("error uploading files: %w", err) + } + + fmt.Println("-> Setting permissions...") + if _, err := client.RunSudo(fmt.Sprintf("chown -R www-data:www-data %s", remoteDir)); err != nil { + return fmt.Errorf("error setting ownership: %w", err) + } + if _, err := client.RunSudo(fmt.Sprintf("chmod -R 755 %s", remoteDir)); err != nil { + return fmt.Errorf("error setting directory permissions: %w", err) + } + if _, err := client.RunSudo(fmt.Sprintf("find %s -type f -exec chmod 644 {} \\;", remoteDir)); err != nil { + return fmt.Errorf("error setting file permissions: %w", err) + } + + fmt.Println("-> Configuring Caddy...") + caddyContent, err := templates.StaticCaddy(map[string]string{ + "Domain": domain, + "RootDir": remoteDir, + }) + if err != nil { + return fmt.Errorf("error generating Caddy config: %w", err) + } + + caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) + if err := client.WriteSudoFile(caddyPath, caddyContent); err != nil { + return fmt.Errorf("error creating Caddy config: %w", err) + } + + fmt.Println("-> Reloading Caddy...") + if _, err := client.RunSudo("systemctl reload caddy"); err != nil { + return fmt.Errorf("error reloading Caddy: %w", err) + } + + st.AddApp(host, name, &state.App{ + Type: "static", + Domain: domain, + }) + if err := st.Save(); err != nil { + return fmt.Errorf("error saving state: %w", err) + } + + fmt.Printf("\n Static site deployed successfully!\n") + fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", domain) + return nil +} + +func parseEnvFile(path string) (map[string]string, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + env := make(map[string]string) + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + env[parts[0]] = parts[1] + } + } + + return env, scanner.Err() +} -- cgit v1.2.3