Skip to content

Commit 8ea5bf1

Browse files
FelixIsaacclaude
andcommitted
feat: add auto-update command for one-click binary updates
Add `claude-code-sync update` command that automatically: - Checks GitHub for latest release - Prompts user (with --yes flag for automation) - Downloads binary for current platform - Extracts archive (tar.gz for Unix, zip for Windows) - Backs up old binary - Installs new binary with executable permissions - Cleans up on success or restores backup on failure Supports interactive and non-interactive modes for both manual updates and CI/CD automation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent 5f9bbe9 commit 8ea5bf1

2 files changed

Lines changed: 266 additions & 0 deletions

File tree

internal/cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ func init() {
3838
rootCmd.AddCommand(unlinkCmd)
3939
rootCmd.AddCommand(doctorCmd)
4040
rootCmd.AddCommand(checkUpdateCmd)
41+
rootCmd.AddCommand(updateCmd)
4142
}
4243

4344
// UI helpers

internal/cmd/update.go

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
package cmd
22

33
import (
4+
"archive/tar"
5+
"archive/zip"
6+
"bufio"
7+
"compress/gzip"
48
"encoding/json"
59
"fmt"
10+
"io"
611
"net/http"
12+
"os"
13+
"path/filepath"
714
"runtime"
815
"strings"
916
"time"
@@ -32,6 +39,18 @@ var checkUpdateCmd = &cobra.Command{
3239
RunE: runCheckUpdate,
3340
}
3441

42+
var updateCmd = &cobra.Command{
43+
Use: "update",
44+
Short: "Download and install latest version",
45+
RunE: runUpdate,
46+
}
47+
48+
var updateAutoConfirm bool
49+
50+
func init() {
51+
updateCmd.Flags().BoolVarP(&updateAutoConfirm, "yes", "y", false, "Auto-confirm update without prompting")
52+
}
53+
3554
func runCheckUpdate(cmd *cobra.Command, args []string) error {
3655
logInfo("Checking for updates...")
3756

@@ -132,3 +151,249 @@ func compareVersions(a, b string) int {
132151

133152
return len(aParts) - len(bParts)
134153
}
154+
155+
// runUpdate handles the automatic update flow
156+
func runUpdate(cmd *cobra.Command, args []string) error {
157+
logInfo("Checking for updates...")
158+
159+
// Check for latest release
160+
latest, err := getLatestRelease()
161+
if err != nil {
162+
return fmt.Errorf("failed to check for updates: %w", err)
163+
}
164+
165+
latestVer := strings.TrimPrefix(latest.TagName, "v")
166+
currentVer := version
167+
168+
// Check if update is needed
169+
if compareVersions(latestVer, currentVer) <= 0 {
170+
logSuccess(fmt.Sprintf("Already on latest version (v%s)", currentVer))
171+
return nil
172+
}
173+
174+
// Prompt user unless --yes flag
175+
if !updateAutoConfirm {
176+
fmt.Printf("Update available: v%s → v%s\n", currentVer, latestVer)
177+
fmt.Printf("Update to v%s? [Y/n]: ", latestVer)
178+
179+
reader := bufio.NewReader(os.Stdin)
180+
response, err := reader.ReadString('\n')
181+
if err != nil && err != io.EOF {
182+
return fmt.Errorf("failed to read input: %w", err)
183+
}
184+
185+
response = strings.TrimSpace(strings.ToLower(response))
186+
if response != "" && response != "y" && response != "yes" {
187+
logInfo("Update cancelled")
188+
return nil
189+
}
190+
}
191+
192+
// Get asset info
193+
assetName := getAssetName()
194+
var downloadURL string
195+
for _, asset := range latest.Assets {
196+
if asset.Name == assetName {
197+
downloadURL = asset.BrowserDownloadURL
198+
break
199+
}
200+
}
201+
202+
if downloadURL == "" {
203+
return fmt.Errorf("no binary available for %s/%s", runtime.GOOS, runtime.GOARCH)
204+
}
205+
206+
logInfo(fmt.Sprintf("Downloading %s...", assetName))
207+
tmpFile, err := downloadToTemp(downloadURL)
208+
if err != nil {
209+
return fmt.Errorf("download failed: %w", err)
210+
}
211+
defer os.Remove(tmpFile)
212+
213+
// Extract binary
214+
logInfo("Extracting binary...")
215+
extractedBinary, err := extractBinary(tmpFile)
216+
if err != nil {
217+
return fmt.Errorf("extraction failed: %w", err)
218+
}
219+
defer os.RemoveAll(filepath.Dir(extractedBinary))
220+
221+
// Get current binary path
222+
currentBinary, err := os.Executable()
223+
if err != nil {
224+
return fmt.Errorf("failed to locate current binary: %w", err)
225+
}
226+
227+
// Check permissions
228+
logInfo("Installing update...")
229+
if err := checkWritePermission(currentBinary); err != nil {
230+
return fmt.Errorf("insufficient permissions: %w", err)
231+
}
232+
233+
// Create backup
234+
backup := currentBinary + ".old"
235+
if err := os.Rename(currentBinary, backup); err != nil {
236+
return fmt.Errorf("failed to backup current binary: %w", err)
237+
}
238+
239+
// Move new binary into place
240+
if err := os.Rename(extractedBinary, currentBinary); err != nil {
241+
// Restore backup on failure
242+
os.Rename(backup, currentBinary)
243+
return fmt.Errorf("failed to install update: %w", err)
244+
}
245+
246+
// Ensure executable permissions
247+
os.Chmod(currentBinary, 0755)
248+
249+
// Clean up backup
250+
os.Remove(backup)
251+
252+
logSuccess(fmt.Sprintf("Updated to v%s!", latestVer))
253+
return nil
254+
}
255+
256+
// downloadToTemp downloads a file from URL to a temp file
257+
func downloadToTemp(url string) (string, error) {
258+
resp, err := http.Get(url)
259+
if err != nil {
260+
return "", err
261+
}
262+
defer resp.Body.Close()
263+
264+
if resp.StatusCode != 200 {
265+
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
266+
}
267+
268+
tmpFile, err := os.CreateTemp("", "claude-code-sync-*.tmp")
269+
if err != nil {
270+
return "", err
271+
}
272+
defer tmpFile.Close()
273+
274+
if _, err := io.Copy(tmpFile, resp.Body); err != nil {
275+
os.Remove(tmpFile.Name())
276+
return "", err
277+
}
278+
279+
return tmpFile.Name(), nil
280+
}
281+
282+
// extractBinary extracts the binary from the archive
283+
func extractBinary(archivePath string) (string, error) {
284+
tmpDir, err := os.MkdirTemp("", "update-")
285+
if err != nil {
286+
return "", err
287+
}
288+
289+
var binaryPath string
290+
291+
if strings.HasSuffix(archivePath, ".zip") {
292+
binaryPath, err = extractZip(archivePath, tmpDir)
293+
} else {
294+
binaryPath, err = extractTarGz(archivePath, tmpDir)
295+
}
296+
297+
if err != nil {
298+
os.RemoveAll(tmpDir)
299+
return "", err
300+
}
301+
302+
return binaryPath, nil
303+
}
304+
305+
// extractZip extracts binary from zip archive
306+
func extractZip(zipPath, destDir string) (string, error) {
307+
reader, err := zip.OpenReader(zipPath)
308+
if err != nil {
309+
return "", err
310+
}
311+
defer reader.Close()
312+
313+
for _, file := range reader.File {
314+
if strings.Contains(file.Name, "claude-code-sync") && !strings.Contains(file.Name, "/") {
315+
// Found the binary at root level
316+
src, err := file.Open()
317+
if err != nil {
318+
return "", err
319+
}
320+
defer src.Close()
321+
322+
destPath := filepath.Join(destDir, file.Name)
323+
dest, err := os.Create(destPath)
324+
if err != nil {
325+
return "", err
326+
}
327+
defer dest.Close()
328+
329+
if _, err := io.Copy(dest, src); err != nil {
330+
return "", err
331+
}
332+
333+
return destPath, nil
334+
}
335+
}
336+
337+
return "", fmt.Errorf("binary not found in archive")
338+
}
339+
340+
// extractTarGz extracts binary from tar.gz archive
341+
func extractTarGz(tarPath, destDir string) (string, error) {
342+
file, err := os.Open(tarPath)
343+
if err != nil {
344+
return "", err
345+
}
346+
defer file.Close()
347+
348+
gz, err := gzip.NewReader(file)
349+
if err != nil {
350+
return "", err
351+
}
352+
defer gz.Close()
353+
354+
tr := tar.NewReader(gz)
355+
356+
for {
357+
header, err := tr.Next()
358+
if err == io.EOF {
359+
break
360+
}
361+
if err != nil {
362+
return "", err
363+
}
364+
365+
if strings.Contains(header.Name, "claude-code-sync") && !strings.Contains(header.Name, "/") {
366+
// Found the binary
367+
destPath := filepath.Join(destDir, header.Name)
368+
dest, err := os.Create(destPath)
369+
if err != nil {
370+
return "", err
371+
}
372+
defer dest.Close()
373+
374+
if _, err := io.Copy(dest, tr); err != nil {
375+
return "", err
376+
}
377+
378+
return destPath, nil
379+
}
380+
}
381+
382+
return "", fmt.Errorf("binary not found in archive")
383+
}
384+
385+
// checkWritePermission checks if we can write to the binary location
386+
func checkWritePermission(binaryPath string) error {
387+
parent := filepath.Dir(binaryPath)
388+
tmpFile := filepath.Join(parent, ".write-test")
389+
390+
if err := os.WriteFile(tmpFile, []byte("test"), 0644); err != nil {
391+
if runtime.GOOS != "windows" {
392+
return fmt.Errorf("try: sudo %s update", os.Args[0])
393+
}
394+
return err
395+
}
396+
397+
os.Remove(tmpFile)
398+
return nil
399+
}

0 commit comments

Comments
 (0)