Skip to content

Commit 87926d3

Browse files
toanbkuclaude
andcommitted
feat: add upgrade command and background update notice
- `lpagent upgrade` checks GitHub for latest release and auto-runs install script - Background update check (once per 24h) prints notice after command output - Install script now uses ~/.local/bin (no sudo required) - Disable with LPAGENT_NO_UPDATE_CHECK=1 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent fe24a2b commit 87926d3

4 files changed

Lines changed: 327 additions & 16 deletions

File tree

install.sh

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,15 @@ set -euo pipefail
33

44
REPO="lpagent/cli"
55
BINARY="lpagent"
6-
INSTALL_DIR="/usr/local/bin"
6+
INSTALL_DIR="${HOME}/.local/bin"
7+
8+
# Colors
9+
GREEN='\033[32m'
10+
YELLOW='\033[33m'
11+
CYAN='\033[36m'
12+
RESET='\033[0m'
13+
14+
echo ""
715

816
# Detect OS and architecture
917
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
@@ -32,22 +40,38 @@ VERSION="${LATEST#v}"
3240
ARCHIVE="cli_${VERSION}_${OS}_${ARCH}.tar.gz"
3341
URL="https://github.com/${REPO}/releases/download/${LATEST}/${ARCHIVE}"
3442

35-
echo "Installing ${BINARY} ${LATEST} (${OS}/${ARCH})..."
43+
echo -e " → Downloading ${BINARY} ${CYAN}${LATEST}${RESET} for ${OS}_${ARCH}..."
3644

3745
TMP=$(mktemp -d)
3846
trap 'rm -rf "$TMP"' EXIT
3947

4048
curl -fsSL "$URL" -o "${TMP}/${ARCHIVE}"
49+
50+
echo " → Extracting..."
4151
tar xzf "${TMP}/${ARCHIVE}" -C "$TMP"
4252

43-
if [ -w "$INSTALL_DIR" ]; then
44-
mv "${TMP}/${BINARY}" "${INSTALL_DIR}/${BINARY}"
45-
else
46-
sudo mv "${TMP}/${BINARY}" "${INSTALL_DIR}/${BINARY}"
53+
# Ensure install directory exists
54+
mkdir -p "$INSTALL_DIR"
55+
mv "${TMP}/${BINARY}" "${INSTALL_DIR}/${BINARY}"
56+
chmod +x "${INSTALL_DIR}/${BINARY}"
57+
58+
echo -e " ${GREEN}${RESET} Installed ${BINARY} ${LATEST} to ${INSTALL_DIR}/${BINARY}"
59+
60+
# Check if INSTALL_DIR is in PATH
61+
if ! echo "$PATH" | tr ':' '\n' | grep -qx "$INSTALL_DIR"; then
62+
echo ""
63+
echo -e " ${YELLOW}${RESET} ${INSTALL_DIR} is not in your PATH. Add it with:"
64+
SHELL_NAME=$(basename "$SHELL")
65+
case "$SHELL_NAME" in
66+
zsh) echo " echo 'export PATH=\"\$HOME/.local/bin:\$PATH\"' >> ~/.zshrc && source ~/.zshrc" ;;
67+
bash) echo " echo 'export PATH=\"\$HOME/.local/bin:\$PATH\"' >> ~/.bashrc && source ~/.bashrc" ;;
68+
*) echo " export PATH=\"\$HOME/.local/bin:\$PATH\"" ;;
69+
esac
4770
fi
4871

49-
echo "Installed ${BINARY} ${LATEST} to ${INSTALL_DIR}/${BINARY}"
5072
echo ""
51-
echo "Get started:"
52-
echo " lpagent auth set-key"
53-
echo " lpagent positions open --owner <wallet> -o table"
73+
echo " Next steps:"
74+
echo -e " ${CYAN}lpagent auth set-key${RESET} Set your API key"
75+
echo -e " ${CYAN}lpagent auth set-default-owner <wallet>${RESET} Set default wallet"
76+
echo -e " ${CYAN}lpagent positions open -o table --native${RESET} View open positions"
77+
echo ""

internal/cli/root.go

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,15 @@ transaction generation for Solana DeFi protocols.
2828
2929
Get started:
3030
lpagent auth set-key Set your API key
31-
lpagent positions opening --owner <wallet> View open positions
31+
lpagent positions open --owner <wallet> View open positions
3232
lpagent pools discover Discover pools`,
3333
Version: fmt.Sprintf("%s (commit: %s, built: %s)", version.Version, version.Commit, version.Date),
3434
SilenceUsage: true,
3535
SilenceErrors: true,
3636
SuggestionsMinimumDistance: 2,
3737
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
38-
// Skip auth setup for auth commands and version/help
39-
if isAuthCommand(cmd) || cmd.Name() == "version" || cmd.Name() == "help" {
38+
// Skip auth setup for auth/upgrade commands and version/help
39+
if isSkipAuthCommand(cmd) {
4040
return nil
4141
}
4242

@@ -65,21 +65,36 @@ Get started:
6565
cmd.AddCommand(commands.NewTokenCmd())
6666
cmd.AddCommand(commands.NewTxCmd())
6767
cmd.AddCommand(commands.NewAPICmd())
68+
cmd.AddCommand(commands.NewUpgradeCmd())
6869

6970
return cmd
7071
}
7172

72-
func isAuthCommand(cmd *cobra.Command) bool {
73+
func isSkipAuthCommand(cmd *cobra.Command) bool {
7374
for c := cmd; c != nil; c = c.Parent() {
74-
if c.Name() == "auth" {
75+
switch c.Name() {
76+
case "auth", "upgrade", "version", "help":
7577
return true
7678
}
7779
}
7880
return false
7981
}
8082

8183
func Execute() {
82-
if err := newRootCmd().Execute(); err != nil {
84+
// Start background update check
85+
uc := commands.StartUpdateCheck()
86+
87+
rootCmd := newRootCmd()
88+
err := rootCmd.Execute()
89+
90+
// Print update notice after command output
91+
if uc != nil {
92+
if notice := uc.Notice(); notice != "" {
93+
fmt.Fprint(os.Stderr, notice)
94+
}
95+
}
96+
97+
if err != nil {
8398
fmt.Fprintf(os.Stderr, "Error: %s\n", err)
8499
os.Exit(1)
85100
}

internal/commands/update_notice.go

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
package commands
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"time"
9+
10+
"github.com/lpagent/cli/internal/version"
11+
)
12+
13+
var checkInterval = 24 * time.Hour
14+
15+
// UpdateCheck holds state for a non-blocking background version check.
16+
type UpdateCheck struct {
17+
latest string
18+
done chan struct{}
19+
}
20+
21+
// StartUpdateCheck begins a background version check if the cache is stale.
22+
// Returns nil if the check should be skipped.
23+
func StartUpdateCheck() *UpdateCheck {
24+
if version.Version == "dev" {
25+
return nil
26+
}
27+
if os.Getenv("LPAGENT_NO_UPDATE_CHECK") == "1" {
28+
return nil
29+
}
30+
31+
// Skip for non-interactive sessions
32+
fi, err := os.Stdout.Stat()
33+
if err != nil || (fi.Mode()&os.ModeCharDevice) == 0 {
34+
return nil
35+
}
36+
37+
uc := &UpdateCheck{done: make(chan struct{})}
38+
cached := readUpdateCache()
39+
40+
if cached != nil {
41+
age := time.Since(cached.CheckedAt)
42+
if age >= 0 && age < checkInterval {
43+
uc.latest = cached.LatestVersion
44+
close(uc.done)
45+
return uc
46+
}
47+
}
48+
49+
// Cache stale or missing — fetch in background
50+
go func() {
51+
defer close(uc.done)
52+
latest, err := latestVersionFetcher()
53+
if err != nil || latest == "" {
54+
return
55+
}
56+
uc.latest = latest
57+
writeUpdateCache(latest)
58+
}()
59+
60+
return uc
61+
}
62+
63+
// Notice returns a formatted update notice, or "" if no update is available.
64+
// Never blocks.
65+
func (uc *UpdateCheck) Notice() string {
66+
if uc == nil {
67+
return ""
68+
}
69+
70+
select {
71+
case <-uc.done:
72+
default:
73+
return ""
74+
}
75+
76+
if !isUpdateAvailable(version.Version, uc.latest) {
77+
return ""
78+
}
79+
80+
return fmt.Sprintf(
81+
"\nUpdate available: %s → %s — Run \"lpagent upgrade\" to update\n",
82+
version.Version, uc.latest,
83+
)
84+
}
85+
86+
type updateCache struct {
87+
LatestVersion string `json:"latest_version"`
88+
CheckedAt time.Time `json:"checked_at"`
89+
}
90+
91+
func updateCachePath() string {
92+
home, err := os.UserHomeDir()
93+
if err != nil {
94+
return ""
95+
}
96+
return filepath.Join(home, ".lpagent", ".update-check")
97+
}
98+
99+
func readUpdateCache() *updateCache {
100+
p := updateCachePath()
101+
if p == "" {
102+
return nil
103+
}
104+
data, err := os.ReadFile(p)
105+
if err != nil {
106+
return nil
107+
}
108+
var c updateCache
109+
if err := json.Unmarshal(data, &c); err != nil {
110+
return nil
111+
}
112+
if c.LatestVersion == "" || c.CheckedAt.IsZero() {
113+
return nil
114+
}
115+
return &c
116+
}
117+
118+
func writeUpdateCache(latestVersion string) {
119+
p := updateCachePath()
120+
if p == "" {
121+
return
122+
}
123+
c := updateCache{
124+
LatestVersion: latestVersion,
125+
CheckedAt: time.Now().UTC(),
126+
}
127+
data, err := json.Marshal(c)
128+
if err != nil {
129+
return
130+
}
131+
dir := filepath.Dir(p)
132+
_ = os.MkdirAll(dir, 0700)
133+
_ = os.WriteFile(p, data, 0644)
134+
}

internal/commands/upgrade.go

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package commands
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"os"
10+
"os/exec"
11+
"runtime"
12+
"strings"
13+
"time"
14+
15+
"github.com/spf13/cobra"
16+
17+
"github.com/lpagent/cli/internal/version"
18+
)
19+
20+
const (
21+
githubRepo = "lpagent/cli"
22+
installURL = "https://raw.githubusercontent.com/lpagent/cli/main/install.sh"
23+
)
24+
25+
// Testable function vars
26+
var latestVersionFetcher = fetchLatestVersion
27+
28+
func NewUpgradeCmd() *cobra.Command {
29+
return &cobra.Command{
30+
Use: "upgrade",
31+
Short: "Upgrade to the latest version",
32+
RunE: runUpgrade,
33+
}
34+
}
35+
36+
func runUpgrade(cmd *cobra.Command, args []string) error {
37+
w := cmd.OutOrStdout()
38+
39+
current := version.Version
40+
if current == "dev" {
41+
fmt.Fprintln(w, "Development build — upgrade not applicable. Build from source instead.")
42+
return nil
43+
}
44+
45+
fmt.Fprintf(w, "Current version: %s\n", current)
46+
fmt.Fprint(w, "Checking for updates... ")
47+
48+
latest, err := latestVersionFetcher()
49+
if err != nil {
50+
fmt.Fprintln(w, "failed")
51+
return fmt.Errorf("could not check for updates: %w", err)
52+
}
53+
54+
if !isUpdateAvailable(current, latest) {
55+
fmt.Fprintln(w, "already up to date.")
56+
return nil
57+
}
58+
59+
fmt.Fprintf(w, "update available: %s\n\n", latest)
60+
61+
// On macOS/Linux, run the install script directly
62+
if runtime.GOOS == "darwin" || runtime.GOOS == "linux" {
63+
fmt.Fprintln(w, "Upgrading...")
64+
return runInstallScript(cmd.Context(), w)
65+
}
66+
67+
// Windows or other: print download link
68+
fmt.Fprintf(w, "Download the latest release:\n")
69+
fmt.Fprintf(w, " https://github.com/%s/releases/tag/v%s\n", githubRepo, latest)
70+
return nil
71+
}
72+
73+
func runInstallScript(ctx context.Context, w io.Writer) error {
74+
cmd := exec.CommandContext(ctx, "bash", "-c",
75+
fmt.Sprintf("curl -fsSL %s | bash", installURL))
76+
cmd.Stdout = w
77+
cmd.Stderr = os.Stderr
78+
return cmd.Run()
79+
}
80+
81+
func fetchLatestVersion() (string, error) {
82+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
83+
defer cancel()
84+
85+
url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", githubRepo)
86+
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
87+
if err != nil {
88+
return "", err
89+
}
90+
req.Header.Set("Accept", "application/vnd.github.v3+json")
91+
92+
resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req)
93+
if err != nil {
94+
return "", err
95+
}
96+
defer resp.Body.Close()
97+
98+
if resp.StatusCode != http.StatusOK {
99+
return "", fmt.Errorf("unexpected status: %d", resp.StatusCode)
100+
}
101+
102+
var release struct {
103+
TagName string `json:"tag_name"`
104+
}
105+
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&release); err != nil {
106+
return "", err
107+
}
108+
109+
return strings.TrimPrefix(release.TagName, "v"), nil
110+
}
111+
112+
func isUpdateAvailable(current, latest string) bool {
113+
current = strings.TrimSpace(strings.TrimPrefix(current, "v"))
114+
latest = strings.TrimSpace(strings.TrimPrefix(latest, "v"))
115+
if current == "" || latest == "" || current == "dev" {
116+
return false
117+
}
118+
return latest != current && compareSemver(latest, current) > 0
119+
}
120+
121+
// compareSemver compares two semver strings. Returns >0 if a > b.
122+
func compareSemver(a, b string) int {
123+
partsA := strings.SplitN(a, ".", 3)
124+
partsB := strings.SplitN(b, ".", 3)
125+
for i := 0; i < 3; i++ {
126+
var va, vb int
127+
if i < len(partsA) {
128+
fmt.Sscanf(partsA[i], "%d", &va)
129+
}
130+
if i < len(partsB) {
131+
fmt.Sscanf(partsB[i], "%d", &vb)
132+
}
133+
if va != vb {
134+
return va - vb
135+
}
136+
}
137+
return 0
138+
}

0 commit comments

Comments
 (0)