From 6401bb8566af4e46bc6ceca82d5ae6ae3577cfd3 Mon Sep 17 00:00:00 2001 From: Jon Fox Date: Thu, 12 Feb 2026 19:51:08 -0800 Subject: [PATCH 01/18] Add design doc for Caddy config file management Replaces API-based route management with config file generation and `caddy reload` for route durability across restarts. Co-Authored-By: Claude Opus 4.6 --- ...-12-caddy-config-file-management-design.md | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 docs/plans/2026-02-12-caddy-config-file-management-design.md 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..d772e82 --- /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, start it with `caddy run --config ` + +## 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`) From 00a249996ba8e2153feb16d0771706378c2cef4a Mon Sep 17 00:00:00 2001 From: Jon Fox Date: Thu, 12 Feb 2026 19:55:54 -0800 Subject: [PATCH 02/18] Add implementation plan for Caddy config file management 12-task plan covering config generation, caller updates, cleanup, and testing for the API-to-config-file refactor. Co-Authored-By: Claude Opus 4.6 --- ...2026-02-12-caddy-config-file-management.md | 1088 +++++++++++++++++ 1 file changed, 1088 insertions(+) create mode 100644 docs/plans/2026-02-12-caddy-config-file-management.md 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..8598f97 --- /dev/null +++ b/docs/plans/2026-02-12-caddy-config-file-management.md @@ -0,0 +1,1088 @@ +# 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) { + 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") + } + }) +} +``` + +**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 + 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, the route-to-hostname conversion (hostnames are now computed directly), and the `store.UpdateSession` call that saved route IDs (no longer needed). + +**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: Remove `caddy` import from `session_create.go` if no longer needed for route provisioning** + +After the edit, `session_create.go` still uses `caddy.NormalizeDNSName` and `caddy.SanitizeHostname`, so the import stays. But remove the `store.UpdateSession` block for routes (lines 262-270) since route IDs are no longer stored per-session. + +**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 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`:** + +- `discoverServerName` (lines 70-97) — server name is always `devx` now +- `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. Hardcode `serverName` to `"devx"`: + +```go +func NewCaddyClient() *CaddyClient { + caddyAPI := viper.GetString("caddy_api") + if caddyAPI == "" { + caddyAPI = "http://localhost:2019" + } + + client := resty.New() + client.SetTimeout(10 * time.Second) + + return &CaddyClient{ + client: client, + baseURL: caddyAPI, + serverName: "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`, and `newTestClient` helper. + +**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: Remove `removeCaddyRoutes` from `session/metadata.go` + +**Files:** +- Modify: `session/metadata.go:229-231` + +**Step 1: Delete the `removeCaddyRoutes` function** + +Delete lines 228-230: + +```go +func removeCaddyRoutes(sessionName string, routes map[string]string) error { + return caddy.DestroySessionRoutes(sessionName, routes) +} +``` + +**Step 2: Remove the `caddy` import if no longer used** + +Check if `session/metadata.go` has any other `caddy` references. If the import `"github.com/jfox85/devx/caddy"` is now unused, remove it. + +**Step 3: Run tests** + +Run: `go build ./... && go test ./session/ -v` +Expected: PASS + +**Step 4: Commit** + +```bash +git add session/metadata.go +git commit -m "refactor: remove unused removeCaddyRoutes helper" +``` + +--- + +### Task 10: Update TUI health check + +Simplify the TUI's Caddy health warning to remove `CatchAllFirst` references. + +**Files:** +- Modify: `tui/model.go:1618-1627` + +**Step 1: Simplify the warning logic** + +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: Run build** + +Run: `go build ./...` +Expected: Compiles cleanly + +**Step 3: Commit** + +```bash +git add tui/model.go +git commit -m "refactor: simplify TUI Caddy health warning" +``` + +--- + +### Task 11: 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 12: 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" +``` From b35377d9cae24874c7fa8b1f6ba490932f7f8c0a Mon Sep 17 00:00:00 2001 From: Jon Fox Date: Thu, 12 Feb 2026 20:16:10 -0800 Subject: [PATCH 03/18] Update plan with review findings: sess.Routes, cleanup, TUI fixes Addresses all critical/important findings from code review: - Keep sess.Routes populated with hostnames (not route IDs) - Fix session/cleanup.go to use stored hostnames directly - Fix TUI openRoutes URL construction bug - Add RemoveSession update for session clear - Fix stale Caddyfile path in help message - Add Viper state cleanup and edge case tests - Clarify design doc: warn on missing Caddy, dont start Co-Authored-By: Claude Opus 4.6 --- ...-12-caddy-config-file-management-design.md | 2 +- ...2026-02-12-caddy-config-file-management.md | 215 ++++++++++++++++-- 2 files changed, 195 insertions(+), 22 deletions(-) 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 index d772e82..78013e2 100644 --- a/docs/plans/2026-02-12-caddy-config-file-management-design.md +++ b/docs/plans/2026-02-12-caddy-config-file-management-design.md @@ -46,7 +46,7 @@ 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, start it with `caddy run --config ` +4. If Caddy isn't running, warn (config is written for next start) ## Caller Changes diff --git a/docs/plans/2026-02-12-caddy-config-file-management.md b/docs/plans/2026-02-12-caddy-config-file-management.md index 8598f97..1522921 100644 --- a/docs/plans/2026-02-12-caddy-config-file-management.md +++ b/docs/plans/2026-02-12-caddy-config-file-management.md @@ -33,6 +33,9 @@ import ( ) 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) @@ -131,6 +134,38 @@ func TestBuildCaddyConfig(t *testing.T) { 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)) + } + }) } ``` @@ -500,7 +535,20 @@ In `cmd/session_create.go`, replace lines 219-270 (the entire Caddy provisioning } ``` -This removes the `ProvisionSessionRoutesWithProject` call, the route-to-hostname conversion (hostnames are now computed directly), and the `store.UpdateSession` call that saved route IDs (no longer needed). +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** @@ -553,9 +601,9 @@ func syncAllCaddyRoutes() error { } ``` -**Step 3: Remove `caddy` import from `session_create.go` if no longer needed for route provisioning** +**Step 3: Verify imports** -After the edit, `session_create.go` still uses `caddy.NormalizeDNSName` and `caddy.SanitizeHostname`, so the import stays. But remove the `store.UpdateSession` block for routes (lines 262-270) since route IDs are no longer stored per-session. +After the edit, `session_create.go` still uses `caddy.NormalizeDNSName` and `caddy.SanitizeHostname`, so the caddy import stays. **Step 4: Run tests** @@ -853,14 +901,12 @@ git commit -m "refactor: delete provisioning.go, move hostname utils to config.g --- -### Task 9: Remove `removeCaddyRoutes` from `session/metadata.go` +### Task 9: Update `session/metadata.go` — remove `removeCaddyRoutes` and fix `RemoveSession` **Files:** -- Modify: `session/metadata.go:229-231` +- Modify: `session/metadata.go:165-182, 228-231` -**Step 1: Delete the `removeCaddyRoutes` function** - -Delete lines 228-230: +**Step 1: Delete the `removeCaddyRoutes` function** (lines 228-231) ```go func removeCaddyRoutes(sessionName string, routes map[string]string) error { @@ -868,9 +914,81 @@ func removeCaddyRoutes(sessionName string, routes map[string]string) error { } ``` -**Step 2: Remove the `caddy` import if no longer used** +**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` -Check if `session/metadata.go` has any other `caddy` references. If the import `"github.com/jfox85/devx/caddy"` is now unused, remove it. +**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** @@ -880,20 +998,60 @@ Expected: PASS **Step 4: Commit** ```bash -git add session/metadata.go -git commit -m "refactor: remove unused removeCaddyRoutes helper" +git add session/cleanup.go +git commit -m "refactor: cleanup uses stored hostnames instead of reconstructing them" ``` --- -### Task 10: Update TUI health check +### Task 11: Fix TUI `openRoutes` — use stored hostnames -Simplify the TUI's Caddy health warning to remove `CatchAllFirst` references. +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** +**Step 1: Simplify the warning logic in TUI** Replace lines 1618-1627: @@ -910,21 +1068,36 @@ Replace lines 1618-1627: This removes the `CatchAllFirst` check since that field no longer exists. -**Step 2: Run build** +**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 3: Commit** +**Step 4: Commit** ```bash -git add tui/model.go -git commit -m "refactor: simplify TUI Caddy health warning" +git add tui/model.go cmd/caddy.go +git commit -m "refactor: simplify TUI health warning, fix Caddy config path in help message" ``` --- -### Task 11: Update integration test +### Task 13: Update integration test Rewrite the Caddy integration test to test `SyncRoutes` instead of the old API-based flow. @@ -1038,7 +1211,7 @@ git commit -m "test: rewrite integration test for SyncRoutes" --- -### Task 12: Full test suite + manual verification +### Task 14: Full test suite + manual verification Run all tests, build, and manually verify with a real `devx caddy check`. From 00aedf7fef125106d62d72b57be3d96acb77754e Mon Sep 17 00:00:00 2001 From: Jon Fox Date: Thu, 12 Feb 2026 20:21:25 -0800 Subject: [PATCH 04/18] Address remaining review findings: server name transition, caddy-start.sh, tests - #5: Keep discoverServerName during transition for GetAllRoutes compat - #7: Add Task 14 to update caddy-start.sh - #10: Explicitly keep contains helper in Task 7 - #11: Document config file permissions (0600 is intentional) - #13: Sort route statuses in CheckCaddyHealth for deterministic output - #14/#16: Add Task 15 for project alias edge case tests Co-Authored-By: Claude Opus 4.6 --- ...2026-02-12-caddy-config-file-management.md | 146 +++++++++++++++++- 1 file changed, 141 insertions(+), 5 deletions(-) diff --git a/docs/plans/2026-02-12-caddy-config-file-management.md b/docs/plans/2026-02-12-caddy-config-file-management.md index 1522921..7b85b5d 100644 --- a/docs/plans/2026-02-12-caddy-config-file-management.md +++ b/docs/plans/2026-02-12-caddy-config-file-management.md @@ -333,6 +333,8 @@ func SyncRoutes(sessions map[string]*SessionInfo) error { } // 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 { @@ -781,6 +783,22 @@ Remove the catch-all detection logic (lines 55-70) and the `IsFirst` assignment } ``` +**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`. @@ -809,7 +827,6 @@ Remove functions that are no longer called. **Step 1: Delete these functions from `caddy/routes.go`:** -- `discoverServerName` (lines 70-97) — server name is always `devx` now - `CreateRoute` (lines 105-107) - `CreateRouteWithProject` (lines 110-171) - `DeleteRoute` (lines 174-187) @@ -820,7 +837,7 @@ Remove functions that are no longer called. **Step 2: Simplify `NewCaddyClient`** -Remove the `discoverServerName()` call. Hardcode `serverName` to `"devx"`: +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 { @@ -832,14 +849,20 @@ func NewCaddyClient() *CaddyClient { client := resty.New() client.SetTimeout(10 * time.Second) - return &CaddyClient{ + 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) @@ -848,7 +871,7 @@ func NewCaddyClient() *CaddyClient { - `TestRoutesUseDiscoveredServer` (lines 396-451) - `TestGetServiceMapping` (lines 58-83) -Keep: `TestRouteGeneration`, `TestSanitizeHostname`, `TestNormalizeDNSName`, `TestGetAllRoutesNullResponse`, and `newTestClient` helper. +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** @@ -1211,7 +1234,120 @@ git commit -m "test: rewrite integration test for SyncRoutes" --- -### Task 14: Full test suite + manual verification +### 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`. From ba4d4f225678ea4f3cf2b9af54caa87600f4e574 Mon Sep 17 00:00:00 2001 From: Jon Fox Date: Thu, 12 Feb 2026 20:25:07 -0800 Subject: [PATCH 05/18] feat: add Caddy config file generation with SyncRoutes Co-Authored-By: Claude Opus 4.6 --- caddy/config.go | 193 +++++++++++++++++++++++++++++++++++++++ caddy/config_test.go | 213 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 406 insertions(+) create mode 100644 caddy/config.go create mode 100644 caddy/config_test.go diff --git a/caddy/config.go b/caddy/config.go new file mode 100644 index 0000000..f6c43e3 --- /dev/null +++ b/caddy/config.go @@ -0,0 +1,193 @@ +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 + 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 +} diff --git a/caddy/config_test.go b/caddy/config_test.go new file mode 100644 index 0000000..46ce722 --- /dev/null +++ b/caddy/config_test.go @@ -0,0 +1,213 @@ +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 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) + + // 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") + } + }) +} From 7283253124c8c37795a5d14ace211bb6f666ad99 Mon Sep 17 00:00:00 2001 From: Jon Fox Date: Thu, 12 Feb 2026 20:25:53 -0800 Subject: [PATCH 06/18] refactor: session create uses SyncRoutes instead of API provisioning Co-Authored-By: Claude Opus 4.6 --- cmd/caddy_sync.go | 42 +++++++++++++++++++++++++++++++++++++++++ cmd/session_create.go | 44 +++++++++++++++++-------------------------- 2 files changed, 59 insertions(+), 27 deletions(-) create mode 100644 cmd/caddy_sync.go diff --git a/cmd/caddy_sync.go b/cmd/caddy_sync.go new file mode 100644 index 0000000..8b425ec --- /dev/null +++ b/cmd/caddy_sync.go @@ -0,0 +1,42 @@ +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) +} diff --git a/cmd/session_create.go b/cmd/session_create.go index bbd2084..a0919ee 100644 --- a/cmd/session_create.go +++ b/cmd/session_create.go @@ -216,25 +216,15 @@ 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 { + 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) } } @@ -259,20 +249,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:") From 066771bc2613339cb3d943ff058eb07adb0b81f3 Mon Sep 17 00:00:00 2001 From: Jon Fox Date: Fri, 13 Feb 2026 10:48:33 -0800 Subject: [PATCH 07/18] refactor: session rm uses SyncRoutes instead of API deletion Co-Authored-By: Claude Opus 4.6 --- cmd/session_rm.go | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) 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 } From dc564913e53d435cbd9c4fe49814193a01f3ed62 Mon Sep 17 00:00:00 2001 From: Jon Fox Date: Fri, 13 Feb 2026 10:48:37 -0800 Subject: [PATCH 08/18] refactor: caddy check uses SyncRoutes for --fix, remove Blocked status Co-Authored-By: Claude Opus 4.6 --- cmd/caddy.go | 29 +++++++++-------------------- 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/cmd/caddy.go b/cmd/caddy.go index 0701051..1374160 100644 --- a/cmd/caddy.go +++ b/cmd/caddy.go @@ -76,14 +76,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 +102,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 } @@ -112,14 +113,6 @@ func displayHealthCheckResults(result *caddy.HealthCheckResult) { 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 { fmt.Println("\n=== Route Details ===") @@ -130,11 +123,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 +151,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") From cddce1e8cc65b8b5157eebf8260ba129e54f78cb Mon Sep 17 00:00:00 2001 From: Jon Fox Date: Fri, 13 Feb 2026 10:48:42 -0800 Subject: [PATCH 09/18] refactor: simplify health check, remove RepairRoutes and route ordering logic Co-Authored-By: Claude Opus 4.6 --- caddy/health.go | 112 +++++++----------------------------------------- tui/model.go | 6 +-- 2 files changed, 17 insertions(+), 101 deletions(-) diff --git a/caddy/health.go b/caddy/health.go index 5ea2c71..e459249 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,7 @@ type RouteStatus struct { Hostname string Port int Exists bool - IsFirst bool // Whether route appears before catch-all - ServiceUp bool // Whether the service is responding + ServiceUp bool Error string } @@ -23,7 +22,6 @@ 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 @@ -51,29 +49,11 @@ 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 } } @@ -107,23 +87,24 @@ 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.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 +114,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/tui/model.go b/tui/model.go index 788aa9a..eb15c98 100644 --- a/tui/model.go +++ b/tui/model.go @@ -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} From ee0cfc683e99f3438d5a32f8f39ed926869671a2 Mon Sep 17 00:00:00 2001 From: Jon Fox Date: Fri, 13 Feb 2026 10:51:27 -0800 Subject: [PATCH 10/18] refactor: remove API-based route management functions Co-Authored-By: Claude Opus 4.6 --- caddy/routes.go | 226 +------------------------------------------ caddy/routes_test.go | 186 ++--------------------------------- 2 files changed, 13 insertions(+), 399 deletions(-) diff --git a/caddy/routes.go b/caddy/routes.go index 76919a7..42f54c6 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 @@ -58,9 +53,12 @@ func NewCaddyClient() *CaddyClient { client.SetTimeout(10 * time.Second) c := &CaddyClient{ - client: client, - baseURL: caddyAPI, + 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 } @@ -68,8 +66,6 @@ func NewCaddyClient() *CaddyClient { // 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 @@ -101,125 +97,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 +128,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 +141,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..34be79f 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 @@ -225,7 +197,7 @@ func TestDiscoverServerName(t *testing.T) { } }) - t.Run("falls back to srv1 when no :80 server", func(t *testing.T) { + t.Run("keeps default 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"}}, @@ -233,96 +205,23 @@ func TestDiscoverServerName(t *testing.T) { })) defer ts.Close() - c := newTestClient(ts, "placeholder") + c := newTestClient(ts, "devx") c.discoverServerName() - if c.serverName != "srv1" { - t.Errorf("expected fallback srv1, got %s", c.serverName) + if c.serverName != "devx" { + t.Errorf("expected devx (unchanged), got %s", c.serverName) } }) - t.Run("falls back to srv1 on API error", func(t *testing.T) { + t.Run("keeps default 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 := newTestClient(ts, "devx") 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") + if c.serverName != "devx" { + t.Errorf("expected devx (unchanged), got %s", c.serverName) } }) } @@ -380,72 +279,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) - } -} From 5673a2409546943ed3c82cd7ab972eb9fa86d840 Mon Sep 17 00:00:00 2001 From: Jon Fox Date: Fri, 13 Feb 2026 10:51:31 -0800 Subject: [PATCH 11/18] refactor: delete provisioning.go, move hostname utils to config.go Co-Authored-By: Claude Opus 4.6 --- caddy/config.go | 62 +++++++++++ caddy/provisioning.go | 233 ------------------------------------------ 2 files changed, 62 insertions(+), 233 deletions(-) delete mode 100644 caddy/provisioning.go diff --git a/caddy/config.go b/caddy/config.go index f6c43e3..791b4e2 100644 --- a/caddy/config.go +++ b/caddy/config.go @@ -7,6 +7,7 @@ import ( "os/exec" "path/filepath" "sort" + "strings" "github.com/spf13/viper" ) @@ -38,6 +39,67 @@ type CaddyServer struct { Routes []Route `json:"routes"` } +// 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 != '-' { + 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 != '-' { + 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 +} + // BuildCaddyConfig generates the complete Caddy JSON config from session data func BuildCaddyConfig(sessions map[string]*SessionInfo) CaddyConfig { adminListen := viper.GetString("caddy_admin") 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 -} From e7ab86f1da59ef3807bb201c9dd5ab43f1599fb3 Mon Sep 17 00:00:00 2001 From: Jon Fox Date: Fri, 13 Feb 2026 10:51:34 -0800 Subject: [PATCH 12/18] refactor: remove Caddy API calls from session metadata Co-Authored-By: Claude Opus 4.6 --- session/metadata.go | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/session/metadata.go b/session/metadata.go index 056398f..ef209e0 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" ) @@ -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() } From 027c5bd7528c8d5841051ac99ee43e032f3d1c81 Mon Sep 17 00:00:00 2001 From: Jon Fox Date: Fri, 13 Feb 2026 10:51:38 -0800 Subject: [PATCH 13/18] test: rewrite integration test for SyncRoutes Co-Authored-By: Claude Opus 4.6 --- caddy/integration_test.go | 121 ++++++++++++-------------------------- 1 file changed, 37 insertions(+), 84 deletions(-) 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) } } } From 72d8ca3fe23a8cc472bec802608dddf5dd9a0cea Mon Sep 17 00:00:00 2001 From: Jon Fox Date: Fri, 13 Feb 2026 10:53:07 -0800 Subject: [PATCH 14/18] refactor: cleanup uses stored hostnames instead of reconstructing them Co-Authored-By: Claude Opus 4.6 --- session/cleanup.go | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) 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 From f64957c200b4c7c43d759d4528aada6d12fc140d Mon Sep 17 00:00:00 2001 From: Jon Fox Date: Fri, 13 Feb 2026 10:53:11 -0800 Subject: [PATCH 15/18] fix: TUI uses stored hostnames directly for openRoutes and loadHostnames Co-Authored-By: Claude Opus 4.6 --- tui/model.go | 28 ++++++---------------------- 1 file changed, 6 insertions(+), 22 deletions(-) diff --git a/tui/model.go b/tui/model.go index eb15c98..860a2c7 100644 --- a/tui/model.go +++ b/tui/model.go @@ -1913,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)} } @@ -1974,16 +1974,8 @@ func (m *model) loadHostnames(sessionName string) tea.Cmd { 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)) - } + for _, hostname := range sess.Routes { + hostnames = append(hostnames, hostname) } sort.Strings(hostnames) return hostnamesLoadedMsg{hostnames: hostnames} @@ -1995,19 +1987,11 @@ func (m *model) loadHostnames(sessionName string) tea.Cmd { client := caddy.NewCaddyClient() routes, err := client.GetAllRoutes() if err != nil { - // Fall back to generating hostnames from session data + // Fall back to stored hostnames from session data 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 } } From fcac9bcdc3b7aa8b964105501dab7728a5c2665d Mon Sep 17 00:00:00 2001 From: Jon Fox Date: Fri, 13 Feb 2026 11:09:28 -0800 Subject: [PATCH 16/18] test: add project alias edge case and mixed-project tests Co-Authored-By: Claude Opus 4.6 --- caddy/config_test.go | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/caddy/config_test.go b/caddy/config_test.go index 46ce722..fca0106 100644 --- a/caddy/config_test.go +++ b/caddy/config_test.go @@ -129,6 +129,36 @@ func TestBuildCaddyConfig(t *testing.T) { } }) + 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("session with empty ports produces no routes", func(t *testing.T) { sessions := map[string]*SessionInfo{ "empty": { From 270dfa331f823c7d108859dc7064b8b1fe440ec1 Mon Sep 17 00:00:00 2001 From: Jon Fox Date: Fri, 13 Feb 2026 12:58:27 -0800 Subject: [PATCH 17/18] fix: address code review findings across caddy config refactor - Fix double .localhost bug in TUI route display (4 locations) - Extract shared buildSessionInfoMap helper to eliminate cmd/ duplication - Unify NormalizeDNSName and SanitizeHostname via shared sanitizeDNS helper - Remove dead server discovery logic from CaddyClient - Simplify TUI loadHostnames to use stored routes instead of Caddy API - Ensure config directory exists before atomic write in SyncRoutes - Fix outdated comment on Routes field in session metadata Co-Authored-By: Claude Opus 4.6 --- caddy/config.go | 62 ++++++++++++--------------------- caddy/routes.go | 35 +------------------ caddy/routes_test.go | 82 +------------------------------------------- cmd/caddy.go | 22 ++---------- cmd/caddy_sync.go | 33 ++++++++++-------- session/metadata.go | 2 +- tui/model.go | 51 ++++++--------------------- 7 files changed, 55 insertions(+), 232 deletions(-) diff --git a/caddy/config.go b/caddy/config.go index 791b4e2..a2f4a53 100644 --- a/caddy/config.go +++ b/caddy/config.go @@ -39,65 +39,42 @@ type CaddyServer struct { Routes []Route `json:"routes"` } -// 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 +// 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, " ", "-") - // Replace any non-alphanumeric characters with hyphens var result strings.Builder for _, r := range normalized { - if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { result.WriteRune(r) - } else if r != '-' { - result.WriteRune('-') } else { - result.WriteRune(r) + result.WriteRune('-') } } - // 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 != '-' { - 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, "--", "-") - } +// NormalizeDNSName converts a service name to be DNS-compatible +func NormalizeDNSName(serviceName string) string { + return sanitizeDNS(serviceName) +} - return final +// 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 @@ -215,6 +192,9 @@ func SyncRoutes(sessions map[string]*SessionInfo) error { // 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) diff --git a/caddy/routes.go b/caddy/routes.go index 42f54c6..b46c282 100644 --- a/caddy/routes.go +++ b/caddy/routes.go @@ -52,44 +52,11 @@ func NewCaddyClient() *CaddyClient { client := resty.New() client.SetTimeout(10 * time.Second) - c := &CaddyClient{ + return &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 -} - -// discoverServerName finds the HTTP server listening on :80. -// Falls back to "srv1" on any failure. -func (c *CaddyClient) discoverServerName() { - 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 - } - } - } } // serverPath returns the Caddy config path for the discovered HTTP server. diff --git a/caddy/routes_test.go b/caddy/routes_test.go index 34be79f..04b4267 100644 --- a/caddy/routes_test.go +++ b/caddy/routes_test.go @@ -135,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) @@ -146,86 +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("keeps default 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, "devx") - c.discoverServerName() - if c.serverName != "devx" { - t.Errorf("expected devx (unchanged), got %s", c.serverName) - } - }) - - t.Run("keeps default 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, "devx") - c.discoverServerName() - if c.serverName != "devx" { - t.Errorf("expected devx (unchanged), got %s", c.serverName) - } - }) -} - // --- GetAllRoutes null/404 handling tests --- func TestGetAllRoutesNullResponse(t *testing.T) { diff --git a/cmd/caddy.go b/cmd/caddy.go index 1374160..0f9f5aa 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) diff --git a/cmd/caddy_sync.go b/cmd/caddy_sync.go index 8b425ec..2573dd5 100644 --- a/cmd/caddy_sync.go +++ b/cmd/caddy_sync.go @@ -8,19 +8,9 @@ import ( "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) - } - +// 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{ @@ -37,6 +27,21 @@ func syncAllCaddyRoutes() error { 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(sessionInfos) + return caddy.SyncRoutes(buildSessionInfoMap(store, registry)) } diff --git a/session/metadata.go b/session/metadata.go index ef209e0..5f56a20 100644 --- a/session/metadata.go +++ b/session/metadata.go @@ -19,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. diff --git a/tui/model.go b/tui/model.go index 860a2c7..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)) } } @@ -1672,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)) } @@ -1969,59 +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 + // 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 stored hostnames from session data + } else { + // Show all stored hostnames across sessions hostnameSet := make(map[string]bool) for _, sess := range store.Sessions { 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} } @@ -2029,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)} } From 858ed9d91c5c8b778431139ef418587cfb8c0b61 Mon Sep 17 00:00:00 2001 From: Jon Fox Date: Fri, 13 Feb 2026 14:54:31 -0800 Subject: [PATCH 18/18] fix: address CodeRabbit review findings - Extract BuildHostname/BuildRouteID helpers to eliminate hostname construction duplication across config.go, health.go, session_create.go - Sanitize ProjectAlias with NormalizeDNSName before use in hostnames - Add empty-dnsService guard in session_create.go hostname construction - Remove unused ServiceUp field and redundant RoutesWorking counter - Fix Windows test failure by setting USERPROFILE alongside HOME - Add test for unsanitized project alias Co-Authored-By: Claude Opus 4.6 --- caddy/config.go | 43 +++++++++++++++++++++++++++++++++---------- caddy/config_test.go | 23 +++++++++++++++++++++++ caddy/health.go | 21 ++++----------------- cmd/caddy.go | 1 - cmd/session_create.go | 10 ++++------ 5 files changed, 64 insertions(+), 34 deletions(-) diff --git a/caddy/config.go b/caddy/config.go index a2f4a53..19bc6b0 100644 --- a/caddy/config.go +++ b/caddy/config.go @@ -101,6 +101,36 @@ func BuildCaddyConfig(sessions map[string]*SessionInfo) CaddyConfig { } } +// 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 @@ -114,7 +144,6 @@ func buildRoutes(sessions map[string]*SessionInfo) []Route { for _, sessionName := range sessionNames { info := sessions[sessionName] - sanitizedSession := SanitizeHostname(sessionName) // Sort service names for deterministic output serviceNames := make([]string, 0, len(info.Ports)) @@ -125,17 +154,11 @@ func buildRoutes(sessions map[string]*SessionInfo) []Route { for _, serviceName := range serviceNames { port := info.Ports[serviceName] - dnsService := NormalizeDNSName(serviceName) - if dnsService == "" { + hostname := BuildHostname(sessionName, serviceName, info.ProjectAlias) + if hostname == "" { 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) - } + routeID := BuildRouteID(sessionName, serviceName, info.ProjectAlias) routes = append(routes, Route{ ID: routeID, diff --git a/caddy/config_test.go b/caddy/config_test.go index fca0106..4957471 100644 --- a/caddy/config_test.go +++ b/caddy/config_test.go @@ -159,6 +159,27 @@ func TestBuildCaddyConfig(t *testing.T) { } }) + 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": { @@ -180,6 +201,7 @@ func TestSyncRoutes(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") @@ -221,6 +243,7 @@ func TestSyncRoutes(t *testing.T) { 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) diff --git a/caddy/health.go b/caddy/health.go index e459249..cf01c77 100644 --- a/caddy/health.go +++ b/caddy/health.go @@ -13,7 +13,6 @@ type RouteStatus struct { Hostname string Port int Exists bool - ServiceUp bool Error string } @@ -24,7 +23,6 @@ type HealthCheckResult struct { RouteStatuses []RouteStatus RoutesNeeded int RoutesExisting int - RoutesWorking int } // CheckCaddyHealth performs a comprehensive health check of Caddy and all routes @@ -60,21 +58,11 @@ func CheckCaddyHealth(sessions map[string]*SessionInfo) (*HealthCheckResult, err // 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, @@ -90,7 +78,6 @@ func CheckCaddyHealth(sessions map[string]*SessionInfo) (*HealthCheckResult, err if existingRoutes[routeID] { status.Exists = true result.RoutesExisting++ - result.RoutesWorking++ } result.RouteStatuses = append(result.RouteStatuses, status) diff --git a/cmd/caddy.go b/cmd/caddy.go index 0f9f5aa..645f31f 100644 --- a/cmd/caddy.go +++ b/cmd/caddy.go @@ -93,7 +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) // Display individual route status if len(result.RouteStatuses) > 0 { diff --git a/cmd/session_create.go b/cmd/session_create.go index a0919ee..9ee2532 100644 --- a/cmd/session_create.go +++ b/cmd/session_create.go @@ -219,13 +219,11 @@ func runSessionCreate(cmd *cobra.Command, args []string) error { // 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) + hostname := caddy.BuildHostname(name, serviceName, projectAlias) + if hostname == "" { + continue } + hostnames[serviceName] = hostname } // Generate .envrc file