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