feat: status command + windows toast notifications

- 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)
This commit is contained in:
2026-08-23 10:40:25 +02:00
parent 63619b90a4
commit 949a431c4c
11 changed files with 496 additions and 6 deletions
+102
View File
@@ -0,0 +1,102 @@
package client
import (
"fmt"
"io"
"github.com/m3tam3re/agent-lib/internal/version"
)
// StatusEntry names one item in a status section.
type StatusEntry struct {
Item string `json:"item"`
Reason string `json:"reason,omitempty"`
}
// StatusReport is the machine-readable local-state report.
type StatusReport struct {
Version string `json:"agent_lib_version"`
DeployedRev string `json:"deployed_revision"`
RepoRev string `json:"repository_revision"`
InSync bool `json:"in_sync"`
Drift bool `json:"problematic_drift"`
Pending []StatusEntry `json:"pending_changes,omitempty"`
Skipped []StatusEntry `json:"skipped_items,omitempty"`
Unrecognized []StatusEntry `json:"unrecognized_items,omitempty"`
}
// Status reports the local state without mutating anything. Drift is true
// when local modifications block repository updates (or occupy names the
// repository needs) — the state headless drift checks flag with a non-zero
// exit code.
func Status(opts SyncOptions) (*StatusReport, error) {
p, err := prepare(opts)
if err != nil {
return nil, err
}
report := &StatusReport{
Version: version.Version,
DeployedRev: p.manifest.Revision,
RepoRev: p.pull.Rev,
}
for _, e := range p.plan {
item := e.Type + "/" + e.Name
switch e.Action {
case ActDeploy, ActUpdate, ActRemove:
report.Pending = append(report.Pending, StatusEntry{Item: item, Reason: string(e.Action)})
case ActSkip:
blocked := true
if rec := p.manifest.Items[manifestKey(e.Type, e.Name)]; rec != nil && rec.Hash == e.NewHash {
blocked = false
}
if blocked {
report.Drift = true
}
report.Skipped = append(report.Skipped, StatusEntry{Item: item, Reason: e.Reason})
case ActLeave:
report.Unrecognized = append(report.Unrecognized, StatusEntry{Item: item, Reason: e.Reason})
}
}
report.InSync = report.DeployedRev == report.RepoRev && len(report.Pending) == 0
return report, nil
}
// PrintStatus renders the human-readable report and returns the drift
// verdict so the caller can set the exit code.
func PrintStatus(w io.Writer, r *StatusReport) bool {
fmt.Fprintf(w, "agent-lib %s\n", r.Version)
fmt.Fprintf(w, "deployed revision: %s\n", revOrNone(r.DeployedRev))
fmt.Fprintf(w, "repository revision: %s\n", revOrNone(r.RepoRev))
if r.InSync {
fmt.Fprintln(w, "state: in sync")
} else {
fmt.Fprintln(w, "state: out of sync")
}
printSection := func(title string, entries []StatusEntry) {
if len(entries) == 0 {
return
}
fmt.Fprintf(w, "%s:\n", title)
for _, e := range entries {
fmt.Fprintf(w, " %s", e.Item)
if e.Reason != "" {
fmt.Fprintf(w, " — %s", e.Reason)
}
fmt.Fprintln(w)
}
}
printSection("pending changes", r.Pending)
printSection("skipped (local modifications)", r.Skipped)
printSection("unrecognized local items", r.Unrecognized)
if r.Drift {
fmt.Fprintln(w, "drift: local modifications block repository updates")
}
return r.Drift
}
func revOrNone(rev string) string {
if rev == "" {
return "(never synced)"
}
return rev
}