mirror of
https://github.com/lukaszraczylo/claude-mnemonic.git
synced 2026-06-05 23:03:55 +00:00
5c2685c7b6
* feat(leann-phase2): implement hybrid vector storage and graph-based search
- [x] Add AST-aware code chunking for Go, Python, and TypeScript using tree-sitter
- [x] Implement LEANN-inspired hybrid vector storage with hub detection and selective embedding storage (60-80% savings)
- [x] Add observation relationship graph with CSR format and edge detection (file overlap, semantic similarity, temporal, concept)
- [x] Implement graph-aware search with two-level traversal and relationship-based ranking
- [x] Add auto-tuning system for dynamic hub threshold adjustment based on query performance
- [x] Add comprehensive metrics tracking for vector storage, queries, latency, and graph traversals
- [x] Update configuration system with graph and hybrid storage settings
- [x] Add graph stats and vector metrics endpoints to worker service
- [x] Enhance UI sidebar with advanced metrics display and graph visualization
- [x] Optimize struct field alignment throughout codebase for memory efficiency
- [x] Update documentation with LEANN Phase 2 features and performance benefits
- [x] Add tree-sitter dependency for AST parsing
* fix: add fts5 build tag to CI workflow
Pass build-tags: "fts5" to shared workflow to properly compile
sqlite-vec-go-bindings with SQLite FTS5 support.
This fixes test failures in hybrid vector storage tests that require
CGO and FTS5 build tags.
Requires shared-actions@8f7f235 or later.
* docs: add testing documentation and macOS ARM64 known issue
Document the macOS ARM64 CGO linking issue with sqlite-vec-go-bindings
that prevents hybrid package tests from compiling locally.
Added:
- .github/TESTING.md: Comprehensive testing guide with platform-specific
issues, workarounds, and CI configuration details
- internal/vector/hybrid/README.md: Package-specific documentation
explaining the macOS limitation
- .github/CI_FIX_SUMMARY.md: Technical details of the CI fix
Key points:
- 41 out of 42 packages test successfully on all platforms
- hybrid package tests fail only on macOS ARM64 (local dev issue)
- Linux CI tests pass with proper build-tags: "fts5" configuration
- Production builds and runtime functionality unaffected
This is a known limitation of sqlite-vec-go-bindings on macOS ARM64
and does not impact CI/CD or production deployments.
* fix: add SQLite busy_timeout to prevent database locked errors
Set PRAGMA busy_timeout=5000 (5 seconds) to allow SQLite to retry
when the database is locked instead of failing immediately.
This fixes race conditions when multiple goroutines try to write
simultaneously, particularly in tests where StoreObservation spawns
async cleanup goroutines.
Root cause:
- StoreObservation launches goroutine -> CleanupOldObservations
- Multiple concurrent cleanups caused "database is locked" errors
- Without busy_timeout, SQLite fails immediately on lock contention
Solution:
- Add 5-second busy timeout for automatic retry on lock
- Standard practice for concurrent SQLite usage
- Works with existing WAL mode configuration
Fixes TestObservationStore_CleanupOldObservations in CI.
* docs: complete summary of all CI test fixes
Comprehensive documentation of all fixes applied:
1. Missing build tags (fts5)
2. Database locked errors (busy_timeout)
All 41/42 packages now pass tests. The hybrid package has a known
macOS ARM64 limitation that doesn't affect CI or production.
No functionality was removed - all fixes are additive only.
* fix: add SQLite driver import to hybrid tests for CGO linking
Add blank import of mattn/go-sqlite3 to hybrid test files to ensure
the SQLite driver is linked into the test binary. This provides the
SQLite symbols that sqlite-vec-go-bindings requires.
Root cause:
- hybrid package imports sqlitevec (transitively depends on sqlite-vec CGO)
- Test binary needs SQLite symbols for linking
- sqlitevec tests already had this import, but hybrid tests didn't
- Without the driver import, linker fails with "undefined symbols"
This fix enables hybrid tests to run with -race flag on all platforms.
Before: 41/42 packages pass (hybrid failed to link)
After: 42/42 packages pass ✅
Fixes hybrid test compilation on macOS ARM64, Linux, and Windows.
* docs: remove outdated macOS limitation documentation
The hybrid test linking issue has been fixed by adding the SQLite
driver import. All tests now pass on all platforms including macOS.
Removed:
- internal/vector/hybrid/README.md (documented workaround no longer needed)
- .github/TESTING.md (macOS limitation section obsolete)
All 42/42 packages now test successfully with -race flag.
* docs: final comprehensive summary of all CI fixes
All three issues now resolved:
1. Missing fts5 build tags
2. Database busy_timeout for concurrent writes
3. Missing SQLite driver import in hybrid tests
Result: 42/42 packages pass with -race on all platforms.
Credit to reviewer for identifying the race detector concern.
666 lines
19 KiB
Go
666 lines
19 KiB
Go
// Package update provides self-update functionality for claude-mnemonic.
|
|
package update
|
|
|
|
import (
|
|
"archive/tar"
|
|
"compress/gzip"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
const (
|
|
GitHubRepo = "lukaszraczylo/claude-mnemonic"
|
|
ReleasesAPI = "https://api.github.com/repos/" + GitHubRepo + "/releases/latest"
|
|
CheckInterval = 24 * time.Hour
|
|
MaxExtractedSize = 250 * 1024 * 1024 // 250MB max per extracted file
|
|
RestartDelay = 500 * time.Millisecond
|
|
)
|
|
|
|
// Release represents a GitHub release.
|
|
type Release struct {
|
|
PublishedAt time.Time `json:"published_at"`
|
|
TagName string `json:"tag_name"`
|
|
Name string `json:"name"`
|
|
Body string `json:"body"`
|
|
Assets []Asset `json:"assets"`
|
|
}
|
|
|
|
// Asset represents a release asset.
|
|
type Asset struct {
|
|
Name string `json:"name"`
|
|
BrowserDownloadURL string `json:"browser_download_url"`
|
|
Size int64 `json:"size"`
|
|
}
|
|
|
|
// UpdateInfo contains information about an available update.
|
|
type UpdateInfo struct {
|
|
PublishedAt time.Time `json:"published_at,omitempty"`
|
|
CurrentVersion string `json:"current_version"`
|
|
LatestVersion string `json:"latest_version"`
|
|
ReleaseNotes string `json:"release_notes,omitempty"`
|
|
DownloadURL string `json:"download_url,omitempty"`
|
|
ChecksumsURL string `json:"checksums_url,omitempty"`
|
|
BundleURL string `json:"bundle_url,omitempty"`
|
|
ManualUpdateCommand string `json:"manual_update_command,omitempty"`
|
|
Available bool `json:"available"`
|
|
}
|
|
|
|
// InstallScriptURL is the URL to the remote installation script.
|
|
const InstallScriptURL = "https://raw.githubusercontent.com/" + GitHubRepo + "/main/scripts/install.sh"
|
|
|
|
// GetManualUpdateCommand returns the curl command for manual update.
|
|
// If version is empty, it installs the latest version.
|
|
func GetManualUpdateCommand(version string) string {
|
|
if version == "" {
|
|
return fmt.Sprintf("curl -sSL %s | bash", InstallScriptURL)
|
|
}
|
|
return fmt.Sprintf("curl -sSL %s | bash -s -- %s", InstallScriptURL, version)
|
|
}
|
|
|
|
// UpdateStatus represents the current update status.
|
|
type UpdateStatus struct {
|
|
State string `json:"state"`
|
|
Message string `json:"message"`
|
|
Error string `json:"error,omitempty"`
|
|
ManualUpdateCommand string `json:"manual_update_command,omitempty"`
|
|
Progress float64 `json:"progress"`
|
|
}
|
|
|
|
// Updater handles self-updates.
|
|
type Updater struct {
|
|
lastCheck time.Time
|
|
httpClient *http.Client
|
|
cachedUpdate *UpdateInfo
|
|
currentVersion string
|
|
installDir string
|
|
status UpdateStatus
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// New creates a new Updater.
|
|
func New(currentVersion, installDir string) *Updater {
|
|
return &Updater{
|
|
currentVersion: currentVersion,
|
|
installDir: installDir,
|
|
httpClient: &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
},
|
|
status: UpdateStatus{State: "idle"},
|
|
}
|
|
}
|
|
|
|
// GetStatus returns the current update status.
|
|
func (u *Updater) GetStatus() UpdateStatus {
|
|
u.mu.RLock()
|
|
defer u.mu.RUnlock()
|
|
return u.status
|
|
}
|
|
|
|
func (u *Updater) setStatus(state string, progress float64, message string) {
|
|
u.mu.Lock()
|
|
defer u.mu.Unlock()
|
|
u.status = UpdateStatus{
|
|
State: state,
|
|
Progress: progress,
|
|
Message: message,
|
|
}
|
|
}
|
|
|
|
func (u *Updater) setError(err error) {
|
|
u.mu.Lock()
|
|
defer u.mu.Unlock()
|
|
u.status = UpdateStatus{
|
|
State: "error",
|
|
Message: "Update failed",
|
|
Error: err.Error(),
|
|
ManualUpdateCommand: GetManualUpdateCommand(""), // Always provide fallback command
|
|
}
|
|
}
|
|
|
|
// CheckForUpdate checks if a new version is available.
|
|
func (u *Updater) CheckForUpdate(ctx context.Context) (*UpdateInfo, error) {
|
|
u.setStatus("checking", 0, "Checking for updates...")
|
|
|
|
// Check cache first (within last hour)
|
|
u.mu.RLock()
|
|
if time.Since(u.lastCheck) < time.Hour && u.cachedUpdate != nil {
|
|
cached := u.cachedUpdate
|
|
u.mu.RUnlock()
|
|
u.setStatus("idle", 0, "")
|
|
return cached, nil
|
|
}
|
|
u.mu.RUnlock()
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", ReleasesAPI, nil)
|
|
if err != nil {
|
|
u.setError(err)
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Accept", "application/vnd.github.v3+json")
|
|
req.Header.Set("User-Agent", "claude-mnemonic/"+u.currentVersion)
|
|
|
|
resp, err := u.httpClient.Do(req)
|
|
if err != nil {
|
|
u.setError(err)
|
|
return nil, fmt.Errorf("failed to check for updates: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
err := fmt.Errorf("GitHub API returned status %d", resp.StatusCode)
|
|
u.setError(err)
|
|
return nil, err
|
|
}
|
|
|
|
var release Release
|
|
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
|
|
u.setError(err)
|
|
return nil, fmt.Errorf("failed to parse release info: %w", err)
|
|
}
|
|
|
|
info := &UpdateInfo{
|
|
CurrentVersion: u.currentVersion,
|
|
LatestVersion: strings.TrimPrefix(release.TagName, "v"),
|
|
ReleaseNotes: release.Body,
|
|
PublishedAt: release.PublishedAt,
|
|
}
|
|
|
|
// Compare versions
|
|
info.Available = isNewerVersion(info.LatestVersion, u.currentVersion)
|
|
|
|
// Always include manual update command as an alternative option
|
|
info.ManualUpdateCommand = GetManualUpdateCommand("v" + info.LatestVersion)
|
|
|
|
if info.Available {
|
|
// Find download URLs for current platform
|
|
platform := getPlatform()
|
|
archiveName := fmt.Sprintf("claude-mnemonic_%s_%s.tar.gz", info.LatestVersion, platform)
|
|
|
|
for _, asset := range release.Assets {
|
|
switch {
|
|
case asset.Name == archiveName:
|
|
info.DownloadURL = asset.BrowserDownloadURL
|
|
case asset.Name == "checksums.txt":
|
|
info.ChecksumsURL = asset.BrowserDownloadURL
|
|
case asset.Name == "checksums.txt.sigstore.json":
|
|
info.BundleURL = asset.BrowserDownloadURL
|
|
}
|
|
}
|
|
|
|
if info.DownloadURL == "" {
|
|
log.Warn().Str("platform", platform).Msg("No release asset found for current platform")
|
|
}
|
|
}
|
|
|
|
// Cache the result
|
|
u.mu.Lock()
|
|
u.lastCheck = time.Now()
|
|
u.cachedUpdate = info
|
|
u.mu.Unlock()
|
|
|
|
u.setStatus("idle", 0, "")
|
|
return info, nil
|
|
}
|
|
|
|
// ApplyUpdate downloads and applies the update.
|
|
func (u *Updater) ApplyUpdate(ctx context.Context, info *UpdateInfo) error {
|
|
if !info.Available || info.DownloadURL == "" {
|
|
return fmt.Errorf("no update available or download URL missing")
|
|
}
|
|
|
|
tmpDir, err := os.MkdirTemp("", "claude-mnemonic-update-*")
|
|
if err != nil {
|
|
u.setError(err)
|
|
return fmt.Errorf("failed to create temp directory: %w", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
// Step 1: Download checksums and sigstore bundle
|
|
u.setStatus("downloading", 0.1, "Downloading checksums...")
|
|
|
|
checksumsPath := filepath.Join(tmpDir, "checksums.txt")
|
|
bundlePath := filepath.Join(tmpDir, "checksums.txt.sigstore.json")
|
|
|
|
if info.ChecksumsURL != "" {
|
|
if err := u.downloadFile(ctx, info.ChecksumsURL, checksumsPath); err != nil {
|
|
u.setError(err)
|
|
return fmt.Errorf("failed to download checksums: %w", err)
|
|
}
|
|
}
|
|
|
|
if info.BundleURL != "" {
|
|
if err := u.downloadFile(ctx, info.BundleURL, bundlePath); err != nil {
|
|
u.setError(err)
|
|
return fmt.Errorf("failed to download sigstore bundle: %w", err)
|
|
}
|
|
}
|
|
|
|
// Step 2: Verify sigstore bundle with cosign (if available)
|
|
u.setStatus("verifying", 0.2, "Verifying signature...")
|
|
|
|
if info.ChecksumsURL != "" && info.BundleURL != "" {
|
|
if err := u.verifySigstoreBundle(ctx, checksumsPath, bundlePath); err != nil {
|
|
// Log warning but continue - signature verification is optional if cosign isn't installed
|
|
log.Warn().Err(err).Msg("Signature verification failed or skipped")
|
|
} else {
|
|
log.Info().Msg("Sigstore signature verification passed")
|
|
}
|
|
}
|
|
|
|
// Step 3: Download the archive
|
|
u.setStatus("downloading", 0.3, "Downloading update...")
|
|
|
|
archivePath := filepath.Join(tmpDir, "release.tar.gz")
|
|
if err := u.downloadFile(ctx, info.DownloadURL, archivePath); err != nil {
|
|
u.setError(err)
|
|
return fmt.Errorf("failed to download release: %w", err)
|
|
}
|
|
|
|
// Step 4: Verify checksum
|
|
u.setStatus("verifying", 0.6, "Verifying checksum...")
|
|
|
|
if info.ChecksumsURL != "" {
|
|
if err := u.verifyChecksum(archivePath, checksumsPath, info.LatestVersion); err != nil {
|
|
u.setError(err)
|
|
return fmt.Errorf("checksum verification failed: %w", err)
|
|
}
|
|
log.Info().Msg("Checksum verification passed")
|
|
}
|
|
|
|
// Step 5: Extract archive
|
|
u.setStatus("applying", 0.7, "Extracting files...")
|
|
|
|
extractDir := filepath.Join(tmpDir, "extracted")
|
|
if err := u.extractTarGz(archivePath, extractDir); err != nil {
|
|
u.setError(err)
|
|
return fmt.Errorf("failed to extract archive: %w", err)
|
|
}
|
|
|
|
// Step 6: Replace binaries
|
|
u.setStatus("applying", 0.85, "Installing update...")
|
|
|
|
if err := u.replaceBinaries(extractDir); err != nil {
|
|
u.setError(err)
|
|
return fmt.Errorf("failed to replace binaries: %w", err)
|
|
}
|
|
|
|
// Clear cache so next check shows no update available
|
|
u.mu.Lock()
|
|
u.cachedUpdate = nil
|
|
u.lastCheck = time.Time{}
|
|
u.mu.Unlock()
|
|
|
|
u.setStatus("done", 1.0, fmt.Sprintf("Updated to v%s. Restart required.", info.LatestVersion))
|
|
log.Info().Str("version", info.LatestVersion).Msg("Update applied successfully")
|
|
|
|
return nil
|
|
}
|
|
|
|
func (u *Updater) downloadFile(ctx context.Context, url, destPath string) error {
|
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("User-Agent", "claude-mnemonic/"+u.currentVersion)
|
|
|
|
resp, err := u.httpClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("download failed with status %d", resp.StatusCode)
|
|
}
|
|
|
|
f, err := os.Create(destPath) // #nosec G304 -- destPath is constructed internally from temp directory
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
|
|
_, err = io.Copy(f, resp.Body)
|
|
return err
|
|
}
|
|
|
|
func (u *Updater) verifySigstoreBundle(ctx context.Context, checksumsPath, bundlePath string) error {
|
|
// Check if cosign is available
|
|
if _, err := exec.LookPath("cosign"); err != nil {
|
|
return fmt.Errorf("cosign not installed: %w", err)
|
|
}
|
|
|
|
// Verify sigstore bundle - uses keyless verification with certificate identity
|
|
// The bundle contains the signature, certificate, and transparency log entry
|
|
// Certificate identity matches GitHub Actions workflow for this repo
|
|
cmd := exec.CommandContext(ctx, "cosign", "verify-blob",
|
|
"--bundle", bundlePath,
|
|
"--certificate-identity-regexp", "https://github.com/lukaszraczylo/claude-mnemonic/.*",
|
|
"--certificate-oidc-issuer", "https://token.actions.githubusercontent.com",
|
|
checksumsPath,
|
|
)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("cosign verification failed: %w, output: %s", err, string(output))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (u *Updater) verifyChecksum(archivePath, checksumsPath, version string) error {
|
|
// Read checksums file
|
|
data, err := os.ReadFile(checksumsPath) // #nosec G304 -- checksumsPath is constructed internally from temp directory
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read checksums: %w", err)
|
|
}
|
|
|
|
// Calculate archive checksum
|
|
f, err := os.Open(archivePath) // #nosec G304 -- archivePath is constructed internally from temp directory
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
|
|
h := sha256.New()
|
|
if _, err := io.Copy(h, f); err != nil {
|
|
return err
|
|
}
|
|
actualChecksum := hex.EncodeToString(h.Sum(nil))
|
|
|
|
// Find expected checksum for our archive
|
|
platform := getPlatform()
|
|
expectedName := fmt.Sprintf("claude-mnemonic_%s_%s.tar.gz", version, platform)
|
|
|
|
for _, line := range strings.Split(string(data), "\n") {
|
|
parts := strings.Fields(line)
|
|
if len(parts) >= 2 && strings.HasSuffix(parts[1], expectedName) {
|
|
if parts[0] == actualChecksum {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("checksum mismatch: expected %s, got %s", parts[0], actualChecksum)
|
|
}
|
|
}
|
|
|
|
return fmt.Errorf("no checksum found for %s", expectedName)
|
|
}
|
|
|
|
func (u *Updater) extractTarGz(archivePath, destDir string) error {
|
|
if err := os.MkdirAll(destDir, 0750); err != nil {
|
|
return err
|
|
}
|
|
|
|
f, err := os.Open(archivePath) // #nosec G304 -- archivePath is constructed internally from temp directory
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
|
|
gzr, err := gzip.NewReader(f)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer gzr.Close()
|
|
|
|
tr := tar.NewReader(gzr)
|
|
|
|
for {
|
|
header, err := tr.Next()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// #nosec G305 -- path traversal is prevented by the check below
|
|
target := filepath.Join(destDir, header.Name)
|
|
|
|
// Prevent path traversal
|
|
if !strings.HasPrefix(target, filepath.Clean(destDir)+string(os.PathSeparator)) {
|
|
return fmt.Errorf("invalid tar path: %s", header.Name)
|
|
}
|
|
|
|
switch header.Typeflag {
|
|
case tar.TypeDir:
|
|
if err := os.MkdirAll(target, 0750); err != nil {
|
|
return err
|
|
}
|
|
case tar.TypeReg:
|
|
if err := os.MkdirAll(filepath.Dir(target), 0750); err != nil {
|
|
return err
|
|
}
|
|
// #nosec G304,G115 -- target is validated above; mode from trusted tar header
|
|
outFile, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode)&0755)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Limit extraction size to prevent decompression bombs
|
|
written, err := io.Copy(outFile, io.LimitReader(tr, MaxExtractedSize))
|
|
if err != nil {
|
|
_ = outFile.Close()
|
|
return err
|
|
}
|
|
if written == MaxExtractedSize {
|
|
_ = outFile.Close()
|
|
return fmt.Errorf("file %s exceeds maximum allowed size", header.Name)
|
|
}
|
|
if err := outFile.Close(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (u *Updater) replaceBinaries(extractDir string) error {
|
|
// Binary files to replace
|
|
binaryFiles := []string{
|
|
"worker",
|
|
"mcp-server",
|
|
"hooks/session-start",
|
|
"hooks/user-prompt",
|
|
"hooks/post-tool-use",
|
|
"hooks/stop",
|
|
"hooks/subagent-stop",
|
|
"hooks/statusline",
|
|
}
|
|
|
|
// Get all install directories (marketplaces and cache directories)
|
|
installDirs := u.getInstallDirectories()
|
|
|
|
for _, installDir := range installDirs {
|
|
log.Debug().Str("dir", installDir).Msg("Installing binaries to directory")
|
|
|
|
for _, binaryFile := range binaryFiles {
|
|
src := filepath.Join(extractDir, binaryFile)
|
|
if _, err := os.Stat(src); os.IsNotExist(err) {
|
|
continue // Skip if not in archive
|
|
}
|
|
|
|
dest := filepath.Join(installDir, binaryFile)
|
|
|
|
// Backup existing binary
|
|
if _, err := os.Stat(dest); err == nil {
|
|
backup := dest + ".bak"
|
|
if err := os.Rename(dest, backup); err != nil {
|
|
log.Warn().Err(err).Str("file", dest).Msg("Failed to backup, continuing anyway")
|
|
} else {
|
|
defer func(backup, dest string) {
|
|
// Clean up backup on success, restore on failure
|
|
if _, err := os.Stat(dest); err == nil {
|
|
_ = os.Remove(backup)
|
|
} else {
|
|
_ = os.Rename(backup, dest)
|
|
}
|
|
}(backup, dest)
|
|
}
|
|
}
|
|
|
|
// Copy new binary
|
|
if err := copyFile(src, dest); err != nil {
|
|
return fmt.Errorf("failed to install %s: %w", dest, err)
|
|
}
|
|
|
|
// Make executable
|
|
// #nosec G302 -- executables require 0755 permissions
|
|
if err := os.Chmod(dest, 0755); err != nil {
|
|
return fmt.Errorf("failed to chmod %s: %w", dest, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// getInstallDirectories returns all directories where binaries should be installed.
|
|
// This includes the marketplaces directory and any cache directories.
|
|
func (u *Updater) getInstallDirectories() []string {
|
|
dirs := []string{u.installDir}
|
|
|
|
// Also check cache directories where Claude Code looks for plugins
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return dirs
|
|
}
|
|
|
|
// Look for cache directories under ~/.claude/plugins/cache/claude-mnemonic/claude-mnemonic/
|
|
cacheBase := filepath.Join(home, ".claude/plugins/cache/claude-mnemonic/claude-mnemonic")
|
|
entries, err := os.ReadDir(cacheBase)
|
|
if err != nil {
|
|
return dirs
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
cacheDir := filepath.Join(cacheBase, entry.Name())
|
|
// Only add if it's different from installDir and contains a worker binary
|
|
if cacheDir != u.installDir {
|
|
workerPath := filepath.Join(cacheDir, "worker")
|
|
if _, err := os.Stat(workerPath); err == nil {
|
|
dirs = append(dirs, cacheDir)
|
|
log.Debug().Str("dir", cacheDir).Msg("Found cache directory to update")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return dirs
|
|
}
|
|
|
|
func copyFile(src, dst string) error {
|
|
// Ensure parent directory exists
|
|
if err := os.MkdirAll(filepath.Dir(dst), 0750); err != nil {
|
|
return err
|
|
}
|
|
|
|
in, err := os.Open(src) // #nosec G304 -- src is constructed internally from extracted temp directory
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer in.Close()
|
|
|
|
out, err := os.Create(dst) // #nosec G304 -- dst is constructed internally from install directory
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer out.Close()
|
|
|
|
_, err = io.Copy(out, in)
|
|
return err
|
|
}
|
|
|
|
func getPlatform() string {
|
|
os := runtime.GOOS
|
|
arch := runtime.GOARCH
|
|
|
|
// Map to release naming convention
|
|
return fmt.Sprintf("%s_%s", os, arch)
|
|
}
|
|
|
|
func isNewerVersion(latest, current string) bool {
|
|
// Strip 'v' prefix if present
|
|
latest = strings.TrimPrefix(latest, "v")
|
|
current = strings.TrimPrefix(current, "v")
|
|
|
|
// For dev/dirty builds, extract the base version for comparison
|
|
// e.g., "0.3.5-2-gca711a8-dirty" -> "0.3.5"
|
|
currentBase := current
|
|
if idx := strings.Index(current, "-"); idx > 0 {
|
|
currentBase = current[:idx]
|
|
}
|
|
|
|
// Simple semver comparison using base version
|
|
latestParts := strings.Split(latest, ".")
|
|
currentParts := strings.Split(currentBase, ".")
|
|
|
|
for i := 0; i < len(latestParts) && i < len(currentParts); i++ {
|
|
latestNum, _ := strconv.Atoi(latestParts[i])
|
|
currentNum, _ := strconv.Atoi(currentParts[i])
|
|
|
|
if latestNum > currentNum {
|
|
return true
|
|
}
|
|
if latestNum < currentNum {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return len(latestParts) > len(currentParts)
|
|
}
|
|
|
|
// Restart spawns the new worker binary and exits the current process.
|
|
// This should be called after a successful update to apply the new version.
|
|
func (u *Updater) Restart() error {
|
|
workerPath := filepath.Join(u.installDir, "worker")
|
|
|
|
// Verify the new binary exists
|
|
if _, err := os.Stat(workerPath); err != nil {
|
|
return fmt.Errorf("new worker binary not found: %w", err)
|
|
}
|
|
|
|
log.Info().Str("path", workerPath).Msg("Restarting worker with new binary")
|
|
|
|
// Use nohup to start a detached process that survives parent exit
|
|
// The new worker will retry binding to the port after the old process exits
|
|
cmd := exec.Command("nohup", workerPath) // #nosec G204 -- workerPath is from internal installDir
|
|
cmd.Stdout = nil // Detach stdout
|
|
cmd.Stderr = nil // Detach stderr
|
|
cmd.Stdin = nil // Detach stdin
|
|
cmd.Env = append(os.Environ(), "CLAUDE_MNEMONIC_RESTART=1")
|
|
|
|
// Start in background - don't wait
|
|
if err := cmd.Start(); err != nil {
|
|
return fmt.Errorf("failed to start new worker: %w", err)
|
|
}
|
|
|
|
// Release the child process so it's not a zombie
|
|
go func() {
|
|
_ = cmd.Wait()
|
|
}()
|
|
|
|
log.Info().Int("new_pid", cmd.Process.Pid).Msg("New worker started, exiting old process")
|
|
|
|
// Give a moment for the log to flush
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
// Exit current process - the new one will bind to the port
|
|
os.Exit(0)
|
|
|
|
return nil // Never reached
|
|
}
|