From 4c20fb334315e5b4e0c5892a6b68a205687ff7d9 Mon Sep 17 00:00:00 2001 From: Clawd Date: Sun, 15 Feb 2026 18:54:22 -0800 Subject: feat(v2): wire up v2 as default interface - main.go: v2 JSON interface is now default - Set SHIP_V1=1 to use legacy human-formatted output - initV2() called before rootV2Cmd.Execute() v2 rebuild complete - ready for testing --- cmd/ship/main.go | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) (limited to 'cmd/ship/main.go') diff --git a/cmd/ship/main.go b/cmd/ship/main.go index f7d95c1..73d9a20 100644 --- a/cmd/ship/main.go +++ b/cmd/ship/main.go @@ -6,8 +6,20 @@ import ( ) func main() { - if err := rootCmd.Execute(); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) + // Use v2 (agent-first JSON) interface by default + // Set SHIP_V1=1 to use legacy human-formatted output + if os.Getenv("SHIP_V1") == "1" { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + return + } + + // v2: JSON output by default + initV2() + if err := rootV2Cmd.Execute(); err != nil { + // Error already printed as JSON by commands os.Exit(1) } } -- cgit v1.2.3 From 6b2c04728cd914f27ae62c1df0bf5df24ac9a628 Mon Sep 17 00:00:00 2001 From: Clawd Date: Tue, 17 Feb 2026 07:54:26 -0800 Subject: Remove v1 code, simplify state to just base_domain - Delete all v1 commands (deploy, init, list, status, remove, etc.) - Delete v1 env/ and host/ subcommand directories - Simplify state.go: remove NextPort, Apps, AllocatePort, etc. - Local state now only tracks default_host + base_domain per host - Ports and deploys are tracked on the server (/etc/ship/ports/) - host init now creates minimal state.json --- cmd/ship/deploy.go | 664 -------------------------------------------- cmd/ship/deploy_cmd.go | 141 ---------- cmd/ship/env/env.go | 17 -- cmd/ship/env/list.go | 72 ----- cmd/ship/env/set.go | 135 --------- cmd/ship/env/unset.go | 95 ------- cmd/ship/host/host.go | 21 -- cmd/ship/host/init.go | 316 --------------------- cmd/ship/host/set_domain.go | 76 ----- cmd/ship/host/ssh.go | 45 --- cmd/ship/host/status.go | 108 ------- cmd/ship/host/update.go | 93 ------- cmd/ship/init.go | 268 ------------------ cmd/ship/list.go | 61 ---- cmd/ship/logs.go | 78 ------ cmd/ship/main.go | 17 +- cmd/ship/remove.go | 109 -------- cmd/ship/restart.go | 60 ---- cmd/ship/root.go | 97 ------- cmd/ship/root_v2.go | 2 + cmd/ship/status.go | 63 ----- cmd/ship/ui.go | 199 ------------- cmd/ship/validate.go | 9 - cmd/ship/version.go | 17 -- internal/state/state.go | 83 +----- ship-new | Bin 13792549 -> 12401121 bytes 26 files changed, 9 insertions(+), 2837 deletions(-) delete mode 100644 cmd/ship/deploy.go delete mode 100644 cmd/ship/deploy_cmd.go delete mode 100644 cmd/ship/env/env.go delete mode 100644 cmd/ship/env/list.go delete mode 100644 cmd/ship/env/set.go delete mode 100644 cmd/ship/env/unset.go delete mode 100644 cmd/ship/host/host.go delete mode 100644 cmd/ship/host/init.go delete mode 100644 cmd/ship/host/set_domain.go delete mode 100644 cmd/ship/host/ssh.go delete mode 100644 cmd/ship/host/status.go delete mode 100644 cmd/ship/host/update.go delete mode 100644 cmd/ship/init.go delete mode 100644 cmd/ship/list.go delete mode 100644 cmd/ship/logs.go delete mode 100644 cmd/ship/remove.go delete mode 100644 cmd/ship/restart.go delete mode 100644 cmd/ship/root.go delete mode 100644 cmd/ship/status.go delete mode 100644 cmd/ship/ui.go delete mode 100644 cmd/ship/validate.go delete mode 100644 cmd/ship/version.go (limited to 'cmd/ship/main.go') diff --git a/cmd/ship/deploy.go b/cmd/ship/deploy.go deleted file mode 100644 index 414ade5..0000000 --- a/cmd/ship/deploy.go +++ /dev/null @@ -1,664 +0,0 @@ -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) - } - } - if err := validateName(name); err != nil { - return err - } - - // 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) - } - - // Create local .ship directory for deployment configs if they don't exist - // (handles both initial deployment and migration of existing deployments) - if _, err := os.Stat(".ship/service"); os.IsNotExist(err) { - fmt.Println("-> Creating local .ship directory...") - if err := os.MkdirAll(".ship", 0755); err != nil { - return fmt.Errorf("error creating .ship directory: %w", err) - } - - fmt.Println("-> Generating 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) - } - if err := os.WriteFile(".ship/service", []byte(serviceContent), 0644); err != nil { - return fmt.Errorf("error writing .ship/service: %w", err) - } - } - - if _, err := os.Stat(".ship/Caddyfile"); os.IsNotExist(err) { - fmt.Println("-> Generating Caddyfile...") - 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) - } - if err := os.WriteFile(".ship/Caddyfile", []byte(caddyContent), 0644); err != nil { - return fmt.Errorf("error writing .ship/Caddyfile: %w", err) - } - } - - // Upload systemd service from .ship/service - fmt.Println("-> Installing systemd service...") - serviceContent, err := os.ReadFile(".ship/service") - if err != nil { - return fmt.Errorf("error reading .ship/service: %w", err) - } - servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", opts.Name) - if err := client.WriteSudoFile(servicePath, string(serviceContent)); err != nil { - return fmt.Errorf("error installing systemd unit: %w", err) - } - - // Upload Caddyfile from .ship/Caddyfile - fmt.Println("-> Installing Caddy config...") - caddyContent, err := os.ReadFile(".ship/Caddyfile") - if err != nil { - return fmt.Errorf("error reading .ship/Caddyfile: %w", err) - } - caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", opts.Name) - if err := client.WriteSudoFile(caddyPath, string(caddyContent)); err != nil { - return fmt.Errorf("error installing 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" && existingApp.Type != "git-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) - } - - // For git-app, the systemd unit comes from .ship/service in the repo, - // so we only update the env file and restart. - if existingApp.Type != "git-app" { - // Regenerate systemd unit to .ship/service (resource flags are being updated) - 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) - } - - // Write to local .ship/service - if err := os.MkdirAll(".ship", 0755); err != nil { - return fmt.Errorf("error creating .ship directory: %w", err) - } - if err := os.WriteFile(".ship/service", []byte(serviceContent), 0644); err != nil { - return fmt.Errorf("error writing .ship/service: %w", err) - } - - // Upload to server - servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", opts.Name) - if err := client.WriteSudoFile(servicePath, serviceContent); err != nil { - return fmt.Errorf("error installing 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) - } - - // Create local .ship directory and Caddyfile for static sites if it doesn't exist - // (handles both initial deployment and migration of existing deployments) - shipDir := filepath.Join(opts.Dir, ".ship") - caddyfilePath := filepath.Join(shipDir, "Caddyfile") - - if _, err := os.Stat(caddyfilePath); os.IsNotExist(err) { - fmt.Println("-> Creating local .ship directory...") - if err := os.MkdirAll(shipDir, 0755); err != nil { - return fmt.Errorf("error creating .ship directory: %w", err) - } - - fmt.Println("-> Generating Caddyfile...") - caddyContent, err := templates.StaticCaddy(map[string]string{ - "Domain": opts.Domain, - "RootDir": remoteDir, - }) - if err != nil { - return fmt.Errorf("error generating Caddy config: %w", err) - } - if err := os.WriteFile(caddyfilePath, []byte(caddyContent), 0644); err != nil { - return fmt.Errorf("error writing .ship/Caddyfile: %w", err) - } - } - - // Upload Caddyfile from .ship/Caddyfile - fmt.Println("-> Installing Caddy config...") - caddyContent, err := os.ReadFile(caddyfilePath) - if err != nil { - return fmt.Errorf("error reading .ship/Caddyfile: %w", err) - } - caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", opts.Name) - if err := client.WriteSudoFile(caddyPath, string(caddyContent)); err != nil { - return fmt.Errorf("error installing 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/deploy_cmd.go b/cmd/ship/deploy_cmd.go deleted file mode 100644 index ba45c4f..0000000 --- a/cmd/ship/deploy_cmd.go +++ /dev/null @@ -1,141 +0,0 @@ -package main - -import ( - "fmt" - - "github.com/bdw/ship/internal/ssh" - "github.com/bdw/ship/internal/state" - "github.com/spf13/cobra" -) - -var deployGitCmd = &cobra.Command{ - Use: "deploy ", - Short: "Manually rebuild and deploy a git-deployed app", - Long: `Trigger a manual rebuild from the latest code in the git repo. - -This runs the same steps as the post-receive hook: checkout code, -install .ship/ configs, docker build (for apps), and restart. - -Examples: - ship deploy myapp`, - Args: cobra.ExactArgs(1), - RunE: runDeployGit, -} - -func runDeployGit(cmd *cobra.Command, args []string) error { - name := args[0] - if err := validateName(name); err != nil { - return err - } - - 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 != "git-app" && app.Type != "git-static" { - return fmt.Errorf("%s is not a git-deployed app (type: %s)", name, app.Type) - } - - fmt.Printf("Deploying %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 == "git-app" { - if err := deployGitApp(client, name); err != nil { - return err - } - } else { - if err := deployGitStatic(client, name); err != nil { - return err - } - } - - fmt.Println("\nDeploy complete!") - return nil -} - -func deployGitApp(client *ssh.Client, name string) error { - repo := fmt.Sprintf("/srv/git/%s.git", name) - src := fmt.Sprintf("/var/lib/%s/src", name) - - fmt.Println("-> Checking out code...") - if _, err := client.RunSudo(fmt.Sprintf("git --work-tree=%s --git-dir=%s checkout -f main", src, repo)); err != nil { - return fmt.Errorf("error checking out code: %w", err) - } - - // Install deployment config from repo - serviceSrc := fmt.Sprintf("%s/.ship/service", src) - serviceDst := fmt.Sprintf("/etc/systemd/system/%s.service", name) - fmt.Println("-> Installing systemd unit...") - if _, err := client.RunSudo(fmt.Sprintf("cp %s %s", serviceSrc, serviceDst)); err != nil { - fmt.Printf(" Warning: no .ship/service found, skipping\n") - } else { - if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { - return fmt.Errorf("error reloading systemd: %w", err) - } - } - - caddySrc := fmt.Sprintf("%s/.ship/Caddyfile", src) - caddyDst := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) - fmt.Println("-> Installing Caddy config...") - if _, err := client.RunSudo(fmt.Sprintf("cp %s %s", caddySrc, caddyDst)); err != nil { - fmt.Printf(" Warning: no .ship/Caddyfile found, skipping\n") - } else { - if _, err := client.RunSudo("systemctl reload caddy"); err != nil { - return fmt.Errorf("error reloading Caddy: %w", err) - } - } - - fmt.Println("-> Building Docker image...") - if err := client.RunSudoStream(fmt.Sprintf("docker build -t %s:latest %s", name, src)); err != nil { - return fmt.Errorf("error building Docker image: %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) - } - - return nil -} - -func deployGitStatic(client *ssh.Client, name string) error { - repo := fmt.Sprintf("/srv/git/%s.git", name) - webroot := fmt.Sprintf("/var/www/%s", name) - - fmt.Println("-> Deploying static site...") - if _, err := client.RunSudo(fmt.Sprintf("git --work-tree=%s --git-dir=%s checkout -f main", webroot, repo)); err != nil { - return fmt.Errorf("error checking out code: %w", err) - } - - caddySrc := fmt.Sprintf("%s/.ship/Caddyfile", webroot) - caddyDst := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) - fmt.Println("-> Installing Caddy config...") - if _, err := client.RunSudo(fmt.Sprintf("cp %s %s", caddySrc, caddyDst)); err != nil { - fmt.Printf(" Warning: no .ship/Caddyfile found, skipping\n") - } else { - if _, err := client.RunSudo("systemctl reload caddy"); err != nil { - return fmt.Errorf("error reloading Caddy: %w", err) - } - } - - return nil -} diff --git a/cmd/ship/env/env.go b/cmd/ship/env/env.go deleted file mode 100644 index 489353a..0000000 --- a/cmd/ship/env/env.go +++ /dev/null @@ -1,17 +0,0 @@ -package env - -import ( - "github.com/spf13/cobra" -) - -var Cmd = &cobra.Command{ - Use: "env", - Short: "Manage environment variables", - Long: "Manage environment variables for deployed applications", -} - -func init() { - Cmd.AddCommand(listCmd) - Cmd.AddCommand(setCmd) - Cmd.AddCommand(unsetCmd) -} diff --git a/cmd/ship/env/list.go b/cmd/ship/env/list.go deleted file mode 100644 index e94b83a..0000000 --- a/cmd/ship/env/list.go +++ /dev/null @@ -1,72 +0,0 @@ -package env - -import ( - "fmt" - "strings" - - "github.com/bdw/ship/internal/state" - "github.com/spf13/cobra" -) - -var listCmd = &cobra.Command{ - Use: "list ", - Short: "List environment variables for an app", - Args: cobra.ExactArgs(1), - RunE: runList, -} - -func runList(cmd *cobra.Command, args []string) error { - name := args[0] - if err := state.ValidateName(name); err != nil { - return err - } - - st, err := state.Load() - if err != nil { - return fmt.Errorf("error loading state: %w", err) - } - - host, _ := cmd.Flags().GetString("host") - if host == "" { - host = st.GetDefaultHost() - } - - if host == "" { - return fmt.Errorf("--host is required") - } - - app, err := st.GetApp(host, name) - if err != nil { - return err - } - - if app.Type != "app" { - return fmt.Errorf("env is only available for apps, not static sites") - } - - fmt.Printf("Environment variables for %s:\n\n", name) - if len(app.Env) == 0 { - fmt.Println(" (none)") - } else { - for k, v := range app.Env { - display := v - if isSensitive(k) { - display = "***" - } - fmt.Printf(" %s=%s\n", k, display) - } - } - - return nil -} - -func isSensitive(key string) bool { - key = strings.ToLower(key) - sensitiveWords := []string{"key", "secret", "password", "token", "api"} - for _, word := range sensitiveWords { - if strings.Contains(key, word) { - return true - } - } - return false -} diff --git a/cmd/ship/env/set.go b/cmd/ship/env/set.go deleted file mode 100644 index d4292f3..0000000 --- a/cmd/ship/env/set.go +++ /dev/null @@ -1,135 +0,0 @@ -package env - -import ( - "bufio" - "fmt" - "os" - "strings" - - "github.com/bdw/ship/internal/ssh" - "github.com/bdw/ship/internal/state" - "github.com/spf13/cobra" -) - -var setCmd = &cobra.Command{ - Use: "set KEY=VALUE...", - Short: "Set environment variable(s)", - Long: "Set one or more environment variables for an app. Variables are specified as KEY=VALUE pairs.", - Args: cobra.MinimumNArgs(2), - RunE: runSet, -} - -func init() { - setCmd.Flags().StringP("file", "f", "", "Load environment from file") -} - -func runSet(cmd *cobra.Command, args []string) error { - name := args[0] - if err := state.ValidateName(name); err != nil { - return err - } - envVars := args[1:] - - st, err := state.Load() - if err != nil { - return fmt.Errorf("error loading state: %w", err) - } - - host, _ := cmd.Flags().GetString("host") - if host == "" { - host = st.GetDefaultHost() - } - - if host == "" { - return fmt.Errorf("--host is required") - } - - app, err := st.GetApp(host, name) - if err != nil { - return err - } - - if app.Type != "app" { - return fmt.Errorf("env is only available for apps, not static sites") - } - - if app.Env == nil { - app.Env = make(map[string]string) - } - - // Set variables from args - for _, e := range envVars { - parts := strings.SplitN(e, "=", 2) - if len(parts) == 2 { - app.Env[parts[0]] = parts[1] - fmt.Printf("Set %s\n", parts[0]) - } else { - return fmt.Errorf("invalid format: %s (expected KEY=VALUE)", e) - } - } - - // Set variables from file if provided - envFile, _ := cmd.Flags().GetString("file") - if envFile != "" { - fileEnv, err := parseEnvFile(envFile) - if err != nil { - return fmt.Errorf("error reading env file: %w", err) - } - for k, v := range fileEnv { - app.Env[k] = v - fmt.Printf("Set %s\n", k) - } - } - - if err := st.Save(); err != nil { - return fmt.Errorf("error saving state: %w", err) - } - - client, err := ssh.Connect(host) - if err != nil { - return fmt.Errorf("error connecting to VPS: %w", err) - } - defer client.Close() - - fmt.Println("-> Updating environment file on VPS...") - envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", name) - envContent := "" - for k, v := range app.Env { - envContent += fmt.Sprintf("%s=%s\n", k, v) - } - if err := client.WriteSudoFile(envFilePath, envContent); err != nil { - return fmt.Errorf("error updating env file: %w", err) - } - - fmt.Println("-> Restarting service...") - if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { - return fmt.Errorf("error restarting service: %w", err) - } - - fmt.Println("Environment variables updated successfully") - return nil -} - -func parseEnvFile(path string) (map[string]string, error) { - file, err := os.Open(path) - if err != nil { - return nil, err - } - defer file.Close() - - env := make(map[string]string) - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - - parts := strings.SplitN(line, "=", 2) - if len(parts) == 2 { - env[parts[0]] = parts[1] - } - } - - return env, scanner.Err() -} diff --git a/cmd/ship/env/unset.go b/cmd/ship/env/unset.go deleted file mode 100644 index 8292f42..0000000 --- a/cmd/ship/env/unset.go +++ /dev/null @@ -1,95 +0,0 @@ -package env - -import ( - "fmt" - - "github.com/bdw/ship/internal/ssh" - "github.com/bdw/ship/internal/state" - "github.com/spf13/cobra" -) - -var unsetCmd = &cobra.Command{ - Use: "unset KEY...", - Short: "Unset environment variable(s)", - Long: "Remove one or more environment variables from an app.", - Args: cobra.MinimumNArgs(2), - RunE: runUnset, -} - -func runUnset(cmd *cobra.Command, args []string) error { - name := args[0] - if err := state.ValidateName(name); err != nil { - return err - } - keys := args[1:] - - st, err := state.Load() - if err != nil { - return fmt.Errorf("error loading state: %w", err) - } - - host, _ := cmd.Flags().GetString("host") - if host == "" { - host = st.GetDefaultHost() - } - - if host == "" { - return fmt.Errorf("--host is required") - } - - app, err := st.GetApp(host, name) - if err != nil { - return err - } - - if app.Type != "app" { - return fmt.Errorf("env is only available for apps, not static sites") - } - - if app.Env == nil { - return fmt.Errorf("no environment variables set") - } - - changed := false - for _, key := range keys { - if _, exists := app.Env[key]; exists { - delete(app.Env, key) - changed = true - fmt.Printf("Unset %s\n", key) - } else { - fmt.Printf("Warning: %s not found\n", key) - } - } - - if !changed { - return nil - } - - if err := st.Save(); err != nil { - return fmt.Errorf("error saving state: %w", err) - } - - client, err := ssh.Connect(host) - if err != nil { - return fmt.Errorf("error connecting to VPS: %w", err) - } - defer client.Close() - - fmt.Println("-> Updating environment file on VPS...") - envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", name) - envContent := "" - for k, v := range app.Env { - envContent += fmt.Sprintf("%s=%s\n", k, v) - } - if err := client.WriteSudoFile(envFilePath, envContent); err != nil { - return fmt.Errorf("error updating env file: %w", err) - } - - fmt.Println("-> Restarting service...") - if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { - return fmt.Errorf("error restarting service: %w", err) - } - - fmt.Println("Environment variables updated successfully") - return nil -} diff --git a/cmd/ship/host/host.go b/cmd/ship/host/host.go deleted file mode 100644 index 81403f9..0000000 --- a/cmd/ship/host/host.go +++ /dev/null @@ -1,21 +0,0 @@ -package host - -import ( - "github.com/spf13/cobra" -) - -var Cmd = &cobra.Command{ - Use: "host", - Short: "Manage VPS host", - Long: "Commands for managing and monitoring the VPS host", -} - -func init() { - Cmd.AddCommand(initCmd) - Cmd.AddCommand(statusCmd) - Cmd.AddCommand(updateCmd) - Cmd.AddCommand(sshCmd) - Cmd.AddCommand(setDomainCmd) - - initCmd.Flags().String("base-domain", "", "Base domain for auto-generated subdomains (e.g., apps.example.com)") -} diff --git a/cmd/ship/host/init.go b/cmd/ship/host/init.go deleted file mode 100644 index cfa2795..0000000 --- a/cmd/ship/host/init.go +++ /dev/null @@ -1,316 +0,0 @@ -package host - -import ( - "fmt" - "strings" - - "github.com/bdw/ship/internal/ssh" - "github.com/bdw/ship/internal/state" - "github.com/bdw/ship/internal/templates" - "github.com/spf13/cobra" -) - -var initCmd = &cobra.Command{ - Use: "init", - Short: "Initialize VPS (one-time setup)", - Long: "Set up a fresh VPS with Caddy for automatic HTTPS and required directories", - RunE: runInit, -} - -func runInit(cmd *cobra.Command, args []string) error { - st, err := state.Load() - if err != nil { - return fmt.Errorf("error loading state: %w", err) - } - - host, _ := cmd.Flags().GetString("host") - if host == "" { - host = st.GetDefaultHost() - } - baseDomain, _ := cmd.Flags().GetString("base-domain") - - if host == "" { - return fmt.Errorf("--host is required") - } - - fmt.Printf("Initializing VPS: %s\n", host) - - client, err := ssh.Connect(host) - if err != nil { - return fmt.Errorf("error connecting to VPS: %w", err) - } - defer client.Close() - - fmt.Println("-> Detecting OS...") - osRelease, err := client.Run("cat /etc/os-release") - if err != nil { - return fmt.Errorf("error detecting OS: %w", err) - } - - if !strings.Contains(osRelease, "Ubuntu") && !strings.Contains(osRelease, "Debian") { - return fmt.Errorf("unsupported OS (only Ubuntu and Debian are supported)") - } - fmt.Println(" Detected Ubuntu/Debian") - - fmt.Println("-> Checking for Caddy...") - _, err = client.Run("which caddy") - if err == nil { - fmt.Println(" Caddy already installed") - } else { - fmt.Println(" Installing Caddy...") - if err := installCaddy(client); err != nil { - return err - } - fmt.Println(" Caddy installed") - } - - fmt.Println("-> Configuring Caddy...") - caddyfile := `{ -} - -import /etc/caddy/sites-enabled/* -` - if err := client.WriteSudoFile("/etc/caddy/Caddyfile", caddyfile); err != nil { - return fmt.Errorf("error creating Caddyfile: %w", err) - } - fmt.Println(" Caddyfile created") - - fmt.Println("-> Creating directories...") - if _, err := client.RunSudo("mkdir -p /etc/ship/env"); err != nil { - return fmt.Errorf("error creating /etc/ship/env: %w", err) - } - if _, err := client.RunSudo("mkdir -p /etc/caddy/sites-enabled"); err != nil { - return fmt.Errorf("error creating /etc/caddy/sites-enabled: %w", err) - } - fmt.Println(" Directories created") - - fmt.Println("-> Starting Caddy...") - if _, err := client.RunSudo("systemctl enable caddy"); err != nil { - return fmt.Errorf("error enabling Caddy: %w", err) - } - if _, err := client.RunSudo("systemctl restart caddy"); err != nil { - return fmt.Errorf("error starting Caddy: %w", err) - } - fmt.Println(" Caddy started") - - fmt.Println("-> Verifying installation...") - output, err := client.RunSudo("systemctl is-active caddy") - if err != nil || strings.TrimSpace(output) != "active" { - fmt.Println(" Warning: Caddy may not be running properly") - } else { - fmt.Println(" Caddy is active") - } - - hostState := st.GetHost(host) - if baseDomain != "" { - hostState.BaseDomain = baseDomain - fmt.Printf(" Base domain: %s\n", baseDomain) - } - - // Git-centric deployment setup (gated on base domain) - if baseDomain != "" { - if err := setupGitDeploy(client, baseDomain, hostState); err != nil { - return err - } - } - - if st.GetDefaultHost() == "" { - st.SetDefaultHost(host) - fmt.Printf(" Set %s as default host\n", host) - } - if err := st.Save(); err != nil { - return fmt.Errorf("error saving state: %w", err) - } - - fmt.Println("\nVPS initialized successfully!") - fmt.Println("\nNext steps:") - fmt.Println(" 1. Deploy an app:") - fmt.Printf(" ship --binary ./myapp --domain api.example.com\n") - fmt.Println(" 2. Deploy a static site:") - fmt.Printf(" ship --static --dir ./dist --domain example.com\n") - if baseDomain != "" { - fmt.Println(" 3. Initialize a git-deployed app:") - fmt.Printf(" ship init myapp\n") - } - return nil -} - -func setupGitDeploy(client *ssh.Client, baseDomain string, hostState *state.Host) error { - fmt.Println("-> Installing Docker...") - dockerCommands := []string{ - "apt-get install -y ca-certificates curl gnupg", - "install -m 0755 -d /etc/apt/keyrings", - "curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc", - "chmod a+r /etc/apt/keyrings/docker.asc", - `sh -c 'echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo ${VERSION_CODENAME}) stable" > /etc/apt/sources.list.d/docker.list'`, - "apt-get update", - "apt-get install -y docker-ce docker-ce-cli containerd.io", - } - for _, cmd := range dockerCommands { - if _, err := client.RunSudo(cmd); err != nil { - return fmt.Errorf("error installing Docker: %w", err) - } - } - fmt.Println(" Docker installed") - - fmt.Println("-> Installing git, fcgiwrap, and cgit...") - if _, err := client.RunSudo("apt-get install -y git fcgiwrap cgit"); err != nil { - return fmt.Errorf("error installing git/fcgiwrap/cgit: %w", err) - } - // Allow git-http-backend (runs as www-data) to access repos owned by git. - // Scoped to www-data only, not system-wide, to preserve CVE-2022-24765 protection. - // www-data's home is /var/www; ensure it can write .gitconfig there. - client.RunSudo("mkdir -p /var/www") - client.RunSudo("chown www-data:www-data /var/www") - if _, err := client.RunSudo("sudo -u www-data git config --global --add safe.directory '*'"); err != nil { - return fmt.Errorf("error setting git safe.directory: %w", err) - } - fmt.Println(" git, fcgiwrap, and cgit installed") - - fmt.Println("-> Creating git user...") - // Create git user (ignore error if already exists) - client.RunSudo("useradd -r -m -d /home/git -s $(which git-shell) git") - if _, err := client.RunSudo("usermod -aG docker git"); err != nil { - return fmt.Errorf("error adding git user to docker group: %w", err) - } - // www-data needs to read git repos for git-http-backend - if _, err := client.RunSudo("usermod -aG git www-data"); err != nil { - return fmt.Errorf("error adding www-data to git group: %w", err) - } - // caddy needs to connect to fcgiwrap socket (owned by www-data) - if _, err := client.RunSudo("usermod -aG www-data caddy"); err != nil { - return fmt.Errorf("error adding caddy to www-data group: %w", err) - } - fmt.Println(" git user created") - - fmt.Println("-> Copying SSH keys to git user...") - copyKeysCommands := []string{ - "mkdir -p /home/git/.ssh", - "cp ~/.ssh/authorized_keys /home/git/.ssh/authorized_keys", - "chown -R git:git /home/git/.ssh", - "chmod 700 /home/git/.ssh", - "chmod 600 /home/git/.ssh/authorized_keys", - } - for _, cmd := range copyKeysCommands { - if _, err := client.RunSudo(cmd); err != nil { - return fmt.Errorf("error copying SSH keys: %w", err) - } - } - fmt.Println(" SSH keys copied") - - fmt.Println("-> Creating /srv/git...") - if _, err := client.RunSudo("mkdir -p /srv/git"); err != nil { - return fmt.Errorf("error creating /srv/git: %w", err) - } - if _, err := client.RunSudo("chown git:git /srv/git"); err != nil { - return fmt.Errorf("error setting /srv/git ownership: %w", err) - } - fmt.Println(" /srv/git created") - - fmt.Println("-> Writing sudoers for git user...") - sudoersContent := `# Ship git-deploy: allow post-receive hooks to install configs and manage services. -# App names are validated to [a-z][a-z0-9-] before reaching this point. -git ALL=(ALL) NOPASSWD: \ - /bin/systemctl daemon-reload, \ - /bin/systemctl reload caddy, \ - /bin/systemctl restart [a-z]*, \ - /bin/systemctl enable [a-z]*, \ - /bin/cp /var/lib/*/src/.ship/service /etc/systemd/system/*.service, \ - /bin/cp /var/lib/*/src/.ship/Caddyfile /etc/caddy/sites-enabled/*.caddy, \ - /bin/cp /var/www/*/.ship/Caddyfile /etc/caddy/sites-enabled/*.caddy, \ - /bin/mkdir -p /var/lib/*, \ - /bin/mkdir -p /var/www/*, \ - /bin/chown -R git\:git /var/lib/*, \ - /bin/chown git\:git /var/www/* -` - if err := client.WriteSudoFile("/etc/sudoers.d/ship-git", sudoersContent); err != nil { - return fmt.Errorf("error writing sudoers: %w", err) - } - if _, err := client.RunSudo("chmod 440 /etc/sudoers.d/ship-git"); err != nil { - return fmt.Errorf("error setting sudoers permissions: %w", err) - } - fmt.Println(" sudoers configured") - - fmt.Println("-> Writing vanity import template...") - vanityHTML := ` - -{{$path := trimPrefix "/" (placeholder "http.request.orig_uri.path")}} -{{$parts := splitList "/" $path}} -{{$module := first $parts}} - - -go get {{.Host}}/{{$module}} - -` - if _, err := client.RunSudo("mkdir -p /opt/ship/vanity"); err != nil { - return fmt.Errorf("error creating vanity directory: %w", err) - } - if err := client.WriteSudoFile("/opt/ship/vanity/index.html", vanityHTML); err != nil { - return fmt.Errorf("error writing vanity template: %w", err) - } - fmt.Println(" vanity template written") - - fmt.Println("-> Writing base domain Caddy config...") - codeCaddyContent, err := templates.CodeCaddy(map[string]string{ - "BaseDomain": baseDomain, - }) - if err != nil { - return fmt.Errorf("error generating code caddy config: %w", err) - } - if err := client.WriteSudoFile("/etc/caddy/sites-enabled/ship-code.caddy", codeCaddyContent); err != nil { - return fmt.Errorf("error writing code caddy config: %w", err) - } - fmt.Println(" base domain Caddy config written") - - fmt.Println("-> Writing cgit config...") - cgitrcContent, err := templates.CgitRC(map[string]string{ - "BaseDomain": baseDomain, - }) - if err != nil { - return fmt.Errorf("error generating cgitrc: %w", err) - } - if err := client.WriteSudoFile("/etc/cgitrc", cgitrcContent); err != nil { - return fmt.Errorf("error writing cgitrc: %w", err) - } - if err := client.WriteSudoFile("/opt/ship/cgit-header.html", templates.CgitHeader()); err != nil { - return fmt.Errorf("error writing cgit header: %w", err) - } - fmt.Println(" cgit config written") - - fmt.Println("-> Starting Docker and fcgiwrap...") - if _, err := client.RunSudo("systemctl enable docker fcgiwrap"); err != nil { - return fmt.Errorf("error enabling services: %w", err) - } - if _, err := client.RunSudo("systemctl restart docker fcgiwrap"); err != nil { - return fmt.Errorf("error starting services: %w", err) - } - fmt.Println(" Docker and fcgiwrap started") - - fmt.Println("-> Restarting Caddy...") - if _, err := client.RunSudo("systemctl restart caddy"); err != nil { - return fmt.Errorf("error restarting Caddy: %w", err) - } - fmt.Println(" Caddy restarted") - - hostState.GitSetup = true - fmt.Println(" Git deployment setup complete") - return nil -} - -func installCaddy(client *ssh.Client) error { - commands := []string{ - "apt-get update", - "apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl", - "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg", - "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list", - "apt-get update", - "apt-get install -y caddy", - } - - for _, cmd := range commands { - if _, err := client.RunSudo(cmd); err != nil { - return fmt.Errorf("error running: %s: %w", cmd, err) - } - } - return nil -} diff --git a/cmd/ship/host/set_domain.go b/cmd/ship/host/set_domain.go deleted file mode 100644 index fed3b31..0000000 --- a/cmd/ship/host/set_domain.go +++ /dev/null @@ -1,76 +0,0 @@ -package host - -import ( - "fmt" - - "github.com/bdw/ship/internal/state" - "github.com/spf13/cobra" -) - -var setDomainCmd = &cobra.Command{ - Use: "set-domain [domain]", - Short: "Set base domain for auto-generated subdomains", - Long: `Set the base domain used to auto-generate subdomains for deployments. - -When a base domain is configured (e.g., apps.example.com), every deployment -will automatically get a subdomain ({name}.apps.example.com). - -Examples: - ship host set-domain apps.example.com # Set base domain - ship host set-domain --clear # Remove base domain`, - RunE: runSetDomain, -} - -func init() { - setDomainCmd.Flags().Bool("clear", false, "Clear the base domain") -} - -func runSetDomain(cmd *cobra.Command, args []string) error { - st, err := state.Load() - if err != nil { - return fmt.Errorf("error loading state: %w", err) - } - - host, _ := cmd.Flags().GetString("host") - if host == "" { - host = st.GetDefaultHost() - } - - if host == "" { - return fmt.Errorf("--host is required") - } - - clear, _ := cmd.Flags().GetBool("clear") - - if !clear && len(args) == 0 { - // Show current base domain - hostState := st.GetHost(host) - if hostState.BaseDomain == "" { - fmt.Printf("No base domain configured for %s\n", host) - } else { - fmt.Printf("Base domain for %s: %s\n", host, hostState.BaseDomain) - } - return nil - } - - hostState := st.GetHost(host) - - if clear { - hostState.BaseDomain = "" - if err := st.Save(); err != nil { - return fmt.Errorf("error saving state: %w", err) - } - fmt.Printf("Cleared base domain for %s\n", host) - return nil - } - - hostState.BaseDomain = args[0] - if err := st.Save(); err != nil { - return fmt.Errorf("error saving state: %w", err) - } - - fmt.Printf("Set base domain for %s: %s\n", host, args[0]) - fmt.Println("\nNew deployments will automatically use subdomains like:") - fmt.Printf(" myapp.%s\n", args[0]) - return nil -} diff --git a/cmd/ship/host/ssh.go b/cmd/ship/host/ssh.go deleted file mode 100644 index e480e47..0000000 --- a/cmd/ship/host/ssh.go +++ /dev/null @@ -1,45 +0,0 @@ -package host - -import ( - "fmt" - "os" - "os/exec" - - "github.com/bdw/ship/internal/state" - "github.com/spf13/cobra" -) - -var sshCmd = &cobra.Command{ - Use: "ssh", - Short: "Open interactive SSH session", - RunE: runSSH, -} - -func runSSH(cmd *cobra.Command, args []string) error { - st, err := state.Load() - if err != nil { - return fmt.Errorf("error loading state: %w", err) - } - - host, _ := cmd.Flags().GetString("host") - if host == "" { - host = st.GetDefaultHost() - } - - if host == "" { - return fmt.Errorf("--host is required (no default host set)") - } - - sshCmd := exec.Command("ssh", host) - sshCmd.Stdin = os.Stdin - sshCmd.Stdout = os.Stdout - sshCmd.Stderr = os.Stderr - - if err := sshCmd.Run(); err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - os.Exit(exitErr.ExitCode()) - } - return err - } - return nil -} diff --git a/cmd/ship/host/status.go b/cmd/ship/host/status.go deleted file mode 100644 index eb2de53..0000000 --- a/cmd/ship/host/status.go +++ /dev/null @@ -1,108 +0,0 @@ -package host - -import ( - "fmt" - - "github.com/bdw/ship/internal/ssh" - "github.com/bdw/ship/internal/state" - "github.com/spf13/cobra" -) - -var statusCmd = &cobra.Command{ - Use: "status", - Short: "Show VPS health (uptime, disk, memory)", - RunE: runStatus, -} - -func runStatus(cmd *cobra.Command, args []string) error { - st, err := state.Load() - if err != nil { - return fmt.Errorf("error loading state: %w", err) - } - - host, _ := cmd.Flags().GetString("host") - if host == "" { - host = st.GetDefaultHost() - } - - if host == "" { - return fmt.Errorf("--host is required (no default host set)") - } - - fmt.Printf("Connecting to %s...\n\n", host) - - client, err := ssh.Connect(host) - if err != nil { - return fmt.Errorf("error connecting to VPS: %w", err) - } - defer client.Close() - - fmt.Println("UPTIME") - if output, err := client.Run("uptime -p"); err == nil { - fmt.Printf(" %s", output) - } - if output, err := client.Run("uptime -s"); err == nil { - fmt.Printf(" Since: %s", output) - } - fmt.Println() - - fmt.Println("LOAD") - if output, err := client.Run("cat /proc/loadavg | awk '{print $1, $2, $3}'"); err == nil { - fmt.Printf(" 1m, 5m, 15m: %s", output) - } - fmt.Println() - - fmt.Println("MEMORY") - if output, err := client.Run("free -h | awk 'NR==2 {print \" Used: \" $3 \" / \" $2}'"); err == nil { - fmt.Print(output) - } - if output, err := client.Run("free -h | awk 'NR==2 {printf \" Available: %s\\n\", $7}'"); err == nil { - fmt.Print(output) - } - fmt.Println() - - fmt.Println("DISK") - if output, err := client.Run("df -h / | awk 'NR==2 {print \" Used: \" $3 \" / \" $2 \" (\" $5 \")\"}'"); err == nil { - fmt.Print(output) - } - if output, err := client.Run("df -h / | awk 'NR==2 {print \" Available: \" $4}'"); err == nil { - fmt.Print(output) - } - fmt.Println() - - fmt.Println("UPDATES") - if output, err := client.Run("[ -f /var/lib/update-notifier/updates-available ] && cat /var/lib/update-notifier/updates-available | head -2 || echo ' (update info not available)'"); err == nil { - fmt.Print(output) - } - fmt.Println() - - fmt.Println("SERVICES") - if output, err := client.Run("systemctl is-active caddy 2>/dev/null && echo ' Caddy: active' || echo ' Caddy: inactive'"); err == nil { - if output == "active\n" { - fmt.Println(" Caddy: active") - } else { - fmt.Println(" Caddy: inactive") - } - } - - hostState := st.GetHost(host) - if hostState != nil && len(hostState.Apps) > 0 { - activeCount := 0 - for name, app := range hostState.Apps { - if app.Type == "app" { - if output, err := client.Run(fmt.Sprintf("systemctl is-active %s 2>/dev/null", name)); err == nil && output == "active\n" { - activeCount++ - } - } - } - appCount := 0 - for _, app := range hostState.Apps { - if app.Type == "app" { - appCount++ - } - } - fmt.Printf(" Deployed apps: %d/%d active\n", activeCount, appCount) - } - - return nil -} diff --git a/cmd/ship/host/update.go b/cmd/ship/host/update.go deleted file mode 100644 index 5f838b6..0000000 --- a/cmd/ship/host/update.go +++ /dev/null @@ -1,93 +0,0 @@ -package host - -import ( - "bufio" - "fmt" - "os" - "strings" - - "github.com/bdw/ship/internal/ssh" - "github.com/bdw/ship/internal/state" - "github.com/spf13/cobra" -) - -var updateCmd = &cobra.Command{ - Use: "update", - Short: "Update VPS packages", - Long: "Run apt update && apt upgrade on the VPS", - RunE: runUpdate, -} - -func init() { - updateCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") -} - -func runUpdate(cmd *cobra.Command, args []string) error { - st, err := state.Load() - if err != nil { - return fmt.Errorf("error loading state: %w", err) - } - - host, _ := cmd.Flags().GetString("host") - if host == "" { - host = st.GetDefaultHost() - } - - if host == "" { - return fmt.Errorf("--host is required (no default host set)") - } - - yes, _ := cmd.Flags().GetBool("yes") - if !yes { - fmt.Printf("This will run apt update && apt upgrade on %s\n", host) - fmt.Print("Continue? [Y/n]: ") - reader := bufio.NewReader(os.Stdin) - response, _ := reader.ReadString('\n') - response = strings.TrimSpace(response) - if response == "n" || response == "N" { - fmt.Println("Aborted.") - return nil - } - } - - fmt.Printf("Connecting to %s...\n", host) - - client, err := ssh.Connect(host) - if err != nil { - return fmt.Errorf("error connecting to VPS: %w", err) - } - defer client.Close() - - fmt.Println("\n-> Running apt update...") - if err := client.RunSudoStream("apt update"); err != nil { - return fmt.Errorf("error running apt update: %w", err) - } - - fmt.Println("\n-> Running apt upgrade...") - if err := client.RunSudoStream("DEBIAN_FRONTEND=noninteractive apt upgrade -y"); err != nil { - return fmt.Errorf("error running apt upgrade: %w", err) - } - - fmt.Println() - if output, err := client.Run("[ -f /var/run/reboot-required ] && echo 'yes' || echo 'no'"); err == nil { - if strings.TrimSpace(output) == "yes" { - fmt.Print("A reboot is required to complete the update. Reboot now? [Y/n]: ") - reader := bufio.NewReader(os.Stdin) - response, _ := reader.ReadString('\n') - response = strings.TrimSpace(response) - if response == "" || response == "y" || response == "Y" { - fmt.Println("Rebooting...") - if _, err := client.RunSudo("reboot"); err != nil { - // reboot command often returns an error as connection drops - // this is expected behavior - } - fmt.Println("Reboot initiated. The host will be back online shortly.") - return nil - } - fmt.Println("Skipping reboot. Run 'sudo reboot' manually when ready.") - } - } - - fmt.Println("Update complete") - return nil -} diff --git a/cmd/ship/init.go b/cmd/ship/init.go deleted file mode 100644 index b495702..0000000 --- a/cmd/ship/init.go +++ /dev/null @@ -1,268 +0,0 @@ -package main - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - "strconv" - - "github.com/bdw/ship/internal/ssh" - "github.com/bdw/ship/internal/state" - "github.com/bdw/ship/internal/templates" - "github.com/spf13/cobra" -) - -var initCmd = &cobra.Command{ - Use: "init ", - Short: "Initialize a git-deployed project", - Long: `Create a bare git repo on the VPS and generate local .ship/ config files. - -Pushing to the remote triggers an automatic docker build and deploy (for apps) -or a static file checkout (for static sites). If no Dockerfile is present in an -app repo, pushes are accepted without triggering a deploy. - -Examples: - # Initialize an app (Docker-based) - ship init myapp - - # Initialize with a custom domain - ship init myapp --domain custom.example.com - - # Initialize a static site - ship init mysite --static - - # Initialize a public repo (cloneable via go get / git clone over HTTPS) - ship init mylib --public`, - Args: cobra.ExactArgs(1), - RunE: runInit, -} - -func init() { - initCmd.Flags().Bool("static", false, "Initialize as static site") - initCmd.Flags().Bool("public", false, "Make repo publicly cloneable over HTTPS (for go get)") - initCmd.Flags().String("domain", "", "Custom domain (default: name.basedomain)") -} - -func runInit(cmd *cobra.Command, args []string) error { - name := args[0] - if err := validateName(name); err != nil { - return err - } - static, _ := cmd.Flags().GetBool("static") - public, _ := cmd.Flags().GetBool("public") - domain, _ := cmd.Flags().GetString("domain") - - 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") - } - - hostState := st.GetHost(host) - if !hostState.GitSetup { - return fmt.Errorf("git deployment not set up on %s (run 'ship host init --base-domain example.com' first)", host) - } - - // Check if app already exists - if _, err := st.GetApp(host, name); err == nil { - return fmt.Errorf("app %s already exists", name) - } - - appType := "git-app" - if static { - appType = "git-static" - } - - // Resolve domain - if domain == "" && hostState.BaseDomain != "" { - domain = name + "." + hostState.BaseDomain - } - if domain == "" { - return fmt.Errorf("--domain required (or configure base domain)") - } - - // Allocate port for apps only - port := 0 - if !static { - port = st.AllocatePort(host) - } - - fmt.Printf("Initializing %s: %s\n", appType, name) - - client, err := ssh.Connect(host) - if err != nil { - return fmt.Errorf("error connecting to VPS: %w", err) - } - defer client.Close() - - // Create bare repo - fmt.Println("-> Creating bare git repo...") - repo := fmt.Sprintf("/srv/git/%s.git", name) - if _, err := client.RunSudo(fmt.Sprintf("sudo -u git git init --bare -b main %s", repo)); err != nil { - return fmt.Errorf("error creating bare repo: %w", err) - } - - if public { - if _, err := client.RunSudo(fmt.Sprintf("sudo -u git touch %s/git-daemon-export-ok", repo)); err != nil { - return fmt.Errorf("error setting repo public: %w", err) - } - } - - if static { - // Create web root - fmt.Println("-> Creating web root...") - if _, err := client.RunSudo(fmt.Sprintf("mkdir -p /var/www/%s", name)); err != nil { - return fmt.Errorf("error creating web root: %w", err) - } - if _, err := client.RunSudo(fmt.Sprintf("chown git:git /var/www/%s", name)); err != nil { - return fmt.Errorf("error setting web root ownership: %w", err) - } - - // Write post-receive hook - fmt.Println("-> Writing post-receive hook...") - hookContent, err := templates.PostReceiveHookStatic(map[string]string{ - "Name": name, - }) - if err != nil { - return fmt.Errorf("error generating hook: %w", err) - } - if err := writeHook(client, repo, hookContent); err != nil { - return err - } - } else { - // Create env file - fmt.Println("-> Creating environment file...") - envContent := fmt.Sprintf("PORT=%d\nDATA_DIR=/data\n", port) - envPath := fmt.Sprintf("/etc/ship/env/%s.env", name) - if err := client.WriteSudoFile(envPath, envContent); err != nil { - return fmt.Errorf("error creating env file: %w", err) - } - - // Write post-receive hook (handles dir creation on first push) - fmt.Println("-> Writing post-receive hook...") - hookContent, err := templates.PostReceiveHook(map[string]string{ - "Name": name, - }) - if err != nil { - return fmt.Errorf("error generating hook: %w", err) - } - if err := writeHook(client, repo, hookContent); err != nil { - return err - } - } - - // Save state - st.AddApp(host, name, &state.App{ - Type: appType, - Domain: domain, - Port: port, - Repo: repo, - Public: public, - }) - if err := st.Save(); err != nil { - return fmt.Errorf("error saving state: %w", err) - } - - // Generate local .ship/ files - fmt.Println("-> Generating local .ship/ config...") - if err := os.MkdirAll(".ship", 0755); err != nil { - return fmt.Errorf("error creating .ship directory: %w", err) - } - - if static { - caddyContent, err := templates.DefaultStaticCaddy(map[string]string{ - "Domain": domain, - "Name": name, - }) - if err != nil { - return fmt.Errorf("error generating Caddyfile: %w", err) - } - if err := os.WriteFile(filepath.Join(".ship", "Caddyfile"), []byte(caddyContent), 0644); err != nil { - return fmt.Errorf("error writing Caddyfile: %w", err) - } - } else { - caddyContent, err := templates.DefaultAppCaddy(map[string]string{ - "Domain": domain, - "Port": strconv.Itoa(port), - }) - if err != nil { - return fmt.Errorf("error generating Caddyfile: %w", err) - } - if err := os.WriteFile(filepath.Join(".ship", "Caddyfile"), []byte(caddyContent), 0644); err != nil { - return fmt.Errorf("error writing Caddyfile: %w", err) - } - - serviceContent, err := templates.DockerService(map[string]string{ - "Name": name, - "Port": strconv.Itoa(port), - }) - if err != nil { - return fmt.Errorf("error generating service file: %w", err) - } - if err := os.WriteFile(filepath.Join(".ship", "service"), []byte(serviceContent), 0644); err != nil { - return fmt.Errorf("error writing service file: %w", err) - } - } - - // Initialize local git repo if needed - if _, err := os.Stat(".git"); os.IsNotExist(err) { - fmt.Println("-> Initializing git repo...") - gitInit := exec.Command("git", "init") - gitInit.Stdout = os.Stdout - gitInit.Stderr = os.Stderr - if err := gitInit.Run(); err != nil { - return fmt.Errorf("error initializing git repo: %w", err) - } - } - - // Add origin remote (replace if it already exists) - sshHost := host - remoteURL := fmt.Sprintf("git@%s:%s", sshHost, repo) - exec.Command("git", "remote", "remove", "origin").Run() // ignore error if not exists - addRemote := exec.Command("git", "remote", "add", "origin", remoteURL) - if err := addRemote.Run(); err != nil { - return fmt.Errorf("error adding git remote: %w", err) - } - - fmt.Printf("\nProject initialized: %s\n", name) - fmt.Println("\nGenerated:") - fmt.Println(" .ship/Caddyfile — Caddy config (edit to customize routing)") - if !static { - fmt.Println(" .ship/service — systemd unit (edit to customize resources, ports)") - } - fmt.Println("\nNext steps:") - if static { - fmt.Println(" git add .ship/") - } else { - fmt.Println(" git add .ship/ Dockerfile") - } - fmt.Println(" git commit -m \"initial deploy\"") - fmt.Println(" git push origin main") - if !static { - fmt.Println("\n (No Dockerfile? Just push — deploy is skipped until one is added.)") - } - - return nil -} - -func writeHook(client *ssh.Client, repo, content string) error { - hookPath := fmt.Sprintf("%s/hooks/post-receive", repo) - if err := client.WriteSudoFile(hookPath, content); err != nil { - return fmt.Errorf("error writing hook: %w", err) - } - if _, err := client.RunSudo(fmt.Sprintf("chmod +x %s", hookPath)); err != nil { - return fmt.Errorf("error making hook executable: %w", err) - } - if _, err := client.RunSudo(fmt.Sprintf("chown git:git %s", hookPath)); err != nil { - return fmt.Errorf("error setting hook ownership: %w", err) - } - return nil -} diff --git a/cmd/ship/list.go b/cmd/ship/list.go deleted file mode 100644 index af5baf8..0000000 --- a/cmd/ship/list.go +++ /dev/null @@ -1,61 +0,0 @@ -package main - -import ( - "fmt" - "os" - "text/tabwriter" - - "github.com/bdw/ship/internal/state" - "github.com/spf13/cobra" -) - -var listCmd = &cobra.Command{ - Use: "list", - Short: "List all deployed apps and sites", - RunE: runList, -} - -func runList(cmd *cobra.Command, args []string) error { - st, err := state.Load() - if err != nil { - return fmt.Errorf("error loading state: %w", err) - } - - host := hostFlag - if host == "" { - host = st.GetDefaultHost() - } - - if host == "" { - return fmt.Errorf("--host is required") - } - - apps := st.ListApps(host) - if len(apps) == 0 { - fmt.Printf("No deployments found for %s\n", host) - return nil - } - - w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) - fmt.Fprintln(w, "NAME\tTYPE\tVISIBILITY\tDOMAIN\tPORT") - for name, app := range apps { - port := "" - if app.Type == "app" || app.Type == "git-app" { - port = fmt.Sprintf(":%d", app.Port) - } - domain := app.Domain - if domain == "" { - domain = "-" - } - visibility := "" - if app.Repo != "" { - visibility = "private" - if app.Public { - visibility = "public" - } - } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", name, app.Type, visibility, domain, port) - } - w.Flush() - return nil -} diff --git a/cmd/ship/logs.go b/cmd/ship/logs.go deleted file mode 100644 index 4c58a9c..0000000 --- a/cmd/ship/logs.go +++ /dev/null @@ -1,78 +0,0 @@ -package main - -import ( - "fmt" - - "github.com/bdw/ship/internal/ssh" - "github.com/bdw/ship/internal/state" - "github.com/spf13/cobra" -) - -var logsCmd = &cobra.Command{ - Use: "logs ", - Short: "View logs for a deployment", - Args: cobra.ExactArgs(1), - RunE: runLogs, -} - -func init() { - logsCmd.Flags().BoolP("follow", "f", false, "Follow logs") - logsCmd.Flags().IntP("lines", "n", 50, "Number of lines to show") -} - -func runLogs(cmd *cobra.Command, args []string) error { - name := args[0] - if err := validateName(name); err != nil { - return err - } - 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" && app.Type != "git-app" { - return fmt.Errorf("logs are only available for apps, not static sites") - } - - client, err := ssh.Connect(host) - if err != nil { - return fmt.Errorf("error connecting to VPS: %w", err) - } - defer client.Close() - - journalCmd := fmt.Sprintf("journalctl -u %s -n %d", name, lines) - if follow { - journalCmd += " -f" - } - - if follow { - if err := client.RunStream(journalCmd); err != nil { - return fmt.Errorf("error fetching logs: %w", err) - } - } else { - output, err := client.Run(journalCmd) - if err != nil { - return fmt.Errorf("error fetching logs: %w", err) - } - fmt.Print(output) - } - - return nil -} diff --git a/cmd/ship/main.go b/cmd/ship/main.go index 73d9a20..17516fb 100644 --- a/cmd/ship/main.go +++ b/cmd/ship/main.go @@ -1,25 +1,10 @@ package main -import ( - "fmt" - "os" -) +import "os" func main() { - // Use v2 (agent-first JSON) interface by default - // Set SHIP_V1=1 to use legacy human-formatted output - if os.Getenv("SHIP_V1") == "1" { - if err := rootCmd.Execute(); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - return - } - - // v2: JSON output by default initV2() if err := rootV2Cmd.Execute(); err != nil { - // Error already printed as JSON by commands os.Exit(1) } } diff --git a/cmd/ship/remove.go b/cmd/ship/remove.go deleted file mode 100644 index b55d0c8..0000000 --- a/cmd/ship/remove.go +++ /dev/null @@ -1,109 +0,0 @@ -package main - -import ( - "fmt" - - "github.com/bdw/ship/internal/ssh" - "github.com/bdw/ship/internal/state" - "github.com/spf13/cobra" -) - -var removeCmd = &cobra.Command{ - Use: "remove ", - Aliases: []string{"rm"}, - Short: "Remove a deployment", - Args: cobra.ExactArgs(1), - RunE: runRemove, -} - -func runRemove(cmd *cobra.Command, args []string) error { - name := args[0] - if err := validateName(name); err != nil { - return err - } - - 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() - - switch app.Type { - case "app": - fmt.Println("-> Stopping service...") - client.RunSudo(fmt.Sprintf("systemctl stop %s", name)) - client.RunSudo(fmt.Sprintf("systemctl disable %s", name)) - - client.RunSudo(fmt.Sprintf("rm -f /etc/systemd/system/%s.service", name)) - client.RunSudo("systemctl daemon-reload") - - client.RunSudo(fmt.Sprintf("rm -f /usr/local/bin/%s", name)) - client.RunSudo(fmt.Sprintf("rm -rf /var/lib/%s", name)) - client.RunSudo(fmt.Sprintf("rm -f /etc/ship/env/%s.env", name)) - client.RunSudo(fmt.Sprintf("userdel %s", name)) - - case "git-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") - - fmt.Println("-> Removing Docker image...") - client.RunSudo(fmt.Sprintf("docker rmi %s:latest", name)) - - fmt.Println("-> Removing files...") - client.RunSudo(fmt.Sprintf("rm -rf /var/lib/%s", name)) - client.RunSudo(fmt.Sprintf("rm -rf /srv/git/%s.git", name)) - client.RunSudo(fmt.Sprintf("rm -f /etc/ship/env/%s.env", name)) - - case "git-static": - fmt.Println("-> Removing files...") - client.RunSudo(fmt.Sprintf("rm -rf /var/www/%s", name)) - client.RunSudo(fmt.Sprintf("rm -rf /srv/git/%s.git", name)) - - default: // "static" - fmt.Println("-> Removing files...") - client.RunSudo(fmt.Sprintf("rm -rf /var/www/%s", name)) - } - - fmt.Println("-> Removing Caddy config...") - client.RunSudo(fmt.Sprintf("rm -f /etc/caddy/sites-enabled/%s.caddy", name)) - - fmt.Println("-> Reloading Caddy...") - if _, err := client.RunSudo("systemctl reload caddy"); err != nil { - fmt.Printf("Warning: Error reloading Caddy: %v\n", err) - } - - if err := st.RemoveApp(host, name); err != nil { - return fmt.Errorf("error updating state: %w", err) - } - if err := st.Save(); err != nil { - return fmt.Errorf("error saving state: %w", err) - } - - fmt.Println("Deployment removed successfully") - return nil -} diff --git a/cmd/ship/restart.go b/cmd/ship/restart.go deleted file mode 100644 index c902adb..0000000 --- a/cmd/ship/restart.go +++ /dev/null @@ -1,60 +0,0 @@ -package main - -import ( - "fmt" - - "github.com/bdw/ship/internal/ssh" - "github.com/bdw/ship/internal/state" - "github.com/spf13/cobra" -) - -var restartCmd = &cobra.Command{ - Use: "restart ", - Short: "Restart a deployment", - Args: cobra.ExactArgs(1), - RunE: runRestart, -} - -func runRestart(cmd *cobra.Command, args []string) error { - name := args[0] - if err := validateName(name); err != nil { - return err - } - - 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" && app.Type != "git-app" { - return fmt.Errorf("restart is only available for apps, not static sites") - } - - client, err := ssh.Connect(host) - if err != nil { - return fmt.Errorf("error connecting to VPS: %w", err) - } - defer client.Close() - - fmt.Printf("Restarting %s...\n", name) - if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { - return fmt.Errorf("error restarting service: %w", err) - } - - fmt.Println("Service restarted successfully") - return nil -} diff --git a/cmd/ship/root.go b/cmd/ship/root.go deleted file mode 100644 index 93280f5..0000000 --- a/cmd/ship/root.go +++ /dev/null @@ -1,97 +0,0 @@ -package main - -import ( - "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(initCmd) - rootCmd.AddCommand(deployGitCmd) - rootCmd.AddCommand(env.Cmd) - rootCmd.AddCommand(host.Cmd) - rootCmd.AddCommand(uiCmd) - rootCmd.AddCommand(versionCmd) -} diff --git a/cmd/ship/root_v2.go b/cmd/ship/root_v2.go index 03c99e7..4101d4e 100644 --- a/cmd/ship/root_v2.go +++ b/cmd/ship/root_v2.go @@ -7,6 +7,8 @@ import ( "github.com/spf13/cobra" ) +var hostFlag string + // This file defines the v2 CLI structure. // The primary command is: ship [PATH] [FLAGS] // All output is JSON by default. diff --git a/cmd/ship/status.go b/cmd/ship/status.go deleted file mode 100644 index 4774fad..0000000 --- a/cmd/ship/status.go +++ /dev/null @@ -1,63 +0,0 @@ -package main - -import ( - "fmt" - - "github.com/bdw/ship/internal/ssh" - "github.com/bdw/ship/internal/state" - "github.com/spf13/cobra" -) - -var statusCmd = &cobra.Command{ - Use: "status ", - Short: "Check status of a deployment", - Args: cobra.ExactArgs(1), - RunE: runStatus, -} - -func runStatus(cmd *cobra.Command, args []string) error { - name := args[0] - if err := validateName(name); err != nil { - return err - } - - 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" && app.Type != "git-app" { - return fmt.Errorf("status is only available for apps, not static sites") - } - - client, err := ssh.Connect(host) - if err != nil { - return fmt.Errorf("error connecting to VPS: %w", err) - } - defer client.Close() - - output, err := client.RunSudo(fmt.Sprintf("systemctl status %s", name)) - if err != nil { - // systemctl status returns non-zero for non-active services - // but we still want to show the output - fmt.Print(output) - return nil - } - - fmt.Print(output) - return nil -} diff --git a/cmd/ship/ui.go b/cmd/ship/ui.go deleted file mode 100644 index cfaea08..0000000 --- a/cmd/ship/ui.go +++ /dev/null @@ -1,199 +0,0 @@ -package main - -import ( - "embed" - "encoding/json" - "fmt" - "html/template" - "net/http" - "sort" - "strconv" - - "github.com/bdw/ship/internal/state" - "github.com/bdw/ship/internal/templates" - "github.com/spf13/cobra" -) - -//go:embed templates/*.html -var templatesFS embed.FS - -var uiCmd = &cobra.Command{ - Use: "ui", - Short: "Launch web management UI", - RunE: runUI, -} - -func init() { - uiCmd.Flags().StringP("port", "p", "8080", "Port to run the web UI on") -} - -func runUI(cmd *cobra.Command, args []string) error { - port, _ := cmd.Flags().GetString("port") - - tmpl, err := template.ParseFS(templatesFS, "templates/webui.html") - if err != nil { - return fmt.Errorf("error parsing template: %w", err) - } - - http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - st, err := state.Load() - if err != nil { - http.Error(w, fmt.Sprintf("Error loading state: %v", err), http.StatusInternalServerError) - return - } - - type AppData struct { - Name string - Type string - Domain string - Port int - Env map[string]string - Host string - } - - type HostData struct { - Host string - Apps []AppData - } - - var hosts []HostData - for hostName, host := range st.Hosts { - var apps []AppData - for appName, app := range host.Apps { - apps = append(apps, AppData{ - Name: appName, - Type: app.Type, - Domain: app.Domain, - Port: app.Port, - Env: app.Env, - Host: hostName, - }) - } - - sort.Slice(apps, func(i, j int) bool { - return apps[i].Name < apps[j].Name - }) - - hosts = append(hosts, HostData{ - Host: hostName, - Apps: apps, - }) - } - - sort.Slice(hosts, func(i, j int) bool { - return hosts[i].Host < hosts[j].Host - }) - - data := struct { - Hosts []HostData - }{ - Hosts: hosts, - } - - if err := tmpl.Execute(w, data); err != nil { - http.Error(w, fmt.Sprintf("Error rendering template: %v", err), http.StatusInternalServerError) - return - } - }) - - http.HandleFunc("/api/state", func(w http.ResponseWriter, r *http.Request) { - st, err := state.Load() - if err != nil { - http.Error(w, fmt.Sprintf("Error loading state: %v", err), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(st) - }) - - http.HandleFunc("/api/configs", func(w http.ResponseWriter, r *http.Request) { - host := r.URL.Query().Get("host") - appName := r.URL.Query().Get("app") - - if host == "" || appName == "" { - http.Error(w, "Missing host or app parameter", http.StatusBadRequest) - return - } - - st, err := state.Load() - if err != nil { - http.Error(w, fmt.Sprintf("Error loading state: %v", err), http.StatusInternalServerError) - return - } - - app, err := st.GetApp(host, appName) - if err != nil { - http.Error(w, fmt.Sprintf("App not found: %v", err), http.StatusNotFound) - return - } - - configs := make(map[string]string) - - if app.Env != nil && len(app.Env) > 0 { - envContent := "" - for k, v := range app.Env { - envContent += fmt.Sprintf("%s=%s\n", k, v) - } - configs["env"] = envContent - configs["envPath"] = fmt.Sprintf("/etc/ship/env/%s.env", appName) - } - - if app.Type == "app" { - workDir := fmt.Sprintf("/var/lib/%s", appName) - binaryPath := fmt.Sprintf("/usr/local/bin/%s", appName) - envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", appName) - - serviceContent, err := templates.SystemdService(map[string]string{ - "Name": appName, - "User": appName, - "WorkDir": workDir, - "BinaryPath": binaryPath, - "Port": strconv.Itoa(app.Port), - "EnvFile": envFilePath, - "Args": app.Args, - }) - if err != nil { - http.Error(w, fmt.Sprintf("Error rendering systemd service: %v", err), http.StatusInternalServerError) - return - } - configs["systemd"] = serviceContent - configs["systemdPath"] = fmt.Sprintf("/etc/systemd/system/%s.service", appName) - - caddyContent, err := templates.AppCaddy(map[string]string{ - "Domain": app.Domain, - "Port": strconv.Itoa(app.Port), - }) - if err != nil { - http.Error(w, fmt.Sprintf("Error rendering Caddy config: %v", err), http.StatusInternalServerError) - return - } - configs["caddy"] = caddyContent - configs["caddyPath"] = fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", appName) - } else if app.Type == "static" { - remoteDir := fmt.Sprintf("/var/www/%s", appName) - caddyContent, err := templates.StaticCaddy(map[string]string{ - "Domain": app.Domain, - "RootDir": remoteDir, - }) - if err != nil { - http.Error(w, fmt.Sprintf("Error rendering Caddy config: %v", err), http.StatusInternalServerError) - return - } - configs["caddy"] = caddyContent - configs["caddyPath"] = fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", appName) - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(configs) - }) - - addr := fmt.Sprintf("localhost:%s", port) - fmt.Printf("Starting web UI on http://%s\n", addr) - fmt.Printf("Press Ctrl+C to stop\n") - - if err := http.ListenAndServe(addr, nil); err != nil { - return fmt.Errorf("error starting server: %w", err) - } - return nil -} diff --git a/cmd/ship/validate.go b/cmd/ship/validate.go deleted file mode 100644 index 00275af..0000000 --- a/cmd/ship/validate.go +++ /dev/null @@ -1,9 +0,0 @@ -package main - -import "github.com/bdw/ship/internal/state" - -// validateName checks that an app/project name is safe for use in shell -// commands, file paths, systemd units, and DNS labels. -func validateName(name string) error { - return state.ValidateName(name) -} diff --git a/cmd/ship/version.go b/cmd/ship/version.go deleted file mode 100644 index 6e4314a..0000000 --- a/cmd/ship/version.go +++ /dev/null @@ -1,17 +0,0 @@ -package main - -import ( - "fmt" - - "github.com/spf13/cobra" -) - -var versionCmd = &cobra.Command{ - Use: "version", - Short: "Show version information", - Run: func(cmd *cobra.Command, args []string) { - fmt.Printf("ship version %s\n", version) - fmt.Printf(" commit: %s\n", commit) - fmt.Printf(" built: %s\n", date) - }, -} diff --git a/internal/state/state.go b/internal/state/state.go index c9aa21d..9b06179 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -8,38 +8,18 @@ import ( "regexp" ) -// State represents the entire local deployment state +// State represents the local ship configuration type State struct { DefaultHost string `json:"default_host,omitempty"` Hosts map[string]*Host `json:"hosts"` } -// Host represents deployment state for a single VPS +// Host represents configuration for a single VPS type Host struct { - NextPort int `json:"next_port"` - BaseDomain string `json:"base_domain,omitempty"` - GitSetup bool `json:"git_setup,omitempty"` - Apps map[string]*App `json:"apps"` + BaseDomain string `json:"base_domain,omitempty"` + GitSetup bool `json:"git_setup,omitempty"` } -// App represents a deployed application or static site -type App struct { - Type string `json:"type"` // "app", "static", "git-app", or "git-static" - Domain string `json:"domain"` - Port int `json:"port,omitempty"` // only for type="app" or "git-app" - Repo string `json:"repo,omitempty"` // only for git types, e.g. "/srv/git/foo.git" - Public bool `json:"public,omitempty"` // only for git types, enables HTTP clone access - Env map[string]string `json:"env,omitempty"` // only for type="app" or "git-app" - Args string `json:"args,omitempty"` // only for type="app" - Files []string `json:"files,omitempty"` // only for type="app" - Memory string `json:"memory,omitempty"` // only for type="app" - CPU string `json:"cpu,omitempty"` // only for type="app" -} - -const ( - startPort = 8001 -) - var validName = regexp.MustCompile(`^[a-z][a-z0-9-]{0,62}$`) // ValidateName checks that a name is safe for use in shell commands, @@ -55,7 +35,6 @@ func ValidateName(name string) error { func Load() (*State, error) { path := statePath() - // If file doesn't exist, return empty state if _, err := os.Stat(path); os.IsNotExist(err) { return &State{ Hosts: make(map[string]*Host), @@ -72,7 +51,6 @@ func Load() (*State, error) { return nil, fmt.Errorf("failed to parse state file: %w", err) } - // Initialize maps if nil if state.Hosts == nil { state.Hosts = make(map[string]*Host) } @@ -84,7 +62,6 @@ func Load() (*State, error) { func (s *State) Save() error { path := statePath() - // Ensure directory exists dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0755); err != nil { return fmt.Errorf("failed to create config directory: %w", err) @@ -102,60 +79,14 @@ func (s *State) Save() error { return nil } -// GetHost returns the host state, creating it if it doesn't exist +// GetHost returns the host config, creating it if it doesn't exist func (s *State) GetHost(host string) *Host { if s.Hosts[host] == nil { - s.Hosts[host] = &Host{ - NextPort: startPort, - Apps: make(map[string]*App), - } - } - if s.Hosts[host].Apps == nil { - s.Hosts[host].Apps = make(map[string]*App) + s.Hosts[host] = &Host{} } return s.Hosts[host] } -// AllocatePort returns the next available port for a host -func (s *State) AllocatePort(host string) int { - h := s.GetHost(host) - port := h.NextPort - h.NextPort++ - return port -} - -// AddApp adds or updates an app in the state -func (s *State) AddApp(host, name string, app *App) { - h := s.GetHost(host) - h.Apps[name] = app -} - -// RemoveApp removes an app from the state -func (s *State) RemoveApp(host, name string) error { - h := s.GetHost(host) - if _, exists := h.Apps[name]; !exists { - return fmt.Errorf("app %s not found", name) - } - delete(h.Apps, name) - return nil -} - -// GetApp returns an app from the state -func (s *State) GetApp(host, name string) (*App, error) { - h := s.GetHost(host) - app, exists := h.Apps[name] - if !exists { - return nil, fmt.Errorf("app %s not found", name) - } - return app, nil -} - -// ListApps returns all apps for a host -func (s *State) ListApps(host string) map[string]*App { - h := s.GetHost(host) - return h.Apps -} - // GetDefaultHost returns the default host, or empty string if not set func (s *State) GetDefaultHost() string { return s.DefaultHost @@ -166,11 +97,9 @@ func (s *State) SetDefaultHost(host string) { s.DefaultHost = host } -// statePath returns the path to the state file func statePath() string { home, err := os.UserHomeDir() if err != nil { - // Fallback to current directory (should rarely happen) return ".ship-state.json" } return filepath.Join(home, ".config", "ship", "state.json") diff --git a/ship-new b/ship-new index 0679b00..814585f 100755 Binary files a/ship-new and b/ship-new differ -- cgit v1.2.3