diff --git a/cmd/pilotctl/main.go b/cmd/pilotctl/main.go index 930c3088..0641d0c0 100644 --- a/cmd/pilotctl/main.go +++ b/cmd/pilotctl/main.go @@ -1148,13 +1148,19 @@ Common keys: Print the pilotctl build version string. `, - "update": `Usage: pilotctl update [flags] + "update": `Usage: pilotctl update [subcommand|flags] -Run the updater once — check for new releases and install if available. -In manual mode (daemon not running), re-runs skill install so newly -installed binaries have matching skill definitions. +Automatic updates are OFF by default. Control them with: + pilotctl update status show whether auto-update is on and the current version + pilotctl update enable turn automatic updates ON + pilotctl update disable turn automatic updates OFF (default) -Flags: +With no subcommand, runs the updater ONCE — a manual check that installs the +latest release if available, regardless of the auto-update setting. In manual +mode (daemon not running), re-runs skill install so newly installed binaries +have matching skill definitions. + +Flags (one-shot mode): --repo GitHub owner/repo for releases (default: pilot-protocol/pilotprotocol) --pin pin to a specific release tag (e.g. v1.10.5) `, diff --git a/cmd/pilotctl/updates.go b/cmd/pilotctl/updates.go index 40ed349f..f3385e52 100644 --- a/cmd/pilotctl/updates.go +++ b/cmd/pilotctl/updates.go @@ -3,6 +3,7 @@ package main import ( + "encoding/json" "encoding/xml" "fmt" "io" @@ -17,6 +18,76 @@ import ( "github.com/pilot-protocol/updater" ) +// autoUpdateStatePath is the JSON control file ({"enabled": bool}) shared with +// the pilot-updater loop (passed as its --state-path). Automatic updates are +// OFF by default: when the file is absent the updater applies nothing. +func autoUpdateStatePath() string { return configDir() + "/auto-update.json" } + +// autoUpdateEnabled reports the persisted auto-update setting (default off). +func autoUpdateEnabled() bool { + data, err := os.ReadFile(autoUpdateStatePath()) + if err != nil { + return false + } + var s struct { + Enabled bool `json:"enabled"` + } + if err := json.Unmarshal(data, &s); err != nil { + return false + } + return s.Enabled +} + +// cmdAutoUpdateSet turns automatic updates on or off (`pilotctl update +// enable|disable`). The pilot-updater re-reads the file each tick, so this +// takes effect without restarting it. +func cmdAutoUpdateSet(on bool) { + path := autoUpdateStatePath() + _ = os.MkdirAll(configDir(), 0o755) + data, _ := json.MarshalIndent(map[string]bool{"enabled": on}, "", " ") + if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil { + fatalCode("internal", "write %s: %v", path, err) + } + if jsonOutput { + outputOK(map[string]interface{}{"auto_update": on}) + return + } + if on { + fmt.Println("Automatic updates ENABLED. The updater will install new stable releases on its check interval.") + fmt.Println("Disable any time with: pilotctl update disable") + } else { + fmt.Println("Automatic updates DISABLED. Nothing will be installed automatically.") + fmt.Println("Run a one-time manual update with: pilotctl update") + } +} + +// cmdAutoUpdateStatus shows whether automatic updates are on and the current +// version (`pilotctl update status`). +func cmdAutoUpdateStatus() { + on := autoUpdateEnabled() + if jsonOutput { + outputOK(map[string]interface{}{ + "auto_update": on, + "current_version": version, + "state_file": autoUpdateStatePath(), + }) + return + } + state := "disabled" + if on { + state = "enabled" + } + fmt.Printf("Automatic updates: %s\n", state) + fmt.Printf("Current version: %s\n", version) + fmt.Printf("State file: %s\n", autoUpdateStatePath()) + if on { + fmt.Println("\nTurn off with: pilotctl update disable") + } else { + fmt.Println("\nTurn on with: pilotctl update enable") + fmt.Println("One-time check: pilotctl update") + } +} + // changelogFeedURL is the canonical RSS 2.0 feed for the public Pilot // Protocol changelog. Hosted on GitHub Pages from the pilot-changelog // repo (per `pilot-changelog/README.md`). RSS chosen over feed.json so @@ -218,6 +289,22 @@ func collapseWhitespace(s string) string { // --pin : pin to a specific release tag (e.g. v1.10.5) // (global) --json : emit machine-readable JSON func cmdUpdate(args []string) { + // Auto-update control surface: `pilotctl update status|enable|disable`. + // Bare `pilotctl update` (or with --repo/--pin flags) runs a one-shot + // manual update, which works regardless of the auto-update setting. + if len(args) >= 1 { + switch args[0] { + case "status": + cmdAutoUpdateStatus() + return + case "enable", "on": + cmdAutoUpdateSet(true) + return + case "disable", "off": + cmdAutoUpdateSet(false) + return + } + } flags, _ := parseFlags(args) repo := flagString(flags, "repo", "pilot-protocol/pilotprotocol") pin := flagString(flags, "pin", "") diff --git a/cmd/pilotctl/zz_autoupdate_test.go b/cmd/pilotctl/zz_autoupdate_test.go new file mode 100644 index 00000000..52c86a7b --- /dev/null +++ b/cmd/pilotctl/zz_autoupdate_test.go @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "os" + "testing" +) + +// TestAutoUpdateControl pins the enable/disable/default-off control surface. +func TestAutoUpdateControl(t *testing.T) { + t.Setenv("HOME", t.TempDir()) // configDir() -> $HOME/.pilot + + if autoUpdateEnabled() { + t.Fatal("auto-update must be OFF by default (no state file)") + } + + prev := jsonOutput + defer func() { jsonOutput = prev }() + jsonOutput = true + + _ = captureStdout(t, func() { cmdAutoUpdateSet(true) }) + if !autoUpdateEnabled() { + t.Fatal("enable did not persist") + } + if _, err := os.Stat(autoUpdateStatePath()); err != nil { + t.Fatalf("state file not written: %v", err) + } + + _ = captureStdout(t, func() { cmdAutoUpdateSet(false) }) + if autoUpdateEnabled() { + t.Fatal("disable did not persist") + } +} diff --git a/cmd/updater/main.go b/cmd/updater/main.go index e6121b2c..6ab976d3 100644 --- a/cmd/updater/main.go +++ b/cmd/updater/main.go @@ -16,6 +16,18 @@ import ( var version = "dev" +// defaultStatePath returns the auto-update control file, matching pilotctl's +// ~/.pilot/auto-update.json so `pilotctl update enable/disable` and this loop +// share one source of truth. Empty if the home dir can't be resolved (the +// updater then treats auto-update as disabled — opt-in). +func defaultStatePath() string { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "" + } + return home + "/.pilot/auto-update.json" +} + func main() { installDir := flag.String("install-dir", "", "directory containing pilot binaries (required)") repo := flag.String("repo", "pilot-protocol/pilotprotocol", "GitHub owner/repo for releases") @@ -24,6 +36,7 @@ func main() { logLevel := flag.String("log-level", "info", "log level (debug, info, warn, error)") logFormat := flag.String("log-format", "text", "log format (text, json)") showVersion := flag.Bool("version", false, "print version and exit") + statePath := flag.String("state-path", defaultStatePath(), "JSON control file {\"enabled\":bool} for automatic updates; auto-update is OFF until enabled (e.g. via `pilotctl update enable`)") flag.Parse() if *showVersion { @@ -44,6 +57,7 @@ func main() { InstallDir: *installDir, Version: version, PinnedVersion: *pin, + StatePath: *statePath, }) u.Start() diff --git a/go.mod b/go.mod index 88ecf29b..6fa22649 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,7 @@ require ( github.com/pilot-protocol/runtime v0.3.1 github.com/pilot-protocol/skillinject v0.2.3 github.com/pilot-protocol/trustedagents v0.2.3 - github.com/pilot-protocol/updater v0.2.2-0.20260616131353-92a3a30a235e + github.com/pilot-protocol/updater v0.2.2 github.com/pilot-protocol/webhook v0.2.0 golang.org/x/sys v0.46.0 ) diff --git a/go.sum b/go.sum index 33217fbd..1dc92834 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,6 @@ github.com/pilot-protocol/app-store v1.0.1-beta.1.0.20260616142430-8edfed7efa72 github.com/pilot-protocol/app-store v1.0.1-beta.1.0.20260616142430-8edfed7efa72/go.mod h1:leZPtX43gE2JB7xeljexXri81g6qhdZfYExLtzI+bhg= github.com/pilot-protocol/beacon v0.2.6 h1:grxwaVyPRUT0W6coyjYfNkO0rpzOIrwrKn94S21DuVE= github.com/pilot-protocol/beacon v0.2.6/go.mod h1:I/UhEv097g1z/qtAVDZbEhf3R5tzM0Dp71vGHah52A4= -github.com/pilot-protocol/common v0.5.3 h1:CsBBmzuQn75G1MKVvKdLp77G9nf6fC7YGLZh8DVeZEI= -github.com/pilot-protocol/common v0.5.3/go.mod h1:yrAwPXGVMbXU+SADvOCmbdXjK/wJ3uA0KshyLvRlej4= github.com/pilot-protocol/common v0.5.5 h1:mnv3q84alVaotGD+Qxfo4ECFEquqsUwrI3mjKIGUKFY= github.com/pilot-protocol/common v0.5.5/go.mod h1:yrAwPXGVMbXU+SADvOCmbdXjK/wJ3uA0KshyLvRlej4= github.com/pilot-protocol/dataexchange v0.2.1-beta.1.0.20260615113607-fac933edea98 h1:Bqgnf4CZC7aZJyDzz/E7agwXotArJg2FvFlNDqouhLo= @@ -30,8 +28,8 @@ github.com/pilot-protocol/skillinject v0.2.3 h1:Bf0tqRe7tqYY27X5RGCOf4LGjtWpyQvN github.com/pilot-protocol/skillinject v0.2.3/go.mod h1:fCzivA/bjkXRgGjp6yd7nqfaIETtU+lQRocBu0J/O9g= github.com/pilot-protocol/trustedagents v0.2.3 h1:QQJHYqzPrECJwkCev0xIDBMjd92uhtcxcCMc2aOrRHc= github.com/pilot-protocol/trustedagents v0.2.3/go.mod h1:gDgEOC9lHmXSS9v45h80XxlmUS861soIrA0AsbXiSV4= -github.com/pilot-protocol/updater v0.2.2-0.20260616131353-92a3a30a235e h1:vFzuw5dUVi0igwI2PdVzDY8OnY6FDLzM05wzI75zUZ8= -github.com/pilot-protocol/updater v0.2.2-0.20260616131353-92a3a30a235e/go.mod h1:/I0uhVk1SljAOEYmjTdI/6CP7UmemmV4WB22ai1FxUw= +github.com/pilot-protocol/updater v0.2.2 h1:uA+Gmbs3/sMoumtjwCMXUHo3TAKg51VRch88m0wUtZA= +github.com/pilot-protocol/updater v0.2.2/go.mod h1:wn+HkjgChZ1QCCkOHBolAol42mxyyW/iz7oOFJLciT4= github.com/pilot-protocol/webhook v0.2.0 h1:3UFU9X2yBb0iKlPbzVcism+Z6yCrBBaOgdo9+vd4Wf4= github.com/pilot-protocol/webhook v0.2.0/go.mod h1:WVXhHFg+o0pHHk+4nXMCh1zl/ZAyZ3AXrtx6mNuZS6g= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=