- status: read-only report of deployed/repository revision, pending changes, skipped items (with reasons), unrecognized local items, binary version; --json machine-readable; exit 1 when local modifications block repository updates (headless drift checks) - sync/status share a read-only prepare() front half - notify package: windows toast via PowerShell script (go-toast mechanism, zero deps, build-tag gated), noop on linux/macos; cross-compile verified - toast fires exactly once per sync with warnings, never for routine syncs; notification failures are logged and never fail the sync (fail-open)
174 lines
4.9 KiB
Go
174 lines
4.9 KiB
Go
package client
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"time"
|
|
|
|
"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/notify"
|
|
"github.com/m3tam3re/agent-lib/internal/target"
|
|
)
|
|
|
|
// Notifier delivers user-facing notifications; swap it in tests. Failures
|
|
// never fail the sync (fail-open, logged to stdout).
|
|
var Notifier notify.Sender = notify.Platform()
|
|
|
|
// SyncReport summarizes one planner-driven sync run.
|
|
type SyncReport struct {
|
|
Revision string
|
|
Deployed int
|
|
Updated int
|
|
Kept int
|
|
Removed int
|
|
Skipped int
|
|
Left int
|
|
Manifest string
|
|
}
|
|
|
|
// ExecutePlan applies a plan item by item: deploys and removes are atomic
|
|
// per item, every skip+warn and leave+warn is appended to the warning log
|
|
// with item name and reason, and the manifest is rewritten once at the end.
|
|
func ExecutePlan(tree *gitsource.GitTree, plan []PlanEntry, manifest *Manifest, paths StatePaths, rev string, stdout io.Writer) (*SyncReport, error) {
|
|
report := &SyncReport{Revision: rev, Manifest: paths.Manifest}
|
|
var warnings []string
|
|
|
|
for _, e := range plan {
|
|
key := manifestKey(e.Type, e.Name)
|
|
switch e.Action {
|
|
case ActDeploy, ActUpdate:
|
|
if err := deployEntry(tree, e); err != nil {
|
|
return nil, fmt.Errorf("%s %s/%s: %w", e.Action, e.Type, e.Name, err)
|
|
}
|
|
manifest.Items[key] = &ManifestItem{
|
|
Name: e.Name, Type: e.Type, Origin: e.Origin, Revision: rev, Hash: e.NewHash,
|
|
}
|
|
if e.Action == ActDeploy {
|
|
report.Deployed++
|
|
} else {
|
|
report.Updated++
|
|
}
|
|
case ActKeep:
|
|
report.Kept++
|
|
case ActRemove:
|
|
if err := os.RemoveAll(e.DstPath); err != nil {
|
|
return nil, fmt.Errorf("removing %s/%s: %w", e.Type, e.Name, err)
|
|
}
|
|
delete(manifest.Items, key)
|
|
report.Removed++
|
|
case ActSkip:
|
|
warnings = append(warnings, fmt.Sprintf("SKIP %s/%s: %s", e.Type, e.Name, e.Reason))
|
|
report.Skipped++
|
|
case ActLeave:
|
|
warnings = append(warnings, fmt.Sprintf("LEAVE %s/%s: %s", e.Type, e.Name, e.Reason))
|
|
report.Left++
|
|
}
|
|
}
|
|
|
|
manifest.Version = ManifestVersion
|
|
manifest.Revision = rev
|
|
if err := manifest.Save(paths.Manifest); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(warnings) > 0 {
|
|
if err := appendWarnings(paths.LogFile, warnings); err != nil {
|
|
fmt.Fprintf(stdout, "warning: could not write sync log: %v\n", err)
|
|
}
|
|
title := "agent-lib sync"
|
|
body := fmt.Sprintf("%d item(s) kept local: modifications blocked company updates. See the sync log for details.", report.Skipped+report.Left)
|
|
if err := Notifier.Send(title, body); err != nil {
|
|
fmt.Fprintf(stdout, "warning: notification failed: %v\n", err)
|
|
}
|
|
}
|
|
|
|
printPlannedSyncReport(stdout, report)
|
|
return report, nil
|
|
}
|
|
|
|
func deployEntry(tree *gitsource.GitTree, e PlanEntry) error {
|
|
if e.Type == lockfile.TypeSkill {
|
|
return deploy.Dir(tree, e.RelPath, e.DstPath)
|
|
}
|
|
return deploy.File(tree, e.RelPath, e.DstPath)
|
|
}
|
|
|
|
// appendWarnings adds timestamped warning lines to the sync log.
|
|
func appendWarnings(logPath string, warnings []string) error {
|
|
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
stamp := time.Now().UTC().Format(time.RFC3339)
|
|
for _, w := range warnings {
|
|
if _, err := fmt.Fprintf(f, "%s %s\n", stamp, w); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// diskFacts observes every deployed path the planner can care about:
|
|
// manifest items, current inventory items, and unmanaged occupants of the
|
|
// target directories.
|
|
func diskFacts(oc *target.OpenCode, manifest *Manifest, items []PlannedItem) map[string]DiskFact {
|
|
facts := map[string]DiskFact{}
|
|
observe := func(typ, name string) {
|
|
key := manifestKey(typ, name)
|
|
if _, seen := facts[key]; seen {
|
|
return
|
|
}
|
|
path, ok := oc.DeployPath(typ, name)
|
|
if !ok {
|
|
return
|
|
}
|
|
if _, err := os.Stat(path); err != nil {
|
|
facts[key] = DiskFact{}
|
|
return
|
|
}
|
|
hash, err := HashDiskPath(typ, path)
|
|
if err != nil {
|
|
hash = ""
|
|
}
|
|
facts[key] = DiskFact{Exists: true, Hash: hash}
|
|
}
|
|
|
|
for _, rec := range manifest.Items {
|
|
observe(rec.Type, rec.Name)
|
|
}
|
|
for _, it := range items {
|
|
observe(it.Type, it.Name)
|
|
}
|
|
for key := range oc.ScanTargetDirs() {
|
|
if _, seen := facts[key]; !seen {
|
|
facts[key] = DiskFact{Exists: true}
|
|
}
|
|
}
|
|
return facts
|
|
}
|
|
|
|
func printPlannedSyncReport(w io.Writer, r *SyncReport) {
|
|
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, " removed: %d\n", r.Removed)
|
|
if r.Skipped > 0 {
|
|
fmt.Fprintf(w, " skipped (local modifications kept): %d\n", r.Skipped)
|
|
}
|
|
if r.Left > 0 {
|
|
fmt.Fprintf(w, " unmanaged items left untouched: %d\n", r.Left)
|
|
}
|
|
fmt.Fprintf(w, " manifest: %s\n", r.Manifest)
|
|
}
|
|
|
|
func shortRev(rev string) string {
|
|
if len(rev) > 12 {
|
|
return rev[:12]
|
|
}
|
|
return rev
|
|
}
|