From af109c04a3edd4dcd4e7b16242052442fb4a3b24 Mon Sep 17 00:00:00 2001 From: bndw Date: Sun, 25 Jan 2026 10:16:23 -0800 Subject: Move deploy implementation to deploy.go Restructure CLI files to follow idiomatic cobra layout: - main.go: minimal entry point - root.go: command definition, flags, and subcommand wiring - deploy.go: all deploy implementation --- cmd/ship/deploy.go | 598 +++++++++++++++++++++++++++++++++++++++++++++++ cmd/ship/main.go | 92 -------- cmd/ship/root.go | 671 +++++++---------------------------------------------- 3 files changed, 682 insertions(+), 679 deletions(-) create mode 100644 cmd/ship/deploy.go (limited to 'cmd') diff --git a/cmd/ship/deploy.go b/cmd/ship/deploy.go new file mode 100644 index 0000000..24eed1e --- /dev/null +++ b/cmd/ship/deploy.go @@ -0,0 +1,598 @@ +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" +) + +// DeployOptions contains all options for deploying or updating an app +type DeployOptions struct { + Host string + Domain string + Name string + Binary string + Dir string // for static sites + Port int + Args string + Files []string + Memory string + CPU string + Env map[string]string // merged env vars + IsUpdate bool +} + +func runDeploy(cmd *cobra.Command, args []string) error { + flags := cmd.Flags() + + // Parse CLI 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") + argsFlag, _ := flags.GetString("args") + files, _ := flags.GetStringArray("file") + memory, _ := flags.GetString("memory") + cpu, _ := flags.GetString("cpu") + + // 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 && name == "" { + return cmd.Help() + } + + if host == "" { + return fmt.Errorf("--host is required") + } + + // Load state once - this will be used throughout + st, err := state.Load() + if err != nil { + return fmt.Errorf("error loading state: %w", err) + } + hostState := st.GetHost(host) + + // Config update mode: --name provided without --binary or --static + if name != "" && binary == "" && !static { + existingApp, err := st.GetApp(host, name) + if err != nil { + return fmt.Errorf("app %s not found (use --binary to deploy a new app)", name) + } + + // Build merged config starting from existing app + opts := DeployOptions{ + Host: host, + Name: name, + Port: existingApp.Port, + Args: existingApp.Args, + Files: existingApp.Files, + Memory: existingApp.Memory, + CPU: existingApp.CPU, + Env: make(map[string]string), + } + for k, v := range existingApp.Env { + opts.Env[k] = v + } + + // Override with CLI flags if provided + if argsFlag != "" { + opts.Args = argsFlag + } + if len(files) > 0 { + opts.Files = files + } + if memory != "" { + opts.Memory = memory + } + if cpu != "" { + opts.CPU = cpu + } + + // Merge env vars (CLI overrides existing) + for _, e := range envVars { + parts := strings.SplitN(e, "=", 2) + if len(parts) == 2 { + opts.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 { + opts.Env[k] = v + } + } + + return updateAppConfig(st, opts) + } + + // Infer name early so we can use it for subdomain generation and existing app lookup + if name == "" { + if static { + name = domain + if name == "" && hostState.BaseDomain != "" { + name = filepath.Base(dir) + } + } else { + name = filepath.Base(binary) + } + } + + // Check if this is an update to an existing app/site + existingApp, _ := st.GetApp(host, name) + isUpdate := existingApp != nil + + // For new deployments, require domain or base domain + if !isUpdate && domain == "" && hostState.BaseDomain == "" { + return fmt.Errorf("--domain required (or configure base domain with 'ship host init --base-domain')") + } + + // Build merged config, starting from existing app if updating + opts := DeployOptions{ + Host: host, + Name: name, + Binary: binary, + Dir: dir, + IsUpdate: isUpdate, + } + + // Merge domain: auto-subdomain + (user-provided or existing custom domain) + var domains []string + if hostState.BaseDomain != "" { + domains = append(domains, name+"."+hostState.BaseDomain) + } + if domain != "" { + domains = append(domains, domain) + } else if isUpdate && existingApp.Domain != "" { + for _, d := range strings.Split(existingApp.Domain, ",") { + d = strings.TrimSpace(d) + if d != "" && (hostState.BaseDomain == "" || !strings.HasSuffix(d, "."+hostState.BaseDomain)) { + domains = append(domains, d) + } + } + } + opts.Domain = strings.Join(domains, ", ") + + // For apps, merge all config fields + if !static { + // Start with existing values if updating + if isUpdate { + opts.Port = existingApp.Port + opts.Args = existingApp.Args + opts.Files = existingApp.Files + opts.Memory = existingApp.Memory + opts.CPU = existingApp.CPU + opts.Env = make(map[string]string) + for k, v := range existingApp.Env { + opts.Env[k] = v + } + } else { + opts.Port = port + opts.Env = make(map[string]string) + } + + // Override with CLI flags if provided + if argsFlag != "" { + opts.Args = argsFlag + } + if len(files) > 0 { + opts.Files = files + } + if memory != "" { + opts.Memory = memory + } + if cpu != "" { + opts.CPU = cpu + } + + // Merge env vars (CLI overrides existing) + for _, e := range envVars { + parts := strings.SplitN(e, "=", 2) + if len(parts) == 2 { + opts.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 { + opts.Env[k] = v + } + } + } + + if static { + return deployStatic(st, opts) + } + return deployApp(st, opts) +} + +func deployApp(st *state.State, opts DeployOptions) error { + if opts.Binary == "" { + return fmt.Errorf("--binary is required") + } + + if _, err := os.Stat(opts.Binary); err != nil { + return fmt.Errorf("binary not found: %s", opts.Binary) + } + + fmt.Printf("Deploying app: %s\n", opts.Name) + fmt.Printf(" Domain(s): %s\n", opts.Domain) + fmt.Printf(" Binary: %s\n", opts.Binary) + + // Allocate port for new apps + port := opts.Port + if opts.IsUpdate { + fmt.Printf(" Updating existing deployment (port %d)\n", port) + } else { + if port == 0 { + port = st.AllocatePort(opts.Host) + } + fmt.Printf(" Allocated port: %d\n", port) + } + + // Add PORT to env + opts.Env["PORT"] = strconv.Itoa(port) + + client, err := ssh.Connect(opts.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", opts.Name) + if err := client.Upload(opts.Binary, 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", opts.Name)) + + fmt.Println("-> Setting up directories...") + workDir := fmt.Sprintf("/var/lib/%s", opts.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", opts.Name, opts.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", opts.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(opts.Files) > 0 { + fmt.Println("-> Uploading config files...") + for _, file := range opts.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)) + fileTmpPath := fmt.Sprintf("/tmp/%s_%s", opts.Name, filepath.Base(file)) + + if err := client.Upload(file, fileTmpPath); err != nil { + return fmt.Errorf("error uploading config file %s: %w", file, err) + } + + if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", fileTmpPath, remotePath)); err != nil { + return fmt.Errorf("error moving config file %s: %w", file, err) + } + + if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", opts.Name, opts.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", opts.Name) + envContent := "" + for k, v := range opts.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", opts.Name, opts.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": opts.Name, + "User": opts.Name, + "WorkDir": workDir, + "BinaryPath": binaryDest, + "Port": strconv.Itoa(port), + "EnvFile": envFilePath, + "Args": opts.Args, + "Memory": opts.Memory, + "CPU": opts.CPU, + }) + if err != nil { + return fmt.Errorf("error generating systemd unit: %w", err) + } + + servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", opts.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": opts.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", opts.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", opts.Name)); err != nil { + return fmt.Errorf("error enabling service: %w", err) + } + if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", opts.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(opts.Host, opts.Name, &state.App{ + Type: "app", + Domain: opts.Domain, + Port: port, + Env: opts.Env, + Args: opts.Args, + Files: opts.Files, + Memory: opts.Memory, + CPU: opts.CPU, + }) + if err := st.Save(); err != nil { + return fmt.Errorf("error saving state: %w", err) + } + + fmt.Printf("\n App deployed successfully!\n") + // Show first domain in the URL message + primaryDomain := strings.Split(opts.Domain, ",")[0] + primaryDomain = strings.TrimSpace(primaryDomain) + fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", primaryDomain) + return nil +} + +func updateAppConfig(st *state.State, opts DeployOptions) error { + existingApp, err := st.GetApp(opts.Host, opts.Name) + if err != nil { + return fmt.Errorf("app %s not found (use --binary to deploy a new app)", opts.Name) + } + + if existingApp.Type != "app" { + return fmt.Errorf("%s is a static site, not an app", opts.Name) + } + + fmt.Printf("Updating config: %s\n", opts.Name) + + // Add PORT to env + opts.Env["PORT"] = strconv.Itoa(existingApp.Port) + + client, err := ssh.Connect(opts.Host) + if err != nil { + return fmt.Errorf("error connecting to VPS: %w", err) + } + defer client.Close() + + // Update env file + fmt.Println("-> Updating environment file...") + envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", opts.Name) + envContent := "" + for k, v := range opts.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) + } + + // Regenerate systemd unit + fmt.Println("-> Updating systemd service...") + workDir := fmt.Sprintf("/var/lib/%s", opts.Name) + binaryDest := fmt.Sprintf("/usr/local/bin/%s", opts.Name) + serviceContent, err := templates.SystemdService(map[string]string{ + "Name": opts.Name, + "User": opts.Name, + "WorkDir": workDir, + "BinaryPath": binaryDest, + "Port": strconv.Itoa(existingApp.Port), + "EnvFile": envFilePath, + "Args": opts.Args, + "Memory": opts.Memory, + "CPU": opts.CPU, + }) + if err != nil { + return fmt.Errorf("error generating systemd unit: %w", err) + } + + servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", opts.Name) + if err := client.WriteSudoFile(servicePath, serviceContent); err != nil { + return fmt.Errorf("error creating systemd unit: %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("-> Restarting service...") + if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", opts.Name)); err != nil { + return fmt.Errorf("error restarting service: %w", err) + } + + // Update state + existingApp.Args = opts.Args + existingApp.Memory = opts.Memory + existingApp.CPU = opts.CPU + existingApp.Env = opts.Env + if err := st.Save(); err != nil { + return fmt.Errorf("error saving state: %w", err) + } + + fmt.Printf("\n Config updated successfully!\n") + return nil +} + +func deployStatic(st *state.State, opts DeployOptions) error { + if _, err := os.Stat(opts.Dir); err != nil { + return fmt.Errorf("directory not found: %s", opts.Dir) + } + + fmt.Printf("Deploying static site: %s\n", opts.Name) + fmt.Printf(" Domain(s): %s\n", opts.Domain) + fmt.Printf(" Directory: %s\n", opts.Dir) + + if opts.IsUpdate { + fmt.Println(" Updating existing deployment") + } + + client, err := ssh.Connect(opts.Host) + if err != nil { + return fmt.Errorf("error connecting to VPS: %w", err) + } + defer client.Close() + + remoteDir := fmt.Sprintf("/var/www/%s", opts.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(opts.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": opts.Domain, + "RootDir": remoteDir, + }) + if err != nil { + return fmt.Errorf("error generating Caddy config: %w", err) + } + + caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", opts.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(opts.Host, opts.Name, &state.App{ + Type: "static", + Domain: opts.Domain, + }) + if err := st.Save(); err != nil { + return fmt.Errorf("error saving state: %w", err) + } + + fmt.Printf("\n Static site deployed successfully!\n") + primaryDomain := strings.Split(opts.Domain, ",")[0] + primaryDomain = strings.TrimSpace(primaryDomain) + fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", primaryDomain) + return nil +} + +func parseEnvFile(path string) (map[string]string, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + env := make(map[string]string) + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + env[parts[0]] = parts[1] + } + } + + return env, scanner.Err() +} diff --git a/cmd/ship/main.go b/cmd/ship/main.go index cd2b0c1..f7d95c1 100644 --- a/cmd/ship/main.go +++ b/cmd/ship/main.go @@ -3,100 +3,8 @@ package main import ( "fmt" "os" - - "github.com/bdw/ship/cmd/ship/env" - "github.com/bdw/ship/cmd/ship/host" - "github.com/spf13/cobra" ) -var ( - // Persistent flags - hostFlag string - - // Version info (set via ldflags) - version = "dev" - commit = "none" - date = "unknown" -) - -const banner = ` - ~ - ___|___ - | _ | - _|__|_|__|_ - | SHIP | Ship apps to your VPS - \_________/ with automatic HTTPS - ~~~~~~~~~ -` - -var rootCmd = &cobra.Command{ - Use: "ship", - Short: "Ship apps and static sites to a VPS with automatic HTTPS", - Long: banner + ` -A CLI tool for deploying applications and static sites to a VPS. - -How it works: - Ship uses only SSH to deploy - no agents, containers, or external services. - It uploads your binary or static website, creates a systemd service, and configures Caddy - for automatic HTTPS. Ports are assigned automatically. Your app runs directly on the VPS - with minimal overhead. - -Requirements: - • A VPS with SSH access (use 'ship host init' to set up a new server) - • An SSH config entry or user@host for your server - • A domain pointing to your VPS - -Examples: - # Deploy a Go binary - ship --binary ./myapp --domain api.example.com - - # Deploy with auto-generated subdomain (requires base domain) - ship --binary ./myapp --name myapp - - # Deploy a static site - ship --static --dir ./dist --domain example.com - - # Update config without redeploying binary - ship --name myapp --memory 512M --cpu 50% - ship --name myapp --env DEBUG=true - - # Set up a new VPS with base domain - ship host init --host user@vps --base-domain apps.example.com`, - RunE: runDeploy, - SilenceUsage: true, - SilenceErrors: true, -} - -func init() { - // Persistent flags available to all subcommands - rootCmd.PersistentFlags().StringVar(&hostFlag, "host", "", "VPS host (SSH config alias or user@host)") - - // Root command (deploy) flags - rootCmd.Flags().String("binary", "", "Path to Go binary (for app deployment)") - rootCmd.Flags().Bool("static", false, "Deploy as static site") - rootCmd.Flags().String("dir", ".", "Directory to deploy (for static sites)") - rootCmd.Flags().String("domain", "", "Custom domain (optional if base domain configured)") - 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)") - rootCmd.Flags().String("memory", "", "Memory limit (e.g., 512M, 1G)") - rootCmd.Flags().String("cpu", "", "CPU limit (e.g., 50%, 200% for 2 cores)") - - // Add subcommands - rootCmd.AddCommand(listCmd) - rootCmd.AddCommand(logsCmd) - rootCmd.AddCommand(statusCmd) - rootCmd.AddCommand(restartCmd) - rootCmd.AddCommand(removeCmd) - rootCmd.AddCommand(env.Cmd) - rootCmd.AddCommand(host.Cmd) - rootCmd.AddCommand(uiCmd) - rootCmd.AddCommand(versionCmd) -} - func main() { if err := rootCmd.Execute(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) diff --git a/cmd/ship/root.go b/cmd/ship/root.go index 24eed1e..837fd4c 100644 --- a/cmd/ship/root.go +++ b/cmd/ship/root.go @@ -1,598 +1,95 @@ 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/bdw/ship/cmd/ship/env" + "github.com/bdw/ship/cmd/ship/host" "github.com/spf13/cobra" ) -// DeployOptions contains all options for deploying or updating an app -type DeployOptions struct { - Host string - Domain string - Name string - Binary string - Dir string // for static sites - Port int - Args string - Files []string - Memory string - CPU string - Env map[string]string // merged env vars - IsUpdate bool -} - -func runDeploy(cmd *cobra.Command, args []string) error { - flags := cmd.Flags() - - // Parse CLI 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") - argsFlag, _ := flags.GetString("args") - files, _ := flags.GetStringArray("file") - memory, _ := flags.GetString("memory") - cpu, _ := flags.GetString("cpu") - - // 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 && name == "" { - return cmd.Help() - } - - if host == "" { - return fmt.Errorf("--host is required") - } - - // Load state once - this will be used throughout - st, err := state.Load() - if err != nil { - return fmt.Errorf("error loading state: %w", err) - } - hostState := st.GetHost(host) - - // Config update mode: --name provided without --binary or --static - if name != "" && binary == "" && !static { - existingApp, err := st.GetApp(host, name) - if err != nil { - return fmt.Errorf("app %s not found (use --binary to deploy a new app)", name) - } - - // Build merged config starting from existing app - opts := DeployOptions{ - Host: host, - Name: name, - Port: existingApp.Port, - Args: existingApp.Args, - Files: existingApp.Files, - Memory: existingApp.Memory, - CPU: existingApp.CPU, - Env: make(map[string]string), - } - for k, v := range existingApp.Env { - opts.Env[k] = v - } - - // Override with CLI flags if provided - if argsFlag != "" { - opts.Args = argsFlag - } - if len(files) > 0 { - opts.Files = files - } - if memory != "" { - opts.Memory = memory - } - if cpu != "" { - opts.CPU = cpu - } - - // Merge env vars (CLI overrides existing) - for _, e := range envVars { - parts := strings.SplitN(e, "=", 2) - if len(parts) == 2 { - opts.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 { - opts.Env[k] = v - } - } - - return updateAppConfig(st, opts) - } - - // Infer name early so we can use it for subdomain generation and existing app lookup - if name == "" { - if static { - name = domain - if name == "" && hostState.BaseDomain != "" { - name = filepath.Base(dir) - } - } else { - name = filepath.Base(binary) - } - } - - // Check if this is an update to an existing app/site - existingApp, _ := st.GetApp(host, name) - isUpdate := existingApp != nil - - // For new deployments, require domain or base domain - if !isUpdate && domain == "" && hostState.BaseDomain == "" { - return fmt.Errorf("--domain required (or configure base domain with 'ship host init --base-domain')") - } - - // Build merged config, starting from existing app if updating - opts := DeployOptions{ - Host: host, - Name: name, - Binary: binary, - Dir: dir, - IsUpdate: isUpdate, - } - - // Merge domain: auto-subdomain + (user-provided or existing custom domain) - var domains []string - if hostState.BaseDomain != "" { - domains = append(domains, name+"."+hostState.BaseDomain) - } - if domain != "" { - domains = append(domains, domain) - } else if isUpdate && existingApp.Domain != "" { - for _, d := range strings.Split(existingApp.Domain, ",") { - d = strings.TrimSpace(d) - if d != "" && (hostState.BaseDomain == "" || !strings.HasSuffix(d, "."+hostState.BaseDomain)) { - domains = append(domains, d) - } - } - } - opts.Domain = strings.Join(domains, ", ") - - // For apps, merge all config fields - if !static { - // Start with existing values if updating - if isUpdate { - opts.Port = existingApp.Port - opts.Args = existingApp.Args - opts.Files = existingApp.Files - opts.Memory = existingApp.Memory - opts.CPU = existingApp.CPU - opts.Env = make(map[string]string) - for k, v := range existingApp.Env { - opts.Env[k] = v - } - } else { - opts.Port = port - opts.Env = make(map[string]string) - } - - // Override with CLI flags if provided - if argsFlag != "" { - opts.Args = argsFlag - } - if len(files) > 0 { - opts.Files = files - } - if memory != "" { - opts.Memory = memory - } - if cpu != "" { - opts.CPU = cpu - } - - // Merge env vars (CLI overrides existing) - for _, e := range envVars { - parts := strings.SplitN(e, "=", 2) - if len(parts) == 2 { - opts.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 { - opts.Env[k] = v - } - } - } - - if static { - return deployStatic(st, opts) - } - return deployApp(st, opts) -} - -func deployApp(st *state.State, opts DeployOptions) error { - if opts.Binary == "" { - return fmt.Errorf("--binary is required") - } - - if _, err := os.Stat(opts.Binary); err != nil { - return fmt.Errorf("binary not found: %s", opts.Binary) - } - - fmt.Printf("Deploying app: %s\n", opts.Name) - fmt.Printf(" Domain(s): %s\n", opts.Domain) - fmt.Printf(" Binary: %s\n", opts.Binary) - - // Allocate port for new apps - port := opts.Port - if opts.IsUpdate { - fmt.Printf(" Updating existing deployment (port %d)\n", port) - } else { - if port == 0 { - port = st.AllocatePort(opts.Host) - } - fmt.Printf(" Allocated port: %d\n", port) - } - - // Add PORT to env - opts.Env["PORT"] = strconv.Itoa(port) - - client, err := ssh.Connect(opts.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", opts.Name) - if err := client.Upload(opts.Binary, 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", opts.Name)) - - fmt.Println("-> Setting up directories...") - workDir := fmt.Sprintf("/var/lib/%s", opts.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", opts.Name, opts.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", opts.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(opts.Files) > 0 { - fmt.Println("-> Uploading config files...") - for _, file := range opts.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)) - fileTmpPath := fmt.Sprintf("/tmp/%s_%s", opts.Name, filepath.Base(file)) - - if err := client.Upload(file, fileTmpPath); err != nil { - return fmt.Errorf("error uploading config file %s: %w", file, err) - } - - if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", fileTmpPath, remotePath)); err != nil { - return fmt.Errorf("error moving config file %s: %w", file, err) - } - - if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", opts.Name, opts.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", opts.Name) - envContent := "" - for k, v := range opts.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", opts.Name, opts.Name, envFilePath)); err != nil { - return fmt.Errorf("error setting env file ownership: %w", err) - } +var ( + // Persistent flags + hostFlag string - fmt.Println("-> Creating systemd service...") - serviceContent, err := templates.SystemdService(map[string]string{ - "Name": opts.Name, - "User": opts.Name, - "WorkDir": workDir, - "BinaryPath": binaryDest, - "Port": strconv.Itoa(port), - "EnvFile": envFilePath, - "Args": opts.Args, - "Memory": opts.Memory, - "CPU": opts.CPU, - }) - if err != nil { - return fmt.Errorf("error generating systemd unit: %w", err) - } - - servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", opts.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": opts.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", opts.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", opts.Name)); err != nil { - return fmt.Errorf("error enabling service: %w", err) - } - if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", opts.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(opts.Host, opts.Name, &state.App{ - Type: "app", - Domain: opts.Domain, - Port: port, - Env: opts.Env, - Args: opts.Args, - Files: opts.Files, - Memory: opts.Memory, - CPU: opts.CPU, - }) - if err := st.Save(); err != nil { - return fmt.Errorf("error saving state: %w", err) - } - - fmt.Printf("\n App deployed successfully!\n") - // Show first domain in the URL message - primaryDomain := strings.Split(opts.Domain, ",")[0] - primaryDomain = strings.TrimSpace(primaryDomain) - fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", primaryDomain) - return nil -} - -func updateAppConfig(st *state.State, opts DeployOptions) error { - existingApp, err := st.GetApp(opts.Host, opts.Name) - if err != nil { - return fmt.Errorf("app %s not found (use --binary to deploy a new app)", opts.Name) - } - - if existingApp.Type != "app" { - return fmt.Errorf("%s is a static site, not an app", opts.Name) - } - - fmt.Printf("Updating config: %s\n", opts.Name) - - // Add PORT to env - opts.Env["PORT"] = strconv.Itoa(existingApp.Port) - - client, err := ssh.Connect(opts.Host) - if err != nil { - return fmt.Errorf("error connecting to VPS: %w", err) - } - defer client.Close() - - // Update env file - fmt.Println("-> Updating environment file...") - envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", opts.Name) - envContent := "" - for k, v := range opts.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) - } - - // Regenerate systemd unit - fmt.Println("-> Updating systemd service...") - workDir := fmt.Sprintf("/var/lib/%s", opts.Name) - binaryDest := fmt.Sprintf("/usr/local/bin/%s", opts.Name) - serviceContent, err := templates.SystemdService(map[string]string{ - "Name": opts.Name, - "User": opts.Name, - "WorkDir": workDir, - "BinaryPath": binaryDest, - "Port": strconv.Itoa(existingApp.Port), - "EnvFile": envFilePath, - "Args": opts.Args, - "Memory": opts.Memory, - "CPU": opts.CPU, - }) - if err != nil { - return fmt.Errorf("error generating systemd unit: %w", err) - } - - servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", opts.Name) - if err := client.WriteSudoFile(servicePath, serviceContent); err != nil { - return fmt.Errorf("error creating systemd unit: %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("-> Restarting service...") - if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", opts.Name)); err != nil { - return fmt.Errorf("error restarting service: %w", err) - } - - // Update state - existingApp.Args = opts.Args - existingApp.Memory = opts.Memory - existingApp.CPU = opts.CPU - existingApp.Env = opts.Env - if err := st.Save(); err != nil { - return fmt.Errorf("error saving state: %w", err) - } - - fmt.Printf("\n Config updated successfully!\n") - return nil -} - -func deployStatic(st *state.State, opts DeployOptions) error { - if _, err := os.Stat(opts.Dir); err != nil { - return fmt.Errorf("directory not found: %s", opts.Dir) - } - - fmt.Printf("Deploying static site: %s\n", opts.Name) - fmt.Printf(" Domain(s): %s\n", opts.Domain) - fmt.Printf(" Directory: %s\n", opts.Dir) - - if opts.IsUpdate { - fmt.Println(" Updating existing deployment") - } - - client, err := ssh.Connect(opts.Host) - if err != nil { - return fmt.Errorf("error connecting to VPS: %w", err) - } - defer client.Close() - - remoteDir := fmt.Sprintf("/var/www/%s", opts.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(opts.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": opts.Domain, - "RootDir": remoteDir, - }) - if err != nil { - return fmt.Errorf("error generating Caddy config: %w", err) - } - - caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", opts.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(opts.Host, opts.Name, &state.App{ - Type: "static", - Domain: opts.Domain, - }) - if err := st.Save(); err != nil { - return fmt.Errorf("error saving state: %w", err) - } + // Version info (set via ldflags) + version = "dev" + commit = "none" + date = "unknown" +) - fmt.Printf("\n Static site deployed successfully!\n") - primaryDomain := strings.Split(opts.Domain, ",")[0] - primaryDomain = strings.TrimSpace(primaryDomain) - fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", primaryDomain) - return nil +const banner = ` + ~ + ___|___ + | _ | + _|__|_|__|_ + | SHIP | Ship apps to your VPS + \_________/ with automatic HTTPS + ~~~~~~~~~ +` + +var rootCmd = &cobra.Command{ + Use: "ship", + Short: "Ship apps and static sites to a VPS with automatic HTTPS", + Long: banner + ` +A CLI tool for deploying applications and static sites to a VPS. + +How it works: + Ship uses only SSH to deploy - no agents, containers, or external services. + It uploads your binary or static website, creates a systemd service, and configures Caddy + for automatic HTTPS. Ports are assigned automatically. Your app runs directly on the VPS + with minimal overhead. + +Requirements: + • A VPS with SSH access (use 'ship host init' to set up a new server) + • An SSH config entry or user@host for your server + • A domain pointing to your VPS + +Examples: + # Deploy a Go binary + ship --binary ./myapp --domain api.example.com + + # Deploy with auto-generated subdomain (requires base domain) + ship --binary ./myapp --name myapp + + # Deploy a static site + ship --static --dir ./dist --domain example.com + + # Update config without redeploying binary + ship --name myapp --memory 512M --cpu 50% + ship --name myapp --env DEBUG=true + + # Set up a new VPS with base domain + ship host init --host user@vps --base-domain apps.example.com`, + RunE: runDeploy, + SilenceUsage: true, + SilenceErrors: true, } -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() +func init() { + // Persistent flags available to all subcommands + rootCmd.PersistentFlags().StringVar(&hostFlag, "host", "", "VPS host (SSH config alias or user@host)") + + // Root command (deploy) flags + rootCmd.Flags().String("binary", "", "Path to Go binary (for app deployment)") + rootCmd.Flags().Bool("static", false, "Deploy as static site") + rootCmd.Flags().String("dir", ".", "Directory to deploy (for static sites)") + rootCmd.Flags().String("domain", "", "Custom domain (optional if base domain configured)") + 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)") + rootCmd.Flags().String("memory", "", "Memory limit (e.g., 512M, 1G)") + rootCmd.Flags().String("cpu", "", "CPU limit (e.g., 50%, 200% for 2 cores)") + + // 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) } -- cgit v1.2.3