diff --git a/caddy/config.go b/caddy/config.go new file mode 100644 index 0000000..19bc6b0 --- /dev/null +++ b/caddy/config.go @@ -0,0 +1,258 @@ +package caddy + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + + "github.com/spf13/viper" +) + +// CaddyConfig represents the full Caddy JSON configuration +type CaddyConfig struct { + Admin CaddyAdmin `json:"admin"` + Apps CaddyApps `json:"apps"` +} + +// CaddyAdmin represents the admin API configuration +type CaddyAdmin struct { + Listen string `json:"listen"` +} + +// CaddyApps contains the HTTP app configuration +type CaddyApps struct { + HTTP CaddyHTTP `json:"http"` +} + +// CaddyHTTP contains the HTTP server configuration +type CaddyHTTP struct { + Servers map[string]CaddyServer `json:"servers"` +} + +// CaddyServer represents a single HTTP server +type CaddyServer struct { + Listen []string `json:"listen"` + Routes []Route `json:"routes"` +} + +// sanitizeDNS is the shared helper that lowercases, replaces non-alphanumeric +// characters with hyphens, collapses runs of hyphens, and trims leading/trailing +// hyphens. extraReplacements are applied before the character-level pass. +func sanitizeDNS(s string, extraReplacements ...string) string { + normalized := strings.ToLower(s) + for _, r := range extraReplacements { + normalized = strings.ReplaceAll(normalized, r, "-") + } + normalized = strings.ReplaceAll(normalized, "_", "-") + normalized = strings.ReplaceAll(normalized, " ", "-") + + var result strings.Builder + for _, r := range normalized { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { + result.WriteRune(r) + } else { + result.WriteRune('-') + } + } + + final := strings.Trim(result.String(), "-") + for strings.Contains(final, "--") { + final = strings.ReplaceAll(final, "--", "-") + } + return final +} + +// NormalizeDNSName converts a service name to be DNS-compatible +func NormalizeDNSName(serviceName string) string { + return sanitizeDNS(serviceName) +} + +// SanitizeHostname converts a session name to be hostname-compatible. +// Unlike NormalizeDNSName, it also converts slashes to hyphens (for branch names like "feature/foo"). +func SanitizeHostname(sessionName string) string { + return sanitizeDNS(sessionName, "/") +} + +// BuildCaddyConfig generates the complete Caddy JSON config from session data +func BuildCaddyConfig(sessions map[string]*SessionInfo) CaddyConfig { + adminListen := viper.GetString("caddy_admin") + if adminListen == "" { + adminListen = "localhost:2019" + } + + routes := buildRoutes(sessions) + + return CaddyConfig{ + Admin: CaddyAdmin{Listen: adminListen}, + Apps: CaddyApps{ + HTTP: CaddyHTTP{ + Servers: map[string]CaddyServer{ + "devx": { + Listen: []string{":80"}, + Routes: routes, + }, + }, + }, + }, + } +} + +// BuildHostname constructs the hostname for a session/service combination. +// Returns "" if the service name normalizes to empty. +func BuildHostname(sessionName, serviceName, projectAlias string) string { + dnsService := NormalizeDNSName(serviceName) + if dnsService == "" { + return "" + } + sanitizedSession := SanitizeHostname(sessionName) + if projectAlias != "" { + sanitizedProject := NormalizeDNSName(projectAlias) + return fmt.Sprintf("%s-%s-%s.localhost", sanitizedProject, sanitizedSession, dnsService) + } + return fmt.Sprintf("%s-%s.localhost", sanitizedSession, dnsService) +} + +// BuildRouteID constructs the route ID for a session/service combination. +// Returns "" if the service name normalizes to empty. +func BuildRouteID(sessionName, serviceName, projectAlias string) string { + dnsService := NormalizeDNSName(serviceName) + if dnsService == "" { + return "" + } + sanitizedSession := SanitizeHostname(sessionName) + if projectAlias != "" { + sanitizedProject := NormalizeDNSName(projectAlias) + return fmt.Sprintf("sess-%s-%s-%s", sanitizedProject, sanitizedSession, dnsService) + } + return fmt.Sprintf("sess-%s-%s", sanitizedSession, dnsService) +} + +// buildRoutes generates all session routes in deterministic order +func buildRoutes(sessions map[string]*SessionInfo) []Route { + var routes []Route + + // Sort session names for deterministic output + sessionNames := make([]string, 0, len(sessions)) + for name := range sessions { + sessionNames = append(sessionNames, name) + } + sort.Strings(sessionNames) + + for _, sessionName := range sessionNames { + info := sessions[sessionName] + + // Sort service names for deterministic output + serviceNames := make([]string, 0, len(info.Ports)) + for svc := range info.Ports { + serviceNames = append(serviceNames, svc) + } + sort.Strings(serviceNames) + + for _, serviceName := range serviceNames { + port := info.Ports[serviceName] + hostname := BuildHostname(sessionName, serviceName, info.ProjectAlias) + if hostname == "" { + continue + } + routeID := BuildRouteID(sessionName, serviceName, info.ProjectAlias) + + routes = append(routes, Route{ + ID: routeID, + Match: []RouteMatch{ + {Host: []string{hostname}}, + }, + Handle: []RouteHandler{ + { + Handler: "reverse_proxy", + Upstreams: []RouteUpstream{{Dial: fmt.Sprintf("localhost:%d", port)}}, + }, + }, + Terminal: true, + }) + } + } + + if routes == nil { + routes = []Route{} + } + + return routes +} + +// configPath returns the path to the generated Caddy config file +func configPath() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".config", "devx", "caddy-config.json") +} + +// SyncRoutes generates the Caddy config file and reloads Caddy. +// It writes the config even if Caddy is not running, so the next +// Caddy start picks up the correct routes. +func SyncRoutes(sessions map[string]*SessionInfo) error { + if viper.GetBool("disable_caddy") { + return nil + } + + config := BuildCaddyConfig(sessions) + + cfgPath := configPath() + if cfgPath == "" { + return fmt.Errorf("could not determine config path") + } + + // Marshal config + jsonData, err := json.MarshalIndent(config, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal Caddy config: %w", err) + } + + // Atomic write: temp file + rename + dir := filepath.Dir(cfgPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + tmpFile, err := os.CreateTemp(dir, "caddy-config-*.json") + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + tmpPath := tmpFile.Name() + + if _, err := tmpFile.Write(jsonData); err != nil { + tmpFile.Close() + os.Remove(tmpPath) + return fmt.Errorf("failed to write config: %w", err) + } + if err := tmpFile.Close(); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("failed to close temp file: %w", err) + } + + if err := os.Rename(tmpPath, cfgPath); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("failed to rename config file: %w", err) + } + + // Try to reload Caddy + if err := reloadCaddy(cfgPath); err != nil { + fmt.Printf("Warning: Caddy reload failed (config saved for next start): %v\n", err) + } + + return nil +} + +// reloadCaddy runs `caddy reload` pointing at the config file. +func reloadCaddy(cfgPath string) error { + cmd := exec.Command("caddy", "reload", "--config", cfgPath) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("%w: %s", err, string(output)) + } + return nil +} diff --git a/caddy/config_test.go b/caddy/config_test.go new file mode 100644 index 0000000..4957471 --- /dev/null +++ b/caddy/config_test.go @@ -0,0 +1,266 @@ +package caddy + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/spf13/viper" +) + +func TestBuildCaddyConfig(t *testing.T) { + // Ensure clean Viper state for all subtests + viper.Set("caddy_admin", "") + + t.Run("empty sessions produces valid config with no routes", func(t *testing.T) { + sessions := map[string]*SessionInfo{} + config := BuildCaddyConfig(sessions) + + jsonData, err := json.Marshal(config) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + + jsonStr := string(jsonData) + // Should have admin listener + if !contains(jsonStr, `"listen":"localhost:2019"`) { + t.Errorf("missing admin listener in config: %s", jsonStr) + } + // Should have server listening on :80 + if !contains(jsonStr, `":80"`) { + t.Errorf("missing :80 listener in config: %s", jsonStr) + } + // Routes should be empty array, not null + if !contains(jsonStr, `"routes":[]`) { + t.Errorf("expected empty routes array in config: %s", jsonStr) + } + }) + + t.Run("single session produces correct routes", func(t *testing.T) { + sessions := map[string]*SessionInfo{ + "my-session": { + Name: "my-session", + Ports: map[string]int{"FRONTEND": 3000, "BACKEND": 4000}, + }, + } + config := BuildCaddyConfig(sessions) + + jsonData, err := json.Marshal(config) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + + jsonStr := string(jsonData) + if !contains(jsonStr, `my-session-frontend.localhost`) { + t.Errorf("missing frontend hostname: %s", jsonStr) + } + if !contains(jsonStr, `my-session-backend.localhost`) { + t.Errorf("missing backend hostname: %s", jsonStr) + } + if !contains(jsonStr, `localhost:3000`) { + t.Errorf("missing frontend port: %s", jsonStr) + } + if !contains(jsonStr, `localhost:4000`) { + t.Errorf("missing backend port: %s", jsonStr) + } + }) + + t.Run("session with project alias includes prefix", func(t *testing.T) { + sessions := map[string]*SessionInfo{ + "my-session": { + Name: "my-session", + Ports: map[string]int{"FRONTEND": 3000}, + ProjectAlias: "myproject", + }, + } + config := BuildCaddyConfig(sessions) + + jsonData, err := json.Marshal(config) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + + jsonStr := string(jsonData) + if !contains(jsonStr, `myproject-my-session-frontend.localhost`) { + t.Errorf("missing project-prefixed hostname: %s", jsonStr) + } + if !contains(jsonStr, `sess-myproject-my-session-frontend`) { + t.Errorf("missing project-prefixed route ID: %s", jsonStr) + } + }) + + t.Run("route IDs and hostnames are deterministically ordered", func(t *testing.T) { + sessions := map[string]*SessionInfo{ + "b-session": { + Name: "b-session", + Ports: map[string]int{"UI": 3000}, + }, + "a-session": { + Name: "a-session", + Ports: map[string]int{"UI": 4000}, + }, + } + config1 := BuildCaddyConfig(sessions) + config2 := BuildCaddyConfig(sessions) + + json1, _ := json.Marshal(config1) + json2, _ := json.Marshal(config2) + + if string(json1) != string(json2) { + t.Errorf("config generation is not deterministic") + } + }) + + t.Run("session with slashes in name is sanitized", func(t *testing.T) { + sessions := map[string]*SessionInfo{ + "feature/my-branch": { + Name: "feature/my-branch", + Ports: map[string]int{"FRONTEND": 3000}, + }, + } + config := BuildCaddyConfig(sessions) + + jsonData, _ := json.Marshal(config) + jsonStr := string(jsonData) + // Slashes should be converted to hyphens + if !contains(jsonStr, `feature-my-branch-frontend.localhost`) { + t.Errorf("session name with slash not properly sanitized: %s", jsonStr) + } + }) + + t.Run("session without project alias when others have one", func(t *testing.T) { + sessions := map[string]*SessionInfo{ + "with-project": { + Name: "with-project", + Ports: map[string]int{"UI": 3000}, + ProjectAlias: "myapp", + }, + "no-project": { + Name: "no-project", + Ports: map[string]int{"UI": 4000}, + }, + } + config := BuildCaddyConfig(sessions) + + jsonData, _ := json.Marshal(config) + jsonStr := string(jsonData) + // Project-prefixed session + if !contains(jsonStr, `myapp-with-project-ui.localhost`) { + t.Errorf("missing project-prefixed hostname: %s", jsonStr) + } + // Non-project session + if !contains(jsonStr, `no-project-ui.localhost`) { + t.Errorf("missing non-project hostname: %s", jsonStr) + } + // Should not have project prefix on the non-project session + if contains(jsonStr, `myapp-no-project`) { + t.Errorf("non-project session incorrectly got project prefix: %s", jsonStr) + } + }) + + t.Run("project alias is sanitized in hostname", func(t *testing.T) { + sessions := map[string]*SessionInfo{ + "my-session": { + Name: "my-session", + Ports: map[string]int{"FRONTEND": 3000}, + ProjectAlias: "My_Project", + }, + } + config := BuildCaddyConfig(sessions) + + jsonData, _ := json.Marshal(config) + jsonStr := string(jsonData) + // ProjectAlias should be sanitized: "My_Project" -> "my-project" + if !contains(jsonStr, `my-project-my-session-frontend.localhost`) { + t.Errorf("project alias not sanitized in hostname: %s", jsonStr) + } + if !contains(jsonStr, `sess-my-project-my-session-frontend`) { + t.Errorf("project alias not sanitized in route ID: %s", jsonStr) + } + }) + + t.Run("session with empty ports produces no routes", func(t *testing.T) { + sessions := map[string]*SessionInfo{ + "empty": { + Name: "empty", + Ports: map[string]int{}, + }, + } + config := BuildCaddyConfig(sessions) + + routes := config.Apps.HTTP.Servers["devx"].Routes + if len(routes) != 0 { + t.Errorf("expected 0 routes for empty ports, got %d", len(routes)) + } + }) +} + +func TestSyncRoutes(t *testing.T) { + t.Run("writes config file", func(t *testing.T) { + // Use a temp dir to avoid writing to real config + tmpDir := t.TempDir() + t.Setenv("HOME", tmpDir) + t.Setenv("USERPROFILE", tmpDir) // Windows: os.UserHomeDir() checks USERPROFILE + + // Create the config directory + configDir := filepath.Join(tmpDir, ".config", "devx") + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatalf("failed to create config dir: %v", err) + } + + sessions := map[string]*SessionInfo{ + "test-session": { + Name: "test-session", + Ports: map[string]int{"FRONTEND": 3000}, + }, + } + + err := SyncRoutes(sessions) + // SyncRoutes may warn about caddy reload failing, that's OK + if err != nil { + t.Fatalf("SyncRoutes failed: %v", err) + } + + // Verify config file was written + cfgFile := filepath.Join(configDir, "caddy-config.json") + data, err := os.ReadFile(cfgFile) + if err != nil { + t.Fatalf("config file not written: %v", err) + } + + // Verify it's valid JSON with expected content + var config CaddyConfig + if err := json.Unmarshal(data, &config); err != nil { + t.Fatalf("config file is not valid JSON: %v", err) + } + + if len(config.Apps.HTTP.Servers["devx"].Routes) != 1 { + t.Errorf("expected 1 route, got %d", len(config.Apps.HTTP.Servers["devx"].Routes)) + } + }) + + t.Run("skips when disable_caddy is true", func(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HOME", tmpDir) + t.Setenv("USERPROFILE", tmpDir) // Windows: os.UserHomeDir() checks USERPROFILE + + viper.Set("disable_caddy", true) + defer viper.Set("disable_caddy", false) + + sessions := map[string]*SessionInfo{ + "test": {Name: "test", Ports: map[string]int{"UI": 3000}}, + } + + err := SyncRoutes(sessions) + if err != nil { + t.Fatalf("SyncRoutes should not error when disabled: %v", err) + } + + // Config file should NOT exist + cfgFile := filepath.Join(tmpDir, ".config", "devx", "caddy-config.json") + if _, err := os.Stat(cfgFile); !os.IsNotExist(err) { + t.Error("config file should not be written when caddy is disabled") + } + }) +} diff --git a/caddy/health.go b/caddy/health.go index 5ea2c71..cf01c77 100644 --- a/caddy/health.go +++ b/caddy/health.go @@ -2,7 +2,7 @@ package caddy import ( "fmt" - "strings" + "sort" ) // RouteStatus represents the status of a Caddy route @@ -13,8 +13,6 @@ type RouteStatus struct { Hostname string Port int Exists bool - IsFirst bool // Whether route appears before catch-all - ServiceUp bool // Whether the service is responding Error string } @@ -23,10 +21,8 @@ type HealthCheckResult struct { CaddyRunning bool CaddyError string RouteStatuses []RouteStatus - CatchAllFirst bool // Whether catch-all route is blocking specific routes RoutesNeeded int RoutesExisting int - RoutesWorking int } // CheckCaddyHealth performs a comprehensive health check of Caddy and all routes @@ -51,50 +47,22 @@ func CheckCaddyHealth(sessions map[string]*SessionInfo) (*HealthCheckResult, err return nil, fmt.Errorf("failed to get routes: %w", err) } - // Check if there are any catch-all routes (routes without IDs) - // and if they appear before specific routes - catchAllPosition := -1 - lastSpecificRoutePosition := -1 - - for i, route := range routes { - if route.ID == "" && catchAllPosition == -1 { - catchAllPosition = i - } else if route.ID != "" { - lastSpecificRoutePosition = i - } - } - - // If catch-all exists and there are specific routes after it, routing is broken - if catchAllPosition != -1 && lastSpecificRoutePosition > catchAllPosition { - result.CatchAllFirst = true - } - // Build a map of existing routes - existingRoutes := make(map[string]int) // routeID -> position - for i, route := range routes { + existingRoutes := make(map[string]bool) + for _, route := range routes { if route.ID != "" { - existingRoutes[route.ID] = i + existingRoutes[route.ID] = true } } // Check each session's expected routes for sessionName, sessionInfo := range sessions { for serviceName, port := range sessionInfo.Ports { - // Normalize service name for DNS compatibility - normalizedServiceName := NormalizeDNSName(serviceName) - - // Sanitize session name for hostname compatibility - sanitizedSessionName := SanitizeHostname(sessionName) - - // Generate expected route ID and hostname - routeID := fmt.Sprintf("sess-%s-%s", sanitizedSessionName, normalizedServiceName) - hostname := fmt.Sprintf("%s-%s.localhost", sanitizedSessionName, normalizedServiceName) - - // Handle project prefixes if present - if sessionInfo.ProjectAlias != "" { - routeID = fmt.Sprintf("sess-%s-%s-%s", sessionInfo.ProjectAlias, sanitizedSessionName, normalizedServiceName) - hostname = fmt.Sprintf("%s-%s-%s.localhost", sessionInfo.ProjectAlias, sanitizedSessionName, normalizedServiceName) + hostname := BuildHostname(sessionName, serviceName, sessionInfo.ProjectAlias) + if hostname == "" { + continue } + routeID := BuildRouteID(sessionName, serviceName, sessionInfo.ProjectAlias) status := RouteStatus{ SessionName: sessionName, @@ -107,23 +75,23 @@ func CheckCaddyHealth(sessions map[string]*SessionInfo) (*HealthCheckResult, err result.RoutesNeeded++ // Check if route exists - if position, exists := existingRoutes[routeID]; exists { + if existingRoutes[routeID] { status.Exists = true - status.IsFirst = !result.CatchAllFirst || position == 0 result.RoutesExisting++ - - // TODO: Check if service is actually responding - // This would require making HTTP requests to test - status.ServiceUp = true // Placeholder - if status.ServiceUp { - result.RoutesWorking++ - } } result.RouteStatuses = append(result.RouteStatuses, status) } } + // Sort route statuses for deterministic display output + sort.Slice(result.RouteStatuses, func(i, j int) bool { + if result.RouteStatuses[i].SessionName != result.RouteStatuses[j].SessionName { + return result.RouteStatuses[i].SessionName < result.RouteStatuses[j].SessionName + } + return result.RouteStatuses[i].ServiceName < result.RouteStatuses[j].ServiceName + }) + return result, nil } @@ -133,66 +101,3 @@ type SessionInfo struct { Ports map[string]int ProjectAlias string } - -// RepairRoutes attempts to fix any routing issues found during health check -func RepairRoutes(result *HealthCheckResult, sessions map[string]*SessionInfo) error { - client := NewCaddyClient() - - if !result.CaddyRunning { - return fmt.Errorf("Caddy is not running") - } - - // Ensure routes array exists (Caddy can't append to null) - if err := client.EnsureRoutesArray(); err != nil { - return fmt.Errorf("failed to initialize routes array: %w", err) - } - - // If catch-all is first, we need to reorder all routes - if result.CatchAllFirst { - fmt.Println("Fixing route order (catch-all route is blocking specific routes)...") - if err := reorderRoutes(client); err != nil { - return fmt.Errorf("failed to reorder routes: %w", err) - } - } - - // Create missing routes - var errors []string - for _, status := range result.RouteStatuses { - if !status.Exists { - fmt.Printf("Creating missing route for %s-%s...\n", status.SessionName, status.ServiceName) - - sessionInfo := sessions[status.SessionName] - if sessionInfo == nil { - errors = append(errors, fmt.Sprintf("session info not found for %s", status.SessionName)) - continue - } - - // Use normalized service name for route creation - normalizedServiceName := NormalizeDNSName(status.ServiceName) - _, err := client.CreateRouteWithProject( - status.SessionName, - normalizedServiceName, - status.Port, - sessionInfo.ProjectAlias, - ) - if err != nil { - errors = append(errors, fmt.Sprintf("failed to create route for %s-%s: %v", - status.SessionName, status.ServiceName, err)) - } - } - } - - if len(errors) > 0 { - return fmt.Errorf("some routes failed to create: %s", strings.Join(errors, "; ")) - } - - // If we created new routes and catch-all exists, reorder again - if result.CatchAllFirst { - fmt.Println("Reordering routes after creating new ones...") - if err := reorderRoutes(client); err != nil { - return fmt.Errorf("failed to reorder routes after creation: %w", err) - } - } - - return nil -} diff --git a/caddy/integration_test.go b/caddy/integration_test.go index d40afd4..8d79e86 100644 --- a/caddy/integration_test.go +++ b/caddy/integration_test.go @@ -1,25 +1,18 @@ package caddy import ( - "crypto/tls" - "fmt" "net/http" - "strings" "testing" - "time" ) -// TestCaddyRouteLifecycle tests the full lifecycle of creating and deleting routes -// This test requires Caddy to be running with admin API on localhost:2019 -func TestCaddyRouteLifecycle(t *testing.T) { - // Skip if running in CI or if Caddy not available +func TestCaddyIntegration(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in short mode") } client := NewCaddyClient() - // Check if Caddy is running - try to actually connect + // Check if Caddy is running caddyResp, err := http.Get("http://localhost:2019/config/") if err != nil { t.Skipf("Caddy not available (connection failed): %v", err) @@ -30,99 +23,59 @@ func TestCaddyRouteLifecycle(t *testing.T) { t.Skipf("Caddy not available (status %d)", caddyResp.StatusCode) } - sessionName := "test-session" - serviceName := "ui" - port := 8080 - - // Clean up any existing routes - defer func() { - _ = client.DeleteSessionRoutes(sessionName) - }() + // Create test sessions + sessions := map[string]*SessionInfo{ + "integration-test": { + Name: "integration-test", + Ports: map[string]int{"ui": 18080, "api": 18081}, + }, + } - // Create route - _, err = client.CreateRoute(sessionName, serviceName, port) + // Sync routes + err = SyncRoutes(sessions) if err != nil { - t.Fatalf("failed to create route: %v", err) + t.Fatalf("SyncRoutes failed: %v", err) } - // Give Caddy a moment to process - time.Sleep(100 * time.Millisecond) - - // Test that route exists (should return 502 since no service is running) - testURL := fmt.Sprintf("https://%s-%s.localhost", sessionName, serviceName) - - // Create HTTP client that accepts self-signed certificates - tr := &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - } - httpClient := &http.Client{ - Transport: tr, - Timeout: 5 * time.Second, + // Verify routes exist + routes, err := client.GetAllRoutes() + if err != nil { + t.Fatalf("GetAllRoutes failed: %v", err) } - resp, err := httpClient.Get(testURL) - if err != nil { - // DNS resolution failures and connection issues are expected in test environments - errStr := err.Error() - if strings.Contains(errStr, "no such host") || strings.Contains(errStr, "lookup") || - strings.Contains(errStr, "connection refused") || strings.Contains(errStr, "dial tcp") { - t.Skipf("Test environment not configured for .localhost HTTPS routing: %v", err) + foundUI := false + foundAPI := false + for _, route := range routes { + if route.ID == "sess-integration-test-ui" { + foundUI = true + } + if route.ID == "sess-integration-test-api" { + foundAPI = true } - t.Fatalf("failed to make request to %s: %v", testURL, err) } - resp.Body.Close() - // Should get 502 (bad gateway) since no service is running on port 8080 - if resp.StatusCode != 502 { - t.Logf("Expected 502 (no service running), got %d", resp.StatusCode) + if !foundUI { + t.Error("expected ui route to exist") } - - // Delete route - err = client.DeleteSessionRoutes(sessionName) - if err != nil { - t.Fatalf("failed to delete routes: %v", err) + if !foundAPI { + t.Error("expected api route to exist") } - // Give Caddy a moment to process - time.Sleep(100 * time.Millisecond) - - // Test that route no longer exists (should return 404) - resp2, err := httpClient.Get(testURL) + // Clean up by syncing empty sessions + err = SyncRoutes(map[string]*SessionInfo{}) if err != nil { - t.Fatalf("failed to make request after deletion: %v", err) - } - resp2.Body.Close() - - // Should get 404 (not found) since route is deleted - if resp2.StatusCode != 404 { - t.Errorf("expected 404 after route deletion, got %d", resp2.StatusCode) + t.Fatalf("cleanup SyncRoutes failed: %v", err) } -} - -func TestProvisionSessionRoutes(t *testing.T) { - // Test the provisioning function without requiring Caddy - sessionName := "test-provision" - ports := map[string]int{ - "ui": 3000, - "api": 3001, - "db": 5432, - } - - // This will skip Caddy operations if not available - routes, err := ProvisionSessionRoutes(sessionName, ports) - // Should not error even if Caddy is not available + // Verify routes are gone + routes, err = client.GetAllRoutes() if err != nil { - t.Logf("Provisioning warning (expected if Caddy not running): %v", err) + t.Fatalf("GetAllRoutes after cleanup failed: %v", err) } - // If Caddy is available, should have created routes - if len(routes) > 0 { - expectedServices := []string{"ui", "api", "db"} - for _, service := range expectedServices { - if _, exists := routes[service]; !exists { - t.Errorf("expected route for service %s not found", service) - } + for _, route := range routes { + if route.ID == "sess-integration-test-ui" || route.ID == "sess-integration-test-api" { + t.Errorf("route %s should have been removed", route.ID) } } } diff --git a/caddy/provisioning.go b/caddy/provisioning.go deleted file mode 100644 index c4b6213..0000000 --- a/caddy/provisioning.go +++ /dev/null @@ -1,233 +0,0 @@ -package caddy - -import ( - "fmt" - "strings" - - "github.com/spf13/viper" -) - -// NormalizeDNSName converts a service name to be DNS-compatible -func NormalizeDNSName(serviceName string) string { - // Convert to lowercase - normalized := strings.ToLower(serviceName) - - // Replace underscores and spaces with hyphens - normalized = strings.ReplaceAll(normalized, "_", "-") - normalized = strings.ReplaceAll(normalized, " ", "-") - - // Replace any non-alphanumeric characters with hyphens - var result strings.Builder - for _, r := range normalized { - if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { - result.WriteRune(r) - } else if r != '-' { - // Replace any non-alphanumeric character with hyphen - result.WriteRune('-') - } else { - result.WriteRune(r) - } - } - - // Remove leading/trailing hyphens and collapse multiple hyphens - final := strings.Trim(result.String(), "-") - for strings.Contains(final, "--") { - final = strings.ReplaceAll(final, "--", "-") - } - - return final -} - -// SanitizeHostname converts a session name to be hostname-compatible -func SanitizeHostname(sessionName string) string { - // Convert to lowercase - normalized := strings.ToLower(sessionName) - - // Replace slashes, underscores, and spaces with hyphens - normalized = strings.ReplaceAll(normalized, "/", "-") - normalized = strings.ReplaceAll(normalized, "_", "-") - normalized = strings.ReplaceAll(normalized, " ", "-") - - // Replace any non-alphanumeric characters with hyphens - var result strings.Builder - for _, r := range normalized { - if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { - result.WriteRune(r) - } else if r != '-' { - // Replace any non-alphanumeric character with hyphen - result.WriteRune('-') - } else { - result.WriteRune(r) - } - } - - // Remove leading/trailing hyphens and collapse multiple hyphens - final := strings.Trim(result.String(), "-") - for strings.Contains(final, "--") { - final = strings.ReplaceAll(final, "--", "-") - } - - return final -} - -// ProvisionSessionRoutes creates Caddy routes for all services in a session -func ProvisionSessionRoutes(sessionName string, services map[string]int) (map[string]string, error) { - return ProvisionSessionRoutesWithProject(sessionName, services, "") -} - -// ProvisionSessionRoutesWithProject creates Caddy routes for all services in a session with optional project prefix -func ProvisionSessionRoutesWithProject(sessionName string, services map[string]int, projectAlias string) (map[string]string, error) { - // Check if Caddy provisioning is enabled - if viper.GetBool("disable_caddy") { - return make(map[string]string), nil - } - - client := NewCaddyClient() - - // Check if Caddy is running - if err := client.CheckCaddyConnection(); err != nil { - fmt.Printf("Warning: Caddy not available, skipping route provisioning: %v\n", err) - return make(map[string]string), nil - } - - // Ensure routes array exists (Caddy can't append to null) - if err := client.EnsureRoutesArray(); err != nil { - return nil, fmt.Errorf("failed to initialize routes array: %w", err) - } - - // Check if there are any catch-all routes that need to be moved to the end - existingRoutes, err := client.GetAllRoutes() - if err == nil { - hasCatchAll := false - for _, route := range existingRoutes { - if route.ID == "" { - hasCatchAll = true - break - } - } - - // If there's a catch-all route, we'll need to reorder after adding new routes - if hasCatchAll { - defer func() { - // Reorder routes to ensure specific routes come before catch-all - if err := reorderRoutes(client); err != nil { - fmt.Printf("Warning: Failed to reorder routes after creation: %v\n", err) - } - }() - } - } - - routes := make(map[string]string) - var errors []string - - // Sanitize session name for hostname compatibility - sanitizedSessionName := SanitizeHostname(sessionName) - - for serviceName, port := range services { - // Normalize service name for DNS compatibility - dnsServiceName := NormalizeDNSName(serviceName) - - if dnsServiceName == "" { - errors = append(errors, fmt.Sprintf("service name '%s' cannot be converted to valid DNS name", serviceName)) - continue - } - - _, err := client.CreateRouteWithProject(sanitizedSessionName, dnsServiceName, port, projectAlias) - if err != nil { - errors = append(errors, fmt.Sprintf("failed to create route for %s: %v", dnsServiceName, err)) - continue - } - - // Generate route ID with project prefix if provided - routeID := fmt.Sprintf("sess-%s-%s", sanitizedSessionName, dnsServiceName) - if projectAlias != "" { - routeID = fmt.Sprintf("sess-%s-%s-%s", projectAlias, sanitizedSessionName, dnsServiceName) - } - routes[serviceName] = routeID - - // Generate hostname with project prefix if provided - hostname := fmt.Sprintf("%s-%s.localhost", sanitizedSessionName, dnsServiceName) - if projectAlias != "" { - hostname = fmt.Sprintf("%s-%s-%s.localhost", projectAlias, sanitizedSessionName, dnsServiceName) - } - - fmt.Printf("Created route: http://%s -> port %d\n", hostname, port) - } - - if len(errors) > 0 { - return routes, fmt.Errorf("some routes failed: %s", strings.Join(errors, "; ")) - } - - return routes, nil -} - -// DestroySessionRoutes removes all Caddy routes for a session -func DestroySessionRoutes(sessionName string, routes map[string]string) error { - // Check if Caddy provisioning is enabled - if viper.GetBool("disable_caddy") { - return nil - } - - client := NewCaddyClient() - - // Check if Caddy is running - if err := client.CheckCaddyConnection(); err != nil { - fmt.Printf("Warning: Caddy not available, skipping route cleanup: %v\n", err) - return nil - } - - // Delete all routes for the session - if err := client.DeleteSessionRoutes(sessionName); err != nil { - return fmt.Errorf("failed to delete session routes: %w", err) - } - - fmt.Printf("Deleted Caddy routes for session '%s'\n", sessionName) - return nil -} - -// reorderRoutes moves specific routes before the catch-all routes -func reorderRoutes(client *CaddyClient) error { - // Get current routes - routes, err := client.GetAllRoutes() - if err != nil { - return err - } - - // Separate specific routes (with IDs) and catch-all routes (without IDs) - var specificRoutes, catchAllRoutes []Route - for _, route := range routes { - if route.ID != "" { - specificRoutes = append(specificRoutes, route) - } else { - catchAllRoutes = append(catchAllRoutes, route) - } - } - - // If all routes are already in the correct order, no need to reorder - if len(catchAllRoutes) == 0 || len(routes) == len(specificRoutes)+len(catchAllRoutes) { - // Check if catch-all routes are already at the end - foundCatchAll := false - for _, route := range routes { - if route.ID == "" { - foundCatchAll = true - } else if foundCatchAll { - // Found a specific route after a catch-all, need to reorder - break - } - } - if !foundCatchAll || routes[len(routes)-1].ID == "" { - // Already in correct order - return nil - } - } - - // Combine with specific routes first - orderedRoutes := append(specificRoutes, catchAllRoutes...) - - // Delete all routes and recreate in correct order - if err := client.ReplaceAllRoutes(orderedRoutes); err != nil { - return err - } - - return nil -} diff --git a/caddy/routes.go b/caddy/routes.go index 76919a7..b46c282 100644 --- a/caddy/routes.go +++ b/caddy/routes.go @@ -35,11 +35,6 @@ type RouteUpstream struct { Dial string `json:"dial"` } -// RouteResponse represents the response from Caddy when creating/updating routes -type RouteResponse struct { - ETag string `json:"etag,omitempty"` -} - // CaddyClient manages communication with Caddy's admin API type CaddyClient struct { client *resty.Client @@ -57,42 +52,10 @@ func NewCaddyClient() *CaddyClient { client := resty.New() client.SetTimeout(10 * time.Second) - c := &CaddyClient{ - client: client, - baseURL: caddyAPI, - } - c.discoverServerName() - return c -} - -// discoverServerName finds the HTTP server listening on :80. -// Falls back to "srv1" on any failure. -func (c *CaddyClient) discoverServerName() { - c.serverName = "srv1" // default fallback - - resp, err := c.client.R().Get(c.baseURL + "/config/apps/http/servers") - if err != nil || resp.StatusCode() != http.StatusOK { - return - } - - var servers map[string]json.RawMessage - if err := json.Unmarshal(resp.Body(), &servers); err != nil { - return - } - - for name, raw := range servers { - var srv struct { - Listen []string `json:"listen"` - } - if err := json.Unmarshal(raw, &srv); err != nil { - continue - } - for _, addr := range srv.Listen { - if strings.HasSuffix(addr, ":80") { - c.serverName = name - return - } - } + return &CaddyClient{ + client: client, + baseURL: caddyAPI, + serverName: "devx", } } @@ -101,125 +64,6 @@ func (c *CaddyClient) serverPath() string { return "/config/apps/http/servers/" + c.serverName } -// CreateRoute creates a route for a service -func (c *CaddyClient) CreateRoute(sessionName, serviceName string, port int) (string, error) { - return c.CreateRouteWithProject(sessionName, serviceName, port, "") -} - -// CreateRouteWithProject creates a route for a service with optional project prefix -func (c *CaddyClient) CreateRouteWithProject(sessionName, serviceName string, port int, projectAlias string) (string, error) { - // Use localhost for reliable resolution (works with both IPv4/IPv6) - upstreams := []RouteUpstream{ - {Dial: fmt.Sprintf("localhost:%d", port)}, - } - - // Sanitize session name for hostname compatibility - sanitizedSessionName := SanitizeHostname(sessionName) - - // Generate hostname with project prefix if provided - hostname := fmt.Sprintf("%s-%s.localhost", sanitizedSessionName, serviceName) - if projectAlias != "" { - hostname = fmt.Sprintf("%s-%s-%s.localhost", projectAlias, sanitizedSessionName, serviceName) - } - - // Generate route ID with project prefix if provided - routeID := fmt.Sprintf("sess-%s-%s", sanitizedSessionName, serviceName) - if projectAlias != "" { - routeID = fmt.Sprintf("sess-%s-%s-%s", projectAlias, sanitizedSessionName, serviceName) - } - - route := Route{ - ID: routeID, - Match: []RouteMatch{ - { - Host: []string{hostname}, - }, - }, - Handle: []RouteHandler{ - { - Handler: "reverse_proxy", - Upstreams: upstreams, - }, - }, - Terminal: true, - } - - // Convert to JSON - routeJSON, err := json.Marshal(route) - if err != nil { - return "", fmt.Errorf("failed to marshal route JSON: %w", err) - } - - // Use array append notation to avoid race conditions - // POST to /routes/- appends to the array, creating it if needed - resp, err := c.client.R(). - SetHeader("Content-Type", "application/json"). - SetBody(routeJSON). - Post(c.baseURL + c.serverPath() + "/routes/-") - - if err != nil { - return "", fmt.Errorf("failed to create route: %w", err) - } - - if resp.StatusCode() != http.StatusOK && resp.StatusCode() != http.StatusCreated { - return "", fmt.Errorf("caddy API returned status %d: %s", resp.StatusCode(), resp.String()) - } - - // Extract ETag from response headers - etag := resp.Header().Get("ETag") - return etag, nil -} - -// DeleteRoute deletes a route by ID -func (c *CaddyClient) DeleteRoute(routeID string) error { - url := fmt.Sprintf("%s/id/%s", c.baseURL, routeID) - - resp, err := c.client.R().Delete(url) - if err != nil { - return fmt.Errorf("failed to delete route: %w", err) - } - - if resp.StatusCode() != http.StatusOK && resp.StatusCode() != http.StatusNoContent && resp.StatusCode() != http.StatusNotFound { - return fmt.Errorf("caddy API returned status %d: %s", resp.StatusCode(), resp.String()) - } - - return nil -} - -// DeleteSessionRoutes deletes all routes for a session -func (c *CaddyClient) DeleteSessionRoutes(sessionName string) error { - // Get list of all routes to find session routes - routes, err := c.GetAllRoutes() - if err != nil { - return fmt.Errorf("failed to get routes: %w", err) - } - - // Sanitize session name for hostname compatibility - sanitizedSessionName := SanitizeHostname(sessionName) - - // Find and delete routes matching the session - // Check both with and without project prefix, using both original and sanitized session names - var errors []string - - for _, route := range routes { - // Match routes that contain the session name in the expected pattern - // This handles both sess-{session}-{service} and sess-{project}-{session}-{service} - // Check both original and sanitized session names for backward compatibility - if strings.Contains(route.ID, fmt.Sprintf("-%s-", sessionName)) || strings.HasPrefix(route.ID, fmt.Sprintf("sess-%s-", sessionName)) || - strings.Contains(route.ID, fmt.Sprintf("-%s-", sanitizedSessionName)) || strings.HasPrefix(route.ID, fmt.Sprintf("sess-%s-", sanitizedSessionName)) { - if err := c.DeleteRoute(route.ID); err != nil { - errors = append(errors, fmt.Sprintf("failed to delete route %s: %v", route.ID, err)) - } - } - } - - if len(errors) > 0 { - return fmt.Errorf("errors deleting routes: %s", strings.Join(errors, "; ")) - } - - return nil -} - // GetAllRoutes retrieves all routes from Caddy func (c *CaddyClient) GetAllRoutes() ([]Route, error) { resp, err := c.client.R().Get(c.baseURL + c.serverPath() + "/routes") @@ -251,43 +95,6 @@ func (c *CaddyClient) GetAllRoutes() ([]Route, error) { return routes, nil } -// EnsureRoutesArray initializes the server's routes array if it is null or missing. -// Caddy cannot append a route to a null RouteList, so this must be called before -// any route-creation batch. -func (c *CaddyClient) EnsureRoutesArray() error { - resp, err := c.client.R().Get(c.baseURL + c.serverPath()) - if err != nil { - return fmt.Errorf("failed to read server config: %w", err) - } - if resp.StatusCode() != http.StatusOK { - return fmt.Errorf("caddy API returned status %d reading server config: %s", resp.StatusCode(), resp.String()) - } - - var serverCfg map[string]json.RawMessage - if err := json.Unmarshal(resp.Body(), &serverCfg); err != nil { - return fmt.Errorf("failed to parse server config: %w", err) - } - - raw, exists := serverCfg["routes"] - if exists && strings.TrimSpace(string(raw)) != "null" { - return nil // routes array already present - } - - // PATCH with an empty routes array — merges into existing config - resp, err = c.client.R(). - SetHeader("Content-Type", "application/json"). - SetBody([]byte(`{"routes":[]}`)). - Patch(c.baseURL + c.serverPath()) - if err != nil { - return fmt.Errorf("failed to initialize routes array: %w", err) - } - if resp.StatusCode() != http.StatusOK { - return fmt.Errorf("caddy API returned status %d initializing routes: %s", resp.StatusCode(), resp.String()) - } - - return nil -} - // CheckCaddyConnection verifies that Caddy is running and accessible func (c *CaddyClient) CheckCaddyConnection() error { resp, err := c.client.R().Get(c.baseURL + "/config/") @@ -301,59 +108,3 @@ func (c *CaddyClient) CheckCaddyConnection() error { return nil } - -// GetServiceMapping maps port environment variable names to service names -func GetServiceMapping(portName string) string { - // Remove _PORT suffix if present - serviceName := strings.TrimSuffix(portName, "_PORT") - - // Convert to lowercase - serviceName = strings.ToLower(serviceName) - - // Apply special mappings - switch serviceName { - case "fe", "web", "frontend": - return "ui" - case "api", "backend": - return "api" - case "db", "database": - return "db" - default: - // Replace underscores with hyphens for multi-word services - return strings.ReplaceAll(serviceName, "_", "-") - } -} - -// ReplaceAllRoutes deletes all current routes and creates new ones in the specified order -func (c *CaddyClient) ReplaceAllRoutes(routes []Route) error { - // First, delete all existing routes - resp, err := c.client.R().Delete(c.baseURL + c.serverPath() + "/routes") - if err != nil { - return fmt.Errorf("failed to delete existing routes: %w", err) - } - - if resp.StatusCode() != http.StatusOK && resp.StatusCode() != http.StatusNoContent { - return fmt.Errorf("failed to delete routes: status %d", resp.StatusCode()) - } - - // Then create new routes in the correct order - routesJSON, err := json.Marshal(routes) - if err != nil { - return fmt.Errorf("failed to marshal routes: %w", err) - } - - resp, err = c.client.R(). - SetHeader("Content-Type", "application/json"). - SetBody(routesJSON). - Post(c.baseURL + c.serverPath() + "/routes") - - if err != nil { - return fmt.Errorf("failed to create routes: %w", err) - } - - if resp.StatusCode() != http.StatusOK && resp.StatusCode() != http.StatusCreated { - return fmt.Errorf("failed to create routes: status %d: %s", resp.StatusCode(), resp.String()) - } - - return nil -} diff --git a/caddy/routes_test.go b/caddy/routes_test.go index 609930c..04b4267 100644 --- a/caddy/routes_test.go +++ b/caddy/routes_test.go @@ -4,7 +4,6 @@ import ( "encoding/json" "net/http" "net/http/httptest" - "sync" "testing" "time" @@ -55,33 +54,6 @@ func TestRouteGeneration(t *testing.T) { } } -func TestGetServiceMapping(t *testing.T) { - tests := []struct { - portName string - expected string - }{ - {"FE_PORT", "ui"}, - {"WEB_PORT", "ui"}, - {"FRONTEND", "ui"}, - {"API_PORT", "api"}, - {"BACKEND", "api"}, - {"DB_PORT", "db"}, - {"DATABASE", "db"}, - {"REDIS_PORT", "redis"}, - {"AUTH_SERVICE_PORT", "auth-service"}, - {"PAYMENT_PORT", "payment"}, - {"CUSTOM_THING_PORT", "custom-thing"}, - } - - for _, test := range tests { - result := GetServiceMapping(test.portName) - if result != test.expected { - t.Errorf("GetServiceMapping(%s) = %s, expected %s", - test.portName, result, test.expected) - } - } -} - func TestSanitizeHostname(t *testing.T) { tests := []struct { input string @@ -163,7 +135,7 @@ func contains(s, substr string) bool { } // newTestClient creates a CaddyClient wired to the given httptest.Server, -// bypassing NewCaddyClient (which uses viper and does live discovery). +// bypassing NewCaddyClient (which uses viper). func newTestClient(ts *httptest.Server, serverName string) *CaddyClient { client := resty.New() client.SetTimeout(5 * time.Second) @@ -174,159 +146,6 @@ func newTestClient(ts *httptest.Server, serverName string) *CaddyClient { } } -// --- discoverServerName tests --- - -func TestDiscoverServerName(t *testing.T) { - t.Run("finds srv1 with :80", func(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _ = json.NewEncoder(w).Encode(map[string]any{ - "srv0": map[string]any{"listen": []string{":443"}}, - "srv1": map[string]any{"listen": []string{":80"}}, - }) - })) - defer ts.Close() - - c := newTestClient(ts, "placeholder") - c.discoverServerName() - if c.serverName != "srv1" { - t.Errorf("expected srv1, got %s", c.serverName) - } - }) - - t.Run("finds srv0 with :80", func(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _ = json.NewEncoder(w).Encode(map[string]any{ - "srv0": map[string]any{"listen": []string{":80"}}, - "srv1": map[string]any{"listen": []string{":443"}}, - }) - })) - defer ts.Close() - - c := newTestClient(ts, "placeholder") - c.discoverServerName() - if c.serverName != "srv0" { - t.Errorf("expected srv0, got %s", c.serverName) - } - }) - - t.Run("does not match :8080 as :80", func(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _ = json.NewEncoder(w).Encode(map[string]any{ - "wrong": map[string]any{"listen": []string{":8080"}}, - "right": map[string]any{"listen": []string{":80"}}, - }) - })) - defer ts.Close() - - c := newTestClient(ts, "placeholder") - c.discoverServerName() - if c.serverName != "right" { - t.Errorf("expected right, got %s", c.serverName) - } - }) - - t.Run("falls back to srv1 when no :80 server", func(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _ = json.NewEncoder(w).Encode(map[string]any{ - "myserver": map[string]any{"listen": []string{":443"}}, - }) - })) - defer ts.Close() - - c := newTestClient(ts, "placeholder") - c.discoverServerName() - if c.serverName != "srv1" { - t.Errorf("expected fallback srv1, got %s", c.serverName) - } - }) - - t.Run("falls back to srv1 on API error", func(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - })) - defer ts.Close() - - c := newTestClient(ts, "placeholder") - c.discoverServerName() - if c.serverName != "srv1" { - t.Errorf("expected fallback srv1, got %s", c.serverName) - } - }) -} - -// --- EnsureRoutesArray tests --- - -func TestEnsureRoutesArray(t *testing.T) { - t.Run("routes already exists — no PATCH", func(t *testing.T) { - patched := false - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { - _, _ = w.Write([]byte(`{"listen":[":80"],"routes":[]}`)) - return - } - if r.Method == http.MethodPatch { - patched = true - } - w.WriteHeader(http.StatusOK) - })) - defer ts.Close() - - c := newTestClient(ts, "srv1") - if err := c.EnsureRoutesArray(); err != nil { - t.Fatalf("unexpected error: %v", err) - } - if patched { - t.Error("expected no PATCH when routes already exists") - } - }) - - t.Run("routes is null — sends PATCH", func(t *testing.T) { - patched := false - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { - _, _ = w.Write([]byte(`{"listen":[":80"],"routes":null}`)) - return - } - if r.Method == http.MethodPatch { - patched = true - } - w.WriteHeader(http.StatusOK) - })) - defer ts.Close() - - c := newTestClient(ts, "srv1") - if err := c.EnsureRoutesArray(); err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !patched { - t.Error("expected PATCH when routes is null") - } - }) - - t.Run("routes key missing — sends PATCH", func(t *testing.T) { - patched := false - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { - _, _ = w.Write([]byte(`{"listen":[":80"]}`)) - return - } - if r.Method == http.MethodPatch { - patched = true - } - w.WriteHeader(http.StatusOK) - })) - defer ts.Close() - - c := newTestClient(ts, "srv1") - if err := c.EnsureRoutesArray(); err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !patched { - t.Error("expected PATCH when routes key is missing") - } - }) -} - // --- GetAllRoutes null/404 handling tests --- func TestGetAllRoutesNullResponse(t *testing.T) { @@ -380,72 +199,3 @@ func TestGetAllRoutesNullResponse(t *testing.T) { } }) } - -// --- serverPath tests --- - -func TestServerPath(t *testing.T) { - c := &CaddyClient{serverName: "myserver"} - expected := "/config/apps/http/servers/myserver" - if got := c.serverPath(); got != expected { - t.Errorf("serverPath() = %q, want %q", got, expected) - } -} - -// --- Integration: routes use discovered server path --- - -func TestRoutesUseDiscoveredServer(t *testing.T) { - var mu sync.Mutex - var requestPaths []string - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - mu.Lock() - requestPaths = append(requestPaths, r.URL.Path) - mu.Unlock() - - // Discovery endpoint - if r.URL.Path == "/config/apps/http/servers" { - _ = json.NewEncoder(w).Encode(map[string]any{ - "myhttp": map[string]any{"listen": []string{":80"}}, - }) - return - } - - // Routes GET - if r.Method == http.MethodGet { - _, _ = w.Write([]byte("[]")) - return - } - - // Routes POST - w.WriteHeader(http.StatusOK) - })) - defer ts.Close() - - client := resty.New() - client.SetTimeout(5 * time.Second) - c := &CaddyClient{ - client: client, - baseURL: ts.URL, - } - c.discoverServerName() - - if c.serverName != "myhttp" { - t.Fatalf("expected myhttp, got %s", c.serverName) - } - - // GetAllRoutes should use /config/apps/http/servers/myhttp/routes - _, _ = c.GetAllRoutes() - - mu.Lock() - defer mu.Unlock() - found := false - for _, p := range requestPaths { - if p == "/config/apps/http/servers/myhttp/routes" { - found = true - break - } - } - if !found { - t.Errorf("expected request to /config/apps/http/servers/myhttp/routes, got paths: %v", requestPaths) - } -} diff --git a/cmd/caddy.go b/cmd/caddy.go index 0701051..645f31f 100644 --- a/cmd/caddy.go +++ b/cmd/caddy.go @@ -35,36 +35,18 @@ func init() { } func runCaddyCheck(cmd *cobra.Command, args []string) error { - // Load sessions + // Load sessions and project registry store, err := session.LoadSessions() if err != nil { return fmt.Errorf("failed to load sessions: %w", err) } - // Load project registry to get project aliases registry, err := config.LoadProjectRegistry() if err != nil { return fmt.Errorf("failed to load project registry: %w", err) } - // Convert sessions to format needed by health check - sessionInfos := make(map[string]*caddy.SessionInfo) - for name, sess := range store.Sessions { - info := &caddy.SessionInfo{ - Name: name, - Ports: sess.Ports, - } - - // Find project alias if session is in a project - for alias, project := range registry.Projects { - if sess.ProjectPath == project.Path { - info.ProjectAlias = alias - break - } - } - - sessionInfos[name] = info - } + sessionInfos := buildSessionInfoMap(store, registry) // Perform health check result, err := caddy.CheckCaddyHealth(sessionInfos) @@ -76,14 +58,14 @@ func runCaddyCheck(cmd *cobra.Command, args []string) error { displayHealthCheckResults(result) // Fix issues if requested - if fixFlag && (result.RoutesNeeded > result.RoutesExisting || result.CatchAllFirst) { - fmt.Println("\nAttempting to fix issues...") - if err := caddy.RepairRoutes(result, sessionInfos); err != nil { - return fmt.Errorf("failed to repair routes: %w", err) + if fixFlag { + fmt.Println("\nSyncing Caddy config...") + if err := syncAllCaddyRoutes(); err != nil { + return fmt.Errorf("failed to sync routes: %w", err) } // Re-run health check to show updated status - fmt.Println("\nRechecking after repairs...") + fmt.Println("\nRechecking after sync...") result, err = caddy.CheckCaddyHealth(sessionInfos) if err != nil { return fmt.Errorf("failed to recheck Caddy health: %w", err) @@ -102,7 +84,8 @@ func displayHealthCheckResults(result *caddy.HealthCheckResult) { } else { fmt.Printf("✗ Caddy is not running: %s\n", result.CaddyError) fmt.Println("\nTo start Caddy, ensure it's installed and run:") - fmt.Println(" caddy run --config ~/.config/devx/Caddyfile") + fmt.Println(" caddy run --config ~/.config/devx/caddy-config.json") + fmt.Println(" (Run 'devx caddy check --fix' first to generate the config file)") return } @@ -110,15 +93,6 @@ func displayHealthCheckResults(result *caddy.HealthCheckResult) { fmt.Printf("\n=== Route Summary ===\n") fmt.Printf("Routes needed: %d\n", result.RoutesNeeded) fmt.Printf("Routes existing: %d\n", result.RoutesExisting) - fmt.Printf("Routes working: %d\n", result.RoutesWorking) - - if result.CatchAllFirst { - fmt.Println("\n⚠️ WARNING: Catch-all route is blocking specific routes!") - fmt.Println(" This prevents session hostnames from working properly.") - if !fixFlag { - fmt.Println(" Run with --fix to repair this issue.") - } - } // Display individual route status if len(result.RouteStatuses) > 0 { @@ -130,11 +104,7 @@ func displayHealthCheckResults(result *caddy.HealthCheckResult) { for _, status := range result.RouteStatuses { statusText := "✗ Missing" if status.Exists { - if status.IsFirst || !result.CatchAllFirst { - statusText = "✓ Configured" - } else { - statusText = "⚠️ Blocked" - } + statusText = "✓ Active" } fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%s\n", @@ -162,7 +132,7 @@ func displayHealthCheckResults(result *caddy.HealthCheckResult) { // Final status fmt.Println() - if result.RoutesNeeded == result.RoutesExisting && !result.CatchAllFirst { + if result.RoutesNeeded == result.RoutesExisting { fmt.Println("✓ All routes are properly configured") } else { fmt.Println("✗ Some issues were found with Caddy routes") diff --git a/cmd/caddy_sync.go b/cmd/caddy_sync.go new file mode 100644 index 0000000..2573dd5 --- /dev/null +++ b/cmd/caddy_sync.go @@ -0,0 +1,47 @@ +package cmd + +import ( + "fmt" + + "github.com/jfox85/devx/caddy" + "github.com/jfox85/devx/config" + "github.com/jfox85/devx/session" +) + +// buildSessionInfoMap converts stored sessions and project registry into +// the caddy.SessionInfo map needed by CheckCaddyHealth and SyncRoutes. +func buildSessionInfoMap(store *session.SessionStore, registry *config.ProjectRegistry) map[string]*caddy.SessionInfo { + sessionInfos := make(map[string]*caddy.SessionInfo) + for name, sess := range store.Sessions { + info := &caddy.SessionInfo{ + Name: name, + Ports: sess.Ports, + } + + for alias, project := range registry.Projects { + if sess.ProjectPath == project.Path { + info.ProjectAlias = alias + break + } + } + + sessionInfos[name] = info + } + return sessionInfos +} + +// syncAllCaddyRoutes loads all sessions and syncs Caddy routes. +// This is called after session create and session remove. +func syncAllCaddyRoutes() error { + store, err := session.LoadSessions() + if err != nil { + return fmt.Errorf("failed to load sessions for Caddy sync: %w", err) + } + + registry, err := config.LoadProjectRegistry() + if err != nil { + return fmt.Errorf("failed to load project registry: %w", err) + } + + return caddy.SyncRoutes(buildSessionInfoMap(store, registry)) +} diff --git a/cmd/session_create.go b/cmd/session_create.go index bbd2084..9ee2532 100644 --- a/cmd/session_create.go +++ b/cmd/session_create.go @@ -216,26 +216,14 @@ func runSessionCreate(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to save session metadata: %w", err) } - // Provision Caddy routes first to get hostnames - routes, err := caddy.ProvisionSessionRoutesWithProject(name, portAllocation.Ports, projectAlias) - if err != nil { - fmt.Printf("Warning: %v\n", err) - } - - // Convert routes to hostnames for environment variables + // Build hostname map for environment variables hostnames := make(map[string]string) - if len(routes) > 0 { - for serviceName := range routes { - // Use the DNS-normalized service name for the hostname - dnsServiceName := caddy.NormalizeDNSName(serviceName) - // Sanitize session name for hostname compatibility - sanitizedSessionName := caddy.SanitizeHostname(name) - if projectAlias != "" { - hostnames[serviceName] = fmt.Sprintf("%s-%s-%s.localhost", projectAlias, sanitizedSessionName, dnsServiceName) - } else { - hostnames[serviceName] = fmt.Sprintf("%s-%s.localhost", sanitizedSessionName, dnsServiceName) - } + for serviceName := range portAllocation.Ports { + hostname := caddy.BuildHostname(name, serviceName, projectAlias) + if hostname == "" { + continue } + hostnames[serviceName] = hostname } // Generate .envrc file @@ -259,20 +247,20 @@ func runSessionCreate(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to generate tmuxp config: %w", err) } - // Update session with route information - if len(routes) > 0 { + // Update session with hostname information + if len(hostnames) > 0 { if err := store.UpdateSession(name, func(s *session.Session) { - if s.Routes == nil { - s.Routes = make(map[string]string) - } - for service, routeID := range routes { - s.Routes[service] = routeID - } + s.Routes = hostnames }); err != nil { - fmt.Printf("Warning: failed to save route information: %v\n", err) + fmt.Printf("Warning: failed to update session routes: %v\n", err) } } + // Sync all Caddy routes (writes config file + reloads) + if err := syncAllCaddyRoutes(); err != nil { + fmt.Printf("Warning: %v\n", err) + } + fmt.Printf("Created session '%s' at %s\n", name, worktreePath) if len(portAllocation.Ports) > 0 { fmt.Printf("Allocated ports:") diff --git a/cmd/session_rm.go b/cmd/session_rm.go index fa9e1a1..d2ea570 100644 --- a/cmd/session_rm.go +++ b/cmd/session_rm.go @@ -5,7 +5,6 @@ import ( "os" "os/exec" - "github.com/jfox85/devx/caddy" "github.com/jfox85/devx/session" "github.com/spf13/cobra" ) @@ -66,13 +65,6 @@ func runSessionRm(cmd *cobra.Command, args []string) error { fmt.Printf("Warning: failed to kill tmux session: %v\n", err) } - // Remove Caddy routes - if len(sess.Routes) > 0 { - if err := caddy.DestroySessionRoutes(name, sess.Routes); err != nil { - fmt.Printf("Warning: failed to remove Caddy routes: %v\n", err) - } - } - // Run cleanup command if configured if err := session.RunCleanupCommandForShell(sess); err != nil { fmt.Printf("Warning: cleanup command failed: %v\n", err) @@ -88,6 +80,11 @@ func runSessionRm(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to remove session metadata: %w", err) } + // Sync Caddy routes after removal + if err := syncAllCaddyRoutes(); err != nil { + fmt.Printf("Warning: failed to sync Caddy routes: %v\n", err) + } + fmt.Printf("Removed session '%s'\n", name) return nil } diff --git a/docs/plans/2026-02-12-caddy-config-file-management-design.md b/docs/plans/2026-02-12-caddy-config-file-management-design.md new file mode 100644 index 0000000..78013e2 --- /dev/null +++ b/docs/plans/2026-02-12-caddy-config-file-management-design.md @@ -0,0 +1,100 @@ +# Caddy Config File Management + +Replace API-based Caddy route management with config file generation and `caddy reload`. + +## Problem + +Routes are currently managed via Caddy's Admin API at runtime. These routes don't survive Caddy restarts (the Caddyfile has no routes), and a catch-all route from the empty `:80 {}` block can end up ordered before session routes, blocking all traffic. + +## Solution + +A new `SyncRoutes()` function builds the full Caddy JSON config from `sessions.json`, writes it atomically to `~/.config/devx/caddy-config.json`, and runs `caddy reload`. This is called after session create, session remove, and `caddy check --fix`. + +## Config Structure + +```json +{ + "admin": { "listen": "localhost:2019" }, + "apps": { + "http": { + "servers": { + "devx": { + "listen": [":80"], + "routes": [ + { + "@id": "sess-toneclone-jf-add-mcp-frontend", + "match": [{"host": ["toneclone-jf-add-mcp-frontend.localhost"]}], + "handle": [{"handler": "reverse_proxy", "upstreams": [{"dial": "localhost:57895"}]}], + "terminal": true + } + ] + } + } + } + } +} +``` + +Session routes are generated in deterministic order. No catch-all route exists, eliminating ordering issues entirely. + +## New Function: `SyncRoutes()` + +```go +func SyncRoutes(sessions map[string]*SessionInfo) error +``` + +1. Build full Caddy JSON config with all session routes +2. Write atomically to `~/.config/devx/caddy-config.json` (temp file + rename) +3. Run `caddy reload --config ` +4. If Caddy isn't running, warn (config is written for next start) + +## Caller Changes + +| Operation | Before | After | +|-----------|--------|-------| +| Session create | `ProvisionSessionRoutes()` per session | `SyncRoutes(allSessions)` | +| Session remove | `DestroySessionRoutes()` per session | `SyncRoutes(allSessions)` | +| `caddy check --fix` | `RepairRoutes()` (reorder + create missing) | `SyncRoutes(allSessions)` | + +## Deleted Code + +- `CreateRoute`, `CreateRouteWithProject` -- no more per-route API calls +- `DeleteRoute`, `DeleteSessionRoutes` -- no more per-route deletion +- `ReplaceAllRoutes` -- no more bulk API replacement +- `EnsureRoutesArray` -- no null array concern +- `reorderRoutes` -- ordering handled at generation time +- `discoverServerName` -- server name is always `devx` +- `RepairRoutes` -- replaced by `SyncRoutes` +- `ProvisionSessionRoutes`, `ProvisionSessionRoutesWithProject` -- replaced by `SyncRoutes` +- `DestroySessionRoutes` -- replaced by `SyncRoutes` + +## Kept Code + +- `CheckCaddyConnection` -- health checks +- `GetAllRoutes` -- comparing expected vs actual in `caddy check` +- `NormalizeDNSName`, `SanitizeHostname` -- hostname generation +- `Route`, `RouteMatch`, `RouteHandler`, `RouteUpstream` structs -- used by config generation + +## Health Check Simplification + +`devx caddy check` compares the generated config against what Caddy is actually running. States: + +1. Caddy not running +2. Config matches -- all good +3. Config drifted -- `--fix` calls `SyncRoutes()` to regenerate and reload + +Per-route "Blocked" status is eliminated. Routes are either "Active" or "Missing". + +## Error Handling + +- **Caddy not available**: Session operations still succeed. Config file is written so next Caddy start picks up correct routes. +- **Concurrent creation**: Last writer wins. Both sessions are in `sessions.json` before `SyncRoutes` runs, so the final config is correct. +- **Empty state**: Config generated with zero routes, just the base server block. +- **`disable_caddy: true`**: `SyncRoutes` returns early, no file written. + +## File Changes + +- **Retired**: `~/.config/devx/Caddyfile` (no longer used) +- **Updated**: `~/.config/devx/caddy-start.sh` (use `caddy-config.json` instead of `Caddyfile`) +- **New**: `~/.config/devx/caddy-config.json` (generated, not checked in) +- **New**: `caddy/config.go` (config generation + `SyncRoutes`) diff --git a/docs/plans/2026-02-12-caddy-config-file-management.md b/docs/plans/2026-02-12-caddy-config-file-management.md new file mode 100644 index 0000000..7b85b5d --- /dev/null +++ b/docs/plans/2026-02-12-caddy-config-file-management.md @@ -0,0 +1,1397 @@ +# Caddy Config File Management — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Replace API-based Caddy route management with config file generation + `caddy reload` so routes survive restarts and ordering bugs are eliminated. + +**Architecture:** A new `SyncRoutes()` function builds a complete Caddy JSON config from `sessions.json`, writes it atomically to `~/.config/devx/caddy-config.json`, and runs `caddy reload`. All per-route API calls (`CreateRoute`, `DeleteRoute`, `reorderRoutes`, etc.) are deleted. The `CaddyClient` is kept only for health checks (`CheckCaddyConnection`, `GetAllRoutes`). + +**Tech Stack:** Go, Caddy JSON config format, `os/exec` for `caddy reload` + +**Design doc:** `docs/plans/2026-02-12-caddy-config-file-management-design.md` + +--- + +### Task 1: Create `caddy/config.go` with `BuildCaddyConfig` and `SyncRoutes` + +This is the core new file. It builds the full Caddy JSON config and writes it atomically. + +**Files:** +- Create: `caddy/config.go` +- Create: `caddy/config_test.go` + +**Step 1: Write failing tests for `BuildCaddyConfig`** + +Create `caddy/config_test.go`: + +```go +package caddy + +import ( + "encoding/json" + "testing" +) + +func TestBuildCaddyConfig(t *testing.T) { + // Ensure clean Viper state for all subtests + viper.Set("caddy_admin", "") + + t.Run("empty sessions produces valid config with no routes", func(t *testing.T) { + sessions := map[string]*SessionInfo{} + config := BuildCaddyConfig(sessions) + + jsonData, err := json.Marshal(config) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + + jsonStr := string(jsonData) + // Should have admin listener + if !contains(jsonStr, `"listen":"localhost:2019"`) { + t.Errorf("missing admin listener in config: %s", jsonStr) + } + // Should have server listening on :80 + if !contains(jsonStr, `":80"`) { + t.Errorf("missing :80 listener in config: %s", jsonStr) + } + // Routes should be empty array, not null + if !contains(jsonStr, `"routes":[]`) { + t.Errorf("expected empty routes array in config: %s", jsonStr) + } + }) + + t.Run("single session produces correct routes", func(t *testing.T) { + sessions := map[string]*SessionInfo{ + "my-session": { + Name: "my-session", + Ports: map[string]int{"FRONTEND": 3000, "BACKEND": 4000}, + }, + } + config := BuildCaddyConfig(sessions) + + jsonData, err := json.Marshal(config) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + + jsonStr := string(jsonData) + if !contains(jsonStr, `my-session-frontend.localhost`) { + t.Errorf("missing frontend hostname: %s", jsonStr) + } + if !contains(jsonStr, `my-session-backend.localhost`) { + t.Errorf("missing backend hostname: %s", jsonStr) + } + if !contains(jsonStr, `localhost:3000`) { + t.Errorf("missing frontend port: %s", jsonStr) + } + if !contains(jsonStr, `localhost:4000`) { + t.Errorf("missing backend port: %s", jsonStr) + } + }) + + t.Run("session with project alias includes prefix", func(t *testing.T) { + sessions := map[string]*SessionInfo{ + "my-session": { + Name: "my-session", + Ports: map[string]int{"FRONTEND": 3000}, + ProjectAlias: "myproject", + }, + } + config := BuildCaddyConfig(sessions) + + jsonData, err := json.Marshal(config) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + + jsonStr := string(jsonData) + if !contains(jsonStr, `myproject-my-session-frontend.localhost`) { + t.Errorf("missing project-prefixed hostname: %s", jsonStr) + } + if !contains(jsonStr, `sess-myproject-my-session-frontend`) { + t.Errorf("missing project-prefixed route ID: %s", jsonStr) + } + }) + + t.Run("route IDs and hostnames are deterministically ordered", func(t *testing.T) { + sessions := map[string]*SessionInfo{ + "b-session": { + Name: "b-session", + Ports: map[string]int{"UI": 3000}, + }, + "a-session": { + Name: "a-session", + Ports: map[string]int{"UI": 4000}, + }, + } + config1 := BuildCaddyConfig(sessions) + config2 := BuildCaddyConfig(sessions) + + json1, _ := json.Marshal(config1) + json2, _ := json.Marshal(config2) + + if string(json1) != string(json2) { + t.Errorf("config generation is not deterministic") + } + }) + + t.Run("session with slashes in name is sanitized", func(t *testing.T) { + sessions := map[string]*SessionInfo{ + "feature/my-branch": { + Name: "feature/my-branch", + Ports: map[string]int{"FRONTEND": 3000}, + }, + } + config := BuildCaddyConfig(sessions) + + jsonData, _ := json.Marshal(config) + jsonStr := string(jsonData) + // Slashes should be converted to hyphens + if !contains(jsonStr, `feature-my-branch-frontend.localhost`) { + t.Errorf("session name with slash not properly sanitized: %s", jsonStr) + } + }) + + t.Run("session with empty ports produces no routes", func(t *testing.T) { + sessions := map[string]*SessionInfo{ + "empty": { + Name: "empty", + Ports: map[string]int{}, + }, + } + config := BuildCaddyConfig(sessions) + + routes := config.Apps.HTTP.Servers["devx"].Routes + if len(routes) != 0 { + t.Errorf("expected 0 routes for empty ports, got %d", len(routes)) + } + }) +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `go test ./caddy/ -run TestBuildCaddyConfig -v` +Expected: FAIL — `BuildCaddyConfig` not defined + +**Step 3: Implement `BuildCaddyConfig` and `SyncRoutes`** + +Create `caddy/config.go`: + +```go +package caddy + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + + "github.com/spf13/viper" +) + +// CaddyConfig represents the full Caddy JSON configuration +type CaddyConfig struct { + Admin CaddyAdmin `json:"admin"` + Apps CaddyApps `json:"apps"` +} + +// CaddyAdmin represents the admin API configuration +type CaddyAdmin struct { + Listen string `json:"listen"` +} + +// CaddyApps contains the HTTP app configuration +type CaddyApps struct { + HTTP CaddyHTTP `json:"http"` +} + +// CaddyHTTP contains the HTTP server configuration +type CaddyHTTP struct { + Servers map[string]CaddyServer `json:"servers"` +} + +// CaddyServer represents a single HTTP server +type CaddyServer struct { + Listen []string `json:"listen"` + Routes []Route `json:"routes"` +} + +// BuildCaddyConfig generates the complete Caddy JSON config from session data +func BuildCaddyConfig(sessions map[string]*SessionInfo) CaddyConfig { + adminListen := viper.GetString("caddy_admin") + if adminListen == "" { + adminListen = "localhost:2019" + } + + routes := buildRoutes(sessions) + + return CaddyConfig{ + Admin: CaddyAdmin{Listen: adminListen}, + Apps: CaddyApps{ + HTTP: CaddyHTTP{ + Servers: map[string]CaddyServer{ + "devx": { + Listen: []string{":80"}, + Routes: routes, + }, + }, + }, + }, + } +} + +// buildRoutes generates all session routes in deterministic order +func buildRoutes(sessions map[string]*SessionInfo) []Route { + var routes []Route + + // Sort session names for deterministic output + sessionNames := make([]string, 0, len(sessions)) + for name := range sessions { + sessionNames = append(sessionNames, name) + } + sort.Strings(sessionNames) + + for _, sessionName := range sessionNames { + info := sessions[sessionName] + sanitizedSession := SanitizeHostname(sessionName) + + // Sort service names for deterministic output + serviceNames := make([]string, 0, len(info.Ports)) + for svc := range info.Ports { + serviceNames = append(serviceNames, svc) + } + sort.Strings(serviceNames) + + for _, serviceName := range serviceNames { + port := info.Ports[serviceName] + dnsService := NormalizeDNSName(serviceName) + if dnsService == "" { + continue + } + + hostname := fmt.Sprintf("%s-%s.localhost", sanitizedSession, dnsService) + routeID := fmt.Sprintf("sess-%s-%s", sanitizedSession, dnsService) + if info.ProjectAlias != "" { + hostname = fmt.Sprintf("%s-%s-%s.localhost", info.ProjectAlias, sanitizedSession, dnsService) + routeID = fmt.Sprintf("sess-%s-%s-%s", info.ProjectAlias, sanitizedSession, dnsService) + } + + routes = append(routes, Route{ + ID: routeID, + Match: []RouteMatch{ + {Host: []string{hostname}}, + }, + Handle: []RouteHandler{ + { + Handler: "reverse_proxy", + Upstreams: []RouteUpstream{{Dial: fmt.Sprintf("localhost:%d", port)}}, + }, + }, + Terminal: true, + }) + } + } + + if routes == nil { + routes = []Route{} + } + + return routes +} + +// configPath returns the path to the generated Caddy config file +func configPath() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".config", "devx", "caddy-config.json") +} + +// SyncRoutes generates the Caddy config file and reloads Caddy. +// It writes the config even if Caddy is not running, so the next +// Caddy start picks up the correct routes. +func SyncRoutes(sessions map[string]*SessionInfo) error { + if viper.GetBool("disable_caddy") { + return nil + } + + config := BuildCaddyConfig(sessions) + + cfgPath := configPath() + if cfgPath == "" { + return fmt.Errorf("could not determine config path") + } + + // Marshal config + jsonData, err := json.MarshalIndent(config, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal Caddy config: %w", err) + } + + // Atomic write: temp file + rename + // Note: os.CreateTemp creates with 0600 permissions, which is appropriate + // for a local config file (owner read/write only) + dir := filepath.Dir(cfgPath) + tmpFile, err := os.CreateTemp(dir, "caddy-config-*.json") + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + tmpPath := tmpFile.Name() + + if _, err := tmpFile.Write(jsonData); err != nil { + tmpFile.Close() + os.Remove(tmpPath) + return fmt.Errorf("failed to write config: %w", err) + } + if err := tmpFile.Close(); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("failed to close temp file: %w", err) + } + + if err := os.Rename(tmpPath, cfgPath); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("failed to rename config file: %w", err) + } + + // Try to reload Caddy + if err := reloadCaddy(cfgPath); err != nil { + fmt.Printf("Warning: Caddy reload failed (config saved for next start): %v\n", err) + } + + return nil +} + +// reloadCaddy runs `caddy reload` pointing at the config file. +func reloadCaddy(cfgPath string) error { + cmd := exec.Command("caddy", "reload", "--config", cfgPath) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("%w: %s", err, string(output)) + } + return nil +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `go test ./caddy/ -run TestBuildCaddyConfig -v` +Expected: All 4 tests PASS + +**Step 5: Commit** + +```bash +git add caddy/config.go caddy/config_test.go +git commit -m "feat: add Caddy config file generation with SyncRoutes" +``` + +--- + +### Task 2: Write `SyncRoutes` tests + +Test the full `SyncRoutes` flow: config writing, atomic write behavior, and `disable_caddy` flag. + +**Files:** +- Modify: `caddy/config_test.go` + +**Step 1: Add `SyncRoutes` tests** + +Append to `caddy/config_test.go`: + +```go +func TestSyncRoutes(t *testing.T) { + t.Run("writes config file", func(t *testing.T) { + // Use a temp dir to avoid writing to real config + tmpDir := t.TempDir() + t.Setenv("HOME", tmpDir) + + // Create the config directory + configDir := filepath.Join(tmpDir, ".config", "devx") + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatalf("failed to create config dir: %v", err) + } + + sessions := map[string]*SessionInfo{ + "test-session": { + Name: "test-session", + Ports: map[string]int{"FRONTEND": 3000}, + }, + } + + err := SyncRoutes(sessions) + // SyncRoutes may warn about caddy reload failing, that's OK + if err != nil { + t.Fatalf("SyncRoutes failed: %v", err) + } + + // Verify config file was written + cfgFile := filepath.Join(configDir, "caddy-config.json") + data, err := os.ReadFile(cfgFile) + if err != nil { + t.Fatalf("config file not written: %v", err) + } + + // Verify it's valid JSON with expected content + var config CaddyConfig + if err := json.Unmarshal(data, &config); err != nil { + t.Fatalf("config file is not valid JSON: %v", err) + } + + if len(config.Apps.HTTP.Servers["devx"].Routes) != 1 { + t.Errorf("expected 1 route, got %d", len(config.Apps.HTTP.Servers["devx"].Routes)) + } + }) + + t.Run("skips when disable_caddy is true", func(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HOME", tmpDir) + + viper.Set("disable_caddy", true) + defer viper.Set("disable_caddy", false) + + sessions := map[string]*SessionInfo{ + "test": {Name: "test", Ports: map[string]int{"UI": 3000}}, + } + + err := SyncRoutes(sessions) + if err != nil { + t.Fatalf("SyncRoutes should not error when disabled: %v", err) + } + + // Config file should NOT exist + cfgFile := filepath.Join(tmpDir, ".config", "devx", "caddy-config.json") + if _, err := os.Stat(cfgFile); !os.IsNotExist(err) { + t.Error("config file should not be written when caddy is disabled") + } + }) +} +``` + +Add these imports to the test file's import block: `"os"`, `"path/filepath"`, `"github.com/spf13/viper"`. + +**Step 2: Run tests** + +Run: `go test ./caddy/ -run TestSyncRoutes -v` +Expected: PASS (the `caddy reload` will fail in tests but `SyncRoutes` handles that gracefully) + +**Step 3: Commit** + +```bash +git add caddy/config_test.go +git commit -m "test: add SyncRoutes tests for config file writing" +``` + +--- + +### Task 3: Update `cmd/session_create.go` to use `SyncRoutes` + +Replace `ProvisionSessionRoutesWithProject` call with `SyncRoutes`. + +**Files:** +- Modify: `cmd/session_create.go:219-270` + +**Step 1: Replace the Caddy provisioning block** + +In `cmd/session_create.go`, replace lines 219-270 (the entire Caddy provisioning + hostname generation + route update block) with: + +```go + // Build hostname map for environment variables + hostnames := make(map[string]string) + for serviceName := range portAllocation.Ports { + dnsServiceName := caddy.NormalizeDNSName(serviceName) + sanitizedSessionName := caddy.SanitizeHostname(name) + if projectAlias != "" { + hostnames[serviceName] = fmt.Sprintf("%s-%s-%s.localhost", projectAlias, sanitizedSessionName, dnsServiceName) + } else { + hostnames[serviceName] = fmt.Sprintf("%s-%s.localhost", sanitizedSessionName, dnsServiceName) + } + } + + // Generate .envrc file + envData := session.EnvrcData{ + Ports: portAllocation.Ports, + Routes: hostnames, + Name: name, + } + if err := session.GenerateEnvrc(worktreePath, envData); err != nil { + return fmt.Errorf("failed to generate .envrc: %w", err) + } + + // Generate tmuxp config + tmuxpData := session.TmuxpData{ + Name: name, + Path: worktreePath, + Ports: portAllocation.Ports, + Routes: hostnames, + } + if err := session.GenerateTmuxpConfig(worktreePath, tmuxpData); err != nil { + return fmt.Errorf("failed to generate tmuxp config: %w", err) + } + + // Sync all Caddy routes (writes config file + reloads) + if err := syncAllCaddyRoutes(); err != nil { + fmt.Printf("Warning: %v\n", err) + } +``` + +This removes the `ProvisionSessionRoutesWithProject` call and the route-to-hostname conversion (hostnames are now computed directly). + +**IMPORTANT:** Keep the `store.UpdateSession` block (lines 262-270) but change it to save **hostnames** instead of route IDs. `sess.Routes` is read by cleanup.go (HOST env vars), TUI (openRoutes, loadHostnames), and session_list.go. Replace: + +```go + // Update session with route information + if len(hostnames) > 0 { + if err := store.UpdateSession(name, func(s *session.Session) { + s.Routes = hostnames + }); err != nil { + fmt.Printf("Warning: failed to update session routes: %v\n", err) + } + } +``` + +**Step 2: Add the `syncAllCaddyRoutes` helper function** + +Add to `cmd/session_create.go` (or a shared location like `cmd/caddy_helpers.go` — but since it's also needed in `session_rm.go`, put it in a new file `cmd/caddy_sync.go`): + +Create `cmd/caddy_sync.go`: + +```go +package cmd + +import ( + "fmt" + + "github.com/jfox85/devx/caddy" + "github.com/jfox85/devx/config" + "github.com/jfox85/devx/session" +) + +// syncAllCaddyRoutes loads all sessions and syncs Caddy routes. +// This is called after session create and session remove. +func syncAllCaddyRoutes() error { + store, err := session.LoadSessions() + if err != nil { + return fmt.Errorf("failed to load sessions for Caddy sync: %w", err) + } + + registry, err := config.LoadProjectRegistry() + if err != nil { + return fmt.Errorf("failed to load project registry: %w", err) + } + + sessionInfos := make(map[string]*caddy.SessionInfo) + for name, sess := range store.Sessions { + info := &caddy.SessionInfo{ + Name: name, + Ports: sess.Ports, + } + + for alias, project := range registry.Projects { + if sess.ProjectPath == project.Path { + info.ProjectAlias = alias + break + } + } + + sessionInfos[name] = info + } + + return caddy.SyncRoutes(sessionInfos) +} +``` + +**Step 3: Verify imports** + +After the edit, `session_create.go` still uses `caddy.NormalizeDNSName` and `caddy.SanitizeHostname`, so the caddy import stays. + +**Step 4: Run tests** + +Run: `go build ./... && go test ./cmd/ -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add cmd/session_create.go cmd/caddy_sync.go +git commit -m "refactor: session create uses SyncRoutes instead of API provisioning" +``` + +--- + +### Task 4: Update `cmd/session_rm.go` to use `SyncRoutes` + +Replace `DestroySessionRoutes` call with `SyncRoutes`. + +**Files:** +- Modify: `cmd/session_rm.go:69-74` + +**Step 1: Replace the Caddy route removal block** + +Replace lines 69-74: + +```go + // Remove Caddy routes + if len(sess.Routes) > 0 { + if err := caddy.DestroySessionRoutes(name, sess.Routes); err != nil { + fmt.Printf("Warning: failed to remove Caddy routes: %v\n", err) + } + } +``` + +With: + +```go + // Sync Caddy routes (session already removed from store below, + // so we sync after RemoveSession to regenerate without this session) +``` + +Then, AFTER the `store.RemoveSession(name)` call (line 87), add: + +```go + // Sync Caddy routes after removal + if err := syncAllCaddyRoutes(); err != nil { + fmt.Printf("Warning: failed to sync Caddy routes: %v\n", err) + } +``` + +**Step 2: Remove the `caddy` import from `session_rm.go`** + +The `caddy` import is no longer used directly — `syncAllCaddyRoutes` lives in the same `cmd` package. + +**Step 3: Run tests** + +Run: `go build ./... && go test ./cmd/ -v` +Expected: PASS + +**Step 4: Commit** + +```bash +git add cmd/session_rm.go +git commit -m "refactor: session rm uses SyncRoutes instead of API deletion" +``` + +--- + +### Task 5: Rewrite `cmd/caddy.go` health check and fix + +Simplify the health check to compare expected config vs running config, and replace `RepairRoutes` with `SyncRoutes`. + +**Files:** +- Modify: `cmd/caddy.go` + +**Step 1: Rewrite `runCaddyCheck`** + +Replace the `runCaddyCheck` function body. The new flow: +1. Load sessions + build `sessionInfos` (same as before) +2. Call `caddy.CheckCaddyHealth(sessionInfos)` (kept, but simplified) +3. Display results (simplified — no more "Blocked" status) +4. If `--fix`, call `syncAllCaddyRoutes()` then re-check + +Replace lines 79-93 (the fix block): + +```go + // Fix issues if requested + if fixFlag { + fmt.Println("\nSyncing Caddy config...") + if err := syncAllCaddyRoutes(); err != nil { + return fmt.Errorf("failed to sync routes: %w", err) + } + + // Re-run health check to show updated status + fmt.Println("\nRechecking after sync...") + result, err = caddy.CheckCaddyHealth(sessionInfos) + if err != nil { + return fmt.Errorf("failed to recheck Caddy health: %w", err) + } + displayHealthCheckResults(result) + } +``` + +**Step 2: Simplify `displayHealthCheckResults`** + +Remove the `CatchAllFirst` warning and the "Blocked" status logic. Replace the status text block (lines 131-138): + +```go + for _, status := range result.RouteStatuses { + statusText := "✗ Missing" + if status.Exists { + statusText = "✓ Active" + } +``` + +Remove the `CatchAllFirst` warning block (lines 115-121) and the condition on line 165 that checks `CatchAllFirst`. + +**Step 3: Run tests** + +Run: `go build ./...` +Expected: Compiles cleanly + +**Step 4: Commit** + +```bash +git add cmd/caddy.go +git commit -m "refactor: caddy check uses SyncRoutes for --fix, remove Blocked status" +``` + +--- + +### Task 6: Simplify `caddy/health.go` + +Remove `RepairRoutes`, `CatchAllFirst`, `IsFirst`, and route ordering logic from the health check. Keep `CheckCaddyHealth` but simplify it. + +**Files:** +- Modify: `caddy/health.go` + +**Step 1: Simplify `RouteStatus` and `HealthCheckResult`** + +Remove `IsFirst` from `RouteStatus`. Remove `CatchAllFirst` from `HealthCheckResult`. + +```go +type RouteStatus struct { + SessionName string + ServiceName string + RouteID string + Hostname string + Port int + Exists bool + ServiceUp bool + Error string +} + +type HealthCheckResult struct { + CaddyRunning bool + CaddyError string + RouteStatuses []RouteStatus + RoutesNeeded int + RoutesExisting int + RoutesWorking int +} +``` + +**Step 2: Simplify `CheckCaddyHealth`** + +Remove the catch-all detection logic (lines 55-70) and the `IsFirst` assignment (line 112). The route existence check (lines 110-121) simplifies to: + +```go + if _, exists := existingRoutes[routeID]; exists { + status.Exists = true + result.RoutesExisting++ + result.RoutesWorking++ + } +``` + +**Step 2b: Sort route statuses for deterministic output** + +After the loop that builds `result.RouteStatuses`, add sorting by session name then service name: + +```go + // Sort route statuses for deterministic display output + sort.Slice(result.RouteStatuses, func(i, j int) bool { + if result.RouteStatuses[i].SessionName != result.RouteStatuses[j].SessionName { + return result.RouteStatuses[i].SessionName < result.RouteStatuses[j].SessionName + } + return result.RouteStatuses[i].ServiceName < result.RouteStatuses[j].ServiceName + }) +``` + +Add `"sort"` to the imports. + +**Step 3: Delete `RepairRoutes` function entirely** (lines 138-198) + +It's replaced by `SyncRoutes`. + +**Step 4: Run tests** + +Run: `go test ./caddy/ -v && go build ./...` +Expected: PASS (some tests for deleted functions will need removal — see Task 7) + +**Step 5: Commit** + +```bash +git add caddy/health.go +git commit -m "refactor: simplify health check, remove RepairRoutes and route ordering logic" +``` + +--- + +### Task 7: Clean up `caddy/routes.go` — delete unused API functions + +Remove functions that are no longer called. + +**Files:** +- Modify: `caddy/routes.go` +- Modify: `caddy/routes_test.go` + +**Step 1: Delete these functions from `caddy/routes.go`:** + +- `CreateRoute` (lines 105-107) +- `CreateRouteWithProject` (lines 110-171) +- `DeleteRoute` (lines 174-187) +- `DeleteSessionRoutes` (lines 190-221) +- `ReplaceAllRoutes` (lines 328-359) +- `EnsureRoutesArray` (lines 257-289) +- `GetServiceMapping` (lines 306-325) — unused + +**Step 2: Simplify `NewCaddyClient`** + +Remove the `discoverServerName()` call. Keep the server discovery for `GetAllRoutes` compatibility during migration — existing Caddy instances may use a different server name until the first `caddy check --fix` regenerates the config. Use `discoverServerName` as a fallback: + +```go +func NewCaddyClient() *CaddyClient { + caddyAPI := viper.GetString("caddy_api") + if caddyAPI == "" { + caddyAPI = "http://localhost:2019" + } + + client := resty.New() + client.SetTimeout(10 * time.Second) + + c := &CaddyClient{ + client: client, + baseURL: caddyAPI, + serverName: "devx", + } + // Try to discover actual server name for health check compatibility + // during transition from Caddyfile to JSON config + c.discoverServerName() + return c +} +``` + +NOTE: Keep `discoverServerName` for now. It will be deleted in a follow-up once all users have migrated to the JSON config (at which point the server is always `"devx"`). + +**Step 3: Delete these tests from `caddy/routes_test.go`:** + +- `TestDiscoverServerName` (lines 179-255) +- `TestEnsureRoutesArray` (lines 259-328) +- `TestServerPath` (lines 386-392) +- `TestRoutesUseDiscoveredServer` (lines 396-451) +- `TestGetServiceMapping` (lines 58-83) + +Keep: `TestRouteGeneration`, `TestSanitizeHostname`, `TestNormalizeDNSName`, `TestGetAllRoutesNullResponse`, `TestDiscoverServerName` (still used during transition), `newTestClient` helper, and the `contains` helper (also used by `config_test.go`). + +**Step 4: Run tests** + +Run: `go test ./caddy/ -v && go build ./...` +Expected: All PASS + +**Step 5: Commit** + +```bash +git add caddy/routes.go caddy/routes_test.go +git commit -m "refactor: remove API-based route management functions" +``` + +--- + +### Task 8: Delete `caddy/provisioning.go` + +The entire file is replaced by `SyncRoutes` in `config.go`. + +**Files:** +- Delete: `caddy/provisioning.go` + +**Step 1: Verify no remaining references** + +Run: `grep -r "ProvisionSession\|DestroySession\|reorderRoutes" --include="*.go" .` (excluding `.worktrees/`) + +Expected: No matches in non-worktree code. + +**Step 2: Delete the file** + +```bash +rm caddy/provisioning.go +``` + +**Step 3: Move `NormalizeDNSName` and `SanitizeHostname` if they're defined in provisioning.go** + +These are actually defined in `caddy/provisioning.go` (lines 11-71). Move them to a surviving file. The cleanest place is a new `caddy/hostname.go` or just into `caddy/config.go`. Since they're utility functions used by config generation, add them to `caddy/config.go` (before `BuildCaddyConfig`). + +**Step 4: Run tests** + +Run: `go test ./... -v && go build ./...` +Expected: PASS + +**Step 5: Commit** + +```bash +git add caddy/provisioning.go caddy/config.go +git commit -m "refactor: delete provisioning.go, move hostname utils to config.go" +``` + +--- + +### Task 9: Update `session/metadata.go` — remove `removeCaddyRoutes` and fix `RemoveSession` + +**Files:** +- Modify: `session/metadata.go:165-182, 228-231` + +**Step 1: Delete the `removeCaddyRoutes` function** (lines 228-231) + +```go +func removeCaddyRoutes(sessionName string, routes map[string]string) error { + return caddy.DestroySessionRoutes(sessionName, routes) +} +``` + +**Step 2: Remove the Caddy route removal from `RemoveSession`** (line 173-176) + +In `RemoveSession()`, delete: + +```go + // Remove Caddy routes + if len(sess.Routes) > 0 { + _ = removeCaddyRoutes(name, sess.Routes) // Don't fail on Caddy errors + } +``` + +Note: Caddy route cleanup is now handled by the caller via `syncAllCaddyRoutes()` after all sessions are removed from the store. + +**Step 3: Remove the `caddy` import** + +The import `"github.com/jfox85/devx/caddy"` should now be unused — remove it. + +**Step 4: Run tests** + +Run: `go build ./... && go test ./session/ -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add session/metadata.go +git commit -m "refactor: remove Caddy API calls from session metadata" +``` + +--- + +### Task 10: Fix `session/cleanup.go` — remove caddy import dependency + +The cleanup environment builder at `cleanup.go:54-67` iterates `sess.Routes` to derive HOST env vars. After Task 3, `sess.Routes` now stores hostnames directly (e.g., `"toneclone-jf-add-mcp-frontend.localhost"`), so we can use them directly instead of reconstructing from the session name. + +**Files:** +- Modify: `session/cleanup.go:53-67` + +**Step 1: Simplify the hostname generation** + +Replace lines 53-67: + +```go + // Add hostname variables if routes exist + if len(sess.Routes) > 0 { + for serviceName := range sess.Routes { + // Convert service name to HOST variable name + // e.g., "ui" -> "UI_HOST", "auth-service" -> "AUTH_SERVICE_HOST" + hostVar := strings.ToUpper(serviceName) + hostVar = strings.ReplaceAll(hostVar, "-", "_") + "_HOST" + + // Reconstruct the hostname from the route ID + // Route IDs are typically in format: "session-service.localhost" + // Sanitize session name for hostname compatibility + sanitizedSessionName := caddy.SanitizeHostname(sess.Name) + hostname := fmt.Sprintf("https://%s-%s.localhost", sanitizedSessionName, strings.ToLower(serviceName)) + env = append(env, fmt.Sprintf("%s=%s", hostVar, hostname)) + } + } +``` + +With: + +```go + // Add hostname variables from stored routes + for serviceName, hostname := range sess.Routes { + hostVar := strings.ToUpper(serviceName) + hostVar = strings.ReplaceAll(hostVar, "-", "_") + "_HOST" + env = append(env, fmt.Sprintf("%s=http://%s", hostVar, hostname)) + } +``` + +**Step 2: Remove the `caddy` import** + +The `caddy.SanitizeHostname` call is gone, so remove `"github.com/jfox85/devx/caddy"` from imports. + +**Step 3: Run tests** + +Run: `go build ./... && go test ./session/ -v` +Expected: PASS + +**Step 4: Commit** + +```bash +git add session/cleanup.go +git commit -m "refactor: cleanup uses stored hostnames instead of reconstructing them" +``` + +--- + +### Task 11: Fix TUI `openRoutes` — use stored hostnames + +The `openRoutes` function at `tui/model.go:1917` does `http://%s.localhost` with the stored value, which previously produced broken URLs since route IDs were stored. Now that `sess.Routes` stores hostnames, fix the URL construction. + +**Files:** +- Modify: `tui/model.go:1917-1918` + +**Step 1: Fix URL construction in `openRoutes`** + +Replace line 1917-1918: + +```go + for _, hostname := range sess.Routes { + url := fmt.Sprintf("http://%s.localhost", hostname) +``` + +With: + +```go + for _, hostname := range sess.Routes { + url := fmt.Sprintf("http://%s", hostname) +``` + +The hostname already includes `.localhost` (e.g., `"toneclone-jf-add-mcp-frontend.localhost"`). + +**Step 2: Run build** + +Run: `go build ./...` +Expected: Compiles cleanly + +**Step 3: Commit** + +```bash +git add tui/model.go +git commit -m "fix: openRoutes uses stored hostname directly instead of appending .localhost" +``` + +--- + +### Task 12: Update TUI health check and Caddy help message + +Simplify the TUI's Caddy health warning to remove `CatchAllFirst` references. Fix stale Caddyfile path in help message. + +**Files:** +- Modify: `tui/model.go:1618-1627` +- Modify: `cmd/caddy.go:105` + +**Step 1: Simplify the warning logic in TUI** + +Replace lines 1618-1627: + +```go + // Generate warning message if issues found + var warning string + if !result.CaddyRunning { + warning = "Caddy is not running. Session hostnames won't work." + } else if result.RoutesNeeded > result.RoutesExisting { + missing := result.RoutesNeeded - result.RoutesExisting + warning = fmt.Sprintf("%d Caddy routes are missing. Run 'devx caddy check --fix' to repair.", missing) + } +``` + +This removes the `CatchAllFirst` check since that field no longer exists. + +**Step 2: Fix stale Caddyfile path in `cmd/caddy.go`** + +At line 105, replace: + +```go + fmt.Println(" caddy run --config ~/.config/devx/Caddyfile") +``` + +With: + +```go + fmt.Println(" caddy run --config ~/.config/devx/caddy-config.json") + fmt.Println(" (Run 'devx caddy check --fix' first to generate the config file)") +``` + +**Step 3: Run build** + +Run: `go build ./...` +Expected: Compiles cleanly + +**Step 4: Commit** + +```bash +git add tui/model.go cmd/caddy.go +git commit -m "refactor: simplify TUI health warning, fix Caddy config path in help message" +``` + +--- + +### Task 13: Update integration test + +Rewrite the Caddy integration test to test `SyncRoutes` instead of the old API-based flow. + +**Files:** +- Modify: `caddy/integration_test.go` + +**Step 1: Rewrite the integration test** + +Replace the full file contents. The new test: +1. Checks if Caddy is available (skip if not) +2. Calls `SyncRoutes` with test sessions +3. Verifies routes exist in Caddy via `GetAllRoutes` +4. Calls `SyncRoutes` with empty sessions to clean up +5. Verifies routes are gone + +```go +package caddy + +import ( + "net/http" + "testing" +) + +func TestCaddyIntegration(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + client := NewCaddyClient() + + // Check if Caddy is running + caddyResp, err := http.Get("http://localhost:2019/config/") + if err != nil { + t.Skipf("Caddy not available (connection failed): %v", err) + } + defer caddyResp.Body.Close() + + if caddyResp.StatusCode != http.StatusOK { + t.Skipf("Caddy not available (status %d)", caddyResp.StatusCode) + } + + // Create test sessions + sessions := map[string]*SessionInfo{ + "integration-test": { + Name: "integration-test", + Ports: map[string]int{"ui": 18080, "api": 18081}, + }, + } + + // Sync routes + err = SyncRoutes(sessions) + if err != nil { + t.Fatalf("SyncRoutes failed: %v", err) + } + + // Verify routes exist + routes, err := client.GetAllRoutes() + if err != nil { + t.Fatalf("GetAllRoutes failed: %v", err) + } + + foundUI := false + foundAPI := false + for _, route := range routes { + if route.ID == "sess-integration-test-ui" { + foundUI = true + } + if route.ID == "sess-integration-test-api" { + foundAPI = true + } + } + + if !foundUI { + t.Error("expected ui route to exist") + } + if !foundAPI { + t.Error("expected api route to exist") + } + + // Clean up by syncing empty sessions + err = SyncRoutes(map[string]*SessionInfo{}) + if err != nil { + t.Fatalf("cleanup SyncRoutes failed: %v", err) + } + + // Verify routes are gone + routes, err = client.GetAllRoutes() + if err != nil { + t.Fatalf("GetAllRoutes after cleanup failed: %v", err) + } + + for _, route := range routes { + if route.ID == "sess-integration-test-ui" || route.ID == "sess-integration-test-api" { + t.Errorf("route %s should have been removed", route.ID) + } + } +} +``` + +**Step 2: Run integration test (only if Caddy is running)** + +Run: `go test ./caddy/ -run TestCaddyIntegration -v` +Expected: PASS (or skip if Caddy not available) + +**Step 3: Commit** + +```bash +git add caddy/integration_test.go +git commit -m "test: rewrite integration test for SyncRoutes" +``` + +--- + +### Task 14: Update `caddy-start.sh` + +The startup script still references the old Caddyfile. Update it to use the generated JSON config, and generate the config if it doesn't exist yet. + +**Files:** +- Modify: `~/.config/devx/caddy-start.sh` (runtime file, not in repo) + +**Step 1: Update the script** + +This is a runtime file at `~/.config/devx/caddy-start.sh`. Update it to: + +```bash +#!/bin/bash + +# Start Caddy for devx development +echo "Starting Caddy for devx..." + +# Check if Caddy is already running +if curl -s http://localhost:2019/config/ > /dev/null 2>&1; then + echo "Caddy is already running on port 2019" + exit 0 +fi + +CONFIG_FILE="$HOME/.config/devx/caddy-config.json" + +# Generate config if it doesn't exist +if [ ! -f "$CONFIG_FILE" ]; then + echo "No config file found. Run 'devx caddy check --fix' to generate it." + echo "Starting with minimal config..." + cat > "$CONFIG_FILE" <<'ENDJSON' +{"admin":{"listen":"localhost:2019"},"apps":{"http":{"servers":{"devx":{"listen":[":80"],"routes":[]}}}}} +ENDJSON +fi + +# Start Caddy in the background +caddy run --config "$CONFIG_FILE" > ~/.config/devx/caddy.log 2>&1 & +CADDY_PID=$! + +# Wait a moment for Caddy to start +sleep 2 + +# Check if Caddy started successfully +if curl -s http://localhost:2019/config/ > /dev/null 2>&1; then + echo "Caddy started successfully (PID: $CADDY_PID)" + echo " Admin API: http://localhost:2019" + echo " Log file: ~/.config/devx/caddy.log" +else + echo "Failed to start Caddy. Check ~/.config/devx/caddy.log for errors" + exit 1 +fi +``` + +**Step 2: No commit** — this is a runtime file, not tracked in git. Note in the PR description that users should update their `caddy-start.sh` or re-run setup. + +--- + +### Task 15: Add missing tests — project alias edge case and empty state + +Fill test coverage gaps identified in review. + +**Files:** +- Modify: `caddy/config_test.go` + +**Step 1: Add project alias test to `TestBuildCaddyConfig`** + +Add to `TestBuildCaddyConfig`: + +```go + t.Run("session without project alias when others have one", func(t *testing.T) { + sessions := map[string]*SessionInfo{ + "with-project": { + Name: "with-project", + Ports: map[string]int{"UI": 3000}, + ProjectAlias: "myapp", + }, + "no-project": { + Name: "no-project", + Ports: map[string]int{"UI": 4000}, + }, + } + config := BuildCaddyConfig(sessions) + + jsonData, _ := json.Marshal(config) + jsonStr := string(jsonData) + // Project-prefixed session + if !contains(jsonStr, `myapp-with-project-ui.localhost`) { + t.Errorf("missing project-prefixed hostname: %s", jsonStr) + } + // Non-project session + if !contains(jsonStr, `no-project-ui.localhost`) { + t.Errorf("missing non-project hostname: %s", jsonStr) + } + // Should not have project prefix on the non-project session + if contains(jsonStr, `myapp-no-project`) { + t.Errorf("non-project session incorrectly got project prefix: %s", jsonStr) + } + }) +``` + +**Step 2: Run tests** + +Run: `go test ./caddy/ -run TestBuildCaddyConfig -v` +Expected: PASS + +**Step 3: Commit** + +```bash +git add caddy/config_test.go +git commit -m "test: add project alias edge case and mixed-project tests" +``` + +--- + +### Task 16: Full test suite + manual verification + +Run all tests, build, and manually verify with a real `devx caddy check`. + +**Files:** None (verification only) + +**Step 1: Run full test suite** + +```bash +gofmt -w . +go vet ./... +go test -v -race ./... +go mod tidy +``` + +Expected: All PASS + +**Step 2: Build and run manual check** + +```bash +make build +./devx caddy check +``` + +Expected: Shows all routes as "Active". No "Blocked" status. + +**Step 3: Test `--fix`** + +```bash +./devx caddy check --fix +``` + +Expected: Writes config, reloads Caddy, shows all routes Active. + +**Step 4: Verify config file exists** + +```bash +cat ~/.config/devx/caddy-config.json | python3 -m json.tool | head -20 +``` + +Expected: Valid JSON with admin config and session routes. + +**Step 5: Commit any final fixes** + +```bash +git add -A +git commit -m "chore: final cleanup for Caddy config file management" +``` diff --git a/session/cleanup.go b/session/cleanup.go index e986aaa..5ee071b 100644 --- a/session/cleanup.go +++ b/session/cleanup.go @@ -8,8 +8,6 @@ import ( "time" "github.com/spf13/viper" - - "github.com/jfox85/devx/caddy" ) // RunCleanupCommand executes the configured cleanup command with session environment variables @@ -50,21 +48,11 @@ func prepareCleanupEnvironment(sess *Session) []string { env = append(env, fmt.Sprintf("%s=%d", portVar, port)) } - // Add hostname variables if routes exist - if len(sess.Routes) > 0 { - for serviceName := range sess.Routes { - // Convert service name to HOST variable name - // e.g., "ui" -> "UI_HOST", "auth-service" -> "AUTH_SERVICE_HOST" - hostVar := strings.ToUpper(serviceName) - hostVar = strings.ReplaceAll(hostVar, "-", "_") + "_HOST" - - // Reconstruct the hostname from the route ID - // Route IDs are typically in format: "session-service.localhost" - // Sanitize session name for hostname compatibility - sanitizedSessionName := caddy.SanitizeHostname(sess.Name) - hostname := fmt.Sprintf("https://%s-%s.localhost", sanitizedSessionName, strings.ToLower(serviceName)) - env = append(env, fmt.Sprintf("%s=%s", hostVar, hostname)) - } + // Add hostname variables from stored routes + for serviceName, hostname := range sess.Routes { + hostVar := strings.ToUpper(serviceName) + hostVar = strings.ReplaceAll(hostVar, "-", "_") + "_HOST" + env = append(env, fmt.Sprintf("%s=http://%s", hostVar, hostname)) } // Add worktree path diff --git a/session/metadata.go b/session/metadata.go index 056398f..5f56a20 100644 --- a/session/metadata.go +++ b/session/metadata.go @@ -9,7 +9,6 @@ import ( "strings" "time" - "github.com/jfox85/devx/caddy" "github.com/jfox85/devx/config" ) @@ -20,7 +19,7 @@ type Session struct { Branch string `json:"branch"` Path string `json:"path"` Ports map[string]int `json:"ports"` - Routes map[string]string `json:"routes,omitempty"` // service -> route ID mapping + Routes map[string]string `json:"routes,omitempty"` // service -> hostname mapping EditorPID int `json:"editor_pid,omitempty"` // PID of the editor process AttentionFlag bool `json:"attention_flag,omitempty"` AttentionReason string `json:"attention_reason,omitempty"` // "claude_done", "claude_stuck", "manual", etc. @@ -170,11 +169,6 @@ func RemoveSession(name string, sess *Session) error { // Kill tmux session if it exists _ = killTmuxSession(name) // Don't fail on tmux errors - // Remove Caddy routes - if len(sess.Routes) > 0 { - _ = removeCaddyRoutes(name, sess.Routes) // Don't fail on Caddy errors - } - // Remove git worktree _ = removeGitWorktree(sess.Path) // Don't fail on worktree errors @@ -226,10 +220,6 @@ func removeGitWorktree(worktreePath string) error { return nil } -func removeCaddyRoutes(sessionName string, routes map[string]string) error { - return caddy.DestroySessionRoutes(sessionName, routes) -} - func getSessionsPath() string { return config.GetSessionsPath() } diff --git a/tui/model.go b/tui/model.go index 788aa9a..cfd3f7e 100644 --- a/tui/model.go +++ b/tui/model.go @@ -1217,7 +1217,7 @@ func (m *model) getSessionDetails(sess sessionItem) string { } sort.Strings(routeServices) for _, service := range routeServices { - url := fmt.Sprintf("http://%s.localhost", sess.routes[service]) + url := fmt.Sprintf("http://%s", sess.routes[service]) details += fmt.Sprintf(" %s: %s\n", service, url) } } @@ -1304,7 +1304,7 @@ func (m *model) getSessionPreview(sess sessionItem, maxWidth int) string { } sort.Strings(routeServices) for _, service := range routeServices { - url := fmt.Sprintf("http://%s.localhost", sess.routes[service]) + url := fmt.Sprintf("http://%s", sess.routes[service]) preview.WriteString(fmt.Sprintf(" %s: %s\n", service, url)) } } @@ -1618,12 +1618,10 @@ func (m *model) checkCaddyHealth() tea.Cmd { // Generate warning message if issues found var warning string if !result.CaddyRunning { - warning = "⚠️ Caddy is not running. Session hostnames won't work." - } else if result.CatchAllFirst { - warning = "⚠️ Caddy routes are misconfigured. Run 'devx caddy check --fix' to repair." + warning = "Caddy is not running. Session hostnames won't work." } else if result.RoutesNeeded > result.RoutesExisting { missing := result.RoutesNeeded - result.RoutesExisting - warning = fmt.Sprintf("⚠️ %d Caddy routes are missing. Run 'devx caddy check --fix' to repair.", missing) + warning = fmt.Sprintf("%d Caddy routes are missing. Run 'devx caddy check --fix' to repair.", missing) } return caddyHealthMsg{warning: warning} @@ -1674,7 +1672,7 @@ func (m *model) hostnamesView() string { cursor = "> " } - url := fmt.Sprintf("http://%s.localhost", hostname) + url := fmt.Sprintf("http://%s", hostname) b.WriteString(fmt.Sprintf("%s%s\n", cursor, url)) } @@ -1915,7 +1913,7 @@ func (m *model) openRoutes(sessionName string) tea.Cmd { // Open all routes in the default browser for _, hostname := range sess.Routes { - url := fmt.Sprintf("http://%s.localhost", hostname) + url := fmt.Sprintf("http://%s", hostname) if err := openURL(url); err != nil { return errMsg{fmt.Errorf("failed to open %s: %w", url, err)} } @@ -1971,75 +1969,28 @@ func (m *model) loadHostnames(sessionName string) tea.Cmd { return errMsg{err} } - // If a specific session is selected, only show its routes + var hostnames []string + if sessionName != "" { - sess, exists := store.Sessions[sessionName] - if exists { - var hostnames []string - for serviceName := range sess.Routes { - // Generate hostname based on project and session info - dnsServiceName := caddy.NormalizeDNSName(serviceName) - // Sanitize session name for hostname compatibility - sanitizedSessionName := caddy.SanitizeHostname(sess.Name) - if sess.ProjectAlias != "" { - hostnames = append(hostnames, fmt.Sprintf("%s-%s-%s", sess.ProjectAlias, sanitizedSessionName, dnsServiceName)) - } else { - hostnames = append(hostnames, fmt.Sprintf("%s-%s", sanitizedSessionName, dnsServiceName)) - } + // Show routes for the selected session only + if sess, exists := store.Sessions[sessionName]; exists { + for _, hostname := range sess.Routes { + hostnames = append(hostnames, hostname) } - sort.Strings(hostnames) - return hostnamesLoadedMsg{hostnames: hostnames} } - } - - // Otherwise, show all hostnames (original behavior) - // Use Caddy client to get actual routes - client := caddy.NewCaddyClient() - routes, err := client.GetAllRoutes() - if err != nil { - // Fall back to generating hostnames from session data + } else { + // Show all stored hostnames across sessions hostnameSet := make(map[string]bool) for _, sess := range store.Sessions { - for serviceName := range sess.Routes { - // Generate hostname based on project and session info - dnsServiceName := caddy.NormalizeDNSName(serviceName) - // Sanitize session name for hostname compatibility - sanitizedSessionName := caddy.SanitizeHostname(sess.Name) - if sess.ProjectAlias != "" { - hostnameSet[fmt.Sprintf("%s-%s-%s", sess.ProjectAlias, sanitizedSessionName, dnsServiceName)] = true - } else { - hostnameSet[fmt.Sprintf("%s-%s", sanitizedSessionName, dnsServiceName)] = true - } + for _, hostname := range sess.Routes { + hostnameSet[hostname] = true } } - - var hostnames []string for hostname := range hostnameSet { hostnames = append(hostnames, hostname) } - sort.Strings(hostnames) - return hostnamesLoadedMsg{hostnames: hostnames} - } - - // Extract hostnames from actual Caddy routes - hostnameSet := make(map[string]bool) - for _, route := range routes { - for _, match := range route.Match { - for _, host := range match.Host { - // Extract just the subdomain part (without .localhost) - if strings.HasSuffix(host, ".localhost") { - subdomain := strings.TrimSuffix(host, ".localhost") - hostnameSet[subdomain] = true - } - } - } } - // Convert to sorted slice - var hostnames []string - for hostname := range hostnameSet { - hostnames = append(hostnames, hostname) - } sort.Strings(hostnames) return hostnamesLoadedMsg{hostnames: hostnames} } @@ -2047,7 +1998,7 @@ func (m *model) loadHostnames(sessionName string) tea.Cmd { func (m *model) openHostname(hostname string) tea.Cmd { return func() tea.Msg { - url := fmt.Sprintf("http://%s.localhost", hostname) + url := fmt.Sprintf("http://%s", hostname) if err := openURL(url); err != nil { return errMsg{fmt.Errorf("failed to open %s: %w", url, err)} }