From c42041d7879522285358a9da8e6a032fad67d0ac Mon Sep 17 00:00:00 2001 From: JY Tan Date: Tue, 17 Mar 2026 18:28:01 -0700 Subject: [PATCH 1/4] Commit --- ARCHITECTURE.md | 2 +- README.md | 5 +- cmd/fence/main.go | 7 +-- docs/configuration.md | 2 +- docs/library.md | 2 +- docs/quickstart.md | 5 +- internal/config/config.go | 77 +++++++++++++++++-------- internal/config/config_test.go | 101 ++++++++++++++++++++++++++++++++- 8 files changed, 166 insertions(+), 35 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e263d95..efb9104 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -71,7 +71,7 @@ type Config struct { } ``` -- Loads from XDG config dir (`~/.config/fence/fence.json` or `~/Library/Application Support/fence/fence.json`) or custom path +- Loads from `~/.config/fence/fence.json` or a custom path - Falls back to restrictive defaults (block all network, default command deny list) - Validates paths and normalizes them diff --git a/README.md b/README.md index a8afa1e..a6d8c45 100644 --- a/README.md +++ b/README.md @@ -36,11 +36,12 @@ brew tap use-tusk/tap brew install use-tusk/tap/fence ``` -**NIX (macOS, Linux, Windows(WSL))** +**Nix (macOS, Linux, Windows (WSL)):** ```sh nix run nixpkgs#fence -- --help ``` + This runs it directly from the repository, without installing `fence`. If you want to install it, follow the guidelines [from NixOS](https://nix.dev) or [nix-darwin](https://github.com/nix-darwin/nix-darwin).
@@ -94,7 +95,7 @@ fence --help ### Configuration -Fence reads from `~/.config/fence/fence.json` by default (or `~/Library/Application Support/fence/fence.json` on macOS). See [configuration reference](./docs/configuration.md). +Fence reads from `~/.config/fence/fence.json` by default on Linux and macOS. Legacy macOS `~/Library/Application Support/fence/fence.json` and legacy `~/.fence.json` are also supported. See [configuration reference](./docs/configuration.md). ```json { diff --git a/cmd/fence/main.go b/cmd/fence/main.go index e896381..8598961 100644 --- a/cmd/fence/main.go +++ b/cmd/fence/main.go @@ -61,8 +61,8 @@ func main() { with network and filesystem restrictions. By default, all network access is blocked. Configure allowed domains in -~/.config/fence/fence.json (or ~/Library/Application Support/fence/fence.json on macOS) -or pass a settings file with --settings, or use a built-in template with --template. +~/.config/fence/fence.json on Linux and macOS, or pass +a settings file with --settings, or use a built-in template with --template. Examples: fence curl https://example.com # Will be blocked (no domains allowed) @@ -440,8 +440,7 @@ Examples: fence import --claude # Save to the default config path - # Linux: ~/.config/fence/fence.json - # macOS: ~/Library/Application Support/fence/fence.json + # Linux/macOS: ~/.config/fence/fence.json fence import --claude --save # Save to a specific output file diff --git a/docs/configuration.md b/docs/configuration.md index 5ea7855..75c023d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,6 +1,6 @@ # Configuration -Fence reads settings from `~/.config/fence/fence.json` by default (or `~/Library/Application Support/fence/fence.json` on macOS). Legacy `~/.fence.json` is also supported. Pass `--settings ./fence.json` to use a custom path. Config files support JSONC. +Fence reads settings from `~/.config/fence/fence.json` by default on Linux and macOS. Legacy macOS `~/Library/Application Support/fence/fence.json` and legacy `~/.fence.json` are also supported. Pass `--settings ./fence.json` to use a custom path. Config files support JSONC. Example config: diff --git a/docs/library.md b/docs/library.md index 0d6c2da..b1a96da 100644 --- a/docs/library.md +++ b/docs/library.md @@ -94,7 +94,7 @@ if cfg == nil { #### `DefaultConfigPath() string` -Returns the default config file path (`~/.config/fence/fence.json` on Linux, `~/Library/Application Support/fence/fence.json` on macOS, with fallback to legacy `~/.fence.json`). +Returns the default config file path (`~/.config/fence/fence.json` on Linux and macOS, with fallback to legacy macOS `~/Library/Application Support/fence/fence.json` and legacy `~/.fence.json`). #### `NewManager(cfg *Config, debug, monitor bool) *Manager` diff --git a/docs/quickstart.md b/docs/quickstart.md index 8f0caa2..ca1c1be 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -15,11 +15,12 @@ To update: brew upgrade use-tusk/tap/fence ``` -### NIX (macOS, Linux, Windows(WSL)) +### Nix (macOS, Linux, Windows (WSL)) ```sh nix run nixpkgs#fence -- --help ``` + This runs it directly from the repository, without installing `fence`. If you want to install it, follow the guidelines [from NixOS](https://nix.dev) or [nix-darwin](https://github.com/nix-darwin/nix-darwin). ### From Source @@ -92,7 +93,7 @@ Create a starter config: fence config init ``` -By default, this writes `{"extends":"code"}` to `~/.config/fence/fence.json` (or `~/Library/Application Support/fence/fence.json` on macOS), so common coding workflows work out of the box. +By default, this writes `{"extends":"code"}` to `~/.config/fence/fence.json` on Linux and macOS, so common coding workflows work out of the box. If you want a starter file with empty arrays as editable hints, use: diff --git a/internal/config/config.go b/internal/config/config.go index bc2f4c4..b68fa4d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "runtime" "slices" "strings" @@ -154,38 +155,68 @@ func Default() *Config { } // DefaultConfigPath returns the default config file path. -// Uses the OS-preferred config directory (XDG on Linux, ~/Library/Application Support on macOS). -// Falls back to ~/.fence.json if the new location doesn't exist but the legacy one does. +// Uses ~/.config/fence/fence.json as the canonical path on macOS and the OS config dir elsewhere. +// Falls back to legacy macOS Application Support and ~/.fence.json when those paths exist. func DefaultConfigPath() string { - // Try OS-preferred config directory first - configDir, err := os.UserConfigDir() - if err == nil { - newPath := filepath.Join(configDir, "fence", "fence.json") - if _, err := os.Stat(newPath); err == nil { - return newPath + home, _ := os.UserHomeDir() + configDir, _ := os.UserConfigDir() + + return defaultConfigPathFor(runtime.GOOS, home, configDir, pathExists) +} + +func defaultConfigPathFor(goos, home, userConfigDir string, exists func(string) bool) string { + canonicalPath := canonicalConfigPath(goos, home, userConfigDir) + if canonicalPath != "" { + if exists(canonicalPath) { + return canonicalPath } - // Check if parent directory exists (user has set up the new location) - // If so, prefer this even if config doesn't exist yet - if _, err := os.Stat(filepath.Dir(newPath)); err == nil { - return newPath + // If the parent directory exists, prefer the canonical location even before + // the file has been created so new configs land in the expected place. + if exists(filepath.Dir(canonicalPath)) { + return canonicalPath } } - // Fall back to legacy path if it exists - home, err := os.UserHomeDir() - if err != nil { - return "fence.json" + for _, legacyPath := range legacyConfigPaths(goos, home) { + if exists(legacyPath) { + return legacyPath + } + } + + if canonicalPath != "" { + return canonicalPath + } + return "fence.json" +} + +func canonicalConfigPath(goos, home, userConfigDir string) string { + switch { + case goos == "darwin" && home != "": + return filepath.Join(home, ".config", "fence", "fence.json") + case userConfigDir != "": + return filepath.Join(userConfigDir, "fence", "fence.json") + case home != "": + return filepath.Join(home, ".config", "fence", "fence.json") + default: + return "" } - legacyPath := filepath.Join(home, ".fence.json") - if _, err := os.Stat(legacyPath); err == nil { - return legacyPath +} + +func legacyConfigPaths(goos, home string) []string { + if home == "" { + return nil } - // Neither exists, prefer new XDG-compliant path - if configDir != "" { - return filepath.Join(configDir, "fence", "fence.json") + paths := make([]string, 0, 2) + if goos == "darwin" { + paths = append(paths, filepath.Join(home, "Library", "Application Support", "fence", "fence.json")) } - return filepath.Join(home, ".config", "fence", "fence.json") + return append(paths, filepath.Join(home, ".fence.json")) +} + +func pathExists(path string) bool { + _, err := os.Stat(path) + return err == nil } // Load loads configuration from a file path. diff --git a/internal/config/config_test.go b/internal/config/config_test.go index af9d6ae..10fb76e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -482,13 +482,112 @@ func TestDefaultConfigPath(t *testing.T) { if path == "" { t.Error("DefaultConfigPath() returned empty string") } - // Should end with fence.json (either new XDG path or legacy .fence.json) + // Should end with fence.json (canonical path or legacy macOS path) or .fence.json. base := filepath.Base(path) if base != "fence.json" && base != ".fence.json" { t.Errorf("DefaultConfigPath() = %q, expected to end with fence.json or .fence.json", path) } } +func TestDefaultConfigPathFor(t *testing.T) { + darwinHome := filepath.Join(string(os.PathSeparator), "Users", "alice") + darwinCanonical := filepath.Join(darwinHome, ".config", "fence", "fence.json") + darwinLegacyAppSupport := filepath.Join(darwinHome, "Library", "Application Support", "fence", "fence.json") + darwinLegacyDotfile := filepath.Join(darwinHome, ".fence.json") + + linuxHome := filepath.Join(string(os.PathSeparator), "home", "alice") + linuxConfigDir := filepath.Join(linuxHome, ".config") + linuxLegacyDotfile := filepath.Join(linuxHome, ".fence.json") + + tests := []struct { + name string + goos string + home string + userConfigDir string + existing map[string]bool + want string + }{ + { + name: "darwin prefers canonical config file", + goos: "darwin", + home: darwinHome, + userConfigDir: filepath.Join(darwinHome, "Library", "Application Support"), + existing: map[string]bool{ + darwinCanonical: true, + darwinLegacyAppSupport: true, + darwinLegacyDotfile: true, + }, + want: darwinCanonical, + }, + { + name: "darwin prefers canonical path when canonical directory exists", + goos: "darwin", + home: darwinHome, + userConfigDir: filepath.Join(darwinHome, "Library", "Application Support"), + existing: map[string]bool{ + filepath.Dir(darwinCanonical): true, + darwinLegacyAppSupport: true, + }, + want: darwinCanonical, + }, + { + name: "darwin falls back to legacy application support file", + goos: "darwin", + home: darwinHome, + userConfigDir: filepath.Join(darwinHome, "Library", "Application Support"), + existing: map[string]bool{ + darwinLegacyAppSupport: true, + }, + want: darwinLegacyAppSupport, + }, + { + name: "darwin falls back to legacy dotfile", + goos: "darwin", + home: darwinHome, + userConfigDir: filepath.Join(darwinHome, "Library", "Application Support"), + existing: map[string]bool{ + darwinLegacyDotfile: true, + }, + want: darwinLegacyDotfile, + }, + { + name: "linux keeps os config dir", + goos: "linux", + home: linuxHome, + userConfigDir: linuxConfigDir, + existing: map[string]bool{}, + want: filepath.Join(linuxConfigDir, "fence", "fence.json"), + }, + { + name: "linux falls back to legacy dotfile", + goos: "linux", + home: linuxHome, + userConfigDir: linuxConfigDir, + existing: map[string]bool{ + linuxLegacyDotfile: true, + }, + want: linuxLegacyDotfile, + }, + { + name: "returns local fallback when home and config dir are unavailable", + goos: "linux", + existing: map[string]bool{}, + want: "fence.json", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := defaultConfigPathFor(tt.goos, tt.home, tt.userConfigDir, func(path string) bool { + return tt.existing[path] + }) + if got != tt.want { + t.Fatalf("defaultConfigPathFor() = %q, want %q", got, tt.want) + } + }) + } +} + func TestMerge(t *testing.T) { t.Run("nil base", func(t *testing.T) { override := &Config{ From 5378cf4e87eadac5d808660db1c94f454056ab5e Mon Sep 17 00:00:00 2001 From: JY Tan Date: Tue, 17 Mar 2026 18:43:04 -0700 Subject: [PATCH 2/4] Fix --- cmd/fence/main.go | 2 +- docs/library.md | 10 ++++-- internal/config/config.go | 29 +++++++++++----- internal/config/config_test.go | 60 ++++++++++++++++++++++++++++++---- pkg/fence/fence.go | 7 +++- 5 files changed, 88 insertions(+), 20 deletions(-) diff --git a/cmd/fence/main.go b/cmd/fence/main.go index 8598961..26fd14d 100644 --- a/cmd/fence/main.go +++ b/cmd/fence/main.go @@ -196,7 +196,7 @@ func runCommand(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to resolve extends: %w", err) } default: - configPath := config.DefaultConfigPath() + configPath := config.ResolveDefaultConfigPath() cfg, err = config.Load(configPath) if err != nil { return fmt.Errorf("failed to load config: %w", err) diff --git a/docs/library.md b/docs/library.md index b1a96da..4531fb6 100644 --- a/docs/library.md +++ b/docs/library.md @@ -83,7 +83,7 @@ cfg.Network.AllowedDomains = []string{"example.com"} Loads configuration from a JSON file. Supports JSONC (comments allowed). ```go -cfg, err := fence.LoadConfig(fence.DefaultConfigPath()) +cfg, err := fence.LoadConfig(fence.ResolveDefaultConfigPath()) if err != nil { log.Fatal(err) } @@ -94,7 +94,11 @@ if cfg == nil { #### `DefaultConfigPath() string` -Returns the default config file path (`~/.config/fence/fence.json` on Linux and macOS, with fallback to legacy macOS `~/Library/Application Support/fence/fence.json` and legacy `~/.fence.json`). +Returns the canonical config file path for new configs (`~/.config/fence/fence.json` on Linux and macOS). + +#### `ResolveDefaultConfigPath() string` + +Returns the config path fence should load by default. This prefers `~/.config/fence/fence.json`, but falls back to legacy macOS `~/Library/Application Support/fence/fence.json` and legacy `~/.fence.json` when those files exist. #### `NewManager(cfg *Config, debug, monitor bool) *Manager` @@ -271,7 +275,7 @@ wrapped, _ := manager.WrapCommand("npm run dev") ### Load and extend config ```go -cfg, err := fence.LoadConfig(fence.DefaultConfigPath()) +cfg, err := fence.LoadConfig(fence.ResolveDefaultConfigPath()) if err != nil { log.Fatal(err) } diff --git a/internal/config/config.go b/internal/config/config.go index b68fa4d..7096774 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -154,19 +154,35 @@ func Default() *Config { } } -// DefaultConfigPath returns the default config file path. +// DefaultConfigPath returns the canonical config file path for new configs. // Uses ~/.config/fence/fence.json as the canonical path on macOS and the OS config dir elsewhere. -// Falls back to legacy macOS Application Support and ~/.fence.json when those paths exist. func DefaultConfigPath() string { home, _ := os.UserHomeDir() configDir, _ := os.UserConfigDir() - return defaultConfigPathFor(runtime.GOOS, home, configDir, pathExists) + return defaultConfigPathFor(runtime.GOOS, home, configDir) } -func defaultConfigPathFor(goos, home, userConfigDir string, exists func(string) bool) string { +// ResolveDefaultConfigPath returns the config path fence should load by default. +// It prefers the canonical path, but falls back to legacy locations if they exist. +func ResolveDefaultConfigPath() string { + home, _ := os.UserHomeDir() + configDir, _ := os.UserConfigDir() + + return resolveDefaultConfigPathFor(runtime.GOOS, home, configDir, pathExists) +} + +func defaultConfigPathFor(goos, home, userConfigDir string) string { canonicalPath := canonicalConfigPath(goos, home, userConfigDir) if canonicalPath != "" { + return canonicalPath + } + return "fence.json" +} + +func resolveDefaultConfigPathFor(goos, home, userConfigDir string, exists func(string) bool) string { + canonicalPath := defaultConfigPathFor(goos, home, userConfigDir) + if canonicalPath != "fence.json" { if exists(canonicalPath) { return canonicalPath } @@ -183,10 +199,7 @@ func defaultConfigPathFor(goos, home, userConfigDir string, exists func(string) } } - if canonicalPath != "" { - return canonicalPath - } - return "fence.json" + return canonicalPath } func canonicalConfigPath(goos, home, userConfigDir string) string { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 10fb76e..a889493 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -482,21 +482,67 @@ func TestDefaultConfigPath(t *testing.T) { if path == "" { t.Error("DefaultConfigPath() returned empty string") } - // Should end with fence.json (canonical path or legacy macOS path) or .fence.json. + // Should always return the canonical destination path. base := filepath.Base(path) - if base != "fence.json" && base != ".fence.json" { - t.Errorf("DefaultConfigPath() = %q, expected to end with fence.json or .fence.json", path) + if base != "fence.json" { + t.Errorf("DefaultConfigPath() = %q, expected to end with fence.json", path) } } func TestDefaultConfigPathFor(t *testing.T) { darwinHome := filepath.Join(string(os.PathSeparator), "Users", "alice") darwinCanonical := filepath.Join(darwinHome, ".config", "fence", "fence.json") + + linuxHome := filepath.Join(string(os.PathSeparator), "home", "alice") + linuxConfigDir := filepath.Join(linuxHome, ".config") + + tests := []struct { + name string + goos string + home string + userConfigDir string + want string + }{ + { + name: "darwin uses xdg-style path", + goos: "darwin", + home: darwinHome, + userConfigDir: filepath.Join(darwinHome, "Library", "Application Support"), + want: darwinCanonical, + }, + { + name: "linux keeps os config dir", + goos: "linux", + home: linuxHome, + userConfigDir: linuxConfigDir, + want: filepath.Join(linuxConfigDir, "fence", "fence.json"), + }, + { + name: "returns local fallback when home and config dir are unavailable", + goos: "linux", + want: "fence.json", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := defaultConfigPathFor(tt.goos, tt.home, tt.userConfigDir) + if got != tt.want { + t.Fatalf("defaultConfigPathFor() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestResolveDefaultConfigPathFor(t *testing.T) { + darwinHome := filepath.Join(string(os.PathSeparator), "Users", "alice") + darwinCanonical := filepath.Join(darwinHome, ".config", "fence", "fence.json") darwinLegacyAppSupport := filepath.Join(darwinHome, "Library", "Application Support", "fence", "fence.json") darwinLegacyDotfile := filepath.Join(darwinHome, ".fence.json") linuxHome := filepath.Join(string(os.PathSeparator), "home", "alice") linuxConfigDir := filepath.Join(linuxHome, ".config") + linuxCanonical := filepath.Join(linuxConfigDir, "fence", "fence.json") linuxLegacyDotfile := filepath.Join(linuxHome, ".fence.json") tests := []struct { @@ -551,12 +597,12 @@ func TestDefaultConfigPathFor(t *testing.T) { want: darwinLegacyDotfile, }, { - name: "linux keeps os config dir", + name: "linux prefers canonical path", goos: "linux", home: linuxHome, userConfigDir: linuxConfigDir, existing: map[string]bool{}, - want: filepath.Join(linuxConfigDir, "fence", "fence.json"), + want: linuxCanonical, }, { name: "linux falls back to legacy dotfile", @@ -578,11 +624,11 @@ func TestDefaultConfigPathFor(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := defaultConfigPathFor(tt.goos, tt.home, tt.userConfigDir, func(path string) bool { + got := resolveDefaultConfigPathFor(tt.goos, tt.home, tt.userConfigDir, func(path string) bool { return tt.existing[path] }) if got != tt.want { - t.Fatalf("defaultConfigPathFor() = %q, want %q", got, tt.want) + t.Fatalf("resolveDefaultConfigPathFor() = %q, want %q", got, tt.want) } }) } diff --git a/pkg/fence/fence.go b/pkg/fence/fence.go index 14d0f1f..c6192b6 100644 --- a/pkg/fence/fence.go +++ b/pkg/fence/fence.go @@ -71,7 +71,12 @@ func MergeConfigs(base, override *Config) *Config { return config.Merge(base, override) } -// DefaultConfigPath returns the default config file path. +// DefaultConfigPath returns the canonical config path for new configs. func DefaultConfigPath() string { return config.DefaultConfigPath() } + +// ResolveDefaultConfigPath returns the config path fence should load by default. +func ResolveDefaultConfigPath() string { + return config.ResolveDefaultConfigPath() +} From 32331455b9b134499de396957bcb50484781e061 Mon Sep 17 00:00:00 2001 From: JY Tan Date: Tue, 17 Mar 2026 18:57:02 -0700 Subject: [PATCH 3/4] Clean up docs --- ARCHITECTURE.md | 2 +- README.md | 2 +- cmd/fence/main.go | 6 ++++-- docs/configuration.md | 9 ++++++++- docs/library.md | 4 ++-- docs/quickstart.md | 2 +- 6 files changed, 17 insertions(+), 8 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index efb9104..1b560a8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -71,7 +71,7 @@ type Config struct { } ``` -- Loads from `~/.config/fence/fence.json` or a custom path +- Loads from the default config path (Linux: `$XDG_CONFIG_HOME/fence/fence.json`, typically `~/.config/fence/fence.json`; macOS: `~/.config/fence/fence.json`) or a custom path - Falls back to restrictive defaults (block all network, default command deny list) - Validates paths and normalizes them diff --git a/README.md b/README.md index a6d8c45..bc998a4 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ fence --help ### Configuration -Fence reads from `~/.config/fence/fence.json` by default on Linux and macOS. Legacy macOS `~/Library/Application Support/fence/fence.json` and legacy `~/.fence.json` are also supported. See [configuration reference](./docs/configuration.md). +Fence reads from (`~/.config/fence/fence.json`) by default. See [configuration reference](./docs/configuration.md) for more details. ```json { diff --git a/cmd/fence/main.go b/cmd/fence/main.go index 26fd14d..cd9e869 100644 --- a/cmd/fence/main.go +++ b/cmd/fence/main.go @@ -61,7 +61,8 @@ func main() { with network and filesystem restrictions. By default, all network access is blocked. Configure allowed domains in -~/.config/fence/fence.json on Linux and macOS, or pass +$XDG_CONFIG_HOME/fence/fence.json on Linux (typically +~/.config/fence/fence.json) or ~/.config/fence/fence.json on macOS, or pass a settings file with --settings, or use a built-in template with --template. Examples: @@ -440,7 +441,8 @@ Examples: fence import --claude # Save to the default config path - # Linux/macOS: ~/.config/fence/fence.json + # Linux: $XDG_CONFIG_HOME/fence/fence.json (typically ~/.config/fence/fence.json) + # macOS: ~/.config/fence/fence.json fence import --claude --save # Save to a specific output file diff --git a/docs/configuration.md b/docs/configuration.md index 75c023d..5d4062e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,6 +1,13 @@ # Configuration -Fence reads settings from `~/.config/fence/fence.json` by default on Linux and macOS. Legacy macOS `~/Library/Application Support/fence/fence.json` and legacy `~/.fence.json` are also supported. Pass `--settings ./fence.json` to use a custom path. Config files support JSONC. +Fence reads settings from: + +- Linux: `$XDG_CONFIG_HOME/fence/fence.json` (typically `~/.config/fence/fence.json`) +- macOS: `~/.config/fence/fence.json` +- Legacy paths still supported: macOS `~/Library/Application Support/fence/fence.json` and `~/.fence.json` +- Custom path: pass `--settings ./fence.json` + +Config files support JSONC. Example config: diff --git a/docs/library.md b/docs/library.md index 4531fb6..96ad389 100644 --- a/docs/library.md +++ b/docs/library.md @@ -94,11 +94,11 @@ if cfg == nil { #### `DefaultConfigPath() string` -Returns the canonical config file path for new configs (`~/.config/fence/fence.json` on Linux and macOS). +Returns the canonical config file path for new configs (`$XDG_CONFIG_HOME/fence/fence.json` on Linux, typically `~/.config/fence/fence.json`; `~/.config/fence/fence.json` on macOS). #### `ResolveDefaultConfigPath() string` -Returns the config path fence should load by default. This prefers `~/.config/fence/fence.json`, but falls back to legacy macOS `~/Library/Application Support/fence/fence.json` and legacy `~/.fence.json` when those files exist. +Returns the config path fence should load by default. This prefers the canonical path (`$XDG_CONFIG_HOME/fence/fence.json` on Linux, typically `~/.config/fence/fence.json`; `~/.config/fence/fence.json` on macOS), but falls back to legacy macOS `~/Library/Application Support/fence/fence.json` and legacy `~/.fence.json` when those files exist. #### `NewManager(cfg *Config, debug, monitor bool) *Manager` diff --git a/docs/quickstart.md b/docs/quickstart.md index ca1c1be..4eba686 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -93,7 +93,7 @@ Create a starter config: fence config init ``` -By default, this writes `{"extends":"code"}` to `~/.config/fence/fence.json` on Linux and macOS, so common coding workflows work out of the box. +By default, this writes `{"extends":"code"}` to `$XDG_CONFIG_HOME/fence/fence.json` on Linux (typically `~/.config/fence/fence.json`) and `~/.config/fence/fence.json` on macOS, so common coding workflows work out of the box. If you want a starter file with empty arrays as editable hints, use: From a32fd6492913a61d9ac06f7390f98086d5a88f08 Mon Sep 17 00:00:00 2001 From: JY Tan Date: Tue, 17 Mar 2026 19:06:17 -0700 Subject: [PATCH 4/4] Fix --- docs/library.md | 19 ++++++++++++++++--- internal/config/config.go | 8 ++------ internal/config/config_test.go | 14 ++++++++++++-- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/docs/library.md b/docs/library.md index 96ad389..84181e2 100644 --- a/docs/library.md +++ b/docs/library.md @@ -82,8 +82,19 @@ cfg.Network.AllowedDomains = []string{"example.com"} Loads configuration from a JSON file. Supports JSONC (comments allowed). +This is a low-level loader and does not resolve `extends` entries relative to +the config file location. Use `LoadConfigResolved` if your config may use +relative `extends` paths. + +#### `LoadConfigResolved(path string) (*Config, error)` + +Loads configuration from a JSON file and resolves `extends` entries relative to +that file's parent directory. This matches the CLI's behavior. + ```go -cfg, err := fence.LoadConfig(fence.ResolveDefaultConfigPath()) +path := fence.ResolveDefaultConfigPath() + +cfg, err := fence.LoadConfigResolved(path) if err != nil { log.Fatal(err) } @@ -98,7 +109,7 @@ Returns the canonical config file path for new configs (`$XDG_CONFIG_HOME/fence/ #### `ResolveDefaultConfigPath() string` -Returns the config path fence should load by default. This prefers the canonical path (`$XDG_CONFIG_HOME/fence/fence.json` on Linux, typically `~/.config/fence/fence.json`; `~/.config/fence/fence.json` on macOS), but falls back to legacy macOS `~/Library/Application Support/fence/fence.json` and legacy `~/.fence.json` when those files exist. +Returns the config path fence should load by default. It uses the canonical path (`$XDG_CONFIG_HOME/fence/fence.json` on Linux, typically `~/.config/fence/fence.json`; `~/.config/fence/fence.json` on macOS) when that file exists, and otherwise falls back to legacy macOS `~/Library/Application Support/fence/fence.json` and legacy `~/.fence.json` when those files exist. #### `NewManager(cfg *Config, debug, monitor bool) *Manager` @@ -275,7 +286,9 @@ wrapped, _ := manager.WrapCommand("npm run dev") ### Load and extend config ```go -cfg, err := fence.LoadConfig(fence.ResolveDefaultConfigPath()) +path := fence.ResolveDefaultConfigPath() + +cfg, err := fence.LoadConfigResolved(path) if err != nil { log.Fatal(err) } diff --git a/internal/config/config.go b/internal/config/config.go index 7096774..f73f2bc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -164,7 +164,8 @@ func DefaultConfigPath() string { } // ResolveDefaultConfigPath returns the config path fence should load by default. -// It prefers the canonical path, but falls back to legacy locations if they exist. +// It prefers the canonical path when that file exists, but falls back to legacy +// locations while migrating older configs. func ResolveDefaultConfigPath() string { home, _ := os.UserHomeDir() configDir, _ := os.UserConfigDir() @@ -186,11 +187,6 @@ func resolveDefaultConfigPathFor(goos, home, userConfigDir string, exists func(s if exists(canonicalPath) { return canonicalPath } - // If the parent directory exists, prefer the canonical location even before - // the file has been created so new configs land in the expected place. - if exists(filepath.Dir(canonicalPath)) { - return canonicalPath - } } for _, legacyPath := range legacyConfigPaths(goos, home) { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a889493..3cb45e4 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -566,7 +566,7 @@ func TestResolveDefaultConfigPathFor(t *testing.T) { want: darwinCanonical, }, { - name: "darwin prefers canonical path when canonical directory exists", + name: "darwin still loads legacy file when only canonical directory exists", goos: "darwin", home: darwinHome, userConfigDir: filepath.Join(darwinHome, "Library", "Application Support"), @@ -574,7 +574,7 @@ func TestResolveDefaultConfigPathFor(t *testing.T) { filepath.Dir(darwinCanonical): true, darwinLegacyAppSupport: true, }, - want: darwinCanonical, + want: darwinLegacyAppSupport, }, { name: "darwin falls back to legacy application support file", @@ -596,6 +596,16 @@ func TestResolveDefaultConfigPathFor(t *testing.T) { }, want: darwinLegacyDotfile, }, + { + name: "darwin returns canonical path when no config exists yet", + goos: "darwin", + home: darwinHome, + userConfigDir: filepath.Join(darwinHome, "Library", "Application Support"), + existing: map[string]bool{ + filepath.Dir(darwinCanonical): true, + }, + want: darwinCanonical, + }, { name: "linux prefers canonical path", goos: "linux",