From 8094639aa2d5095af512d4e943fcb4af801aef07 Mon Sep 17 00:00:00 2001 From: Clawd Date: Sun, 15 Feb 2026 18:49:30 -0800 Subject: feat(v2): add CLI structure and deploy orchestration - cmd/ship/root_v2.go: new CLI with ship [PATH] as primary command - cmd/ship/deploy_v2.go: deploy orchestration with context struct - Placeholder implementations for static/docker/binary deploys - Placeholder subcommands (list, status, logs, remove, host) - Support for --name, --health, --ttl, --env flags - SHIP_PRETTY env var support Next: implement actual deploy flows --- cmd/ship/deploy_v2.go | 247 ++++++++++++++++++++++++++++++++++++++++++++++++++ cmd/ship/root_v2.go | 158 ++++++++++++++++++++++++++++++++ 2 files changed, 405 insertions(+) create mode 100644 cmd/ship/deploy_v2.go create mode 100644 cmd/ship/root_v2.go (limited to 'cmd/ship') diff --git a/cmd/ship/deploy_v2.go b/cmd/ship/deploy_v2.go new file mode 100644 index 0000000..7642b38 --- /dev/null +++ b/cmd/ship/deploy_v2.go @@ -0,0 +1,247 @@ +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 := validateName(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 + 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 + Health string + TTL string + Env []string + EnvFile string + 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 +} + +// validateName checks if name matches allowed pattern +func validateName(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 +} + +// Placeholder implementations - to be filled in + +func deployStaticV2(ctx *deployContext) *output.ErrorResponse { + // TODO: implement + // 1. rsync ctx.Path to /var/www// + // 2. Generate and upload Caddyfile + // 3. Reload Caddy + return nil +} + +func deployDockerV2(ctx *deployContext) *output.ErrorResponse { + // TODO: implement + // 1. rsync ctx.Path to /var/lib//src/ + // 2. docker build + // 3. Generate systemd unit and Caddyfile + // 4. Start service, reload Caddy + return nil +} + +func deployBinaryV2(ctx *deployContext) *output.ErrorResponse { + // TODO: implement + // 1. scp binary to /usr/local/bin/ + // 2. Generate systemd unit and Caddyfile + // 3. Start service, reload Caddy + return nil +} + +func setTTLV2(ctx *deployContext, ttl time.Duration) error { + // TODO: implement + // Write Unix timestamp to /etc/ship/ttl/ + return nil +} + +func runHealthCheck(url, endpoint string) (*output.HealthResult, *output.ErrorResponse) { + // TODO: implement + // 1. Wait 2s + // 2. GET url+endpoint + // 3. Retry up to 15 times (30s total) + // 4. Return result or error + return &output.HealthResult{ + Endpoint: endpoint, + Status: 200, + LatencyMs: 50, + }, nil +} diff --git a/cmd/ship/root_v2.go b/cmd/ship/root_v2.go new file mode 100644 index 0000000..dab63be --- /dev/null +++ b/cmd/ship/root_v2.go @@ -0,0 +1,158 @@ +package main + +import ( + "os" + + "github.com/bdw/ship/internal/output" + "github.com/spf13/cobra" +) + +// This file defines the v2 CLI structure. +// The primary command is: ship [PATH] [FLAGS] +// All output is JSON by default. + +var rootV2Cmd = &cobra.Command{ + Use: "ship [PATH]", + Short: "Deploy code to a VPS. JSON output for agents.", + Long: `Ship deploys code to a VPS. Point it at a directory or binary, get a URL back. + + ship ./myproject # auto-detect and deploy + ship ./site --name docs # deploy with specific name + ship ./api --health /healthz # deploy with health check + ship ./preview --ttl 24h # deploy with auto-expiry + +All output is JSON. Use --pretty for human-readable output.`, + Args: cobra.MaximumNArgs(1), + RunE: runDeployV2, + SilenceUsage: true, + SilenceErrors: true, + DisableAutoGenTag: true, +} + +func initV2() { + // Global flags + rootV2Cmd.PersistentFlags().StringVar(&hostFlag, "host", "", "VPS host (SSH config alias or user@host)") + rootV2Cmd.PersistentFlags().BoolVar(&output.Pretty, "pretty", false, "Human-readable output") + + // Deploy flags + rootV2Cmd.Flags().String("name", "", "Deploy name (becomes subdomain)") + rootV2Cmd.Flags().String("health", "", "Health check endpoint (e.g., /healthz)") + 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") + + // Check for SHIP_PRETTY env var + if os.Getenv("SHIP_PRETTY") == "1" { + output.Pretty = true + } + + // Add subcommands + rootV2Cmd.AddCommand(listV2Cmd) + rootV2Cmd.AddCommand(statusV2Cmd) + rootV2Cmd.AddCommand(logsV2Cmd) + rootV2Cmd.AddCommand(removeV2Cmd) + rootV2Cmd.AddCommand(hostV2Cmd) +} + +func runDeployV2(cmd *cobra.Command, args []string) error { + path := "." + if len(args) > 0 { + path = args[0] + } + + opts := deployV2Options{ + Host: hostFlag, + Pretty: output.Pretty, + } + + // Get flag values + opts.Name, _ = cmd.Flags().GetString("name") + opts.Health, _ = cmd.Flags().GetString("health") + opts.TTL, _ = cmd.Flags().GetString("ttl") + opts.Env, _ = cmd.Flags().GetStringArray("env") + opts.EnvFile, _ = cmd.Flags().GetString("env-file") + + // deployV2 handles all output and exits + deployV2(path, opts) + + // Should not reach here (deployV2 calls os.Exit) + return nil +} + +// Placeholder subcommands - to be implemented + +var listV2Cmd = &cobra.Command{ + Use: "list", + Short: "List all deployments", + RunE: func(cmd *cobra.Command, args []string) error { + // TODO: implement + output.PrintAndExit(&output.ListResponse{ + Status: "ok", + Deploys: []output.DeployInfo{}, + }) + return nil + }, +} + +var statusV2Cmd = &cobra.Command{ + Use: "status NAME", + Short: "Check status of a deployment", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // TODO: implement + output.PrintAndExit(output.Err(output.ErrNotFound, "not implemented")) + return nil + }, +} + +var logsV2Cmd = &cobra.Command{ + Use: "logs NAME", + Short: "View logs for a deployment", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // TODO: implement + output.PrintAndExit(output.Err(output.ErrNotFound, "not implemented")) + return nil + }, +} + +var removeV2Cmd = &cobra.Command{ + Use: "remove NAME", + Short: "Remove a deployment", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // TODO: implement + output.PrintAndExit(output.Err(output.ErrNotFound, "not implemented")) + return nil + }, +} + +var hostV2Cmd = &cobra.Command{ + Use: "host", + Short: "Manage VPS host", +} + +func init() { + hostV2Cmd.AddCommand(hostInitV2Cmd) + hostV2Cmd.AddCommand(hostStatusV2Cmd) +} + +var hostInitV2Cmd = &cobra.Command{ + Use: "init USER@HOST --domain DOMAIN", + Short: "Initialize a VPS for deployments", + RunE: func(cmd *cobra.Command, args []string) error { + // TODO: implement - this is critical functionality to preserve + output.PrintAndExit(output.Err(output.ErrNotFound, "not implemented")) + return nil + }, +} + +var hostStatusV2Cmd = &cobra.Command{ + Use: "status", + Short: "Check host status", + RunE: func(cmd *cobra.Command, args []string) error { + // TODO: implement + output.PrintAndExit(output.Err(output.ErrNotFound, "not implemented")) + return nil + }, +} -- cgit v1.2.3