feat(client): config search chain, --repo zero-config override, actionable missing-config error
sync/status now resolve the work repository from a fallback chain: --repo flag > --config flag > AGENT_LIB_CONFIG env > user-level config (XDG / AppData) > admin platform default. --repo works without any config file; tokens fall back to AGENT_LIB_GIT_TOKEN/AGENT_LIB_GIT_USER without ever overriding a configured token. A completely empty chain yields an error listing every searched location, a minimal config example and the --repo shortcut. Closes beads: agent-lib-9o8
This commit is contained in:
+139
-16
@@ -8,9 +8,13 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/m3tam3re/agent-lib/internal/gitsource"
|
||||
)
|
||||
|
||||
// ClientConfig is the admin-protected fleet configuration for sync.
|
||||
// ClientConfig is the client configuration for sync: fleet-provided or
|
||||
// personal, discovered via the search chain or overridden inline.
|
||||
type ClientConfig struct {
|
||||
RepoURL string `json:"repo_url"`
|
||||
Ref string `json:"ref,omitempty"`
|
||||
@@ -20,14 +24,8 @@ type ClientConfig struct {
|
||||
|
||||
// ConfigEnv names the override environment variables.
|
||||
const (
|
||||
ConfigEnv = "AGENT_LIB_CONFIG"
|
||||
StateDirEnv = "AGENT_LIB_STATE_DIR"
|
||||
defaultConfig = `{
|
||||
"repo_url": "",
|
||||
"ref": "",
|
||||
"token": "",
|
||||
"token_user": ""
|
||||
}`
|
||||
ConfigEnv = "AGENT_LIB_CONFIG"
|
||||
StateDirEnv = "AGENT_LIB_STATE_DIR"
|
||||
)
|
||||
|
||||
// DefaultConfigPath returns the admin-protected config location per platform:
|
||||
@@ -42,16 +40,128 @@ func DefaultConfigPath() string {
|
||||
return "/etc/agent-lib/config.json"
|
||||
}
|
||||
|
||||
// LoadClientConfig reads the config from flagPath, the override env, or the
|
||||
// platform default — in that order.
|
||||
// UserConfigPath returns the user-level config location per platform:
|
||||
// XDG_CONFIG_HOME or ~/.config on Linux/macOS, AppData on Windows.
|
||||
func UserConfigPath() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
if ad := os.Getenv("AppData"); ad != "" {
|
||||
return filepath.Join(ad, "agent-lib", "config.json")
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
return filepath.Join(home, "AppData", "Roaming", "agent-lib", "config.json")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
|
||||
return filepath.Join(xdg, "agent-lib", "config.json")
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(home, ".config", "agent-lib", "config.json")
|
||||
}
|
||||
|
||||
// configCandidate is one link in the config search chain.
|
||||
type configCandidate struct {
|
||||
path string
|
||||
desc string
|
||||
}
|
||||
|
||||
// ConfigSearchChain returns the ordered locations a client config is looked
|
||||
// up in: the --config flag, the AGENT_LIB_CONFIG env, the user-level config
|
||||
// and the admin-protected platform default. The flag and env entries are
|
||||
// only included when actually set.
|
||||
func ConfigSearchChain(flagPath string) []configCandidate {
|
||||
chain := make([]configCandidate, 0, 4)
|
||||
if flagPath != "" {
|
||||
chain = append(chain, configCandidate{path: flagPath, desc: "--config flag"})
|
||||
}
|
||||
if envPath := os.Getenv(ConfigEnv); envPath != "" {
|
||||
chain = append(chain, configCandidate{path: envPath, desc: ConfigEnv + " env"})
|
||||
}
|
||||
if p := UserConfigPath(); p != "" {
|
||||
chain = append(chain, configCandidate{path: p, desc: "user config"})
|
||||
}
|
||||
chain = append(chain, configCandidate{path: DefaultConfigPath(), desc: "admin default"})
|
||||
return chain
|
||||
}
|
||||
|
||||
// MissingConfigError reports that no client config exists anywhere in the
|
||||
// search chain — carrying the searched locations and the way out.
|
||||
type MissingConfigError struct {
|
||||
Chain []configCandidate
|
||||
}
|
||||
|
||||
func (e *MissingConfigError) Error() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("no client config found. Searched (in order):\n")
|
||||
for _, c := range e.Chain {
|
||||
fmt.Fprintf(&b, " - %s (%s)\n", c.path, c.desc)
|
||||
}
|
||||
b.WriteString("\nCreate one — minimal working example:\n\n")
|
||||
b.WriteString(` {"repo_url": "https://git.example.com/company/agent-content.git", "ref": "main"}` + "\n")
|
||||
if p := UserConfigPath(); p != "" {
|
||||
fmt.Fprintf(&b, "\nat %s (personal) or %s (fleet rollout).\n", p, DefaultConfigPath())
|
||||
} else {
|
||||
fmt.Fprintf(&b, "\nat %s (fleet rollout).\n", DefaultConfigPath())
|
||||
}
|
||||
b.WriteString("\nOr skip the config entirely:\n\n agent-lib sync --repo <url> [--ref <ref>]\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// LoadClientConfig resolves and reads the client config via the search
|
||||
// chain: --config flag, AGENT_LIB_CONFIG env, user-level config, admin
|
||||
// default — first existing file wins. Explicit flag/env paths that do not
|
||||
// exist are hard errors; a completely empty chain yields *MissingConfigError.
|
||||
func LoadClientConfig(flagPath string) (*ClientConfig, error) {
|
||||
path := flagPath
|
||||
if path == "" {
|
||||
path = os.Getenv(ConfigEnv)
|
||||
path, ok, err := resolveConfigPath(flagPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if path == "" {
|
||||
path = DefaultConfigPath()
|
||||
if !ok {
|
||||
return nil, &MissingConfigError{Chain: ConfigSearchChain(flagPath)}
|
||||
}
|
||||
return readClientConfig(path)
|
||||
}
|
||||
|
||||
// LoadOptionalConfig behaves like LoadClientConfig but returns (nil, nil)
|
||||
// when no config exists anywhere — for runs that carry their own --repo.
|
||||
// Malformed existing configs are still hard errors.
|
||||
func LoadOptionalConfig(flagPath string) (*ClientConfig, error) {
|
||||
path, ok, err := resolveConfigPath(flagPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return readClientConfig(path)
|
||||
}
|
||||
|
||||
// resolveConfigPath walks the search chain and returns the config to read.
|
||||
// The flag and env legs must exist when set (explicit intent); the user and
|
||||
// admin legs are skipped when absent.
|
||||
func resolveConfigPath(flagPath string) (path string, found bool, err error) {
|
||||
if flagPath != "" {
|
||||
return flagPath, true, nil
|
||||
}
|
||||
if envPath := os.Getenv(ConfigEnv); envPath != "" {
|
||||
return envPath, true, nil
|
||||
}
|
||||
if p := UserConfigPath(); p != "" {
|
||||
if _, statErr := os.Stat(p); statErr == nil {
|
||||
return p, true, nil
|
||||
}
|
||||
}
|
||||
admin := DefaultConfigPath()
|
||||
if _, statErr := os.Stat(admin); statErr == nil {
|
||||
return admin, true, nil
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
func readClientConfig(path string) (*ClientConfig, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading client config %s: %w", path, err)
|
||||
@@ -66,6 +176,19 @@ func LoadClientConfig(flagPath string) (*ClientConfig, error) {
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// FillTokenAuth completes the config's credentials from the environment:
|
||||
// AGENT_LIB_GIT_TOKEN / AGENT_LIB_GIT_USER are only consulted when the
|
||||
// discovered config carries no token — env never overrides a config token.
|
||||
func (c *ClientConfig) FillTokenAuth() {
|
||||
if c == nil || c.Token != "" {
|
||||
return
|
||||
}
|
||||
if token := os.Getenv(gitsource.TokenEnv); token != "" {
|
||||
c.Token = token
|
||||
c.TokenUser = os.Getenv(gitsource.UserEnv)
|
||||
}
|
||||
}
|
||||
|
||||
// StateDir returns the client state directory: the override env, then the
|
||||
// platform default (LocalAppData on Windows, XDG state elsewhere).
|
||||
func StateDir() (string, error) {
|
||||
|
||||
Reference in New Issue
Block a user