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:
2026-08-23 15:08:27 +02:00
parent 1b2fcb6fbb
commit 7070d8cc25
7 changed files with 627 additions and 31 deletions
+139 -16
View File
@@ -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) {
+165
View File
@@ -0,0 +1,165 @@
package client
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func writeConfigFile(t *testing.T, path, repoURL string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
data, err := json.Marshal(map[string]string{"repo_url": repoURL, "ref": "main"})
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
}
// runIsolated points HOME/XDG at empty temp dirs and clears the override env
// so chain tests never see the real machine's config.
func runIsolated(t *testing.T, fn func()) {
t.Helper()
base := t.TempDir()
home := filepath.Join(base, "home")
xdg := filepath.Join(base, "xdg-config")
for _, d := range []string{home, xdg} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", xdg)
t.Setenv(ConfigEnv, "")
fn()
}
func TestUserConfigPathHonorsXDG(t *testing.T) {
runIsolated(t, func() {
base := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", base)
want := filepath.Join(base, "agent-lib", "config.json")
if got := UserConfigPath(); got != want {
t.Errorf("UserConfigPath() = %q, want %q", got, want)
}
})
}
func TestLoadClientConfigChainOrder(t *testing.T) {
runIsolated(t, func() {
base := t.TempDir()
flagCfg := filepath.Join(base, "flag.json")
envCfg := filepath.Join(base, "env.json")
userCfg := filepath.Join(os.Getenv("XDG_CONFIG_HOME"), "agent-lib", "config.json")
writeConfigFile(t, flagCfg, "https://flag.example/repo")
writeConfigFile(t, envCfg, "https://env.example/repo")
writeConfigFile(t, userCfg, "https://user.example/repo")
assertRepo := func(name, got, want string) {
t.Helper()
if got != want {
t.Errorf("%s: repo_url = %q, want %q", name, got, want)
}
}
cfg, err := LoadClientConfig(flagCfg)
if err != nil {
t.Fatal(err)
}
assertRepo("flag beats env and user", cfg.RepoURL, "https://flag.example/repo")
t.Setenv(ConfigEnv, envCfg)
cfg, err = LoadClientConfig("")
if err != nil {
t.Fatal(err)
}
assertRepo("env beats user", cfg.RepoURL, "https://env.example/repo")
t.Setenv(ConfigEnv, "")
cfg, err = LoadClientConfig("")
if err != nil {
t.Fatal(err)
}
assertRepo("user picked up without flags", cfg.RepoURL, "https://user.example/repo")
})
}
func TestLoadClientConfigMissingIsActionable(t *testing.T) {
runIsolated(t, func() {
_, err := LoadClientConfig("")
if err == nil {
t.Fatal("empty chain must fail")
}
var missing *MissingConfigError
if e, ok := err.(*MissingConfigError); !ok {
t.Fatalf("error must be MissingConfigError, got %T: %v", err, err)
} else {
missing = e
}
msg := missing.Error()
userPath := UserConfigPath()
for _, want := range []string{
"no client config found",
userPath,
DefaultConfigPath(),
`"repo_url"`,
"--repo",
} {
if !strings.Contains(msg, want) {
t.Errorf("error message must mention %q:\n%s", want, msg)
}
}
})
}
func TestLoadOptionalConfigNilWhenEmpty(t *testing.T) {
runIsolated(t, func() {
cfg, err := LoadOptionalConfig("")
if err != nil {
t.Fatalf("empty chain must be (nil, nil), got %v", err)
}
if cfg != nil {
t.Errorf("cfg must be nil, got %+v", cfg)
}
})
}
func TestLoadOptionalConfigMalformedStillFails(t *testing.T) {
runIsolated(t, func() {
userCfg := filepath.Join(os.Getenv("XDG_CONFIG_HOME"), "agent-lib", "config.json")
writeConfigFile(t, userCfg, "")
if _, err := LoadOptionalConfig(""); err == nil {
t.Error("user config with empty repo_url must fail even in optional mode")
}
})
}
func TestLoadClientConfigExplicitFlagMustExist(t *testing.T) {
runIsolated(t, func() {
_, err := LoadClientConfig(filepath.Join(t.TempDir(), "nope.json"))
if err == nil || !strings.Contains(err.Error(), "nope.json") {
t.Errorf("explicit --config pointing nowhere must name the path, got %v", err)
}
})
}
func TestFillTokenAuthEnvOnlyFillsEmptyToken(t *testing.T) {
t.Setenv("AGENT_LIB_GIT_TOKEN", "env-token")
t.Setenv("AGENT_LIB_GIT_USER", "env-user")
fromEnv := &ClientConfig{}
fromEnv.FillTokenAuth()
if fromEnv.Token != "env-token" || fromEnv.TokenUser != "env-user" {
t.Errorf("empty token must be filled from env: %+v", fromEnv)
}
configured := &ClientConfig{Token: "config-token", TokenUser: "config-user"}
configured.FillTokenAuth()
if configured.Token != "config-token" || configured.TokenUser != "config-user" {
t.Errorf("env must never override a config token: %+v", configured)
}
}
+36 -2
View File
@@ -10,9 +10,13 @@ import (
"github.com/m3tam3re/agent-lib/internal/target"
)
// SyncOptions parameterize a client sync run.
// SyncOptions parameterize a client sync run. RepoURL (with optional Ref)
// is the inline --repo override: it works without any config file and beats
// a repo_url discovered in the config chain.
type SyncOptions struct {
ConfigPath string
RepoURL string
Ref string
}
// prepared is everything sync and status share: the pulled repository, the
@@ -28,7 +32,10 @@ type prepared struct {
// prepare pulls, inventories, hashes and plans — the complete read-only
// front half of a client run.
func prepare(opts SyncOptions) (*prepared, error) {
cfg, err := LoadClientConfig(opts.ConfigPath)
if opts.RepoURL == "" && opts.Ref != "" {
return nil, fmt.Errorf("--ref requires --repo (it only overrides the repository to pull)")
}
cfg, err := resolveRunConfig(opts)
if err != nil {
return nil, err
}
@@ -82,6 +89,33 @@ func prepare(opts SyncOptions) (*prepared, error) {
return &prepared{paths: paths, pull: pull, manifest: manifest, items: items, plan: plan}, nil
}
// resolveRunConfig applies the --repo inline override to the config chain:
// with --repo, any discovered config only contributes auth (and ref, unless
// --ref is also given); without --repo, a config with a repo_url is required.
func resolveRunConfig(opts SyncOptions) (*ClientConfig, error) {
if opts.RepoURL == "" {
cfg, err := LoadClientConfig(opts.ConfigPath)
if err != nil {
return nil, err
}
cfg.FillTokenAuth()
return cfg, nil
}
cfg, err := LoadOptionalConfig(opts.ConfigPath)
if err != nil {
return nil, err
}
if cfg == nil {
cfg = &ClientConfig{}
}
cfg.RepoURL = opts.RepoURL
if opts.Ref != "" {
cfg.Ref = opts.Ref
}
cfg.FillTokenAuth()
return cfg, nil
}
// Sync pulls the work repository, plans the sync as a pure function over
// (inventory, manifest, disk facts) and executes the plan. Validation and
// hashing happen before the first mutation; unreachable repositories fail