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
+11
View File
@@ -0,0 +1,11 @@
//go:build !windows
package notify
// Noop drops every notification; Linux and macOS stay silent by design.
type Noop struct{}
func (Noop) Send(title, body string) error { return nil }
// Platform returns the no-op sender on Linux and macOS.
func Platform() Sender { return Noop{} }
+9
View File
@@ -0,0 +1,9 @@
// Package notify fires user-facing notifications. The platform sender is a
// Windows toast (PowerShell-backed, mirroring go-toast's script); Linux and
// macOS are deliberate no-ops.
package notify
// Sender delivers one notification.
type Sender interface {
Send(title, body string) error
}
+49
View File
@@ -0,0 +1,49 @@
//go:build windows
package notify
import (
"fmt"
"os/exec"
"strings"
)
// PowerShellToast raises a Windows toast notification by invoking
// PowerShell with an AppId-tagged script — the same mechanism go-toast
// uses, without the dependency.
type PowerShellToast struct {
AppID string
}
// Platform returns the PowerShell toast sender on Windows.
func Platform() Sender { return PowerShellToast{AppID: "agent-lib"} }
var toastScript = `$app = '%[1]s'
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
$tmpl = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
$txt = $tmpl.GetElementsByTagName('text')
$txt.Item(0).AppendChild($tmpl.CreateTextNode('%[2]s')) | Out-Null
$txt.Item(1).AppendChild($tmpl.CreateTextNode('%[3]s')) | Out-Null
$toast = [Windows.UI.Notifications.ToastNotification]::new($tmpl)
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($app).Show($toast)`
// Send fires one toast. It requires an interactive user session; callers
// must treat errors as non-fatal.
func (p PowerShellToast) Send(title, body string) error {
appID := p.AppID
if appID == "" {
appID = "agent-lib"
}
script := fmt.Sprintf(toastScript, escape(appID), escape(title), escape(body))
cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", script)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("toast failed: %v: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
// escape makes s safe inside a PowerShell single-quoted string.
func escape(s string) string {
r := strings.NewReplacer("'", "''", "\n", " ", "\r", " ")
return r.Replace(s)
}