From 5548b36e0953c17dbe30f6b63c892b7c83196b20 Mon Sep 17 00:00:00 2001 From: Clawd Date: Thu, 19 Feb 2026 08:10:45 -0800 Subject: Clean up: drop v2 suffix, remove webui --- cmd/ship/deploy.go | 210 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 cmd/ship/deploy.go (limited to 'cmd/ship/deploy.go') diff --git a/cmd/ship/deploy.go b/cmd/ship/deploy.go new file mode 100644 index 0000000..7d498b2 --- /dev/null +++ b/cmd/ship/deploy.go @@ -0,0 +1,210 @@ +package main + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "regexp" + "strings" + "time" + + "github.com/bdw/ship/internal/detect" + "github.com/bdw/ship/internal/output" + "github.com/bdw/ship/internal/state" +) + +// deployV2 implements the new agent-first deploy interface. +// Usage: ship [PATH] [FLAGS] +// PATH defaults to "." if not provided. +func deployV2(path string, opts deployV2Options) { + start := time.Now() + + // Validate name if provided + if opts.Name != "" { + if err := validateNameV2(opts.Name); err != nil { + output.PrintAndExit(err) + } + } + + // Parse TTL if provided + var ttlDuration time.Duration + if opts.TTL != "" { + var err error + ttlDuration, err = parseTTL(opts.TTL) + if err != nil { + output.PrintAndExit(output.Err(output.ErrInvalidTTL, err.Error())) + } + } + + // Get host configuration + st, err := state.Load() + if err != nil { + output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "failed to load state: "+err.Error())) + } + + hostName := opts.Host + if hostName == "" { + hostName = st.DefaultHost + } + if hostName == "" { + output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified and no default host configured. Run: ship host init")) + } + + hostConfig := st.GetHost(hostName) + if hostConfig.BaseDomain == "" { + output.PrintAndExit(output.Err(output.ErrHostNotConfigured, fmt.Sprintf("host %q has no base domain configured. Run: ship host init", hostName))) + } + + // Auto-detect project type + result := detect.Detect(path) + if result.Error != nil { + output.PrintAndExit(result.Error) + } + + // Generate name if not provided + name := opts.Name + if name == "" { + name = generateName() + } + + // Build URL: use custom domain if provided, otherwise use subdomain + var url string + if opts.Domain != "" { + url = fmt.Sprintf("https://%s", opts.Domain) + } else { + url = fmt.Sprintf("https://%s.%s", name, hostConfig.BaseDomain) + } + + // Build deploy context + ctx := &deployContext{ + SSHHost: hostName, + HostConfig: hostConfig, + Name: name, + Path: result.Path, + URL: url, + Opts: opts, + } + + // Deploy based on type + var deployErr *output.ErrorResponse + switch result.Type { + case detect.TypeStatic: + deployErr = deployStaticV2(ctx) + case detect.TypeDocker: + deployErr = deployDockerV2(ctx) + case detect.TypeBinary: + deployErr = deployBinaryV2(ctx) + } + + if deployErr != nil { + deployErr.Name = name + deployErr.URL = url + output.PrintAndExit(deployErr) + } + + // Set TTL if specified + if ttlDuration > 0 { + if err := setTTLV2(ctx, ttlDuration); err != nil { + // Non-fatal, deploy succeeded + // TODO: log warning + } + } + + // Health check + var healthResult *output.HealthResult + if opts.Health != "" || result.Type == detect.TypeStatic { + endpoint := opts.Health + if endpoint == "" { + endpoint = "/" + } + healthResult, deployErr = runHealthCheck(url, endpoint) + if deployErr != nil { + deployErr.Name = name + deployErr.URL = url + output.PrintAndExit(deployErr) + } + } + + // Build response + resp := &output.DeployResponse{ + Status: "ok", + Name: name, + URL: url, + Type: string(result.Type), + TookMs: time.Since(start).Milliseconds(), + Health: healthResult, + } + + if ttlDuration > 0 { + resp.Expires = time.Now().Add(ttlDuration).UTC().Format(time.RFC3339) + } + + output.PrintAndExit(resp) +} + +type deployV2Options struct { + 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 +type deployContext struct { + SSHHost string // SSH connection string (config alias or user@host) + HostConfig *state.Host // Host configuration + Name string // Deploy name + Path string // Local path to deploy + URL string // Full URL after deploy + Opts deployV2Options +} + +// validateNameV2 checks if name matches allowed pattern +func validateNameV2(name string) *output.ErrorResponse { + // Must be lowercase alphanumeric with hyphens, 1-63 chars + pattern := regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$|^[a-z0-9]$`) + if !pattern.MatchString(name) { + return output.Err(output.ErrInvalidName, + "name must be lowercase alphanumeric with hyphens, 1-63 characters") + } + return nil +} + +// generateName creates a random deploy name +func generateName() string { + bytes := make([]byte, 3) + rand.Read(bytes) + return "ship-" + hex.EncodeToString(bytes) +} + +// parseTTL converts duration strings like "1h", "7d" to time.Duration +func parseTTL(s string) (time.Duration, error) { + s = strings.TrimSpace(s) + if s == "" { + return 0, nil + } + + // Handle days specially (not supported by time.ParseDuration) + if strings.HasSuffix(s, "d") { + days := strings.TrimSuffix(s, "d") + var d int + _, err := fmt.Sscanf(days, "%d", &d) + if err != nil { + return 0, fmt.Errorf("invalid TTL: %s", s) + } + return time.Duration(d) * 24 * time.Hour, nil + } + + d, err := time.ParseDuration(s) + if err != nil { + return 0, fmt.Errorf("invalid TTL: %s", s) + } + return d, nil +} + +// Deploy implementations are in deploy_impl_v2.go -- cgit v1.2.3