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
235 lines
7.2 KiB
Go
235 lines
7.2 KiB
Go
// Package client implements the employee side: config loading, work-repo
|
|
// pull, inventory, manifest and deployment into the OpenCode target.
|
|
package client
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
|
|
"github.com/m3tam3re/agent-lib/internal/gitsource"
|
|
)
|
|
|
|
// 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"`
|
|
Token string `json:"token,omitempty"`
|
|
TokenUser string `json:"token_user,omitempty"`
|
|
}
|
|
|
|
// ConfigEnv names the override environment variables.
|
|
const (
|
|
ConfigEnv = "AGENT_LIB_CONFIG"
|
|
StateDirEnv = "AGENT_LIB_STATE_DIR"
|
|
)
|
|
|
|
// DefaultConfigPath returns the admin-protected config location per platform:
|
|
// ProgramData on Windows, /etc on Linux/macOS.
|
|
func DefaultConfigPath() string {
|
|
if runtime.GOOS == "windows" {
|
|
if pd := os.Getenv("ProgramData"); pd != "" {
|
|
return filepath.Join(pd, "agent-lib", "config.json")
|
|
}
|
|
return `C:\ProgramData\agent-lib\config.json`
|
|
}
|
|
return "/etc/agent-lib/config.json"
|
|
}
|
|
|
|
// 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, ok, err := resolveConfigPath(flagPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
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)
|
|
}
|
|
var cfg ClientConfig
|
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
return nil, fmt.Errorf("client config %s is not valid JSON: %w", path, err)
|
|
}
|
|
if cfg.RepoURL == "" {
|
|
return nil, fmt.Errorf("client config %s: repo_url is required", path)
|
|
}
|
|
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) {
|
|
if d := os.Getenv(StateDirEnv); d != "" {
|
|
return d, nil
|
|
}
|
|
if runtime.GOOS == "windows" {
|
|
base := os.Getenv("LocalAppData")
|
|
if base == "" {
|
|
return "", fmt.Errorf("LocalAppData is not set")
|
|
}
|
|
return filepath.Join(base, "agent-lib"), nil
|
|
}
|
|
if xdg := os.Getenv("XDG_STATE_HOME"); xdg != "" {
|
|
return filepath.Join(xdg, "agent-lib"), nil
|
|
}
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.Join(home, ".local", "state", "agent-lib"), nil
|
|
}
|
|
|
|
// StatePaths bundles the well-known files inside the state dir.
|
|
type StatePaths struct {
|
|
Root string
|
|
CacheDir string
|
|
Manifest string
|
|
LogFile string
|
|
}
|
|
|
|
func ResolveStatePaths() (StatePaths, error) {
|
|
root, err := StateDir()
|
|
if err != nil {
|
|
return StatePaths{}, err
|
|
}
|
|
return StatePaths{
|
|
Root: root,
|
|
CacheDir: filepath.Join(root, "work-repo"),
|
|
Manifest: filepath.Join(root, "manifest.json"),
|
|
LogFile: filepath.Join(root, "sync.log"),
|
|
}, nil
|
|
}
|