- 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
173 lines
4.1 KiB
Go
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
|
|
}
|