Skip to content

Commit bc2aec3

Browse files
committed
feat: add update logic, version
1 parent a1e516b commit bc2aec3

8 files changed

Lines changed: 696 additions & 9 deletions

File tree

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ jobs:
4545
fi
4646
echo "Building ${OUT}"
4747
GOOS="${GOOS}" GOARCH="${GOARCH}" CGO_ENABLED=0 \
48-
go build -ldflags "-s -w -X main.version=latest-prerelease -X main.commit=${GITHUB_SHA} -X main.date=${BUILD_DATE}" -o "${OUT}" .
48+
go build -ldflags "-s -w -X main.buildVersion=latest-prerelease -X main.commit=${GITHUB_SHA} -X main.date=${BUILD_DATE}" -o "${OUT}" .
4949
done
5050
done
5151
(cd dist && sha256sum * > checksums.txt)

.goreleaser.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ builds:
2121
- amd64
2222
- arm64
2323
ldflags:
24-
- -s -w -X main.version={{ .Version }} -X main.commit={{ .Commit }} -X main.date={{ .Date }}
24+
- -s -w -X main.buildVersion={{ .Version }} -X main.commit={{ .Commit }} -X main.date={{ .Date }}
2525

2626
archives:
2727
- id: release

internal/commands/upgrade.go

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
package commands
2+
3+
import (
4+
"crypto/sha256"
5+
"encoding/hex"
6+
"fmt"
7+
"io"
8+
"os"
9+
"os/exec"
10+
"path/filepath"
11+
"runtime"
12+
"strings"
13+
14+
"github.com/spf13/cobra"
15+
)
16+
17+
const (
18+
upgradeRepo = "wherobots/wbc-cli"
19+
upgradeDefaultTag = "latest-prerelease"
20+
upgradeBinary = "wherobots"
21+
)
22+
23+
type upgradeOptions struct {
24+
tag string
25+
skipChecksum bool
26+
installDir string
27+
currentVersion string
28+
}
29+
30+
// AddUpgradeCommand registers the "upgrade" subcommand on the root command.
31+
// It requires the current build version to display during the upgrade flow.
32+
func AddUpgradeCommand(root *cobra.Command, currentVersion string) {
33+
opts := &upgradeOptions{currentVersion: currentVersion}
34+
35+
cmd := &cobra.Command{
36+
Use: "upgrade",
37+
Short: "Upgrade the CLI to the latest release",
38+
SilenceUsage: true,
39+
SilenceErrors: true,
40+
RunE: func(cmd *cobra.Command, _ []string) error {
41+
return runUpgrade(cmd, opts)
42+
},
43+
}
44+
45+
cmd.Flags().StringVar(&opts.tag, "tag", upgradeDefaultTag, "release tag to install")
46+
cmd.Flags().BoolVar(&opts.skipChecksum, "skip-checksum", false, "skip SHA-256 checksum verification")
47+
cmd.Flags().StringVar(&opts.installDir, "install-dir", "", "override install directory (default: directory of the current binary)")
48+
49+
root.AddCommand(cmd)
50+
}
51+
52+
func runUpgrade(cmd *cobra.Command, opts *upgradeOptions) error {
53+
w := cmd.ErrOrStderr()
54+
55+
// Resolve the install directory: prefer flag, then location of the running binary.
56+
installDir := opts.installDir
57+
if installDir == "" {
58+
exe, err := os.Executable()
59+
if err != nil {
60+
return fmt.Errorf("cannot determine current binary location: %w", err)
61+
}
62+
exe, err = filepath.EvalSymlinks(exe)
63+
if err != nil {
64+
return fmt.Errorf("cannot resolve binary symlink: %w", err)
65+
}
66+
installDir = filepath.Dir(exe)
67+
}
68+
69+
// Ensure gh is available and authenticated.
70+
if err := requireGh(); err != nil {
71+
return err
72+
}
73+
74+
osName, archName, err := detectPlatform()
75+
if err != nil {
76+
return err
77+
}
78+
79+
asset := fmt.Sprintf("%s_%s_%s", upgradeBinary, osName, archName)
80+
tag := opts.tag
81+
82+
fmt.Fprintf(w, "Downloading %s from %s@%s...\n", asset, upgradeRepo, tag)
83+
84+
tmpDir, err := os.MkdirTemp("", "wherobots-upgrade-*")
85+
if err != nil {
86+
return fmt.Errorf("create temp dir: %w", err)
87+
}
88+
defer os.RemoveAll(tmpDir)
89+
90+
if err := ghDownload(tag, asset, tmpDir); err != nil {
91+
return fmt.Errorf("download asset: %w", err)
92+
}
93+
94+
assetPath := filepath.Join(tmpDir, asset)
95+
96+
if !opts.skipChecksum {
97+
fmt.Fprintln(w, "Verifying checksum...")
98+
if err := ghDownload(tag, "checksums.txt", tmpDir); err != nil {
99+
return fmt.Errorf("download checksums: %w", err)
100+
}
101+
if err := verifyChecksum(assetPath, filepath.Join(tmpDir, "checksums.txt"), asset); err != nil {
102+
return err
103+
}
104+
}
105+
106+
target := filepath.Join(installDir, upgradeBinary)
107+
108+
if err := installBinary(assetPath, target); err != nil {
109+
return err
110+
}
111+
112+
fmt.Fprintf(w, "Installed: %s\n", target)
113+
return nil
114+
}
115+
116+
// requireGh checks that the gh CLI is installed and authenticated.
117+
func requireGh() error {
118+
if _, err := exec.LookPath("gh"); err != nil {
119+
return fmt.Errorf("gh CLI is required; install from https://cli.github.com/")
120+
}
121+
if err := exec.Command("gh", "auth", "status").Run(); err != nil {
122+
return fmt.Errorf("gh is not authenticated; run: gh auth login")
123+
}
124+
return nil
125+
}
126+
127+
func detectPlatform() (string, string, error) {
128+
var osName string
129+
switch runtime.GOOS {
130+
case "darwin":
131+
osName = "darwin"
132+
case "linux":
133+
osName = "linux"
134+
case "windows":
135+
osName = "windows"
136+
default:
137+
return "", "", fmt.Errorf("unsupported OS: %s", runtime.GOOS)
138+
}
139+
140+
var archName string
141+
switch runtime.GOARCH {
142+
case "amd64":
143+
archName = "amd64"
144+
case "arm64":
145+
archName = "arm64"
146+
default:
147+
return "", "", fmt.Errorf("unsupported architecture: %s", runtime.GOARCH)
148+
}
149+
150+
return osName, archName, nil
151+
}
152+
153+
func ghDownload(tag, pattern, dir string) error {
154+
out, err := exec.Command("gh", "release", "download", tag,
155+
"--repo", upgradeRepo,
156+
"--pattern", pattern,
157+
"--dir", dir,
158+
"--clobber",
159+
).CombinedOutput()
160+
if err != nil {
161+
return fmt.Errorf("%s: %s", err, strings.TrimSpace(string(out)))
162+
}
163+
return nil
164+
}
165+
166+
func verifyChecksum(assetPath, checksumsPath, assetName string) error {
167+
data, err := os.ReadFile(checksumsPath)
168+
if err != nil {
169+
return fmt.Errorf("read checksums file: %w", err)
170+
}
171+
172+
var expected string
173+
for _, line := range strings.Split(string(data), "\n") {
174+
fields := strings.Fields(line)
175+
if len(fields) == 2 && fields[1] == assetName {
176+
expected = fields[0]
177+
break
178+
}
179+
}
180+
if expected == "" {
181+
return fmt.Errorf("no checksum entry found for %s", assetName)
182+
}
183+
184+
f, err := os.Open(assetPath)
185+
if err != nil {
186+
return fmt.Errorf("open asset for checksum: %w", err)
187+
}
188+
defer f.Close()
189+
190+
h := sha256.New()
191+
if _, err := io.Copy(h, f); err != nil {
192+
return fmt.Errorf("hash asset: %w", err)
193+
}
194+
actual := hex.EncodeToString(h.Sum(nil))
195+
196+
if actual != expected {
197+
return fmt.Errorf("checksum mismatch for %s:\n expected: %s\n actual: %s", assetName, expected, actual)
198+
}
199+
return nil
200+
}
201+
202+
func installBinary(src, dst string) error {
203+
// Read the downloaded binary into memory so we can write it even if the
204+
// target is the currently running executable (write-to-temp + rename).
205+
data, err := os.ReadFile(src)
206+
if err != nil {
207+
return fmt.Errorf("read downloaded binary: %w", err)
208+
}
209+
210+
dir := filepath.Dir(dst)
211+
212+
// Try writing directly first.
213+
tmp, err := os.CreateTemp(dir, ".wherobots-upgrade-*")
214+
if err != nil {
215+
// Might lack write permission; try with sudo via install(1).
216+
return installWithSudo(src, dst)
217+
}
218+
tmpPath := tmp.Name()
219+
220+
if _, writeErr := tmp.Write(data); writeErr != nil {
221+
tmp.Close()
222+
os.Remove(tmpPath)
223+
return fmt.Errorf("write binary: %w", writeErr)
224+
}
225+
if err := tmp.Chmod(0755); err != nil {
226+
tmp.Close()
227+
os.Remove(tmpPath)
228+
return fmt.Errorf("chmod binary: %w", err)
229+
}
230+
tmp.Close()
231+
232+
if err := os.Rename(tmpPath, dst); err != nil {
233+
os.Remove(tmpPath)
234+
return installWithSudo(src, dst)
235+
}
236+
return nil
237+
}
238+
239+
func installWithSudo(src, dst string) error {
240+
if _, err := exec.LookPath("sudo"); err != nil {
241+
return fmt.Errorf("no write access to %s and sudo is unavailable", filepath.Dir(dst))
242+
}
243+
dir := filepath.Dir(dst)
244+
if err := exec.Command("sudo", "mkdir", "-p", dir).Run(); err != nil {
245+
return fmt.Errorf("sudo mkdir: %w", err)
246+
}
247+
if err := exec.Command("sudo", "install", "-m", "0755", src, dst).Run(); err != nil {
248+
return fmt.Errorf("sudo install: %w", err)
249+
}
250+
return nil
251+
}

internal/commands/upgrade_test.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package commands
2+
3+
import (
4+
"crypto/sha256"
5+
"encoding/hex"
6+
"os"
7+
"path/filepath"
8+
"runtime"
9+
"testing"
10+
)
11+
12+
func TestDetectPlatform(t *testing.T) {
13+
osName, archName, err := detectPlatform()
14+
if err != nil {
15+
t.Fatalf("detectPlatform() error: %v", err)
16+
}
17+
18+
if osName != runtime.GOOS {
19+
t.Errorf("osName = %q, want %q", osName, runtime.GOOS)
20+
}
21+
if archName != runtime.GOARCH {
22+
t.Errorf("archName = %q, want %q", archName, runtime.GOARCH)
23+
}
24+
}
25+
26+
func TestVerifyChecksum_Valid(t *testing.T) {
27+
dir := t.TempDir()
28+
29+
content := []byte("hello wherobots")
30+
assetPath := filepath.Join(dir, "wherobots_darwin_arm64")
31+
if err := os.WriteFile(assetPath, content, 0644); err != nil {
32+
t.Fatal(err)
33+
}
34+
35+
h := sha256.Sum256(content)
36+
checksum := hex.EncodeToString(h[:])
37+
38+
checksumFile := filepath.Join(dir, "checksums.txt")
39+
checksumContent := checksum + " wherobots_darwin_arm64\n" +
40+
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa wherobots_linux_amd64\n"
41+
if err := os.WriteFile(checksumFile, []byte(checksumContent), 0644); err != nil {
42+
t.Fatal(err)
43+
}
44+
45+
if err := verifyChecksum(assetPath, checksumFile, "wherobots_darwin_arm64"); err != nil {
46+
t.Fatalf("verifyChecksum should pass with correct hash, got: %v", err)
47+
}
48+
}
49+
50+
func TestVerifyChecksum_Mismatch(t *testing.T) {
51+
dir := t.TempDir()
52+
53+
assetPath := filepath.Join(dir, "wherobots_darwin_arm64")
54+
if err := os.WriteFile(assetPath, []byte("real content"), 0644); err != nil {
55+
t.Fatal(err)
56+
}
57+
58+
checksumFile := filepath.Join(dir, "checksums.txt")
59+
checksumContent := "0000000000000000000000000000000000000000000000000000000000000000 wherobots_darwin_arm64\n"
60+
if err := os.WriteFile(checksumFile, []byte(checksumContent), 0644); err != nil {
61+
t.Fatal(err)
62+
}
63+
64+
err := verifyChecksum(assetPath, checksumFile, "wherobots_darwin_arm64")
65+
if err == nil {
66+
t.Fatal("verifyChecksum should fail on mismatch")
67+
}
68+
}
69+
70+
func TestVerifyChecksum_MissingEntry(t *testing.T) {
71+
dir := t.TempDir()
72+
73+
assetPath := filepath.Join(dir, "wherobots_darwin_arm64")
74+
if err := os.WriteFile(assetPath, []byte("content"), 0644); err != nil {
75+
t.Fatal(err)
76+
}
77+
78+
checksumFile := filepath.Join(dir, "checksums.txt")
79+
if err := os.WriteFile(checksumFile, []byte("abc123 wherobots_linux_amd64\n"), 0644); err != nil {
80+
t.Fatal(err)
81+
}
82+
83+
err := verifyChecksum(assetPath, checksumFile, "wherobots_darwin_arm64")
84+
if err == nil {
85+
t.Fatal("verifyChecksum should fail when asset not in checksums file")
86+
}
87+
}
88+
89+
func TestInstallBinary(t *testing.T) {
90+
dir := t.TempDir()
91+
92+
src := filepath.Join(dir, "src-binary")
93+
if err := os.WriteFile(src, []byte("#!/bin/sh\necho hi"), 0644); err != nil {
94+
t.Fatal(err)
95+
}
96+
97+
dst := filepath.Join(dir, "wherobots")
98+
if err := installBinary(src, dst); err != nil {
99+
t.Fatalf("installBinary() error: %v", err)
100+
}
101+
102+
info, err := os.Stat(dst)
103+
if err != nil {
104+
t.Fatalf("installed binary not found: %v", err)
105+
}
106+
if info.Mode().Perm()&0111 == 0 {
107+
t.Error("installed binary should be executable")
108+
}
109+
110+
got, err := os.ReadFile(dst)
111+
if err != nil {
112+
t.Fatal(err)
113+
}
114+
if string(got) != "#!/bin/sh\necho hi" {
115+
t.Errorf("binary content mismatch: %s", got)
116+
}
117+
}

0 commit comments

Comments
 (0)