Skip to content

Commit f2ee6eb

Browse files
committed
feat: install to ~/.local/bin (no sudo), auto-add PATH, add 'codeany update' self-update
install.sh: - Install dir changed to ~/.local/bin (user-owned, no sudo needed) - Auto-detects shell (zsh/bash/fish/sh) and config file - Writes PATH export to shell config if not already present - Prompts 'source ~/.zshrc' after install - Supports CODEANY_INSTALL_DIR env override - All log output to stderr (stdout only has file paths) cmd/update.go: - 'codeany update' subcommand - Fetches latest release from GitHub API - Auto-detects current platform (GOOS/GOARCH) - Downloads correct binary asset - Atomic self-replace (rename old, move new, remove old) - Works without sudo when installed in user dir - Shows current vs latest version, skips if already up to date Platform compatibility: macOS (arm64/amd64), Linux (amd64/arm64/arm), Windows
1 parent c9799f1 commit f2ee6eb

File tree

2 files changed

+254
-47
lines changed

2 files changed

+254
-47
lines changed

cmd/update.go

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
"os"
9+
"path/filepath"
10+
"runtime"
11+
"strings"
12+
13+
"github.com/spf13/cobra"
14+
)
15+
16+
const githubRepo = "thinkany-ai/codeany"
17+
18+
var updateCmd = &cobra.Command{
19+
Use: "update",
20+
Short: "Update codeany to the latest version",
21+
Long: "Check GitHub Releases for a newer version and self-update if available.",
22+
RunE: runUpdate,
23+
}
24+
25+
func init() {
26+
rootCmd.AddCommand(updateCmd)
27+
}
28+
29+
type ghRelease struct {
30+
TagName string `json:"tag_name"`
31+
Assets []struct {
32+
Name string `json:"name"`
33+
BrowserDownloadURL string `json:"browser_download_url"`
34+
} `json:"assets"`
35+
}
36+
37+
func runUpdate(cmd *cobra.Command, args []string) error {
38+
current := appVersion
39+
fmt.Printf("Current version : %s\n", current)
40+
fmt.Printf("Checking latest release from GitHub...\n")
41+
42+
rel, err := fetchLatestRelease()
43+
if err != nil {
44+
return fmt.Errorf("checking for updates: %w", err)
45+
}
46+
47+
latest := rel.TagName
48+
fmt.Printf("Latest version : %s\n", latest)
49+
50+
if latest == current || latest == "v"+strings.TrimPrefix(current, "v") {
51+
fmt.Println("✓ Already up to date.")
52+
return nil
53+
}
54+
55+
// Find asset for current platform
56+
platform := fmt.Sprintf("%s_%s", runtime.GOOS, runtime.GOARCH)
57+
ext := ""
58+
if runtime.GOOS == "windows" {
59+
ext = ".exe"
60+
}
61+
assetName := fmt.Sprintf("codeany_%s%s", platform, ext)
62+
63+
var downloadURL string
64+
for _, a := range rel.Assets {
65+
if a.Name == assetName {
66+
downloadURL = a.BrowserDownloadURL
67+
break
68+
}
69+
}
70+
if downloadURL == "" {
71+
return fmt.Errorf("no binary found for platform %s in release %s", platform, latest)
72+
}
73+
74+
fmt.Printf("Downloading %s...\n", latest)
75+
76+
// Download to temp file
77+
tmpFile, err := os.CreateTemp("", "codeany-update-*")
78+
if err != nil {
79+
return fmt.Errorf("creating temp file: %w", err)
80+
}
81+
tmpPath := tmpFile.Name()
82+
defer os.Remove(tmpPath)
83+
84+
resp, err := http.Get(downloadURL) //nolint:gosec
85+
if err != nil {
86+
return fmt.Errorf("downloading: %w", err)
87+
}
88+
defer resp.Body.Close()
89+
90+
if resp.StatusCode != http.StatusOK {
91+
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
92+
}
93+
94+
if _, err := io.Copy(tmpFile, resp.Body); err != nil {
95+
return fmt.Errorf("saving download: %w", err)
96+
}
97+
tmpFile.Close()
98+
99+
if err := os.Chmod(tmpPath, 0755); err != nil {
100+
return fmt.Errorf("chmod: %w", err)
101+
}
102+
103+
// Get path of current executable
104+
exePath, err := os.Executable()
105+
if err != nil {
106+
return fmt.Errorf("getting executable path: %w", err)
107+
}
108+
exePath, err = filepath.EvalSymlinks(exePath)
109+
if err != nil {
110+
return fmt.Errorf("resolving symlinks: %w", err)
111+
}
112+
113+
// Check if writable (user bin = no sudo needed)
114+
if err := checkWritable(exePath); err != nil {
115+
return fmt.Errorf("cannot update %s (permission denied). Try: sudo codeany update", exePath)
116+
}
117+
118+
// Replace binary atomically: rename old, move new, remove old
119+
oldPath := exePath + ".old"
120+
if err := os.Rename(exePath, oldPath); err != nil {
121+
return fmt.Errorf("backing up old binary: %w", err)
122+
}
123+
if err := os.Rename(tmpPath, exePath); err != nil {
124+
// Restore old binary on failure
125+
_ = os.Rename(oldPath, exePath)
126+
return fmt.Errorf("replacing binary: %w", err)
127+
}
128+
os.Remove(oldPath)
129+
130+
fmt.Printf("✓ Updated to %s (%s)\n", latest, exePath)
131+
return nil
132+
}
133+
134+
func fetchLatestRelease() (*ghRelease, error) {
135+
url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", githubRepo)
136+
resp, err := http.Get(url) //nolint:gosec
137+
if err != nil {
138+
return nil, err
139+
}
140+
defer resp.Body.Close()
141+
142+
if resp.StatusCode != http.StatusOK {
143+
return nil, fmt.Errorf("GitHub API returned HTTP %d", resp.StatusCode)
144+
}
145+
146+
var rel ghRelease
147+
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
148+
return nil, err
149+
}
150+
return &rel, nil
151+
}
152+
153+
func checkWritable(path string) error {
154+
f, err := os.OpenFile(path, os.O_WRONLY, 0)
155+
if err != nil {
156+
return err
157+
}
158+
f.Close()
159+
return nil
160+
}

install.sh

Lines changed: 94 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ set -e
33

44
REPO="thinkany-ai/codeany"
55
BINARY="codeany"
6-
INSTALL_DIR="/usr/local/bin"
6+
# Install dir: always user-owned, no sudo needed
7+
INSTALL_DIR="${CODEANY_INSTALL_DIR:-$HOME/.local/bin}"
78

8-
# Colors (output to stderr so they don't pollute stdout captures)
99
RED='\033[0;31m'
1010
GREEN='\033[0;32m'
1111
YELLOW='\033[1;33m'
@@ -19,8 +19,8 @@ error() { printf "${RED}[codeany]${NC} ERROR: %s\n" "$1" >&2; exit 1; }
1919

2020
# Detect OS and arch
2121
detect_platform() {
22-
OS="$(uname -s)"
23-
ARCH="$(uname -m)"
22+
OS="$(uname -s 2>/dev/null || echo unknown)"
23+
ARCH="$(uname -m 2>/dev/null || echo unknown)"
2424

2525
case "$OS" in
2626
Linux) OS="linux" ;;
@@ -30,81 +30,118 @@ detect_platform() {
3030
esac
3131

3232
case "$ARCH" in
33-
x86_64|amd64) ARCH="amd64" ;;
34-
arm64|aarch64) ARCH="arm64" ;;
35-
armv7l) ARCH="arm" ;;
33+
x86_64|amd64) ARCH="amd64" ;;
34+
arm64|aarch64) ARCH="arm64" ;;
35+
armv7l|armv6l) ARCH="arm" ;;
3636
*) error "Unsupported architecture: $ARCH" ;;
3737
esac
3838

3939
PLATFORM="${OS}_${ARCH}"
4040
}
4141

42-
# Get latest release version from GitHub
42+
# Get latest release version
4343
get_latest_version() {
4444
if command -v curl >/dev/null 2>&1; then
45-
VERSION=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | sed -E 's/.*"tag_name": *"([^"]+)".*/\1/')
45+
VERSION=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \
46+
| grep '"tag_name"' | sed -E 's/.*"tag_name": *"([^"]+)".*/\1/')
4647
elif command -v wget >/dev/null 2>&1; then
47-
VERSION=$(wget -qO- "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | sed -E 's/.*"tag_name": *"([^"]+)".*/\1/')
48+
VERSION=$(wget -qO- "https://api.github.com/repos/${REPO}/releases/latest" \
49+
| grep '"tag_name"' | sed -E 's/.*"tag_name": *"([^"]+)".*/\1/')
4850
else
4951
error "curl or wget is required"
5052
fi
5153

52-
if [ -z "$VERSION" ]; then
53-
error "Could not determine latest version. Check https://github.com/${REPO}/releases"
54-
fi
54+
[ -n "$VERSION" ] || error "Could not get latest version from GitHub. Check: https://github.com/${REPO}/releases"
5555
}
5656

57-
# Download binary to a temp file, print path to stdout
57+
# Download binary, return path via stdout
5858
download_binary() {
59-
FILENAME="${BINARY}_${PLATFORM}"
60-
if [ "$OS" = "windows" ]; then
61-
FILENAME="${FILENAME}.exe"
62-
fi
63-
59+
EXT=""
60+
[ "$OS" = "windows" ] && EXT=".exe"
61+
FILENAME="${BINARY}_${PLATFORM}${EXT}"
6462
URL="https://github.com/${REPO}/releases/download/${VERSION}/${FILENAME}"
63+
6564
TMP_DIR="$(mktemp -d)"
66-
TMP_FILE="${TMP_DIR}/${BINARY}"
65+
TMP_FILE="${TMP_DIR}/${BINARY}${EXT}"
6766

6867
info "Downloading ${BINARY} ${VERSION} for ${PLATFORM}..."
69-
info "URL: ${URL}"
7068

7169
if command -v curl >/dev/null 2>&1; then
72-
curl -fsSL "$URL" -o "$TMP_FILE" || error "Download failed. Is v${VERSION} released? Check: https://github.com/${REPO}/releases"
70+
curl -fsSL "$URL" -o "$TMP_FILE" \
71+
|| error "Download failed. Check releases: https://github.com/${REPO}/releases"
7372
else
74-
wget -qO "$TMP_FILE" "$URL" || error "Download failed. Check: https://github.com/${REPO}/releases"
73+
wget -qO "$TMP_FILE" "$URL" \
74+
|| error "Download failed. Check releases: https://github.com/${REPO}/releases"
7575
fi
7676

7777
chmod +x "$TMP_FILE"
78-
# Only print the path to stdout — all other output above goes to stderr
79-
printf "%s" "$TMP_FILE"
78+
printf "%s" "$TMP_FILE" # Only path goes to stdout
8079
}
8180

82-
# Install binary
81+
# Install binary to user dir (no sudo)
8382
install_binary() {
8483
TMP_FILE="$1"
84+
mkdir -p "$INSTALL_DIR"
85+
mv "$TMP_FILE" "${INSTALL_DIR}/${BINARY}"
86+
success "Installed to ${INSTALL_DIR}/${BINARY}"
87+
}
8588

86-
if [ -w "$INSTALL_DIR" ]; then
87-
mv "$TMP_FILE" "${INSTALL_DIR}/${BINARY}"
88-
success "Installed to ${INSTALL_DIR}/${BINARY}"
89-
elif command -v sudo >/dev/null 2>&1; then
90-
sudo mv "$TMP_FILE" "${INSTALL_DIR}/${BINARY}"
91-
success "Installed to ${INSTALL_DIR}/${BINARY} (with sudo)"
92-
else
93-
LOCAL_BIN="$HOME/.local/bin"
94-
mkdir -p "$LOCAL_BIN"
95-
mv "$TMP_FILE" "${LOCAL_BIN}/${BINARY}"
96-
success "Installed to ${LOCAL_BIN}/${BINARY}"
97-
warn "Add to PATH: export PATH=\"\$HOME/.local/bin:\$PATH\""
89+
# Detect current shell config file
90+
detect_shell_config() {
91+
SHELL_NAME="$(basename "${SHELL:-sh}")"
92+
case "$SHELL_NAME" in
93+
zsh) echo "$HOME/.zshrc" ;;
94+
bash)
95+
# macOS bash uses ~/.bash_profile, Linux uses ~/.bashrc
96+
if [ "$(uname -s)" = "Darwin" ]; then
97+
echo "$HOME/.bash_profile"
98+
else
99+
echo "$HOME/.bashrc"
100+
fi
101+
;;
102+
fish) echo "$HOME/.config/fish/config.fish" ;;
103+
*) echo "$HOME/.profile" ;;
104+
esac
105+
}
106+
107+
# Add INSTALL_DIR to PATH in shell config if not already there
108+
add_to_path() {
109+
# Check if already in PATH
110+
case ":$PATH:" in
111+
*":${INSTALL_DIR}:"*)
112+
return 0 # Already in PATH
113+
;;
114+
esac
115+
116+
SHELL_CONFIG="$(detect_shell_config)"
117+
PATH_SNIPPET="export PATH=\"${INSTALL_DIR}:\$PATH\""
118+
119+
# fish uses different syntax
120+
if [ "$(basename "${SHELL:-sh}")" = "fish" ]; then
121+
PATH_SNIPPET="fish_add_path ${INSTALL_DIR}"
98122
fi
123+
124+
# Check if already in config file
125+
if [ -f "$SHELL_CONFIG" ] && grep -qF "$INSTALL_DIR" "$SHELL_CONFIG" 2>/dev/null; then
126+
return 0
127+
fi
128+
129+
# Append to config file
130+
{
131+
printf "\n# Added by CodeAny installer\n"
132+
printf "%s\n" "$PATH_SNIPPET"
133+
} >> "$SHELL_CONFIG"
134+
135+
success "Added ${INSTALL_DIR} to PATH in ${SHELL_CONFIG}"
136+
warn "Run: source ${SHELL_CONFIG} (or open a new terminal)"
137+
NEED_SOURCE="$SHELL_CONFIG"
99138
}
100139

101140
# Verify installation
102141
verify_install() {
103-
if command -v "$BINARY" >/dev/null 2>&1; then
104-
INSTALLED_VERSION=$("$BINARY" --version 2>/dev/null || echo "unknown")
105-
success "${BINARY} installed! (${INSTALLED_VERSION})"
106-
else
107-
warn "${BINARY} installed but not in PATH. Restart your shell or run: hash -r"
142+
if PATH="${INSTALL_DIR}:${PATH}" command -v "$BINARY" >/dev/null 2>&1; then
143+
INSTALLED_VERSION=$(PATH="${INSTALL_DIR}:${PATH}" "$BINARY" --version 2>/dev/null || echo "installed")
144+
success "${BINARY} ${INSTALLED_VERSION} ready"
108145
fi
109146
}
110147

@@ -115,22 +152,32 @@ main() {
115152

116153
detect_platform
117154
info "Platform: ${PLATFORM}"
155+
info "Install dir: ${INSTALL_DIR}"
118156

119157
get_latest_version
120158
info "Latest version: ${VERSION}"
121159

122160
TMP_FILE=$(download_binary)
123161
install_binary "$TMP_FILE"
162+
add_to_path
124163
verify_install
125164

126165
printf "\n" >&2
127-
success "🎉 CodeAny is ready!"
166+
success "🎉 CodeAny ${VERSION} installed!"
128167
printf "\n" >&2
168+
169+
if [ -n "$NEED_SOURCE" ]; then
170+
printf " Activate PATH for this session:\n" >&2
171+
printf " ${YELLOW}source ${NEED_SOURCE}${NC}\n" >&2
172+
printf "\n" >&2
173+
fi
174+
129175
printf " Set your API key and start:\n" >&2
130-
printf " ${YELLOW}export ANTHROPIC_API_KEY=\"sk-ant-...\"${NC}\n" >&2
131-
printf " ${GREEN}codeany${NC}\n" >&2
176+
printf " ${YELLOW}export ANTHROPIC_API_KEY=\"sk-ant-...\"${NC}\n" >&2
177+
printf " ${GREEN}codeany${NC}\n" >&2
132178
printf "\n" >&2
133-
printf " Docs: https://github.com/${REPO}\n" >&2
179+
printf " Self-update anytime:\n" >&2
180+
printf " ${GREEN}codeany update${NC}\n" >&2
134181
printf "\n" >&2
135182
}
136183

0 commit comments

Comments
 (0)