Skip to content

Refactor Caddy from API-based routes to config file generation#27

Merged
jfox85 merged 18 commits intomainfrom
jf-caddy-config-file-management
Feb 13, 2026
Merged

Refactor Caddy from API-based routes to config file generation#27
jfox85 merged 18 commits intomainfrom
jf-caddy-config-file-management

Conversation

@jfox85
Copy link
Owner

@jfox85 jfox85 commented Feb 13, 2026

Summary

  • Replace Caddy API-based route management with declarative config file generation - routes are now written to ~/.config/devx/caddy-config.json and applied via caddy reload, eliminating fragile per-route API mutations
  • Simplify all Caddy consumers - session create/rm, health check, TUI, and cleanup all use a single SyncRoutes() call instead of individual API provisioning/deletion
  • Net deletion of ~450 lines (638 added, 1087 removed across 14 files) - removed provisioning.go, API route CRUD, server discovery, and route repair/reordering logic

Key Changes

New: Config file generation (caddy/config.go)

  • BuildCaddyConfig() generates complete Caddy JSON config from session data
  • SyncRoutes() performs atomic file write (temp + rename) and caddy reload
  • Deterministic route ordering for predictable diffs
  • DNS name sanitization (NormalizeDNSName, SanitizeHostname) via shared helper

Removed: API-based route management

  • Deleted caddy/provisioning.go (233 lines)
  • Removed CreateRoute, DeleteRoute, EnsureRoutesArray, RepairRoutes from routes.go
  • Removed discoverServerName server discovery (dead code after config file approach)

Simplified consumers

  • session create / session rm: call syncAllCaddyRoutes() instead of per-service API calls
  • caddy check --fix: uses SyncRoutes instead of RepairRoutes
  • TUI: uses stored sess.Routes directly instead of querying Caddy API
  • Cleanup: uses stored hostnames instead of reconstructing them

Review fixes (final commit)

  • Fixed double .localhost bug in TUI route display (4 locations)
  • Extracted buildSessionInfoMap helper to eliminate duplication
  • Ensured config directory exists before atomic write

Test plan

  • All existing tests pass with -race detection
  • New unit tests for BuildCaddyConfig (7 subtests covering empty sessions, project aliases, determinism, sanitization, mixed projects)
  • New unit tests for SyncRoutes (file write verification, disable_caddy skip)
  • Integration test updated to use SyncRoutes flow
  • go vet clean, gofmt clean
  • Manual: devx session create → verify config file written and routes work
  • Manual: devx session rm → verify routes removed from config
  • Manual: devx caddy check / devx caddy check --fix → verify health check and repair

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Config-file based Caddy management: generates and applies a deterministic JSON config from sessions.
  • Bug Fixes

    • Atomic config writes and safer reloads; preserves config if reload fails and honors a disable flag.
  • Refactor

    • Replaced per-route API flow with centralized sync; simplified health checks and route status.
    • Session routes now stored as hostnames; UI/URLs use plain http:// (no .localhost).
  • Tests / Docs

    • Added tests and design docs for config generation and sync behavior.

jfox85 and others added 17 commits February 12, 2026 19:51
Replaces API-based route management with config file generation
and `caddy reload` for route durability across restarts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…t.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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ng logic

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
@coderabbitai
Copy link

coderabbitai bot commented Feb 13, 2026

📝 Walkthrough

Walkthrough

Replaces per-route Caddy API provisioning with a config-file approach: generates a deterministic Caddy JSON from all sessions (BuildCaddyConfig), writes it atomically (SyncRoutes), and triggers Caddy reload. Removes legacy provisioning and many per-route APIs, updates callers to use session-to-config synchronization.

Changes

Cohort / File(s) Summary
Config File Management
caddy/config.go, caddy/config_test.go
Adds Caddy JSON model types, BuildCaddyConfig, BuildHostname/BuildRouteID, NormalizeDNSName/SanitizeHostname helpers, and SyncRoutes which atomically writes ~/.config/devx/caddy-config.json and attempts caddy reload. Tests cover empty/single/multi-session configs, project aliases, deterministic ordering, sanitization, and disable flag handling.
Provisioning & Route API Removal
caddy/provisioning.go (deleted), caddy/routes.go, caddy/routes_test.go
Removes legacy provisioning (provisioning.go) and many per-route APIs (CreateRoute, DeleteRoute, ReplaceAllRoutes, EnsureRoutesArray, etc.). routes.go simplified to GetAllRoutes and connection checks; tests trimmed accordingly.
Health Check Simplification
caddy/health.go
Removes catch‑all ordering logic, dropped RepairRoutes, and simplified RouteStatus/HealthCheckResult (removed IsFirst/ServiceUp/CatchAllFirst/RoutesWorking). Uses BuildHostname/BuildRouteID for IDs and boolean existence checks; sorts statuses deterministically.
Sync Invocation & Integration Tests
cmd/caddy_sync.go, caddy/integration_test.go
Adds buildSessionInfoMap and syncAllCaddyRoutes to construct session map and call caddy.SyncRoutes. Integration test refactored to validate SyncRoutes-driven route presence and cleanup instead of per-route lifecycle and TLS client probing.
Session CLI & Metadata Changes
cmd/session_create.go, cmd/session_rm.go, session/metadata.go, session/cleanup.go
session_create now builds and stores hostnames (using BuildHostname) instead of route IDs and calls syncAllCaddyRoutes. session_rm removed per-session Caddy deletion and calls full sync after removal. Session.Routes semantics changed to service->hostname mapping; removeCaddyRoutes helper deleted. cleanup uses stored hostnames directly.
Command UX Changes
cmd/caddy.go
Health/fix flow updated to call syncAllCaddyRoutes; messages simplified, config path changed to caddy-config.json, and status text normalized (e.g., "Active").
TUI Adjustments
tui/model.go
URL generation simplified to use stored hostnames (no .localhost suffix). loadHostnames now aggregates stored routes directly and removes live Caddy-dependent derivation.
Design Docs
docs/plans/2026-02-12-caddy-config-file-management*
Adds design and migration plan documenting centralized config-file approach, SyncRoutes workflow, testing strategy, and impacted call sites.

Sequence Diagram(s)

sequenceDiagram
    participant Store as Session Store
    participant Sync as Syncer (buildSessionInfoMap / SyncRoutes)
    participant File as caddy-config.json
    participant Caddy as Caddy Server

    rect rgba(0,100,200,0.5)
    Note over Store,Sync: New Flow — config-file based
    Store->>Sync: buildSessionInfoMap(all sessions)
    Sync->>Sync: BuildCaddyConfig(sessions)
    Sync->>File: write atomically (caddy-config.json)
    Sync->>Caddy: caddy reload --config <file>
    Caddy->>Caddy: load routes from file
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐰 I stitched routes into one tidy file,
One write, one reload, no per-route trial.
Hostnames sorted, sanitized with care,
A rabbit's config — neat as a hare. 🥕

🚥 Pre-merge checks | ✅ 3 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 58.06% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: refactoring Caddy from API-based route management to config file generation, which is the core objective of this large PR.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch jf-caddy-config-file-management

No actionable comments were generated in the recent review. 🎉

🧹 Recent nitpick comments
caddy/config_test.go (1)

93-113: Deterministic ordering test is good but may not always catch regressions.

Go map iteration order is randomized per-process, not per-iteration. Two calls to BuildCaddyConfig in the same test run may iterate maps in the same order, making the test pass even without explicit sorting. The test is still valuable but consider running it with -count=N in CI for stronger confidence, or asserting the actual order of route IDs in the output.

caddy/config.go (2)

195-248: Atomic write pattern for config file is solid.

The temp-file-then-rename approach (Lines 221–240) is correct for atomic writes. Error paths consistently clean up the temp file. The disable_caddy early-return (Line 199) is a clean guard.

One observation: reloadCaddy failure on Line 243 prints to stdout via fmt.Printf. Consider using fmt.Fprintf(os.Stderr, ...) for warnings so they don't intermingle with structured output (e.g., if the CLI's stdout is piped/parsed).


250-258: Consider adding a timeout to the caddy reload command.

exec.Command("caddy", "reload", ...) has no timeout. If the Caddy process hangs or is unresponsive, this will block the CLI indefinitely. Using exec.CommandContext with a reasonable timeout (e.g., 10–15 seconds) would improve resilience.

♻️ Proposed fix
-func reloadCaddy(cfgPath string) error {
-	cmd := exec.Command("caddy", "reload", "--config", cfgPath)
+func reloadCaddy(cfgPath string) error {
+	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+	defer cancel()
+	cmd := exec.CommandContext(ctx, "caddy", "reload", "--config", cfgPath)
 	output, err := cmd.CombinedOutput()
 	if err != nil {
 		return fmt.Errorf("%w: %s", err, string(output))
 	}
 	return nil
 }

This requires adding "context" and "time" to the imports.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🤖 Fix all issues with AI agents
In `@caddy/config_test.go`:
- Around line 178-218: TestSyncRoutes fails on Windows because configPath() uses
os.UserHomeDir(), which reads USERPROFILE on Windows, but the test only sets
HOME via t.Setenv; update the test to also set USERPROFILE when setting HOME so
configPath() points to the temp dir on Windows. Specifically, in the "writes
config file" subtest (and likewise in the "skips when disable_caddy is true"
subtest) after t.Setenv("HOME", tmpDir) call, add a t.Setenv("USERPROFILE",
tmpDir) to ensure SyncRoutes and configPath() use the temp configDir on Windows.

In `@caddy/config.go`:
- Around line 135-138: ProjectAlias is being used raw when building hostname and
routeID (the fmt.Sprintf calls that set hostname and routeID) which can produce
invalid DNS names; sanitize it with NormalizeDNSName() first (e.g., create a
sanitizedProjectAlias := NormalizeDNSName(info.ProjectAlias) and use that in the
fmt.Sprintf for hostname and routeID), and apply the same change in the
analogous code in the health.go block that constructs hostnames/route IDs so
both places use the normalized project alias.

In `@caddy/health.go`:
- Around line 90-94: The RouteStatus struct's ServiceUp field is unused and the
counters RoutesWorking and RoutesExisting are incremented together (so always
identical); remove the unused ServiceUp field from RouteStatus and then decide
whether to consolidate or clarify counters: either (A) collapse RoutesWorking
into RoutesExisting (remove RoutesWorking usage and only expose RoutesExisting)
or (B) rename RoutesWorking to RoutesConfigured and update all references where
RoutesWorking is incremented/printed (e.g., where result.RoutesWorking++ and the
display code that prints both counters) so it reflects presence in Caddy config
rather than service reachability; update any documentation/prints (the code that
prints route counts) to use the chosen single counter/name.

In `@caddy/integration_test.go`:
- Around line 64-80: The test currently calls
SyncRoutes(map[string]*SessionInfo{}) which writes an empty Caddy config and
wipes all routes; instead, before cleanup call client.GetAllRoutes() to capture
existing routes (or the current config) and then either (A) construct a sessions
map containing only the two test session IDs ("sess-integration-test-ui",
"sess-integration-test-api") and pass that to SyncRoutes so only those routes
are removed, or (B) save the pre-test route/config snapshot via GetAllRoutes and
restore it after test by calling SyncRoutes with the original sessions; update
the cleanup code that calls SyncRoutes, and keep the verification using
GetAllRoutes/route.ID as-is.

In `@cmd/caddy_sync.go`:
- Around line 11-31: The function buildSessionInfoMap duplicates logic in
tui/model.go; move it into the caddy package as an exported
BuildSessionInfoMap(store *session.SessionStore, registry
*config.ProjectRegistry) map[string]*caddy.SessionInfo, implementing the same
body (use caddy.SessionInfo, session.SessionStore and config.ProjectRegistry
types) and then update callers in cmd/caddy_sync.go and tui/model.go to call
caddy.BuildSessionInfoMap(...) instead of their local copies; ensure the new
function is exported and imported where needed, removing the duplicate
implementations.

In `@cmd/session_create.go`:
- Around line 219-229: This duplicates the hostname construction from
caddy.config.buildRoutes and lacks the empty-dns guard; add a shared helper in
the caddy package named BuildHostname(sessionName, serviceName, projectAlias
string) string that encapsulates the NormalizeDNSName + SanitizeHostname logic
and returns "" when NormalizeDNSName yields an empty string, then replace the
inline construction in this file (the loop building hostnames) to call
caddy.BuildHostname(name, serviceName, projectAlias) and skip entries where it
returns "" so hostnames stay consistent with buildRoutes.

In `@docs/plans/2026-02-12-caddy-config-file-management-design.md`:
- Line 91: Update the design doc to clarify that concurrent session creation is
not strictly "last writer wins" due to a TOCTOU race in SessionStore.Save():
when two session create flows concurrently read sessions.json, each appends and
writes, the second write can overwrite the first session's metadata so
SyncRoutes may only see one of the two sessions; either note this limitation
explicitly in the Concurrent creation section (mention sessions.json,
SessionStore.Save(), and SyncRoutes) and recommend adding file-locking or atomic
merge logic in SessionStore.Save() as a follow-up, or propose specific
mitigation steps (e.g., advisory file locks or read-modify-write retries) to
prevent lost session entries.

In `@docs/plans/2026-02-12-caddy-config-file-management.md`:
- Around line 335-342: The plan snippet for SyncRoutes omits creating the config
directory before creating a temp file; update the Plan’s SyncRoutes text to
match the implementation by calling os.MkdirAll(dir, 0755) (where dir :=
filepath.Dir(cfgPath)) before os.CreateTemp so the directory is guaranteed to
exist prior to creating tmpFile with os.CreateTemp.

In `@session/metadata.go`:
- Line 22: The Routes map now stores hostnames instead of route IDs which breaks
code that expects hostnames; add a release-note telling users to recreate
sessions or run a migration, and implement a migration on session load that
detects legacy route values (e.g., values matching the old pattern like "sess-*"
or lacking a hostname form) in the Routes map, regenerate hostnames from the
session Ports data, replace/update Routes in memory and persist the updated
session file; also make the affected code paths (cleanup logic, envrc
generation, TUI URL generation, tmuxp template rendering—see references to
Routes usage in cleanup.go, envrc.go, tui/model.go and session/tmuxp.go)
tolerant during transition by accepting both legacy IDs and hostnames until
migration runs.
🧹 Nitpick comments (5)
caddy/config.go (2)

164-170: configPath() silently returns empty string on UserHomeDir error.

If os.UserHomeDir() fails, the caller gets a generic "could not determine config path" error at line 184, losing the actual underlying error (e.g., $HOME unset). Consider returning an error or propagating it.

♻️ Suggested change to propagate error
-func configPath() string {
-	home, err := os.UserHomeDir()
-	if err != nil {
-		return ""
-	}
-	return filepath.Join(home, ".config", "devx", "caddy-config.json")
+func configPath() (string, error) {
+	home, err := os.UserHomeDir()
+	if err != nil {
+		return "", fmt.Errorf("could not determine home directory: %w", err)
+	}
+	return filepath.Join(home, ".config", "devx", "caddy-config.json"), nil
 }

Then update SyncRoutes accordingly:

-	cfgPath := configPath()
-	if cfgPath == "" {
-		return fmt.Errorf("could not determine config path")
-	}
+	cfgPath, err := configPath()
+	if err != nil {
+		return err
+	}

219-224: Reload failure is a warning, not an error — verify this is the intended UX.

SyncRoutes returns nil even when reloadCaddy fails. The design doc confirms this is intentional ("config is written for next start"), but callers like session create and session rm will report success even if Caddy isn't updated. The fmt.Printf warning may be missed in non-interactive contexts (e.g., scripted usage).

Consider using log.Warn or returning a distinct "partial success" signal if structured logging is adopted later.

caddy/routes_test.go (1)

128-135: Custom contains can be replaced with strings.Contains.

The strings package is already imported (line 4 — actually no, strings is not imported in the test file). Since encoding/json and other stdlib packages are imported, adding strings would be trivial and more idiomatic.

♻️ Suggested simplification
+import "strings"
+
-func contains(s, substr string) bool {
-	for i := 0; i <= len(s)-len(substr); i++ {
-		if s[i:i+len(substr)] == substr {
-			return true
-		}
-	}
-	return false
-}
+func contains(s, substr string) bool {
+	return strings.Contains(s, substr)
+}

Or inline strings.Contains at the call sites directly.

tui/model.go (1)

1594-1610: Inline session-info-map construction duplicates buildSessionInfoMap.

This block is functionally identical to cmd/caddy_sync.go:buildSessionInfoMap. Since the TUI already imports both the caddy and config packages, consider moving buildSessionInfoMap to the caddy package (or making it importable) so both cmd and tui can share it, avoiding the risk of the two copies diverging.

docs/plans/2026-02-12-caddy-config-file-management.md (1)

15-15: Minor: Markdown heading levels skip from h1 to h3.

The document jumps from # (h1, line 1) directly to ### (h3, line 15). Static analysis flags this as MD001. Consider using ## for the task headings, or adding an ## level in between.

Comment on lines +64 to 80
// 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)
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Cleanup with empty sessions wipes all Caddy routes, not just the test routes.

SyncRoutes(map[string]*SessionInfo{}) writes a config with zero routes and reloads Caddy. If this test is run on a developer's machine with active sessions, it will remove all their real routes. Consider restoring the pre-test config or only running this in CI.

🤖 Prompt for AI Agents
In `@caddy/integration_test.go` around lines 64 - 80, The test currently calls
SyncRoutes(map[string]*SessionInfo{}) which writes an empty Caddy config and
wipes all routes; instead, before cleanup call client.GetAllRoutes() to capture
existing routes (or the current config) and then either (A) construct a sessions
map containing only the two test session IDs ("sess-integration-test-ui",
"sess-integration-test-api") and pass that to SyncRoutes so only those routes
are removed, or (B) save the pre-test route/config snapshot via GetAllRoutes and
restore it after test by calling SyncRoutes with the original sessions; update
the cleanup code that calls SyncRoutes, and keep the verification using
GetAllRoutes/route.ID as-is.

Comment on lines +11 to +31
// buildSessionInfoMap converts stored sessions and project registry into
// the caddy.SessionInfo map needed by CheckCaddyHealth and SyncRoutes.
func buildSessionInfoMap(store *session.SessionStore, registry *config.ProjectRegistry) map[string]*caddy.SessionInfo {
sessionInfos := make(map[string]*caddy.SessionInfo)
for name, sess := range store.Sessions {
info := &caddy.SessionInfo{
Name: name,
Ports: sess.Ports,
}

for alias, project := range registry.Projects {
if sess.ProjectPath == project.Path {
info.ProjectAlias = alias
break
}
}

sessionInfos[name] = info
}
return sessionInfos
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, confirm the buildSessionInfoMap function in cmd/caddy_sync.go
echo "=== cmd/caddy_sync.go buildSessionInfoMap function ==="
sed -n '11,31p' cmd/caddy_sync.go

Repository: jfox85/devx

Length of output: 738


🏁 Script executed:

#!/bin/bash
# Find and examine the alleged duplicate in tui/model.go
echo "=== Searching for similar pattern in tui/model.go around lines 1594-1610 ==="
if [ -f tui/model.go ]; then
    sed -n '1590,1620p' tui/model.go
else
    find . -name "model.go" -type f | head -5
fi

Repository: jfox85/devx

Length of output: 958


🏁 Script executed:

#!/bin/bash
# Search for all occurrences of sessionInfos map construction in the codebase
echo "=== All sessionInfos map constructions ==="
rg -n "sessionInfos\s*:=\s*make\(map\[string\]\*caddy\.SessionInfo\)" --type go

Repository: jfox85/devx

Length of output: 243


🏁 Script executed:

#!/bin/bash
# Check if buildSessionInfoMap is exported or used elsewhere
echo "=== buildSessionInfoMap references ==="
rg -n "buildSessionInfoMap" --type go

Repository: jfox85/devx

Length of output: 483


🏁 Script executed:

#!/bin/bash
# Check the package structure and verify if tui can import from cmd
echo "=== Check if buildSessionInfoMap is exported (capitalized) ==="
rg -n "func [A-Z].*sessionInfos" --type go cmd/

echo ""
echo "=== Check current imports in tui/model.go ==="
head -30 tui/model.go | grep "import" -A 20

Repository: jfox85/devx

Length of output: 558


🏁 Script executed:

#!/bin/bash
# Check the caddy package to see if there's a public function there
echo "=== caddy package structure ==="
find . -path "*/caddy/*.go" -type f | head -10

echo ""
echo "=== Check for SessionInfo and related types in caddy package ==="
rg -n "type SessionInfo" --type go

Repository: jfox85/devx

Length of output: 326


🏁 Script executed:

#!/bin/bash
# Verify the exact dependency relationship between tui and cmd packages
echo "=== Check tui imports ==="
grep "^import" -A 20 tui/model.go | head -30

echo ""
echo "=== Check if cmd imports are used in tui ==="
rg "cmd\." tui/model.go | head -5

Repository: jfox85/devx

Length of output: 774


🏁 Script executed:

#!/bin/bash
# Let's see the exact full implementation in both places side by side
echo "=== Full context around tui/model.go sessionInfos construction ==="
sed -n '1590,1625p' tui/model.go

echo ""
echo "=== Check if there's a BuildSessionInfoMap (capitalized) anywhere ==="
rg -n "BuildSessionInfoMap" --type go

Repository: jfox85/devx

Length of output: 1312


🏁 Script executed:

#!/bin/bash
# Check the caddy package to see if it already has or should have this utility
echo "=== caddy/health.go to understand SessionInfo context ==="
sed -n '100,130p' caddy/health.go

echo ""
echo "=== Check caddy package exports ==="
grep "^func " caddy/*.go | grep "^caddy" -o | sort -u

Repository: jfox85/devx

Length of output: 725


🏁 Script executed:

#!/bin/bash
# Determine the best place for this function by checking what's in caddy vs cmd
echo "=== What's exported from caddy package ==="
rg "^func [A-Z]" caddy/*.go | head -20

Repository: jfox85/devx

Length of output: 993


🏁 Script executed:

#!/bin/bash
# Check if caddy package can access session and config types
echo "=== caddy/health.go imports ==="
head -20 caddy/health.go

echo ""
echo "=== caddy/routes.go imports (to see what's available) ==="
head -20 caddy/routes.go

Repository: jfox85/devx

Length of output: 858


🏁 Script executed:

#!/bin/bash
# Check the session and config package structure
echo "=== session package imports ==="
head -10 session/metadata.go

echo ""
echo "=== config package imports ==="
head -10 caddy/config.go | grep -A 10 "^package"

Repository: jfox85/devx

Length of output: 322


🏁 Script executed:

#!/bin/bash
# Verify if caddy package can safely import session and config types
echo "=== Check if caddy/*.go files already import session or config ==="
grep -r "^import" caddy/*.go | grep -E "(session|config)"

echo ""
echo "=== Confirm SessionStore location ==="
rg "type SessionStore struct" --type go

echo ""
echo "=== Confirm ProjectRegistry location ==="
rg "type ProjectRegistry struct" --type go

Repository: jfox85/devx

Length of output: 353


Consolidate duplicated session-info-map construction in the caddy package.

The logic in buildSessionInfoMap (cmd/caddy_sync.go:13-31) is duplicated in tui/model.go:1594-1610. Move this function to the caddy package as an exported BuildSessionInfoMap() to enable both cmd/ and tui/ to import and reuse it, eliminating the duplication while maintaining proper dependency structure.

🤖 Prompt for AI Agents
In `@cmd/caddy_sync.go` around lines 11 - 31, The function buildSessionInfoMap
duplicates logic in tui/model.go; move it into the caddy package as an exported
BuildSessionInfoMap(store *session.SessionStore, registry
*config.ProjectRegistry) map[string]*caddy.SessionInfo, implementing the same
body (use caddy.SessionInfo, session.SessionStore and config.ProjectRegistry
types) and then update callers in cmd/caddy_sync.go and tui/model.go to call
caddy.BuildSessionInfoMap(...) instead of their local copies; ensure the new
function is exported and imported where needed, removing the duplicate
implementations.

## 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.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Concurrent creation caveat is more nuanced than "last writer wins".

The doc states both sessions are in sessions.json before SyncRoutes runs, but if two session create commands run concurrently, there's a TOCTOU window: both load sessions.json, each adds its session, each saves — the second writer overwrites the first session's metadata entry. This would cause the subsequent SyncRoutes to only include one of the two sessions.

This is a pre-existing concern in SessionStore.Save() (not introduced by this PR), but the design doc's claim that "the final config is correct" may be misleading. Consider noting this limitation or adding file-locking to SessionStore in a follow-up.

🤖 Prompt for AI Agents
In `@docs/plans/2026-02-12-caddy-config-file-management-design.md` at line 91,
Update the design doc to clarify that concurrent session creation is not
strictly "last writer wins" due to a TOCTOU race in SessionStore.Save(): when
two session create flows concurrently read sessions.json, each appends and
writes, the second write can overwrite the first session's metadata so
SyncRoutes may only see one of the two sessions; either note this limitation
explicitly in the Concurrent creation section (mention sessions.json,
SessionStore.Save(), and SyncRoutes) and recommend adding file-locking or atomic
merge logic in SessionStore.Save() as a follow-up, or propose specific
mitigation steps (e.g., advisory file locks or read-modify-write retries) to
prevent lost session entries.

Comment on lines +335 to +342
// Atomic write: temp file + rename
// Note: os.CreateTemp creates with 0600 permissions, which is appropriate
// for a local config file (owner read/write only)
dir := filepath.Dir(cfgPath)
tmpFile, err := os.CreateTemp(dir, "caddy-config-*.json")
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Plan's SyncRoutes snippet is missing the os.MkdirAll that was added to the actual implementation.

The actual caddy/config.go:SyncRoutes (from the relevant snippets) includes os.MkdirAll(dir, 0755) before os.CreateTemp, which was added to fix the "config directory doesn't exist" issue. This plan snippet doesn't reflect that fix. Consider updating the plan to match the final implementation so it stays a useful reference.

🤖 Prompt for AI Agents
In `@docs/plans/2026-02-12-caddy-config-file-management.md` around lines 335 -
342, The plan snippet for SyncRoutes omits creating the config directory before
creating a temp file; update the Plan’s SyncRoutes text to match the
implementation by calling os.MkdirAll(dir, 0755) (where dir :=
filepath.Dir(cfgPath)) before os.CreateTemp so the directory is guaranteed to
exist prior to creating tmpFile with os.CreateTemp.

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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how Routes values are consumed across the codebase
rg -n --type=go 'sess\.Routes|\.Routes\[' -C3 --glob '!*_test.go'

Repository: jfox85/devx

Length of output: 2927


🏁 Script executed:

#!/bin/bash
# Check cleanup.go specifically for how Routes is used
rg -n --type=go 'Routes' session/cleanup.go -C5

Repository: jfox85/devx

Length of output: 478


🏁 Script executed:

#!/bin/bash
# Look for any migration, sync, or initialization logic related to Routes
rg -n --type=go 'SyncRoutes|MigrateRoutes|Routes.*=' -C3 --glob '!*_test.go'

Repository: jfox85/devx

Length of output: 5748


🏁 Script executed:

#!/bin/bash
# Check the current metadata.go to understand the full context of Routes field
head -50 session/metadata.go

Repository: jfox85/devx

Length of output: 1809


Routes semantic change: Document impact on existing sessions in release notes.

The Routes map now stores hostnames (e.g., "foo-bar.localhost") instead of the previous route IDs (e.g., "sess-foo-bar"). While the JSON field name and type remain unchanged (backward-compatible for deserialization), the values in old session files are now semantically incompatible with code that expects hostnames.

Affected code paths:

  • session/cleanup.go (line 52–55): Builds http://{hostname} from Routes values
  • session/envrc.go (line 50–53): Uses Routes values as hostnames in environment variables
  • tui/model.go (line 1915–1916, etc.): Treats Routes values as hostnames for URL generation
  • session/tmuxp.go (line 31–32, etc.): Uses Routes values as hostnames in templates

Recommendation: Add a note in release documentation that users with existing sessions should recreate them to regenerate routes with the new format. Alternatively, consider a migration step (e.g., regenerating Routes from Ports on first load of old sessions).

🤖 Prompt for AI Agents
In `@session/metadata.go` at line 22, The Routes map now stores hostnames instead
of route IDs which breaks code that expects hostnames; add a release-note
telling users to recreate sessions or run a migration, and implement a migration
on session load that detects legacy route values (e.g., values matching the old
pattern like "sess-*" or lacking a hostname form) in the Routes map, regenerate
hostnames from the session Ports data, replace/update Routes in memory and
persist the updated session file; also make the affected code paths (cleanup
logic, envrc generation, TUI URL generation, tmuxp template rendering—see
references to Routes usage in cleanup.go, envrc.go, tui/model.go and
session/tmuxp.go) tolerant during transition by accepting both legacy IDs and
hostnames until migration runs.

- 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 <noreply@anthropic.com>
@jfox85 jfox85 merged commit 1b2712b into main Feb 13, 2026
23 checks passed
@jfox85 jfox85 deleted the jf-caddy-config-file-management branch February 13, 2026 23:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant