aboutsummaryrefslogtreecommitdiffstats
path: root/cmd/ship
diff options
context:
space:
mode:
Diffstat (limited to 'cmd/ship')
-rw-r--r--cmd/ship/commands.go365
-rw-r--r--cmd/ship/deploy.go210
-rw-r--r--cmd/ship/deploy_impl.go394
-rw-r--r--cmd/ship/host.go445
-rw-r--r--cmd/ship/main.go10
-rw-r--r--cmd/ship/root.go98
6 files changed, 0 insertions, 1522 deletions
diff --git a/cmd/ship/commands.go b/cmd/ship/commands.go
deleted file mode 100644
index 1b0d09c..0000000
--- a/cmd/ship/commands.go
+++ /dev/null
@@ -1,365 +0,0 @@
1package main
2
3import (
4 "fmt"
5 "strconv"
6 "strings"
7 "time"
8
9 "github.com/bdw/ship/internal/output"
10 "github.com/bdw/ship/internal/ssh"
11 "github.com/bdw/ship/internal/state"
12 "github.com/spf13/cobra"
13)
14
15// listV2Cmd lists all deployments
16var listV2Cmd = &cobra.Command{
17 Use: "list",
18 Short: "List all deployments",
19 RunE: runListV2,
20}
21
22func runListV2(cmd *cobra.Command, args []string) error {
23 st, err := state.Load()
24 if err != nil {
25 output.PrintAndExit(output.Err(output.ErrHostNotConfigured, err.Error()))
26 }
27
28 hostName := hostFlag
29 if hostName == "" {
30 hostName = st.DefaultHost
31 }
32 if hostName == "" {
33 output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified"))
34 }
35
36 hostConfig := st.GetHost(hostName)
37
38 client, err := ssh.Connect(hostName)
39 if err != nil {
40 output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error()))
41 }
42 defer client.Close()
43
44 var deploys []output.DeployInfo
45
46 // Get all deployed services by checking /etc/ship/ports and /var/www
47 // Check ports (apps and docker)
48 portsOut, _ := client.Run("ls /etc/ship/ports/ 2>/dev/null || true")
49 for _, name := range strings.Fields(portsOut) {
50 if name == "" {
51 continue
52 }
53
54 // Get actual domain from Caddyfile (first word of first line)
55 domain := fmt.Sprintf("%s.%s", name, hostConfig.BaseDomain)
56 caddyOut, _ := client.Run(fmt.Sprintf("head -1 /etc/caddy/sites-enabled/%s.caddy 2>/dev/null | awk '{print $1}'", name))
57 if d := strings.TrimSpace(caddyOut); d != "" && d != "{" {
58 domain = d
59 }
60
61 info := output.DeployInfo{
62 Name: name,
63 URL: fmt.Sprintf("https://%s", domain),
64 }
65
66 // Check if it's docker or binary
67 dockerOut, _ := client.Run(fmt.Sprintf("docker inspect %s 2>/dev/null && echo docker", name))
68 if strings.Contains(dockerOut, "docker") {
69 info.Type = "docker"
70 } else {
71 info.Type = "binary"
72 }
73
74 // Check if running
75 statusOut, _ := client.RunSudo(fmt.Sprintf("systemctl is-active %s 2>/dev/null || echo inactive", name))
76 info.Running = strings.TrimSpace(statusOut) == "active"
77
78 // Check TTL
79 ttlOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ttl/%s 2>/dev/null || true", name))
80 if ttlOut != "" {
81 if expires, err := strconv.ParseInt(strings.TrimSpace(ttlOut), 10, 64); err == nil {
82 info.Expires = time.Unix(expires, 0).UTC().Format(time.RFC3339)
83 }
84 }
85
86 deploys = append(deploys, info)
87 }
88
89 // Check static sites in /var/www
90 wwwOut, _ := client.Run("ls -d /var/www/*/ 2>/dev/null | xargs -n1 basename 2>/dev/null || true")
91 for _, name := range strings.Fields(wwwOut) {
92 if name == "" || name == "html" {
93 continue
94 }
95
96 // Skip if already in ports (would be an app, not static)
97 found := false
98 for _, d := range deploys {
99 if d.Name == name {
100 found = true
101 break
102 }
103 }
104 if found {
105 continue
106 }
107
108 // Get actual domain from Caddyfile
109 domain := fmt.Sprintf("%s.%s", name, hostConfig.BaseDomain)
110 caddyOut, _ := client.Run(fmt.Sprintf("head -1 /etc/caddy/sites-enabled/%s.caddy 2>/dev/null | awk '{print $1}'", name))
111 if d := strings.TrimSpace(caddyOut); d != "" && d != "{" {
112 domain = d
113 }
114
115 info := output.DeployInfo{
116 Name: name,
117 URL: fmt.Sprintf("https://%s", domain),
118 Type: "static",
119 Running: true, // Static sites are always "running"
120 }
121
122 // Check TTL
123 ttlOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ttl/%s 2>/dev/null || true", name))
124 if ttlOut != "" {
125 if expires, err := strconv.ParseInt(strings.TrimSpace(ttlOut), 10, 64); err == nil {
126 info.Expires = time.Unix(expires, 0).UTC().Format(time.RFC3339)
127 }
128 }
129
130 deploys = append(deploys, info)
131 }
132
133 output.PrintAndExit(&output.ListResponse{
134 Status: "ok",
135 Deploys: deploys,
136 })
137 return nil
138}
139
140// statusV2Cmd shows status for a single deployment
141var statusV2Cmd = &cobra.Command{
142 Use: "status NAME",
143 Short: "Check status of a deployment",
144 Args: cobra.ExactArgs(1),
145 RunE: runStatusV2,
146}
147
148func runStatusV2(cmd *cobra.Command, args []string) error {
149 name := args[0]
150
151 st, err := state.Load()
152 if err != nil {
153 output.PrintAndExit(output.Err(output.ErrHostNotConfigured, err.Error()))
154 }
155
156 hostName := hostFlag
157 if hostName == "" {
158 hostName = st.DefaultHost
159 }
160 if hostName == "" {
161 output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified"))
162 }
163
164 hostConfig := st.GetHost(hostName)
165
166 client, err := ssh.Connect(hostName)
167 if err != nil {
168 output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error()))
169 }
170 defer client.Close()
171
172 // Check if deployment exists
173 portOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ports/%s 2>/dev/null || true", name))
174 wwwExists, _ := client.Run(fmt.Sprintf("test -d /var/www/%s && echo exists || true", name))
175
176 if strings.TrimSpace(portOut) == "" && strings.TrimSpace(wwwExists) == "" {
177 output.PrintAndExit(output.ErrWithName(output.ErrNotFound, "deployment not found", name))
178 }
179
180 // Get actual domain from Caddyfile
181 domain := fmt.Sprintf("%s.%s", name, hostConfig.BaseDomain)
182 caddyOut, _ := client.Run(fmt.Sprintf("head -1 /etc/caddy/sites-enabled/%s.caddy 2>/dev/null | awk '{print $1}'", name))
183 if d := strings.TrimSpace(caddyOut); d != "" && d != "{" {
184 domain = d
185 }
186
187 resp := &output.StatusResponse{
188 Status: "ok",
189 Name: name,
190 URL: fmt.Sprintf("https://%s", domain),
191 }
192
193 // Determine type and get details
194 if portOut != "" {
195 port, _ := strconv.Atoi(strings.TrimSpace(portOut))
196 resp.Port = port
197
198 // Check if docker
199 dockerOut, _ := client.Run(fmt.Sprintf("docker inspect %s 2>/dev/null && echo docker", name))
200 if strings.Contains(dockerOut, "docker") {
201 resp.Type = "docker"
202 } else {
203 resp.Type = "binary"
204 }
205
206 // Check if running
207 statusOut, _ := client.RunSudo(fmt.Sprintf("systemctl is-active %s 2>/dev/null || echo inactive", name))
208 resp.Running = strings.TrimSpace(statusOut) == "active"
209 } else {
210 resp.Type = "static"
211 resp.Running = true
212 }
213
214 // Check TTL
215 ttlOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ttl/%s 2>/dev/null || true", name))
216 if ttlOut != "" {
217 if expires, err := strconv.ParseInt(strings.TrimSpace(ttlOut), 10, 64); err == nil {
218 resp.Expires = time.Unix(expires, 0).UTC().Format(time.RFC3339)
219 }
220 }
221
222 output.PrintAndExit(resp)
223 return nil
224}
225
226// logsV2Cmd shows logs for a deployment
227var logsV2Cmd = &cobra.Command{
228 Use: "logs NAME",
229 Short: "View logs for a deployment",
230 Args: cobra.ExactArgs(1),
231 RunE: runLogsV2,
232}
233
234func init() {
235 logsV2Cmd.Flags().Int("lines", 50, "Number of log lines to show")
236}
237
238func runLogsV2(cmd *cobra.Command, args []string) error {
239 name := args[0]
240 lines, _ := cmd.Flags().GetInt("lines")
241
242 st, err := state.Load()
243 if err != nil {
244 output.PrintAndExit(output.Err(output.ErrHostNotConfigured, err.Error()))
245 }
246
247 hostName := hostFlag
248 if hostName == "" {
249 hostName = st.DefaultHost
250 }
251 if hostName == "" {
252 output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified"))
253 }
254
255 client, err := ssh.Connect(hostName)
256 if err != nil {
257 output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error()))
258 }
259 defer client.Close()
260
261 // Check if it's a static site (no logs)
262 portOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ports/%s 2>/dev/null || true", name))
263 if strings.TrimSpace(portOut) == "" {
264 // Check if static site exists
265 wwwExists, _ := client.Run(fmt.Sprintf("test -d /var/www/%s && echo exists || true", name))
266 if strings.TrimSpace(wwwExists) == "" {
267 output.PrintAndExit(output.ErrWithName(output.ErrNotFound, "deployment not found", name))
268 }
269 // Static site - check Caddy access logs
270 logsOut, err := client.RunSudo(fmt.Sprintf("journalctl -u caddy -n %d --no-pager 2>/dev/null | grep %s || echo 'No logs found'", lines*2, name))
271 if err != nil {
272 output.PrintAndExit(output.Err(output.ErrServiceFailed, err.Error()))
273 }
274 logLines := strings.Split(strings.TrimSpace(logsOut), "\n")
275 output.PrintAndExit(&output.LogsResponse{
276 Status: "ok",
277 Name: name,
278 Lines: logLines,
279 })
280 return nil
281 }
282
283 // Get journalctl logs
284 logsOut, err := client.RunSudo(fmt.Sprintf("journalctl -u %s -n %d --no-pager 2>/dev/null || echo 'No logs found'", name, lines))
285 if err != nil {
286 output.PrintAndExit(output.Err(output.ErrServiceFailed, err.Error()))
287 }
288
289 logLines := strings.Split(strings.TrimSpace(logsOut), "\n")
290
291 output.PrintAndExit(&output.LogsResponse{
292 Status: "ok",
293 Name: name,
294 Lines: logLines,
295 })
296 return nil
297}
298
299// removeV2Cmd removes a deployment
300var removeV2Cmd = &cobra.Command{
301 Use: "remove NAME",
302 Short: "Remove a deployment",
303 Args: cobra.ExactArgs(1),
304 RunE: runRemoveV2,
305}
306
307func runRemoveV2(cmd *cobra.Command, args []string) error {
308 name := args[0]
309
310 st, err := state.Load()
311 if err != nil {
312 output.PrintAndExit(output.Err(output.ErrHostNotConfigured, err.Error()))
313 }
314
315 hostName := hostFlag
316 if hostName == "" {
317 hostName = st.DefaultHost
318 }
319 if hostName == "" {
320 output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified"))
321 }
322
323 client, err := ssh.Connect(hostName)
324 if err != nil {
325 output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error()))
326 }
327 defer client.Close()
328
329 // Check if deployment exists
330 portOut, _ := client.Run(fmt.Sprintf("cat /etc/ship/ports/%s 2>/dev/null || true", name))
331 wwwExists, _ := client.Run(fmt.Sprintf("test -d /var/www/%s && echo exists || true", name))
332
333 if strings.TrimSpace(portOut) == "" && strings.TrimSpace(wwwExists) == "" {
334 output.PrintAndExit(output.ErrWithName(output.ErrNotFound, "deployment not found", name))
335 }
336
337 // Stop and disable service
338 client.RunSudo(fmt.Sprintf("systemctl stop %s 2>/dev/null || true", name))
339 client.RunSudo(fmt.Sprintf("systemctl disable %s 2>/dev/null || true", name))
340
341 // Remove files
342 client.RunSudo(fmt.Sprintf("rm -f /etc/systemd/system/%s.service", name))
343 client.RunSudo(fmt.Sprintf("rm -f /etc/caddy/sites-enabled/%s.caddy", name))
344 client.RunSudo(fmt.Sprintf("rm -rf /var/www/%s", name))
345 client.RunSudo(fmt.Sprintf("rm -rf /var/lib/%s", name))
346 client.RunSudo(fmt.Sprintf("rm -f /usr/local/bin/%s", name))
347 client.RunSudo(fmt.Sprintf("rm -f /etc/ship/env/%s.env", name))
348 client.RunSudo(fmt.Sprintf("rm -f /etc/ship/ports/%s", name))
349 client.RunSudo(fmt.Sprintf("rm -f /etc/ship/ttl/%s", name))
350
351 // Remove docker container and image
352 client.Run(fmt.Sprintf("docker rm -f %s 2>/dev/null || true", name))
353 client.Run(fmt.Sprintf("docker rmi %s 2>/dev/null || true", name))
354
355 // Reload services
356 client.RunSudo("systemctl daemon-reload")
357 client.RunSudo("systemctl reload caddy")
358
359 output.PrintAndExit(&output.RemoveResponse{
360 Status: "ok",
361 Name: name,
362 Removed: true,
363 })
364 return nil
365}
diff --git a/cmd/ship/deploy.go b/cmd/ship/deploy.go
deleted file mode 100644
index 7d498b2..0000000
--- a/cmd/ship/deploy.go
+++ /dev/null
@@ -1,210 +0,0 @@
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 := validateNameV2(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: use custom domain if provided, otherwise use subdomain
71 var url string
72 if opts.Domain != "" {
73 url = fmt.Sprintf("https://%s", opts.Domain)
74 } else {
75 url = fmt.Sprintf("https://%s.%s", name, hostConfig.BaseDomain)
76 }
77
78 // Build deploy context
79 ctx := &deployContext{
80 SSHHost: hostName,
81 HostConfig: hostConfig,
82 Name: name,
83 Path: result.Path,
84 URL: url,
85 Opts: opts,
86 }
87
88 // Deploy based on type
89 var deployErr *output.ErrorResponse
90 switch result.Type {
91 case detect.TypeStatic:
92 deployErr = deployStaticV2(ctx)
93 case detect.TypeDocker:
94 deployErr = deployDockerV2(ctx)
95 case detect.TypeBinary:
96 deployErr = deployBinaryV2(ctx)
97 }
98
99 if deployErr != nil {
100 deployErr.Name = name
101 deployErr.URL = url
102 output.PrintAndExit(deployErr)
103 }
104
105 // Set TTL if specified
106 if ttlDuration > 0 {
107 if err := setTTLV2(ctx, ttlDuration); err != nil {
108 // Non-fatal, deploy succeeded
109 // TODO: log warning
110 }
111 }
112
113 // Health check
114 var healthResult *output.HealthResult
115 if opts.Health != "" || result.Type == detect.TypeStatic {
116 endpoint := opts.Health
117 if endpoint == "" {
118 endpoint = "/"
119 }
120 healthResult, deployErr = runHealthCheck(url, endpoint)
121 if deployErr != nil {
122 deployErr.Name = name
123 deployErr.URL = url
124 output.PrintAndExit(deployErr)
125 }
126 }
127
128 // Build response
129 resp := &output.DeployResponse{
130 Status: "ok",
131 Name: name,
132 URL: url,
133 Type: string(result.Type),
134 TookMs: time.Since(start).Milliseconds(),
135 Health: healthResult,
136 }
137
138 if ttlDuration > 0 {
139 resp.Expires = time.Now().Add(ttlDuration).UTC().Format(time.RFC3339)
140 }
141
142 output.PrintAndExit(resp)
143}
144
145type deployV2Options struct {
146 Name string
147 Host string
148 Domain string
149 Health string
150 TTL string
151 Env []string
152 EnvFile string
153 ContainerPort int // Port the container listens on (default 80 for Docker)
154 Pretty bool
155}
156
157// deployContext holds all info needed for a deploy
158type deployContext struct {
159 SSHHost string // SSH connection string (config alias or user@host)
160 HostConfig *state.Host // Host configuration
161 Name string // Deploy name
162 Path string // Local path to deploy
163 URL string // Full URL after deploy
164 Opts deployV2Options
165}
166
167// validateNameV2 checks if name matches allowed pattern
168func validateNameV2(name string) *output.ErrorResponse {
169 // Must be lowercase alphanumeric with hyphens, 1-63 chars
170 pattern := regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$|^[a-z0-9]$`)
171 if !pattern.MatchString(name) {
172 return output.Err(output.ErrInvalidName,
173 "name must be lowercase alphanumeric with hyphens, 1-63 characters")
174 }
175 return nil
176}
177
178// generateName creates a random deploy name
179func generateName() string {
180 bytes := make([]byte, 3)
181 rand.Read(bytes)
182 return "ship-" + hex.EncodeToString(bytes)
183}
184
185// parseTTL converts duration strings like "1h", "7d" to time.Duration
186func parseTTL(s string) (time.Duration, error) {
187 s = strings.TrimSpace(s)
188 if s == "" {
189 return 0, nil
190 }
191
192 // Handle days specially (not supported by time.ParseDuration)
193 if strings.HasSuffix(s, "d") {
194 days := strings.TrimSuffix(s, "d")
195 var d int
196 _, err := fmt.Sscanf(days, "%d", &d)
197 if err != nil {
198 return 0, fmt.Errorf("invalid TTL: %s", s)
199 }
200 return time.Duration(d) * 24 * time.Hour, nil
201 }
202
203 d, err := time.ParseDuration(s)
204 if err != nil {
205 return 0, fmt.Errorf("invalid TTL: %s", s)
206 }
207 return d, nil
208}
209
210// Deploy implementations are in deploy_impl_v2.go
diff --git a/cmd/ship/deploy_impl.go b/cmd/ship/deploy_impl.go
deleted file mode 100644
index bfec9d3..0000000
--- a/cmd/ship/deploy_impl.go
+++ /dev/null
@@ -1,394 +0,0 @@
1package main
2
3import (
4 "fmt"
5 "net/http"
6 "strconv"
7 "strings"
8 "time"
9
10 "github.com/bdw/ship/internal/output"
11 "github.com/bdw/ship/internal/ssh"
12 "github.com/bdw/ship/internal/templates"
13)
14
15// deployStaticV2 deploys a static site
16// 1. rsync path to /var/www/<name>/
17// 2. Generate and upload Caddyfile
18// 3. Reload Caddy
19func deployStaticV2(ctx *deployContext) *output.ErrorResponse {
20 client, err := ssh.Connect(ctx.SSHHost)
21 if err != nil {
22 return output.Err(output.ErrSSHConnectFailed, err.Error())
23 }
24 defer client.Close()
25
26 name := ctx.Name
27 remotePath := fmt.Sprintf("/var/www/%s", name)
28
29 // Create directory and set ownership for upload
30 user, _ := client.Run("whoami")
31 user = strings.TrimSpace(user)
32 if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", remotePath)); err != nil {
33 return output.Err(output.ErrUploadFailed, "failed to create directory: "+err.Error())
34 }
35 if _, err := client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", user, user, remotePath)); err != nil {
36 return output.Err(output.ErrUploadFailed, "failed to set directory ownership: "+err.Error())
37 }
38
39 // Upload files using rsync
40 if err := client.UploadDir(ctx.Path, remotePath); err != nil {
41 return output.Err(output.ErrUploadFailed, err.Error())
42 }
43
44 // Set ownership back to www-data
45 if _, err := client.RunSudo(fmt.Sprintf("chown -R www-data:www-data %s", remotePath)); err != nil {
46 // Non-fatal, continue
47 }
48
49 // Generate Caddyfile only if it doesn't exist (preserve manual edits)
50 caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name)
51 caddyExists, _ := client.Run(fmt.Sprintf("test -f %s && echo exists", caddyPath))
52 if strings.TrimSpace(caddyExists) != "exists" {
53 caddyfile, err := templates.StaticCaddy(map[string]string{
54 "Domain": ctx.URL[8:], // Strip https://
55 "RootDir": remotePath,
56 "Name": name,
57 })
58 if err != nil {
59 return output.Err(output.ErrCaddyFailed, "failed to generate Caddyfile: "+err.Error())
60 }
61
62 if err := client.WriteSudoFile(caddyPath, caddyfile); err != nil {
63 return output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error())
64 }
65 }
66
67 // Reload Caddy
68 if _, err := client.RunSudo("systemctl reload caddy"); err != nil {
69 return output.Err(output.ErrCaddyFailed, "failed to reload Caddy: "+err.Error())
70 }
71
72 return nil
73}
74
75// deployDockerV2 deploys a Docker-based app
76// 1. Allocate port
77// 2. rsync path to /var/lib/<name>/src/
78// 3. docker build
79// 4. Generate systemd unit and env file
80// 5. Generate Caddyfile
81// 6. Start service, reload Caddy
82func deployDockerV2(ctx *deployContext) *output.ErrorResponse {
83 client, err := ssh.Connect(ctx.SSHHost)
84 if err != nil {
85 return output.Err(output.ErrSSHConnectFailed, err.Error())
86 }
87 defer client.Close()
88
89 name := ctx.Name
90
91 // Allocate port on server
92 port, err := allocatePort(client, name)
93 if err != nil {
94 return output.Err(output.ErrServiceFailed, "failed to allocate port: "+err.Error())
95 }
96
97 srcPath := fmt.Sprintf("/var/lib/%s/src", name)
98 dataPath := fmt.Sprintf("/var/lib/%s/data", name)
99
100 // Create directories
101 if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s %s", srcPath, dataPath)); err != nil {
102 return output.Err(output.ErrUploadFailed, "failed to create directories: "+err.Error())
103 }
104
105 // Set ownership for upload
106 user, _ := client.Run("whoami")
107 user = strings.TrimSpace(user)
108 if _, err := client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", user, user, srcPath)); err != nil {
109 return output.Err(output.ErrUploadFailed, "failed to set directory ownership: "+err.Error())
110 }
111
112 // Upload source
113 if err := client.UploadDir(ctx.Path, srcPath); err != nil {
114 return output.Err(output.ErrUploadFailed, err.Error())
115 }
116
117 // Docker build
118 buildCmd := fmt.Sprintf("docker build -t %s:latest %s", name, srcPath)
119 if _, err := client.RunSudo(buildCmd); err != nil {
120 return output.Err(output.ErrBuildFailed, err.Error())
121 }
122
123 // Determine container port
124 containerPort := ctx.Opts.ContainerPort
125 if containerPort == 0 {
126 containerPort = 80
127 }
128
129 // Generate and write env file
130 // Use containerPort so the app listens on the correct port inside the container
131 envContent := fmt.Sprintf("PORT=%d\nSHIP_NAME=%s\nSHIP_URL=%s\n", containerPort, name, ctx.URL)
132 for _, e := range ctx.Opts.Env {
133 envContent += e + "\n"
134 }
135 envPath := fmt.Sprintf("/etc/ship/env/%s.env", name)
136 if _, err := client.RunSudo("mkdir -p /etc/ship/env"); err != nil {
137 // Continue, directory might exist
138 }
139 if err := client.WriteSudoFile(envPath, envContent); err != nil {
140 return output.Err(output.ErrServiceFailed, "failed to write env file: "+err.Error())
141 }
142
143 // Generate systemd unit
144 service, err := templates.DockerService(map[string]string{
145 "Name": name,
146 "Port": strconv.Itoa(port),
147 "ContainerPort": strconv.Itoa(containerPort),
148 })
149 if err != nil {
150 return output.Err(output.ErrServiceFailed, "failed to generate systemd unit: "+err.Error())
151 }
152
153 servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name)
154 if err := client.WriteSudoFile(servicePath, service); err != nil {
155 return output.Err(output.ErrServiceFailed, "failed to write systemd unit: "+err.Error())
156 }
157
158 // Generate Caddyfile only if it doesn't exist (preserve manual edits)
159 caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name)
160 caddyExists, _ := client.Run(fmt.Sprintf("test -f %s && echo exists", caddyPath))
161 if strings.TrimSpace(caddyExists) != "exists" {
162 caddyfile, err := templates.AppCaddy(map[string]string{
163 "Domain": ctx.URL[8:], // Strip https://
164 "Port": strconv.Itoa(port),
165 })
166 if err != nil {
167 return output.Err(output.ErrCaddyFailed, "failed to generate Caddyfile: "+err.Error())
168 }
169
170 if err := client.WriteSudoFile(caddyPath, caddyfile); err != nil {
171 return output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error())
172 }
173 }
174
175 // Reload systemd and start service
176 if _, err := client.RunSudo("systemctl daemon-reload"); err != nil {
177 return output.Err(output.ErrServiceFailed, "failed to reload systemd: "+err.Error())
178 }
179 if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil {
180 return output.Err(output.ErrServiceFailed, "failed to start service: "+err.Error())
181 }
182
183 // Reload Caddy
184 if _, err := client.RunSudo("systemctl reload caddy"); err != nil {
185 return output.Err(output.ErrCaddyFailed, "failed to reload Caddy: "+err.Error())
186 }
187
188 return nil
189}
190
191// deployBinaryV2 deploys a pre-built binary
192// 1. Allocate port
193// 2. scp binary to /usr/local/bin/<name>
194// 3. Create user for service
195// 4. Generate systemd unit and env file
196// 5. Generate Caddyfile
197// 6. Start service, reload Caddy
198func deployBinaryV2(ctx *deployContext) *output.ErrorResponse {
199 client, err := ssh.Connect(ctx.SSHHost)
200 if err != nil {
201 return output.Err(output.ErrSSHConnectFailed, err.Error())
202 }
203 defer client.Close()
204
205 name := ctx.Name
206
207 // Allocate port on server
208 port, err := allocatePort(client, name)
209 if err != nil {
210 return output.Err(output.ErrServiceFailed, "failed to allocate port: "+err.Error())
211 }
212
213 binaryPath := fmt.Sprintf("/usr/local/bin/%s", name)
214 workDir := fmt.Sprintf("/var/lib/%s", name)
215
216 // Upload binary
217 if err := client.Upload(ctx.Path, "/tmp/"+name); err != nil {
218 return output.Err(output.ErrUploadFailed, err.Error())
219 }
220
221 // Move to final location and set permissions
222 if _, err := client.RunSudo(fmt.Sprintf("mv /tmp/%s %s && chmod +x %s", name, binaryPath, binaryPath)); err != nil {
223 return output.Err(output.ErrUploadFailed, "failed to install binary: "+err.Error())
224 }
225
226 // Create work directory
227 if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", workDir)); err != nil {
228 return output.Err(output.ErrServiceFailed, "failed to create work directory: "+err.Error())
229 }
230
231 // Create service user (ignore error if exists)
232 client.RunSudo(fmt.Sprintf("useradd -r -s /bin/false %s 2>/dev/null || true", name))
233 client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", name, name, workDir))
234
235 // Generate and write env file
236 envContent := fmt.Sprintf("PORT=%d\nSHIP_NAME=%s\nSHIP_URL=%s\n", port, name, ctx.URL)
237 for _, e := range ctx.Opts.Env {
238 envContent += e + "\n"
239 }
240 envPath := fmt.Sprintf("/etc/ship/env/%s.env", name)
241 if _, err := client.RunSudo("mkdir -p /etc/ship/env"); err != nil {
242 // Continue
243 }
244 if err := client.WriteSudoFile(envPath, envContent); err != nil {
245 return output.Err(output.ErrServiceFailed, "failed to write env file: "+err.Error())
246 }
247
248 // Generate systemd unit
249 service, err := templates.SystemdService(map[string]string{
250 "Name": name,
251 "User": name,
252 "WorkDir": workDir,
253 "EnvFile": envPath,
254 "BinaryPath": binaryPath,
255 "Args": "",
256 })
257 if err != nil {
258 return output.Err(output.ErrServiceFailed, "failed to generate systemd unit: "+err.Error())
259 }
260
261 servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name)
262 if err := client.WriteSudoFile(servicePath, service); err != nil {
263 return output.Err(output.ErrServiceFailed, "failed to write systemd unit: "+err.Error())
264 }
265
266 // Generate Caddyfile only if it doesn't exist (preserve manual edits)
267 caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name)
268 caddyExists, _ := client.Run(fmt.Sprintf("test -f %s && echo exists", caddyPath))
269 if strings.TrimSpace(caddyExists) != "exists" {
270 caddyfile, err := templates.AppCaddy(map[string]string{
271 "Domain": ctx.URL[8:], // Strip https://
272 "Port": strconv.Itoa(port),
273 })
274 if err != nil {
275 return output.Err(output.ErrCaddyFailed, "failed to generate Caddyfile: "+err.Error())
276 }
277
278 if err := client.WriteSudoFile(caddyPath, caddyfile); err != nil {
279 return output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error())
280 }
281 }
282
283 // Reload systemd and start service
284 if _, err := client.RunSudo("systemctl daemon-reload"); err != nil {
285 return output.Err(output.ErrServiceFailed, "failed to reload systemd: "+err.Error())
286 }
287 if _, err := client.RunSudo(fmt.Sprintf("systemctl enable --now %s", name)); err != nil {
288 return output.Err(output.ErrServiceFailed, "failed to start service: "+err.Error())
289 }
290
291 // Reload Caddy
292 if _, err := client.RunSudo("systemctl reload caddy"); err != nil {
293 return output.Err(output.ErrCaddyFailed, "failed to reload Caddy: "+err.Error())
294 }
295
296 return nil
297}
298
299// allocatePort allocates or retrieves a port for a service
300// Uses atomic increment on /etc/ship/next_port to avoid collisions
301func allocatePort(client *ssh.Client, name string) (int, error) {
302 portFile := fmt.Sprintf("/etc/ship/ports/%s", name)
303
304 // Try to read existing port for this app
305 out, err := client.Run(fmt.Sprintf("cat %s 2>/dev/null || echo ''", portFile))
306 if err == nil && out != "" {
307 out = strings.TrimSpace(out)
308 if port, err := strconv.Atoi(out); err == nil && port > 0 {
309 return port, nil
310 }
311 }
312
313 // Allocate new port atomically using flock
314 // Scans existing port files to avoid collisions even if next_port is stale
315 allocScript := `flock -x /etc/ship/.port.lock sh -c 'mkdir -p /etc/ship/ports; NEXT=$(cat /etc/ship/next_port 2>/dev/null || echo 9000); MAX=8999; for f in /etc/ship/ports/*; do [ -f "$f" ] && P=$(cat "$f" 2>/dev/null) && [ "$P" -gt "$MAX" ] 2>/dev/null && MAX=$P; done; PORT=$(( NEXT > MAX ? NEXT : MAX + 1 )); echo $((PORT + 1)) > /etc/ship/next_port; echo $PORT'`
316 out, err = client.RunSudo(allocScript)
317 if err != nil {
318 return 0, fmt.Errorf("failed to allocate port: %w", err)
319 }
320
321 port, err := strconv.Atoi(strings.TrimSpace(out))
322 if err != nil {
323 return 0, fmt.Errorf("invalid port allocated: %s", out)
324 }
325
326 // Write port allocation for this app
327 if err := client.WriteSudoFile(portFile, strconv.Itoa(port)); err != nil {
328 return 0, err
329 }
330
331 return port, nil
332}
333
334// setTTLV2 sets auto-expiry for a deploy
335func setTTLV2(ctx *deployContext, ttl time.Duration) error {
336 client, err := ssh.Connect(ctx.SSHHost)
337 if err != nil {
338 return err
339 }
340 defer client.Close()
341
342 expires := time.Now().Add(ttl).Unix()
343 ttlPath := fmt.Sprintf("/etc/ship/ttl/%s", ctx.Name)
344
345 if _, err := client.RunSudo("mkdir -p /etc/ship/ttl"); err != nil {
346 return err
347 }
348
349 return client.WriteSudoFile(ttlPath, strconv.FormatInt(expires, 10))
350}
351
352// runHealthCheck verifies the deploy is responding
353func runHealthCheck(url, endpoint string) (*output.HealthResult, *output.ErrorResponse) {
354 fullURL := url + endpoint
355
356 // Wait for app to start
357 time.Sleep(2 * time.Second)
358
359 var lastErr error
360 var lastStatus int
361
362 for i := 0; i < 15; i++ {
363 start := time.Now()
364 resp, err := http.Get(fullURL)
365 latency := time.Since(start).Milliseconds()
366
367 if err != nil {
368 lastErr = err
369 time.Sleep(2 * time.Second)
370 continue
371 }
372 resp.Body.Close()
373 lastStatus = resp.StatusCode
374
375 if resp.StatusCode >= 200 && resp.StatusCode < 400 {
376 return &output.HealthResult{
377 Endpoint: endpoint,
378 Status: resp.StatusCode,
379 LatencyMs: latency,
380 }, nil
381 }
382
383 time.Sleep(2 * time.Second)
384 }
385
386 msg := fmt.Sprintf("health check failed after 30s: ")
387 if lastErr != nil {
388 msg += lastErr.Error()
389 } else {
390 msg += fmt.Sprintf("status %d", lastStatus)
391 }
392
393 return nil, output.Err(output.ErrHealthCheckFailed, msg)
394}
diff --git a/cmd/ship/host.go b/cmd/ship/host.go
deleted file mode 100644
index b19c376..0000000
--- a/cmd/ship/host.go
+++ /dev/null
@@ -1,445 +0,0 @@
1package main
2
3import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "strings"
10
11 "github.com/bdw/ship/internal/output"
12 "github.com/bdw/ship/internal/ssh"
13 "github.com/bdw/ship/internal/state"
14 "github.com/bdw/ship/internal/templates"
15 "github.com/spf13/cobra"
16)
17
18func initHostV2() {
19 hostInitV2Cmd.Flags().String("domain", "", "Base domain for deployments (required)")
20 hostInitV2Cmd.MarkFlagRequired("domain")
21
22 hostV2Cmd.AddCommand(hostInitV2Cmd)
23 hostV2Cmd.AddCommand(hostStatusV2Cmd)
24}
25
26var hostInitV2Cmd = &cobra.Command{
27 Use: "init USER@HOST --domain DOMAIN",
28 Short: "Initialize a VPS for deployments",
29 Long: `Set up a fresh VPS with Caddy, Docker, and required directories.
30
31Example:
32 ship host init user@my-vps --domain example.com`,
33 Args: cobra.ExactArgs(1),
34 RunE: runHostInitV2,
35}
36
37func runHostInitV2(cmd *cobra.Command, args []string) error {
38 host := args[0]
39 domain, _ := cmd.Flags().GetString("domain")
40
41 if domain == "" {
42 output.PrintAndExit(output.Err(output.ErrInvalidArgs, "--domain is required"))
43 }
44
45 // Ensure SSH key exists
46 keyPath, pubkey, err := ensureSSHKey()
47 if err != nil {
48 output.PrintAndExit(output.Err(output.ErrSSHAuthFailed, "failed to setup SSH key: "+err.Error()))
49 }
50
51 // Try to connect first (to verify key is authorized)
52 client, err := ssh.Connect(host)
53 if err != nil {
54 // Connection failed - provide helpful error with pubkey
55 resp := map[string]interface{}{
56 "status": "error",
57 "code": "SSH_AUTH_FAILED",
58 "message": "SSH connection failed. Add this public key to your VPS authorized_keys:",
59 "public_key": pubkey,
60 "key_path": keyPath,
61 "host": host,
62 "setup_command": fmt.Sprintf("ssh-copy-id -i %s %s", keyPath, host),
63 }
64 printJSON(resp)
65 os.Exit(output.ExitSSHFailed)
66 }
67 defer client.Close()
68
69 // Detect OS
70 osRelease, err := client.Run("cat /etc/os-release")
71 if err != nil {
72 output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, "failed to detect OS: "+err.Error()))
73 }
74
75 if !strings.Contains(osRelease, "Ubuntu") && !strings.Contains(osRelease, "Debian") {
76 output.PrintAndExit(output.Err(output.ErrInvalidArgs, "unsupported OS (only Ubuntu and Debian are supported)"))
77 }
78
79 var installed []string
80
81 // Install Caddy if needed
82 if _, err := client.Run("which caddy"); err != nil {
83 if err := installCaddyV2(client); err != nil {
84 output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to install Caddy: "+err.Error()))
85 }
86 installed = append(installed, "caddy")
87 }
88
89 // Configure Caddy
90 caddyfile := `{
91}
92
93import /etc/caddy/sites-enabled/*
94`
95 if err := client.WriteSudoFile("/etc/caddy/Caddyfile", caddyfile); err != nil {
96 output.PrintAndExit(output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error()))
97 }
98
99 // Create directories
100 dirs := []string{
101 "/etc/ship/env",
102 "/etc/ship/ports",
103 "/etc/ship/ttl",
104 "/etc/caddy/sites-enabled",
105 "/var/www",
106 }
107 for _, dir := range dirs {
108 if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", dir)); err != nil {
109 output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to create directory: "+err.Error()))
110 }
111 }
112
113 // Install Docker
114 if _, err := client.Run("which docker"); err != nil {
115 if err := installDockerV2(client); err != nil {
116 output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to install Docker: "+err.Error()))
117 }
118 installed = append(installed, "docker")
119 }
120
121 // Install cleanup timer for TTL
122 if err := installCleanupTimer(client); err != nil {
123 // Non-fatal
124 }
125
126 // Enable and start services
127 if _, err := client.RunSudo("systemctl enable --now caddy docker"); err != nil {
128 output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to enable services: "+err.Error()))
129 }
130
131 // Save state
132 st, err := state.Load()
133 if err != nil {
134 output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to load state: "+err.Error()))
135 }
136
137 hostState := st.GetHost(host)
138 hostState.BaseDomain = domain
139
140 if st.GetDefaultHost() == "" {
141 st.SetDefaultHost(host)
142 }
143
144 if err := st.Save(); err != nil {
145 output.PrintAndExit(output.Err(output.ErrServiceFailed, "failed to save state: "+err.Error()))
146 }
147
148 // Success
149 output.PrintAndExit(&output.HostInitResponse{
150 Status: "ok",
151 Host: host,
152 Domain: domain,
153 Installed: installed,
154 })
155
156 return nil
157}
158
159func installCaddyV2(client *ssh.Client) error {
160 commands := []string{
161 "apt-get update",
162 "apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl gnupg",
163 "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' -o /tmp/caddy.gpg",
164 "gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg < /tmp/caddy.gpg",
165 "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' -o /etc/apt/sources.list.d/caddy-stable.list",
166 "apt-get update",
167 "apt-get install -y caddy",
168 }
169
170 for _, cmd := range commands {
171 if _, err := client.RunSudo(cmd); err != nil {
172 return fmt.Errorf("command failed: %s: %w", cmd, err)
173 }
174 }
175 return nil
176}
177
178func installDockerV2(client *ssh.Client) error {
179 commands := []string{
180 "apt-get install -y ca-certificates curl gnupg",
181 "install -m 0755 -d /etc/apt/keyrings",
182 "curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc",
183 "chmod a+r /etc/apt/keyrings/docker.asc",
184 `sh -c 'echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo ${VERSION_CODENAME}) stable" > /etc/apt/sources.list.d/docker.list'`,
185 "apt-get update",
186 "apt-get install -y docker-ce docker-ce-cli containerd.io",
187 }
188
189 for _, cmd := range commands {
190 if _, err := client.RunSudo(cmd); err != nil {
191 return fmt.Errorf("command failed: %s: %w", cmd, err)
192 }
193 }
194 return nil
195}
196
197func installCleanupTimer(client *ssh.Client) error {
198 // Cleanup script
199 script := `#!/bin/bash
200now=$(date +%s)
201for f in /etc/ship/ttl/*; do
202 [ -f "$f" ] || continue
203 name=$(basename "$f")
204 expires=$(cat "$f")
205 if [ "$now" -gt "$expires" ]; then
206 systemctl stop "$name" 2>/dev/null || true
207 systemctl disable "$name" 2>/dev/null || true
208 rm -f "/etc/systemd/system/${name}.service"
209 rm -f "/etc/caddy/sites-enabled/${name}.caddy"
210 rm -rf "/var/www/${name}"
211 rm -rf "/var/lib/${name}"
212 rm -f "/usr/local/bin/${name}"
213 rm -f "/etc/ship/env/${name}.env"
214 rm -f "/etc/ship/ports/${name}"
215 rm -f "/etc/ship/ttl/${name}"
216 docker rm -f "$name" 2>/dev/null || true
217 docker rmi "$name" 2>/dev/null || true
218 fi
219done
220systemctl daemon-reload
221systemctl reload caddy
222`
223 if err := client.WriteSudoFile("/usr/local/bin/ship-cleanup", script); err != nil {
224 return err
225 }
226 if _, err := client.RunSudo("chmod +x /usr/local/bin/ship-cleanup"); err != nil {
227 return err
228 }
229
230 // Timer unit
231 timer := `[Unit]
232Description=Ship TTL cleanup timer
233
234[Timer]
235OnCalendar=hourly
236Persistent=true
237
238[Install]
239WantedBy=timers.target
240`
241 if err := client.WriteSudoFile("/etc/systemd/system/ship-cleanup.timer", timer); err != nil {
242 return err
243 }
244
245 // Service unit
246 service := `[Unit]
247Description=Ship TTL cleanup
248
249[Service]
250Type=oneshot
251ExecStart=/usr/local/bin/ship-cleanup
252`
253 if err := client.WriteSudoFile("/etc/systemd/system/ship-cleanup.service", service); err != nil {
254 return err
255 }
256
257 // Enable timer
258 if _, err := client.RunSudo("systemctl daemon-reload"); err != nil {
259 return err
260 }
261 if _, err := client.RunSudo("systemctl enable --now ship-cleanup.timer"); err != nil {
262 return err
263 }
264
265 return nil
266}
267
268var hostStatusV2Cmd = &cobra.Command{
269 Use: "status",
270 Short: "Check host status",
271 RunE: func(cmd *cobra.Command, args []string) error {
272 st, err := state.Load()
273 if err != nil {
274 output.PrintAndExit(output.Err(output.ErrHostNotConfigured, err.Error()))
275 }
276
277 hostName := hostFlag
278 if hostName == "" {
279 hostName = st.DefaultHost
280 }
281 if hostName == "" {
282 output.PrintAndExit(output.Err(output.ErrHostNotConfigured, "no host specified"))
283 }
284
285 hostConfig := st.GetHost(hostName)
286
287 client, err := ssh.Connect(hostName)
288 if err != nil {
289 output.PrintAndExit(output.Err(output.ErrSSHConnectFailed, err.Error()))
290 }
291 defer client.Close()
292
293 // Check services
294 caddyStatus, _ := client.RunSudo("systemctl is-active caddy")
295 dockerStatus, _ := client.RunSudo("systemctl is-active docker")
296
297 // Print as JSON directly (custom response type)
298 fmt.Printf(`{"status":"ok","host":%q,"domain":%q,"caddy":%t,"docker":%t}`+"\n",
299 hostName,
300 hostConfig.BaseDomain,
301 strings.TrimSpace(caddyStatus) == "active",
302 strings.TrimSpace(dockerStatus) == "active",
303 )
304 return nil
305 },
306}
307
308// Preserve git setup functionality from v1 for advanced users
309func setupGitDeployV2(client *ssh.Client, baseDomain string, hostState *state.Host) *output.ErrorResponse {
310 // Install git, fcgiwrap, cgit
311 if _, err := client.RunSudo("apt-get install -y git fcgiwrap cgit"); err != nil {
312 return output.Err(output.ErrServiceFailed, "failed to install git tools: "+err.Error())
313 }
314
315 // Create git user
316 client.RunSudo("useradd -r -m -d /home/git -s $(which git-shell) git")
317 client.RunSudo("usermod -aG docker git")
318 client.RunSudo("usermod -aG git www-data")
319 client.RunSudo("usermod -aG www-data caddy")
320
321 // Copy SSH keys
322 copyKeysCommands := []string{
323 "mkdir -p /home/git/.ssh",
324 "cp ~/.ssh/authorized_keys /home/git/.ssh/authorized_keys",
325 "chown -R git:git /home/git/.ssh",
326 "chmod 700 /home/git/.ssh",
327 "chmod 600 /home/git/.ssh/authorized_keys",
328 }
329 for _, cmd := range copyKeysCommands {
330 if _, err := client.RunSudo(cmd); err != nil {
331 return output.Err(output.ErrServiceFailed, "failed to setup git SSH: "+err.Error())
332 }
333 }
334
335 // Create /srv/git
336 client.RunSudo("mkdir -p /srv/git")
337 client.RunSudo("chown git:git /srv/git")
338
339 // Sudoers
340 sudoersContent := `git ALL=(ALL) NOPASSWD: \
341 /bin/systemctl daemon-reload, \
342 /bin/systemctl reload caddy, \
343 /bin/systemctl restart [a-z]*, \
344 /bin/systemctl enable [a-z]*, \
345 /bin/cp /var/lib/*/src/.ship/service /etc/systemd/system/*.service, \
346 /bin/cp /var/lib/*/src/.ship/Caddyfile /etc/caddy/sites-enabled/*.caddy, \
347 /bin/cp /var/www/*/.ship/Caddyfile /etc/caddy/sites-enabled/*.caddy, \
348 /bin/mkdir -p /var/lib/*, \
349 /bin/mkdir -p /var/www/*, \
350 /bin/chown -R git\:git /var/lib/*, \
351 /bin/chown git\:git /var/www/*
352`
353 if err := client.WriteSudoFile("/etc/sudoers.d/ship-git", sudoersContent); err != nil {
354 return output.Err(output.ErrServiceFailed, "failed to write sudoers: "+err.Error())
355 }
356 client.RunSudo("chmod 440 /etc/sudoers.d/ship-git")
357
358 // Vanity import template
359 vanityHTML := `<!DOCTYPE html>
360<html><head>
361{{$path := trimPrefix "/" (placeholder "http.request.orig_uri.path")}}
362{{$parts := splitList "/" $path}}
363{{$module := first $parts}}
364<meta name="go-import" content="{{.Host}}/{{$module}} git https://{{.Host}}/{{$module}}.git">
365</head>
366<body>go get {{.Host}}/{{$module}}</body>
367</html>
368`
369 client.RunSudo("mkdir -p /opt/ship/vanity")
370 client.WriteSudoFile("/opt/ship/vanity/index.html", vanityHTML)
371
372 // cgit config
373 codeCaddyContent, _ := templates.CodeCaddy(map[string]string{"BaseDomain": baseDomain})
374 client.WriteSudoFile("/etc/caddy/sites-enabled/ship-code.caddy", codeCaddyContent)
375
376 cgitrcContent, _ := templates.CgitRC(map[string]string{"BaseDomain": baseDomain})
377 client.WriteSudoFile("/etc/cgitrc", cgitrcContent)
378 client.WriteSudoFile("/opt/ship/cgit-header.html", templates.CgitHeader())
379
380 // Start services
381 client.RunSudo("systemctl enable --now fcgiwrap")
382 client.RunSudo("systemctl restart caddy")
383
384 hostState.GitSetup = true
385 return nil
386}
387
388// ensureSSHKey checks for an existing SSH key or generates a new one.
389// Returns the key path, public key contents, and any error.
390func ensureSSHKey() (keyPath string, pubkey string, err error) {
391 home, err := os.UserHomeDir()
392 if err != nil {
393 return "", "", err
394 }
395
396 // Check common key locations
397 keyPaths := []string{
398 filepath.Join(home, ".ssh", "id_ed25519"),
399 filepath.Join(home, ".ssh", "id_rsa"),
400 filepath.Join(home, ".ssh", "id_ecdsa"),
401 }
402
403 for _, kp := range keyPaths {
404 pubPath := kp + ".pub"
405 if _, err := os.Stat(kp); err == nil {
406 if _, err := os.Stat(pubPath); err == nil {
407 // Key exists, read public key
408 pub, err := os.ReadFile(pubPath)
409 if err != nil {
410 continue
411 }
412 return kp, strings.TrimSpace(string(pub)), nil
413 }
414 }
415 }
416
417 // No key found, generate one
418 keyPath = filepath.Join(home, ".ssh", "id_ed25519")
419 sshDir := filepath.Dir(keyPath)
420
421 // Ensure .ssh directory exists
422 if err := os.MkdirAll(sshDir, 0700); err != nil {
423 return "", "", fmt.Errorf("failed to create .ssh directory: %w", err)
424 }
425
426 // Generate key
427 cmd := exec.Command("ssh-keygen", "-t", "ed25519", "-f", keyPath, "-N", "", "-C", "ship")
428 if err := cmd.Run(); err != nil {
429 return "", "", fmt.Errorf("failed to generate SSH key: %w", err)
430 }
431
432 // Read public key
433 pub, err := os.ReadFile(keyPath + ".pub")
434 if err != nil {
435 return "", "", fmt.Errorf("failed to read public key: %w", err)
436 }
437
438 return keyPath, strings.TrimSpace(string(pub)), nil
439}
440
441// printJSON outputs a value as JSON to stdout
442func printJSON(v interface{}) {
443 enc := json.NewEncoder(os.Stdout)
444 enc.Encode(v)
445}
diff --git a/cmd/ship/main.go b/cmd/ship/main.go
deleted file mode 100644
index 17516fb..0000000
--- a/cmd/ship/main.go
+++ /dev/null
@@ -1,10 +0,0 @@
1package main
2
3import "os"
4
5func main() {
6 initV2()
7 if err := rootV2Cmd.Execute(); err != nil {
8 os.Exit(1)
9 }
10}
diff --git a/cmd/ship/root.go b/cmd/ship/root.go
deleted file mode 100644
index aa81d1e..0000000
--- a/cmd/ship/root.go
+++ /dev/null
@@ -1,98 +0,0 @@
1package main
2
3import (
4 "os"
5
6 "github.com/bdw/ship/internal/output"
7 "github.com/spf13/cobra"
8)
9
10var hostFlag string
11
12// This file defines the v2 CLI structure.
13// The primary command is: ship [PATH] [FLAGS]
14// All output is JSON by default.
15
16var rootV2Cmd = &cobra.Command{
17 Use: "ship [PATH]",
18 Short: "Deploy code to a VPS. JSON output for agents.",
19 Long: `Ship deploys code to a VPS. Point it at a directory or binary, get a URL back.
20
21 ship ./myproject # auto-detect and deploy
22 ship ./site --name docs # deploy with specific name
23 ship ./api --health /healthz # deploy with health check
24 ship ./preview --ttl 24h # deploy with auto-expiry
25
26All output is JSON. Use --pretty for human-readable output.`,
27 Args: cobra.MaximumNArgs(1),
28 RunE: runDeployV2,
29 SilenceUsage: true,
30 SilenceErrors: true,
31 DisableAutoGenTag: true,
32}
33
34func initV2() {
35 // Global flags
36 rootV2Cmd.PersistentFlags().StringVar(&hostFlag, "host", "", "VPS host (SSH config alias or user@host)")
37 rootV2Cmd.PersistentFlags().BoolVar(&output.Pretty, "pretty", false, "Human-readable output")
38
39 // Deploy flags
40 rootV2Cmd.Flags().String("name", "", "Deploy name (becomes subdomain)")
41 rootV2Cmd.Flags().String("domain", "", "Custom domain for deployment")
42 rootV2Cmd.Flags().String("health", "", "Health check endpoint (e.g., /healthz)")
43 rootV2Cmd.Flags().String("ttl", "", "Auto-delete after duration (e.g., 1h, 7d)")
44 rootV2Cmd.Flags().StringArray("env", nil, "Environment variable (KEY=VALUE)")
45 rootV2Cmd.Flags().String("env-file", "", "Path to .env file")
46 rootV2Cmd.Flags().Int("container-port", 80, "Port the container listens on (Docker only)")
47
48 // Check for SHIP_PRETTY env var
49 if os.Getenv("SHIP_PRETTY") == "1" {
50 output.Pretty = true
51 }
52
53 // Add subcommands
54 rootV2Cmd.AddCommand(listV2Cmd)
55 rootV2Cmd.AddCommand(statusV2Cmd)
56 rootV2Cmd.AddCommand(logsV2Cmd)
57 rootV2Cmd.AddCommand(removeV2Cmd)
58 rootV2Cmd.AddCommand(hostV2Cmd)
59
60 // Initialize host subcommands (from host_v2.go)
61 initHostV2()
62}
63
64func runDeployV2(cmd *cobra.Command, args []string) error {
65 path := "."
66 if len(args) > 0 {
67 path = args[0]
68 }
69
70 opts := deployV2Options{
71 Host: hostFlag,
72 Pretty: output.Pretty,
73 }
74
75 // Get flag values
76 opts.Name, _ = cmd.Flags().GetString("name")
77 opts.Domain, _ = cmd.Flags().GetString("domain")
78 opts.Health, _ = cmd.Flags().GetString("health")
79 opts.TTL, _ = cmd.Flags().GetString("ttl")
80 opts.Env, _ = cmd.Flags().GetStringArray("env")
81 opts.EnvFile, _ = cmd.Flags().GetString("env-file")
82 opts.ContainerPort, _ = cmd.Flags().GetInt("container-port")
83
84 // deployV2 handles all output and exits
85 deployV2(path, opts)
86
87 // Should not reach here (deployV2 calls os.Exit)
88 return nil
89}
90
91// Subcommands (list, status, logs, remove) are defined in commands_v2.go
92
93var hostV2Cmd = &cobra.Command{
94 Use: "host",
95 Short: "Manage VPS host",
96}
97
98// hostInitV2Cmd, hostStatusV2Cmd, and initHostV2() are defined in host_v2.go