feat(forge): add config file persistence
Some checks failed
Security Scan / security (push) Successful in 16s
Test / test (push) Has been cancelled

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-02 07:58:17 +00:00
parent edbf3f7088
commit 0dd5916f4e
2 changed files with 187 additions and 3 deletions

View file

@ -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.
//

View file

@ -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"])
}
}