|
1 | 1 | package cmd |
2 | 2 |
|
3 | 3 | import ( |
| 4 | + "archive/tar" |
| 5 | + "archive/zip" |
| 6 | + "bufio" |
| 7 | + "compress/gzip" |
4 | 8 | "encoding/json" |
5 | 9 | "fmt" |
| 10 | + "io" |
6 | 11 | "net/http" |
| 12 | + "os" |
| 13 | + "path/filepath" |
7 | 14 | "runtime" |
8 | 15 | "strings" |
9 | 16 | "time" |
@@ -32,6 +39,18 @@ var checkUpdateCmd = &cobra.Command{ |
32 | 39 | RunE: runCheckUpdate, |
33 | 40 | } |
34 | 41 |
|
| 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 | + |
35 | 54 | func runCheckUpdate(cmd *cobra.Command, args []string) error { |
36 | 55 | logInfo("Checking for updates...") |
37 | 56 |
|
@@ -132,3 +151,249 @@ func compareVersions(a, b string) int { |
132 | 151 |
|
133 | 152 | return len(aParts) - len(bParts) |
134 | 153 | } |
| 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