summaryrefslogtreecommitdiffstats
path: root/cmd/ship/deploy_v2.go
diff options
context:
space:
mode:
authorClawd <ai@clawd.bot>2026-02-15 18:49:30 -0800
committerClawd <ai@clawd.bot>2026-02-15 18:49:30 -0800
commit8094639aa2d5095af512d4e943fcb4af801aef07 (patch)
tree31db0d3d9f8248a8ce35ec87abbc6a2466e86b9a /cmd/ship/deploy_v2.go
parent5b8893550130ad8ffe39a6523a11994757493691 (diff)
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
Diffstat (limited to 'cmd/ship/deploy_v2.go')
-rw-r--r--cmd/ship/deploy_v2.go247
1 files changed, 247 insertions, 0 deletions
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 @@
1package main
2
3import (
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.
19func deployV2(path string, opts deployV2Options) {
20 start := time.Now()
21
22 // Validate name if provided
23 if opts.Name != "" {
24 if err := validateName(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
71 url := fmt.Sprintf("https://%s.%s", name, hostConfig.BaseDomain)
72
73 // Build deploy context
74 ctx := &deployContext{
75 SSHHost: hostName,
76 HostConfig: hostConfig,
77 Name: name,
78 Path: result.Path,
79 URL: url,
80 Opts: opts,
81 }
82
83 // Deploy based on type
84 var deployErr *output.ErrorResponse
85 switch result.Type {
86 case detect.TypeStatic:
87 deployErr = deployStaticV2(ctx)
88 case detect.TypeDocker:
89 deployErr = deployDockerV2(ctx)
90 case detect.TypeBinary:
91 deployErr = deployBinaryV2(ctx)
92 }
93
94 if deployErr != nil {
95 deployErr.Name = name
96 deployErr.URL = url
97 output.PrintAndExit(deployErr)
98 }
99
100 // Set TTL if specified
101 if ttlDuration > 0 {
102 if err := setTTLV2(ctx, ttlDuration); err != nil {
103 // Non-fatal, deploy succeeded
104 // TODO: log warning
105 }
106 }
107
108 // Health check
109 var healthResult *output.HealthResult
110 if opts.Health != "" || result.Type == detect.TypeStatic {
111 endpoint := opts.Health
112 if endpoint == "" {
113 endpoint = "/"
114 }
115 healthResult, deployErr = runHealthCheck(url, endpoint)
116 if deployErr != nil {
117 deployErr.Name = name
118 deployErr.URL = url
119 output.PrintAndExit(deployErr)
120 }
121 }
122
123 // Build response
124 resp := &output.DeployResponse{
125 Status: "ok",
126 Name: name,
127 URL: url,
128 Type: string(result.Type),
129 TookMs: time.Since(start).Milliseconds(),
130 Health: healthResult,
131 }
132
133 if ttlDuration > 0 {
134 resp.Expires = time.Now().Add(ttlDuration).UTC().Format(time.RFC3339)
135 }
136
137 output.PrintAndExit(resp)
138}
139
140type deployV2Options struct {
141 Name string
142 Host string
143 Health string
144 TTL string
145 Env []string
146 EnvFile string
147 Pretty bool
148}
149
150// deployContext holds all info needed for a deploy
151type deployContext struct {
152 SSHHost string // SSH connection string (config alias or user@host)
153 HostConfig *state.Host // Host configuration
154 Name string // Deploy name
155 Path string // Local path to deploy
156 URL string // Full URL after deploy
157 Opts deployV2Options
158}
159
160// validateName checks if name matches allowed pattern
161func validateName(name string) *output.ErrorResponse {
162 // Must be lowercase alphanumeric with hyphens, 1-63 chars
163 pattern := regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$|^[a-z0-9]$`)
164 if !pattern.MatchString(name) {
165 return output.Err(output.ErrInvalidName,
166 "name must be lowercase alphanumeric with hyphens, 1-63 characters")
167 }
168 return nil
169}
170
171// generateName creates a random deploy name
172func generateName() string {
173 bytes := make([]byte, 3)
174 rand.Read(bytes)
175 return "ship-" + hex.EncodeToString(bytes)
176}
177
178// parseTTL converts duration strings like "1h", "7d" to time.Duration
179func parseTTL(s string) (time.Duration, error) {
180 s = strings.TrimSpace(s)
181 if s == "" {
182 return 0, nil
183 }
184
185 // Handle days specially (not supported by time.ParseDuration)
186 if strings.HasSuffix(s, "d") {
187 days := strings.TrimSuffix(s, "d")
188 var d int
189 _, err := fmt.Sscanf(days, "%d", &d)
190 if err != nil {
191 return 0, fmt.Errorf("invalid TTL: %s", s)
192 }
193 return time.Duration(d) * 24 * time.Hour, nil
194 }
195
196 d, err := time.ParseDuration(s)
197 if err != nil {
198 return 0, fmt.Errorf("invalid TTL: %s", s)
199 }
200 return d, nil
201}
202
203// Placeholder implementations - to be filled in
204
205func deployStaticV2(ctx *deployContext) *output.ErrorResponse {
206 // TODO: implement
207 // 1. rsync ctx.Path to /var/www/<name>/
208 // 2. Generate and upload Caddyfile
209 // 3. Reload Caddy
210 return nil
211}
212
213func deployDockerV2(ctx *deployContext) *output.ErrorResponse {
214 // TODO: implement
215 // 1. rsync ctx.Path to /var/lib/<name>/src/
216 // 2. docker build
217 // 3. Generate systemd unit and Caddyfile
218 // 4. Start service, reload Caddy
219 return nil
220}
221
222func deployBinaryV2(ctx *deployContext) *output.ErrorResponse {
223 // TODO: implement
224 // 1. scp binary to /usr/local/bin/<name>
225 // 2. Generate systemd unit and Caddyfile
226 // 3. Start service, reload Caddy
227 return nil
228}
229
230func setTTLV2(ctx *deployContext, ttl time.Duration) error {
231 // TODO: implement
232 // Write Unix timestamp to /etc/ship/ttl/<name>
233 return nil
234}
235
236func runHealthCheck(url, endpoint string) (*output.HealthResult, *output.ErrorResponse) {
237 // TODO: implement
238 // 1. Wait 2s
239 // 2. GET url+endpoint
240 // 3. Retry up to 15 times (30s total)
241 // 4. Return result or error
242 return &output.HealthResult{
243 Endpoint: endpoint,
244 Status: 200,
245 LatencyMs: 50,
246 }, nil
247}