From 146393527c20df3717711eced83fb9f58f96c884 Mon Sep 17 00:00:00 2001 From: Clawd Date: Sun, 15 Feb 2026 18:50:54 -0800 Subject: feat(v2): implement deploy flows - deploy_impl_v2.go: full implementations for static, docker, binary deploys - Static: rsync + Caddyfile generation + reload - Docker: rsync + build + systemd unit + env file + Caddyfile - Binary: scp + systemd unit + service user + env file + Caddyfile - Port allocation: server-side in /etc/ship/ports/ - TTL: server-side in /etc/ship/ttl/ - Health checks: HTTP GET with 30s retry loop All deploy types now functional (pending Go compilation test) --- cmd/ship/deploy_impl_v2.go | 365 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 365 insertions(+) create mode 100644 cmd/ship/deploy_impl_v2.go (limited to 'cmd/ship/deploy_impl_v2.go') diff --git a/cmd/ship/deploy_impl_v2.go b/cmd/ship/deploy_impl_v2.go new file mode 100644 index 0000000..5d9629d --- /dev/null +++ b/cmd/ship/deploy_impl_v2.go @@ -0,0 +1,365 @@ +package main + +import ( + "fmt" + "net/http" + "strconv" + "time" + + "github.com/bdw/ship/internal/output" + "github.com/bdw/ship/internal/ssh" + "github.com/bdw/ship/internal/templates" +) + +// deployStaticV2 deploys a static site +// 1. rsync path to /var/www// +// 2. Generate and upload Caddyfile +// 3. Reload Caddy +func deployStaticV2(ctx *deployContext) *output.ErrorResponse { + client, err := ssh.Connect(ctx.SSHHost) + if err != nil { + return output.Err(output.ErrSSHConnectFailed, err.Error()) + } + defer client.Close() + + name := ctx.Name + remotePath := fmt.Sprintf("/var/www/%s", name) + + // Create directory + if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", remotePath)); err != nil { + return output.Err(output.ErrUploadFailed, "failed to create directory: "+err.Error()) + } + + // Upload files using rsync + if err := client.UploadDir(ctx.Path, remotePath); err != nil { + return output.Err(output.ErrUploadFailed, err.Error()) + } + + // Set ownership + if _, err := client.RunSudo(fmt.Sprintf("chown -R www-data:www-data %s", remotePath)); err != nil { + // Non-fatal, continue + } + + // Generate Caddyfile + caddyfile, err := templates.StaticCaddy(map[string]string{ + "Domain": ctx.URL[8:], // Strip https:// + "RootDir": remotePath, + "Name": name, + }) + if err != nil { + return output.Err(output.ErrCaddyFailed, "failed to generate Caddyfile: "+err.Error()) + } + + // Upload Caddyfile + caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) + if err := client.WriteSudoFile(caddyPath, caddyfile); err != nil { + return output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error()) + } + + // Reload Caddy + if _, err := client.RunSudo("systemctl reload caddy"); err != nil { + return output.Err(output.ErrCaddyFailed, "failed to reload Caddy: "+err.Error()) + } + + return nil +} + +// deployDockerV2 deploys a Docker-based app +// 1. Allocate port +// 2. rsync path to /var/lib//src/ +// 3. docker build +// 4. Generate systemd unit and env file +// 5. Generate Caddyfile +// 6. Start service, reload Caddy +func deployDockerV2(ctx *deployContext) *output.ErrorResponse { + client, err := ssh.Connect(ctx.SSHHost) + if err != nil { + return output.Err(output.ErrSSHConnectFailed, err.Error()) + } + defer client.Close() + + name := ctx.Name + + // Allocate port on server + port, err := allocatePort(client, name) + if err != nil { + return output.Err(output.ErrServiceFailed, "failed to allocate port: "+err.Error()) + } + + srcPath := fmt.Sprintf("/var/lib/%s/src", name) + dataPath := fmt.Sprintf("/var/lib/%s/data", name) + + // Create directories + if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s %s", srcPath, dataPath)); err != nil { + return output.Err(output.ErrUploadFailed, "failed to create directories: "+err.Error()) + } + + // Upload source + if err := client.UploadDir(ctx.Path, srcPath); err != nil { + return output.Err(output.ErrUploadFailed, err.Error()) + } + + // Docker build + buildCmd := fmt.Sprintf("docker build -t %s:latest %s", name, srcPath) + if _, err := client.RunSudo(buildCmd); err != nil { + return output.Err(output.ErrBuildFailed, err.Error()) + } + + // Generate and write env file + envContent := fmt.Sprintf("PORT=%d\nSHIP_NAME=%s\nSHIP_URL=%s\n", port, name, ctx.URL) + for _, e := range ctx.Opts.Env { + envContent += e + "\n" + } + envPath := fmt.Sprintf("/etc/ship/env/%s.env", name) + if _, err := client.RunSudo("mkdir -p /etc/ship/env"); err != nil { + // Continue, directory might exist + } + if err := client.WriteSudoFile(envPath, envContent); err != nil { + return output.Err(output.ErrServiceFailed, "failed to write env file: "+err.Error()) + } + + // Generate systemd unit + service, err := templates.DockerService(map[string]string{ + "Name": name, + "Port": strconv.Itoa(port), + }) + if err != nil { + return output.Err(output.ErrServiceFailed, "failed to generate systemd unit: "+err.Error()) + } + + servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name) + if err := client.WriteSudoFile(servicePath, service); err != nil { + return output.Err(output.ErrServiceFailed, "failed to write systemd unit: "+err.Error()) + } + + // Generate Caddyfile + caddyfile, err := templates.AppCaddy(map[string]string{ + "Domain": ctx.URL[8:], // Strip https:// + "Port": strconv.Itoa(port), + }) + if err != nil { + return output.Err(output.ErrCaddyFailed, "failed to generate Caddyfile: "+err.Error()) + } + + caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) + if err := client.WriteSudoFile(caddyPath, caddyfile); err != nil { + return output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error()) + } + + // Reload systemd and start service + if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { + return output.Err(output.ErrServiceFailed, "failed to reload systemd: "+err.Error()) + } + if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil { + return output.Err(output.ErrServiceFailed, "failed to start service: "+err.Error()) + } + + // Reload Caddy + if _, err := client.RunSudo("systemctl reload caddy"); err != nil { + return output.Err(output.ErrCaddyFailed, "failed to reload Caddy: "+err.Error()) + } + + return nil +} + +// deployBinaryV2 deploys a pre-built binary +// 1. Allocate port +// 2. scp binary to /usr/local/bin/ +// 3. Create user for service +// 4. Generate systemd unit and env file +// 5. Generate Caddyfile +// 6. Start service, reload Caddy +func deployBinaryV2(ctx *deployContext) *output.ErrorResponse { + client, err := ssh.Connect(ctx.SSHHost) + if err != nil { + return output.Err(output.ErrSSHConnectFailed, err.Error()) + } + defer client.Close() + + name := ctx.Name + + // Allocate port on server + port, err := allocatePort(client, name) + if err != nil { + return output.Err(output.ErrServiceFailed, "failed to allocate port: "+err.Error()) + } + + binaryPath := fmt.Sprintf("/usr/local/bin/%s", name) + workDir := fmt.Sprintf("/var/lib/%s", name) + + // Upload binary + if err := client.Upload(ctx.Path, "/tmp/"+name); err != nil { + return output.Err(output.ErrUploadFailed, err.Error()) + } + + // Move to final location and set permissions + if _, err := client.RunSudo(fmt.Sprintf("mv /tmp/%s %s && chmod +x %s", name, binaryPath, binaryPath)); err != nil { + return output.Err(output.ErrUploadFailed, "failed to install binary: "+err.Error()) + } + + // Create work directory + if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", workDir)); err != nil { + return output.Err(output.ErrServiceFailed, "failed to create work directory: "+err.Error()) + } + + // Create service user (ignore error if exists) + client.RunSudo(fmt.Sprintf("useradd -r -s /bin/false %s 2>/dev/null || true", name)) + client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", name, name, workDir)) + + // Generate and write env file + envContent := fmt.Sprintf("PORT=%d\nSHIP_NAME=%s\nSHIP_URL=%s\n", port, name, ctx.URL) + for _, e := range ctx.Opts.Env { + envContent += e + "\n" + } + envPath := fmt.Sprintf("/etc/ship/env/%s.env", name) + if _, err := client.RunSudo("mkdir -p /etc/ship/env"); err != nil { + // Continue + } + if err := client.WriteSudoFile(envPath, envContent); err != nil { + return output.Err(output.ErrServiceFailed, "failed to write env file: "+err.Error()) + } + + // Generate systemd unit + service, err := templates.SystemdService(map[string]string{ + "Name": name, + "User": name, + "WorkDir": workDir, + "EnvFile": envPath, + "BinaryPath": binaryPath, + "Args": "", + }) + if err != nil { + return output.Err(output.ErrServiceFailed, "failed to generate systemd unit: "+err.Error()) + } + + servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name) + if err := client.WriteSudoFile(servicePath, service); err != nil { + return output.Err(output.ErrServiceFailed, "failed to write systemd unit: "+err.Error()) + } + + // Generate Caddyfile + caddyfile, err := templates.AppCaddy(map[string]string{ + "Domain": ctx.URL[8:], // Strip https:// + "Port": strconv.Itoa(port), + }) + if err != nil { + return output.Err(output.ErrCaddyFailed, "failed to generate Caddyfile: "+err.Error()) + } + + caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) + if err := client.WriteSudoFile(caddyPath, caddyfile); err != nil { + return output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error()) + } + + // Reload systemd and start service + if _, err := client.RunSudo("systemctl daemon-reload"); err != nil { + return output.Err(output.ErrServiceFailed, "failed to reload systemd: "+err.Error()) + } + if _, err := client.RunSudo(fmt.Sprintf("systemctl enable --now %s", name)); err != nil { + return output.Err(output.ErrServiceFailed, "failed to start service: "+err.Error()) + } + + // Reload Caddy + if _, err := client.RunSudo("systemctl reload caddy"); err != nil { + return output.Err(output.ErrCaddyFailed, "failed to reload Caddy: "+err.Error()) + } + + return nil +} + +// allocatePort allocates or retrieves a port for a service +func allocatePort(client *ssh.Client, name string) (int, error) { + portFile := fmt.Sprintf("/etc/ship/ports/%s", name) + + // Try to read existing port + out, err := client.Run(fmt.Sprintf("cat %s 2>/dev/null || echo ''", portFile)) + if err == nil && out != "" { + port, err := strconv.Atoi(out[:len(out)-1]) // Strip newline + if err == nil && port > 0 { + return port, nil + } + } + + // Allocate new port + // Find highest used port and increment + out, err = client.RunSudo("mkdir -p /etc/ship/ports && ls /etc/ship/ports/ 2>/dev/null | xargs -I{} cat /etc/ship/ports/{} 2>/dev/null | sort -n | tail -1") + if err != nil { + out = "" + } + + nextPort := 9000 + if out != "" { + if lastPort, err := strconv.Atoi(out[:len(out)-1]); err == nil { + nextPort = lastPort + 1 + } + } + + // Write port allocation + if err := client.WriteSudoFile(portFile, strconv.Itoa(nextPort)); err != nil { + return 0, err + } + + return nextPort, nil +} + +// setTTLV2 sets auto-expiry for a deploy +func setTTLV2(ctx *deployContext, ttl time.Duration) error { + client, err := ssh.Connect(ctx.SSHHost) + if err != nil { + return err + } + defer client.Close() + + expires := time.Now().Add(ttl).Unix() + ttlPath := fmt.Sprintf("/etc/ship/ttl/%s", ctx.Name) + + if _, err := client.RunSudo("mkdir -p /etc/ship/ttl"); err != nil { + return err + } + + return client.WriteSudoFile(ttlPath, strconv.FormatInt(expires, 10)) +} + +// runHealthCheck verifies the deploy is responding +func runHealthCheck(url, endpoint string) (*output.HealthResult, *output.ErrorResponse) { + fullURL := url + endpoint + + // Wait for app to start + time.Sleep(2 * time.Second) + + var lastErr error + var lastStatus int + + for i := 0; i < 15; i++ { + start := time.Now() + resp, err := http.Get(fullURL) + latency := time.Since(start).Milliseconds() + + if err != nil { + lastErr = err + time.Sleep(2 * time.Second) + continue + } + resp.Body.Close() + lastStatus = resp.StatusCode + + if resp.StatusCode >= 200 && resp.StatusCode < 400 { + return &output.HealthResult{ + Endpoint: endpoint, + Status: resp.StatusCode, + LatencyMs: latency, + }, nil + } + + time.Sleep(2 * time.Second) + } + + msg := fmt.Sprintf("health check failed after 30s: ") + if lastErr != nil { + msg += lastErr.Error() + } else { + msg += fmt.Sprintf("status %d", lastStatus) + } + + return nil, output.Err(output.ErrHealthCheckFailed, msg) +} -- cgit v1.2.3 From e2c65318328d435d34d73a8a974de70762b40ae7 Mon Sep 17 00:00:00 2001 From: Clawd Date: Sun, 15 Feb 2026 19:09:20 -0800 Subject: fix: split sudo commands to fix ownership for rsync upload --- cmd/ship/deploy_impl_v2.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) (limited to 'cmd/ship/deploy_impl_v2.go') diff --git a/cmd/ship/deploy_impl_v2.go b/cmd/ship/deploy_impl_v2.go index 5d9629d..417ee8f 100644 --- a/cmd/ship/deploy_impl_v2.go +++ b/cmd/ship/deploy_impl_v2.go @@ -4,6 +4,7 @@ import ( "fmt" "net/http" "strconv" + "strings" "time" "github.com/bdw/ship/internal/output" @@ -25,17 +26,22 @@ func deployStaticV2(ctx *deployContext) *output.ErrorResponse { name := ctx.Name remotePath := fmt.Sprintf("/var/www/%s", name) - // Create directory + // Create directory and set ownership for upload + user, _ := client.Run("whoami") + user = strings.TrimSpace(user) if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", remotePath)); err != nil { return output.Err(output.ErrUploadFailed, "failed to create directory: "+err.Error()) } + if _, err := client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", user, user, remotePath)); err != nil { + return output.Err(output.ErrUploadFailed, "failed to set directory ownership: "+err.Error()) + } // Upload files using rsync if err := client.UploadDir(ctx.Path, remotePath); err != nil { return output.Err(output.ErrUploadFailed, err.Error()) } - // Set ownership + // Set ownership back to www-data if _, err := client.RunSudo(fmt.Sprintf("chown -R www-data:www-data %s", remotePath)); err != nil { // Non-fatal, continue } -- cgit v1.2.3 From 385577a14de35dcf70996ccfee6508d54e090c16 Mon Sep 17 00:00:00 2001 From: Clawd Date: Sun, 15 Feb 2026 19:11:12 -0800 Subject: fix: set ownership before rsync for Docker deploy --- cmd/ship/deploy_impl_v2.go | 7 +++++++ 1 file changed, 7 insertions(+) (limited to 'cmd/ship/deploy_impl_v2.go') diff --git a/cmd/ship/deploy_impl_v2.go b/cmd/ship/deploy_impl_v2.go index 417ee8f..e2989b2 100644 --- a/cmd/ship/deploy_impl_v2.go +++ b/cmd/ship/deploy_impl_v2.go @@ -100,6 +100,13 @@ func deployDockerV2(ctx *deployContext) *output.ErrorResponse { return output.Err(output.ErrUploadFailed, "failed to create directories: "+err.Error()) } + // Set ownership for upload + user, _ := client.Run("whoami") + user = strings.TrimSpace(user) + if _, err := client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", user, user, srcPath)); err != nil { + return output.Err(output.ErrUploadFailed, "failed to set directory ownership: "+err.Error()) + } + // Upload source if err := client.UploadDir(ctx.Path, srcPath); err != nil { return output.Err(output.ErrUploadFailed, err.Error()) -- cgit v1.2.3 From d97bb6f53eefd2139115d39bca7e17d565222472 Mon Sep 17 00:00:00 2001 From: Clawd Date: Tue, 17 Feb 2026 07:59:50 -0800 Subject: Fix port collision bug, add --container-port flag Port allocation: - Use atomic flock-based allocation via /etc/ship/next_port - Prevents race conditions when multiple deploys run concurrently - Each app still gets its port stored in /etc/ship/ports/ Docker container port: - Add --container-port flag (default 80) - Template now uses {{.ContainerPort}} instead of hardcoded 80 - Supports containers that listen on 8080, 3000, etc. --- cmd/ship/deploy_impl_v2.go | 45 +++++++++++++++++++++++++--------------- cmd/ship/deploy_v2.go | 17 ++++++++------- cmd/ship/root_v2.go | 2 ++ internal/templates/templates.go | 2 +- ship-new | Bin 12401121 -> 12400369 bytes 5 files changed, 40 insertions(+), 26 deletions(-) (limited to 'cmd/ship/deploy_impl_v2.go') diff --git a/cmd/ship/deploy_impl_v2.go b/cmd/ship/deploy_impl_v2.go index e2989b2..9ff674e 100644 --- a/cmd/ship/deploy_impl_v2.go +++ b/cmd/ship/deploy_impl_v2.go @@ -132,9 +132,14 @@ func deployDockerV2(ctx *deployContext) *output.ErrorResponse { } // Generate systemd unit + containerPort := ctx.Opts.ContainerPort + if containerPort == 0 { + containerPort = 80 + } service, err := templates.DockerService(map[string]string{ - "Name": name, - "Port": strconv.Itoa(port), + "Name": name, + "Port": strconv.Itoa(port), + "ContainerPort": strconv.Itoa(containerPort), }) if err != nil { return output.Err(output.ErrServiceFailed, "failed to generate systemd unit: "+err.Error()) @@ -281,38 +286,44 @@ func deployBinaryV2(ctx *deployContext) *output.ErrorResponse { } // allocatePort allocates or retrieves a port for a service +// Uses atomic increment on /etc/ship/next_port to avoid collisions func allocatePort(client *ssh.Client, name string) (int, error) { portFile := fmt.Sprintf("/etc/ship/ports/%s", name) - // Try to read existing port + // Try to read existing port for this app out, err := client.Run(fmt.Sprintf("cat %s 2>/dev/null || echo ''", portFile)) if err == nil && out != "" { - port, err := strconv.Atoi(out[:len(out)-1]) // Strip newline - if err == nil && port > 0 { + out = strings.TrimSpace(out) + if port, err := strconv.Atoi(out); err == nil && port > 0 { return port, nil } } - // Allocate new port - // Find highest used port and increment - out, err = client.RunSudo("mkdir -p /etc/ship/ports && ls /etc/ship/ports/ 2>/dev/null | xargs -I{} cat /etc/ship/ports/{} 2>/dev/null | sort -n | tail -1") + // Allocate new port atomically using flock + // This reads next_port, increments it, and writes back while holding a lock + allocScript := ` +flock -x /etc/ship/.port.lock sh -c ' + mkdir -p /etc/ship/ports + PORT=$(cat /etc/ship/next_port 2>/dev/null || echo 9000) + echo $((PORT + 1)) > /etc/ship/next_port + echo $PORT +'` + out, err = client.RunSudo(allocScript) if err != nil { - out = "" + return 0, fmt.Errorf("failed to allocate port: %w", err) } - nextPort := 9000 - if out != "" { - if lastPort, err := strconv.Atoi(out[:len(out)-1]); err == nil { - nextPort = lastPort + 1 - } + port, err := strconv.Atoi(strings.TrimSpace(out)) + if err != nil { + return 0, fmt.Errorf("invalid port allocated: %s", out) } - // Write port allocation - if err := client.WriteSudoFile(portFile, strconv.Itoa(nextPort)); err != nil { + // Write port allocation for this app + if err := client.WriteSudoFile(portFile, strconv.Itoa(port)); err != nil { return 0, err } - return nextPort, nil + return port, nil } // setTTLV2 sets auto-expiry for a deploy diff --git a/cmd/ship/deploy_v2.go b/cmd/ship/deploy_v2.go index b636709..7d498b2 100644 --- a/cmd/ship/deploy_v2.go +++ b/cmd/ship/deploy_v2.go @@ -143,14 +143,15 @@ func deployV2(path string, opts deployV2Options) { } type deployV2Options struct { - Name string - Host string - Domain string - Health string - TTL string - Env []string - EnvFile string - Pretty bool + Name string + Host string + Domain string + Health string + TTL string + Env []string + EnvFile string + ContainerPort int // Port the container listens on (default 80 for Docker) + Pretty bool } // deployContext holds all info needed for a deploy diff --git a/cmd/ship/root_v2.go b/cmd/ship/root_v2.go index 4101d4e..aa81d1e 100644 --- a/cmd/ship/root_v2.go +++ b/cmd/ship/root_v2.go @@ -43,6 +43,7 @@ func initV2() { rootV2Cmd.Flags().String("ttl", "", "Auto-delete after duration (e.g., 1h, 7d)") rootV2Cmd.Flags().StringArray("env", nil, "Environment variable (KEY=VALUE)") rootV2Cmd.Flags().String("env-file", "", "Path to .env file") + rootV2Cmd.Flags().Int("container-port", 80, "Port the container listens on (Docker only)") // Check for SHIP_PRETTY env var if os.Getenv("SHIP_PRETTY") == "1" { @@ -78,6 +79,7 @@ func runDeployV2(cmd *cobra.Command, args []string) error { opts.TTL, _ = cmd.Flags().GetString("ttl") opts.Env, _ = cmd.Flags().GetStringArray("env") opts.EnvFile, _ = cmd.Flags().GetString("env-file") + opts.ContainerPort, _ = cmd.Flags().GetInt("container-port") // deployV2 handles all output and exits deployV2(path, opts) diff --git a/internal/templates/templates.go b/internal/templates/templates.go index f1b9a9e..2163f47 100644 --- a/internal/templates/templates.go +++ b/internal/templates/templates.go @@ -216,7 +216,7 @@ Requires=docker.service Type=simple ExecStartPre=-/usr/bin/docker rm -f {{.Name}} ExecStart=/usr/bin/docker run --rm --name {{.Name}} \ - -p 127.0.0.1:{{.Port}}:80 \ + -p 127.0.0.1:{{.Port}}:{{.ContainerPort}} \ --env-file /etc/ship/env/{{.Name}}.env \ -v /var/lib/{{.Name}}/data:/data \ {{.Name}}:latest diff --git a/ship-new b/ship-new index 814585f..cd3667d 100755 Binary files a/ship-new and b/ship-new differ -- cgit v1.2.3 From b976b147e2e5e34b940c69fee7d7c121e12cd9a8 Mon Sep 17 00:00:00 2001 From: Clawd Date: Tue, 17 Feb 2026 08:09:34 -0800 Subject: Preserve existing Caddyfiles on redeploy Don't overwrite Caddyfile if it already exists. This preserves manual customizations (NIP-05 routes, custom headers, etc.). First deploy generates Caddyfile, subsequent deploys leave it alone. --- cmd/ship/deploy_impl_v2.go | 78 +++++++++++++++++++++++++-------------------- ship-new | Bin 12391001 -> 12392993 bytes 2 files changed, 43 insertions(+), 35 deletions(-) (limited to 'cmd/ship/deploy_impl_v2.go') diff --git a/cmd/ship/deploy_impl_v2.go b/cmd/ship/deploy_impl_v2.go index 9ff674e..5b68dc3 100644 --- a/cmd/ship/deploy_impl_v2.go +++ b/cmd/ship/deploy_impl_v2.go @@ -46,20 +46,22 @@ func deployStaticV2(ctx *deployContext) *output.ErrorResponse { // Non-fatal, continue } - // Generate Caddyfile - caddyfile, err := templates.StaticCaddy(map[string]string{ - "Domain": ctx.URL[8:], // Strip https:// - "RootDir": remotePath, - "Name": name, - }) - if err != nil { - return output.Err(output.ErrCaddyFailed, "failed to generate Caddyfile: "+err.Error()) - } - - // Upload Caddyfile + // Generate Caddyfile only if it doesn't exist (preserve manual edits) caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) - if err := client.WriteSudoFile(caddyPath, caddyfile); err != nil { - return output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error()) + caddyExists, _ := client.Run(fmt.Sprintf("test -f %s && echo exists", caddyPath)) + if strings.TrimSpace(caddyExists) != "exists" { + caddyfile, err := templates.StaticCaddy(map[string]string{ + "Domain": ctx.URL[8:], // Strip https:// + "RootDir": remotePath, + "Name": name, + }) + if err != nil { + return output.Err(output.ErrCaddyFailed, "failed to generate Caddyfile: "+err.Error()) + } + + if err := client.WriteSudoFile(caddyPath, caddyfile); err != nil { + return output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error()) + } } // Reload Caddy @@ -150,18 +152,21 @@ func deployDockerV2(ctx *deployContext) *output.ErrorResponse { return output.Err(output.ErrServiceFailed, "failed to write systemd unit: "+err.Error()) } - // Generate Caddyfile - caddyfile, err := templates.AppCaddy(map[string]string{ - "Domain": ctx.URL[8:], // Strip https:// - "Port": strconv.Itoa(port), - }) - if err != nil { - return output.Err(output.ErrCaddyFailed, "failed to generate Caddyfile: "+err.Error()) - } - + // Generate Caddyfile only if it doesn't exist (preserve manual edits) caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) - if err := client.WriteSudoFile(caddyPath, caddyfile); err != nil { - return output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error()) + caddyExists, _ := client.Run(fmt.Sprintf("test -f %s && echo exists", caddyPath)) + if strings.TrimSpace(caddyExists) != "exists" { + caddyfile, err := templates.AppCaddy(map[string]string{ + "Domain": ctx.URL[8:], // Strip https:// + "Port": strconv.Itoa(port), + }) + if err != nil { + return output.Err(output.ErrCaddyFailed, "failed to generate Caddyfile: "+err.Error()) + } + + if err := client.WriteSudoFile(caddyPath, caddyfile); err != nil { + return output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error()) + } } // Reload systemd and start service @@ -255,18 +260,21 @@ func deployBinaryV2(ctx *deployContext) *output.ErrorResponse { return output.Err(output.ErrServiceFailed, "failed to write systemd unit: "+err.Error()) } - // Generate Caddyfile - caddyfile, err := templates.AppCaddy(map[string]string{ - "Domain": ctx.URL[8:], // Strip https:// - "Port": strconv.Itoa(port), - }) - if err != nil { - return output.Err(output.ErrCaddyFailed, "failed to generate Caddyfile: "+err.Error()) - } - + // Generate Caddyfile only if it doesn't exist (preserve manual edits) caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name) - if err := client.WriteSudoFile(caddyPath, caddyfile); err != nil { - return output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error()) + caddyExists, _ := client.Run(fmt.Sprintf("test -f %s && echo exists", caddyPath)) + if strings.TrimSpace(caddyExists) != "exists" { + caddyfile, err := templates.AppCaddy(map[string]string{ + "Domain": ctx.URL[8:], // Strip https:// + "Port": strconv.Itoa(port), + }) + if err != nil { + return output.Err(output.ErrCaddyFailed, "failed to generate Caddyfile: "+err.Error()) + } + + if err := client.WriteSudoFile(caddyPath, caddyfile); err != nil { + return output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error()) + } } // Reload systemd and start service diff --git a/ship-new b/ship-new index 39e3473..28b9780 100755 Binary files a/ship-new and b/ship-new differ -- cgit v1.2.3