summaryrefslogtreecommitdiffstats
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.go664
-rw-r--r--cmd/ship/deploy_cmd.go141
-rw-r--r--cmd/ship/deploy_impl_v2.go397
-rw-r--r--cmd/ship/deploy_v2.go210
-rw-r--r--cmd/ship/env/env.go17
-rw-r--r--cmd/ship/env/list.go72
-rw-r--r--cmd/ship/env/set.go135
-rw-r--r--cmd/ship/env/unset.go95
-rw-r--r--cmd/ship/host/host.go21
-rw-r--r--cmd/ship/host/init.go316
-rw-r--r--cmd/ship/host/set_domain.go76
-rw-r--r--cmd/ship/host/ssh.go45
-rw-r--r--cmd/ship/host/status.go108
-rw-r--r--cmd/ship/host/update.go93
-rw-r--r--cmd/ship/host_v2.go445
-rw-r--r--cmd/ship/init.go268
-rw-r--r--cmd/ship/list.go61
-rw-r--r--cmd/ship/logs.go78
-rw-r--r--cmd/ship/main.go9
-rw-r--r--cmd/ship/remove.go109
-rw-r--r--cmd/ship/restart.go60
-rw-r--r--cmd/ship/root.go97
-rw-r--r--cmd/ship/root_v2.go98
-rw-r--r--cmd/ship/status.go63
-rw-r--r--cmd/ship/ui.go199
-rw-r--r--cmd/ship/validate.go9
-rw-r--r--cmd/ship/version.go17
28 files changed, 1518 insertions, 2750 deletions
diff --git a/cmd/ship/commands_v2.go b/cmd/ship/commands_v2.go
new file mode 100644
index 0000000..1b0d09c
--- /dev/null
+++ b/cmd/ship/commands_v2.go
@@ -0,0 +1,365 @@
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 414ade5..0000000
--- a/cmd/ship/deploy.go
+++ /dev/null
@@ -1,664 +0,0 @@
1package main
2
3import (
4 "bufio"
5 "fmt"
6 "os"
7 "path/filepath"
8 "strconv"
9 "strings"
10
11 "github.com/bdw/ship/internal/ssh"
12 "github.com/bdw/ship/internal/state"
13 "github.com/bdw/ship/internal/templates"
14 "github.com/spf13/cobra"
15)
16
17// DeployOptions contains all options for deploying or updating an app
18type DeployOptions struct {
19 Host string
20 Domain string
21 Name string
22 Binary string
23 Dir string // for static sites
24 Port int
25 Args string
26 Files []string
27 Memory string
28 CPU string
29 Env map[string]string // merged env vars
30 IsUpdate bool
31}
32
33func runDeploy(cmd *cobra.Command, args []string) error {
34 flags := cmd.Flags()
35
36 // Parse CLI flags
37 binary, _ := flags.GetString("binary")
38 static, _ := flags.GetBool("static")
39 dir, _ := flags.GetString("dir")
40 domain, _ := flags.GetString("domain")
41 name, _ := flags.GetString("name")
42 port, _ := flags.GetInt("port")
43 envVars, _ := flags.GetStringArray("env")
44 envFile, _ := flags.GetString("env-file")
45 argsFlag, _ := flags.GetString("args")
46 files, _ := flags.GetStringArray("file")
47 memory, _ := flags.GetString("memory")
48 cpu, _ := flags.GetString("cpu")
49
50 // Get host from flag or state default
51 host := hostFlag
52 if host == "" {
53 st, err := state.Load()
54 if err != nil {
55 return fmt.Errorf("error loading state: %w", err)
56 }
57 host = st.GetDefaultHost()
58 }
59
60 // If no flags provided, show help
61 if domain == "" && binary == "" && !static && name == "" {
62 return cmd.Help()
63 }
64
65 if host == "" {
66 return fmt.Errorf("--host is required")
67 }
68
69 // Load state once - this will be used throughout
70 st, err := state.Load()
71 if err != nil {
72 return fmt.Errorf("error loading state: %w", err)
73 }
74 hostState := st.GetHost(host)
75
76 // Config update mode: --name provided without --binary or --static
77 if name != "" && binary == "" && !static {
78 existingApp, err := st.GetApp(host, name)
79 if err != nil {
80 return fmt.Errorf("app %s not found (use --binary to deploy a new app)", name)
81 }
82
83 // Build merged config starting from existing app
84 opts := DeployOptions{
85 Host: host,
86 Name: name,
87 Port: existingApp.Port,
88 Args: existingApp.Args,
89 Files: existingApp.Files,
90 Memory: existingApp.Memory,
91 CPU: existingApp.CPU,
92 Env: make(map[string]string),
93 }
94 for k, v := range existingApp.Env {
95 opts.Env[k] = v
96 }
97
98 // Override with CLI flags if provided
99 if argsFlag != "" {
100 opts.Args = argsFlag
101 }
102 if len(files) > 0 {
103 opts.Files = files
104 }
105 if memory != "" {
106 opts.Memory = memory
107 }
108 if cpu != "" {
109 opts.CPU = cpu
110 }
111
112 // Merge env vars (CLI overrides existing)
113 for _, e := range envVars {
114 parts := strings.SplitN(e, "=", 2)
115 if len(parts) == 2 {
116 opts.Env[parts[0]] = parts[1]
117 }
118 }
119 if envFile != "" {
120 fileEnv, err := parseEnvFile(envFile)
121 if err != nil {
122 return fmt.Errorf("error reading env file: %w", err)
123 }
124 for k, v := range fileEnv {
125 opts.Env[k] = v
126 }
127 }
128
129 return updateAppConfig(st, opts)
130 }
131
132 // Infer name early so we can use it for subdomain generation and existing app lookup
133 if name == "" {
134 if static {
135 name = domain
136 if name == "" && hostState.BaseDomain != "" {
137 name = filepath.Base(dir)
138 }
139 } else {
140 name = filepath.Base(binary)
141 }
142 }
143 if err := validateName(name); err != nil {
144 return err
145 }
146
147 // Check if this is an update to an existing app/site
148 existingApp, _ := st.GetApp(host, name)
149 isUpdate := existingApp != nil
150
151 // For new deployments, require domain or base domain
152 if !isUpdate && domain == "" && hostState.BaseDomain == "" {
153 return fmt.Errorf("--domain required (or configure base domain with 'ship host init --base-domain')")
154 }
155
156 // Build merged config, starting from existing app if updating
157 opts := DeployOptions{
158 Host: host,
159 Name: name,
160 Binary: binary,
161 Dir: dir,
162 IsUpdate: isUpdate,
163 }
164
165 // Merge domain: auto-subdomain + (user-provided or existing custom domain)
166 var domains []string
167 if hostState.BaseDomain != "" {
168 domains = append(domains, name+"."+hostState.BaseDomain)
169 }
170 if domain != "" {
171 domains = append(domains, domain)
172 } else if isUpdate && existingApp.Domain != "" {
173 for _, d := range strings.Split(existingApp.Domain, ",") {
174 d = strings.TrimSpace(d)
175 if d != "" && (hostState.BaseDomain == "" || !strings.HasSuffix(d, "."+hostState.BaseDomain)) {
176 domains = append(domains, d)
177 }
178 }
179 }
180 opts.Domain = strings.Join(domains, ", ")
181
182 // For apps, merge all config fields
183 if !static {
184 // Start with existing values if updating
185 if isUpdate {
186 opts.Port = existingApp.Port
187 opts.Args = existingApp.Args
188 opts.Files = existingApp.Files
189 opts.Memory = existingApp.Memory
190 opts.CPU = existingApp.CPU
191 opts.Env = make(map[string]string)
192 for k, v := range existingApp.Env {
193 opts.Env[k] = v
194 }
195 } else {
196 opts.Port = port
197 opts.Env = make(map[string]string)
198 }
199
200 // Override with CLI flags if provided
201 if argsFlag != "" {
202 opts.Args = argsFlag
203 }
204 if len(files) > 0 {
205 opts.Files = files
206 }
207 if memory != "" {
208 opts.Memory = memory
209 }
210 if cpu != "" {
211 opts.CPU = cpu
212 }
213
214 // Merge env vars (CLI overrides existing)
215 for _, e := range envVars {
216 parts := strings.SplitN(e, "=", 2)
217 if len(parts) == 2 {
218 opts.Env[parts[0]] = parts[1]
219 }
220 }
221 if envFile != "" {
222 fileEnv, err := parseEnvFile(envFile)
223 if err != nil {
224 return fmt.Errorf("error reading env file: %w", err)
225 }
226 for k, v := range fileEnv {
227 opts.Env[k] = v
228 }
229 }
230 }
231
232 if static {
233 return deployStatic(st, opts)
234 }
235 return deployApp(st, opts)
236}
237
238func deployApp(st *state.State, opts DeployOptions) error {
239 if opts.Binary == "" {
240 return fmt.Errorf("--binary is required")
241 }
242
243 if _, err := os.Stat(opts.Binary); err != nil {
244 return fmt.Errorf("binary not found: %s", opts.Binary)
245 }
246
247 fmt.Printf("Deploying app: %s\n", opts.Name)
248 fmt.Printf(" Domain(s): %s\n", opts.Domain)
249 fmt.Printf(" Binary: %s\n", opts.Binary)
250
251 // Allocate port for new apps
252 port := opts.Port
253 if opts.IsUpdate {
254 fmt.Printf(" Updating existing deployment (port %d)\n", port)
255 } else {
256 if port == 0 {
257 port = st.AllocatePort(opts.Host)
258 }
259 fmt.Printf(" Allocated port: %d\n", port)
260 }
261
262 // Add PORT to env
263 opts.Env["PORT"] = strconv.Itoa(port)
264
265 client, err := ssh.Connect(opts.Host)
266 if err != nil {
267 return fmt.Errorf("error connecting to VPS: %w", err)
268 }
269 defer client.Close()
270
271 fmt.Println("-> Uploading binary...")
272 remoteTmpPath := fmt.Sprintf("/tmp/%s", opts.Name)
273 if err := client.Upload(opts.Binary, remoteTmpPath); err != nil {
274 return fmt.Errorf("error uploading binary: %w", err)
275 }
276
277 fmt.Println("-> Creating system user...")
278 client.RunSudo(fmt.Sprintf("useradd -r -s /bin/false %s", opts.Name))
279
280 fmt.Println("-> Setting up directories...")
281 workDir := fmt.Sprintf("/var/lib/%s", opts.Name)
282 if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", workDir)); err != nil {
283 return fmt.Errorf("error creating work directory: %w", err)
284 }
285 if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", opts.Name, opts.Name, workDir)); err != nil {
286 return fmt.Errorf("error setting work directory ownership: %w", err)
287 }
288
289 fmt.Println("-> Installing binary...")
290 binaryDest := fmt.Sprintf("/usr/local/bin/%s", opts.Name)
291 if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", remoteTmpPath, binaryDest)); err != nil {
292 return fmt.Errorf("error moving binary: %w", err)
293 }
294 if _, err := client.RunSudo(fmt.Sprintf("chmod +x %s", binaryDest)); err != nil {
295 return fmt.Errorf("error making binary executable: %w", err)
296 }
297
298 if len(opts.Files) > 0 {
299 fmt.Println("-> Uploading config files...")
300 for _, file := range opts.Files {
301 if _, err := os.Stat(file); err != nil {
302 return fmt.Errorf("config file not found: %s", file)
303 }
304
305 remotePath := fmt.Sprintf("%s/%s", workDir, filepath.Base(file))
306 fileTmpPath := fmt.Sprintf("/tmp/%s_%s", opts.Name, filepath.Base(file))
307
308 if err := client.Upload(file, fileTmpPath); err != nil {
309 return fmt.Errorf("error uploading config file %s: %w", file, err)
310 }
311
312 if _, err := client.RunSudo(fmt.Sprintf("mv %s %s", fileTmpPath, remotePath)); err != nil {
313 return fmt.Errorf("error moving config file %s: %w", file, err)
314 }
315
316 if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", opts.Name, opts.Name, remotePath)); err != nil {
317 return fmt.Errorf("error setting config file ownership %s: %w", file, err)
318 }
319
320 fmt.Printf(" Uploaded: %s\n", file)
321 }
322 }
323
324 fmt.Println("-> Creating environment file...")
325 envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", opts.Name)
326 envContent := ""
327 for k, v := range opts.Env {
328 envContent += fmt.Sprintf("%s=%s\n", k, v)
329 }
330 if err := client.WriteSudoFile(envFilePath, envContent); err != nil {
331 return fmt.Errorf("error creating env file: %w", err)
332 }
333 if _, err := client.RunSudo(fmt.Sprintf("chmod 600 %s", envFilePath)); err != nil {
334 return fmt.Errorf("error setting env file permissions: %w", err)
335 }
336 if _, err := client.RunSudo(fmt.Sprintf("chown %s:%s %s", opts.Name, opts.Name, envFilePath)); err != nil {
337 return fmt.Errorf("error setting env file ownership: %w", err)
338 }
339
340 // Create local .ship directory for deployment configs if they don't exist
341 // (handles both initial deployment and migration of existing deployments)
342 if _, err := os.Stat(".ship/service"); os.IsNotExist(err) {
343 fmt.Println("-> Creating local .ship directory...")
344 if err := os.MkdirAll(".ship", 0755); err != nil {
345 return fmt.Errorf("error creating .ship directory: %w", err)
346 }
347
348 fmt.Println("-> Generating systemd service...")
349 serviceContent, err := templates.SystemdService(map[string]string{
350 "Name": opts.Name,
351 "User": opts.Name,
352 "WorkDir": workDir,
353 "BinaryPath": binaryDest,
354 "Port": strconv.Itoa(port),
355 "EnvFile": envFilePath,
356 "Args": opts.Args,
357 "Memory": opts.Memory,
358 "CPU": opts.CPU,
359 })
360 if err != nil {
361 return fmt.Errorf("error generating systemd unit: %w", err)
362 }
363 if err := os.WriteFile(".ship/service", []byte(serviceContent), 0644); err != nil {
364 return fmt.Errorf("error writing .ship/service: %w", err)
365 }
366 }
367
368 if _, err := os.Stat(".ship/Caddyfile"); os.IsNotExist(err) {
369 fmt.Println("-> Generating Caddyfile...")
370 caddyContent, err := templates.AppCaddy(map[string]string{
371 "Domain": opts.Domain,
372 "Port": strconv.Itoa(port),
373 })
374 if err != nil {
375 return fmt.Errorf("error generating Caddy config: %w", err)
376 }
377 if err := os.WriteFile(".ship/Caddyfile", []byte(caddyContent), 0644); err != nil {
378 return fmt.Errorf("error writing .ship/Caddyfile: %w", err)
379 }
380 }
381
382 // Upload systemd service from .ship/service
383 fmt.Println("-> Installing systemd service...")
384 serviceContent, err := os.ReadFile(".ship/service")
385 if err != nil {
386 return fmt.Errorf("error reading .ship/service: %w", err)
387 }
388 servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", opts.Name)
389 if err := client.WriteSudoFile(servicePath, string(serviceContent)); err != nil {
390 return fmt.Errorf("error installing systemd unit: %w", err)
391 }
392
393 // Upload Caddyfile from .ship/Caddyfile
394 fmt.Println("-> Installing Caddy config...")
395 caddyContent, err := os.ReadFile(".ship/Caddyfile")
396 if err != nil {
397 return fmt.Errorf("error reading .ship/Caddyfile: %w", err)
398 }
399 caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", opts.Name)
400 if err := client.WriteSudoFile(caddyPath, string(caddyContent)); err != nil {
401 return fmt.Errorf("error installing Caddy config: %w", err)
402 }
403
404 fmt.Println("-> Reloading systemd...")
405 if _, err := client.RunSudo("systemctl daemon-reload"); err != nil {
406 return fmt.Errorf("error reloading systemd: %w", err)
407 }
408
409 fmt.Println("-> Starting service...")
410 if _, err := client.RunSudo(fmt.Sprintf("systemctl enable %s", opts.Name)); err != nil {
411 return fmt.Errorf("error enabling service: %w", err)
412 }
413 if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", opts.Name)); err != nil {
414 return fmt.Errorf("error starting service: %w", err)
415 }
416
417 fmt.Println("-> Reloading Caddy...")
418 if _, err := client.RunSudo("systemctl reload caddy"); err != nil {
419 return fmt.Errorf("error reloading Caddy: %w", err)
420 }
421
422 st.AddApp(opts.Host, opts.Name, &state.App{
423 Type: "app",
424 Domain: opts.Domain,
425 Port: port,
426 Env: opts.Env,
427 Args: opts.Args,
428 Files: opts.Files,
429 Memory: opts.Memory,
430 CPU: opts.CPU,
431 })
432 if err := st.Save(); err != nil {
433 return fmt.Errorf("error saving state: %w", err)
434 }
435
436 fmt.Printf("\n App deployed successfully!\n")
437 // Show first domain in the URL message
438 primaryDomain := strings.Split(opts.Domain, ",")[0]
439 primaryDomain = strings.TrimSpace(primaryDomain)
440 fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", primaryDomain)
441 return nil
442}
443
444func updateAppConfig(st *state.State, opts DeployOptions) error {
445 existingApp, err := st.GetApp(opts.Host, opts.Name)
446 if err != nil {
447 return fmt.Errorf("app %s not found (use --binary to deploy a new app)", opts.Name)
448 }
449
450 if existingApp.Type != "app" && existingApp.Type != "git-app" {
451 return fmt.Errorf("%s is a static site, not an app", opts.Name)
452 }
453
454 fmt.Printf("Updating config: %s\n", opts.Name)
455
456 // Add PORT to env
457 opts.Env["PORT"] = strconv.Itoa(existingApp.Port)
458
459 client, err := ssh.Connect(opts.Host)
460 if err != nil {
461 return fmt.Errorf("error connecting to VPS: %w", err)
462 }
463 defer client.Close()
464
465 // Update env file
466 fmt.Println("-> Updating environment file...")
467 envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", opts.Name)
468 envContent := ""
469 for k, v := range opts.Env {
470 envContent += fmt.Sprintf("%s=%s\n", k, v)
471 }
472 if err := client.WriteSudoFile(envFilePath, envContent); err != nil {
473 return fmt.Errorf("error creating env file: %w", err)
474 }
475
476 // For git-app, the systemd unit comes from .ship/service in the repo,
477 // so we only update the env file and restart.
478 if existingApp.Type != "git-app" {
479 // Regenerate systemd unit to .ship/service (resource flags are being updated)
480 fmt.Println("-> Updating systemd service...")
481 workDir := fmt.Sprintf("/var/lib/%s", opts.Name)
482 binaryDest := fmt.Sprintf("/usr/local/bin/%s", opts.Name)
483 serviceContent, err := templates.SystemdService(map[string]string{
484 "Name": opts.Name,
485 "User": opts.Name,
486 "WorkDir": workDir,
487 "BinaryPath": binaryDest,
488 "Port": strconv.Itoa(existingApp.Port),
489 "EnvFile": envFilePath,
490 "Args": opts.Args,
491 "Memory": opts.Memory,
492 "CPU": opts.CPU,
493 })
494 if err != nil {
495 return fmt.Errorf("error generating systemd unit: %w", err)
496 }
497
498 // Write to local .ship/service
499 if err := os.MkdirAll(".ship", 0755); err != nil {
500 return fmt.Errorf("error creating .ship directory: %w", err)
501 }
502 if err := os.WriteFile(".ship/service", []byte(serviceContent), 0644); err != nil {
503 return fmt.Errorf("error writing .ship/service: %w", err)
504 }
505
506 // Upload to server
507 servicePath := fmt.Sprintf("/etc/systemd/system/%s.service", opts.Name)
508 if err := client.WriteSudoFile(servicePath, serviceContent); err != nil {
509 return fmt.Errorf("error installing systemd unit: %w", err)
510 }
511
512 fmt.Println("-> Reloading systemd...")
513 if _, err := client.RunSudo("systemctl daemon-reload"); err != nil {
514 return fmt.Errorf("error reloading systemd: %w", err)
515 }
516 }
517
518 fmt.Println("-> Restarting service...")
519 if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", opts.Name)); err != nil {
520 return fmt.Errorf("error restarting service: %w", err)
521 }
522
523 // Update state
524 existingApp.Args = opts.Args
525 existingApp.Memory = opts.Memory
526 existingApp.CPU = opts.CPU
527 existingApp.Env = opts.Env
528 if err := st.Save(); err != nil {
529 return fmt.Errorf("error saving state: %w", err)
530 }
531
532 fmt.Printf("\n Config updated successfully!\n")
533 return nil
534}
535
536func deployStatic(st *state.State, opts DeployOptions) error {
537 if _, err := os.Stat(opts.Dir); err != nil {
538 return fmt.Errorf("directory not found: %s", opts.Dir)
539 }
540
541 fmt.Printf("Deploying static site: %s\n", opts.Name)
542 fmt.Printf(" Domain(s): %s\n", opts.Domain)
543 fmt.Printf(" Directory: %s\n", opts.Dir)
544
545 if opts.IsUpdate {
546 fmt.Println(" Updating existing deployment")
547 }
548
549 client, err := ssh.Connect(opts.Host)
550 if err != nil {
551 return fmt.Errorf("error connecting to VPS: %w", err)
552 }
553 defer client.Close()
554
555 remoteDir := fmt.Sprintf("/var/www/%s", opts.Name)
556 fmt.Println("-> Creating remote directory...")
557 if _, err := client.RunSudo(fmt.Sprintf("mkdir -p %s", remoteDir)); err != nil {
558 return fmt.Errorf("error creating remote directory: %w", err)
559 }
560
561 currentUser, err := client.Run("whoami")
562 if err != nil {
563 return fmt.Errorf("error getting current user: %w", err)
564 }
565 currentUser = strings.TrimSpace(currentUser)
566
567 if _, err := client.RunSudo(fmt.Sprintf("chown -R %s:%s %s", currentUser, currentUser, remoteDir)); err != nil {
568 return fmt.Errorf("error setting temporary ownership: %w", err)
569 }
570
571 fmt.Println("-> Uploading files...")
572 if err := client.UploadDir(opts.Dir, remoteDir); err != nil {
573 return fmt.Errorf("error uploading files: %w", err)
574 }
575
576 fmt.Println("-> Setting permissions...")
577 if _, err := client.RunSudo(fmt.Sprintf("chown -R www-data:www-data %s", remoteDir)); err != nil {
578 return fmt.Errorf("error setting ownership: %w", err)
579 }
580 if _, err := client.RunSudo(fmt.Sprintf("chmod -R 755 %s", remoteDir)); err != nil {
581 return fmt.Errorf("error setting directory permissions: %w", err)
582 }
583 if _, err := client.RunSudo(fmt.Sprintf("find %s -type f -exec chmod 644 {} \\;", remoteDir)); err != nil {
584 return fmt.Errorf("error setting file permissions: %w", err)
585 }
586
587 // Create local .ship directory and Caddyfile for static sites if it doesn't exist
588 // (handles both initial deployment and migration of existing deployments)
589 shipDir := filepath.Join(opts.Dir, ".ship")
590 caddyfilePath := filepath.Join(shipDir, "Caddyfile")
591
592 if _, err := os.Stat(caddyfilePath); os.IsNotExist(err) {
593 fmt.Println("-> Creating local .ship directory...")
594 if err := os.MkdirAll(shipDir, 0755); err != nil {
595 return fmt.Errorf("error creating .ship directory: %w", err)
596 }
597
598 fmt.Println("-> Generating Caddyfile...")
599 caddyContent, err := templates.StaticCaddy(map[string]string{
600 "Domain": opts.Domain,
601 "RootDir": remoteDir,
602 })
603 if err != nil {
604 return fmt.Errorf("error generating Caddy config: %w", err)
605 }
606 if err := os.WriteFile(caddyfilePath, []byte(caddyContent), 0644); err != nil {
607 return fmt.Errorf("error writing .ship/Caddyfile: %w", err)
608 }
609 }
610
611 // Upload Caddyfile from .ship/Caddyfile
612 fmt.Println("-> Installing Caddy config...")
613 caddyContent, err := os.ReadFile(caddyfilePath)
614 if err != nil {
615 return fmt.Errorf("error reading .ship/Caddyfile: %w", err)
616 }
617 caddyPath := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", opts.Name)
618 if err := client.WriteSudoFile(caddyPath, string(caddyContent)); err != nil {
619 return fmt.Errorf("error installing Caddy config: %w", err)
620 }
621
622 fmt.Println("-> Reloading Caddy...")
623 if _, err := client.RunSudo("systemctl reload caddy"); err != nil {
624 return fmt.Errorf("error reloading Caddy: %w", err)
625 }
626
627 st.AddApp(opts.Host, opts.Name, &state.App{
628 Type: "static",
629 Domain: opts.Domain,
630 })
631 if err := st.Save(); err != nil {
632 return fmt.Errorf("error saving state: %w", err)
633 }
634
635 fmt.Printf("\n Static site deployed successfully!\n")
636 primaryDomain := strings.Split(opts.Domain, ",")[0]
637 primaryDomain = strings.TrimSpace(primaryDomain)
638 fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", primaryDomain)
639 return nil
640}
641
642func parseEnvFile(path string) (map[string]string, error) {
643 file, err := os.Open(path)
644 if err != nil {
645 return nil, err
646 }
647 defer file.Close()
648
649 env := make(map[string]string)
650 scanner := bufio.NewScanner(file)
651 for scanner.Scan() {
652 line := strings.TrimSpace(scanner.Text())
653 if line == "" || strings.HasPrefix(line, "#") {
654 continue
655 }
656
657 parts := strings.SplitN(line, "=", 2)
658 if len(parts) == 2 {
659 env[parts[0]] = parts[1]
660 }
661 }
662
663 return env, scanner.Err()
664}
diff --git a/cmd/ship/deploy_cmd.go b/cmd/ship/deploy_cmd.go
deleted file mode 100644
index ba45c4f..0000000
--- a/cmd/ship/deploy_cmd.go
+++ /dev/null
@@ -1,141 +0,0 @@
1package main
2
3import (
4 "fmt"
5
6 "github.com/bdw/ship/internal/ssh"
7 "github.com/bdw/ship/internal/state"
8 "github.com/spf13/cobra"
9)
10
11var deployGitCmd = &cobra.Command{
12 Use: "deploy <name>",
13 Short: "Manually rebuild and deploy a git-deployed app",
14 Long: `Trigger a manual rebuild from the latest code in the git repo.
15
16This runs the same steps as the post-receive hook: checkout code,
17install .ship/ configs, docker build (for apps), and restart.
18
19Examples:
20 ship deploy myapp`,
21 Args: cobra.ExactArgs(1),
22 RunE: runDeployGit,
23}
24
25func runDeployGit(cmd *cobra.Command, args []string) error {
26 name := args[0]
27 if err := validateName(name); err != nil {
28 return err
29 }
30
31 st, err := state.Load()
32 if err != nil {
33 return fmt.Errorf("error loading state: %w", err)
34 }
35
36 host := hostFlag
37 if host == "" {
38 host = st.GetDefaultHost()
39 }
40 if host == "" {
41 return fmt.Errorf("--host is required")
42 }
43
44 app, err := st.GetApp(host, name)
45 if err != nil {
46 return err
47 }
48
49 if app.Type != "git-app" && app.Type != "git-static" {
50 return fmt.Errorf("%s is not a git-deployed app (type: %s)", name, app.Type)
51 }
52
53 fmt.Printf("Deploying %s...\n", name)
54
55 client, err := ssh.Connect(host)
56 if err != nil {
57 return fmt.Errorf("error connecting to VPS: %w", err)
58 }
59 defer client.Close()
60
61 if app.Type == "git-app" {
62 if err := deployGitApp(client, name); err != nil {
63 return err
64 }
65 } else {
66 if err := deployGitStatic(client, name); err != nil {
67 return err
68 }
69 }
70
71 fmt.Println("\nDeploy complete!")
72 return nil
73}
74
75func deployGitApp(client *ssh.Client, name string) error {
76 repo := fmt.Sprintf("/srv/git/%s.git", name)
77 src := fmt.Sprintf("/var/lib/%s/src", name)
78
79 fmt.Println("-> Checking out code...")
80 if _, err := client.RunSudo(fmt.Sprintf("git --work-tree=%s --git-dir=%s checkout -f main", src, repo)); err != nil {
81 return fmt.Errorf("error checking out code: %w", err)
82 }
83
84 // Install deployment config from repo
85 serviceSrc := fmt.Sprintf("%s/.ship/service", src)
86 serviceDst := fmt.Sprintf("/etc/systemd/system/%s.service", name)
87 fmt.Println("-> Installing systemd unit...")
88 if _, err := client.RunSudo(fmt.Sprintf("cp %s %s", serviceSrc, serviceDst)); err != nil {
89 fmt.Printf(" Warning: no .ship/service found, skipping\n")
90 } else {
91 if _, err := client.RunSudo("systemctl daemon-reload"); err != nil {
92 return fmt.Errorf("error reloading systemd: %w", err)
93 }
94 }
95
96 caddySrc := fmt.Sprintf("%s/.ship/Caddyfile", src)
97 caddyDst := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name)
98 fmt.Println("-> Installing Caddy config...")
99 if _, err := client.RunSudo(fmt.Sprintf("cp %s %s", caddySrc, caddyDst)); err != nil {
100 fmt.Printf(" Warning: no .ship/Caddyfile found, skipping\n")
101 } else {
102 if _, err := client.RunSudo("systemctl reload caddy"); err != nil {
103 return fmt.Errorf("error reloading Caddy: %w", err)
104 }
105 }
106
107 fmt.Println("-> Building Docker image...")
108 if err := client.RunSudoStream(fmt.Sprintf("docker build -t %s:latest %s", name, src)); err != nil {
109 return fmt.Errorf("error building Docker image: %w", err)
110 }
111
112 fmt.Println("-> Restarting service...")
113 if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil {
114 return fmt.Errorf("error restarting service: %w", err)
115 }
116
117 return nil
118}
119
120func deployGitStatic(client *ssh.Client, name string) error {
121 repo := fmt.Sprintf("/srv/git/%s.git", name)
122 webroot := fmt.Sprintf("/var/www/%s", name)
123
124 fmt.Println("-> Deploying static site...")
125 if _, err := client.RunSudo(fmt.Sprintf("git --work-tree=%s --git-dir=%s checkout -f main", webroot, repo)); err != nil {
126 return fmt.Errorf("error checking out code: %w", err)
127 }
128
129 caddySrc := fmt.Sprintf("%s/.ship/Caddyfile", webroot)
130 caddyDst := fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", name)
131 fmt.Println("-> Installing Caddy config...")
132 if _, err := client.RunSudo(fmt.Sprintf("cp %s %s", caddySrc, caddyDst)); err != nil {
133 fmt.Printf(" Warning: no .ship/Caddyfile found, skipping\n")
134 } else {
135 if _, err := client.RunSudo("systemctl reload caddy"); err != nil {
136 return fmt.Errorf("error reloading Caddy: %w", err)
137 }
138 }
139
140 return nil
141}
diff --git a/cmd/ship/deploy_impl_v2.go b/cmd/ship/deploy_impl_v2.go
new file mode 100644
index 0000000..5b68dc3
--- /dev/null
+++ b/cmd/ship/deploy_impl_v2.go
@@ -0,0 +1,397 @@
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 // This reads next_port, increments it, and writes back while holding a lock
312 allocScript := `
313flock -x /etc/ship/.port.lock sh -c '
314 mkdir -p /etc/ship/ports
315 PORT=$(cat /etc/ship/next_port 2>/dev/null || echo 9000)
316 echo $((PORT + 1)) > /etc/ship/next_port
317 echo $PORT
318'`
319 out, err = client.RunSudo(allocScript)
320 if err != nil {
321 return 0, fmt.Errorf("failed to allocate port: %w", err)
322 }
323
324 port, err := strconv.Atoi(strings.TrimSpace(out))
325 if err != nil {
326 return 0, fmt.Errorf("invalid port allocated: %s", out)
327 }
328
329 // Write port allocation for this app
330 if err := client.WriteSudoFile(portFile, strconv.Itoa(port)); err != nil {
331 return 0, err
332 }
333
334 return port, nil
335}
336
337// setTTLV2 sets auto-expiry for a deploy
338func setTTLV2(ctx *deployContext, ttl time.Duration) error {
339 client, err := ssh.Connect(ctx.SSHHost)
340 if err != nil {
341 return err
342 }
343 defer client.Close()
344
345 expires := time.Now().Add(ttl).Unix()
346 ttlPath := fmt.Sprintf("/etc/ship/ttl/%s", ctx.Name)
347
348 if _, err := client.RunSudo("mkdir -p /etc/ship/ttl"); err != nil {
349 return err
350 }
351
352 return client.WriteSudoFile(ttlPath, strconv.FormatInt(expires, 10))
353}
354
355// runHealthCheck verifies the deploy is responding
356func runHealthCheck(url, endpoint string) (*output.HealthResult, *output.ErrorResponse) {
357 fullURL := url + endpoint
358
359 // Wait for app to start
360 time.Sleep(2 * time.Second)
361
362 var lastErr error
363 var lastStatus int
364
365 for i := 0; i < 15; i++ {
366 start := time.Now()
367 resp, err := http.Get(fullURL)
368 latency := time.Since(start).Milliseconds()
369
370 if err != nil {
371 lastErr = err
372 time.Sleep(2 * time.Second)
373 continue
374 }
375 resp.Body.Close()
376 lastStatus = resp.StatusCode
377
378 if resp.StatusCode >= 200 && resp.StatusCode < 400 {
379 return &output.HealthResult{
380 Endpoint: endpoint,
381 Status: resp.StatusCode,
382 LatencyMs: latency,
383 }, nil
384 }
385
386 time.Sleep(2 * time.Second)
387 }
388
389 msg := fmt.Sprintf("health check failed after 30s: ")
390 if lastErr != nil {
391 msg += lastErr.Error()
392 } else {
393 msg += fmt.Sprintf("status %d", lastStatus)
394 }
395
396 return nil, output.Err(output.ErrHealthCheckFailed, msg)
397}
diff --git a/cmd/ship/deploy_v2.go b/cmd/ship/deploy_v2.go
new file mode 100644
index 0000000..7d498b2
--- /dev/null
+++ b/cmd/ship/deploy_v2.go
@@ -0,0 +1,210 @@
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/env/env.go b/cmd/ship/env/env.go
deleted file mode 100644
index 489353a..0000000
--- a/cmd/ship/env/env.go
+++ /dev/null
@@ -1,17 +0,0 @@
1package env
2
3import (
4 "github.com/spf13/cobra"
5)
6
7var Cmd = &cobra.Command{
8 Use: "env",
9 Short: "Manage environment variables",
10 Long: "Manage environment variables for deployed applications",
11}
12
13func init() {
14 Cmd.AddCommand(listCmd)
15 Cmd.AddCommand(setCmd)
16 Cmd.AddCommand(unsetCmd)
17}
diff --git a/cmd/ship/env/list.go b/cmd/ship/env/list.go
deleted file mode 100644
index e94b83a..0000000
--- a/cmd/ship/env/list.go
+++ /dev/null
@@ -1,72 +0,0 @@
1package env
2
3import (
4 "fmt"
5 "strings"
6
7 "github.com/bdw/ship/internal/state"
8 "github.com/spf13/cobra"
9)
10
11var listCmd = &cobra.Command{
12 Use: "list <app>",
13 Short: "List environment variables for an app",
14 Args: cobra.ExactArgs(1),
15 RunE: runList,
16}
17
18func runList(cmd *cobra.Command, args []string) error {
19 name := args[0]
20 if err := state.ValidateName(name); err != nil {
21 return err
22 }
23
24 st, err := state.Load()
25 if err != nil {
26 return fmt.Errorf("error loading state: %w", err)
27 }
28
29 host, _ := cmd.Flags().GetString("host")
30 if host == "" {
31 host = st.GetDefaultHost()
32 }
33
34 if host == "" {
35 return fmt.Errorf("--host is required")
36 }
37
38 app, err := st.GetApp(host, name)
39 if err != nil {
40 return err
41 }
42
43 if app.Type != "app" {
44 return fmt.Errorf("env is only available for apps, not static sites")
45 }
46
47 fmt.Printf("Environment variables for %s:\n\n", name)
48 if len(app.Env) == 0 {
49 fmt.Println(" (none)")
50 } else {
51 for k, v := range app.Env {
52 display := v
53 if isSensitive(k) {
54 display = "***"
55 }
56 fmt.Printf(" %s=%s\n", k, display)
57 }
58 }
59
60 return nil
61}
62
63func isSensitive(key string) bool {
64 key = strings.ToLower(key)
65 sensitiveWords := []string{"key", "secret", "password", "token", "api"}
66 for _, word := range sensitiveWords {
67 if strings.Contains(key, word) {
68 return true
69 }
70 }
71 return false
72}
diff --git a/cmd/ship/env/set.go b/cmd/ship/env/set.go
deleted file mode 100644
index d4292f3..0000000
--- a/cmd/ship/env/set.go
+++ /dev/null
@@ -1,135 +0,0 @@
1package env
2
3import (
4 "bufio"
5 "fmt"
6 "os"
7 "strings"
8
9 "github.com/bdw/ship/internal/ssh"
10 "github.com/bdw/ship/internal/state"
11 "github.com/spf13/cobra"
12)
13
14var setCmd = &cobra.Command{
15 Use: "set <app> KEY=VALUE...",
16 Short: "Set environment variable(s)",
17 Long: "Set one or more environment variables for an app. Variables are specified as KEY=VALUE pairs.",
18 Args: cobra.MinimumNArgs(2),
19 RunE: runSet,
20}
21
22func init() {
23 setCmd.Flags().StringP("file", "f", "", "Load environment from file")
24}
25
26func runSet(cmd *cobra.Command, args []string) error {
27 name := args[0]
28 if err := state.ValidateName(name); err != nil {
29 return err
30 }
31 envVars := args[1:]
32
33 st, err := state.Load()
34 if err != nil {
35 return fmt.Errorf("error loading state: %w", err)
36 }
37
38 host, _ := cmd.Flags().GetString("host")
39 if host == "" {
40 host = st.GetDefaultHost()
41 }
42
43 if host == "" {
44 return fmt.Errorf("--host is required")
45 }
46
47 app, err := st.GetApp(host, name)
48 if err != nil {
49 return err
50 }
51
52 if app.Type != "app" {
53 return fmt.Errorf("env is only available for apps, not static sites")
54 }
55
56 if app.Env == nil {
57 app.Env = make(map[string]string)
58 }
59
60 // Set variables from args
61 for _, e := range envVars {
62 parts := strings.SplitN(e, "=", 2)
63 if len(parts) == 2 {
64 app.Env[parts[0]] = parts[1]
65 fmt.Printf("Set %s\n", parts[0])
66 } else {
67 return fmt.Errorf("invalid format: %s (expected KEY=VALUE)", e)
68 }
69 }
70
71 // Set variables from file if provided
72 envFile, _ := cmd.Flags().GetString("file")
73 if envFile != "" {
74 fileEnv, err := parseEnvFile(envFile)
75 if err != nil {
76 return fmt.Errorf("error reading env file: %w", err)
77 }
78 for k, v := range fileEnv {
79 app.Env[k] = v
80 fmt.Printf("Set %s\n", k)
81 }
82 }
83
84 if err := st.Save(); err != nil {
85 return fmt.Errorf("error saving state: %w", err)
86 }
87
88 client, err := ssh.Connect(host)
89 if err != nil {
90 return fmt.Errorf("error connecting to VPS: %w", err)
91 }
92 defer client.Close()
93
94 fmt.Println("-> Updating environment file on VPS...")
95 envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", name)
96 envContent := ""
97 for k, v := range app.Env {
98 envContent += fmt.Sprintf("%s=%s\n", k, v)
99 }
100 if err := client.WriteSudoFile(envFilePath, envContent); err != nil {
101 return fmt.Errorf("error updating env file: %w", err)
102 }
103
104 fmt.Println("-> Restarting service...")
105 if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil {
106 return fmt.Errorf("error restarting service: %w", err)
107 }
108
109 fmt.Println("Environment variables updated successfully")
110 return nil
111}
112
113func parseEnvFile(path string) (map[string]string, error) {
114 file, err := os.Open(path)
115 if err != nil {
116 return nil, err
117 }
118 defer file.Close()
119
120 env := make(map[string]string)
121 scanner := bufio.NewScanner(file)
122 for scanner.Scan() {
123 line := strings.TrimSpace(scanner.Text())
124 if line == "" || strings.HasPrefix(line, "#") {
125 continue
126 }
127
128 parts := strings.SplitN(line, "=", 2)
129 if len(parts) == 2 {
130 env[parts[0]] = parts[1]
131 }
132 }
133
134 return env, scanner.Err()
135}
diff --git a/cmd/ship/env/unset.go b/cmd/ship/env/unset.go
deleted file mode 100644
index 8292f42..0000000
--- a/cmd/ship/env/unset.go
+++ /dev/null
@@ -1,95 +0,0 @@
1package env
2
3import (
4 "fmt"
5
6 "github.com/bdw/ship/internal/ssh"
7 "github.com/bdw/ship/internal/state"
8 "github.com/spf13/cobra"
9)
10
11var unsetCmd = &cobra.Command{
12 Use: "unset <app> KEY...",
13 Short: "Unset environment variable(s)",
14 Long: "Remove one or more environment variables from an app.",
15 Args: cobra.MinimumNArgs(2),
16 RunE: runUnset,
17}
18
19func runUnset(cmd *cobra.Command, args []string) error {
20 name := args[0]
21 if err := state.ValidateName(name); err != nil {
22 return err
23 }
24 keys := args[1:]
25
26 st, err := state.Load()
27 if err != nil {
28 return fmt.Errorf("error loading state: %w", err)
29 }
30
31 host, _ := cmd.Flags().GetString("host")
32 if host == "" {
33 host = st.GetDefaultHost()
34 }
35
36 if host == "" {
37 return fmt.Errorf("--host is required")
38 }
39
40 app, err := st.GetApp(host, name)
41 if err != nil {
42 return err
43 }
44
45 if app.Type != "app" {
46 return fmt.Errorf("env is only available for apps, not static sites")
47 }
48
49 if app.Env == nil {
50 return fmt.Errorf("no environment variables set")
51 }
52
53 changed := false
54 for _, key := range keys {
55 if _, exists := app.Env[key]; exists {
56 delete(app.Env, key)
57 changed = true
58 fmt.Printf("Unset %s\n", key)
59 } else {
60 fmt.Printf("Warning: %s not found\n", key)
61 }
62 }
63
64 if !changed {
65 return nil
66 }
67
68 if err := st.Save(); err != nil {
69 return fmt.Errorf("error saving state: %w", err)
70 }
71
72 client, err := ssh.Connect(host)
73 if err != nil {
74 return fmt.Errorf("error connecting to VPS: %w", err)
75 }
76 defer client.Close()
77
78 fmt.Println("-> Updating environment file on VPS...")
79 envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", name)
80 envContent := ""
81 for k, v := range app.Env {
82 envContent += fmt.Sprintf("%s=%s\n", k, v)
83 }
84 if err := client.WriteSudoFile(envFilePath, envContent); err != nil {
85 return fmt.Errorf("error updating env file: %w", err)
86 }
87
88 fmt.Println("-> Restarting service...")
89 if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil {
90 return fmt.Errorf("error restarting service: %w", err)
91 }
92
93 fmt.Println("Environment variables updated successfully")
94 return nil
95}
diff --git a/cmd/ship/host/host.go b/cmd/ship/host/host.go
deleted file mode 100644
index 81403f9..0000000
--- a/cmd/ship/host/host.go
+++ /dev/null
@@ -1,21 +0,0 @@
1package host
2
3import (
4 "github.com/spf13/cobra"
5)
6
7var Cmd = &cobra.Command{
8 Use: "host",
9 Short: "Manage VPS host",
10 Long: "Commands for managing and monitoring the VPS host",
11}
12
13func init() {
14 Cmd.AddCommand(initCmd)
15 Cmd.AddCommand(statusCmd)
16 Cmd.AddCommand(updateCmd)
17 Cmd.AddCommand(sshCmd)
18 Cmd.AddCommand(setDomainCmd)
19
20 initCmd.Flags().String("base-domain", "", "Base domain for auto-generated subdomains (e.g., apps.example.com)")
21}
diff --git a/cmd/ship/host/init.go b/cmd/ship/host/init.go
deleted file mode 100644
index cfa2795..0000000
--- a/cmd/ship/host/init.go
+++ /dev/null
@@ -1,316 +0,0 @@
1package host
2
3import (
4 "fmt"
5 "strings"
6
7 "github.com/bdw/ship/internal/ssh"
8 "github.com/bdw/ship/internal/state"
9 "github.com/bdw/ship/internal/templates"
10 "github.com/spf13/cobra"
11)
12
13var initCmd = &cobra.Command{
14 Use: "init",
15 Short: "Initialize VPS (one-time setup)",
16 Long: "Set up a fresh VPS with Caddy for automatic HTTPS and required directories",
17 RunE: runInit,
18}
19
20func runInit(cmd *cobra.Command, args []string) error {
21 st, err := state.Load()
22 if err != nil {
23 return fmt.Errorf("error loading state: %w", err)
24 }
25
26 host, _ := cmd.Flags().GetString("host")
27 if host == "" {
28 host = st.GetDefaultHost()
29 }
30 baseDomain, _ := cmd.Flags().GetString("base-domain")
31
32 if host == "" {
33 return fmt.Errorf("--host is required")
34 }
35
36 fmt.Printf("Initializing VPS: %s\n", host)
37
38 client, err := ssh.Connect(host)
39 if err != nil {
40 return fmt.Errorf("error connecting to VPS: %w", err)
41 }
42 defer client.Close()
43
44 fmt.Println("-> Detecting OS...")
45 osRelease, err := client.Run("cat /etc/os-release")
46 if err != nil {
47 return fmt.Errorf("error detecting OS: %w", err)
48 }
49
50 if !strings.Contains(osRelease, "Ubuntu") && !strings.Contains(osRelease, "Debian") {
51 return fmt.Errorf("unsupported OS (only Ubuntu and Debian are supported)")
52 }
53 fmt.Println(" Detected Ubuntu/Debian")
54
55 fmt.Println("-> Checking for Caddy...")
56 _, err = client.Run("which caddy")
57 if err == nil {
58 fmt.Println(" Caddy already installed")
59 } else {
60 fmt.Println(" Installing Caddy...")
61 if err := installCaddy(client); err != nil {
62 return err
63 }
64 fmt.Println(" Caddy installed")
65 }
66
67 fmt.Println("-> Configuring Caddy...")
68 caddyfile := `{
69}
70
71import /etc/caddy/sites-enabled/*
72`
73 if err := client.WriteSudoFile("/etc/caddy/Caddyfile", caddyfile); err != nil {
74 return fmt.Errorf("error creating Caddyfile: %w", err)
75 }
76 fmt.Println(" Caddyfile created")
77
78 fmt.Println("-> Creating directories...")
79 if _, err := client.RunSudo("mkdir -p /etc/ship/env"); err != nil {
80 return fmt.Errorf("error creating /etc/ship/env: %w", err)
81 }
82 if _, err := client.RunSudo("mkdir -p /etc/caddy/sites-enabled"); err != nil {
83 return fmt.Errorf("error creating /etc/caddy/sites-enabled: %w", err)
84 }
85 fmt.Println(" Directories created")
86
87 fmt.Println("-> Starting Caddy...")
88 if _, err := client.RunSudo("systemctl enable caddy"); err != nil {
89 return fmt.Errorf("error enabling Caddy: %w", err)
90 }
91 if _, err := client.RunSudo("systemctl restart caddy"); err != nil {
92 return fmt.Errorf("error starting Caddy: %w", err)
93 }
94 fmt.Println(" Caddy started")
95
96 fmt.Println("-> Verifying installation...")
97 output, err := client.RunSudo("systemctl is-active caddy")
98 if err != nil || strings.TrimSpace(output) != "active" {
99 fmt.Println(" Warning: Caddy may not be running properly")
100 } else {
101 fmt.Println(" Caddy is active")
102 }
103
104 hostState := st.GetHost(host)
105 if baseDomain != "" {
106 hostState.BaseDomain = baseDomain
107 fmt.Printf(" Base domain: %s\n", baseDomain)
108 }
109
110 // Git-centric deployment setup (gated on base domain)
111 if baseDomain != "" {
112 if err := setupGitDeploy(client, baseDomain, hostState); err != nil {
113 return err
114 }
115 }
116
117 if st.GetDefaultHost() == "" {
118 st.SetDefaultHost(host)
119 fmt.Printf(" Set %s as default host\n", host)
120 }
121 if err := st.Save(); err != nil {
122 return fmt.Errorf("error saving state: %w", err)
123 }
124
125 fmt.Println("\nVPS initialized successfully!")
126 fmt.Println("\nNext steps:")
127 fmt.Println(" 1. Deploy an app:")
128 fmt.Printf(" ship --binary ./myapp --domain api.example.com\n")
129 fmt.Println(" 2. Deploy a static site:")
130 fmt.Printf(" ship --static --dir ./dist --domain example.com\n")
131 if baseDomain != "" {
132 fmt.Println(" 3. Initialize a git-deployed app:")
133 fmt.Printf(" ship init myapp\n")
134 }
135 return nil
136}
137
138func setupGitDeploy(client *ssh.Client, baseDomain string, hostState *state.Host) error {
139 fmt.Println("-> Installing Docker...")
140 dockerCommands := []string{
141 "apt-get install -y ca-certificates curl gnupg",
142 "install -m 0755 -d /etc/apt/keyrings",
143 "curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc",
144 "chmod a+r /etc/apt/keyrings/docker.asc",
145 `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'`,
146 "apt-get update",
147 "apt-get install -y docker-ce docker-ce-cli containerd.io",
148 }
149 for _, cmd := range dockerCommands {
150 if _, err := client.RunSudo(cmd); err != nil {
151 return fmt.Errorf("error installing Docker: %w", err)
152 }
153 }
154 fmt.Println(" Docker installed")
155
156 fmt.Println("-> Installing git, fcgiwrap, and cgit...")
157 if _, err := client.RunSudo("apt-get install -y git fcgiwrap cgit"); err != nil {
158 return fmt.Errorf("error installing git/fcgiwrap/cgit: %w", err)
159 }
160 // Allow git-http-backend (runs as www-data) to access repos owned by git.
161 // Scoped to www-data only, not system-wide, to preserve CVE-2022-24765 protection.
162 // www-data's home is /var/www; ensure it can write .gitconfig there.
163 client.RunSudo("mkdir -p /var/www")
164 client.RunSudo("chown www-data:www-data /var/www")
165 if _, err := client.RunSudo("sudo -u www-data git config --global --add safe.directory '*'"); err != nil {
166 return fmt.Errorf("error setting git safe.directory: %w", err)
167 }
168 fmt.Println(" git, fcgiwrap, and cgit installed")
169
170 fmt.Println("-> Creating git user...")
171 // Create git user (ignore error if already exists)
172 client.RunSudo("useradd -r -m -d /home/git -s $(which git-shell) git")
173 if _, err := client.RunSudo("usermod -aG docker git"); err != nil {
174 return fmt.Errorf("error adding git user to docker group: %w", err)
175 }
176 // www-data needs to read git repos for git-http-backend
177 if _, err := client.RunSudo("usermod -aG git www-data"); err != nil {
178 return fmt.Errorf("error adding www-data to git group: %w", err)
179 }
180 // caddy needs to connect to fcgiwrap socket (owned by www-data)
181 if _, err := client.RunSudo("usermod -aG www-data caddy"); err != nil {
182 return fmt.Errorf("error adding caddy to www-data group: %w", err)
183 }
184 fmt.Println(" git user created")
185
186 fmt.Println("-> Copying SSH keys to git user...")
187 copyKeysCommands := []string{
188 "mkdir -p /home/git/.ssh",
189 "cp ~/.ssh/authorized_keys /home/git/.ssh/authorized_keys",
190 "chown -R git:git /home/git/.ssh",
191 "chmod 700 /home/git/.ssh",
192 "chmod 600 /home/git/.ssh/authorized_keys",
193 }
194 for _, cmd := range copyKeysCommands {
195 if _, err := client.RunSudo(cmd); err != nil {
196 return fmt.Errorf("error copying SSH keys: %w", err)
197 }
198 }
199 fmt.Println(" SSH keys copied")
200
201 fmt.Println("-> Creating /srv/git...")
202 if _, err := client.RunSudo("mkdir -p /srv/git"); err != nil {
203 return fmt.Errorf("error creating /srv/git: %w", err)
204 }
205 if _, err := client.RunSudo("chown git:git /srv/git"); err != nil {
206 return fmt.Errorf("error setting /srv/git ownership: %w", err)
207 }
208 fmt.Println(" /srv/git created")
209
210 fmt.Println("-> Writing sudoers for git user...")
211 sudoersContent := `# Ship git-deploy: allow post-receive hooks to install configs and manage services.
212# App names are validated to [a-z][a-z0-9-] before reaching this point.
213git ALL=(ALL) NOPASSWD: \
214 /bin/systemctl daemon-reload, \
215 /bin/systemctl reload caddy, \
216 /bin/systemctl restart [a-z]*, \
217 /bin/systemctl enable [a-z]*, \
218 /bin/cp /var/lib/*/src/.ship/service /etc/systemd/system/*.service, \
219 /bin/cp /var/lib/*/src/.ship/Caddyfile /etc/caddy/sites-enabled/*.caddy, \
220 /bin/cp /var/www/*/.ship/Caddyfile /etc/caddy/sites-enabled/*.caddy, \
221 /bin/mkdir -p /var/lib/*, \
222 /bin/mkdir -p /var/www/*, \
223 /bin/chown -R git\:git /var/lib/*, \
224 /bin/chown git\:git /var/www/*
225`
226 if err := client.WriteSudoFile("/etc/sudoers.d/ship-git", sudoersContent); err != nil {
227 return fmt.Errorf("error writing sudoers: %w", err)
228 }
229 if _, err := client.RunSudo("chmod 440 /etc/sudoers.d/ship-git"); err != nil {
230 return fmt.Errorf("error setting sudoers permissions: %w", err)
231 }
232 fmt.Println(" sudoers configured")
233
234 fmt.Println("-> Writing vanity import template...")
235 vanityHTML := `<!DOCTYPE html>
236<html><head>
237{{$path := trimPrefix "/" (placeholder "http.request.orig_uri.path")}}
238{{$parts := splitList "/" $path}}
239{{$module := first $parts}}
240<meta name="go-import" content="{{.Host}}/{{$module}} git https://{{.Host}}/{{$module}}.git">
241</head>
242<body>go get {{.Host}}/{{$module}}</body>
243</html>
244`
245 if _, err := client.RunSudo("mkdir -p /opt/ship/vanity"); err != nil {
246 return fmt.Errorf("error creating vanity directory: %w", err)
247 }
248 if err := client.WriteSudoFile("/opt/ship/vanity/index.html", vanityHTML); err != nil {
249 return fmt.Errorf("error writing vanity template: %w", err)
250 }
251 fmt.Println(" vanity template written")
252
253 fmt.Println("-> Writing base domain Caddy config...")
254 codeCaddyContent, err := templates.CodeCaddy(map[string]string{
255 "BaseDomain": baseDomain,
256 })
257 if err != nil {
258 return fmt.Errorf("error generating code caddy config: %w", err)
259 }
260 if err := client.WriteSudoFile("/etc/caddy/sites-enabled/ship-code.caddy", codeCaddyContent); err != nil {
261 return fmt.Errorf("error writing code caddy config: %w", err)
262 }
263 fmt.Println(" base domain Caddy config written")
264
265 fmt.Println("-> Writing cgit config...")
266 cgitrcContent, err := templates.CgitRC(map[string]string{
267 "BaseDomain": baseDomain,
268 })
269 if err != nil {
270 return fmt.Errorf("error generating cgitrc: %w", err)
271 }
272 if err := client.WriteSudoFile("/etc/cgitrc", cgitrcContent); err != nil {
273 return fmt.Errorf("error writing cgitrc: %w", err)
274 }
275 if err := client.WriteSudoFile("/opt/ship/cgit-header.html", templates.CgitHeader()); err != nil {
276 return fmt.Errorf("error writing cgit header: %w", err)
277 }
278 fmt.Println(" cgit config written")
279
280 fmt.Println("-> Starting Docker and fcgiwrap...")
281 if _, err := client.RunSudo("systemctl enable docker fcgiwrap"); err != nil {
282 return fmt.Errorf("error enabling services: %w", err)
283 }
284 if _, err := client.RunSudo("systemctl restart docker fcgiwrap"); err != nil {
285 return fmt.Errorf("error starting services: %w", err)
286 }
287 fmt.Println(" Docker and fcgiwrap started")
288
289 fmt.Println("-> Restarting Caddy...")
290 if _, err := client.RunSudo("systemctl restart caddy"); err != nil {
291 return fmt.Errorf("error restarting Caddy: %w", err)
292 }
293 fmt.Println(" Caddy restarted")
294
295 hostState.GitSetup = true
296 fmt.Println(" Git deployment setup complete")
297 return nil
298}
299
300func installCaddy(client *ssh.Client) error {
301 commands := []string{
302 "apt-get update",
303 "apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl",
304 "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg",
305 "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list",
306 "apt-get update",
307 "apt-get install -y caddy",
308 }
309
310 for _, cmd := range commands {
311 if _, err := client.RunSudo(cmd); err != nil {
312 return fmt.Errorf("error running: %s: %w", cmd, err)
313 }
314 }
315 return nil
316}
diff --git a/cmd/ship/host/set_domain.go b/cmd/ship/host/set_domain.go
deleted file mode 100644
index fed3b31..0000000
--- a/cmd/ship/host/set_domain.go
+++ /dev/null
@@ -1,76 +0,0 @@
1package host
2
3import (
4 "fmt"
5
6 "github.com/bdw/ship/internal/state"
7 "github.com/spf13/cobra"
8)
9
10var setDomainCmd = &cobra.Command{
11 Use: "set-domain [domain]",
12 Short: "Set base domain for auto-generated subdomains",
13 Long: `Set the base domain used to auto-generate subdomains for deployments.
14
15When a base domain is configured (e.g., apps.example.com), every deployment
16will automatically get a subdomain ({name}.apps.example.com).
17
18Examples:
19 ship host set-domain apps.example.com # Set base domain
20 ship host set-domain --clear # Remove base domain`,
21 RunE: runSetDomain,
22}
23
24func init() {
25 setDomainCmd.Flags().Bool("clear", false, "Clear the base domain")
26}
27
28func runSetDomain(cmd *cobra.Command, args []string) error {
29 st, err := state.Load()
30 if err != nil {
31 return fmt.Errorf("error loading state: %w", err)
32 }
33
34 host, _ := cmd.Flags().GetString("host")
35 if host == "" {
36 host = st.GetDefaultHost()
37 }
38
39 if host == "" {
40 return fmt.Errorf("--host is required")
41 }
42
43 clear, _ := cmd.Flags().GetBool("clear")
44
45 if !clear && len(args) == 0 {
46 // Show current base domain
47 hostState := st.GetHost(host)
48 if hostState.BaseDomain == "" {
49 fmt.Printf("No base domain configured for %s\n", host)
50 } else {
51 fmt.Printf("Base domain for %s: %s\n", host, hostState.BaseDomain)
52 }
53 return nil
54 }
55
56 hostState := st.GetHost(host)
57
58 if clear {
59 hostState.BaseDomain = ""
60 if err := st.Save(); err != nil {
61 return fmt.Errorf("error saving state: %w", err)
62 }
63 fmt.Printf("Cleared base domain for %s\n", host)
64 return nil
65 }
66
67 hostState.BaseDomain = args[0]
68 if err := st.Save(); err != nil {
69 return fmt.Errorf("error saving state: %w", err)
70 }
71
72 fmt.Printf("Set base domain for %s: %s\n", host, args[0])
73 fmt.Println("\nNew deployments will automatically use subdomains like:")
74 fmt.Printf(" myapp.%s\n", args[0])
75 return nil
76}
diff --git a/cmd/ship/host/ssh.go b/cmd/ship/host/ssh.go
deleted file mode 100644
index e480e47..0000000
--- a/cmd/ship/host/ssh.go
+++ /dev/null
@@ -1,45 +0,0 @@
1package host
2
3import (
4 "fmt"
5 "os"
6 "os/exec"
7
8 "github.com/bdw/ship/internal/state"
9 "github.com/spf13/cobra"
10)
11
12var sshCmd = &cobra.Command{
13 Use: "ssh",
14 Short: "Open interactive SSH session",
15 RunE: runSSH,
16}
17
18func runSSH(cmd *cobra.Command, args []string) error {
19 st, err := state.Load()
20 if err != nil {
21 return fmt.Errorf("error loading state: %w", err)
22 }
23
24 host, _ := cmd.Flags().GetString("host")
25 if host == "" {
26 host = st.GetDefaultHost()
27 }
28
29 if host == "" {
30 return fmt.Errorf("--host is required (no default host set)")
31 }
32
33 sshCmd := exec.Command("ssh", host)
34 sshCmd.Stdin = os.Stdin
35 sshCmd.Stdout = os.Stdout
36 sshCmd.Stderr = os.Stderr
37
38 if err := sshCmd.Run(); err != nil {
39 if exitErr, ok := err.(*exec.ExitError); ok {
40 os.Exit(exitErr.ExitCode())
41 }
42 return err
43 }
44 return nil
45}
diff --git a/cmd/ship/host/status.go b/cmd/ship/host/status.go
deleted file mode 100644
index eb2de53..0000000
--- a/cmd/ship/host/status.go
+++ /dev/null
@@ -1,108 +0,0 @@
1package host
2
3import (
4 "fmt"
5
6 "github.com/bdw/ship/internal/ssh"
7 "github.com/bdw/ship/internal/state"
8 "github.com/spf13/cobra"
9)
10
11var statusCmd = &cobra.Command{
12 Use: "status",
13 Short: "Show VPS health (uptime, disk, memory)",
14 RunE: runStatus,
15}
16
17func runStatus(cmd *cobra.Command, args []string) error {
18 st, err := state.Load()
19 if err != nil {
20 return fmt.Errorf("error loading state: %w", err)
21 }
22
23 host, _ := cmd.Flags().GetString("host")
24 if host == "" {
25 host = st.GetDefaultHost()
26 }
27
28 if host == "" {
29 return fmt.Errorf("--host is required (no default host set)")
30 }
31
32 fmt.Printf("Connecting to %s...\n\n", host)
33
34 client, err := ssh.Connect(host)
35 if err != nil {
36 return fmt.Errorf("error connecting to VPS: %w", err)
37 }
38 defer client.Close()
39
40 fmt.Println("UPTIME")
41 if output, err := client.Run("uptime -p"); err == nil {
42 fmt.Printf(" %s", output)
43 }
44 if output, err := client.Run("uptime -s"); err == nil {
45 fmt.Printf(" Since: %s", output)
46 }
47 fmt.Println()
48
49 fmt.Println("LOAD")
50 if output, err := client.Run("cat /proc/loadavg | awk '{print $1, $2, $3}'"); err == nil {
51 fmt.Printf(" 1m, 5m, 15m: %s", output)
52 }
53 fmt.Println()
54
55 fmt.Println("MEMORY")
56 if output, err := client.Run("free -h | awk 'NR==2 {print \" Used: \" $3 \" / \" $2}'"); err == nil {
57 fmt.Print(output)
58 }
59 if output, err := client.Run("free -h | awk 'NR==2 {printf \" Available: %s\\n\", $7}'"); err == nil {
60 fmt.Print(output)
61 }
62 fmt.Println()
63
64 fmt.Println("DISK")
65 if output, err := client.Run("df -h / | awk 'NR==2 {print \" Used: \" $3 \" / \" $2 \" (\" $5 \")\"}'"); err == nil {
66 fmt.Print(output)
67 }
68 if output, err := client.Run("df -h / | awk 'NR==2 {print \" Available: \" $4}'"); err == nil {
69 fmt.Print(output)
70 }
71 fmt.Println()
72
73 fmt.Println("UPDATES")
74 if output, err := client.Run("[ -f /var/lib/update-notifier/updates-available ] && cat /var/lib/update-notifier/updates-available | head -2 || echo ' (update info not available)'"); err == nil {
75 fmt.Print(output)
76 }
77 fmt.Println()
78
79 fmt.Println("SERVICES")
80 if output, err := client.Run("systemctl is-active caddy 2>/dev/null && echo ' Caddy: active' || echo ' Caddy: inactive'"); err == nil {
81 if output == "active\n" {
82 fmt.Println(" Caddy: active")
83 } else {
84 fmt.Println(" Caddy: inactive")
85 }
86 }
87
88 hostState := st.GetHost(host)
89 if hostState != nil && len(hostState.Apps) > 0 {
90 activeCount := 0
91 for name, app := range hostState.Apps {
92 if app.Type == "app" {
93 if output, err := client.Run(fmt.Sprintf("systemctl is-active %s 2>/dev/null", name)); err == nil && output == "active\n" {
94 activeCount++
95 }
96 }
97 }
98 appCount := 0
99 for _, app := range hostState.Apps {
100 if app.Type == "app" {
101 appCount++
102 }
103 }
104 fmt.Printf(" Deployed apps: %d/%d active\n", activeCount, appCount)
105 }
106
107 return nil
108}
diff --git a/cmd/ship/host/update.go b/cmd/ship/host/update.go
deleted file mode 100644
index 5f838b6..0000000
--- a/cmd/ship/host/update.go
+++ /dev/null
@@ -1,93 +0,0 @@
1package host
2
3import (
4 "bufio"
5 "fmt"
6 "os"
7 "strings"
8
9 "github.com/bdw/ship/internal/ssh"
10 "github.com/bdw/ship/internal/state"
11 "github.com/spf13/cobra"
12)
13
14var updateCmd = &cobra.Command{
15 Use: "update",
16 Short: "Update VPS packages",
17 Long: "Run apt update && apt upgrade on the VPS",
18 RunE: runUpdate,
19}
20
21func init() {
22 updateCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
23}
24
25func runUpdate(cmd *cobra.Command, args []string) error {
26 st, err := state.Load()
27 if err != nil {
28 return fmt.Errorf("error loading state: %w", err)
29 }
30
31 host, _ := cmd.Flags().GetString("host")
32 if host == "" {
33 host = st.GetDefaultHost()
34 }
35
36 if host == "" {
37 return fmt.Errorf("--host is required (no default host set)")
38 }
39
40 yes, _ := cmd.Flags().GetBool("yes")
41 if !yes {
42 fmt.Printf("This will run apt update && apt upgrade on %s\n", host)
43 fmt.Print("Continue? [Y/n]: ")
44 reader := bufio.NewReader(os.Stdin)
45 response, _ := reader.ReadString('\n')
46 response = strings.TrimSpace(response)
47 if response == "n" || response == "N" {
48 fmt.Println("Aborted.")
49 return nil
50 }
51 }
52
53 fmt.Printf("Connecting to %s...\n", host)
54
55 client, err := ssh.Connect(host)
56 if err != nil {
57 return fmt.Errorf("error connecting to VPS: %w", err)
58 }
59 defer client.Close()
60
61 fmt.Println("\n-> Running apt update...")
62 if err := client.RunSudoStream("apt update"); err != nil {
63 return fmt.Errorf("error running apt update: %w", err)
64 }
65
66 fmt.Println("\n-> Running apt upgrade...")
67 if err := client.RunSudoStream("DEBIAN_FRONTEND=noninteractive apt upgrade -y"); err != nil {
68 return fmt.Errorf("error running apt upgrade: %w", err)
69 }
70
71 fmt.Println()
72 if output, err := client.Run("[ -f /var/run/reboot-required ] && echo 'yes' || echo 'no'"); err == nil {
73 if strings.TrimSpace(output) == "yes" {
74 fmt.Print("A reboot is required to complete the update. Reboot now? [Y/n]: ")
75 reader := bufio.NewReader(os.Stdin)
76 response, _ := reader.ReadString('\n')
77 response = strings.TrimSpace(response)
78 if response == "" || response == "y" || response == "Y" {
79 fmt.Println("Rebooting...")
80 if _, err := client.RunSudo("reboot"); err != nil {
81 // reboot command often returns an error as connection drops
82 // this is expected behavior
83 }
84 fmt.Println("Reboot initiated. The host will be back online shortly.")
85 return nil
86 }
87 fmt.Println("Skipping reboot. Run 'sudo reboot' manually when ready.")
88 }
89 }
90
91 fmt.Println("Update complete")
92 return nil
93}
diff --git a/cmd/ship/host_v2.go b/cmd/ship/host_v2.go
new file mode 100644
index 0000000..b19c376
--- /dev/null
+++ b/cmd/ship/host_v2.go
@@ -0,0 +1,445 @@
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/init.go b/cmd/ship/init.go
deleted file mode 100644
index b495702..0000000
--- a/cmd/ship/init.go
+++ /dev/null
@@ -1,268 +0,0 @@
1package main
2
3import (
4 "fmt"
5 "os"
6 "os/exec"
7 "path/filepath"
8 "strconv"
9
10 "github.com/bdw/ship/internal/ssh"
11 "github.com/bdw/ship/internal/state"
12 "github.com/bdw/ship/internal/templates"
13 "github.com/spf13/cobra"
14)
15
16var initCmd = &cobra.Command{
17 Use: "init <name>",
18 Short: "Initialize a git-deployed project",
19 Long: `Create a bare git repo on the VPS and generate local .ship/ config files.
20
21Pushing to the remote triggers an automatic docker build and deploy (for apps)
22or a static file checkout (for static sites). If no Dockerfile is present in an
23app repo, pushes are accepted without triggering a deploy.
24
25Examples:
26 # Initialize an app (Docker-based)
27 ship init myapp
28
29 # Initialize with a custom domain
30 ship init myapp --domain custom.example.com
31
32 # Initialize a static site
33 ship init mysite --static
34
35 # Initialize a public repo (cloneable via go get / git clone over HTTPS)
36 ship init mylib --public`,
37 Args: cobra.ExactArgs(1),
38 RunE: runInit,
39}
40
41func init() {
42 initCmd.Flags().Bool("static", false, "Initialize as static site")
43 initCmd.Flags().Bool("public", false, "Make repo publicly cloneable over HTTPS (for go get)")
44 initCmd.Flags().String("domain", "", "Custom domain (default: name.basedomain)")
45}
46
47func runInit(cmd *cobra.Command, args []string) error {
48 name := args[0]
49 if err := validateName(name); err != nil {
50 return err
51 }
52 static, _ := cmd.Flags().GetBool("static")
53 public, _ := cmd.Flags().GetBool("public")
54 domain, _ := cmd.Flags().GetString("domain")
55
56 st, err := state.Load()
57 if err != nil {
58 return fmt.Errorf("error loading state: %w", err)
59 }
60
61 host := hostFlag
62 if host == "" {
63 host = st.GetDefaultHost()
64 }
65 if host == "" {
66 return fmt.Errorf("--host is required")
67 }
68
69 hostState := st.GetHost(host)
70 if !hostState.GitSetup {
71 return fmt.Errorf("git deployment not set up on %s (run 'ship host init --base-domain example.com' first)", host)
72 }
73
74 // Check if app already exists
75 if _, err := st.GetApp(host, name); err == nil {
76 return fmt.Errorf("app %s already exists", name)
77 }
78
79 appType := "git-app"
80 if static {
81 appType = "git-static"
82 }
83
84 // Resolve domain
85 if domain == "" && hostState.BaseDomain != "" {
86 domain = name + "." + hostState.BaseDomain
87 }
88 if domain == "" {
89 return fmt.Errorf("--domain required (or configure base domain)")
90 }
91
92 // Allocate port for apps only
93 port := 0
94 if !static {
95 port = st.AllocatePort(host)
96 }
97
98 fmt.Printf("Initializing %s: %s\n", appType, name)
99
100 client, err := ssh.Connect(host)
101 if err != nil {
102 return fmt.Errorf("error connecting to VPS: %w", err)
103 }
104 defer client.Close()
105
106 // Create bare repo
107 fmt.Println("-> Creating bare git repo...")
108 repo := fmt.Sprintf("/srv/git/%s.git", name)
109 if _, err := client.RunSudo(fmt.Sprintf("sudo -u git git init --bare -b main %s", repo)); err != nil {
110 return fmt.Errorf("error creating bare repo: %w", err)
111 }
112
113 if public {
114 if _, err := client.RunSudo(fmt.Sprintf("sudo -u git touch %s/git-daemon-export-ok", repo)); err != nil {
115 return fmt.Errorf("error setting repo public: %w", err)
116 }
117 }
118
119 if static {
120 // Create web root
121 fmt.Println("-> Creating web root...")
122 if _, err := client.RunSudo(fmt.Sprintf("mkdir -p /var/www/%s", name)); err != nil {
123 return fmt.Errorf("error creating web root: %w", err)
124 }
125 if _, err := client.RunSudo(fmt.Sprintf("chown git:git /var/www/%s", name)); err != nil {
126 return fmt.Errorf("error setting web root ownership: %w", err)
127 }
128
129 // Write post-receive hook
130 fmt.Println("-> Writing post-receive hook...")
131 hookContent, err := templates.PostReceiveHookStatic(map[string]string{
132 "Name": name,
133 })
134 if err != nil {
135 return fmt.Errorf("error generating hook: %w", err)
136 }
137 if err := writeHook(client, repo, hookContent); err != nil {
138 return err
139 }
140 } else {
141 // Create env file
142 fmt.Println("-> Creating environment file...")
143 envContent := fmt.Sprintf("PORT=%d\nDATA_DIR=/data\n", port)
144 envPath := fmt.Sprintf("/etc/ship/env/%s.env", name)
145 if err := client.WriteSudoFile(envPath, envContent); err != nil {
146 return fmt.Errorf("error creating env file: %w", err)
147 }
148
149 // Write post-receive hook (handles dir creation on first push)
150 fmt.Println("-> Writing post-receive hook...")
151 hookContent, err := templates.PostReceiveHook(map[string]string{
152 "Name": name,
153 })
154 if err != nil {
155 return fmt.Errorf("error generating hook: %w", err)
156 }
157 if err := writeHook(client, repo, hookContent); err != nil {
158 return err
159 }
160 }
161
162 // Save state
163 st.AddApp(host, name, &state.App{
164 Type: appType,
165 Domain: domain,
166 Port: port,
167 Repo: repo,
168 Public: public,
169 })
170 if err := st.Save(); err != nil {
171 return fmt.Errorf("error saving state: %w", err)
172 }
173
174 // Generate local .ship/ files
175 fmt.Println("-> Generating local .ship/ config...")
176 if err := os.MkdirAll(".ship", 0755); err != nil {
177 return fmt.Errorf("error creating .ship directory: %w", err)
178 }
179
180 if static {
181 caddyContent, err := templates.DefaultStaticCaddy(map[string]string{
182 "Domain": domain,
183 "Name": name,
184 })
185 if err != nil {
186 return fmt.Errorf("error generating Caddyfile: %w", err)
187 }
188 if err := os.WriteFile(filepath.Join(".ship", "Caddyfile"), []byte(caddyContent), 0644); err != nil {
189 return fmt.Errorf("error writing Caddyfile: %w", err)
190 }
191 } else {
192 caddyContent, err := templates.DefaultAppCaddy(map[string]string{
193 "Domain": domain,
194 "Port": strconv.Itoa(port),
195 })
196 if err != nil {
197 return fmt.Errorf("error generating Caddyfile: %w", err)
198 }
199 if err := os.WriteFile(filepath.Join(".ship", "Caddyfile"), []byte(caddyContent), 0644); err != nil {
200 return fmt.Errorf("error writing Caddyfile: %w", err)
201 }
202
203 serviceContent, err := templates.DockerService(map[string]string{
204 "Name": name,
205 "Port": strconv.Itoa(port),
206 })
207 if err != nil {
208 return fmt.Errorf("error generating service file: %w", err)
209 }
210 if err := os.WriteFile(filepath.Join(".ship", "service"), []byte(serviceContent), 0644); err != nil {
211 return fmt.Errorf("error writing service file: %w", err)
212 }
213 }
214
215 // Initialize local git repo if needed
216 if _, err := os.Stat(".git"); os.IsNotExist(err) {
217 fmt.Println("-> Initializing git repo...")
218 gitInit := exec.Command("git", "init")
219 gitInit.Stdout = os.Stdout
220 gitInit.Stderr = os.Stderr
221 if err := gitInit.Run(); err != nil {
222 return fmt.Errorf("error initializing git repo: %w", err)
223 }
224 }
225
226 // Add origin remote (replace if it already exists)
227 sshHost := host
228 remoteURL := fmt.Sprintf("git@%s:%s", sshHost, repo)
229 exec.Command("git", "remote", "remove", "origin").Run() // ignore error if not exists
230 addRemote := exec.Command("git", "remote", "add", "origin", remoteURL)
231 if err := addRemote.Run(); err != nil {
232 return fmt.Errorf("error adding git remote: %w", err)
233 }
234
235 fmt.Printf("\nProject initialized: %s\n", name)
236 fmt.Println("\nGenerated:")
237 fmt.Println(" .ship/Caddyfile — Caddy config (edit to customize routing)")
238 if !static {
239 fmt.Println(" .ship/service — systemd unit (edit to customize resources, ports)")
240 }
241 fmt.Println("\nNext steps:")
242 if static {
243 fmt.Println(" git add .ship/")
244 } else {
245 fmt.Println(" git add .ship/ Dockerfile")
246 }
247 fmt.Println(" git commit -m \"initial deploy\"")
248 fmt.Println(" git push origin main")
249 if !static {
250 fmt.Println("\n (No Dockerfile? Just push — deploy is skipped until one is added.)")
251 }
252
253 return nil
254}
255
256func writeHook(client *ssh.Client, repo, content string) error {
257 hookPath := fmt.Sprintf("%s/hooks/post-receive", repo)
258 if err := client.WriteSudoFile(hookPath, content); err != nil {
259 return fmt.Errorf("error writing hook: %w", err)
260 }
261 if _, err := client.RunSudo(fmt.Sprintf("chmod +x %s", hookPath)); err != nil {
262 return fmt.Errorf("error making hook executable: %w", err)
263 }
264 if _, err := client.RunSudo(fmt.Sprintf("chown git:git %s", hookPath)); err != nil {
265 return fmt.Errorf("error setting hook ownership: %w", err)
266 }
267 return nil
268}
diff --git a/cmd/ship/list.go b/cmd/ship/list.go
deleted file mode 100644
index af5baf8..0000000
--- a/cmd/ship/list.go
+++ /dev/null
@@ -1,61 +0,0 @@
1package main
2
3import (
4 "fmt"
5 "os"
6 "text/tabwriter"
7
8 "github.com/bdw/ship/internal/state"
9 "github.com/spf13/cobra"
10)
11
12var listCmd = &cobra.Command{
13 Use: "list",
14 Short: "List all deployed apps and sites",
15 RunE: runList,
16}
17
18func runList(cmd *cobra.Command, args []string) error {
19 st, err := state.Load()
20 if err != nil {
21 return fmt.Errorf("error loading state: %w", err)
22 }
23
24 host := hostFlag
25 if host == "" {
26 host = st.GetDefaultHost()
27 }
28
29 if host == "" {
30 return fmt.Errorf("--host is required")
31 }
32
33 apps := st.ListApps(host)
34 if len(apps) == 0 {
35 fmt.Printf("No deployments found for %s\n", host)
36 return nil
37 }
38
39 w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
40 fmt.Fprintln(w, "NAME\tTYPE\tVISIBILITY\tDOMAIN\tPORT")
41 for name, app := range apps {
42 port := ""
43 if app.Type == "app" || app.Type == "git-app" {
44 port = fmt.Sprintf(":%d", app.Port)
45 }
46 domain := app.Domain
47 if domain == "" {
48 domain = "-"
49 }
50 visibility := ""
51 if app.Repo != "" {
52 visibility = "private"
53 if app.Public {
54 visibility = "public"
55 }
56 }
57 fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", name, app.Type, visibility, domain, port)
58 }
59 w.Flush()
60 return nil
61}
diff --git a/cmd/ship/logs.go b/cmd/ship/logs.go
deleted file mode 100644
index 4c58a9c..0000000
--- a/cmd/ship/logs.go
+++ /dev/null
@@ -1,78 +0,0 @@
1package main
2
3import (
4 "fmt"
5
6 "github.com/bdw/ship/internal/ssh"
7 "github.com/bdw/ship/internal/state"
8 "github.com/spf13/cobra"
9)
10
11var logsCmd = &cobra.Command{
12 Use: "logs <app>",
13 Short: "View logs for a deployment",
14 Args: cobra.ExactArgs(1),
15 RunE: runLogs,
16}
17
18func init() {
19 logsCmd.Flags().BoolP("follow", "f", false, "Follow logs")
20 logsCmd.Flags().IntP("lines", "n", 50, "Number of lines to show")
21}
22
23func runLogs(cmd *cobra.Command, args []string) error {
24 name := args[0]
25 if err := validateName(name); err != nil {
26 return err
27 }
28 follow, _ := cmd.Flags().GetBool("follow")
29 lines, _ := cmd.Flags().GetInt("lines")
30
31 st, err := state.Load()
32 if err != nil {
33 return fmt.Errorf("error loading state: %w", err)
34 }
35
36 host := hostFlag
37 if host == "" {
38 host = st.GetDefaultHost()
39 }
40
41 if host == "" {
42 return fmt.Errorf("--host is required")
43 }
44
45 app, err := st.GetApp(host, name)
46 if err != nil {
47 return err
48 }
49
50 if app.Type != "app" && app.Type != "git-app" {
51 return fmt.Errorf("logs are only available for apps, not static sites")
52 }
53
54 client, err := ssh.Connect(host)
55 if err != nil {
56 return fmt.Errorf("error connecting to VPS: %w", err)
57 }
58 defer client.Close()
59
60 journalCmd := fmt.Sprintf("journalctl -u %s -n %d", name, lines)
61 if follow {
62 journalCmd += " -f"
63 }
64
65 if follow {
66 if err := client.RunStream(journalCmd); err != nil {
67 return fmt.Errorf("error fetching logs: %w", err)
68 }
69 } else {
70 output, err := client.Run(journalCmd)
71 if err != nil {
72 return fmt.Errorf("error fetching logs: %w", err)
73 }
74 fmt.Print(output)
75 }
76
77 return nil
78}
diff --git a/cmd/ship/main.go b/cmd/ship/main.go
index f7d95c1..17516fb 100644
--- a/cmd/ship/main.go
+++ b/cmd/ship/main.go
@@ -1,13 +1,10 @@
1package main 1package main
2 2
3import ( 3import "os"
4 "fmt"
5 "os"
6)
7 4
8func main() { 5func main() {
9 if err := rootCmd.Execute(); err != nil { 6 initV2()
10 fmt.Fprintf(os.Stderr, "Error: %v\n", err) 7 if err := rootV2Cmd.Execute(); err != nil {
11 os.Exit(1) 8 os.Exit(1)
12 } 9 }
13} 10}
diff --git a/cmd/ship/remove.go b/cmd/ship/remove.go
deleted file mode 100644
index b55d0c8..0000000
--- a/cmd/ship/remove.go
+++ /dev/null
@@ -1,109 +0,0 @@
1package main
2
3import (
4 "fmt"
5
6 "github.com/bdw/ship/internal/ssh"
7 "github.com/bdw/ship/internal/state"
8 "github.com/spf13/cobra"
9)
10
11var removeCmd = &cobra.Command{
12 Use: "remove <app>",
13 Aliases: []string{"rm"},
14 Short: "Remove a deployment",
15 Args: cobra.ExactArgs(1),
16 RunE: runRemove,
17}
18
19func runRemove(cmd *cobra.Command, args []string) error {
20 name := args[0]
21 if err := validateName(name); err != nil {
22 return err
23 }
24
25 st, err := state.Load()
26 if err != nil {
27 return fmt.Errorf("error loading state: %w", err)
28 }
29
30 host := hostFlag
31 if host == "" {
32 host = st.GetDefaultHost()
33 }
34
35 if host == "" {
36 return fmt.Errorf("--host is required")
37 }
38
39 app, err := st.GetApp(host, name)
40 if err != nil {
41 return err
42 }
43
44 fmt.Printf("Removing deployment: %s\n", name)
45
46 client, err := ssh.Connect(host)
47 if err != nil {
48 return fmt.Errorf("error connecting to VPS: %w", err)
49 }
50 defer client.Close()
51
52 switch app.Type {
53 case "app":
54 fmt.Println("-> Stopping service...")
55 client.RunSudo(fmt.Sprintf("systemctl stop %s", name))
56 client.RunSudo(fmt.Sprintf("systemctl disable %s", name))
57
58 client.RunSudo(fmt.Sprintf("rm -f /etc/systemd/system/%s.service", name))
59 client.RunSudo("systemctl daemon-reload")
60
61 client.RunSudo(fmt.Sprintf("rm -f /usr/local/bin/%s", name))
62 client.RunSudo(fmt.Sprintf("rm -rf /var/lib/%s", name))
63 client.RunSudo(fmt.Sprintf("rm -f /etc/ship/env/%s.env", name))
64 client.RunSudo(fmt.Sprintf("userdel %s", name))
65
66 case "git-app":
67 fmt.Println("-> Stopping service...")
68 client.RunSudo(fmt.Sprintf("systemctl stop %s", name))
69 client.RunSudo(fmt.Sprintf("systemctl disable %s", name))
70
71 client.RunSudo(fmt.Sprintf("rm -f /etc/systemd/system/%s.service", name))
72 client.RunSudo("systemctl daemon-reload")
73
74 fmt.Println("-> Removing Docker image...")
75 client.RunSudo(fmt.Sprintf("docker rmi %s:latest", name))
76
77 fmt.Println("-> Removing files...")
78 client.RunSudo(fmt.Sprintf("rm -rf /var/lib/%s", name))
79 client.RunSudo(fmt.Sprintf("rm -rf /srv/git/%s.git", name))
80 client.RunSudo(fmt.Sprintf("rm -f /etc/ship/env/%s.env", name))
81
82 case "git-static":
83 fmt.Println("-> Removing files...")
84 client.RunSudo(fmt.Sprintf("rm -rf /var/www/%s", name))
85 client.RunSudo(fmt.Sprintf("rm -rf /srv/git/%s.git", name))
86
87 default: // "static"
88 fmt.Println("-> Removing files...")
89 client.RunSudo(fmt.Sprintf("rm -rf /var/www/%s", name))
90 }
91
92 fmt.Println("-> Removing Caddy config...")
93 client.RunSudo(fmt.Sprintf("rm -f /etc/caddy/sites-enabled/%s.caddy", name))
94
95 fmt.Println("-> Reloading Caddy...")
96 if _, err := client.RunSudo("systemctl reload caddy"); err != nil {
97 fmt.Printf("Warning: Error reloading Caddy: %v\n", err)
98 }
99
100 if err := st.RemoveApp(host, name); err != nil {
101 return fmt.Errorf("error updating state: %w", err)
102 }
103 if err := st.Save(); err != nil {
104 return fmt.Errorf("error saving state: %w", err)
105 }
106
107 fmt.Println("Deployment removed successfully")
108 return nil
109}
diff --git a/cmd/ship/restart.go b/cmd/ship/restart.go
deleted file mode 100644
index c902adb..0000000
--- a/cmd/ship/restart.go
+++ /dev/null
@@ -1,60 +0,0 @@
1package main
2
3import (
4 "fmt"
5
6 "github.com/bdw/ship/internal/ssh"
7 "github.com/bdw/ship/internal/state"
8 "github.com/spf13/cobra"
9)
10
11var restartCmd = &cobra.Command{
12 Use: "restart <app>",
13 Short: "Restart a deployment",
14 Args: cobra.ExactArgs(1),
15 RunE: runRestart,
16}
17
18func runRestart(cmd *cobra.Command, args []string) error {
19 name := args[0]
20 if err := validateName(name); err != nil {
21 return err
22 }
23
24 st, err := state.Load()
25 if err != nil {
26 return fmt.Errorf("error loading state: %w", err)
27 }
28
29 host := hostFlag
30 if host == "" {
31 host = st.GetDefaultHost()
32 }
33
34 if host == "" {
35 return fmt.Errorf("--host is required")
36 }
37
38 app, err := st.GetApp(host, name)
39 if err != nil {
40 return err
41 }
42
43 if app.Type != "app" && app.Type != "git-app" {
44 return fmt.Errorf("restart is only available for apps, not static sites")
45 }
46
47 client, err := ssh.Connect(host)
48 if err != nil {
49 return fmt.Errorf("error connecting to VPS: %w", err)
50 }
51 defer client.Close()
52
53 fmt.Printf("Restarting %s...\n", name)
54 if _, err := client.RunSudo(fmt.Sprintf("systemctl restart %s", name)); err != nil {
55 return fmt.Errorf("error restarting service: %w", err)
56 }
57
58 fmt.Println("Service restarted successfully")
59 return nil
60}
diff --git a/cmd/ship/root.go b/cmd/ship/root.go
deleted file mode 100644
index 93280f5..0000000
--- a/cmd/ship/root.go
+++ /dev/null
@@ -1,97 +0,0 @@
1package main
2
3import (
4 "github.com/bdw/ship/cmd/ship/env"
5 "github.com/bdw/ship/cmd/ship/host"
6 "github.com/spf13/cobra"
7)
8
9var (
10 // Persistent flags
11 hostFlag string
12
13 // Version info (set via ldflags)
14 version = "dev"
15 commit = "none"
16 date = "unknown"
17)
18
19const banner = `
20 ~
21 ___|___
22 | _ |
23 _|__|_|__|_
24 | SHIP | Ship apps to your VPS
25 \_________/ with automatic HTTPS
26 ~~~~~~~~~
27`
28
29var rootCmd = &cobra.Command{
30 Use: "ship",
31 Short: "Ship apps and static sites to a VPS with automatic HTTPS",
32 Long: banner + `
33A CLI tool for deploying applications and static sites to a VPS.
34
35How it works:
36 Ship uses only SSH to deploy - no agents, containers, or external services.
37 It uploads your binary or static website, creates a systemd service, and configures Caddy
38 for automatic HTTPS. Ports are assigned automatically. Your app runs directly on the VPS
39 with minimal overhead.
40
41Requirements:
42 • A VPS with SSH access (use 'ship host init' to set up a new server)
43 • An SSH config entry or user@host for your server
44 • A domain pointing to your VPS
45
46Examples:
47 # Deploy a Go binary
48 ship --binary ./myapp --domain api.example.com
49
50 # Deploy with auto-generated subdomain (requires base domain)
51 ship --binary ./myapp --name myapp
52
53 # Deploy a static site
54 ship --static --dir ./dist --domain example.com
55
56 # Update config without redeploying binary
57 ship --name myapp --memory 512M --cpu 50%
58 ship --name myapp --env DEBUG=true
59
60 # Set up a new VPS with base domain
61 ship host init --host user@vps --base-domain apps.example.com`,
62 RunE: runDeploy,
63 SilenceUsage: true,
64 SilenceErrors: true,
65}
66
67func init() {
68 // Persistent flags available to all subcommands
69 rootCmd.PersistentFlags().StringVar(&hostFlag, "host", "", "VPS host (SSH config alias or user@host)")
70
71 // Root command (deploy) flags
72 rootCmd.Flags().String("binary", "", "Path to Go binary (for app deployment)")
73 rootCmd.Flags().Bool("static", false, "Deploy as static site")
74 rootCmd.Flags().String("dir", ".", "Directory to deploy (for static sites)")
75 rootCmd.Flags().String("domain", "", "Custom domain (optional if base domain configured)")
76 rootCmd.Flags().String("name", "", "App name (default: inferred from binary or directory)")
77 rootCmd.Flags().Int("port", 0, "Port override (default: auto-allocate)")
78 rootCmd.Flags().StringArray("env", nil, "Environment variable (KEY=VALUE, can be specified multiple times)")
79 rootCmd.Flags().String("env-file", "", "Path to .env file")
80 rootCmd.Flags().String("args", "", "Arguments to pass to binary")
81 rootCmd.Flags().StringArray("file", nil, "Config file to upload to working directory (can be specified multiple times)")
82 rootCmd.Flags().String("memory", "", "Memory limit (e.g., 512M, 1G)")
83 rootCmd.Flags().String("cpu", "", "CPU limit (e.g., 50%, 200% for 2 cores)")
84
85 // Add subcommands
86 rootCmd.AddCommand(listCmd)
87 rootCmd.AddCommand(logsCmd)
88 rootCmd.AddCommand(statusCmd)
89 rootCmd.AddCommand(restartCmd)
90 rootCmd.AddCommand(removeCmd)
91 rootCmd.AddCommand(initCmd)
92 rootCmd.AddCommand(deployGitCmd)
93 rootCmd.AddCommand(env.Cmd)
94 rootCmd.AddCommand(host.Cmd)
95 rootCmd.AddCommand(uiCmd)
96 rootCmd.AddCommand(versionCmd)
97}
diff --git a/cmd/ship/root_v2.go b/cmd/ship/root_v2.go
new file mode 100644
index 0000000..aa81d1e
--- /dev/null
+++ b/cmd/ship/root_v2.go
@@ -0,0 +1,98 @@
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/status.go b/cmd/ship/status.go
deleted file mode 100644
index 4774fad..0000000
--- a/cmd/ship/status.go
+++ /dev/null
@@ -1,63 +0,0 @@
1package main
2
3import (
4 "fmt"
5
6 "github.com/bdw/ship/internal/ssh"
7 "github.com/bdw/ship/internal/state"
8 "github.com/spf13/cobra"
9)
10
11var statusCmd = &cobra.Command{
12 Use: "status <app>",
13 Short: "Check status of a deployment",
14 Args: cobra.ExactArgs(1),
15 RunE: runStatus,
16}
17
18func runStatus(cmd *cobra.Command, args []string) error {
19 name := args[0]
20 if err := validateName(name); err != nil {
21 return err
22 }
23
24 st, err := state.Load()
25 if err != nil {
26 return fmt.Errorf("error loading state: %w", err)
27 }
28
29 host := hostFlag
30 if host == "" {
31 host = st.GetDefaultHost()
32 }
33
34 if host == "" {
35 return fmt.Errorf("--host is required")
36 }
37
38 app, err := st.GetApp(host, name)
39 if err != nil {
40 return err
41 }
42
43 if app.Type != "app" && app.Type != "git-app" {
44 return fmt.Errorf("status is only available for apps, not static sites")
45 }
46
47 client, err := ssh.Connect(host)
48 if err != nil {
49 return fmt.Errorf("error connecting to VPS: %w", err)
50 }
51 defer client.Close()
52
53 output, err := client.RunSudo(fmt.Sprintf("systemctl status %s", name))
54 if err != nil {
55 // systemctl status returns non-zero for non-active services
56 // but we still want to show the output
57 fmt.Print(output)
58 return nil
59 }
60
61 fmt.Print(output)
62 return nil
63}
diff --git a/cmd/ship/ui.go b/cmd/ship/ui.go
deleted file mode 100644
index cfaea08..0000000
--- a/cmd/ship/ui.go
+++ /dev/null
@@ -1,199 +0,0 @@
1package main
2
3import (
4 "embed"
5 "encoding/json"
6 "fmt"
7 "html/template"
8 "net/http"
9 "sort"
10 "strconv"
11
12 "github.com/bdw/ship/internal/state"
13 "github.com/bdw/ship/internal/templates"
14 "github.com/spf13/cobra"
15)
16
17//go:embed templates/*.html
18var templatesFS embed.FS
19
20var uiCmd = &cobra.Command{
21 Use: "ui",
22 Short: "Launch web management UI",
23 RunE: runUI,
24}
25
26func init() {
27 uiCmd.Flags().StringP("port", "p", "8080", "Port to run the web UI on")
28}
29
30func runUI(cmd *cobra.Command, args []string) error {
31 port, _ := cmd.Flags().GetString("port")
32
33 tmpl, err := template.ParseFS(templatesFS, "templates/webui.html")
34 if err != nil {
35 return fmt.Errorf("error parsing template: %w", err)
36 }
37
38 http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
39 st, err := state.Load()
40 if err != nil {
41 http.Error(w, fmt.Sprintf("Error loading state: %v", err), http.StatusInternalServerError)
42 return
43 }
44
45 type AppData struct {
46 Name string
47 Type string
48 Domain string
49 Port int
50 Env map[string]string
51 Host string
52 }
53
54 type HostData struct {
55 Host string
56 Apps []AppData
57 }
58
59 var hosts []HostData
60 for hostName, host := range st.Hosts {
61 var apps []AppData
62 for appName, app := range host.Apps {
63 apps = append(apps, AppData{
64 Name: appName,
65 Type: app.Type,
66 Domain: app.Domain,
67 Port: app.Port,
68 Env: app.Env,
69 Host: hostName,
70 })
71 }
72
73 sort.Slice(apps, func(i, j int) bool {
74 return apps[i].Name < apps[j].Name
75 })
76
77 hosts = append(hosts, HostData{
78 Host: hostName,
79 Apps: apps,
80 })
81 }
82
83 sort.Slice(hosts, func(i, j int) bool {
84 return hosts[i].Host < hosts[j].Host
85 })
86
87 data := struct {
88 Hosts []HostData
89 }{
90 Hosts: hosts,
91 }
92
93 if err := tmpl.Execute(w, data); err != nil {
94 http.Error(w, fmt.Sprintf("Error rendering template: %v", err), http.StatusInternalServerError)
95 return
96 }
97 })
98
99 http.HandleFunc("/api/state", func(w http.ResponseWriter, r *http.Request) {
100 st, err := state.Load()
101 if err != nil {
102 http.Error(w, fmt.Sprintf("Error loading state: %v", err), http.StatusInternalServerError)
103 return
104 }
105
106 w.Header().Set("Content-Type", "application/json")
107 json.NewEncoder(w).Encode(st)
108 })
109
110 http.HandleFunc("/api/configs", func(w http.ResponseWriter, r *http.Request) {
111 host := r.URL.Query().Get("host")
112 appName := r.URL.Query().Get("app")
113
114 if host == "" || appName == "" {
115 http.Error(w, "Missing host or app parameter", http.StatusBadRequest)
116 return
117 }
118
119 st, err := state.Load()
120 if err != nil {
121 http.Error(w, fmt.Sprintf("Error loading state: %v", err), http.StatusInternalServerError)
122 return
123 }
124
125 app, err := st.GetApp(host, appName)
126 if err != nil {
127 http.Error(w, fmt.Sprintf("App not found: %v", err), http.StatusNotFound)
128 return
129 }
130
131 configs := make(map[string]string)
132
133 if app.Env != nil && len(app.Env) > 0 {
134 envContent := ""
135 for k, v := range app.Env {
136 envContent += fmt.Sprintf("%s=%s\n", k, v)
137 }
138 configs["env"] = envContent
139 configs["envPath"] = fmt.Sprintf("/etc/ship/env/%s.env", appName)
140 }
141
142 if app.Type == "app" {
143 workDir := fmt.Sprintf("/var/lib/%s", appName)
144 binaryPath := fmt.Sprintf("/usr/local/bin/%s", appName)
145 envFilePath := fmt.Sprintf("/etc/ship/env/%s.env", appName)
146
147 serviceContent, err := templates.SystemdService(map[string]string{
148 "Name": appName,
149 "User": appName,
150 "WorkDir": workDir,
151 "BinaryPath": binaryPath,
152 "Port": strconv.Itoa(app.Port),
153 "EnvFile": envFilePath,
154 "Args": app.Args,
155 })
156 if err != nil {
157 http.Error(w, fmt.Sprintf("Error rendering systemd service: %v", err), http.StatusInternalServerError)
158 return
159 }
160 configs["systemd"] = serviceContent
161 configs["systemdPath"] = fmt.Sprintf("/etc/systemd/system/%s.service", appName)
162
163 caddyContent, err := templates.AppCaddy(map[string]string{
164 "Domain": app.Domain,
165 "Port": strconv.Itoa(app.Port),
166 })
167 if err != nil {
168 http.Error(w, fmt.Sprintf("Error rendering Caddy config: %v", err), http.StatusInternalServerError)
169 return
170 }
171 configs["caddy"] = caddyContent
172 configs["caddyPath"] = fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", appName)
173 } else if app.Type == "static" {
174 remoteDir := fmt.Sprintf("/var/www/%s", appName)
175 caddyContent, err := templates.StaticCaddy(map[string]string{
176 "Domain": app.Domain,
177 "RootDir": remoteDir,
178 })
179 if err != nil {
180 http.Error(w, fmt.Sprintf("Error rendering Caddy config: %v", err), http.StatusInternalServerError)
181 return
182 }
183 configs["caddy"] = caddyContent
184 configs["caddyPath"] = fmt.Sprintf("/etc/caddy/sites-enabled/%s.caddy", appName)
185 }
186
187 w.Header().Set("Content-Type", "application/json")
188 json.NewEncoder(w).Encode(configs)
189 })
190
191 addr := fmt.Sprintf("localhost:%s", port)
192 fmt.Printf("Starting web UI on http://%s\n", addr)
193 fmt.Printf("Press Ctrl+C to stop\n")
194
195 if err := http.ListenAndServe(addr, nil); err != nil {
196 return fmt.Errorf("error starting server: %w", err)
197 }
198 return nil
199}
diff --git a/cmd/ship/validate.go b/cmd/ship/validate.go
deleted file mode 100644
index 00275af..0000000
--- a/cmd/ship/validate.go
+++ /dev/null
@@ -1,9 +0,0 @@
1package main
2
3import "github.com/bdw/ship/internal/state"
4
5// validateName checks that an app/project name is safe for use in shell
6// commands, file paths, systemd units, and DNS labels.
7func validateName(name string) error {
8 return state.ValidateName(name)
9}
diff --git a/cmd/ship/version.go b/cmd/ship/version.go
deleted file mode 100644
index 6e4314a..0000000
--- a/cmd/ship/version.go
+++ /dev/null
@@ -1,17 +0,0 @@
1package main
2
3import (
4 "fmt"
5
6 "github.com/spf13/cobra"
7)
8
9var versionCmd = &cobra.Command{
10 Use: "version",
11 Short: "Show version information",
12 Run: func(cmd *cobra.Command, args []string) {
13 fmt.Printf("ship version %s\n", version)
14 fmt.Printf(" commit: %s\n", commit)
15 fmt.Printf(" built: %s\n", date)
16 },
17}