summaryrefslogtreecommitdiffstats
path: root/cmd/ship
diff options
context:
space:
mode:
Diffstat (limited to 'cmd/ship')
-rw-r--r--cmd/ship/deploy.go664
-rw-r--r--cmd/ship/deploy_cmd.go141
-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/init.go268
-rw-r--r--cmd/ship/list.go61
-rw-r--r--cmd/ship/logs.go78
-rw-r--r--cmd/ship/main.go17
-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.go2
-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
24 files changed, 3 insertions, 2760 deletions
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/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/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 73d9a20..17516fb 100644
--- a/cmd/ship/main.go
+++ b/cmd/ship/main.go
@@ -1,25 +1,10 @@
1package main 1package main
2 2
3import ( 3import "os"
4 "fmt"
5 "os"
6)
7 4
8func main() { 5func main() {
9 // Use v2 (agent-first JSON) interface by default
10 // Set SHIP_V1=1 to use legacy human-formatted output
11 if os.Getenv("SHIP_V1") == "1" {
12 if err := rootCmd.Execute(); err != nil {
13 fmt.Fprintf(os.Stderr, "Error: %v\n", err)
14 os.Exit(1)
15 }
16 return
17 }
18
19 // v2: JSON output by default
20 initV2() 6 initV2()
21 if err := rootV2Cmd.Execute(); err != nil { 7 if err := rootV2Cmd.Execute(); err != nil {
22 // Error already printed as JSON by commands
23 os.Exit(1) 8 os.Exit(1)
24 } 9 }
25} 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
index 03c99e7..4101d4e 100644
--- a/cmd/ship/root_v2.go
+++ b/cmd/ship/root_v2.go
@@ -7,6 +7,8 @@ import (
7 "github.com/spf13/cobra" 7 "github.com/spf13/cobra"
8) 8)
9 9
10var hostFlag string
11
10// This file defines the v2 CLI structure. 12// This file defines the v2 CLI structure.
11// The primary command is: ship [PATH] [FLAGS] 13// The primary command is: ship [PATH] [FLAGS]
12// All output is JSON by default. 14// All output is JSON by default.
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}