From 8a3cff0dd7eb88cadb73a6df4e14f85450d63317 Mon Sep 17 00:00:00 2001 From: bndw Date: Sat, 24 Jan 2026 16:55:52 -0800 Subject: 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 --- cmd/ship/host/host.go | 3 ++ cmd/ship/host/init.go | 8 +++-- cmd/ship/host/set_domain.go | 76 +++++++++++++++++++++++++++++++++++++++++++++ cmd/ship/list.go | 6 +++- cmd/ship/main.go | 4 ++- cmd/ship/root.go | 63 ++++++++++++++++++++++++++++--------- 6 files changed, 141 insertions(+), 19 deletions(-) create mode 100644 cmd/ship/host/set_domain.go (limited to 'cmd/ship') 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() { Cmd.AddCommand(statusCmd) Cmd.AddCommand(updateCmd) Cmd.AddCommand(sshCmd) + Cmd.AddCommand(setDomainCmd) + + initCmd.Flags().String("base-domain", "", "Base domain for auto-generated subdomains (e.g., apps.example.com)") } 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 { if host == "" { host = st.GetDefaultHost() } + baseDomain, _ := cmd.Flags().GetString("base-domain") if host == "" { return fmt.Errorf("--host is required") @@ -64,7 +65,6 @@ func runInit(cmd *cobra.Command, args []string) error { fmt.Println("-> Configuring Caddy...") caddyfile := `{ - email admin@example.com } import /etc/caddy/sites-enabled/* @@ -100,7 +100,11 @@ import /etc/caddy/sites-enabled/* fmt.Println(" Caddy is active") } - st.GetHost(host) + hostState := st.GetHost(host) + if baseDomain != "" { + hostState.BaseDomain = baseDomain + fmt.Printf(" Base domain: %s\n", baseDomain) + } if st.GetDefaultHost() == "" { st.SetDefaultHost(host) 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 @@ +package host + +import ( + "fmt" + + "github.com/bdw/ship/internal/state" + "github.com/spf13/cobra" +) + +var setDomainCmd = &cobra.Command{ + Use: "set-domain [domain]", + Short: "Set base domain for auto-generated subdomains", + Long: `Set the base domain used to auto-generate subdomains for deployments. + +When a base domain is configured (e.g., apps.example.com), every deployment +will automatically get a subdomain ({name}.apps.example.com). + +Examples: + ship host set-domain apps.example.com # Set base domain + ship host set-domain --clear # Remove base domain`, + RunE: runSetDomain, +} + +func init() { + setDomainCmd.Flags().Bool("clear", false, "Clear the base domain") +} + +func runSetDomain(cmd *cobra.Command, args []string) error { + st, err := state.Load() + if err != nil { + return fmt.Errorf("error loading state: %w", err) + } + + host, _ := cmd.Flags().GetString("host") + if host == "" { + host = st.GetDefaultHost() + } + + if host == "" { + return fmt.Errorf("--host is required") + } + + clear, _ := cmd.Flags().GetBool("clear") + + if !clear && len(args) == 0 { + // Show current base domain + hostState := st.GetHost(host) + if hostState.BaseDomain == "" { + fmt.Printf("No base domain configured for %s\n", host) + } else { + fmt.Printf("Base domain for %s: %s\n", host, hostState.BaseDomain) + } + return nil + } + + hostState := st.GetHost(host) + + if clear { + hostState.BaseDomain = "" + if err := st.Save(); err != nil { + return fmt.Errorf("error saving state: %w", err) + } + fmt.Printf("Cleared base domain for %s\n", host) + return nil + } + + hostState.BaseDomain = args[0] + if err := st.Save(); err != nil { + return fmt.Errorf("error saving state: %w", err) + } + + fmt.Printf("Set base domain for %s: %s\n", host, args[0]) + fmt.Println("\nNew deployments will automatically use subdomains like:") + fmt.Printf(" myapp.%s\n", args[0]) + return nil +} 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 { if app.Type == "app" { port = fmt.Sprintf(":%d", app.Port) } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", name, app.Type, app.Domain, port) + domain := app.Domain + if domain == "" { + domain = "-" + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", name, app.Type, domain, port) } w.Flush() 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 @@ package main import ( + "fmt" "os" "github.com/bdw/ship/cmd/ship/env" @@ -57,7 +58,7 @@ func init() { rootCmd.Flags().String("binary", "", "Path to Go binary (for app deployment)") rootCmd.Flags().Bool("static", false, "Deploy as static site") rootCmd.Flags().String("dir", ".", "Directory to deploy (for static sites)") - rootCmd.Flags().String("domain", "", "Domain name (required)") + rootCmd.Flags().String("domain", "", "Custom domain (optional if base domain configured)") rootCmd.Flags().String("name", "", "App name (default: inferred from binary or directory)") rootCmd.Flags().Int("port", 0, "Port override (default: auto-allocate)") rootCmd.Flags().StringArray("env", nil, "Environment variable (KEY=VALUE, can be specified multiple times)") @@ -79,6 +80,7 @@ func init() { func main() { if err := rootCmd.Execute(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } } 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 { return cmd.Help() } - if host == "" || domain == "" { - return fmt.Errorf("--host and --domain are required") + if host == "" { + return fmt.Errorf("--host is required") + } + + // Load state to check base domain + st, err := state.Load() + if err != nil { + return fmt.Errorf("error loading state: %w", err) + } + hostState := st.GetHost(host) + + if domain == "" && hostState.BaseDomain == "" { + return fmt.Errorf("--domain required (or configure base domain with 'ship host init --base-domain')") } + // Infer name early so we can use it for subdomain generation + inferredName := name + if inferredName == "" { + if static { + inferredName = domain + if inferredName == "" && hostState.BaseDomain != "" { + inferredName = filepath.Base(dir) + } + } else { + inferredName = filepath.Base(binary) + } + } + + // Generate subdomain if base domain configured + var domains []string + if hostState.BaseDomain != "" { + domains = append(domains, inferredName+"."+hostState.BaseDomain) + } + if domain != "" { + domains = append(domains, domain) + } + combinedDomains := strings.Join(domains, ", ") + if static { - return deployStatic(host, domain, name, dir) + return deployStatic(host, combinedDomains, inferredName, dir) } - return deployApp(host, domain, name, binary, port, envVars, envFile, binaryArgs, files) + return deployApp(host, combinedDomains, inferredName, binary, port, envVars, envFile, binaryArgs, files) } func 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 return fmt.Errorf("--binary is required") } - if name == "" { - name = filepath.Base(binaryPath) - } - if _, err := os.Stat(binaryPath); err != nil { return fmt.Errorf("binary not found: %s", binaryPath) } fmt.Printf("Deploying app: %s\n", name) - fmt.Printf(" Domain: %s\n", domain) + fmt.Printf(" Domain(s): %s\n", domain) fmt.Printf(" Binary: %s\n", binaryPath) st, err := state.Load() @@ -260,21 +290,21 @@ func deployApp(host, domain, name, binaryPath string, portOverride int, envVars } fmt.Printf("\n App deployed successfully!\n") - fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", domain) + // Show first domain in the URL message + primaryDomain := strings.Split(domain, ",")[0] + primaryDomain = strings.TrimSpace(primaryDomain) + fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", primaryDomain) return nil } func deployStatic(host, domain, name, dir string) error { - if name == "" { - name = domain - } if _, err := os.Stat(dir); err != nil { return fmt.Errorf("directory not found: %s", dir) } fmt.Printf("Deploying static site: %s\n", name) - fmt.Printf(" Domain: %s\n", domain) + fmt.Printf(" Domain(s): %s\n", domain) fmt.Printf(" Directory: %s\n", dir) st, err := state.Load() @@ -348,7 +378,10 @@ func deployStatic(host, domain, name, dir string) error { } fmt.Printf("\n Static site deployed successfully!\n") - fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", domain) + // Show first domain in the URL message + primaryDomain := strings.Split(domain, ",")[0] + primaryDomain = strings.TrimSpace(primaryDomain) + fmt.Printf(" https://%s (may take a minute for HTTPS cert)\n", primaryDomain) return nil } -- cgit v1.2.3