aboutsummaryrefslogtreecommitdiffstats
path: root/cmd
diff options
context:
space:
mode:
Diffstat (limited to 'cmd')
-rw-r--r--cmd/ship/commands_v2.go365
-rw-r--r--cmd/ship/deploy_impl_v2.go391
-rw-r--r--cmd/ship/deploy_v2.go210
-rw-r--r--cmd/ship/host_v2.go445
-rw-r--r--cmd/ship/main.go10
-rw-r--r--cmd/ship/root_v2.go98
-rw-r--r--cmd/ship/templates/webui.html440
7 files changed, 0 insertions, 1959 deletions
diff --git a/cmd/ship/commands_v2.go b/cmd/ship/commands_v2.go
deleted file mode 100644
index 1b0d09c..0000000
--- a/cmd/ship/commands_v2.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_impl_v2.go b/cmd/ship/deploy_impl_v2.go
deleted file mode 100644
index ec5c4a3..0000000
--- a/cmd/ship/deploy_impl_v2.go
+++ /dev/null
@@ -1,391 +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 // Generate and write env file
124 envContent := fmt.Sprintf("PORT=%d\nSHIP_NAME=%s\nSHIP_URL=%s\n", port, name, ctx.URL)
125 for _, e := range ctx.Opts.Env {
126 envContent += e + "\n"
127 }
128 envPath := fmt.Sprintf("/etc/ship/env/%s.env", name)
129 if _, err := client.RunSudo("mkdir -p /etc/ship/env"); err != nil {
130 // Continue, directory might exist
131 }
132 if err := client.WriteSudoFile(envPath, envContent); err != nil {
133 return output.Err(output.ErrServiceFailed, "failed to write env file: "+err.Error())
134 }
135
136 // Generate systemd unit
137 containerPort := ctx.Opts.ContainerPort
138 if containerPort == 0 {
139 containerPort = 80
140 }
141 service, err := templates.DockerService(map[string]string{
142 "Name": name,
143 "Port": strconv.Itoa(port),
144 "ContainerPort": strconv.Itoa(containerPort),
145 })
146 if err != nil {
147 return output.Err(output.ErrServiceFailed, "failed to generate systemd unit: "+err.Error())
148 }
149
150 servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name)
151 if err := client.WriteSudoFile(servicePath, service); err != nil {
152 return output.Err(output.ErrServiceFailed, "failed to write systemd unit: "+err.Error())
153 }
154
155 // Generate Caddyfile only if it doesn't exist (preserve manual edits)
156 caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name)
157 caddyExists, _ := client.Run(fmt.Sprintf("test -f %s && echo exists", caddyPath))
158 if strings.TrimSpace(caddyExists) != "exists" {
159 caddyfile, err := templates.AppCaddy(map[string]string{
160 "Domain": ctx.URL[8:], // Strip https://
161 "Port": strconv.Itoa(port),
162 })
163 if err != nil {
164 return output.Err(output.ErrCaddyFailed, "failed to generate Caddyfile: "+err.Error())
165 }
166
167 if err := client.WriteSudoFile(caddyPath, caddyfile); err != nil {
168 return output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error())
169 }
170 }
171
172 // Reload systemd and start service
173 if _, err := client.RunSudo("systemctl daemon-reload"); err != nil {
174 return output.Err(output.ErrServiceFailed, "failed to reload systemd: "+err.Error())
175 }
176 if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil {
177 return output.Err(output.ErrServiceFailed, "failed to start service: "+err.Error())
178 }
179
180 // Reload Caddy
181 if _, err := client.RunSudo("systemctl reload caddy"); err != nil {
182 return output.Err(output.ErrCaddyFailed, "failed to reload Caddy: "+err.Error())
183 }
184
185 return nil
186}
187
188// deployBinaryV2 deploys a pre-built binary
189// 1. Allocate port
190// 2. scp binary to /usr/local/bin/<name>
191// 3. Create user for service
192// 4. Generate systemd unit and env file
193// 5. Generate Caddyfile
194// 6. Start service, reload Caddy
195func deployBinaryV2(ctx *deployContext) *output.ErrorResponse {
196 client, err := ssh.Connect(ctx.SSHHost)
197 if err != nil {
198 return output.Err(output.ErrSSHConnectFailed, err.Error())
199 }
200 defer client.Close()
201
202 name := ctx.Name
203
204 // Allocate port on server
205 port, err := allocatePort(client, name)
206 if err != nil {
207 return output.Err(output.ErrServiceFailed, "failed to allocate port: "+err.Error())
208 }
209
210 binaryPath := fmt.Sprintf("/usr/local/bin/%s", name)
211 workDir := fmt.Sprintf("/var/lib/%s", name)
212
213 // Upload binary
214 if err := client.Upload(ctx.Path, "/tmp/"+name); err != nil {
215 return output.Err(output.ErrUploadFailed, err.Error())
216 }
217
218 // Move to final location and set permissions
219 if _, err := client.RunSudo(fmt.Sprintf("mv /tmp/%s %s && chmod +x %s", name, binaryPath, binaryPath)); err != nil {
220 return output.Err(output.ErrUploadFailed, "failed to install binary: "+err.Error())
221 }
222
223 // Create work directory
224 if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", workDir)); err != nil {
225 return output.Err(output.ErrServiceFailed, "failed to create work directory: "+err.Error())
226 }
227
228 // Create service user (ignore error if exists)
229 client.RunSudo(fmt.Sprintf("useradd -r -s /bin/false %s 2>/dev/null || true", name))
230 client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", name, name, workDir))
231
232 // Generate and write env file
233 envContent := fmt.Sprintf("PORT=%d\nSHIP_NAME=%s\nSHIP_URL=%s\n", port, name, ctx.URL)
234 for _, e := range ctx.Opts.Env {
235 envContent += e + "\n"
236 }
237 envPath := fmt.Sprintf("/etc/ship/env/%s.env", name)
238 if _, err := client.RunSudo("mkdir -p /etc/ship/env"); err != nil {
239 // Continue
240 }
241 if err := client.WriteSudoFile(envPath, envContent); err != nil {
242 return output.Err(output.ErrServiceFailed, "failed to write env file: "+err.Error())
243 }
244
245 // Generate systemd unit
246 service, err := templates.SystemdService(map[string]string{
247 "Name": name,
248 "User": name,
249 "WorkDir": workDir,
250 "EnvFile": envPath,
251 "BinaryPath": binaryPath,
252 "Args": "",
253 })
254 if err != nil {
255 return output.Err(output.ErrServiceFailed, "failed to generate systemd unit: "+err.Error())
256 }
257
258 servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", name)
259 if err := client.WriteSudoFile(servicePath, service); err != nil {
260 return output.Err(output.ErrServiceFailed, "failed to write systemd unit: "+err.Error())
261 }
262
263 // Generate Caddyfile only if it doesn't exist (preserve manual edits)
264 caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name)
265 caddyExists, _ := client.Run(fmt.Sprintf("test -f %s && echo exists", caddyPath))
266 if strings.TrimSpace(caddyExists) != "exists" {
267 caddyfile, err := templates.AppCaddy(map[string]string{
268 "Domain": ctx.URL[8:], // Strip https://
269 "Port": strconv.Itoa(port),
270 })
271 if err != nil {
272 return output.Err(output.ErrCaddyFailed, "failed to generate Caddyfile: "+err.Error())
273 }
274
275 if err := client.WriteSudoFile(caddyPath, caddyfile); err != nil {
276 return output.Err(output.ErrCaddyFailed, "failed to write Caddyfile: "+err.Error())
277 }
278 }
279
280 // Reload systemd and start service
281 if _, err := client.RunSudo("systemctl daemon-reload"); err != nil {
282 return output.Err(output.ErrServiceFailed, "failed to reload systemd: "+err.Error())
283 }
284 if _, err := client.RunSudo(fmt.Sprintf("systemctl enable --now %s", name)); err != nil {
285 return output.Err(output.ErrServiceFailed, "failed to start service: "+err.Error())
286 }
287
288 // Reload Caddy
289 if _, err := client.RunSudo("systemctl reload caddy"); err != nil {
290 return output.Err(output.ErrCaddyFailed, "failed to reload Caddy: "+err.Error())
291 }
292
293 return nil
294}
295
296// allocatePort allocates or retrieves a port for a service
297// Uses atomic increment on /etc/ship/next_port to avoid collisions
298func allocatePort(client *ssh.Client, name string) (int, error) {
299 portFile := fmt.Sprintf("/etc/ship/ports/%s", name)
300
301 // Try to read existing port for this app
302 out, err := client.Run(fmt.Sprintf("cat %s 2>/dev/null || echo ''", portFile))
303 if err == nil && out != "" {
304 out = strings.TrimSpace(out)
305 if port, err := strconv.Atoi(out); err == nil && port > 0 {
306 return port, nil
307 }
308 }
309
310 // Allocate new port atomically using flock
311 // Scans existing port files to avoid collisions even if next_port is stale
312 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'`
313 out, err = client.RunSudo(allocScript)
314 if err != nil {
315 return 0, fmt.Errorf("failed to allocate port: %w", err)
316 }
317
318 port, err := strconv.Atoi(strings.TrimSpace(out))
319 if err != nil {
320 return 0, fmt.Errorf("invalid port allocated: %s", out)
321 }
322
323 // Write port allocation for this app
324 if err := client.WriteSudoFile(portFile, strconv.Itoa(port)); err != nil {
325 return 0, err
326 }
327
328 return port, nil
329}
330
331// setTTLV2 sets auto-expiry for a deploy
332func setTTLV2(ctx *deployContext, ttl time.Duration) error {
333 client, err := ssh.Connect(ctx.SSHHost)
334 if err != nil {
335 return err
336 }
337 defer client.Close()
338
339 expires := time.Now().Add(ttl).Unix()
340 ttlPath := fmt.Sprintf("/etc/ship/ttl/%s", ctx.Name)
341
342 if _, err := client.RunSudo("mkdir -p /etc/ship/ttl"); err != nil {
343 return err
344 }
345
346 return client.WriteSudoFile(ttlPath, strconv.FormatInt(expires, 10))
347}
348
349// runHealthCheck verifies the deploy is responding
350func runHealthCheck(url, endpoint string) (*output.HealthResult, *output.ErrorResponse) {
351 fullURL := url + endpoint
352
353 // Wait for app to start
354 time.Sleep(2 * time.Second)
355
356 var lastErr error
357 var lastStatus int
358
359 for i := 0; i < 15; i++ {
360 start := time.Now()
361 resp, err := http.Get(fullURL)
362 latency := time.Since(start).Milliseconds()
363
364 if err != nil {
365 lastErr = err
366 time.Sleep(2 * time.Second)
367 continue
368 }
369 resp.Body.Close()
370 lastStatus = resp.StatusCode
371
372 if resp.StatusCode >= 200 && resp.StatusCode < 400 {
373 return &output.HealthResult{
374 Endpoint: endpoint,
375 Status: resp.StatusCode,
376 LatencyMs: latency,
377 }, nil
378 }
379
380 time.Sleep(2 * time.Second)
381 }
382
383 msg := fmt.Sprintf("health check failed after 30s: ")
384 if lastErr != nil {
385 msg += lastErr.Error()
386 } else {
387 msg += fmt.Sprintf("status %d", lastStatus)
388 }
389
390 return nil, output.Err(output.ErrHealthCheckFailed, msg)
391}
diff --git a/cmd/ship/deploy_v2.go b/cmd/ship/deploy_v2.go
deleted file mode 100644
index 7d498b2..0000000
--- a/cmd/ship/deploy_v2.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/host_v2.go b/cmd/ship/host_v2.go
deleted file mode 100644
index b19c376..0000000
--- a/cmd/ship/host_v2.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_v2.go b/cmd/ship/root_v2.go
deleted file mode 100644
index aa81d1e..0000000
--- a/cmd/ship/root_v2.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
diff --git a/cmd/ship/templates/webui.html b/cmd/ship/templates/webui.html
deleted file mode 100644
index 052d599..0000000
--- a/cmd/ship/templates/webui.html
+++ /dev/null
@@ -1,440 +0,0 @@
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>Deploy - Web UI</title>
7 <style>
8 * {
9 margin: 0;
10 padding: 0;
11 box-sizing: border-box;
12 }
13
14 body {
15 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
16 background: #f5f5f5;
17 color: #333;
18 line-height: 1.6;
19 }
20
21 header {
22 background: #2c3e50;
23 color: white;
24 padding: 1.5rem 2rem;
25 box-shadow: 0 2px 4px rgba(0,0,0,0.1);
26 }
27
28 header h1 {
29 font-size: 1.8rem;
30 font-weight: 600;
31 }
32
33 header p {
34 color: #bdc3c7;
35 margin-top: 0.25rem;
36 font-size: 0.9rem;
37 }
38
39 .container {
40 max-width: 1200px;
41 margin: 2rem auto;
42 padding: 0 2rem;
43 }
44
45 .empty-state {
46 text-align: center;
47 padding: 4rem 2rem;
48 background: white;
49 border-radius: 8px;
50 box-shadow: 0 2px 4px rgba(0,0,0,0.1);
51 }
52
53 .empty-state h2 {
54 color: #7f8c8d;
55 font-weight: 500;
56 margin-bottom: 0.5rem;
57 }
58
59 .empty-state p {
60 color: #95a5a6;
61 }
62
63 .host-section {
64 margin-bottom: 2rem;
65 }
66
67 .host-header {
68 background: white;
69 padding: 1rem 1.5rem;
70 border-radius: 8px 8px 0 0;
71 border-left: 4px solid #3498db;
72 box-shadow: 0 2px 4px rgba(0,0,0,0.1);
73 }
74
75 .host-header h2 {
76 font-size: 1.3rem;
77 color: #2c3e50;
78 font-weight: 600;
79 }
80
81 .apps-grid {
82 display: grid;
83 grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
84 gap: 1rem;
85 padding: 1rem;
86 background: #ecf0f1;
87 border-radius: 0 0 8px 8px;
88 }
89
90 .app-card {
91 background: white;
92 padding: 1.5rem;
93 border-radius: 6px;
94 box-shadow: 0 1px 3px rgba(0,0,0,0.1);
95 transition: transform 0.2s, box-shadow 0.2s;
96 }
97
98 .app-card:hover {
99 transform: translateY(-2px);
100 box-shadow: 0 4px 8px rgba(0,0,0,0.15);
101 }
102
103 .app-header {
104 display: flex;
105 justify-content: space-between;
106 align-items: center;
107 margin-bottom: 1rem;
108 }
109
110 .app-name {
111 font-size: 1.2rem;
112 font-weight: 600;
113 color: #2c3e50;
114 }
115
116 .app-type {
117 padding: 0.25rem 0.75rem;
118 border-radius: 12px;
119 font-size: 0.75rem;
120 font-weight: 500;
121 text-transform: uppercase;
122 }
123
124 .app-type.app {
125 background: #3498db;
126 color: white;
127 }
128
129 .app-type.static {
130 background: #2ecc71;
131 color: white;
132 }
133
134 .app-info {
135 margin-bottom: 0.5rem;
136 }
137
138 .app-info-label {
139 color: #7f8c8d;
140 font-size: 0.85rem;
141 font-weight: 500;
142 margin-bottom: 0.25rem;
143 }
144
145 .app-info-value {
146 color: #2c3e50;
147 font-family: 'Monaco', 'Courier New', monospace;
148 font-size: 0.9rem;
149 word-break: break-all;
150 }
151
152 .app-info-value a {
153 color: #3498db;
154 text-decoration: none;
155 }
156
157 .app-info-value a:hover {
158 text-decoration: underline;
159 }
160
161 .config-buttons {
162 margin-top: 1rem;
163 padding-top: 1rem;
164 border-top: 1px solid #ecf0f1;
165 display: flex;
166 gap: 0.5rem;
167 flex-wrap: wrap;
168 }
169
170 .config-btn {
171 padding: 0.4rem 0.8rem;
172 background: #3498db;
173 color: white;
174 border: none;
175 border-radius: 4px;
176 font-size: 0.8rem;
177 cursor: pointer;
178 transition: background 0.2s;
179 }
180
181 .config-btn:hover {
182 background: #2980b9;
183 }
184
185 .config-btn.secondary {
186 background: #95a5a6;
187 }
188
189 .config-btn.secondary:hover {
190 background: #7f8c8d;
191 }
192
193 .modal {
194 display: none;
195 position: fixed;
196 z-index: 1000;
197 left: 0;
198 top: 0;
199 width: 100%;
200 height: 100%;
201 overflow: auto;
202 background-color: rgba(0,0,0,0.6);
203 }
204
205 .modal.active {
206 display: block;
207 }
208
209 .modal-content {
210 background-color: #fefefe;
211 margin: 5% auto;
212 padding: 0;
213 border-radius: 8px;
214 width: 90%;
215 max-width: 900px;
216 max-height: 80vh;
217 display: flex;
218 flex-direction: column;
219 box-shadow: 0 4px 20px rgba(0,0,0,0.3);
220 }
221
222 .modal-header {
223 padding: 1.5rem;
224 border-bottom: 1px solid #ecf0f1;
225 display: flex;
226 justify-content: space-between;
227 align-items: center;
228 }
229
230 .modal-header h3 {
231 margin: 0;
232 color: #2c3e50;
233 }
234
235 .modal-path {
236 font-family: 'Monaco', 'Courier New', monospace;
237 font-size: 0.85rem;
238 color: #7f8c8d;
239 margin-top: 0.25rem;
240 }
241
242 .close {
243 color: #aaa;
244 font-size: 28px;
245 font-weight: bold;
246 cursor: pointer;
247 line-height: 1;
248 }
249
250 .close:hover {
251 color: #000;
252 }
253
254 .modal-body {
255 padding: 1.5rem;
256 overflow: auto;
257 flex: 1;
258 }
259
260 .config-content {
261 background: #282c34;
262 color: #abb2bf;
263 padding: 1rem;
264 border-radius: 4px;
265 font-family: 'Monaco', 'Courier New', monospace;
266 font-size: 0.85rem;
267 line-height: 1.5;
268 white-space: pre-wrap;
269 word-wrap: break-word;
270 overflow-x: auto;
271 text-align: left;
272 }
273
274 .loading {
275 text-align: center;
276 padding: 2rem;
277 color: #7f8c8d;
278 }
279
280 .refresh-info {
281 text-align: center;
282 color: #7f8c8d;
283 font-size: 0.9rem;
284 margin-top: 2rem;
285 padding: 1rem;
286 }
287 </style>
288</head>
289<body>
290 <header>
291 <h1>Deploy Web UI</h1>
292 <p>Manage your VPS deployments</p>
293 </header>
294
295 <div class="container">
296 {{if not .Hosts}}
297 <div class="empty-state">
298 <h2>No deployments found</h2>
299 <p>Use the CLI to deploy your first app or static site</p>
300 </div>
301 {{else}}
302 {{range .Hosts}}
303 <div class="host-section">
304 <div class="host-header">
305 <h2>{{.Host}}</h2>
306 </div>
307 <div class="apps-grid">
308 {{range .Apps}}
309 <div class="app-card">
310 <div class="app-header">
311 <div class="app-name">{{.Name}}</div>
312 <div class="app-type {{.Type}}">{{.Type}}</div>
313 </div>
314
315 <div class="app-info">
316 <div class="app-info-label">Domain</div>
317 <div class="app-info-value">
318 <a href="https://{{.Domain}}" target="_blank">{{.Domain}}</a>
319 </div>
320 </div>
321
322 {{if eq .Type "app"}}
323 <div class="app-info">
324 <div class="app-info-label">Port</div>
325 <div class="app-info-value">{{.Port}}</div>
326 </div>
327 {{end}}
328
329 <div class="config-buttons">
330 {{if eq .Type "app"}}
331 <button class="config-btn" onclick="showConfig('{{.Host}}', '{{.Name}}', 'systemd')">Systemd Unit</button>
332 {{end}}
333 <button class="config-btn" onclick="showConfig('{{.Host}}', '{{.Name}}', 'caddy')">Caddy Config</button>
334 {{if .Env}}
335 <button class="config-btn secondary" onclick="showConfig('{{.Host}}', '{{.Name}}', 'env')">Environment</button>
336 {{end}}
337 </div>
338 </div>
339 {{end}}
340 </div>
341 </div>
342 {{end}}
343 {{end}}
344
345 <div class="refresh-info">
346 Refresh the page to see latest changes
347 </div>
348 </div>
349
350 <!-- Modal -->
351 <div id="configModal" class="modal">
352 <div class="modal-content">
353 <div class="modal-header">
354 <div>
355 <h3 id="modalTitle">Configuration</h3>
356 <div class="modal-path" id="modalPath"></div>
357 </div>
358 <span class="close" onclick="closeModal()">&times;</span>
359 </div>
360 <div class="modal-body">
361 <div id="modalContent" class="loading">Loading...</div>
362 </div>
363 </div>
364 </div>
365
366 <script>
367 const modal = document.getElementById('configModal');
368 const modalTitle = document.getElementById('modalTitle');
369 const modalPath = document.getElementById('modalPath');
370 const modalContent = document.getElementById('modalContent');
371
372 function closeModal() {
373 modal.classList.remove('active');
374 }
375
376 window.onclick = function(event) {
377 if (event.target == modal) {
378 closeModal();
379 }
380 }
381
382 async function showConfig(host, app, type) {
383 modal.classList.add('active');
384 modalContent.innerHTML = '<div class="loading">Loading...</div>';
385
386 const titles = {
387 'systemd': 'Systemd Service Unit',
388 'caddy': 'Caddy Configuration',
389 'env': 'Environment Variables'
390 };
391
392 modalTitle.textContent = titles[type];
393
394 try {
395 const response = await fetch(`/api/configs?host=${encodeURIComponent(host)}&app=${encodeURIComponent(app)}`);
396 if (!response.ok) {
397 throw new Error(`HTTP error! status: ${response.status}`);
398 }
399 const configs = await response.json();
400
401 let content = '';
402 let path = '';
403
404 switch(type) {
405 case 'systemd':
406 content = configs.systemd || 'No systemd config available';
407 path = configs.systemdPath || '';
408 break;
409 case 'caddy':
410 content = configs.caddy || 'No Caddy config available';
411 path = configs.caddyPath || '';
412 break;
413 case 'env':
414 content = configs.env || 'No environment variables';
415 path = configs.envPath || '';
416 break;
417 }
418
419 modalPath.textContent = path;
420 modalContent.innerHTML = `<div class="config-content">${escapeHtml(content)}</div>`;
421 } catch (error) {
422 modalContent.innerHTML = `<div class="loading">Error loading config: ${error.message}</div>`;
423 }
424 }
425
426 function escapeHtml(text) {
427 const div = document.createElement('div');
428 div.textContent = text;
429 return div.innerHTML;
430 }
431
432 // Close modal with Escape key
433 document.addEventListener('keydown', function(event) {
434 if (event.key === 'Escape') {
435 closeModal();
436 }
437 });
438 </script>
439</body>
440</html>