diff options
Diffstat (limited to 'cmd')
| -rw-r--r-- | cmd/ship/commands_v2.go | 365 | ||||
| -rw-r--r-- | cmd/ship/deploy_impl_v2.go | 391 | ||||
| -rw-r--r-- | cmd/ship/deploy_v2.go | 210 | ||||
| -rw-r--r-- | cmd/ship/host_v2.go | 445 | ||||
| -rw-r--r-- | cmd/ship/main.go | 10 | ||||
| -rw-r--r-- | cmd/ship/root_v2.go | 98 | ||||
| -rw-r--r-- | cmd/ship/templates/webui.html | 440 |
7 files changed, 0 insertions, 1959 deletions
diff --git a/cmd/ship/commands_v2.go b/cmd/ship/commands_v2.go deleted file mode 100644 index 1b0d09c..0000000 --- a/cmd/ship/commands_v2.go +++ /dev/null | |||
| @@ -1,365 +0,0 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "strconv" | ||
| 6 | "strings" | ||
| 7 | "time" | ||
| 8 | |||
| 9 | "github.com/bdw/ship/internal/output" | ||
| 10 | "github.com/bdw/ship/internal/ssh" | ||
| 11 | "github.com/bdw/ship/internal/state" | ||
| 12 | "github.com/spf13/cobra" | ||
| 13 | ) | ||
| 14 | |||
| 15 | // listV2Cmd lists all deployments | ||
| 16 | var listV2Cmd = &cobra.Command{ | ||
| 17 | Use: "list", | ||
| 18 | Short: "List all deployments", | ||
| 19 | RunE: runListV2, | ||
| 20 | } | ||
| 21 | |||
| 22 | func runListV2(cmd *cobra.Command, args []string) error { | ||
| 23 | st, err := state.Load() | ||
| 24 | if err != nil { | ||
| 25 | output.PrintAndExit(output.Err(output.ErrHostNotConfigured, err.Error())) | ||
| 26 | } | ||
| 27 | |||
| 28 | hostName := hostFlag | ||
| 29 | if hostName == "" { | ||
| 30 | hostName = st.DefaultHost | ||
| 31 | } | ||
| 32 | if hostName == "" { | ||
| 33 | output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified")) | ||
| 34 | } | ||
| 35 | |||
| 36 | hostConfig := st.GetHost(hostName) | ||
| 37 | |||
| 38 | client, err := ssh.Connect(hostName) | ||
| 39 | if err != nil { | ||
| 40 | output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error())) | ||
| 41 | } | ||
| 42 | defer client.Close() | ||
| 43 | |||
| 44 | var deploys []output.DeployInfo | ||
| 45 | |||
| 46 | // Get all deployed services by checking /etc/ship/ports and /var/www | ||
| 47 | // Check ports (apps and docker) | ||
| 48 | portsOut, _ := client.Run("ls /etc/ship/ports/ 2>/dev/null || true") | ||
| 49 | for _, name := range strings.Fields(portsOut) { | ||
| 50 | if name == "" { | ||
| 51 | continue | ||
| 52 | } | ||
| 53 | |||
| 54 | // Get actual domain from Caddyfile (first word of first line) | ||
| 55 | domain := fmt.Sprintf("%s.%s", name, hostConfig.BaseDomain) | ||
| 56 | caddyOut, _ := client.Run(fmt.Sprintf("head -1 /etc/caddy/sites-enabled/%s.caddy 2>/dev/null | awk '{print $1}'", name)) | ||
| 57 | if d := strings.TrimSpace(caddyOut); d != "" && d != "{" { | ||
| 58 | domain = d | ||
| 59 | } | ||
| 60 | |||
| 61 | info := output.DeployInfo{ | ||
| 62 | Name: name, | ||
| 63 | URL: fmt.Sprintf("https://%s", domain), | ||
| 64 | } | ||
| 65 | |||
| 66 | // Check if it's docker or binary | ||
| 67 | dockerOut, _ := client.Run(fmt.Sprintf("docker inspect %s 2>/dev/null && echo docker", name)) | ||
| 68 | if strings.Contains(dockerOut, "docker") { | ||
| 69 | info.Type = "docker" | ||
| 70 | } else { | ||
| 71 | info.Type = "binary" | ||
| 72 | } | ||
| 73 | |||
| 74 | // Check if running | ||
| 75 | statusOut, _ := client.RunSudo(fmt.Sprintf("systemctl is-active %s 2>/dev/null || echo inactive", name)) | ||
| 76 | info.Running = strings.TrimSpace(statusOut) == "active" | ||
| 77 | |||
| 78 | // Check TTL | ||
| 79 | ttlOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ttl/%s 2>/dev/null || true", name)) | ||
| 80 | if ttlOut != "" { | ||
| 81 | if expires, err := strconv.ParseInt(strings.TrimSpace(ttlOut), 10, 64); err == nil { | ||
| 82 | info.Expires = time.Unix(expires, 0).UTC().Format(time.RFC3339) | ||
| 83 | } | ||
| 84 | } | ||
| 85 | |||
| 86 | deploys = append(deploys, info) | ||
| 87 | } | ||
| 88 | |||
| 89 | // Check static sites in /var/www | ||
| 90 | wwwOut, _ := client.Run("ls -d /var/www/*/ 2>/dev/null | xargs -n1 basename 2>/dev/null || true") | ||
| 91 | for _, name := range strings.Fields(wwwOut) { | ||
| 92 | if name == "" || name == "html" { | ||
| 93 | continue | ||
| 94 | } | ||
| 95 | |||
| 96 | // Skip if already in ports (would be an app, not static) | ||
| 97 | found := false | ||
| 98 | for _, d := range deploys { | ||
| 99 | if d.Name == name { | ||
| 100 | found = true | ||
| 101 | break | ||
| 102 | } | ||
| 103 | } | ||
| 104 | if found { | ||
| 105 | continue | ||
| 106 | } | ||
| 107 | |||
| 108 | // Get actual domain from Caddyfile | ||
| 109 | domain := fmt.Sprintf("%s.%s", name, hostConfig.BaseDomain) | ||
| 110 | caddyOut, _ := client.Run(fmt.Sprintf("head -1 /etc/caddy/sites-enabled/%s.caddy 2>/dev/null | awk '{print $1}'", name)) | ||
| 111 | if d := strings.TrimSpace(caddyOut); d != "" && d != "{" { | ||
| 112 | domain = d | ||
| 113 | } | ||
| 114 | |||
| 115 | info := output.DeployInfo{ | ||
| 116 | Name: name, | ||
| 117 | URL: fmt.Sprintf("https://%s", domain), | ||
| 118 | Type: "static", | ||
| 119 | Running: true, // Static sites are always "running" | ||
| 120 | } | ||
| 121 | |||
| 122 | // Check TTL | ||
| 123 | ttlOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ttl/%s 2>/dev/null || true", name)) | ||
| 124 | if ttlOut != "" { | ||
| 125 | if expires, err := strconv.ParseInt(strings.TrimSpace(ttlOut), 10, 64); err == nil { | ||
| 126 | info.Expires = time.Unix(expires, 0).UTC().Format(time.RFC3339) | ||
| 127 | } | ||
| 128 | } | ||
| 129 | |||
| 130 | deploys = append(deploys, info) | ||
| 131 | } | ||
| 132 | |||
| 133 | output.PrintAndExit(&output.ListResponse{ | ||
| 134 | Status: "ok", | ||
| 135 | Deploys: deploys, | ||
| 136 | }) | ||
| 137 | return nil | ||
| 138 | } | ||
| 139 | |||
| 140 | // statusV2Cmd shows status for a single deployment | ||
| 141 | var statusV2Cmd = &cobra.Command{ | ||
| 142 | Use: "status NAME", | ||
| 143 | Short: "Check status of a deployment", | ||
| 144 | Args: cobra.ExactArgs(1), | ||
| 145 | RunE: runStatusV2, | ||
| 146 | } | ||
| 147 | |||
| 148 | func runStatusV2(cmd *cobra.Command, args []string) error { | ||
| 149 | name := args[0] | ||
| 150 | |||
| 151 | st, err := state.Load() | ||
| 152 | if err != nil { | ||
| 153 | output.PrintAndExit(output.Err(output.ErrHostNotConfigured, err.Error())) | ||
| 154 | } | ||
| 155 | |||
| 156 | hostName := hostFlag | ||
| 157 | if hostName == "" { | ||
| 158 | hostName = st.DefaultHost | ||
| 159 | } | ||
| 160 | if hostName == "" { | ||
| 161 | output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified")) | ||
| 162 | } | ||
| 163 | |||
| 164 | hostConfig := st.GetHost(hostName) | ||
| 165 | |||
| 166 | client, err := ssh.Connect(hostName) | ||
| 167 | if err != nil { | ||
| 168 | output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error())) | ||
| 169 | } | ||
| 170 | defer client.Close() | ||
| 171 | |||
| 172 | // Check if deployment exists | ||
| 173 | portOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ports/%s 2>/dev/null || true", name)) | ||
| 174 | wwwExists, _ := client.Run(fmt.Sprintf("test -d /var/www/%s && echo exists || true", name)) | ||
| 175 | |||
| 176 | if strings.TrimSpace(portOut) == "" && strings.TrimSpace(wwwExists) == "" { | ||
| 177 | output.PrintAndExit(output.ErrWithName(output.ErrNotFound, "deployment not found", name)) | ||
| 178 | } | ||
| 179 | |||
| 180 | // Get actual domain from Caddyfile | ||
| 181 | domain := fmt.Sprintf("%s.%s", name, hostConfig.BaseDomain) | ||
| 182 | caddyOut, _ := client.Run(fmt.Sprintf("head -1 /etc/caddy/sites-enabled/%s.caddy 2>/dev/null | awk '{print $1}'", name)) | ||
| 183 | if d := strings.TrimSpace(caddyOut); d != "" && d != "{" { | ||
| 184 | domain = d | ||
| 185 | } | ||
| 186 | |||
| 187 | resp := &output.StatusResponse{ | ||
| 188 | Status: "ok", | ||
| 189 | Name: name, | ||
| 190 | URL: fmt.Sprintf("https://%s", domain), | ||
| 191 | } | ||
| 192 | |||
| 193 | // Determine type and get details | ||
| 194 | if portOut != "" { | ||
| 195 | port, _ := strconv.Atoi(strings.TrimSpace(portOut)) | ||
| 196 | resp.Port = port | ||
| 197 | |||
| 198 | // Check if docker | ||
| 199 | dockerOut, _ := client.Run(fmt.Sprintf("docker inspect %s 2>/dev/null && echo docker", name)) | ||
| 200 | if strings.Contains(dockerOut, "docker") { | ||
| 201 | resp.Type = "docker" | ||
| 202 | } else { | ||
| 203 | resp.Type = "binary" | ||
| 204 | } | ||
| 205 | |||
| 206 | // Check if running | ||
| 207 | statusOut, _ := client.RunSudo(fmt.Sprintf("systemctl is-active %s 2>/dev/null || echo inactive", name)) | ||
| 208 | resp.Running = strings.TrimSpace(statusOut) == "active" | ||
| 209 | } else { | ||
| 210 | resp.Type = "static" | ||
| 211 | resp.Running = true | ||
| 212 | } | ||
| 213 | |||
| 214 | // Check TTL | ||
| 215 | ttlOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ttl/%s 2>/dev/null || true", name)) | ||
| 216 | if ttlOut != "" { | ||
| 217 | if expires, err := strconv.ParseInt(strings.TrimSpace(ttlOut), 10, 64); err == nil { | ||
| 218 | resp.Expires = time.Unix(expires, 0).UTC().Format(time.RFC3339) | ||
| 219 | } | ||
| 220 | } | ||
| 221 | |||
| 222 | output.PrintAndExit(resp) | ||
| 223 | return nil | ||
| 224 | } | ||
| 225 | |||
| 226 | // logsV2Cmd shows logs for a deployment | ||
| 227 | var logsV2Cmd = &cobra.Command{ | ||
| 228 | Use: "logs NAME", | ||
| 229 | Short: "View logs for a deployment", | ||
| 230 | Args: cobra.ExactArgs(1), | ||
| 231 | RunE: runLogsV2, | ||
| 232 | } | ||
| 233 | |||
| 234 | func init() { | ||
| 235 | logsV2Cmd.Flags().Int("lines", 50, "Number of log lines to show") | ||
| 236 | } | ||
| 237 | |||
| 238 | func runLogsV2(cmd *cobra.Command, args []string) error { | ||
| 239 | name := args[0] | ||
| 240 | lines, _ := cmd.Flags().GetInt("lines") | ||
| 241 | |||
| 242 | st, err := state.Load() | ||
| 243 | if err != nil { | ||
| 244 | output.PrintAndExit(output.Err(output.ErrHostNotConfigured, err.Error())) | ||
| 245 | } | ||
| 246 | |||
| 247 | hostName := hostFlag | ||
| 248 | if hostName == "" { | ||
| 249 | hostName = st.DefaultHost | ||
| 250 | } | ||
| 251 | if hostName == "" { | ||
| 252 | output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified")) | ||
| 253 | } | ||
| 254 | |||
| 255 | client, err := ssh.Connect(hostName) | ||
| 256 | if err != nil { | ||
| 257 | output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error())) | ||
| 258 | } | ||
| 259 | defer client.Close() | ||
| 260 | |||
| 261 | // Check if it's a static site (no logs) | ||
| 262 | portOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ports/%s 2>/dev/null || true", name)) | ||
| 263 | if strings.TrimSpace(portOut) == "" { | ||
| 264 | // Check if static site exists | ||
| 265 | wwwExists, _ := client.Run(fmt.Sprintf("test -d /var/www/%s && echo exists || true", name)) | ||
| 266 | if strings.TrimSpace(wwwExists) == "" { | ||
| 267 | output.PrintAndExit(output.ErrWithName(output.ErrNotFound, "deployment not found", name)) | ||
| 268 | } | ||
| 269 | // Static site - check Caddy access logs | ||
| 270 | logsOut, err := client.RunSudo(fmt.Sprintf("journalctl -u caddy -n %d --no-pager 2>/dev/null | grep %s || echo 'No logs found'", lines*2, name)) | ||
| 271 | if err != nil { | ||
| 272 | output.PrintAndExit(output.Err(output.ErrServiceFailed, err.Error())) | ||
| 273 | } | ||
| 274 | logLines := strings.Split(strings.TrimSpace(logsOut), "\n") | ||
| 275 | output.PrintAndExit(&output.LogsResponse{ | ||
| 276 | Status: "ok", | ||
| 277 | Name: name, | ||
| 278 | Lines: logLines, | ||
| 279 | }) | ||
| 280 | return nil | ||
| 281 | } | ||
| 282 | |||
| 283 | // Get journalctl logs | ||
| 284 | logsOut, err := client.RunSudo(fmt.Sprintf("journalctl -u %s -n %d --no-pager 2>/dev/null || echo 'No logs found'", name, lines)) | ||
| 285 | if err != nil { | ||
| 286 | output.PrintAndExit(output.Err(output.ErrServiceFailed, err.Error())) | ||
| 287 | } | ||
| 288 | |||
| 289 | logLines := strings.Split(strings.TrimSpace(logsOut), "\n") | ||
| 290 | |||
| 291 | output.PrintAndExit(&output.LogsResponse{ | ||
| 292 | Status: "ok", | ||
| 293 | Name: name, | ||
| 294 | Lines: logLines, | ||
| 295 | }) | ||
| 296 | return nil | ||
| 297 | } | ||
| 298 | |||
| 299 | // removeV2Cmd removes a deployment | ||
| 300 | var removeV2Cmd = &cobra.Command{ | ||
| 301 | Use: "remove NAME", | ||
| 302 | Short: "Remove a deployment", | ||
| 303 | Args: cobra.ExactArgs(1), | ||
| 304 | RunE: runRemoveV2, | ||
| 305 | } | ||
| 306 | |||
| 307 | func runRemoveV2(cmd *cobra.Command, args []string) error { | ||
| 308 | name := args[0] | ||
| 309 | |||
| 310 | st, err := state.Load() | ||
| 311 | if err != nil { | ||
| 312 | output.PrintAndExit(output.Err(output.ErrHostNotConfigured, err.Error())) | ||
| 313 | } | ||
| 314 | |||
| 315 | hostName := hostFlag | ||
| 316 | if hostName == "" { | ||
| 317 | hostName = st.DefaultHost | ||
| 318 | } | ||
| 319 | if hostName == "" { | ||
| 320 | output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified")) | ||
| 321 | } | ||
| 322 | |||
| 323 | client, err := ssh.Connect(hostName) | ||
| 324 | if err != nil { | ||
| 325 | output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error())) | ||
| 326 | } | ||
| 327 | defer client.Close() | ||
| 328 | |||
| 329 | // Check if deployment exists | ||
| 330 | portOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ports/%s 2>/dev/null || true", name)) | ||
| 331 | wwwExists, _ := client.Run(fmt.Sprintf("test -d /var/www/%s && echo exists || true", name)) | ||
| 332 | |||
| 333 | if strings.TrimSpace(portOut) == "" && strings.TrimSpace(wwwExists) == "" { | ||
| 334 | output.PrintAndExit(output.ErrWithName(output.ErrNotFound, "deployment not found", name)) | ||
| 335 | } | ||
| 336 | |||
| 337 | // Stop and disable service | ||
| 338 | client.RunSudo(fmt.Sprintf("systemctl stop %s 2>/dev/null || true", name)) | ||
| 339 | client.RunSudo(fmt.Sprintf("systemctl disable %s 2>/dev/null || true", name)) | ||
| 340 | |||
| 341 | // Remove files | ||
| 342 | client.RunSudo(fmt.Sprintf("rm -f /etc/systemd/system/%s.service", name)) | ||
| 343 | client.RunSudo(fmt.Sprintf("rm -f /etc/caddy/sites-enabled/%s.caddy", name)) | ||
| 344 | client.RunSudo(fmt.Sprintf("rm -rf /var/www/%s", name)) | ||
| 345 | client.RunSudo(fmt.Sprintf("rm -rf /var/lib/%s", name)) | ||
| 346 | client.RunSudo(fmt.Sprintf("rm -f /usr/local/bin/%s", name)) | ||
| 347 | client.RunSudo(fmt.Sprintf("rm -f /etc/ship/env/%s.env", name)) | ||
| 348 | client.RunSudo(fmt.Sprintf("rm -f /etc/ship/ports/%s", name)) | ||
| 349 | client.RunSudo(fmt.Sprintf("rm -f /etc/ship/ttl/%s", name)) | ||
| 350 | |||
| 351 | // Remove docker container and image | ||
| 352 | client.Run(fmt.Sprintf("docker rm -f %s 2>/dev/null || true", name)) | ||
| 353 | client.Run(fmt.Sprintf("docker rmi %s 2>/dev/null || true", name)) | ||
| 354 | |||
| 355 | // Reload services | ||
| 356 | client.RunSudo("systemctl daemon-reload") | ||
| 357 | client.RunSudo("systemctl reload caddy") | ||
| 358 | |||
| 359 | output.PrintAndExit(&output.RemoveResponse{ | ||
| 360 | Status: "ok", | ||
| 361 | Name: name, | ||
| 362 | Removed: true, | ||
| 363 | }) | ||
| 364 | return nil | ||
| 365 | } | ||
diff --git a/cmd/ship/deploy_impl_v2.go b/cmd/ship/deploy_impl_v2.go deleted file mode 100644 index ec5c4a3..0000000 --- a/cmd/ship/deploy_impl_v2.go +++ /dev/null | |||
| @@ -1,391 +0,0 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "net/http" | ||
| 6 | "strconv" | ||
| 7 | "strings" | ||
| 8 | "time" | ||
| 9 | |||
| 10 | "github.com/bdw/ship/internal/output" | ||
| 11 | "github.com/bdw/ship/internal/ssh" | ||
| 12 | "github.com/bdw/ship/internal/templates" | ||
| 13 | ) | ||
| 14 | |||
| 15 | // deployStaticV2 deploys a static site | ||
| 16 | // 1. rsync path to /var/www/<name>/ | ||
| 17 | // 2. Generate and upload Caddyfile | ||
| 18 | // 3. Reload Caddy | ||
| 19 | func deployStaticV2(ctx *deployContext) *output.ErrorResponse { | ||
| 20 | client, err := ssh.Connect(ctx.SSHHost) | ||
| 21 | if err != nil { | ||
| 22 | return output.Err(output.ErrSSHConnectFailed, err.Error()) | ||
| 23 | } | ||
| 24 | defer client.Close() | ||
| 25 | |||
| 26 | name := ctx.Name | ||
| 27 | remotePath := fmt.Sprintf("/var/www/%s", name) | ||
| 28 | |||
| 29 | // Create directory and set ownership for upload | ||
| 30 | user, _ := client.Run("whoami") | ||
| 31 | user = strings.TrimSpace(user) | ||
| 32 | if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", remotePath)); err != nil { | ||
| 33 | return output.Err(output.ErrUploadFailed, "failed to create directory: "+err.Error()) | ||
| 34 | } | ||
| 35 | if _, err := client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", user, user, remotePath)); err != nil { | ||
| 36 | return output.Err(output.ErrUploadFailed, "failed to set directory ownership: "+err.Error()) | ||
| 37 | } | ||
| 38 | |||
| 39 | // Upload files using rsync | ||
| 40 | if err := client.UploadDir(ctx.Path, remotePath); err != nil { | ||
| 41 | return output.Err(output.ErrUploadFailed, err.Error()) | ||
| 42 | } | ||
| 43 | |||
| 44 | // Set ownership back to www-data | ||
| 45 | if _, err := client.RunSudo(fmt.Sprintf("chown -R www-data:www-data %s", remotePath)); err != nil { | ||
| 46 | // Non-fatal, continue | ||
| 47 | } | ||
| 48 | |||
| 49 | // Generate Caddyfile only if it doesn't exist (preserve manual edits) | ||
| 50 | caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) | ||
| 51 | caddyExists, _ := client.Run(fmt.Sprintf("test -f %s && echo exists", caddyPath)) | ||
| 52 | if strings.TrimSpace(caddyExists) != "exists" { | ||
| 53 | caddyfile, err := templates.StaticCaddy(map[string]string{ | ||
| 54 | "Domain": ctx.URL[8:], // Strip https:// | ||
| 55 | "RootDir": remotePath, | ||
| 56 | "Name": name, | ||
| 57 | }) | ||
| 58 | if err != nil { | ||
| 59 | return output.Err(output.ErrCaddyFailed, "failed to generate Caddyfile: "+err.Error()) | ||
| 60 | } | ||
| 61 | |||
| 62 | if err := client.WriteSudoFile(caddyPath, caddyfile); err != nil { | ||
| 63 | return output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error()) | ||
| 64 | } | ||
| 65 | } | ||
| 66 | |||
| 67 | // Reload Caddy | ||
| 68 | if _, err := client.RunSudo("systemctl reload caddy"); err != nil { | ||
| 69 | return output.Err(output.ErrCaddyFailed, "failed to reload Caddy: "+err.Error()) | ||
| 70 | } | ||
| 71 | |||
| 72 | return nil | ||
| 73 | } | ||
| 74 | |||
| 75 | // deployDockerV2 deploys a Docker-based app | ||
| 76 | // 1. Allocate port | ||
| 77 | // 2. rsync path to /var/lib/<name>/src/ | ||
| 78 | // 3. docker build | ||
| 79 | // 4. Generate systemd unit and env file | ||
| 80 | // 5. Generate Caddyfile | ||
| 81 | // 6. Start service, reload Caddy | ||
| 82 | func deployDockerV2(ctx *deployContext) *output.ErrorResponse { | ||
| 83 | client, err := ssh.Connect(ctx.SSHHost) | ||
| 84 | if err != nil { | ||
| 85 | return output.Err(output.ErrSSHConnectFailed, err.Error()) | ||
| 86 | } | ||
| 87 | defer client.Close() | ||
| 88 | |||
| 89 | name := ctx.Name | ||
| 90 | |||
| 91 | // Allocate port on server | ||
| 92 | port, err := allocatePort(client, name) | ||
| 93 | if err != nil { | ||
| 94 | return output.Err(output.ErrServiceFailed, "failed to allocate port: "+err.Error()) | ||
| 95 | } | ||
| 96 | |||
| 97 | srcPath := fmt.Sprintf("/var/lib/%s/src", name) | ||
| 98 | dataPath := fmt.Sprintf("/var/lib/%s/data", name) | ||
| 99 | |||
| 100 | // Create directories | ||
| 101 | if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s %s", srcPath, dataPath)); err != nil { | ||
| 102 | return output.Err(output.ErrUploadFailed, "failed to create directories: "+err.Error()) | ||
| 103 | } | ||
| 104 | |||
| 105 | // Set ownership for upload | ||
| 106 | user, _ := client.Run("whoami") | ||
| 107 | user = strings.TrimSpace(user) | ||
| 108 | if _, err := client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", user, user, srcPath)); err != nil { | ||
| 109 | return output.Err(output.ErrUploadFailed, "failed to set directory ownership: "+err.Error()) | ||
| 110 | } | ||
| 111 | |||
| 112 | // Upload source | ||
| 113 | if err := client.UploadDir(ctx.Path, srcPath); err != nil { | ||
| 114 | return output.Err(output.ErrUploadFailed, err.Error()) | ||
| 115 | } | ||
| 116 | |||
| 117 | // Docker build | ||
| 118 | buildCmd := fmt.Sprintf("docker build -t %s:latest %s", name, srcPath) | ||
| 119 | if _, err := client.RunSudo(buildCmd); err != nil { | ||
| 120 | return output.Err(output.ErrBuildFailed, err.Error()) | ||
| 121 | } | ||
| 122 | |||
| 123 | // Generate and write env file | ||
| 124 | envContent := fmt.Sprintf("PORT=%d\nSHIP_NAME=%s\nSHIP_URL=%s\n", port, name, ctx.URL) | ||
| 125 | for _, e := range ctx.Opts.Env { | ||
| 126 | envContent += e + "\n" | ||
| 127 | } | ||
| 128 | envPath := fmt.Sprintf("/etc/ship/env/%s.env", name) | ||
| 129 | if _, err := client.RunSudo("mkdir -p /etc/ship/env"); err != nil { | ||
| 130 | // Continue, directory might exist | ||
| 131 | } | ||
| 132 | if err := client.WriteSudoFile(envPath, envContent); err != nil { | ||
| 133 | return output.Err(output.ErrServiceFailed, "failed to write env file: "+err.Error()) | ||
| 134 | } | ||
| 135 | |||
| 136 | // Generate systemd unit | ||
| 137 | containerPort := ctx.Opts.ContainerPort | ||
| 138 | if containerPort == 0 { | ||
| 139 | containerPort = 80 | ||
| 140 | } | ||
| 141 | service, err := templates.DockerService(map[string]string{ | ||
| 142 | "Name": name, | ||
| 143 | "Port": strconv.Itoa(port), | ||
| 144 | "ContainerPort": strconv.Itoa(containerPort), | ||
| 145 | }) | ||
| 146 | if err != nil { | ||
| 147 | return output.Err(output.ErrServiceFailed, "failed to generate systemd unit: "+err.Error()) | ||
| 148 | } | ||
| 149 | |||
| 150 | servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name) | ||
| 151 | if err := client.WriteSudoFile(servicePath, service); err != nil { | ||
| 152 | return output.Err(output.ErrServiceFailed, "failed to write systemd unit: "+err.Error()) | ||
| 153 | } | ||
| 154 | |||
| 155 | // Generate Caddyfile only if it doesn't exist (preserve manual edits) | ||
| 156 | caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) | ||
| 157 | caddyExists, _ := client.Run(fmt.Sprintf("test -f %s && echo exists", caddyPath)) | ||
| 158 | if strings.TrimSpace(caddyExists) != "exists" { | ||
| 159 | caddyfile, err := templates.AppCaddy(map[string]string{ | ||
| 160 | "Domain": ctx.URL[8:], // Strip https:// | ||
| 161 | "Port": strconv.Itoa(port), | ||
| 162 | }) | ||
| 163 | if err != nil { | ||
| 164 | return output.Err(output.ErrCaddyFailed, "failed to generate Caddyfile: "+err.Error()) | ||
| 165 | } | ||
| 166 | |||
| 167 | if err := client.WriteSudoFile(caddyPath, caddyfile); err != nil { | ||
| 168 | return output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error()) | ||
| 169 | } | ||
| 170 | } | ||
| 171 | |||
| 172 | // Reload systemd and start service | ||
| 173 | if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { | ||
| 174 | return output.Err(output.ErrServiceFailed, "failed to reload systemd: "+err.Error()) | ||
| 175 | } | ||
| 176 | if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { | ||
| 177 | return output.Err(output.ErrServiceFailed, "failed to start service: "+err.Error()) | ||
| 178 | } | ||
| 179 | |||
| 180 | // Reload Caddy | ||
| 181 | if _, err := client.RunSudo("systemctl reload caddy"); err != nil { | ||
| 182 | return output.Err(output.ErrCaddyFailed, "failed to reload Caddy: "+err.Error()) | ||
| 183 | } | ||
| 184 | |||
| 185 | return nil | ||
| 186 | } | ||
| 187 | |||
| 188 | // deployBinaryV2 deploys a pre-built binary | ||
| 189 | // 1. Allocate port | ||
| 190 | // 2. scp binary to /usr/local/bin/<name> | ||
| 191 | // 3. Create user for service | ||
| 192 | // 4. Generate systemd unit and env file | ||
| 193 | // 5. Generate Caddyfile | ||
| 194 | // 6. Start service, reload Caddy | ||
| 195 | func deployBinaryV2(ctx *deployContext) *output.ErrorResponse { | ||
| 196 | client, err := ssh.Connect(ctx.SSHHost) | ||
| 197 | if err != nil { | ||
| 198 | return output.Err(output.ErrSSHConnectFailed, err.Error()) | ||
| 199 | } | ||
| 200 | defer client.Close() | ||
| 201 | |||
| 202 | name := ctx.Name | ||
| 203 | |||
| 204 | // Allocate port on server | ||
| 205 | port, err := allocatePort(client, name) | ||
| 206 | if err != nil { | ||
| 207 | return output.Err(output.ErrServiceFailed, "failed to allocate port: "+err.Error()) | ||
| 208 | } | ||
| 209 | |||
| 210 | binaryPath := fmt.Sprintf("/usr/local/bin/%s", name) | ||
| 211 | workDir := fmt.Sprintf("/var/lib/%s", name) | ||
| 212 | |||
| 213 | // Upload binary | ||
| 214 | if err := client.Upload(ctx.Path, "/tmp/"+name); err != nil { | ||
| 215 | return output.Err(output.ErrUploadFailed, err.Error()) | ||
| 216 | } | ||
| 217 | |||
| 218 | // Move to final location and set permissions | ||
| 219 | if _, err := client.RunSudo(fmt.Sprintf("mv /tmp/%s %s && chmod +x %s", name, binaryPath, binaryPath)); err != nil { | ||
| 220 | return output.Err(output.ErrUploadFailed, "failed to install binary: "+err.Error()) | ||
| 221 | } | ||
| 222 | |||
| 223 | // Create work directory | ||
| 224 | if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", workDir)); err != nil { | ||
| 225 | return output.Err(output.ErrServiceFailed, "failed to create work directory: "+err.Error()) | ||
| 226 | } | ||
| 227 | |||
| 228 | // Create service user (ignore error if exists) | ||
| 229 | client.RunSudo(fmt.Sprintf("useradd -r -s /bin/false %s 2>/dev/null || true", name)) | ||
| 230 | client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", name, name, workDir)) | ||
| 231 | |||
| 232 | // Generate and write env file | ||
| 233 | envContent := fmt.Sprintf("PORT=%d\nSHIP_NAME=%s\nSHIP_URL=%s\n", port, name, ctx.URL) | ||
| 234 | for _, e := range ctx.Opts.Env { | ||
| 235 | envContent += e + "\n" | ||
| 236 | } | ||
| 237 | envPath := fmt.Sprintf("/etc/ship/env/%s.env", name) | ||
| 238 | if _, err := client.RunSudo("mkdir -p /etc/ship/env"); err != nil { | ||
| 239 | // Continue | ||
| 240 | } | ||
| 241 | if err := client.WriteSudoFile(envPath, envContent); err != nil { | ||
| 242 | return output.Err(output.ErrServiceFailed, "failed to write env file: "+err.Error()) | ||
| 243 | } | ||
| 244 | |||
| 245 | // Generate systemd unit | ||
| 246 | service, err := templates.SystemdService(map[string]string{ | ||
| 247 | "Name": name, | ||
| 248 | "User": name, | ||
| 249 | "WorkDir": workDir, | ||
| 250 | "EnvFile": envPath, | ||
| 251 | "BinaryPath": binaryPath, | ||
| 252 | "Args": "", | ||
| 253 | }) | ||
| 254 | if err != nil { | ||
| 255 | return output.Err(output.ErrServiceFailed, "failed to generate systemd unit: "+err.Error()) | ||
| 256 | } | ||
| 257 | |||
| 258 | servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name) | ||
| 259 | if err := client.WriteSudoFile(servicePath, service); err != nil { | ||
| 260 | return output.Err(output.ErrServiceFailed, "failed to write systemd unit: "+err.Error()) | ||
| 261 | } | ||
| 262 | |||
| 263 | // Generate Caddyfile only if it doesn't exist (preserve manual edits) | ||
| 264 | caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) | ||
| 265 | caddyExists, _ := client.Run(fmt.Sprintf("test -f %s && echo exists", caddyPath)) | ||
| 266 | if strings.TrimSpace(caddyExists) != "exists" { | ||
| 267 | caddyfile, err := templates.AppCaddy(map[string]string{ | ||
| 268 | "Domain": ctx.URL[8:], // Strip https:// | ||
| 269 | "Port": strconv.Itoa(port), | ||
| 270 | }) | ||
| 271 | if err != nil { | ||
| 272 | return output.Err(output.ErrCaddyFailed, "failed to generate Caddyfile: "+err.Error()) | ||
| 273 | } | ||
| 274 | |||
| 275 | if err := client.WriteSudoFile(caddyPath, caddyfile); err != nil { | ||
| 276 | return output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error()) | ||
| 277 | } | ||
| 278 | } | ||
| 279 | |||
| 280 | // Reload systemd and start service | ||
| 281 | if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { | ||
| 282 | return output.Err(output.ErrServiceFailed, "failed to reload systemd: "+err.Error()) | ||
| 283 | } | ||
| 284 | if _, err := client.RunSudo(fmt.Sprintf("systemctl enable --now %s", name)); err != nil { | ||
| 285 | return output.Err(output.ErrServiceFailed, "failed to start service: "+err.Error()) | ||
| 286 | } | ||
| 287 | |||
| 288 | // Reload Caddy | ||
| 289 | if _, err := client.RunSudo("systemctl reload caddy"); err != nil { | ||
| 290 | return output.Err(output.ErrCaddyFailed, "failed to reload Caddy: "+err.Error()) | ||
| 291 | } | ||
| 292 | |||
| 293 | return nil | ||
| 294 | } | ||
| 295 | |||
| 296 | // allocatePort allocates or retrieves a port for a service | ||
| 297 | // Uses atomic increment on /etc/ship/next_port to avoid collisions | ||
| 298 | func allocatePort(client *ssh.Client, name string) (int, error) { | ||
| 299 | portFile := fmt.Sprintf("/etc/ship/ports/%s", name) | ||
| 300 | |||
| 301 | // Try to read existing port for this app | ||
| 302 | out, err := client.Run(fmt.Sprintf("cat %s 2>/dev/null || echo ''", portFile)) | ||
| 303 | if err == nil && out != "" { | ||
| 304 | out = strings.TrimSpace(out) | ||
| 305 | if port, err := strconv.Atoi(out); err == nil && port > 0 { | ||
| 306 | return port, nil | ||
| 307 | } | ||
| 308 | } | ||
| 309 | |||
| 310 | // Allocate new port atomically using flock | ||
| 311 | // Scans existing port files to avoid collisions even if next_port is stale | ||
| 312 | allocScript := `flock -x /etc/ship/.port.lock sh -c 'mkdir -p /etc/ship/ports; NEXT=$(cat /etc/ship/next_port 2>/dev/null || echo 9000); MAX=8999; for f in /etc/ship/ports/*; do [ -f "$f" ] && P=$(cat "$f" 2>/dev/null) && [ "$P" -gt "$MAX" ] 2>/dev/null && MAX=$P; done; PORT=$(( NEXT > MAX ? NEXT : MAX + 1 )); echo $((PORT + 1)) > /etc/ship/next_port; echo $PORT'` | ||
| 313 | out, err = client.RunSudo(allocScript) | ||
| 314 | if err != nil { | ||
| 315 | return 0, fmt.Errorf("failed to allocate port: %w", err) | ||
| 316 | } | ||
| 317 | |||
| 318 | port, err := strconv.Atoi(strings.TrimSpace(out)) | ||
| 319 | if err != nil { | ||
| 320 | return 0, fmt.Errorf("invalid port allocated: %s", out) | ||
| 321 | } | ||
| 322 | |||
| 323 | // Write port allocation for this app | ||
| 324 | if err := client.WriteSudoFile(portFile, strconv.Itoa(port)); err != nil { | ||
| 325 | return 0, err | ||
| 326 | } | ||
| 327 | |||
| 328 | return port, nil | ||
| 329 | } | ||
| 330 | |||
| 331 | // setTTLV2 sets auto-expiry for a deploy | ||
| 332 | func setTTLV2(ctx *deployContext, ttl time.Duration) error { | ||
| 333 | client, err := ssh.Connect(ctx.SSHHost) | ||
| 334 | if err != nil { | ||
| 335 | return err | ||
| 336 | } | ||
| 337 | defer client.Close() | ||
| 338 | |||
| 339 | expires := time.Now().Add(ttl).Unix() | ||
| 340 | ttlPath := fmt.Sprintf("/etc/ship/ttl/%s", ctx.Name) | ||
| 341 | |||
| 342 | if _, err := client.RunSudo("mkdir -p /etc/ship/ttl"); err != nil { | ||
| 343 | return err | ||
| 344 | } | ||
| 345 | |||
| 346 | return client.WriteSudoFile(ttlPath, strconv.FormatInt(expires, 10)) | ||
| 347 | } | ||
| 348 | |||
| 349 | // runHealthCheck verifies the deploy is responding | ||
| 350 | func runHealthCheck(url, endpoint string) (*output.HealthResult, *output.ErrorResponse) { | ||
| 351 | fullURL := url + endpoint | ||
| 352 | |||
| 353 | // Wait for app to start | ||
| 354 | time.Sleep(2 * time.Second) | ||
| 355 | |||
| 356 | var lastErr error | ||
| 357 | var lastStatus int | ||
| 358 | |||
| 359 | for i := 0; i < 15; i++ { | ||
| 360 | start := time.Now() | ||
| 361 | resp, err := http.Get(fullURL) | ||
| 362 | latency := time.Since(start).Milliseconds() | ||
| 363 | |||
| 364 | if err != nil { | ||
| 365 | lastErr = err | ||
| 366 | time.Sleep(2 * time.Second) | ||
| 367 | continue | ||
| 368 | } | ||
| 369 | resp.Body.Close() | ||
| 370 | lastStatus = resp.StatusCode | ||
| 371 | |||
| 372 | if resp.StatusCode >= 200 && resp.StatusCode < 400 { | ||
| 373 | return &output.HealthResult{ | ||
| 374 | Endpoint: endpoint, | ||
| 375 | Status: resp.StatusCode, | ||
| 376 | LatencyMs: latency, | ||
| 377 | }, nil | ||
| 378 | } | ||
| 379 | |||
| 380 | time.Sleep(2 * time.Second) | ||
| 381 | } | ||
| 382 | |||
| 383 | msg := fmt.Sprintf("health check failed after 30s: ") | ||
| 384 | if lastErr != nil { | ||
| 385 | msg += lastErr.Error() | ||
| 386 | } else { | ||
| 387 | msg += fmt.Sprintf("status %d", lastStatus) | ||
| 388 | } | ||
| 389 | |||
| 390 | return nil, output.Err(output.ErrHealthCheckFailed, msg) | ||
| 391 | } | ||
diff --git a/cmd/ship/deploy_v2.go b/cmd/ship/deploy_v2.go deleted file mode 100644 index 7d498b2..0000000 --- a/cmd/ship/deploy_v2.go +++ /dev/null | |||
| @@ -1,210 +0,0 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "crypto/rand" | ||
| 5 | "encoding/hex" | ||
| 6 | "fmt" | ||
| 7 | "regexp" | ||
| 8 | "strings" | ||
| 9 | "time" | ||
| 10 | |||
| 11 | "github.com/bdw/ship/internal/detect" | ||
| 12 | "github.com/bdw/ship/internal/output" | ||
| 13 | "github.com/bdw/ship/internal/state" | ||
| 14 | ) | ||
| 15 | |||
| 16 | // deployV2 implements the new agent-first deploy interface. | ||
| 17 | // Usage: ship [PATH] [FLAGS] | ||
| 18 | // PATH defaults to "." if not provided. | ||
| 19 | func deployV2(path string, opts deployV2Options) { | ||
| 20 | start := time.Now() | ||
| 21 | |||
| 22 | // Validate name if provided | ||
| 23 | if opts.Name != "" { | ||
| 24 | if err := validateNameV2(opts.Name); err != nil { | ||
| 25 | output.PrintAndExit(err) | ||
| 26 | } | ||
| 27 | } | ||
| 28 | |||
| 29 | // Parse TTL if provided | ||
| 30 | var ttlDuration time.Duration | ||
| 31 | if opts.TTL != "" { | ||
| 32 | var err error | ||
| 33 | ttlDuration, err = parseTTL(opts.TTL) | ||
| 34 | if err != nil { | ||
| 35 | output.PrintAndExit(output.Err(output.ErrInvalidTTL, err.Error())) | ||
| 36 | } | ||
| 37 | } | ||
| 38 | |||
| 39 | // Get host configuration | ||
| 40 | st, err := state.Load() | ||
| 41 | if err != nil { | ||
| 42 | output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "failed to load state: "+err.Error())) | ||
| 43 | } | ||
| 44 | |||
| 45 | hostName := opts.Host | ||
| 46 | if hostName == "" { | ||
| 47 | hostName = st.DefaultHost | ||
| 48 | } | ||
| 49 | if hostName == "" { | ||
| 50 | output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified and no default host configured. Run: ship host init")) | ||
| 51 | } | ||
| 52 | |||
| 53 | hostConfig := st.GetHost(hostName) | ||
| 54 | if hostConfig.BaseDomain == "" { | ||
| 55 | output.PrintAndExit(output.Err(output.ErrHostNotConfigured, fmt.Sprintf("host %q has no base domain configured. Run: ship host init", hostName))) | ||
| 56 | } | ||
| 57 | |||
| 58 | // Auto-detect project type | ||
| 59 | result := detect.Detect(path) | ||
| 60 | if result.Error != nil { | ||
| 61 | output.PrintAndExit(result.Error) | ||
| 62 | } | ||
| 63 | |||
| 64 | // Generate name if not provided | ||
| 65 | name := opts.Name | ||
| 66 | if name == "" { | ||
| 67 | name = generateName() | ||
| 68 | } | ||
| 69 | |||
| 70 | // Build URL: use custom domain if provided, otherwise use subdomain | ||
| 71 | var url string | ||
| 72 | if opts.Domain != "" { | ||
| 73 | url = fmt.Sprintf("https://%s", opts.Domain) | ||
| 74 | } else { | ||
| 75 | url = fmt.Sprintf("https://%s.%s", name, hostConfig.BaseDomain) | ||
| 76 | } | ||
| 77 | |||
| 78 | // Build deploy context | ||
| 79 | ctx := &deployContext{ | ||
| 80 | SSHHost: hostName, | ||
| 81 | HostConfig: hostConfig, | ||
| 82 | Name: name, | ||
| 83 | Path: result.Path, | ||
| 84 | URL: url, | ||
| 85 | Opts: opts, | ||
| 86 | } | ||
| 87 | |||
| 88 | // Deploy based on type | ||
| 89 | var deployErr *output.ErrorResponse | ||
| 90 | switch result.Type { | ||
| 91 | case detect.TypeStatic: | ||
| 92 | deployErr = deployStaticV2(ctx) | ||
| 93 | case detect.TypeDocker: | ||
| 94 | deployErr = deployDockerV2(ctx) | ||
| 95 | case detect.TypeBinary: | ||
| 96 | deployErr = deployBinaryV2(ctx) | ||
| 97 | } | ||
| 98 | |||
| 99 | if deployErr != nil { | ||
| 100 | deployErr.Name = name | ||
| 101 | deployErr.URL = url | ||
| 102 | output.PrintAndExit(deployErr) | ||
| 103 | } | ||
| 104 | |||
| 105 | // Set TTL if specified | ||
| 106 | if ttlDuration > 0 { | ||
| 107 | if err := setTTLV2(ctx, ttlDuration); err != nil { | ||
| 108 | // Non-fatal, deploy succeeded | ||
| 109 | // TODO: log warning | ||
| 110 | } | ||
| 111 | } | ||
| 112 | |||
| 113 | // Health check | ||
| 114 | var healthResult *output.HealthResult | ||
| 115 | if opts.Health != "" || result.Type == detect.TypeStatic { | ||
| 116 | endpoint := opts.Health | ||
| 117 | if endpoint == "" { | ||
| 118 | endpoint = "/" | ||
| 119 | } | ||
| 120 | healthResult, deployErr = runHealthCheck(url, endpoint) | ||
| 121 | if deployErr != nil { | ||
| 122 | deployErr.Name = name | ||
| 123 | deployErr.URL = url | ||
| 124 | output.PrintAndExit(deployErr) | ||
| 125 | } | ||
| 126 | } | ||
| 127 | |||
| 128 | // Build response | ||
| 129 | resp := &output.DeployResponse{ | ||
| 130 | Status: "ok", | ||
| 131 | Name: name, | ||
| 132 | URL: url, | ||
| 133 | Type: string(result.Type), | ||
| 134 | TookMs: time.Since(start).Milliseconds(), | ||
| 135 | Health: healthResult, | ||
| 136 | } | ||
| 137 | |||
| 138 | if ttlDuration > 0 { | ||
| 139 | resp.Expires = time.Now().Add(ttlDuration).UTC().Format(time.RFC3339) | ||
| 140 | } | ||
| 141 | |||
| 142 | output.PrintAndExit(resp) | ||
| 143 | } | ||
| 144 | |||
| 145 | type deployV2Options struct { | ||
| 146 | Name string | ||
| 147 | Host string | ||
| 148 | Domain string | ||
| 149 | Health string | ||
| 150 | TTL string | ||
| 151 | Env []string | ||
| 152 | EnvFile string | ||
| 153 | ContainerPort int // Port the container listens on (default 80 for Docker) | ||
| 154 | Pretty bool | ||
| 155 | } | ||
| 156 | |||
| 157 | // deployContext holds all info needed for a deploy | ||
| 158 | type deployContext struct { | ||
| 159 | SSHHost string // SSH connection string (config alias or user@host) | ||
| 160 | HostConfig *state.Host // Host configuration | ||
| 161 | Name string // Deploy name | ||
| 162 | Path string // Local path to deploy | ||
| 163 | URL string // Full URL after deploy | ||
| 164 | Opts deployV2Options | ||
| 165 | } | ||
| 166 | |||
| 167 | // validateNameV2 checks if name matches allowed pattern | ||
| 168 | func validateNameV2(name string) *output.ErrorResponse { | ||
| 169 | // Must be lowercase alphanumeric with hyphens, 1-63 chars | ||
| 170 | pattern := regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$|^[a-z0-9]$`) | ||
| 171 | if !pattern.MatchString(name) { | ||
| 172 | return output.Err(output.ErrInvalidName, | ||
| 173 | "name must be lowercase alphanumeric with hyphens, 1-63 characters") | ||
| 174 | } | ||
| 175 | return nil | ||
| 176 | } | ||
| 177 | |||
| 178 | // generateName creates a random deploy name | ||
| 179 | func generateName() string { | ||
| 180 | bytes := make([]byte, 3) | ||
| 181 | rand.Read(bytes) | ||
| 182 | return "ship-" + hex.EncodeToString(bytes) | ||
| 183 | } | ||
| 184 | |||
| 185 | // parseTTL converts duration strings like "1h", "7d" to time.Duration | ||
| 186 | func parseTTL(s string) (time.Duration, error) { | ||
| 187 | s = strings.TrimSpace(s) | ||
| 188 | if s == "" { | ||
| 189 | return 0, nil | ||
| 190 | } | ||
| 191 | |||
| 192 | // Handle days specially (not supported by time.ParseDuration) | ||
| 193 | if strings.HasSuffix(s, "d") { | ||
| 194 | days := strings.TrimSuffix(s, "d") | ||
| 195 | var d int | ||
| 196 | _, err := fmt.Sscanf(days, "%d", &d) | ||
| 197 | if err != nil { | ||
| 198 | return 0, fmt.Errorf("invalid TTL: %s", s) | ||
| 199 | } | ||
| 200 | return time.Duration(d) * 24 * time.Hour, nil | ||
| 201 | } | ||
| 202 | |||
| 203 | d, err := time.ParseDuration(s) | ||
| 204 | if err != nil { | ||
| 205 | return 0, fmt.Errorf("invalid TTL: %s", s) | ||
| 206 | } | ||
| 207 | return d, nil | ||
| 208 | } | ||
| 209 | |||
| 210 | // Deploy implementations are in deploy_impl_v2.go | ||
diff --git a/cmd/ship/host_v2.go b/cmd/ship/host_v2.go deleted file mode 100644 index b19c376..0000000 --- a/cmd/ship/host_v2.go +++ /dev/null | |||
| @@ -1,445 +0,0 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "encoding/json" | ||
| 5 | "fmt" | ||
| 6 | "os" | ||
| 7 | "os/exec" | ||
| 8 | "path/filepath" | ||
| 9 | "strings" | ||
| 10 | |||
| 11 | "github.com/bdw/ship/internal/output" | ||
| 12 | "github.com/bdw/ship/internal/ssh" | ||
| 13 | "github.com/bdw/ship/internal/state" | ||
| 14 | "github.com/bdw/ship/internal/templates" | ||
| 15 | "github.com/spf13/cobra" | ||
| 16 | ) | ||
| 17 | |||
| 18 | func initHostV2() { | ||
| 19 | hostInitV2Cmd.Flags().String("domain", "", "Base domain for deployments (required)") | ||
| 20 | hostInitV2Cmd.MarkFlagRequired("domain") | ||
| 21 | |||
| 22 | hostV2Cmd.AddCommand(hostInitV2Cmd) | ||
| 23 | hostV2Cmd.AddCommand(hostStatusV2Cmd) | ||
| 24 | } | ||
| 25 | |||
| 26 | var hostInitV2Cmd = &cobra.Command{ | ||
| 27 | Use: "init USER@HOST --domain DOMAIN", | ||
| 28 | Short: "Initialize a VPS for deployments", | ||
| 29 | Long: `Set up a fresh VPS with Caddy, Docker, and required directories. | ||
| 30 | |||
| 31 | Example: | ||
| 32 | ship host init user@my-vps --domain example.com`, | ||
| 33 | Args: cobra.ExactArgs(1), | ||
| 34 | RunE: runHostInitV2, | ||
| 35 | } | ||
| 36 | |||
| 37 | func runHostInitV2(cmd *cobra.Command, args []string) error { | ||
| 38 | host := args[0] | ||
| 39 | domain, _ := cmd.Flags().GetString("domain") | ||
| 40 | |||
| 41 | if domain == "" { | ||
| 42 | output.PrintAndExit(output.Err(output.ErrInvalidArgs, "--domain is required")) | ||
| 43 | } | ||
| 44 | |||
| 45 | // Ensure SSH key exists | ||
| 46 | keyPath, pubkey, err := ensureSSHKey() | ||
| 47 | if err != nil { | ||
| 48 | output.PrintAndExit(output.Err(output.ErrSSHAuthFailed, "failed to setup SSH key: "+err.Error())) | ||
| 49 | } | ||
| 50 | |||
| 51 | // Try to connect first (to verify key is authorized) | ||
| 52 | client, err := ssh.Connect(host) | ||
| 53 | if err != nil { | ||
| 54 | // Connection failed - provide helpful error with pubkey | ||
| 55 | resp := map[string]interface{}{ | ||
| 56 | "status": "error", | ||
| 57 | "code": "SSH_AUTH_FAILED", | ||
| 58 | "message": "SSH connection failed. Add this public key to your VPS authorized_keys:", | ||
| 59 | "public_key": pubkey, | ||
| 60 | "key_path": keyPath, | ||
| 61 | "host": host, | ||
| 62 | "setup_command": fmt.Sprintf("ssh-copy-id -i %s %s", keyPath, host), | ||
| 63 | } | ||
| 64 | printJSON(resp) | ||
| 65 | os.Exit(output.ExitSSHFailed) | ||
| 66 | } | ||
| 67 | defer client.Close() | ||
| 68 | |||
| 69 | // Detect OS | ||
| 70 | osRelease, err := client.Run("cat /etc/os-release") | ||
| 71 | if err != nil { | ||
| 72 | output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, "failed to detect OS: "+err.Error())) | ||
| 73 | } | ||
| 74 | |||
| 75 | if !strings.Contains(osRelease, "Ubuntu") && !strings.Contains(osRelease, "Debian") { | ||
| 76 | output.PrintAndExit(output.Err(output.ErrInvalidArgs, "unsupported OS (only Ubuntu and Debian are supported)")) | ||
| 77 | } | ||
| 78 | |||
| 79 | var installed []string | ||
| 80 | |||
| 81 | // Install Caddy if needed | ||
| 82 | if _, err := client.Run("which caddy"); err != nil { | ||
| 83 | if err := installCaddyV2(client); err != nil { | ||
| 84 | output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to install Caddy: "+err.Error())) | ||
| 85 | } | ||
| 86 | installed = append(installed, "caddy") | ||
| 87 | } | ||
| 88 | |||
| 89 | // Configure Caddy | ||
| 90 | caddyfile := `{ | ||
| 91 | } | ||
| 92 | |||
| 93 | import /etc/caddy/sites-enabled/* | ||
| 94 | ` | ||
| 95 | if err := client.WriteSudoFile("/etc/caddy/Caddyfile", caddyfile); err != nil { | ||
| 96 | output.PrintAndExit(output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error())) | ||
| 97 | } | ||
| 98 | |||
| 99 | // Create directories | ||
| 100 | dirs := []string{ | ||
| 101 | "/etc/ship/env", | ||
| 102 | "/etc/ship/ports", | ||
| 103 | "/etc/ship/ttl", | ||
| 104 | "/etc/caddy/sites-enabled", | ||
| 105 | "/var/www", | ||
| 106 | } | ||
| 107 | for _, dir := range dirs { | ||
| 108 | if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", dir)); err != nil { | ||
| 109 | output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to create directory: "+err.Error())) | ||
| 110 | } | ||
| 111 | } | ||
| 112 | |||
| 113 | // Install Docker | ||
| 114 | if _, err := client.Run("which docker"); err != nil { | ||
| 115 | if err := installDockerV2(client); err != nil { | ||
| 116 | output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to install Docker: "+err.Error())) | ||
| 117 | } | ||
| 118 | installed = append(installed, "docker") | ||
| 119 | } | ||
| 120 | |||
| 121 | // Install cleanup timer for TTL | ||
| 122 | if err := installCleanupTimer(client); err != nil { | ||
| 123 | // Non-fatal | ||
| 124 | } | ||
| 125 | |||
| 126 | // Enable and start services | ||
| 127 | if _, err := client.RunSudo("systemctl enable --now caddy docker"); err != nil { | ||
| 128 | output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to enable services: "+err.Error())) | ||
| 129 | } | ||
| 130 | |||
| 131 | // Save state | ||
| 132 | st, err := state.Load() | ||
| 133 | if err != nil { | ||
| 134 | output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to load state: "+err.Error())) | ||
| 135 | } | ||
| 136 | |||
| 137 | hostState := st.GetHost(host) | ||
| 138 | hostState.BaseDomain = domain | ||
| 139 | |||
| 140 | if st.GetDefaultHost() == "" { | ||
| 141 | st.SetDefaultHost(host) | ||
| 142 | } | ||
| 143 | |||
| 144 | if err := st.Save(); err != nil { | ||
| 145 | output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to save state: "+err.Error())) | ||
| 146 | } | ||
| 147 | |||
| 148 | // Success | ||
| 149 | output.PrintAndExit(&output.HostInitResponse{ | ||
| 150 | Status: "ok", | ||
| 151 | Host: host, | ||
| 152 | Domain: domain, | ||
| 153 | Installed: installed, | ||
| 154 | }) | ||
| 155 | |||
| 156 | return nil | ||
| 157 | } | ||
| 158 | |||
| 159 | func installCaddyV2(client *ssh.Client) error { | ||
| 160 | commands := []string{ | ||
| 161 | "apt-get update", | ||
| 162 | "apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl gnupg", | ||
| 163 | "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' -o /tmp/caddy.gpg", | ||
| 164 | "gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg < /tmp/caddy.gpg", | ||
| 165 | "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' -o /etc/apt/sources.list.d/caddy-stable.list", | ||
| 166 | "apt-get update", | ||
| 167 | "apt-get install -y caddy", | ||
| 168 | } | ||
| 169 | |||
| 170 | for _, cmd := range commands { | ||
| 171 | if _, err := client.RunSudo(cmd); err != nil { | ||
| 172 | return fmt.Errorf("command failed: %s: %w", cmd, err) | ||
| 173 | } | ||
| 174 | } | ||
| 175 | return nil | ||
| 176 | } | ||
| 177 | |||
| 178 | func installDockerV2(client *ssh.Client) error { | ||
| 179 | commands := []string{ | ||
| 180 | "apt-get install -y ca-certificates curl gnupg", | ||
| 181 | "install -m 0755 -d /etc/apt/keyrings", | ||
| 182 | "curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc", | ||
| 183 | "chmod a+r /etc/apt/keyrings/docker.asc", | ||
| 184 | `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'`, | ||
| 185 | "apt-get update", | ||
| 186 | "apt-get install -y docker-ce docker-ce-cli containerd.io", | ||
| 187 | } | ||
| 188 | |||
| 189 | for _, cmd := range commands { | ||
| 190 | if _, err := client.RunSudo(cmd); err != nil { | ||
| 191 | return fmt.Errorf("command failed: %s: %w", cmd, err) | ||
| 192 | } | ||
| 193 | } | ||
| 194 | return nil | ||
| 195 | } | ||
| 196 | |||
| 197 | func installCleanupTimer(client *ssh.Client) error { | ||
| 198 | // Cleanup script | ||
| 199 | script := `#!/bin/bash | ||
| 200 | now=$(date +%s) | ||
| 201 | for f in /etc/ship/ttl/*; do | ||
| 202 | [ -f "$f" ] || continue | ||
| 203 | name=$(basename "$f") | ||
| 204 | expires=$(cat "$f") | ||
| 205 | if [ "$now" -gt "$expires" ]; then | ||
| 206 | systemctl stop "$name" 2>/dev/null || true | ||
| 207 | systemctl disable "$name" 2>/dev/null || true | ||
| 208 | rm -f "/etc/systemd/system/${name}.service" | ||
| 209 | rm -f "/etc/caddy/sites-enabled/${name}.caddy" | ||
| 210 | rm -rf "/var/www/${name}" | ||
| 211 | rm -rf "/var/lib/${name}" | ||
| 212 | rm -f "/usr/local/bin/${name}" | ||
| 213 | rm -f "/etc/ship/env/${name}.env" | ||
| 214 | rm -f "/etc/ship/ports/${name}" | ||
| 215 | rm -f "/etc/ship/ttl/${name}" | ||
| 216 | docker rm -f "$name" 2>/dev/null || true | ||
| 217 | docker rmi "$name" 2>/dev/null || true | ||
| 218 | fi | ||
| 219 | done | ||
| 220 | systemctl daemon-reload | ||
| 221 | systemctl reload caddy | ||
| 222 | ` | ||
| 223 | if err := client.WriteSudoFile("/usr/local/bin/ship-cleanup", script); err != nil { | ||
| 224 | return err | ||
| 225 | } | ||
| 226 | if _, err := client.RunSudo("chmod +x /usr/local/bin/ship-cleanup"); err != nil { | ||
| 227 | return err | ||
| 228 | } | ||
| 229 | |||
| 230 | // Timer unit | ||
| 231 | timer := `[Unit] | ||
| 232 | Description=Ship TTL cleanup timer | ||
| 233 | |||
| 234 | [Timer] | ||
| 235 | OnCalendar=hourly | ||
| 236 | Persistent=true | ||
| 237 | |||
| 238 | [Install] | ||
| 239 | WantedBy=timers.target | ||
| 240 | ` | ||
| 241 | if err := client.WriteSudoFile("/etc/systemd/system/ship-cleanup.timer", timer); err != nil { | ||
| 242 | return err | ||
| 243 | } | ||
| 244 | |||
| 245 | // Service unit | ||
| 246 | service := `[Unit] | ||
| 247 | Description=Ship TTL cleanup | ||
| 248 | |||
| 249 | [Service] | ||
| 250 | Type=oneshot | ||
| 251 | ExecStart=/usr/local/bin/ship-cleanup | ||
| 252 | ` | ||
| 253 | if err := client.WriteSudoFile("/etc/systemd/system/ship-cleanup.service", service); err != nil { | ||
| 254 | return err | ||
| 255 | } | ||
| 256 | |||
| 257 | // Enable timer | ||
| 258 | if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { | ||
| 259 | return err | ||
| 260 | } | ||
| 261 | if _, err := client.RunSudo("systemctl enable --now ship-cleanup.timer"); err != nil { | ||
| 262 | return err | ||
| 263 | } | ||
| 264 | |||
| 265 | return nil | ||
| 266 | } | ||
| 267 | |||
| 268 | var hostStatusV2Cmd = &cobra.Command{ | ||
| 269 | Use: "status", | ||
| 270 | Short: "Check host status", | ||
| 271 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 272 | st, err := state.Load() | ||
| 273 | if err != nil { | ||
| 274 | output.PrintAndExit(output.Err(output.ErrHostNotConfigured, err.Error())) | ||
| 275 | } | ||
| 276 | |||
| 277 | hostName := hostFlag | ||
| 278 | if hostName == "" { | ||
| 279 | hostName = st.DefaultHost | ||
| 280 | } | ||
| 281 | if hostName == "" { | ||
| 282 | output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified")) | ||
| 283 | } | ||
| 284 | |||
| 285 | hostConfig := st.GetHost(hostName) | ||
| 286 | |||
| 287 | client, err := ssh.Connect(hostName) | ||
| 288 | if err != nil { | ||
| 289 | output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error())) | ||
| 290 | } | ||
| 291 | defer client.Close() | ||
| 292 | |||
| 293 | // Check services | ||
| 294 | caddyStatus, _ := client.RunSudo("systemctl is-active caddy") | ||
| 295 | dockerStatus, _ := client.RunSudo("systemctl is-active docker") | ||
| 296 | |||
| 297 | // Print as JSON directly (custom response type) | ||
| 298 | fmt.Printf(`{"status":"ok","host":%q,"domain":%q,"caddy":%t,"docker":%t}`+"\n", | ||
| 299 | hostName, | ||
| 300 | hostConfig.BaseDomain, | ||
| 301 | strings.TrimSpace(caddyStatus) == "active", | ||
| 302 | strings.TrimSpace(dockerStatus) == "active", | ||
| 303 | ) | ||
| 304 | return nil | ||
| 305 | }, | ||
| 306 | } | ||
| 307 | |||
| 308 | // Preserve git setup functionality from v1 for advanced users | ||
| 309 | func setupGitDeployV2(client *ssh.Client, baseDomain string, hostState *state.Host) *output.ErrorResponse { | ||
| 310 | // Install git, fcgiwrap, cgit | ||
| 311 | if _, err := client.RunSudo("apt-get install -y git fcgiwrap cgit"); err != nil { | ||
| 312 | return output.Err(output.ErrServiceFailed, "failed to install git tools: "+err.Error()) | ||
| 313 | } | ||
| 314 | |||
| 315 | // Create git user | ||
| 316 | client.RunSudo("useradd -r -m -d /home/git -s $(which git-shell) git") | ||
| 317 | client.RunSudo("usermod -aG docker git") | ||
| 318 | client.RunSudo("usermod -aG git www-data") | ||
| 319 | client.RunSudo("usermod -aG www-data caddy") | ||
| 320 | |||
| 321 | // Copy SSH keys | ||
| 322 | copyKeysCommands := []string{ | ||
| 323 | "mkdir -p /home/git/.ssh", | ||
| 324 | "cp ~/.ssh/authorized_keys /home/git/.ssh/authorized_keys", | ||
| 325 | "chown -R git:git /home/git/.ssh", | ||
| 326 | "chmod 700 /home/git/.ssh", | ||
| 327 | "chmod 600 /home/git/.ssh/authorized_keys", | ||
| 328 | } | ||
| 329 | for _, cmd := range copyKeysCommands { | ||
| 330 | if _, err := client.RunSudo(cmd); err != nil { | ||
| 331 | return output.Err(output.ErrServiceFailed, "failed to setup git SSH: "+err.Error()) | ||
| 332 | } | ||
| 333 | } | ||
| 334 | |||
| 335 | // Create /srv/git | ||
| 336 | client.RunSudo("mkdir -p /srv/git") | ||
| 337 | client.RunSudo("chown git:git /srv/git") | ||
| 338 | |||
| 339 | // Sudoers | ||
| 340 | sudoersContent := `git ALL=(ALL) NOPASSWD: \ | ||
| 341 | /bin/systemctl daemon-reload, \ | ||
| 342 | /bin/systemctl reload caddy, \ | ||
| 343 | /bin/systemctl restart [a-z]*, \ | ||
| 344 | /bin/systemctl enable [a-z]*, \ | ||
| 345 | /bin/cp /var/lib/*/src/.ship/service /etc/systemd/system/*.service, \ | ||
| 346 | /bin/cp /var/lib/*/src/.ship/Caddyfile /etc/caddy/sites-enabled/*.caddy, \ | ||
| 347 | /bin/cp /var/www/*/.ship/Caddyfile /etc/caddy/sites-enabled/*.caddy, \ | ||
| 348 | /bin/mkdir -p /var/lib/*, \ | ||
| 349 | /bin/mkdir -p /var/www/*, \ | ||
| 350 | /bin/chown -R git\:git /var/lib/*, \ | ||
| 351 | /bin/chown git\:git /var/www/* | ||
| 352 | ` | ||
| 353 | if err := client.WriteSudoFile("/etc/sudoers.d/ship-git", sudoersContent); err != nil { | ||
| 354 | return output.Err(output.ErrServiceFailed, "failed to write sudoers: "+err.Error()) | ||
| 355 | } | ||
| 356 | client.RunSudo("chmod 440 /etc/sudoers.d/ship-git") | ||
| 357 | |||
| 358 | // Vanity import template | ||
| 359 | vanityHTML := `<!DOCTYPE html> | ||
| 360 | <html><head> | ||
| 361 | {{$path := trimPrefix "/" (placeholder "http.request.orig_uri.path")}} | ||
| 362 | {{$parts := splitList "/" $path}} | ||
| 363 | {{$module := first $parts}} | ||
| 364 | <meta name="go-import" content="{{.Host}}/{{$module}} git https://{{.Host}}/{{$module}}.git"> | ||
| 365 | </head> | ||
| 366 | <body>go get {{.Host}}/{{$module}}</body> | ||
| 367 | </html> | ||
| 368 | ` | ||
| 369 | client.RunSudo("mkdir -p /opt/ship/vanity") | ||
| 370 | client.WriteSudoFile("/opt/ship/vanity/index.html", vanityHTML) | ||
| 371 | |||
| 372 | // cgit config | ||
| 373 | codeCaddyContent, _ := templates.CodeCaddy(map[string]string{"BaseDomain": baseDomain}) | ||
| 374 | client.WriteSudoFile("/etc/caddy/sites-enabled/ship-code.caddy", codeCaddyContent) | ||
| 375 | |||
| 376 | cgitrcContent, _ := templates.CgitRC(map[string]string{"BaseDomain": baseDomain}) | ||
| 377 | client.WriteSudoFile("/etc/cgitrc", cgitrcContent) | ||
| 378 | client.WriteSudoFile("/opt/ship/cgit-header.html", templates.CgitHeader()) | ||
| 379 | |||
| 380 | // Start services | ||
| 381 | client.RunSudo("systemctl enable --now fcgiwrap") | ||
| 382 | client.RunSudo("systemctl restart caddy") | ||
| 383 | |||
| 384 | hostState.GitSetup = true | ||
| 385 | return nil | ||
| 386 | } | ||
| 387 | |||
| 388 | // ensureSSHKey checks for an existing SSH key or generates a new one. | ||
| 389 | // Returns the key path, public key contents, and any error. | ||
| 390 | func ensureSSHKey() (keyPath string, pubkey string, err error) { | ||
| 391 | home, err := os.UserHomeDir() | ||
| 392 | if err != nil { | ||
| 393 | return "", "", err | ||
| 394 | } | ||
| 395 | |||
| 396 | // Check common key locations | ||
| 397 | keyPaths := []string{ | ||
| 398 | filepath.Join(home, ".ssh", "id_ed25519"), | ||
| 399 | filepath.Join(home, ".ssh", "id_rsa"), | ||
| 400 | filepath.Join(home, ".ssh", "id_ecdsa"), | ||
| 401 | } | ||
| 402 | |||
| 403 | for _, kp := range keyPaths { | ||
| 404 | pubPath := kp + ".pub" | ||
| 405 | if _, err := os.Stat(kp); err == nil { | ||
| 406 | if _, err := os.Stat(pubPath); err == nil { | ||
| 407 | // Key exists, read public key | ||
| 408 | pub, err := os.ReadFile(pubPath) | ||
| 409 | if err != nil { | ||
| 410 | continue | ||
| 411 | } | ||
| 412 | return kp, strings.TrimSpace(string(pub)), nil | ||
| 413 | } | ||
| 414 | } | ||
| 415 | } | ||
| 416 | |||
| 417 | // No key found, generate one | ||
| 418 | keyPath = filepath.Join(home, ".ssh", "id_ed25519") | ||
| 419 | sshDir := filepath.Dir(keyPath) | ||
| 420 | |||
| 421 | // Ensure .ssh directory exists | ||
| 422 | if err := os.MkdirAll(sshDir, 0700); err != nil { | ||
| 423 | return "", "", fmt.Errorf("failed to create .ssh directory: %w", err) | ||
| 424 | } | ||
| 425 | |||
| 426 | // Generate key | ||
| 427 | cmd := exec.Command("ssh-keygen", "-t", "ed25519", "-f", keyPath, "-N", "", "-C", "ship") | ||
| 428 | if err := cmd.Run(); err != nil { | ||
| 429 | return "", "", fmt.Errorf("failed to generate SSH key: %w", err) | ||
| 430 | } | ||
| 431 | |||
| 432 | // Read public key | ||
| 433 | pub, err := os.ReadFile(keyPath + ".pub") | ||
| 434 | if err != nil { | ||
| 435 | return "", "", fmt.Errorf("failed to read public key: %w", err) | ||
| 436 | } | ||
| 437 | |||
| 438 | return keyPath, strings.TrimSpace(string(pub)), nil | ||
| 439 | } | ||
| 440 | |||
| 441 | // printJSON outputs a value as JSON to stdout | ||
| 442 | func printJSON(v interface{}) { | ||
| 443 | enc := json.NewEncoder(os.Stdout) | ||
| 444 | enc.Encode(v) | ||
| 445 | } | ||
diff --git a/cmd/ship/main.go b/cmd/ship/main.go deleted file mode 100644 index 17516fb..0000000 --- a/cmd/ship/main.go +++ /dev/null | |||
| @@ -1,10 +0,0 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import "os" | ||
| 4 | |||
| 5 | func main() { | ||
| 6 | initV2() | ||
| 7 | if err := rootV2Cmd.Execute(); err != nil { | ||
| 8 | os.Exit(1) | ||
| 9 | } | ||
| 10 | } | ||
diff --git a/cmd/ship/root_v2.go b/cmd/ship/root_v2.go deleted file mode 100644 index aa81d1e..0000000 --- a/cmd/ship/root_v2.go +++ /dev/null | |||
| @@ -1,98 +0,0 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "os" | ||
| 5 | |||
| 6 | "github.com/bdw/ship/internal/output" | ||
| 7 | "github.com/spf13/cobra" | ||
| 8 | ) | ||
| 9 | |||
| 10 | var hostFlag string | ||
| 11 | |||
| 12 | // This file defines the v2 CLI structure. | ||
| 13 | // The primary command is: ship [PATH] [FLAGS] | ||
| 14 | // All output is JSON by default. | ||
| 15 | |||
| 16 | var rootV2Cmd = &cobra.Command{ | ||
| 17 | Use: "ship [PATH]", | ||
| 18 | Short: "Deploy code to a VPS. JSON output for agents.", | ||
| 19 | Long: `Ship deploys code to a VPS. Point it at a directory or binary, get a URL back. | ||
| 20 | |||
| 21 | ship ./myproject # auto-detect and deploy | ||
| 22 | ship ./site --name docs # deploy with specific name | ||
| 23 | ship ./api --health /healthz # deploy with health check | ||
| 24 | ship ./preview --ttl 24h # deploy with auto-expiry | ||
| 25 | |||
| 26 | All output is JSON. Use --pretty for human-readable output.`, | ||
| 27 | Args: cobra.MaximumNArgs(1), | ||
| 28 | RunE: runDeployV2, | ||
| 29 | SilenceUsage: true, | ||
| 30 | SilenceErrors: true, | ||
| 31 | DisableAutoGenTag: true, | ||
| 32 | } | ||
| 33 | |||
| 34 | func initV2() { | ||
| 35 | // Global flags | ||
| 36 | rootV2Cmd.PersistentFlags().StringVar(&hostFlag, "host", "", "VPS host (SSH config alias or user@host)") | ||
| 37 | rootV2Cmd.PersistentFlags().BoolVar(&output.Pretty, "pretty", false, "Human-readable output") | ||
| 38 | |||
| 39 | // Deploy flags | ||
| 40 | rootV2Cmd.Flags().String("name", "", "Deploy name (becomes subdomain)") | ||
| 41 | rootV2Cmd.Flags().String("domain", "", "Custom domain for deployment") | ||
| 42 | rootV2Cmd.Flags().String("health", "", "Health check endpoint (e.g., /healthz)") | ||
| 43 | rootV2Cmd.Flags().String("ttl", "", "Auto-delete after duration (e.g., 1h, 7d)") | ||
| 44 | rootV2Cmd.Flags().StringArray("env", nil, "Environment variable (KEY=VALUE)") | ||
| 45 | rootV2Cmd.Flags().String("env-file", "", "Path to .env file") | ||
| 46 | rootV2Cmd.Flags().Int("container-port", 80, "Port the container listens on (Docker only)") | ||
| 47 | |||
| 48 | // Check for SHIP_PRETTY env var | ||
| 49 | if os.Getenv("SHIP_PRETTY") == "1" { | ||
| 50 | output.Pretty = true | ||
| 51 | } | ||
| 52 | |||
| 53 | // Add subcommands | ||
| 54 | rootV2Cmd.AddCommand(listV2Cmd) | ||
| 55 | rootV2Cmd.AddCommand(statusV2Cmd) | ||
| 56 | rootV2Cmd.AddCommand(logsV2Cmd) | ||
| 57 | rootV2Cmd.AddCommand(removeV2Cmd) | ||
| 58 | rootV2Cmd.AddCommand(hostV2Cmd) | ||
| 59 | |||
| 60 | // Initialize host subcommands (from host_v2.go) | ||
| 61 | initHostV2() | ||
| 62 | } | ||
| 63 | |||
| 64 | func runDeployV2(cmd *cobra.Command, args []string) error { | ||
| 65 | path := "." | ||
| 66 | if len(args) > 0 { | ||
| 67 | path = args[0] | ||
| 68 | } | ||
| 69 | |||
| 70 | opts := deployV2Options{ | ||
| 71 | Host: hostFlag, | ||
| 72 | Pretty: output.Pretty, | ||
| 73 | } | ||
| 74 | |||
| 75 | // Get flag values | ||
| 76 | opts.Name, _ = cmd.Flags().GetString("name") | ||
| 77 | opts.Domain, _ = cmd.Flags().GetString("domain") | ||
| 78 | opts.Health, _ = cmd.Flags().GetString("health") | ||
| 79 | opts.TTL, _ = cmd.Flags().GetString("ttl") | ||
| 80 | opts.Env, _ = cmd.Flags().GetStringArray("env") | ||
| 81 | opts.EnvFile, _ = cmd.Flags().GetString("env-file") | ||
| 82 | opts.ContainerPort, _ = cmd.Flags().GetInt("container-port") | ||
| 83 | |||
| 84 | // deployV2 handles all output and exits | ||
| 85 | deployV2(path, opts) | ||
| 86 | |||
| 87 | // Should not reach here (deployV2 calls os.Exit) | ||
| 88 | return nil | ||
| 89 | } | ||
| 90 | |||
| 91 | // Subcommands (list, status, logs, remove) are defined in commands_v2.go | ||
| 92 | |||
| 93 | var hostV2Cmd = &cobra.Command{ | ||
| 94 | Use: "host", | ||
| 95 | Short: "Manage VPS host", | ||
| 96 | } | ||
| 97 | |||
| 98 | // hostInitV2Cmd, hostStatusV2Cmd, and initHostV2() are defined in host_v2.go | ||
diff --git a/cmd/ship/templates/webui.html b/cmd/ship/templates/webui.html deleted file mode 100644 index 052d599..0000000 --- a/cmd/ship/templates/webui.html +++ /dev/null | |||
| @@ -1,440 +0,0 @@ | |||
| 1 | <!DOCTYPE html> | ||
| 2 | <html lang="en"> | ||
| 3 | <head> | ||
| 4 | <meta charset="UTF-8"> | ||
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||
| 6 | <title>Deploy - Web UI</title> | ||
| 7 | <style> | ||
| 8 | * { | ||
| 9 | margin: 0; | ||
| 10 | padding: 0; | ||
| 11 | box-sizing: border-box; | ||
| 12 | } | ||
| 13 | |||
| 14 | body { | ||
| 15 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; | ||
| 16 | background: #f5f5f5; | ||
| 17 | color: #333; | ||
| 18 | line-height: 1.6; | ||
| 19 | } | ||
| 20 | |||
| 21 | header { | ||
| 22 | background: #2c3e50; | ||
| 23 | color: white; | ||
| 24 | padding: 1.5rem 2rem; | ||
| 25 | box-shadow: 0 2px 4px rgba(0,0,0,0.1); | ||
| 26 | } | ||
| 27 | |||
| 28 | header h1 { | ||
| 29 | font-size: 1.8rem; | ||
| 30 | font-weight: 600; | ||
| 31 | } | ||
| 32 | |||
| 33 | header p { | ||
| 34 | color: #bdc3c7; | ||
| 35 | margin-top: 0.25rem; | ||
| 36 | font-size: 0.9rem; | ||
| 37 | } | ||
| 38 | |||
| 39 | .container { | ||
| 40 | max-width: 1200px; | ||
| 41 | margin: 2rem auto; | ||
| 42 | padding: 0 2rem; | ||
| 43 | } | ||
| 44 | |||
| 45 | .empty-state { | ||
| 46 | text-align: center; | ||
| 47 | padding: 4rem 2rem; | ||
| 48 | background: white; | ||
| 49 | border-radius: 8px; | ||
| 50 | box-shadow: 0 2px 4px rgba(0,0,0,0.1); | ||
| 51 | } | ||
| 52 | |||
| 53 | .empty-state h2 { | ||
| 54 | color: #7f8c8d; | ||
| 55 | font-weight: 500; | ||
| 56 | margin-bottom: 0.5rem; | ||
| 57 | } | ||
| 58 | |||
| 59 | .empty-state p { | ||
| 60 | color: #95a5a6; | ||
| 61 | } | ||
| 62 | |||
| 63 | .host-section { | ||
| 64 | margin-bottom: 2rem; | ||
| 65 | } | ||
| 66 | |||
| 67 | .host-header { | ||
| 68 | background: white; | ||
| 69 | padding: 1rem 1.5rem; | ||
| 70 | border-radius: 8px 8px 0 0; | ||
| 71 | border-left: 4px solid #3498db; | ||
| 72 | box-shadow: 0 2px 4px rgba(0,0,0,0.1); | ||
| 73 | } | ||
| 74 | |||
| 75 | .host-header h2 { | ||
| 76 | font-size: 1.3rem; | ||
| 77 | color: #2c3e50; | ||
| 78 | font-weight: 600; | ||
| 79 | } | ||
| 80 | |||
| 81 | .apps-grid { | ||
| 82 | display: grid; | ||
| 83 | grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); | ||
| 84 | gap: 1rem; | ||
| 85 | padding: 1rem; | ||
| 86 | background: #ecf0f1; | ||
| 87 | border-radius: 0 0 8px 8px; | ||
| 88 | } | ||
| 89 | |||
| 90 | .app-card { | ||
| 91 | background: white; | ||
| 92 | padding: 1.5rem; | ||
| 93 | border-radius: 6px; | ||
| 94 | box-shadow: 0 1px 3px rgba(0,0,0,0.1); | ||
| 95 | transition: transform 0.2s, box-shadow 0.2s; | ||
| 96 | } | ||
| 97 | |||
| 98 | .app-card:hover { | ||
| 99 | transform: translateY(-2px); | ||
| 100 | box-shadow: 0 4px 8px rgba(0,0,0,0.15); | ||
| 101 | } | ||
| 102 | |||
| 103 | .app-header { | ||
| 104 | display: flex; | ||
| 105 | justify-content: space-between; | ||
| 106 | align-items: center; | ||
| 107 | margin-bottom: 1rem; | ||
| 108 | } | ||
| 109 | |||
| 110 | .app-name { | ||
| 111 | font-size: 1.2rem; | ||
| 112 | font-weight: 600; | ||
| 113 | color: #2c3e50; | ||
| 114 | } | ||
| 115 | |||
| 116 | .app-type { | ||
| 117 | padding: 0.25rem 0.75rem; | ||
| 118 | border-radius: 12px; | ||
| 119 | font-size: 0.75rem; | ||
| 120 | font-weight: 500; | ||
| 121 | text-transform: uppercase; | ||
| 122 | } | ||
| 123 | |||
| 124 | .app-type.app { | ||
| 125 | background: #3498db; | ||
| 126 | color: white; | ||
| 127 | } | ||
| 128 | |||
| 129 | .app-type.static { | ||
| 130 | background: #2ecc71; | ||
| 131 | color: white; | ||
| 132 | } | ||
| 133 | |||
| 134 | .app-info { | ||
| 135 | margin-bottom: 0.5rem; | ||
| 136 | } | ||
| 137 | |||
| 138 | .app-info-label { | ||
| 139 | color: #7f8c8d; | ||
| 140 | font-size: 0.85rem; | ||
| 141 | font-weight: 500; | ||
| 142 | margin-bottom: 0.25rem; | ||
| 143 | } | ||
| 144 | |||
| 145 | .app-info-value { | ||
| 146 | color: #2c3e50; | ||
| 147 | font-family: 'Monaco', 'Courier New', monospace; | ||
| 148 | font-size: 0.9rem; | ||
| 149 | word-break: break-all; | ||
| 150 | } | ||
| 151 | |||
| 152 | .app-info-value a { | ||
| 153 | color: #3498db; | ||
| 154 | text-decoration: none; | ||
| 155 | } | ||
| 156 | |||
| 157 | .app-info-value a:hover { | ||
| 158 | text-decoration: underline; | ||
| 159 | } | ||
| 160 | |||
| 161 | .config-buttons { | ||
| 162 | margin-top: 1rem; | ||
| 163 | padding-top: 1rem; | ||
| 164 | border-top: 1px solid #ecf0f1; | ||
| 165 | display: flex; | ||
| 166 | gap: 0.5rem; | ||
| 167 | flex-wrap: wrap; | ||
| 168 | } | ||
| 169 | |||
| 170 | .config-btn { | ||
| 171 | padding: 0.4rem 0.8rem; | ||
| 172 | background: #3498db; | ||
| 173 | color: white; | ||
| 174 | border: none; | ||
| 175 | border-radius: 4px; | ||
| 176 | font-size: 0.8rem; | ||
| 177 | cursor: pointer; | ||
| 178 | transition: background 0.2s; | ||
| 179 | } | ||
| 180 | |||
| 181 | .config-btn:hover { | ||
| 182 | background: #2980b9; | ||
| 183 | } | ||
| 184 | |||
| 185 | .config-btn.secondary { | ||
| 186 | background: #95a5a6; | ||
| 187 | } | ||
| 188 | |||
| 189 | .config-btn.secondary:hover { | ||
| 190 | background: #7f8c8d; | ||
| 191 | } | ||
| 192 | |||
| 193 | .modal { | ||
| 194 | display: none; | ||
| 195 | position: fixed; | ||
| 196 | z-index: 1000; | ||
| 197 | left: 0; | ||
| 198 | top: 0; | ||
| 199 | width: 100%; | ||
| 200 | height: 100%; | ||
| 201 | overflow: auto; | ||
| 202 | background-color: rgba(0,0,0,0.6); | ||
| 203 | } | ||
| 204 | |||
| 205 | .modal.active { | ||
| 206 | display: block; | ||
| 207 | } | ||
| 208 | |||
| 209 | .modal-content { | ||
| 210 | background-color: #fefefe; | ||
| 211 | margin: 5% auto; | ||
| 212 | padding: 0; | ||
| 213 | border-radius: 8px; | ||
| 214 | width: 90%; | ||
| 215 | max-width: 900px; | ||
| 216 | max-height: 80vh; | ||
| 217 | display: flex; | ||
| 218 | flex-direction: column; | ||
| 219 | box-shadow: 0 4px 20px rgba(0,0,0,0.3); | ||
| 220 | } | ||
| 221 | |||
| 222 | .modal-header { | ||
| 223 | padding: 1.5rem; | ||
| 224 | border-bottom: 1px solid #ecf0f1; | ||
| 225 | display: flex; | ||
| 226 | justify-content: space-between; | ||
| 227 | align-items: center; | ||
| 228 | } | ||
| 229 | |||
| 230 | .modal-header h3 { | ||
| 231 | margin: 0; | ||
| 232 | color: #2c3e50; | ||
| 233 | } | ||
| 234 | |||
| 235 | .modal-path { | ||
| 236 | font-family: 'Monaco', 'Courier New', monospace; | ||
| 237 | font-size: 0.85rem; | ||
| 238 | color: #7f8c8d; | ||
| 239 | margin-top: 0.25rem; | ||
| 240 | } | ||
| 241 | |||
| 242 | .close { | ||
| 243 | color: #aaa; | ||
| 244 | font-size: 28px; | ||
| 245 | font-weight: bold; | ||
| 246 | cursor: pointer; | ||
| 247 | line-height: 1; | ||
| 248 | } | ||
| 249 | |||
| 250 | .close:hover { | ||
| 251 | color: #000; | ||
| 252 | } | ||
| 253 | |||
| 254 | .modal-body { | ||
| 255 | padding: 1.5rem; | ||
| 256 | overflow: auto; | ||
| 257 | flex: 1; | ||
| 258 | } | ||
| 259 | |||
| 260 | .config-content { | ||
| 261 | background: #282c34; | ||
| 262 | color: #abb2bf; | ||
| 263 | padding: 1rem; | ||
| 264 | border-radius: 4px; | ||
| 265 | font-family: 'Monaco', 'Courier New', monospace; | ||
| 266 | font-size: 0.85rem; | ||
| 267 | line-height: 1.5; | ||
| 268 | white-space: pre-wrap; | ||
| 269 | word-wrap: break-word; | ||
| 270 | overflow-x: auto; | ||
| 271 | text-align: left; | ||
| 272 | } | ||
| 273 | |||
| 274 | .loading { | ||
| 275 | text-align: center; | ||
| 276 | padding: 2rem; | ||
| 277 | color: #7f8c8d; | ||
| 278 | } | ||
| 279 | |||
| 280 | .refresh-info { | ||
| 281 | text-align: center; | ||
| 282 | color: #7f8c8d; | ||
| 283 | font-size: 0.9rem; | ||
| 284 | margin-top: 2rem; | ||
| 285 | padding: 1rem; | ||
| 286 | } | ||
| 287 | </style> | ||
| 288 | </head> | ||
| 289 | <body> | ||
| 290 | <header> | ||
| 291 | <h1>Deploy Web UI</h1> | ||
| 292 | <p>Manage your VPS deployments</p> | ||
| 293 | </header> | ||
| 294 | |||
| 295 | <div class="container"> | ||
| 296 | {{if not .Hosts}} | ||
| 297 | <div class="empty-state"> | ||
| 298 | <h2>No deployments found</h2> | ||
| 299 | <p>Use the CLI to deploy your first app or static site</p> | ||
| 300 | </div> | ||
| 301 | {{else}} | ||
| 302 | {{range .Hosts}} | ||
| 303 | <div class="host-section"> | ||
| 304 | <div class="host-header"> | ||
| 305 | <h2>{{.Host}}</h2> | ||
| 306 | </div> | ||
| 307 | <div class="apps-grid"> | ||
| 308 | {{range .Apps}} | ||
| 309 | <div class="app-card"> | ||
| 310 | <div class="app-header"> | ||
| 311 | <div class="app-name">{{.Name}}</div> | ||
| 312 | <div class="app-type {{.Type}}">{{.Type}}</div> | ||
| 313 | </div> | ||
| 314 | |||
| 315 | <div class="app-info"> | ||
| 316 | <div class="app-info-label">Domain</div> | ||
| 317 | <div class="app-info-value"> | ||
| 318 | <a href="https://{{.Domain}}" target="_blank">{{.Domain}}</a> | ||
| 319 | </div> | ||
| 320 | </div> | ||
| 321 | |||
| 322 | {{if eq .Type "app"}} | ||
| 323 | <div class="app-info"> | ||
| 324 | <div class="app-info-label">Port</div> | ||
| 325 | <div class="app-info-value">{{.Port}}</div> | ||
| 326 | </div> | ||
| 327 | {{end}} | ||
| 328 | |||
| 329 | <div class="config-buttons"> | ||
| 330 | {{if eq .Type "app"}} | ||
| 331 | <button class="config-btn" onclick="showConfig('{{.Host}}', '{{.Name}}', 'systemd')">Systemd Unit</button> | ||
| 332 | {{end}} | ||
| 333 | <button class="config-btn" onclick="showConfig('{{.Host}}', '{{.Name}}', 'caddy')">Caddy Config</button> | ||
| 334 | {{if .Env}} | ||
| 335 | <button class="config-btn secondary" onclick="showConfig('{{.Host}}', '{{.Name}}', 'env')">Environment</button> | ||
| 336 | {{end}} | ||
| 337 | </div> | ||
| 338 | </div> | ||
| 339 | {{end}} | ||
| 340 | </div> | ||
| 341 | </div> | ||
| 342 | {{end}} | ||
| 343 | {{end}} | ||
| 344 | |||
| 345 | <div class="refresh-info"> | ||
| 346 | Refresh the page to see latest changes | ||
| 347 | </div> | ||
| 348 | </div> | ||
| 349 | |||
| 350 | <!-- Modal --> | ||
| 351 | <div id="configModal" class="modal"> | ||
| 352 | <div class="modal-content"> | ||
| 353 | <div class="modal-header"> | ||
| 354 | <div> | ||
| 355 | <h3 id="modalTitle">Configuration</h3> | ||
| 356 | <div class="modal-path" id="modalPath"></div> | ||
| 357 | </div> | ||
| 358 | <span class="close" onclick="closeModal()">×</span> | ||
| 359 | </div> | ||
| 360 | <div class="modal-body"> | ||
| 361 | <div id="modalContent" class="loading">Loading...</div> | ||
| 362 | </div> | ||
| 363 | </div> | ||
| 364 | </div> | ||
| 365 | |||
| 366 | <script> | ||
| 367 | const modal = document.getElementById('configModal'); | ||
| 368 | const modalTitle = document.getElementById('modalTitle'); | ||
| 369 | const modalPath = document.getElementById('modalPath'); | ||
| 370 | const modalContent = document.getElementById('modalContent'); | ||
| 371 | |||
| 372 | function closeModal() { | ||
| 373 | modal.classList.remove('active'); | ||
| 374 | } | ||
| 375 | |||
| 376 | window.onclick = function(event) { | ||
| 377 | if (event.target == modal) { | ||
| 378 | closeModal(); | ||
| 379 | } | ||
| 380 | } | ||
| 381 | |||
| 382 | async function showConfig(host, app, type) { | ||
| 383 | modal.classList.add('active'); | ||
| 384 | modalContent.innerHTML = '<div class="loading">Loading...</div>'; | ||
| 385 | |||
| 386 | const titles = { | ||
| 387 | 'systemd': 'Systemd Service Unit', | ||
| 388 | 'caddy': 'Caddy Configuration', | ||
| 389 | 'env': 'Environment Variables' | ||
| 390 | }; | ||
| 391 | |||
| 392 | modalTitle.textContent = titles[type]; | ||
| 393 | |||
| 394 | try { | ||
| 395 | const response = await fetch(`/api/configs?host=${encodeURIComponent(host)}&app=${encodeURIComponent(app)}`); | ||
| 396 | if (!response.ok) { | ||
| 397 | throw new Error(`HTTP error! status: ${response.status}`); | ||
| 398 | } | ||
| 399 | const configs = await response.json(); | ||
| 400 | |||
| 401 | let content = ''; | ||
| 402 | let path = ''; | ||
| 403 | |||
| 404 | switch(type) { | ||
| 405 | case 'systemd': | ||
| 406 | content = configs.systemd || 'No systemd config available'; | ||
| 407 | path = configs.systemdPath || ''; | ||
| 408 | break; | ||
| 409 | case 'caddy': | ||
| 410 | content = configs.caddy || 'No Caddy config available'; | ||
| 411 | path = configs.caddyPath || ''; | ||
| 412 | break; | ||
| 413 | case 'env': | ||
| 414 | content = configs.env || 'No environment variables'; | ||
| 415 | path = configs.envPath || ''; | ||
| 416 | break; | ||
| 417 | } | ||
| 418 | |||
| 419 | modalPath.textContent = path; | ||
| 420 | modalContent.innerHTML = `<div class="config-content">${escapeHtml(content)}</div>`; | ||
| 421 | } catch (error) { | ||
| 422 | modalContent.innerHTML = `<div class="loading">Error loading config: ${error.message}</div>`; | ||
| 423 | } | ||
| 424 | } | ||
| 425 | |||
| 426 | function escapeHtml(text) { | ||
| 427 | const div = document.createElement('div'); | ||
| 428 | div.textContent = text; | ||
| 429 | return div.innerHTML; | ||
| 430 | } | ||
| 431 | |||
| 432 | // Close modal with Escape key | ||
| 433 | document.addEventListener('keydown', function(event) { | ||
| 434 | if (event.key === 'Escape') { | ||
| 435 | closeModal(); | ||
| 436 | } | ||
| 437 | }); | ||
| 438 | </script> | ||
| 439 | </body> | ||
| 440 | </html> | ||
