diff --git a/README.md b/README.md index 997f57c..0052683 100644 --- a/README.md +++ b/README.md @@ -154,9 +154,9 @@ pv uninstall # Complete removal with guided cleanup │ ├── colima │ ├── mago │ └── composer.phar +├── pv.yml # Global settings (TLD, default PHP) ├── config/ # Server configuration │ ├── Caddyfile -│ ├── settings.json │ ├── sites/ # Per-project Caddyfile includes │ └── sites-{ver}/ # Per-version site configs ├── data/ # Registry, PID file diff --git a/cmd/doctor.go b/cmd/doctor.go index 0ba5de0..b2309c2 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -44,7 +44,7 @@ var doctorCmd = &cobra.Command{ } versions, _ := phpenv.InstalledVersions() - globalPHP := settings.GlobalPHP + globalPHP := settings.Defaults.PHP var allChecks []sectionResult @@ -343,7 +343,7 @@ func runNetworkChecks(settings *config.Settings) sectionResult { var checks []check // DNS resolver file. - if err := setup.CheckResolverFile(settings.TLD); err == nil { + if err := setup.CheckResolverFile(settings.Defaults.TLD); err == nil { checks = append(checks, check{Name: "DNS resolver configured", Status: true}) } else { checks = append(checks, check{ @@ -356,7 +356,7 @@ func runNetworkChecks(settings *config.Settings) sectionResult { // DNS responding (only if server appears to be running). if server.IsRunning() || daemon.IsLoaded() { - if checkDNSResponding(settings.TLD) { + if checkDNSResponding(settings.Defaults.TLD) { checks = append(checks, check{Name: "DNS responding", Status: true}) } else { checks = append(checks, check{ @@ -500,7 +500,7 @@ func runProjectChecks(settings *config.Settings, reg *registry.Registry, globalP phpV = "none" } - domain := p.Name + "." + settings.TLD + domain := p.Name + "." + settings.Defaults.TLD // Check project path exists. if !dirExists(p.Path) { diff --git a/cmd/doctor_test.go b/cmd/doctor_test.go index 91475f0..bbea315 100644 --- a/cmd/doctor_test.go +++ b/cmd/doctor_test.go @@ -76,7 +76,7 @@ func TestDoctor_WithValidProject(t *testing.T) { // Set global PHP and save settings. settings := config.DefaultSettings() - settings.GlobalPHP = "8.4" + settings.Defaults.PHP = "8.4" if err := settings.Save(); err != nil { t.Fatal(err) } diff --git a/cmd/install.go b/cmd/install.go index 5f030cd..8b86e07 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -136,11 +136,11 @@ pv install --with="php:8.3,service[redis:7],service[mysql:8.0]"`, if err := config.EnsureDirs(); err != nil { return "", fmt.Errorf("cannot create directories: %w", err) } - settings, _ := config.LoadSettings() - if settings == nil { - settings = &config.Settings{} + settings, err := config.LoadSettings() + if err != nil { + return "", fmt.Errorf("cannot load settings: %w", err) } - settings.TLD = installTLD + settings.Defaults.TLD = installTLD if err := settings.Save(); err != nil { return "", fmt.Errorf("cannot save settings: %w", err) } diff --git a/cmd/link.go b/cmd/link.go index 5e3140b..a30e0c5 100644 --- a/cmd/link.go +++ b/cmd/link.go @@ -67,7 +67,7 @@ pv link --name=myapp ~/Code/myapp`, if err != nil { return fmt.Errorf("cannot load settings: %w", err) } - globalPHP := settings.GlobalPHP + globalPHP := settings.Defaults.PHP phpVersion := globalPHP if v, err := phpenv.ResolveVersion(absPath); err == nil && v != "" { @@ -95,8 +95,8 @@ pv link --name=myapp ~/Code/myapp`, } // Generate TLS certificate for Vite dev server auto-detection. - hostname := name + "." + settings.TLD - if err := certs.EnsureValetConfig(settings.TLD); err != nil { + hostname := name + "." + settings.Defaults.TLD + if err := certs.EnsureValetConfig(settings.Defaults.TLD); err != nil { ui.Subtle(fmt.Sprintf("Skipped Vite TLS setup: %v", err)) } else if err := certs.GenerateSiteTLS(hostname); err != nil { ui.Subtle(fmt.Sprintf("Vite TLS cert not generated (HTTPS HMR may need manual config): %v", err)) @@ -107,7 +107,7 @@ pv link --name=myapp ~/Code/myapp`, typeLabel = "unknown" } - domain := "https://" + name + "." + settings.TLD + domain := "https://" + name + "." + settings.Defaults.TLD fmt.Fprintln(os.Stderr) ui.Success(fmt.Sprintf("Linked %s", ui.Purple.Bold(true).Render(domain))) diff --git a/cmd/link_test.go b/cmd/link_test.go index 9354c69..ca4b76f 100644 --- a/cmd/link_test.go +++ b/cmd/link_test.go @@ -1,7 +1,6 @@ package cmd import ( - "encoding/json" "os" "path/filepath" "strings" @@ -14,12 +13,9 @@ import ( func writeDefaultSettings(t *testing.T) { t.Helper() - if err := config.EnsureDirs(); err != nil { - t.Fatalf("EnsureDirs() error = %v", err) - } - data, _ := json.Marshal(config.DefaultSettings()) - if err := os.WriteFile(config.SettingsPath(), data, 0644); err != nil { - t.Fatalf("write settings error = %v", err) + s := config.DefaultSettings() + if err := s.Save(); err != nil { + t.Fatalf("Save settings error = %v", err) } } diff --git a/cmd/list.go b/cmd/list.go index a8e73ee..e279264 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -24,7 +24,7 @@ var listCmd = &cobra.Command{ settings, _ := config.LoadSettings() tld := "test" if settings != nil { - tld = settings.TLD + tld = settings.Defaults.TLD } projects := reg.List() diff --git a/cmd/setup.go b/cmd/setup.go index 6ec90af..efbf555 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -79,8 +79,8 @@ var setupCmd = &cobra.Command{ ui.Subtle(fmt.Sprintf("Warning: could not load settings: %v", err)) } tld := "test" - if settings != nil && settings.TLD != "" { - tld = settings.TLD + if settings != nil && settings.Defaults.TLD != "" { + tld = settings.Defaults.TLD } // Run the tabbed setup wizard. @@ -125,9 +125,9 @@ var setupCmd = &cobra.Command{ } // Save TLD. - s := &config.Settings{TLD: tld} + s := &config.Settings{Defaults: config.Defaults{TLD: tld}} if settings != nil { - s.GlobalPHP = settings.GlobalPHP + s.Defaults.PHP = settings.Defaults.PHP } if err := s.Save(); err != nil { return fmt.Errorf("cannot save settings: %w", err) diff --git a/cmd/start.go b/cmd/start.go index f8d193b..6e096ba 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -38,7 +38,7 @@ func startFG() error { return fmt.Errorf("cannot load settings: %w", err) } - return server.Start(settings.TLD) + return server.Start(settings.Defaults.TLD) } func startDaemon() error { diff --git a/cmd/status.go b/cmd/status.go index c476aac..b5de266 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -57,7 +57,7 @@ var statusCmd = &cobra.Command{ fmt.Fprintln(os.Stderr) // Network info. - fmt.Fprintf(os.Stderr, " %s %s\n", ui.Purple.Render("TLD"), ui.Bold.Render("."+settings.TLD)) + fmt.Fprintf(os.Stderr, " %s %s\n", ui.Purple.Render("TLD"), ui.Bold.Render("."+settings.Defaults.TLD)) fmt.Fprintf(os.Stderr, " %s %s %s %s\n", ui.Purple.Render("DNS"), fmt.Sprintf("127.0.0.1:%d", config.DNSPort), @@ -66,7 +66,7 @@ var statusCmd = &cobra.Command{ ) // PHP version info. - globalPHP := settings.GlobalPHP + globalPHP := settings.Defaults.PHP versions, _ := phpenv.InstalledVersions() if len(versions) > 0 { var labels []string @@ -112,7 +112,7 @@ var statusCmd = &cobra.Command{ typeLabel = "unknown" } - domain := "https://" + p.Name + "." + settings.TLD + domain := "https://" + p.Name + "." + settings.Defaults.TLD rows[i] = []string{domain, typeLabel, phpV} } ui.Table([]string{"Site", "Type", "PHP"}, rows) diff --git a/cmd/uninstall.go b/cmd/uninstall.go index 2886dca..e1769f8 100644 --- a/cmd/uninstall.go +++ b/cmd/uninstall.go @@ -93,7 +93,7 @@ var uninstallCmd = &cobra.Command{ settings, _ := config.LoadSettings() tld := "test" if settings != nil { - tld = settings.TLD + tld = settings.Defaults.TLD } // Uninstall tools (each cleans up its own binary + PATH entry). diff --git a/cmd/uninstall_test.go b/cmd/uninstall_test.go index 65b4923..f70dd1a 100644 --- a/cmd/uninstall_test.go +++ b/cmd/uninstall_test.go @@ -203,7 +203,7 @@ func TestUninstall_SettingsLoadFallback(t *testing.T) { if err != nil { t.Fatalf("LoadSettings error = %v", err) } - if settings.TLD != "test" { - t.Errorf("TLD = %q, want %q", settings.TLD, "test") + if settings.Defaults.TLD != "test" { + t.Errorf("TLD = %q, want %q", settings.Defaults.TLD, "test") } } diff --git a/cmd/unlink.go b/cmd/unlink.go index 2644246..2d9414b 100644 --- a/cmd/unlink.go +++ b/cmd/unlink.go @@ -69,7 +69,7 @@ pv unlink`, settings, _ := config.LoadSettings() tld := "test" if settings != nil { - tld = settings.TLD + tld = settings.Defaults.TLD } // Remove TLS certificate for Vite dev server. diff --git a/internal/caddy/caddy.go b/internal/caddy/caddy.go index 4aa1e94..96788d0 100644 --- a/internal/caddy/caddy.go +++ b/internal/caddy/caddy.go @@ -375,7 +375,7 @@ func GenerateServiceSiteConfigs(reg *registry.Registry) error { var buf bytes.Buffer if err := tmpl.Execute(&buf, serviceConsoleData{ Subdomain: route.Subdomain, - TLD: settings.TLD, + TLD: settings.Defaults.TLD, Port: route.Port, }); err != nil { return err @@ -415,7 +415,7 @@ func writeConfig(dir string, p registry.Project, settings *config.Settings, root Name: p.Name, Path: p.Path, RootPath: rootPath, - TLD: settings.TLD, + TLD: settings.Defaults.TLD, Port: port, }); err != nil { return err diff --git a/internal/commands/service/add.go b/internal/commands/service/add.go index b14b147..db641e9 100644 --- a/internal/commands/service/add.go +++ b/internal/commands/service/add.go @@ -152,7 +152,7 @@ pv service:add postgres 16`, settings, _ := config.LoadSettings() if settings != nil { for _, route := range routes { - fmt.Fprintf(os.Stderr, " %s https://%s.pv.%s\n", ui.Muted.Render(route.Subdomain), route.Subdomain, settings.TLD) + fmt.Fprintf(os.Stderr, " %s https://%s.pv.%s\n", ui.Muted.Render(route.Subdomain), route.Subdomain, settings.Defaults.TLD) } } } else if consolePt := svc.ConsolePort(version); consolePt > 0 { diff --git a/internal/config/paths.go b/internal/config/paths.go index 32f169c..d4a1fc6 100644 --- a/internal/config/paths.go +++ b/internal/config/paths.go @@ -114,9 +114,10 @@ func VersionsPath() string { } func SettingsPath() string { - return filepath.Join(ConfigDir(), "settings.json") + return filepath.Join(PvDir(), "pv.yml") } + func CaddyfilePath() string { return filepath.Join(ConfigDir(), "Caddyfile") } diff --git a/internal/config/paths_test.go b/internal/config/paths_test.go index a5b165a..a753072 100644 --- a/internal/config/paths_test.go +++ b/internal/config/paths_test.go @@ -92,8 +92,8 @@ func TestSettingsPath(t *testing.T) { t.Setenv("HOME", home) got := SettingsPath() - if !strings.HasSuffix(got, filepath.Join(".pv", "config", "settings.json")) { - t.Errorf("SettingsPath() = %q, want suffix .pv/config/settings.json", got) + if !strings.HasSuffix(got, filepath.Join(".pv", "pv.yml")) { + t.Errorf("SettingsPath() = %q, want suffix .pv/pv.yml", got) } } diff --git a/internal/config/settings.go b/internal/config/settings.go index 33e1c37..819f31b 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -1,21 +1,26 @@ package config import ( - "encoding/json" "fmt" "os" "regexp" + + "gopkg.in/yaml.v3" ) +type Defaults struct { + PHP string `yaml:"php,omitempty"` + TLD string `yaml:"tld"` +} + type Settings struct { - TLD string `json:"tld"` - GlobalPHP string `json:"global_php,omitempty"` + Defaults Defaults `yaml:"defaults"` } var validTLD = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`) func DefaultSettings() *Settings { - return &Settings{TLD: "test"} + return &Settings{Defaults: Defaults{TLD: "test"}} } func LoadSettings() (*Settings, error) { @@ -28,20 +33,26 @@ func LoadSettings() (*Settings, error) { return nil, err } var s Settings - if err := json.Unmarshal(data, &s); err != nil { + if err := yaml.Unmarshal(data, &s); err != nil { return nil, err } - if s.TLD == "" { - s.TLD = "test" + if s.Defaults.TLD == "" { + s.Defaults.TLD = "test" } return &s, nil } func (s *Settings) Save() error { + if s.Defaults.TLD == "" { + s.Defaults.TLD = "test" + } + if err := ValidateTLD(s.Defaults.TLD); err != nil { + return err + } if err := EnsureDirs(); err != nil { return err } - data, err := json.MarshalIndent(s, "", " ") + data, err := yaml.Marshal(s) if err != nil { return err } diff --git a/internal/config/settings_test.go b/internal/config/settings_test.go index dcde3da..780d18f 100644 --- a/internal/config/settings_test.go +++ b/internal/config/settings_test.go @@ -1,6 +1,7 @@ package config import ( + "os" "testing" ) @@ -11,15 +12,15 @@ func TestLoadSettings_DefaultWhenMissing(t *testing.T) { if err != nil { t.Fatalf("LoadSettings() error = %v", err) } - if s.TLD != "test" { - t.Errorf("TLD = %q, want %q", s.TLD, "test") + if s.Defaults.TLD != "test" { + t.Errorf("TLD = %q, want %q", s.Defaults.TLD, "test") } } func TestSettings_SaveAndLoad(t *testing.T) { t.Setenv("HOME", t.TempDir()) - s := &Settings{TLD: "pv-test"} + s := &Settings{Defaults: Defaults{TLD: "pv-test"}} if err := s.Save(); err != nil { t.Fatalf("Save() error = %v", err) } @@ -28,15 +29,15 @@ func TestSettings_SaveAndLoad(t *testing.T) { if err != nil { t.Fatalf("LoadSettings() error = %v", err) } - if loaded.TLD != "pv-test" { - t.Errorf("TLD = %q, want %q", loaded.TLD, "pv-test") + if loaded.Defaults.TLD != "pv-test" { + t.Errorf("TLD = %q, want %q", loaded.Defaults.TLD, "pv-test") } } -func TestLoadSettings_EmptyTLDDefaultsToTest(t *testing.T) { +func TestSettings_SaveAndLoad_WithPHP(t *testing.T) { t.Setenv("HOME", t.TempDir()) - s := &Settings{TLD: ""} + s := &Settings{Defaults: Defaults{TLD: "test", PHP: "8.4"}} if err := s.Save(); err != nil { t.Fatalf("Save() error = %v", err) } @@ -45,15 +46,79 @@ func TestLoadSettings_EmptyTLDDefaultsToTest(t *testing.T) { if err != nil { t.Fatalf("LoadSettings() error = %v", err) } - if loaded.TLD != "test" { - t.Errorf("TLD = %q, want %q", loaded.TLD, "test") + if loaded.Defaults.PHP != "8.4" { + t.Errorf("PHP = %q, want %q", loaded.Defaults.PHP, "8.4") + } +} + +func TestLoadSettings_EmptyTLDDefaultsToTest(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + // Write a pv.yml with empty TLD directly to bypass Save() validation. + if err := EnsureDirs(); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(SettingsPath(), []byte("defaults:\n tld: \"\"\n"), 0644); err != nil { + t.Fatal(err) + } + + loaded, err := LoadSettings() + if err != nil { + t.Fatalf("LoadSettings() error = %v", err) + } + if loaded.Defaults.TLD != "test" { + t.Errorf("TLD = %q, want %q", loaded.Defaults.TLD, "test") + } +} + +func TestLoadSettings_CorruptYAML(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + if err := EnsureDirs(); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(SettingsPath(), []byte("defaults: [broken\n"), 0644); err != nil { + t.Fatal(err) + } + + _, err := LoadSettings() + if err == nil { + t.Error("expected error for corrupt YAML") } } func TestDefaultSettings(t *testing.T) { s := DefaultSettings() - if s.TLD != "test" { - t.Errorf("TLD = %q, want %q", s.TLD, "test") + if s.Defaults.TLD != "test" { + t.Errorf("TLD = %q, want %q", s.Defaults.TLD, "test") + } +} + +func TestSettings_SaveValidatesTLD(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + s := &Settings{Defaults: Defaults{TLD: "-bad-"}} + if err := s.Save(); err == nil { + t.Error("expected Save() to reject invalid TLD") + } +} + +func TestSettings_SaveDefaultsEmptyTLD(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + s := &Settings{Defaults: Defaults{TLD: ""}} + if err := s.Save(); err != nil { + t.Fatalf("Save() error = %v", err) + } + + loaded, err := LoadSettings() + if err != nil { + t.Fatalf("LoadSettings() error = %v", err) + } + if loaded.Defaults.TLD != "test" { + t.Errorf("TLD = %q, want %q", loaded.Defaults.TLD, "test") } } diff --git a/internal/phpenv/phpenv.go b/internal/phpenv/phpenv.go index b08ee98..01b03be 100644 --- a/internal/phpenv/phpenv.go +++ b/internal/phpenv/phpenv.go @@ -72,7 +72,7 @@ func SetGlobal(version string) error { if err != nil { return err } - settings.GlobalPHP = version + settings.Defaults.PHP = version if err := settings.Save(); err != nil { return err } @@ -86,10 +86,10 @@ func GlobalVersion() (string, error) { if err != nil { return "", err } - if settings.GlobalPHP == "" { + if settings.Defaults.PHP == "" { return "", fmt.Errorf("no global PHP version set (run: pv php:install [version])") } - return settings.GlobalPHP, nil + return settings.Defaults.PHP, nil } // Remove deletes an installed PHP version. @@ -103,7 +103,7 @@ func Remove(version string) error { if err != nil { return err } - if settings.GlobalPHP == version { + if settings.Defaults.PHP == version { return fmt.Errorf("cannot remove PHP %s: it is the global default (switch with: pv php:use)", version) } diff --git a/internal/server/process.go b/internal/server/process.go index fee739e..289784e 100644 --- a/internal/server/process.go +++ b/internal/server/process.go @@ -31,7 +31,7 @@ func Start(tld string) error { if err != nil { return fmt.Errorf("cannot load settings: %w", err) } - globalPHP := settings.GlobalPHP + globalPHP := settings.Defaults.PHP reg, err := registry.Load() if err != nil { @@ -143,7 +143,7 @@ func ReconfigureServer() error { } // Regenerate all site configs and Caddyfiles. - if err := caddy.GenerateAllConfigs(reg.List(), settings.GlobalPHP); err != nil { + if err := caddy.GenerateAllConfigs(reg.List(), settings.Defaults.PHP); err != nil { return err } diff --git a/internal/tools/tool.go b/internal/tools/tool.go index 4164f09..86f429b 100644 --- a/internal/tools/tool.go +++ b/internal/tools/tool.go @@ -34,10 +34,10 @@ type Tool struct { // globalPHPVersion returns the global PHP version from settings. func globalPHPVersion() string { s, err := config.LoadSettings() - if err != nil || s.GlobalPHP == "" { + if err != nil || s.Defaults.PHP == "" { return "" } - return s.GlobalPHP + return s.Defaults.PHP } // registry of all managed tools, keyed by name. diff --git a/scripts/e2e/diagnostics.sh b/scripts/e2e/diagnostics.sh index 29f0dd1..aee66c0 100755 --- a/scripts/e2e/diagnostics.sh +++ b/scripts/e2e/diagnostics.sh @@ -34,8 +34,8 @@ echo "" echo "==> registry.json" cat ~/.pv/data/registry.json 2>/dev/null || echo "(no registry.json)" echo "" -echo "==> settings.json" -cat ~/.pv/config/settings.json 2>/dev/null || echo "(no settings.json)" +echo "==> pv.yml" +cat ~/.pv/pv.yml 2>/dev/null || echo "(no pv.yml)" echo "" echo "==> versions.json" cat ~/.pv/data/versions.json 2>/dev/null || echo "(no versions.json)" diff --git a/scripts/e2e/lifecycle.sh b/scripts/e2e/lifecycle.sh index 9326b8f..ab7accd 100755 --- a/scripts/e2e/lifecycle.sh +++ b/scripts/e2e/lifecycle.sh @@ -8,7 +8,7 @@ eval "$(pv env)" # Switch global to 8.3 pv php:use 8.3 echo "==> Verify settings after switching to 8.3" -grep -q '"global_php": "8.3"' ~/.pv/config/settings.json || { echo "FAIL: settings not updated"; exit 1; } +grep -q 'php: "8.3"' ~/.pv/pv.yml || { echo "FAIL: settings not updated"; exit 1; } readlink ~/.pv/bin/frankenphp | grep -q "8.3" || { echo "FAIL: symlink not pointing to 8.3"; exit 1; } OUT=$(cd /tmp && php --version) echo "$OUT" @@ -17,7 +17,7 @@ echo "OK: pv php:use 8.3 works" # Switch back to 8.4 pv php:use 8.4 -grep -q '"global_php": "8.4"' ~/.pv/config/settings.json || { echo "FAIL: settings not updated back"; exit 1; } +grep -q 'php: "8.4"' ~/.pv/pv.yml || { echo "FAIL: settings not updated back"; exit 1; } echo "OK: pv php:use 8.4 works" # Unlink e2e-php83 to free PHP 8.3 diff --git a/scripts/e2e/verify-install.sh b/scripts/e2e/verify-install.sh index c58a902..f35b3f4 100755 --- a/scripts/e2e/verify-install.sh +++ b/scripts/e2e/verify-install.sh @@ -25,9 +25,9 @@ readlink ~/.pv/bin/frankenphp | grep -q "8.4" || { echo "FAIL: symlink not point echo "==> Verify php shim works" ~/.pv/bin/php --version -echo "==> Verify settings.json" -cat ~/.pv/config/settings.json -grep -q '"global_php": "8.4"' ~/.pv/config/settings.json || { echo "FAIL: settings.json wrong"; exit 1; } +echo "==> Verify pv.yml" +cat ~/.pv/pv.yml +grep -q 'php: "8.4"' ~/.pv/pv.yml || { echo "FAIL: pv.yml wrong"; exit 1; } echo "==> Verify resolver" cat /etc/resolver/test