diff --git a/config.go b/config.go index d1e442f..21e88cd 100644 --- a/config.go +++ b/config.go @@ -1,9 +1,13 @@ package forge import ( + "encoding/json" + "os" + "path/filepath" "syscall" core "dappco.re/go/core" + coreio "dappco.re/go/core/io" ) const ( @@ -16,8 +20,62 @@ const ( DefaultURL = "http://localhost:3000" ) +const defaultConfigPath = ".config/forge/config.json" + +type configFile struct { + URL string `json:"url"` + Token string `json:"token"` +} + +func configPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", core.E("configPath", "forge: resolve home directory", err) + } + return filepath.Join(home, defaultConfigPath), nil +} + +func readConfigFile() (url, token string, err error) { + path, err := configPath() + if err != nil { + return "", "", err + } + + data, err := coreio.Local.Read(path) + if err != nil { + if os.IsNotExist(err) { + return "", "", nil + } + return "", "", core.E("ResolveConfig", "forge: read config file", err) + } + + var cfg configFile + if err := json.Unmarshal([]byte(data), &cfg); err != nil { + return "", "", core.E("ResolveConfig", "forge: decode config file", err) + } + return cfg.URL, cfg.Token, nil +} + +// SaveConfig persists the Forgejo URL and API token to the default config file. +// +// Usage: +// +// _ = forge.SaveConfig("https://forge.example.com", "token") +func SaveConfig(url, token string) error { + path, err := configPath() + if err != nil { + return err + } + payload, err := json.MarshalIndent(configFile{URL: url, Token: token}, "", " ") + if err != nil { + return core.E("SaveConfig", "forge: encode config file", err) + } + return coreio.Local.WriteMode(path, string(payload), 0600) +} + // ResolveConfig resolves the Forgejo URL and API token from flags, environment -// variables, and built-in defaults. Priority order: flags > env > defaults. +// variables, config file, and built-in defaults. Priority order: +// flags > env > config file > defaults. // // Environment variables: // - FORGE_URL — base URL of the Forgejo instance @@ -29,8 +87,19 @@ const ( // _ = url // _ = token func ResolveConfig(flagURL, flagToken string) (url, token string, err error) { - url, _ = syscall.Getenv("FORGE_URL") - token, _ = syscall.Getenv("FORGE_TOKEN") + if fileURL, fileToken, fileErr := readConfigFile(); fileErr != nil { + return "", "", fileErr + } else { + url = fileURL + token = fileToken + } + + if envURL, ok := syscall.Getenv("FORGE_URL"); ok && envURL != "" { + url = envURL + } + if envToken, ok := syscall.Getenv("FORGE_TOKEN"); ok && envToken != "" { + token = envToken + } if flagURL != "" { url = flagURL @@ -44,6 +113,16 @@ func ResolveConfig(flagURL, flagToken string) (url, token string, err error) { return url, token, nil } +// NewFromConfig creates a new Forge client using resolved configuration. +// +// Usage: +// +// f, err := forge.NewFromConfig("", "") +// _ = f +func NewFromConfig(flagURL, flagToken string, opts ...Option) (*Forge, error) { + return NewForgeFromConfig(flagURL, flagToken, opts...) +} + // NewForgeFromConfig creates a new Forge client using resolved configuration. // It returns an error if no API token is available from flags or environment. // diff --git a/config_test.go b/config_test.go index 009789e..f0326d0 100644 --- a/config_test.go +++ b/config_test.go @@ -1,10 +1,15 @@ package forge import ( + "encoding/json" + "path/filepath" "testing" + + coreio "dappco.re/go/core/io" ) func TestResolveConfig_EnvOverrides_Good(t *testing.T) { + t.Setenv("HOME", t.TempDir()) t.Setenv("FORGE_URL", "https://forge.example.com") t.Setenv("FORGE_TOKEN", "env-token") @@ -21,6 +26,7 @@ func TestResolveConfig_EnvOverrides_Good(t *testing.T) { } func TestResolveConfig_FlagOverridesEnv_Good(t *testing.T) { + t.Setenv("HOME", t.TempDir()) t.Setenv("FORGE_URL", "https://env.example.com") t.Setenv("FORGE_TOKEN", "env-token") @@ -37,6 +43,7 @@ func TestResolveConfig_FlagOverridesEnv_Good(t *testing.T) { } func TestResolveConfig_DefaultURL_Good(t *testing.T) { + t.Setenv("HOME", t.TempDir()) t.Setenv("FORGE_URL", "") t.Setenv("FORGE_TOKEN", "") @@ -49,7 +56,63 @@ func TestResolveConfig_DefaultURL_Good(t *testing.T) { } } +func TestResolveConfig_ConfigFile_Good(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("FORGE_URL", "") + t.Setenv("FORGE_TOKEN", "") + + cfgPath := filepath.Join(home, ".config", "forge", "config.json") + if err := coreio.Local.EnsureDir(filepath.Dir(cfgPath)); err != nil { + t.Fatal(err) + } + data, err := json.Marshal(map[string]string{ + "url": "https://file.example.com", + "token": "file-token", + }) + if err != nil { + t.Fatal(err) + } + if err := coreio.Local.WriteMode(cfgPath, string(data), 0600); err != nil { + t.Fatal(err) + } + + url, token, err := ResolveConfig("", "") + if err != nil { + t.Fatal(err) + } + if url != "https://file.example.com" { + t.Errorf("got url=%q", url) + } + if token != "file-token" { + t.Errorf("got token=%q", token) + } +} + +func TestResolveConfig_EnvOverridesConfig_Good(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("FORGE_URL", "https://env.example.com") + t.Setenv("FORGE_TOKEN", "env-token") + + if err := SaveConfig("https://file.example.com", "file-token"); err != nil { + t.Fatal(err) + } + + url, token, err := ResolveConfig("", "") + if err != nil { + t.Fatal(err) + } + if url != "https://env.example.com" { + t.Errorf("got url=%q", url) + } + if token != "env-token" { + t.Errorf("got token=%q", token) + } +} + func TestNewForgeFromConfig_NoToken_Bad(t *testing.T) { + t.Setenv("HOME", t.TempDir()) t.Setenv("FORGE_URL", "") t.Setenv("FORGE_TOKEN", "") @@ -58,3 +121,45 @@ func TestNewForgeFromConfig_NoToken_Bad(t *testing.T) { t.Fatal("expected error for missing token") } } + +func TestNewFromConfig_Good(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("FORGE_URL", "https://forge.example.com") + t.Setenv("FORGE_TOKEN", "env-token") + + f, err := NewFromConfig("", "") + if err != nil { + t.Fatal(err) + } + if f == nil { + t.Fatal("expected forge client") + } + if got := f.BaseURL(); got != "https://forge.example.com" { + t.Errorf("got baseURL=%q", got) + } +} + +func TestSaveConfig_Good(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + if err := SaveConfig("https://file.example.com", "file-token"); err != nil { + t.Fatal(err) + } + + cfgPath := filepath.Join(home, ".config", "forge", "config.json") + data, err := coreio.Local.Read(cfgPath) + if err != nil { + t.Fatal(err) + } + var cfg map[string]string + if err := json.Unmarshal([]byte(data), &cfg); err != nil { + t.Fatal(err) + } + if cfg["url"] != "https://file.example.com" { + t.Errorf("got url=%q", cfg["url"]) + } + if cfg["token"] != "file-token" { + t.Errorf("got token=%q", cfg["token"]) + } +}