Files
agent-lib/internal/client/sync.go
T
m3ta-chiron 49dbcf469e 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
2026-08-23 10:31:00 +02:00

173 lines
4.1 KiB
Go

package client
import (
"bytes"
"fmt"
"io"
"os"
"path/filepath"
"github.com/m3tam3re/agent-lib/internal/deploy"
"github.com/m3tam3re/agent-lib/internal/gitsource"
"github.com/m3tam3re/agent-lib/internal/lockfile"
"github.com/m3tam3re/agent-lib/internal/target"
)
// SyncOptions parameterize a client sync run.
type SyncOptions struct {
ConfigPath string
}
// SyncReport summarizes one sync run.
type SyncReport struct {
Revision string
Deployed int
Updated int
Kept int
Manifest string
}
// Sync pulls the work repository and deploys its content into the OpenCode
// target. Everything is validated and hashed before the first mutation;
// unreachable repositories fail cleanly with the previous state intact.
func Sync(opts SyncOptions, stdout io.Writer) (*SyncReport, error) {
cfg, err := LoadClientConfig(opts.ConfigPath)
if err != nil {
return nil, err
}
paths, err := ResolveStatePaths()
if err != nil {
return nil, err
}
if err := os.MkdirAll(paths.Root, 0o755); err != nil {
return nil, err
}
pull, err := Pull(paths.CacheDir, cfg.RepoURL, cfg.Ref, cfg.Token, cfg.TokenUser)
if err != nil {
return nil, err
}
inv, err := BuildInventory(pull.Tree, pull.Rev)
if err != nil {
return nil, err
}
oc, err := target.NewOpenCode()
if err != nil {
return nil, err
}
manifest, err := LoadManifest(paths.Manifest)
if err != nil {
return nil, err
}
type prepared struct {
item InventoryItem
hash string
dst string
}
var plan []prepared
for _, it := range inv.Deployable() {
dst, ok := oc.DeployPath(it.Type, it.Name)
if !ok {
continue
}
hash, err := HashItem(pull.Tree, it)
if err != nil {
return nil, fmt.Errorf("hashing %s/%s: %w", it.Type, it.Name, err)
}
if !safePath(dst) {
return nil, fmt.Errorf("refusing unsafe deploy path %q", dst)
}
plan = append(plan, prepared{item: it, hash: hash, dst: dst})
}
report := &SyncReport{Revision: pull.Rev, Manifest: paths.Manifest}
for _, p := range plan {
key := manifestKey(p.item.Type, p.item.Name)
record := manifest.Items[key]
if record != nil && record.Hash == p.hash {
if diskHash, err := HashDiskPath(p.item.Type, p.dst); err == nil && diskHash == p.hash {
report.Kept++
continue
}
}
if err := deployItem(pull.Tree, p.item, p.dst); err != nil {
return nil, fmt.Errorf("deploying %s/%s: %w", p.item.Type, p.item.Name, err)
}
manifest.Items[key] = &ManifestItem{
Name: p.item.Name, Type: p.item.Type, Origin: p.item.Origin,
Revision: pull.Rev, Hash: p.hash,
}
if record == nil {
report.Deployed++
} else {
report.Updated++
}
}
manifest.Version = ManifestVersion
manifest.Revision = pull.Rev
unchanged, err := manifestUnchanged(manifest, paths.Manifest)
if err != nil {
return nil, err
}
if !unchanged {
if err := manifest.Save(paths.Manifest); err != nil {
return nil, err
}
}
printSyncReport(stdout, report, len(inv.Items))
return report, nil
}
func deployItem(tree *gitsource.GitTree, it InventoryItem, dst string) error {
if it.Type == lockfile.TypeSkill {
return deploy.Dir(tree, it.RelPath, dst)
}
return deploy.File(tree, it.RelPath, dst)
}
// safePath rejects destinations that escape the user's home directory.
func safePath(dst string) bool {
home, err := os.UserHomeDir()
if err != nil {
return false
}
rel, err := filepath.Rel(home, dst)
if err != nil {
return false
}
return rel != ".." && !filepath.IsAbs(rel)
}
func manifestUnchanged(m *Manifest, path string) (bool, error) {
existing, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
fresh, err := marshalManifest(m)
if err != nil {
return false, err
}
return bytes.Equal(existing, fresh), nil
}
func printSyncReport(w io.Writer, r *SyncReport, totalItems int) {
fmt.Fprintf(w, "synced to revision %s\n", shortRev(r.Revision))
fmt.Fprintf(w, " deployed: %d\n", r.Deployed)
fmt.Fprintf(w, " updated: %d\n", r.Updated)
fmt.Fprintf(w, " kept: %d\n", r.Kept)
fmt.Fprintf(w, " manifest: %s\n", r.Manifest)
}
func shortRev(rev string) string {
if len(rev) > 12 {
return rev[:12]
}
return rev
}