summaryrefslogtreecommitdiffstats
path: root/cmd
diff options
context:
space:
mode:
authorbndw <ben@bdw.to>2026-01-24 16:55:52 -0800
committerbndw <ben@bdw.to>2026-01-24 16:55:52 -0800
commit8a3cff0dd7eb88cadb73a6df4e14f85450d63317 (patch)
tree461e3b7af3a71f92c8a916f2e4578a63e2d92f13 /cmd
parent9c222e3fe49c6786c1719b6100564d413d7f8db6 (diff)
Add auto-generated subdomain feature
When a base domain is configured on a host (e.g., apps.example.com), deployments automatically get a subdomain ({name}.apps.example.com). Custom --domain can still be provided to route both domains. - Add BaseDomain field to Host state - Add --base-domain flag to host init - Add 'ship host set-domain' command to update base domain - Update deploy flow to auto-generate subdomains - Fix error display (errors were being silently swallowed) - Remove placeholder email from Caddyfile template
Diffstat (limited to 'cmd')
-rw-r--r--cmd/ship/host/host.go3
-rw-r--r--cmd/ship/host/init.go8
-rw-r--r--cmd/ship/host/set_domain.go76
-rw-r--r--cmd/ship/list.go6
-rw-r--r--cmd/ship/main.go4
-rw-r--r--cmd/ship/root.go63
6 files changed, 141 insertions, 19 deletions
diff --git a/cmd/ship/host/host.go b/cmd/ship/host/host.go
index 603a946..81403f9 100644
--- a/cmd/ship/host/host.go
+++ b/cmd/ship/host/host.go
@@ -15,4 +15,7 @@ func init() {
15 Cmd.AddCommand(statusCmd) 15 Cmd.AddCommand(statusCmd)
16 Cmd.AddCommand(updateCmd) 16 Cmd.AddCommand(updateCmd)
17 Cmd.AddCommand(sshCmd) 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)")
18} 21}
diff --git a/cmd/ship/host/init.go b/cmd/ship/host/init.go
index ea25922..27f67af 100644
--- a/cmd/ship/host/init.go
+++ b/cmd/ship/host/init.go
@@ -26,6 +26,7 @@ func runInit(cmd *cobra.Command, args []string) error {
26 if host == "" { 26 if host == "" {
27 host = st.GetDefaultHost() 27 host = st.GetDefaultHost()
28 } 28 }
29 baseDomain, _ := cmd.Flags().GetString("base-domain")
29 30
30 if host == "" { 31 if host == "" {
31 return fmt.Errorf("--host is required") 32 return fmt.Errorf("--host is required")
@@ -64,7 +65,6 @@ func runInit(cmd *cobra.Command, args []string) error {
64 65
65 fmt.Println("-> Configuring Caddy...") 66 fmt.Println("-> Configuring Caddy...")
66 caddyfile := `{ 67 caddyfile := `{
67 email admin@example.com
68} 68}
69 69
70import /etc/caddy/sites-enabled/* 70import /etc/caddy/sites-enabled/*
@@ -100,7 +100,11 @@ import /etc/caddy/sites-enabled/*
100 fmt.Println(" Caddy is active") 100 fmt.Println(" Caddy is active")
101 } 101 }
102 102
103 st.GetHost(host) 103 hostState := st.GetHost(host)
104 if baseDomain != "" {
105 hostState.BaseDomain = baseDomain
106 fmt.Printf(" Base domain: %s\n", baseDomain)
107 }
104 if st.GetDefaultHost() == "" { 108 if st.GetDefaultHost() == "" {
105 st.SetDefaultHost(host) 109 st.SetDefaultHost(host)
106 fmt.Printf(" Set %s as default host\n", host) 110 fmt.Printf(" Set %s as default host\n", host)
diff --git a/cmd/ship/host/set_domain.go b/cmd/ship/host/set_domain.go
new file mode 100644
index 0000000..fed3b31
--- /dev/null
+++ b/cmd/ship/host/set_domain.go
@@ -0,0 +1,76 @@
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/list.go b/cmd/ship/list.go
index a5b8df3..404ca68 100644
--- a/cmd/ship/list.go
+++ b/cmd/ship/list.go
@@ -43,7 +43,11 @@ func runList(cmd *cobra.Command, args []string) error {
43 if app.Type == "app" { 43 if app.Type == "app" {
44 port = fmt.Sprintf(":%d", app.Port) 44 port = fmt.Sprintf(":%d", app.Port)
45 } 45 }
46 fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", name, app.Type, app.Domain, port) 46 domain := app.Domain
47 if domain == "" {
48 domain = "-"
49 }
50 fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", name, app.Type, domain, port)
47 } 51 }
48 w.Flush() 52 w.Flush()
49 return nil 53 return nil
diff --git a/cmd/ship/main.go b/cmd/ship/main.go
index 27fe8b7..a6984ec 100644
--- a/cmd/ship/main.go
+++ b/cmd/ship/main.go
@@ -1,6 +1,7 @@
1package main 1package main
2 2
3import ( 3import (
4 "fmt"
4 "os" 5 "os"
5 6
6 "github.com/bdw/ship/cmd/ship/env" 7 "github.com/bdw/ship/cmd/ship/env"
@@ -57,7 +58,7 @@ func init() {
57 rootCmd.Flags().String("binary", "", "Path to Go binary (for app deployment)") 58 rootCmd.Flags().String("binary", "", "Path to Go binary (for app deployment)")
58 rootCmd.Flags().Bool("static", false, "Deploy as static site") 59 rootCmd.Flags().Bool("static", false, "Deploy as static site")
59 rootCmd.Flags().String("dir", ".", "Directory to deploy (for static sites)") 60 rootCmd.Flags().String("dir", ".", "Directory to deploy (for static sites)")
60 rootCmd.Flags().String("domain", "", "Domain name (required)") 61 rootCmd.Flags().String("domain", "", "Custom domain (optional if base domain configured)")
61 rootCmd.Flags().String("name", "", "App name (default: inferred from binary or directory)") 62 rootCmd.Flags().String("name", "", "App name (default: inferred from binary or directory)")
62 rootCmd.Flags().Int("port", 0, "Port override (default: auto-allocate)") 63 rootCmd.Flags().Int("port", 0, "Port override (default: auto-allocate)")
63 rootCmd.Flags().StringArray("env", nil, "Environment variable (KEY=VALUE, can be specified multiple times)") 64 rootCmd.Flags().StringArray("env", nil, "Environment variable (KEY=VALUE, can be specified multiple times)")
@@ -79,6 +80,7 @@ func init() {
79 80
80func main() { 81func main() {
81 if err := rootCmd.Execute(); err != nil { 82 if err := rootCmd.Execute(); err != nil {
83 fmt.Fprintf(os.Stderr, "Error: %v\n", err)
82 os.Exit(1) 84 os.Exit(1)
83 } 85 }
84} 86}
diff --git a/cmd/ship/root.go b/cmd/ship/root.go
index e5d6753..81d33d1 100644
--- a/cmd/ship/root.go
+++ b/cmd/ship/root.go
@@ -43,14 +43,48 @@ func runDeploy(cmd *cobra.Command, args []string) error {
43 return cmd.Help() 43 return cmd.Help()
44 } 44 }
45 45
46 if host == "" || domain == "" { 46 if host == "" {
47 return fmt.Errorf("--host and --domain are required") 47 return fmt.Errorf("--host is required")
48 }
49
50 // Load state to check base domain
51 st, err := state.Load()
52 if err != nil {
53 return fmt.Errorf("error loading state: %w", err)
54 }
55 hostState := st.GetHost(host)
56
57 if domain == "" && hostState.BaseDomain == "" {
58 return fmt.Errorf("--domain required (or configure base domain with 'ship host init --base-domain')")
48 } 59 }
49 60
61 // Infer name early so we can use it for subdomain generation
62 inferredName := name
63 if inferredName == "" {
64 if static {
65 inferredName = domain
66 if inferredName == "" && hostState.BaseDomain != "" {
67 inferredName = filepath.Base(dir)
68 }
69 } else {
70 inferredName = filepath.Base(binary)
71 }
72 }
73
74 // Generate subdomain if base domain configured
75 var domains []string
76 if hostState.BaseDomain != "" {
77 domains = append(domains, inferredName+"."+hostState.BaseDomain)
78 }
79 if domain != "" {
80 domains = append(domains, domain)
81 }
82 combinedDomains := strings.Join(domains, ", ")
83
50 if static { 84 if static {
51 return deployStatic(host, domain, name, dir) 85 return deployStatic(host, combinedDomains, inferredName, dir)
52 } 86 }
53 return deployApp(host, domain, name, binary, port, envVars, envFile, binaryArgs, files) 87 return deployApp(host, combinedDomains, inferredName, binary, port, envVars, envFile, binaryArgs, files)
54} 88}
55 89
56func deployApp(host, domain, name, binaryPath string, portOverride int, envVars []string, envFile, args string, files []string) error { 90func deployApp(host, domain, name, binaryPath string, portOverride int, envVars []string, envFile, args string, files []string) error {
@@ -58,16 +92,12 @@ func deployApp(host, domain, name, binaryPath string, portOverride int, envVars
58 return fmt.Errorf("--binary is required") 92 return fmt.Errorf("--binary is required")
59 } 93 }
60 94
61 if name == "" {
62 name = filepath.Base(binaryPath)
63 }
64
65 if _, err := os.Stat(binaryPath); err != nil { 95 if _, err := os.Stat(binaryPath); err != nil {
66 return fmt.Errorf("binary not found: %s", binaryPath) 96 return fmt.Errorf("binary not found: %s", binaryPath)
67 } 97 }
68 98
69 fmt.Printf("Deploying app: %s\n", name) 99 fmt.Printf("Deploying app: %s\n", name)
70 fmt.Printf(" Domain: %s\n", domain) 100 fmt.Printf(" Domain(s): %s\n", domain)
71 fmt.Printf(" Binary: %s\n", binaryPath) 101 fmt.Printf(" Binary: %s\n", binaryPath)
72 102
73 st, err := state.Load() 103 st, err := state.Load()
@@ -260,21 +290,21 @@ func deployApp(host, domain, name, binaryPath string, portOverride int, envVars
260 } 290 }
261 291
262 fmt.Printf("\n App deployed successfully!\n") 292 fmt.Printf("\n App deployed successfully!\n")
263 fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", domain) 293 // Show first domain in the URL message
294 primaryDomain := strings.Split(domain, ",")[0]
295 primaryDomain = strings.TrimSpace(primaryDomain)
296 fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", primaryDomain)
264 return nil 297 return nil
265} 298}
266 299
267func deployStatic(host, domain, name, dir string) error { 300func deployStatic(host, domain, name, dir string) error {
268 if name == "" {
269 name = domain
270 }
271 301
272 if _, err := os.Stat(dir); err != nil { 302 if _, err := os.Stat(dir); err != nil {
273 return fmt.Errorf("directory not found: %s", dir) 303 return fmt.Errorf("directory not found: %s", dir)
274 } 304 }
275 305
276 fmt.Printf("Deploying static site: %s\n", name) 306 fmt.Printf("Deploying static site: %s\n", name)
277 fmt.Printf(" Domain: %s\n", domain) 307 fmt.Printf(" Domain(s): %s\n", domain)
278 fmt.Printf(" Directory: %s\n", dir) 308 fmt.Printf(" Directory: %s\n", dir)
279 309
280 st, err := state.Load() 310 st, err := state.Load()
@@ -348,7 +378,10 @@ func deployStatic(host, domain, name, dir string) error {
348 } 378 }
349 379
350 fmt.Printf("\n Static site deployed successfully!\n") 380 fmt.Printf("\n Static site deployed successfully!\n")
351 fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", domain) 381 // Show first domain in the URL message
382 primaryDomain := strings.Split(domain, ",")[0]
383 primaryDomain = strings.TrimSpace(primaryDomain)
384 fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", primaryDomain)
352 return nil 385 return nil
353} 386}
354 387