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:
@@ -123,6 +123,9 @@ agent-lib sync # pull the work repository and deploy
|
||||
agent-lib status # report local state; exit 1 when updates are blocked
|
||||
```
|
||||
|
||||
Both accept `--repo <url>` / `--ref <ref>` for config-free personal use —
|
||||
see [Configuration](#configuration) below.
|
||||
|
||||
Deployment mapping (OpenCode target):
|
||||
|
||||
| Type | Destination |
|
||||
@@ -149,9 +152,22 @@ an unreachable repository never leaves a half-deployed machine.
|
||||
|
||||
### Configuration
|
||||
|
||||
The client config is admin-provided (fleet rollout), JSON at
|
||||
`C:\ProgramData\agent-lib\config.json` on Windows, `/etc/agent-lib/config.json`
|
||||
elsewhere. Override per run with `--config` or `AGENT_LIB_CONFIG`:
|
||||
The repository to pull comes from the first source that provides one:
|
||||
|
||||
1. `--repo <url>` flag (works with **no config file at all**)
|
||||
2. `--config <path>` flag
|
||||
3. `AGENT_LIB_CONFIG` env
|
||||
4. user-level config (personal machines)
|
||||
5. admin-protected platform default (fleet rollout)
|
||||
|
||||
| Source | Linux / macOS | Windows |
|
||||
|---|---|---|
|
||||
| user-level config | `$XDG_CONFIG_HOME/agent-lib/config.json` (default `~/.config/agent-lib/config.json`) | `%AppData%\agent-lib\config.json` |
|
||||
| admin default | `/etc/agent-lib/config.json` | `%ProgramData%\agent-lib\config.json` |
|
||||
|
||||
`--repo` overrides a `repo_url` from any discovered config; `--ref` narrows it
|
||||
to a branch or tag. When no config exists anywhere, the error lists every
|
||||
searched location, a minimal working example, and the `--repo` shortcut.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -163,7 +179,18 @@ elsewhere. Override per run with `--config` or `AGENT_LIB_CONFIG`:
|
||||
```
|
||||
|
||||
The token is used for basic auth only. It never appears in the lockfile, the
|
||||
manifest, logs or error messages.
|
||||
manifest, logs or error messages. On `--repo` runs (or when the discovered
|
||||
config carries no token) `AGENT_LIB_GIT_TOKEN` / `AGENT_LIB_GIT_USER` supply
|
||||
credentials instead — env never overrides a configured token.
|
||||
|
||||
**Personal quickstart (no fleet, no config):**
|
||||
|
||||
```sh
|
||||
agent-lib sync --repo https://github.com/you/your-agent-content.git
|
||||
```
|
||||
|
||||
The work repository must be a committed git repository (sync pulls it
|
||||
read-only via go-git; local uncommitted state is ignored).
|
||||
|
||||
### Notifications
|
||||
|
||||
@@ -192,6 +219,7 @@ flag that drives the non-zero exit code — suitable for headless drift checks.
|
||||
|
||||
| Symptom | Cause / fix |
|
||||
|---|---|
|
||||
| `error: … no client config found. Searched (in order): …` | No config anywhere. Create one where the error suggests, or skip it: `agent-lib sync --repo <url>`. |
|
||||
| `error: … include entry "x" not found upstream` | Typo, or the item was renamed/removed upstream. Fix the lockfile include list, or re-add with a corrected `--include`. |
|
||||
| `error: … collides with own skills "x"` | Deployed-name collision. Add `--rename <upstream>=<deployed>` (add) or fix the rename map (update). |
|
||||
| `validate` reports tree/lockfile divergence | Someone edited `external/` by hand. Re-run `vendor update <source>` to restore the pinned state. |
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
)
|
||||
|
||||
func newStatusCmd() *cobra.Command {
|
||||
var configPath string
|
||||
var configPath, repoURL, ref string
|
||||
var asJSON bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "status",
|
||||
@@ -20,7 +20,8 @@ func newStatusCmd() *cobra.Command {
|
||||
"agent-lib version is included in the report.",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
report, err := client.Status(client.SyncOptions{ConfigPath: configPath})
|
||||
opts := client.SyncOptions{ConfigPath: configPath, RepoURL: repoURL, Ref: ref}
|
||||
report, err := client.Status(opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("status: %w", err)
|
||||
}
|
||||
@@ -37,7 +38,9 @@ func newStatusCmd() *cobra.Command {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&configPath, "config", "", "client config path (default: "+client.ConfigEnv+" env or platform default)")
|
||||
cmd.Flags().StringVar(&configPath, "config", "", "client config path (default: "+client.ConfigEnv+" env, user config, or platform default)")
|
||||
cmd.Flags().StringVar(&repoURL, "repo", "", "work repository URL or path — overrides any config, works without one")
|
||||
cmd.Flags().StringVar(&ref, "ref", "", "branch or tag to pull (requires --repo)")
|
||||
cmd.Flags().BoolVar(&asJSON, "json", false, "machine-readable output")
|
||||
return cmd
|
||||
}
|
||||
|
||||
+10
-6
@@ -9,22 +9,26 @@ import (
|
||||
)
|
||||
|
||||
func newSyncCmd() *cobra.Command {
|
||||
var configPath string
|
||||
var configPath, repoURL, ref string
|
||||
cmd := &cobra.Command{
|
||||
Use: "sync",
|
||||
Short: "Pull the work repository and deploy its content to OpenCode",
|
||||
Long: "Pulls the fleet-configured work repository (config from --config, " +
|
||||
"AGENT_LIB_CONFIG, or the admin-protected platform default) and deploys " +
|
||||
"skills, commands and agents into the OpenCode runtime paths. " +
|
||||
Long: "Pulls the work repository and deploys skills, commands and agents " +
|
||||
"into the OpenCode runtime paths. The repository comes from --repo, " +
|
||||
"or from the config chain: --config, " + client.ConfigEnv + " env, the " +
|
||||
"user-level config, or the admin-protected platform default. " +
|
||||
"Idempotent: re-running changes nothing when content and disk match.",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if _, err := client.Sync(client.SyncOptions{ConfigPath: configPath}, cmd.OutOrStdout()); err != nil {
|
||||
opts := client.SyncOptions{ConfigPath: configPath, RepoURL: repoURL, Ref: ref}
|
||||
if _, err := client.Sync(opts, cmd.OutOrStdout()); err != nil {
|
||||
return fmt.Errorf("sync: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&configPath, "config", "", "client config path (default: "+client.ConfigEnv+" env or platform default)")
|
||||
cmd.Flags().StringVar(&configPath, "config", "", "client config path (default: "+client.ConfigEnv+" env, user config, or platform default)")
|
||||
cmd.Flags().StringVar(&repoURL, "repo", "", "work repository URL or path — overrides any config, works without one")
|
||||
cmd.Flags().StringVar(&ref, "ref", "", "branch or tag to pull (requires --repo)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
+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) {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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