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,111 @@
|
||||
// Package client implements the employee side: config loading, work-repo
|
||||
// pull, inventory, manifest and deployment into the OpenCode target.
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// ClientConfig is the admin-protected fleet configuration for sync.
|
||||
type ClientConfig struct {
|
||||
RepoURL string `json:"repo_url"`
|
||||
Ref string `json:"ref,omitempty"`
|
||||
Token string `json:"token,omitempty"`
|
||||
TokenUser string `json:"token_user,omitempty"`
|
||||
}
|
||||
|
||||
// ConfigEnv names the override environment variables.
|
||||
const (
|
||||
ConfigEnv = "AGENT_LIB_CONFIG"
|
||||
StateDirEnv = "AGENT_LIB_STATE_DIR"
|
||||
defaultConfig = `{
|
||||
"repo_url": "",
|
||||
"ref": "",
|
||||
"token": "",
|
||||
"token_user": ""
|
||||
}`
|
||||
)
|
||||
|
||||
// DefaultConfigPath returns the admin-protected config location per platform:
|
||||
// ProgramData on Windows, /etc on Linux/macOS.
|
||||
func DefaultConfigPath() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
if pd := os.Getenv("ProgramData"); pd != "" {
|
||||
return filepath.Join(pd, "agent-lib", "config.json")
|
||||
}
|
||||
return `C:\ProgramData\agent-lib\config.json`
|
||||
}
|
||||
return "/etc/agent-lib/config.json"
|
||||
}
|
||||
|
||||
// LoadClientConfig reads the config from flagPath, the override env, or the
|
||||
// platform default — in that order.
|
||||
func LoadClientConfig(flagPath string) (*ClientConfig, error) {
|
||||
path := flagPath
|
||||
if path == "" {
|
||||
path = os.Getenv(ConfigEnv)
|
||||
}
|
||||
if path == "" {
|
||||
path = DefaultConfigPath()
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading client config %s: %w", path, err)
|
||||
}
|
||||
var cfg ClientConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("client config %s is not valid JSON: %w", path, err)
|
||||
}
|
||||
if cfg.RepoURL == "" {
|
||||
return nil, fmt.Errorf("client config %s: repo_url is required", path)
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// StateDir returns the client state directory: the override env, then the
|
||||
// platform default (LocalAppData on Windows, XDG state elsewhere).
|
||||
func StateDir() (string, error) {
|
||||
if d := os.Getenv(StateDirEnv); d != "" {
|
||||
return d, nil
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
base := os.Getenv("LocalAppData")
|
||||
if base == "" {
|
||||
return "", fmt.Errorf("LocalAppData is not set")
|
||||
}
|
||||
return filepath.Join(base, "agent-lib"), nil
|
||||
}
|
||||
if xdg := os.Getenv("XDG_STATE_HOME"); xdg != "" {
|
||||
return filepath.Join(xdg, "agent-lib"), nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".local", "state", "agent-lib"), nil
|
||||
}
|
||||
|
||||
// StatePaths bundles the well-known files inside the state dir.
|
||||
type StatePaths struct {
|
||||
Root string
|
||||
CacheDir string
|
||||
Manifest string
|
||||
LogFile string
|
||||
}
|
||||
|
||||
func ResolveStatePaths() (StatePaths, error) {
|
||||
root, err := StateDir()
|
||||
if err != nil {
|
||||
return StatePaths{}, err
|
||||
}
|
||||
return StatePaths{
|
||||
Root: root,
|
||||
CacheDir: filepath.Join(root, "work-repo"),
|
||||
Manifest: filepath.Join(root, "manifest.json"),
|
||||
LogFile: filepath.Join(root, "sync.log"),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/m3tam3re/agent-lib/internal/discovery"
|
||||
"github.com/m3tam3re/agent-lib/internal/gitsource"
|
||||
"github.com/m3tam3re/agent-lib/internal/lockfile"
|
||||
)
|
||||
|
||||
// InventoryItem is one deployable item found in the work repository.
|
||||
type InventoryItem struct {
|
||||
Type string
|
||||
Name string
|
||||
Origin string
|
||||
RelPath string
|
||||
Hash string
|
||||
Warnings []string
|
||||
}
|
||||
|
||||
// Inventory is the full content of a work-repo revision: own contributions
|
||||
// plus every external area. MCP fragments are inventoried but filtered at
|
||||
// deployment time.
|
||||
type Inventory struct {
|
||||
Revision string
|
||||
Items []InventoryItem
|
||||
}
|
||||
|
||||
// BuildInventory reads the work repository tree and the lockfile (when
|
||||
// present) and resolves the flat deployed namespace.
|
||||
func BuildInventory(tree *gitsource.GitTree, rev string) (*Inventory, error) {
|
||||
inv := &Inventory{Revision: rev}
|
||||
|
||||
own, err := discovery.Scan(tree, discovery.Config{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scanning own items: %w", err)
|
||||
}
|
||||
for _, it := range own {
|
||||
inv.Items = append(inv.Items, InventoryItem{
|
||||
Type: it.Type, Name: it.UpstreamID, Origin: "own", RelPath: it.RelPath,
|
||||
})
|
||||
}
|
||||
|
||||
var lf *lockfile.Lockfile
|
||||
if data, err := tree.ReadFile(lockfile.FileName); err == nil {
|
||||
lf, err = lockfile.ParseBytes(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("work repository lockfile is invalid: %w", err)
|
||||
}
|
||||
} else {
|
||||
lf = lockfile.New()
|
||||
}
|
||||
|
||||
sources := make([]string, 0, len(lf.Sources))
|
||||
for name := range lf.Sources {
|
||||
sources = append(sources, name)
|
||||
}
|
||||
sort.Strings(sources)
|
||||
for _, name := range sources {
|
||||
sub := &discovery.PrefixTree{Tree: tree, Prefix: "external/" + name}
|
||||
items, err := discovery.Scan(sub, discovery.Config{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scanning external area of %q: %w", name, err)
|
||||
}
|
||||
for _, it := range items {
|
||||
inv.Items = append(inv.Items, InventoryItem{
|
||||
Type: it.Type, Name: it.UpstreamID, Origin: name, RelPath: "external/" + name + "/" + it.RelPath,
|
||||
Warnings: it.Warnings,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(inv.Items, func(i, j int) bool {
|
||||
if inv.Items[i].Type != inv.Items[j].Type {
|
||||
return inv.Items[i].Type < inv.Items[j].Type
|
||||
}
|
||||
return inv.Items[i].Name < inv.Items[j].Name
|
||||
})
|
||||
return inv, nil
|
||||
}
|
||||
|
||||
// Deployable filters the inventory down to the types the target deploys.
|
||||
func (inv *Inventory) Deployable() []InventoryItem {
|
||||
var out []InventoryItem
|
||||
for _, it := range inv.Items {
|
||||
if it.Type == lockfile.TypeMcp {
|
||||
continue
|
||||
}
|
||||
out = append(out, it)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/m3tam3re/agent-lib/internal/gitsource"
|
||||
"github.com/m3tam3re/agent-lib/internal/lockfile"
|
||||
)
|
||||
|
||||
// ManifestVersion is the local manifest schema version.
|
||||
const ManifestVersion = 1
|
||||
|
||||
// ManifestItem records one deployed item: name, type, origin, the work-repo
|
||||
// revision it came from, and the content hash at deploy time.
|
||||
type ManifestItem struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Origin string `json:"origin"`
|
||||
Revision string `json:"revision"`
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
|
||||
// Manifest is the local record of everything agent-lib deployed.
|
||||
type Manifest struct {
|
||||
Version int `json:"version"`
|
||||
Revision string `json:"revision"`
|
||||
Items map[string]*ManifestItem `json:"items"`
|
||||
}
|
||||
|
||||
// NewManifest returns an empty manifest v1.
|
||||
func NewManifest() *Manifest {
|
||||
return &Manifest{Version: ManifestVersion, Items: map[string]*ManifestItem{}}
|
||||
}
|
||||
|
||||
// LoadManifest reads the manifest at path; a missing file yields an empty
|
||||
// manifest, not an error (first sync).
|
||||
func LoadManifest(path string) (*Manifest, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return NewManifest(), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var m Manifest
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
return nil, fmt.Errorf("manifest %s is not valid JSON: %w", path, err)
|
||||
}
|
||||
if m.Items == nil {
|
||||
m.Items = map[string]*ManifestItem{}
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// Save writes the manifest deterministically (sorted item keys, two-space
|
||||
// indent, trailing newline).
|
||||
func (m *Manifest) Save(path string) error {
|
||||
data, err := marshalManifest(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0o644)
|
||||
}
|
||||
|
||||
func marshalManifest(m *Manifest) ([]byte, error) {
|
||||
data, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(data, '\n'), nil
|
||||
}
|
||||
|
||||
// Key identifies an item in the manifest: "<type>/<name>".
|
||||
func manifestKey(typ, name string) string {
|
||||
return typ + "/" + name
|
||||
}
|
||||
|
||||
// HashItem computes the content hash of an inventory item from its git tree:
|
||||
// single files hash their bytes; folders hash "relpath 0x00 hash" lines over
|
||||
// all contained files, sorted.
|
||||
func HashItem(tree *gitsource.GitTree, item InventoryItem) (string, error) {
|
||||
if item.Type != lockfile.TypeSkill {
|
||||
data, err := tree.ReadFile(item.RelPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
return "sha256:" + hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
files, err := tree.FilesUnder(item.RelPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
prefix := strings.TrimSuffix(item.RelPath, "/") + "/"
|
||||
h := sha256.New()
|
||||
for _, f := range files {
|
||||
data, err := tree.ReadFile(f)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fsum := sha256.Sum256(data)
|
||||
fmt.Fprintf(h, "%s\x00%s\n", strings.TrimPrefix(f, prefix), hex.EncodeToString(fsum[:]))
|
||||
}
|
||||
return "sha256:" + hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// HashDiskPath computes the same content hash for a deployed path on disk.
|
||||
func HashDiskPath(typ, path string) (string, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
return "sha256:" + hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
var lines []string
|
||||
err = filepath.WalkDir(path, func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(path, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fsum := sha256.Sum256(data)
|
||||
lines = append(lines, fmt.Sprintf("%s\x00%s", filepath.ToSlash(rel), hex.EncodeToString(fsum[:])))
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sort.Strings(lines)
|
||||
h := sha256.New()
|
||||
for _, l := range lines {
|
||||
fmt.Fprintf(h, "%s\n", l)
|
||||
}
|
||||
return "sha256:" + hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user