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:
@@ -0,0 +1,239 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// configChainEnv isolates HOME/XDG/AGENT_LIB_* so config-chain e2e tests see
|
||||
// only the fixture paths they explicitly create.
|
||||
type configChainEnv struct {
|
||||
home string
|
||||
xdg string
|
||||
stateDir string
|
||||
}
|
||||
|
||||
func newConfigChainEnv(t *testing.T) *configChainEnv {
|
||||
t.Helper()
|
||||
base := t.TempDir()
|
||||
env := &configChainEnv{
|
||||
home: filepath.Join(base, "home"),
|
||||
xdg: filepath.Join(base, "xdg"),
|
||||
stateDir: filepath.Join(base, "state"),
|
||||
}
|
||||
for _, d := range []string{env.home, env.xdg, env.stateDir} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func (e *configChainEnv) run(t *testing.T, extraEnv map[string]string, args ...string) (string, error) {
|
||||
t.Helper()
|
||||
cmd := agentLibCmd(args...)
|
||||
scrubbed := []string{}
|
||||
for _, kv := range os.Environ() {
|
||||
if strings.HasPrefix(kv, "AGENT_LIB_") || strings.HasPrefix(kv, "XDG_CONFIG_HOME=") || strings.HasPrefix(kv, "HOME=") {
|
||||
continue
|
||||
}
|
||||
scrubbed = append(scrubbed, kv)
|
||||
}
|
||||
cmd.Env = append(scrubbed,
|
||||
"HOME="+e.home, "USERPROFILE="+e.home,
|
||||
"XDG_CONFIG_HOME="+e.xdg,
|
||||
"AGENT_LIB_STATE_DIR="+e.stateDir,
|
||||
)
|
||||
for k, v := range extraEnv {
|
||||
cmd.Env = append(cmd.Env, k+"="+v)
|
||||
}
|
||||
out, err := cmd.CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
func (e *configChainEnv) userConfigPath() string {
|
||||
return filepath.Join(e.xdg, "agent-lib", "config.json")
|
||||
}
|
||||
|
||||
func (e *configChainEnv) writeUserConfig(t *testing.T, repoURL string) {
|
||||
t.Helper()
|
||||
writeJSONConfig(t, e.userConfigPath(), repoURL)
|
||||
}
|
||||
|
||||
func writeJSONConfig(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)
|
||||
}
|
||||
}
|
||||
|
||||
// buildNamedRepo creates a committed work repo whose skills/<id>/SKILL.md
|
||||
// carries a distinctive id, so the deployed skill identifies the winning
|
||||
// config source.
|
||||
func buildNamedRepo(t *testing.T, h *harness, dir, skillID string) {
|
||||
t.Helper()
|
||||
h.git(t, dir, "init", "-b", "main")
|
||||
p := filepath.Join(dir, "skills", skillID, "SKILL.md")
|
||||
os.MkdirAll(filepath.Dir(p), 0o755)
|
||||
os.WriteFile(p, []byte("---\nname: "+skillID+"\n---\n# "+skillID+"\n"), 0o644)
|
||||
h.git(t, dir, "add", "-A")
|
||||
h.git(t, dir, "commit", "-m", "repo with "+skillID)
|
||||
}
|
||||
|
||||
func deployedSkillPath(home, skillID string) string {
|
||||
return filepath.Join(home, ".agents", "skills", skillID, "SKILL.md")
|
||||
}
|
||||
|
||||
func TestSyncRepoFlagZeroConfig(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
env := newConfigChainEnv(t)
|
||||
repo := filepath.Join(t.TempDir(), "work")
|
||||
os.MkdirAll(repo, 0o755)
|
||||
buildNamedRepo(t, h, repo, "zero-config-skill")
|
||||
|
||||
out, err := env.run(t, nil, "sync", "--repo", repo)
|
||||
if err != nil {
|
||||
t.Fatalf("zero-config sync via --repo failed: %v\n%s", err, out)
|
||||
}
|
||||
if _, err := os.Stat(deployedSkillPath(env.home, "zero-config-skill")); err != nil {
|
||||
t.Error("--repo must deploy without any config file")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(env.stateDir, "manifest.json")); err != nil {
|
||||
t.Error("--repo run must write a manifest")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncRepoFlagOverridesConfiguredRepo(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
env := newConfigChainEnv(t)
|
||||
repoA := filepath.Join(t.TempDir(), "repo-a")
|
||||
repoB := filepath.Join(t.TempDir(), "repo-b")
|
||||
os.MkdirAll(repoA, 0o755)
|
||||
os.MkdirAll(repoB, 0o755)
|
||||
buildNamedRepo(t, h, repoA, "from-config-skill")
|
||||
buildNamedRepo(t, h, repoB, "from-flag-skill")
|
||||
env.writeUserConfig(t, repoA)
|
||||
|
||||
out, err := env.run(t, nil, "sync", "--repo", repoB)
|
||||
if err != nil {
|
||||
t.Fatalf("sync with --repo over config failed: %v\n%s", err, out)
|
||||
}
|
||||
if _, err := os.Stat(deployedSkillPath(env.home, "from-flag-skill")); err != nil {
|
||||
t.Error("--repo must beat the configured repo_url")
|
||||
}
|
||||
if _, err := os.Stat(deployedSkillPath(env.home, "from-config-skill")); err == nil {
|
||||
t.Error("configured repo must not also deploy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncUserConfigDiscoveredWithoutFlags(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
env := newConfigChainEnv(t)
|
||||
repo := filepath.Join(t.TempDir(), "work")
|
||||
os.MkdirAll(repo, 0o755)
|
||||
buildNamedRepo(t, h, repo, "user-config-skill")
|
||||
env.writeUserConfig(t, repo)
|
||||
|
||||
out, err := env.run(t, nil, "sync")
|
||||
if err != nil {
|
||||
t.Fatalf("sync with only a user-level config failed: %v\n%s", err, out)
|
||||
}
|
||||
if _, err := os.Stat(deployedSkillPath(env.home, "user-config-skill")); err != nil {
|
||||
t.Error("user-level config must be picked up without flags or env")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncConfigSearchOrder(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
env := newConfigChainEnv(t)
|
||||
flagRepo := filepath.Join(t.TempDir(), "flag-repo")
|
||||
envRepo := filepath.Join(t.TempDir(), "env-repo")
|
||||
os.MkdirAll(flagRepo, 0o755)
|
||||
os.MkdirAll(envRepo, 0o755)
|
||||
buildNamedRepo(t, h, flagRepo, "flag-wins-skill")
|
||||
buildNamedRepo(t, h, envRepo, "env-wins-skill")
|
||||
flagCfg := filepath.Join(t.TempDir(), "flag.json")
|
||||
envCfg := filepath.Join(t.TempDir(), "env.json")
|
||||
writeJSONConfig(t, flagCfg, flagRepo)
|
||||
writeJSONConfig(t, envCfg, envRepo)
|
||||
env.writeUserConfig(t, envRepo)
|
||||
|
||||
out, err := env.run(t, map[string]string{"AGENT_LIB_CONFIG": envCfg}, "sync", "--config", flagCfg)
|
||||
if err != nil {
|
||||
t.Fatalf("flag>env sync failed: %v\n%s", err, out)
|
||||
}
|
||||
if _, err := os.Stat(deployedSkillPath(env.home, "flag-wins-skill")); err != nil {
|
||||
t.Error("--config flag must beat AGENT_LIB_CONFIG")
|
||||
}
|
||||
|
||||
out, err = env.run(t, map[string]string{"AGENT_LIB_CONFIG": envCfg}, "sync")
|
||||
if err != nil {
|
||||
t.Fatalf("env>user sync failed: %v\n%s", err, out)
|
||||
}
|
||||
if _, err := os.Stat(deployedSkillPath(env.home, "env-wins-skill")); err != nil {
|
||||
t.Error("AGENT_LIB_CONFIG must beat the user config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncMissingConfigErrorIsActionable(t *testing.T) {
|
||||
newHarness(t)
|
||||
env := newConfigChainEnv(t)
|
||||
|
||||
out, err := env.run(t, nil, "sync")
|
||||
if err == nil {
|
||||
t.Fatalf("sync without any config must fail:\n%s", out)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"no client config found",
|
||||
"user config",
|
||||
"admin default",
|
||||
env.userConfigPath(),
|
||||
`"repo_url"`,
|
||||
"--repo",
|
||||
} {
|
||||
if !contains(out, want) {
|
||||
t.Errorf("missing-config error must mention %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncRefWithoutRepoIsError(t *testing.T) {
|
||||
newHarness(t)
|
||||
env := newConfigChainEnv(t)
|
||||
out, err := env.run(t, nil, "sync", "--ref", "main")
|
||||
if err == nil {
|
||||
t.Fatalf("--ref without --repo must fail:\n%s", out)
|
||||
}
|
||||
if !contains(out, "--ref requires --repo") {
|
||||
t.Errorf("error must explain the --ref/--repo relationship:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusRepoFlagZeroConfig(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
env := newConfigChainEnv(t)
|
||||
repo := filepath.Join(t.TempDir(), "work")
|
||||
os.MkdirAll(repo, 0o755)
|
||||
buildNamedRepo(t, h, repo, "status-repo-skill")
|
||||
|
||||
if out, err := env.run(t, nil, "sync", "--repo", repo); err != nil {
|
||||
t.Fatalf("sync failed: %v\n%s", err, out)
|
||||
}
|
||||
out, err := env.run(t, nil, "status", "--repo", repo, "--json")
|
||||
if err != nil {
|
||||
t.Fatalf("status --repo --json failed: %v\n%s", err, out)
|
||||
}
|
||||
if !contains(out, `"in_sync": true`) {
|
||||
t.Errorf("status via --repo must report in sync:\n%s", out)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user