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 }