Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions cmd/pilotctl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> GitHub owner/repo for releases (default: pilot-protocol/pilotprotocol)
--pin <tag> pin to a specific release tag (e.g. v1.10.5)
`,
Expand Down
87 changes: 87 additions & 0 deletions cmd/pilotctl/updates.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package main

import (
"encoding/json"
"encoding/xml"
"fmt"
"io"
Expand All @@ -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
Expand Down Expand Up @@ -218,6 +289,22 @@ func collapseWhitespace(s string) string {
// --pin <tag> : 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", "")
Expand Down
34 changes: 34 additions & 0 deletions cmd/pilotctl/zz_autoupdate_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
14 changes: 14 additions & 0 deletions cmd/updater/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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 {
Expand All @@ -44,6 +57,7 @@ func main() {
InstallDir: *installDir,
Version: version,
PinnedVersion: *pin,
StatePath: *statePath,
})

u.Start()
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
6 changes: 2 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand Down
Loading