Skip to content

Commit 4c2334e

Browse files
sgx-labsclaude
andcommitted
Add self-update command (same update)
- Check GitHub releases for latest version - Download correct binary for platform (darwin-arm64, linux-amd64, windows-amd64) - Atomically replace the current binary - --force flag to reinstall even if on latest - Gracefully handle dev builds - Update version check to suggest `same update` instead of curl Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent cb1162c commit 4c2334e

3 files changed

Lines changed: 223 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,24 @@
11
# Changelog
22

3+
## v0.5.2 — Self-Update
4+
5+
One-command updates, no more curl.
6+
7+
### Added
8+
9+
- **`same update`** — Check for and install the latest version from GitHub releases
10+
- Detects platform (darwin-arm64, linux-amd64, windows-amd64)
11+
- Downloads correct binary
12+
- Replaces itself atomically
13+
- `--force` flag to reinstall even if on latest
14+
- Handles dev builds gracefully (warns instead of failing)
15+
16+
### Changed
17+
18+
- Version check now suggests `same update` instead of curl command
19+
20+
---
21+
322
## v0.5.1 — Onboarding & UX Polish
423

524
Better first-run experience and vibe-coder friendly commands.

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
BINARY_NAME := same
22
BUILD_DIR := build
3-
VERSION := 0.5.0
3+
VERSION := 0.5.2
44
LDFLAGS := -ldflags "-s -w -X main.Version=$(VERSION)"
55

66
# CGO is required for sqlite3 + sqlite-vec

cmd/same/main.go

Lines changed: 203 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"os"
1010
"os/exec"
1111
"path/filepath"
12+
"runtime"
1213
"sort"
1314
"strings"
1415
"time"
@@ -65,6 +66,7 @@ Need help? https://discord.gg/GZGHtrrKF2`,
6566

6667
root.AddCommand(initCmd())
6768
root.AddCommand(versionCmd())
69+
root.AddCommand(updateCmd())
6870
root.AddCommand(reindexCmd())
6971
root.AddCommand(statsCmd())
7072
root.AddCommand(migrateCmd())
@@ -155,13 +157,213 @@ func runVersionCheck() error {
155157

156158
if latestVer != currentVer && latestVer > currentVer {
157159
// Output as hook-compatible JSON for SessionStart hook
158-
fmt.Printf(`{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"\n\n**SAME update available:** %s → %s\nRun: same version --check\nInstall: curl -fsSL https://raw.githubusercontent.com/sgx-labs/statelessagent/main/install.sh | bash\n\n"}}`, currentVer, latestVer)
160+
fmt.Printf(`{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"\n\n**SAME update available:** %s → %s\nRun: same update\n\n"}}`, currentVer, latestVer)
159161
fmt.Println()
160162
}
161163

162164
return nil
163165
}
164166

167+
func updateCmd() *cobra.Command {
168+
var force bool
169+
cmd := &cobra.Command{
170+
Use: "update",
171+
Short: "Update SAME to the latest version",
172+
Long: `Check for and install the latest version of SAME from GitHub releases.
173+
174+
This command will:
175+
1. Check the current version against GitHub releases
176+
2. Download the appropriate binary for your platform
177+
3. Replace the current binary with the new version
178+
179+
Example:
180+
same update Check and install if newer version available
181+
same update --force Force reinstall even if already on latest`,
182+
RunE: func(cmd *cobra.Command, args []string) error {
183+
return runUpdate(force)
184+
},
185+
}
186+
cmd.Flags().BoolVar(&force, "force", false, "Force update even if already on latest version")
187+
return cmd
188+
}
189+
190+
func runUpdate(force bool) error {
191+
cli.Header("SAME Update")
192+
fmt.Println()
193+
194+
// Get current version
195+
currentVer := strings.TrimPrefix(Version, "v")
196+
fmt.Printf(" Current version: %s%s%s\n", cli.Bold, Version, cli.Reset)
197+
198+
if Version == "dev" && !force {
199+
fmt.Printf("\n %s⚠%s Running dev build (built from source)\n", cli.Yellow, cli.Reset)
200+
fmt.Println(" Use --force to update anyway, or rebuild from source.")
201+
return nil
202+
}
203+
204+
// Fetch latest release from GitHub
205+
fmt.Printf(" Checking GitHub releases...")
206+
207+
client := &http.Client{Timeout: 10 * time.Second}
208+
resp, err := client.Get("https://api.github.com/repos/sgx-labs/statelessagent/releases/latest")
209+
if err != nil {
210+
fmt.Printf(" %sfailed%s\n", cli.Red, cli.Reset)
211+
return fmt.Errorf("cannot reach GitHub: %w", err)
212+
}
213+
defer resp.Body.Close()
214+
215+
if resp.StatusCode != 200 {
216+
fmt.Printf(" %sfailed%s\n", cli.Red, cli.Reset)
217+
return fmt.Errorf("GitHub API returned %d", resp.StatusCode)
218+
}
219+
220+
body, err := io.ReadAll(resp.Body)
221+
if err != nil {
222+
return fmt.Errorf("read response: %w", err)
223+
}
224+
225+
var release struct {
226+
TagName string `json:"tag_name"`
227+
HTMLURL string `json:"html_url"`
228+
Assets []struct {
229+
Name string `json:"name"`
230+
BrowserDownloadURL string `json:"browser_download_url"`
231+
} `json:"assets"`
232+
}
233+
if err := json.Unmarshal(body, &release); err != nil {
234+
return fmt.Errorf("parse release: %w", err)
235+
}
236+
237+
latestVer := strings.TrimPrefix(release.TagName, "v")
238+
fmt.Printf(" %s✓%s\n", cli.Green, cli.Reset)
239+
fmt.Printf(" Latest version: %s%s%s\n", cli.Bold, release.TagName, cli.Reset)
240+
241+
// Compare versions
242+
if latestVer == currentVer && !force {
243+
fmt.Printf("\n %s✓%s Already on the latest version.\n\n", cli.Green, cli.Reset)
244+
return nil
245+
}
246+
247+
if latestVer <= currentVer && !force {
248+
fmt.Printf("\n %s✓%s Already up to date.\n\n", cli.Green, cli.Reset)
249+
return nil
250+
}
251+
252+
// Determine the asset to download
253+
goos := runtime.GOOS
254+
goarch := runtime.GOARCH
255+
256+
var assetName string
257+
switch {
258+
case goos == "darwin" && goarch == "arm64":
259+
assetName = "same-darwin-arm64"
260+
case goos == "darwin" && goarch == "amd64":
261+
assetName = "same-darwin-amd64"
262+
case goos == "linux" && goarch == "amd64":
263+
assetName = "same-linux-amd64"
264+
case goos == "windows" && goarch == "amd64":
265+
assetName = "same-windows-amd64.exe"
266+
default:
267+
return fmt.Errorf("unsupported platform: %s/%s", goos, goarch)
268+
}
269+
270+
// Find the download URL
271+
var downloadURL string
272+
for _, asset := range release.Assets {
273+
if asset.Name == assetName {
274+
downloadURL = asset.BrowserDownloadURL
275+
break
276+
}
277+
}
278+
279+
if downloadURL == "" {
280+
return fmt.Errorf("no binary found for %s/%s in release %s", goos, goarch, release.TagName)
281+
}
282+
283+
fmt.Printf("\n Downloading %s...", assetName)
284+
285+
// Download to temp file
286+
dlResp, err := client.Get(downloadURL)
287+
if err != nil {
288+
fmt.Printf(" %sfailed%s\n", cli.Red, cli.Reset)
289+
return fmt.Errorf("download: %w", err)
290+
}
291+
defer dlResp.Body.Close()
292+
293+
if dlResp.StatusCode != 200 {
294+
fmt.Printf(" %sfailed%s\n", cli.Red, cli.Reset)
295+
return fmt.Errorf("download returned %d", dlResp.StatusCode)
296+
}
297+
298+
// Get current executable path
299+
execPath, err := os.Executable()
300+
if err != nil {
301+
return fmt.Errorf("cannot determine executable path: %w", err)
302+
}
303+
execPath, err = filepath.EvalSymlinks(execPath)
304+
if err != nil {
305+
return fmt.Errorf("resolve symlinks: %w", err)
306+
}
307+
308+
// Create temp file in same directory (for atomic rename)
309+
tmpFile, err := os.CreateTemp(filepath.Dir(execPath), "same-update-*")
310+
if err != nil {
311+
fmt.Printf(" %sfailed%s\n", cli.Red, cli.Reset)
312+
return fmt.Errorf("create temp file: %w", err)
313+
}
314+
tmpPath := tmpFile.Name()
315+
316+
// Download the file
317+
_, err = io.Copy(tmpFile, dlResp.Body)
318+
tmpFile.Close()
319+
if err != nil {
320+
os.Remove(tmpPath)
321+
fmt.Printf(" %sfailed%s\n", cli.Red, cli.Reset)
322+
return fmt.Errorf("write file: %w", err)
323+
}
324+
325+
// Make executable
326+
if err := os.Chmod(tmpPath, 0755); err != nil {
327+
os.Remove(tmpPath)
328+
fmt.Printf(" %sfailed%s\n", cli.Red, cli.Reset)
329+
return fmt.Errorf("chmod: %w", err)
330+
}
331+
332+
fmt.Printf(" %s✓%s\n", cli.Green, cli.Reset)
333+
334+
// Replace the binary
335+
fmt.Printf(" Installing...")
336+
337+
// On Windows, we need to rename the old binary first
338+
if goos == "windows" {
339+
oldPath := execPath + ".old"
340+
os.Remove(oldPath) // ignore error
341+
if err := os.Rename(execPath, oldPath); err != nil {
342+
os.Remove(tmpPath)
343+
fmt.Printf(" %sfailed%s\n", cli.Red, cli.Reset)
344+
return fmt.Errorf("backup old binary: %w", err)
345+
}
346+
}
347+
348+
// Atomic rename
349+
if err := os.Rename(tmpPath, execPath); err != nil {
350+
os.Remove(tmpPath)
351+
fmt.Printf(" %sfailed%s\n", cli.Red, cli.Reset)
352+
return fmt.Errorf("install: %w", err)
353+
}
354+
355+
fmt.Printf(" %s✓%s\n", cli.Green, cli.Reset)
356+
357+
// Success message
358+
fmt.Println()
359+
fmt.Printf(" %s✓%s Updated to %s%s%s\n", cli.Green, cli.Reset, cli.Bold, release.TagName, cli.Reset)
360+
fmt.Println()
361+
fmt.Printf(" Run %ssame doctor%s to verify.\n", cli.Bold, cli.Reset)
362+
363+
cli.Footer()
364+
return nil
365+
}
366+
165367
func reindexCmd() *cobra.Command {
166368
var force bool
167369
cmd := &cobra.Command{

0 commit comments

Comments
 (0)