|
9 | 9 | "os" |
10 | 10 | "os/exec" |
11 | 11 | "path/filepath" |
| 12 | + "runtime" |
12 | 13 | "sort" |
13 | 14 | "strings" |
14 | 15 | "time" |
@@ -65,6 +66,7 @@ Need help? https://discord.gg/GZGHtrrKF2`, |
65 | 66 |
|
66 | 67 | root.AddCommand(initCmd()) |
67 | 68 | root.AddCommand(versionCmd()) |
| 69 | + root.AddCommand(updateCmd()) |
68 | 70 | root.AddCommand(reindexCmd()) |
69 | 71 | root.AddCommand(statsCmd()) |
70 | 72 | root.AddCommand(migrateCmd()) |
@@ -155,13 +157,213 @@ func runVersionCheck() error { |
155 | 157 |
|
156 | 158 | if latestVer != currentVer && latestVer > currentVer { |
157 | 159 | // 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) |
159 | 161 | fmt.Println() |
160 | 162 | } |
161 | 163 |
|
162 | 164 | return nil |
163 | 165 | } |
164 | 166 |
|
| 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 | + |
165 | 367 | func reindexCmd() *cobra.Command { |
166 | 368 | var force bool |
167 | 369 | cmd := &cobra.Command{ |
|
0 commit comments