feat: client sync core — config, pull, inventory, manifest, OpenCode target
- admin-protected client config (flag > AGENT_LIB_CONFIG > platform default), token used for basic auth only, redacted from all errors and files - work-repo pull via go-git (bare cache clone + fetch, remote re-pointed on config change), clean failure keeps previous state - inventory: own items + external areas from lockfile; MCP inventoried but never deployed - manifest v1 records name/type/origin/revision/content-hash per item, deterministic bytes; folder hashes over sorted relpath+filehash lines so tree and disk hashes agree - shared deploy package (atomic temp+rename, exec-bit preserving) now backs both curator materialization and client deployment - sync validates and hashes everything before the first mutation; second run is a byte-level no-op
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/config"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
gittransport "github.com/go-git/go-git/v5/plumbing/transport"
|
||||
"github.com/go-git/go-git/v5/plumbing/transport/http"
|
||||
|
||||
"github.com/m3tam3re/agent-lib/internal/gitsource"
|
||||
)
|
||||
|
||||
// PullResult is one successful work-repo pull: the resolved revision plus
|
||||
// read access to its commit tree.
|
||||
type PullResult struct {
|
||||
Rev string
|
||||
Tree *gitsource.GitTree
|
||||
}
|
||||
|
||||
// Pull fetches the work repository into the cache dir (bare clone, then
|
||||
// fetch) and resolves ref — the default branch when empty. An unreachable
|
||||
// repository yields a clean error before any deployment state is touched.
|
||||
func Pull(cacheDir, url, ref, token, tokenUser string) (*PullResult, error) {
|
||||
auth := basicAuth(token, tokenUser)
|
||||
repo, err := openOrCreate(cacheDir, url, ref, auth)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hash, err := resolveRemoteRef(repo, ref)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolving %q in %s: %w", refLabel(ref), gitsource.RedactURL(url), err)
|
||||
}
|
||||
commit, err := repo.CommitObject(hash)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading commit %s: %w", hash, err)
|
||||
}
|
||||
tree, err := commit.Tree()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading tree of %s: %w", hash, err)
|
||||
}
|
||||
return &PullResult{Rev: hash.String(), Tree: gitsource.NewGitTree(tree)}, nil
|
||||
}
|
||||
|
||||
func basicAuth(token, tokenUser string) gittransport.AuthMethod {
|
||||
if token == "" {
|
||||
return nil
|
||||
}
|
||||
user := tokenUser
|
||||
if user == "" {
|
||||
user = "agent-lib"
|
||||
}
|
||||
return &http.BasicAuth{Username: user, Password: token}
|
||||
}
|
||||
|
||||
func openOrCreate(cacheDir, url, ref string, auth gittransport.AuthMethod) (*git.Repository, error) {
|
||||
if _, err := os.Stat(filepath.Join(cacheDir, "HEAD")); err == nil {
|
||||
repo, err := git.PlainOpen(cacheDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening cached work repo: %w", err)
|
||||
}
|
||||
if err := repointRemote(repo, url); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := repo.Fetch(&git.FetchOptions{Auth: auth}); err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
|
||||
return nil, fmt.Errorf("fetching %s: %w", gitsource.RedactURL(url), err)
|
||||
}
|
||||
return repo, nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(cacheDir), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts := &git.CloneOptions{URL: url, Auth: auth, NoCheckout: true}
|
||||
if ref != "" {
|
||||
opts.ReferenceName = plumbing.NewBranchReferenceName(ref)
|
||||
}
|
||||
repo, err := git.PlainClone(cacheDir, true, opts)
|
||||
if err != nil {
|
||||
os.RemoveAll(cacheDir)
|
||||
return nil, fmt.Errorf("cloning %s: %w", gitsource.RedactURL(url), err)
|
||||
}
|
||||
return repo, nil
|
||||
}
|
||||
|
||||
// repointRemote aligns the cached origin with the configured URL so a fleet
|
||||
// re-point takes effect on the next sync.
|
||||
func repointRemote(repo *git.Repository, url string) error {
|
||||
remote, err := repo.Remote(git.DefaultRemoteName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading cached remote: %w", err)
|
||||
}
|
||||
if len(remote.Config().URLs) == 0 || remote.Config().URLs[0] == url {
|
||||
return nil
|
||||
}
|
||||
if err := repo.DeleteRemote(git.DefaultRemoteName); err != nil {
|
||||
return fmt.Errorf("replacing cached remote: %w", err)
|
||||
}
|
||||
_, err = repo.CreateRemote(&config.RemoteConfig{
|
||||
Name: git.DefaultRemoteName,
|
||||
URLs: []string{url},
|
||||
Fetch: []config.RefSpec{
|
||||
config.RefSpec("+refs/heads/*:refs/remotes/origin/*"),
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func resolveRemoteRef(repo *git.Repository, ref string) (plumbing.Hash, error) {
|
||||
candidates := remoteRefCandidates(ref)
|
||||
for _, name := range candidates {
|
||||
if h, err := repo.ResolveRevision(plumbing.Revision(name)); err == nil {
|
||||
return *h, nil
|
||||
}
|
||||
}
|
||||
return plumbing.ZeroHash, fmt.Errorf("reference not found (tried %v)", candidates)
|
||||
}
|
||||
|
||||
func remoteRefCandidates(ref string) []string {
|
||||
if ref == "" {
|
||||
return []string{"refs/remotes/origin/HEAD", "HEAD"}
|
||||
}
|
||||
return []string{
|
||||
"refs/remotes/origin/" + ref,
|
||||
"refs/tags/" + ref,
|
||||
"refs/heads/" + ref,
|
||||
ref,
|
||||
}
|
||||
}
|
||||
|
||||
func refLabel(ref string) string {
|
||||
if ref == "" {
|
||||
return "default branch"
|
||||
}
|
||||
return ref
|
||||
}
|
||||
Reference in New Issue
Block a user