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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ mv gander ~/go/bin/gander # or any directory in your PATH
gander --upgrade
```

Downloads the latest release binary that matches your OS/arch, verifies its SHA256 checksum, and atomically replaces the running binary. Sets `GITHUB_TOKEN` in the environment to raise the API rate limit on shared networks.
Downloads the latest release binary that matches your OS/arch, verifies its SHA256 checksum, and atomically replaces the running binary. If `~/.gander/skill` is already installed, the same command also pulls the latest [`gandermd/gander-skill`](https://github.com/gandermd/gander-skill) and re-links agent dests. Sets `GITHUB_TOKEN` in the environment to raise the API rate limit on shared networks.

If you built from source the old-fashioned way, re-run `install.sh` (or `git pull && ./install.sh --source`).

Expand Down Expand Up @@ -257,6 +257,7 @@ The legacy `~/.mdp` fallback only applies when `GANDER_CONFIG` is unset; named p
Download and install the latest release, then exit. The runner is shut
down over UDS first, the binary is replaced, then the supervisor (or a
fresh spawn) brings the upgraded daemon back up with the same watches.
If the agent skill is already installed, also refreshes ~/.gander/skill.
```

Subcommands:
Expand Down
11 changes: 11 additions & 0 deletions man/man1/gander.1
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ or debugging the watcher itself.
.TP
.BR \-upgrade ", " \-\-upgrade
Download and install the latest release, then exit.
If the agent skill is already installed, also refresh
.IR ~/.gander/skill .
.TP
.BR \-\-help ", " \-h
Print help and exit.
Expand Down Expand Up @@ -322,6 +324,15 @@ instead.
.BR \-\-upgrade
Download the latest release binary for the current platform, verify its
checksum, atomically replace the running binary, and exit.
If
.I ~/.gander/skill
(or a dest symlink pointing at it) is already present, also pull the
latest
.B gandermd/gander-skill
and re-link the agent skill directories, matching
.BR "gander skill" .
A skill refresh failure does not undo a successful binary replace; the
command still exits non-zero.
.SH FILES
.TP
.I ~/.gander
Expand Down
27 changes: 27 additions & 0 deletions skill.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,33 @@ func runSkill(args []string) error {
return fmt.Errorf("usage: gander skill [install]")
}

func skillAlreadyInstalled() bool {
home, err := os.UserHomeDir()
if err != nil {
return false
}
skillDir := filepath.Join(home, ".gander", "skill")
if _, err := os.Stat(filepath.Join(skillDir, "SKILL.md")); err == nil {
return true
}
for _, d := range skillDests {
if isOurSkillLink(filepath.Join(home, d.rel), skillDir) {
return true
}
}
return false
}

func refreshSkillIfInstalled() error {
if !skillAlreadyInstalled() {
return nil
}
if err := runSkillInstall(); err != nil {
return fmt.Errorf("skill: %w", err)
}
return nil
}

func runSkillInstall() error {
home, err := os.UserHomeDir()
if err != nil {
Expand Down
38 changes: 38 additions & 0 deletions skill_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,44 @@ func TestSkillInstallPrefersAgentsLayout(t *testing.T) {
}
}

func TestSkillAlreadyInstalled(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
if skillAlreadyInstalled() {
t.Fatal("want false when nothing installed")
}

skillDir := filepath.Join(home, ".gander", "skill")
if err := os.MkdirAll(skillDir, 0700); err != nil {
t.Fatal(err)
}
if skillAlreadyInstalled() {
t.Fatal("empty skill dir without SKILL.md is not installed")
}
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# Gander\n"), 0644); err != nil {
t.Fatal(err)
}
if !skillAlreadyInstalled() {
t.Fatal("want true when ~/.gander/skill/SKILL.md exists")
}
}

func TestSkillAlreadyInstalledDestSymlink(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
skillDir := filepath.Join(home, ".gander", "skill")
dest := filepath.Join(home, ".claude", "skills", "gander")
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(skillDir, dest); err != nil {
t.Fatal(err)
}
if !skillAlreadyInstalled() {
t.Fatal("want true when a dest is our skill symlink")
}
}

func TestSkillRejectsBadUsage(t *testing.T) {
err := runSkill([]string{"foo"})
if err == nil {
Expand Down
56 changes: 28 additions & 28 deletions upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,42 +61,42 @@ func runUpgrade() error {

if Version != "dev" && rel.TagName == Version {
fmt.Println("Already on the latest version.")
return nil
}
} else {
assetName := assetNameForRuntime()
asset, ok := findAsset(rel.Assets, assetName)
if !ok {
return fmt.Errorf("no release asset named %s in %s; available: %s",
assetName, rel.HTMLURL, listAssetNames(rel.Assets))
}

assetName := assetNameForRuntime()
asset, ok := findAsset(rel.Assets, assetName)
if !ok {
return fmt.Errorf("no release asset named %s in %s; available: %s",
assetName, rel.HTMLURL, listAssetNames(rel.Assets))
}
fmt.Printf("Found %s, downloading %s...\n", rel.TagName, assetName)

fmt.Printf("Found %s, downloading %s...\n", rel.TagName, assetName)
binPath, err := downloadToTemp(asset.BrowserDownloadURL)
if err != nil {
return fmt.Errorf("download: %w", err)
}
defer os.Remove(binPath)

binPath, err := downloadToTemp(asset.BrowserDownloadURL)
if err != nil {
return fmt.Errorf("download: %w", err)
}
defer os.Remove(binPath)
sum, err := downloadSha256(asset.BrowserDownloadURL + ".sha256")
if err != nil {
return fmt.Errorf("download checksum: %w", err)
}

sum, err := downloadSha256(asset.BrowserDownloadURL + ".sha256")
if err != nil {
return fmt.Errorf("download checksum: %w", err)
}
if err := verifySha256(binPath, sum); err != nil {
return fmt.Errorf("checksum mismatch: %w", err)
}

if err := verifySha256(binPath, sum); err != nil {
return fmt.Errorf("checksum mismatch: %w", err)
}
if err := installBinary(binPath, exePath); err != nil {
return fmt.Errorf("install: %w", err)
}

if err := installBinary(binPath, exePath); err != nil {
return fmt.Errorf("install: %w", err)
}
fmt.Printf("Upgraded %s -> %s\n", Version, rel.TagName)
fmt.Printf("Release notes: %s\n", rel.HTMLURL)

fmt.Printf("Upgraded %s -> %s\n", Version, rel.TagName)
fmt.Printf("Release notes: %s\n", rel.HTMLURL)
restartDaemonAfterUpgrade(exePath)
}

restartDaemonAfterUpgrade(exePath)
return nil
return refreshSkillIfInstalled()
}

// stopDaemonForUpgrade asks the live runner to shut down over IPC so the
Expand Down
140 changes: 140 additions & 0 deletions upgrade_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package main

import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)

Expand Down Expand Up @@ -34,3 +37,140 @@ func TestIsRunnerSupervisedNoUnit(t *testing.T) {
t.Skip("runner is currently supervised in this environment")
}
}

func TestUpgradeRefreshesInstalledSkill(t *testing.T) {
home := isolatedUpgradeHome(t)
skillDir := filepath.Join(home, ".gander", "skill")
if err := os.MkdirAll(skillDir, 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# old\n"), 0644); err != nil {
t.Fatal(err)
}
serveSkillArchive(t, map[string]string{
"gander-skill-main/SKILL.md": "# new\n",
})
stubUpgradeAlreadyCurrent(t, "v9.9.9")

if err := runUpgrade(); err != nil {
t.Fatalf("runUpgrade: %v", err)
}
body, err := os.ReadFile(filepath.Join(skillDir, "SKILL.md"))
if err != nil {
t.Fatal(err)
}
if string(body) != "# new\n" {
t.Errorf("SKILL.md = %q, want refreshed copy", body)
}
for _, dest := range skillDestPaths(home) {
assertSkillLinked(t, dest, skillDir)
}
}

func TestUpgradeRefreshesSkillFromDestSymlink(t *testing.T) {
home := isolatedUpgradeHome(t)
skillDir := filepath.Join(home, ".gander", "skill")
dest := filepath.Join(home, ".claude", "skills", "gander")
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(skillDir, dest); err != nil {
t.Fatal(err)
}
serveSkillArchive(t, map[string]string{
"gander-skill-main/SKILL.md": "# from dest\n",
})
stubUpgradeAlreadyCurrent(t, "v9.9.9")

if err := runUpgrade(); err != nil {
t.Fatalf("runUpgrade: %v", err)
}
body, err := os.ReadFile(filepath.Join(skillDir, "SKILL.md"))
if err != nil {
t.Fatal(err)
}
if string(body) != "# from dest\n" {
t.Errorf("SKILL.md = %q, want dest-triggered refresh", body)
}
}

func TestUpgradeSkipsSkillWhenNotInstalled(t *testing.T) {
home := isolatedUpgradeHome(t)
hit := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hit = true
http.Error(w, "should not download skill", http.StatusInternalServerError)
}))
t.Cleanup(srv.Close)
prev := skillDownloadURL
skillDownloadURL = srv.URL
t.Cleanup(func() { skillDownloadURL = prev })
stubUpgradeAlreadyCurrent(t, "v9.9.9")

if err := runUpgrade(); err != nil {
t.Fatalf("runUpgrade: %v", err)
}
if hit {
t.Error("skill download ran even though skill was not installed")
}
for _, dest := range skillDestPaths(home) {
if _, err := os.Lstat(dest); !os.IsNotExist(err) {
t.Errorf("created dest %s: %v", dest, err)
}
}
}

func TestUpgradeSkillFailureDoesNotUndoCLI(t *testing.T) {
home := isolatedUpgradeHome(t)
skillDir := filepath.Join(home, ".gander", "skill")
if err := os.MkdirAll(skillDir, 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# old\n"), 0644); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "boom", http.StatusInternalServerError)
}))
t.Cleanup(srv.Close)
prev := skillDownloadURL
skillDownloadURL = srv.URL
t.Cleanup(func() { skillDownloadURL = prev })
stubUpgradeAlreadyCurrent(t, "v9.9.9")

err := runUpgrade()
if err == nil {
t.Fatal("expected skill error")
}
if !strings.Contains(err.Error(), "skill:") {
t.Errorf("err = %v, want wrapped skill: prefix", err)
}
body, readErr := os.ReadFile(filepath.Join(skillDir, "SKILL.md"))
if readErr != nil {
t.Fatal(readErr)
}
if string(body) != "# old\n" {
t.Errorf("SKILL.md replaced after failed download: %q", body)
}
}

func isolatedUpgradeHome(t *testing.T) string {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("GANDER_CONFIG", "")
return home
}

func stubUpgradeAlreadyCurrent(t *testing.T, version string) {
t.Helper()
prevV := Version
Version = version
t.Cleanup(func() { Version = prevV })

prevFetch := fetchLatestRelease
fetchLatestRelease = func() (*releaseInfo, error) {
return &releaseInfo{TagName: version}, nil
}
t.Cleanup(func() { fetchLatestRelease = prevFetch })
}