diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e263d95..1b560a8 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 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 a8afa1e..bc998a4 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. See [configuration reference](./docs/configuration.md) for more details. ```json { diff --git a/cmd/fence/main.go b/cmd/fence/main.go index e896381..cd9e869 100644 --- a/cmd/fence/main.go +++ b/cmd/fence/main.go @@ -61,8 +61,9 @@ 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. +$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: fence curl https://example.com # Will be blocked (no domains allowed) @@ -196,7 +197,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) @@ -440,8 +441,8 @@ Examples: fence import --claude # Save to the default config path - # Linux: ~/.config/fence/fence.json - # macOS: ~/Library/Application Support/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 5ea7855..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 (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: + +- 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 0d6c2da..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.DefaultConfigPath()) +path := fence.ResolveDefaultConfigPath() + +cfg, err := fence.LoadConfigResolved(path) if err != nil { log.Fatal(err) } @@ -94,7 +105,11 @@ 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 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. 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` @@ -271,7 +286,9 @@ wrapped, _ := manager.WrapCommand("npm run dev") ### Load and extend config ```go -cfg, err := fence.LoadConfig(fence.DefaultConfigPath()) +path := fence.ResolveDefaultConfigPath() + +cfg, err := fence.LoadConfigResolved(path) if err != nil { log.Fatal(err) } diff --git a/docs/quickstart.md b/docs/quickstart.md index 8f0caa2..4eba686 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 `$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: diff --git a/internal/config/config.go b/internal/config/config.go index bc2f4c4..f73f2bc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "runtime" "slices" "strings" @@ -153,39 +154,78 @@ 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. +// 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. 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) +} + +// ResolveDefaultConfigPath returns the config path fence should load by default. +// 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() + + 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 } - // 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 + } + + for _, legacyPath := range legacyConfigPaths(goos, home) { + if exists(legacyPath) { + return legacyPath } } - // Fall back to legacy path if it exists - home, err := os.UserHomeDir() - if err != nil { - return "fence.json" + return canonicalPath +} + +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..3cb45e4 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -482,10 +482,165 @@ 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 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 { + 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 still loads legacy file when only 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: darwinLegacyAppSupport, + }, + { + 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: "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", + home: linuxHome, + userConfigDir: linuxConfigDir, + existing: map[string]bool{}, + want: linuxCanonical, + }, + { + 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 := resolveDefaultConfigPathFor(tt.goos, tt.home, tt.userConfigDir, func(path string) bool { + return tt.existing[path] + }) + if 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() +}