From 85e1dfa7f3b363e81a3c620c716b50d35d754050 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Mon, 15 Dec 2025 00:50:04 +0000 Subject: [PATCH] Release to the world. --- .github/FUNDING.yml | 2 + .github/workflows/release.yaml | 2 + .goreleaser.yaml | 11 +- README.md | 13 + internal/update/update.go | 555 ++++++++++++++++++++++++++++++++ internal/worker/handlers.go | 47 +++ internal/worker/service.go | 14 + internal/worker/static/.gitkeep | 0 ui/src/App.vue | 7 +- ui/src/components/Header.vue | 95 ++++++ ui/src/composables/index.ts | 1 + ui/src/composables/useUpdate.ts | 120 +++++++ ui/tsconfig.tsbuildinfo | 2 +- 13 files changed, 858 insertions(+), 11 deletions(-) create mode 100644 .github/FUNDING.yml create mode 100644 internal/update/update.go delete mode 100644 internal/worker/static/.gitkeep create mode 100644 ui/src/composables/useUpdate.ts diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..9935099 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: [lukaszraczylo] +custom: [monzo.me/lukaszraczylo] diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index b8fe413..87f70ee 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -10,7 +10,9 @@ on: workflow_dispatch: permissions: + id-token: write contents: write + packages: write jobs: release: diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 2a35f95..0f2add2 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -254,17 +254,10 @@ release: signs: - cmd: cosign - env: - - COSIGN_PASSWORD={{ .Env.COSIGN_PASSWORD }} - certificate: "${artifact}.pem" + signature: "${artifact}.sigstore.json" args: - sign-blob - - "--key" - - "env://COSIGN_KEY" - - "--output-signature" - - "${signature}" - - "--output-certificate" - - "${certificate}" + - "--bundle=${signature}" - "${artifact}" - "--yes" artifacts: checksum diff --git a/README.md b/README.md index 9739f40..cd90498 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,19 @@ Note: Requires Python 3.13+. Most package managers install the latest version. After install, open **http://localhost:37777** to see the dashboard. Start a new Claude Code session - memory is now active. +### Verifying Release Signatures + +All release checksums are signed with [cosign](https://github.com/sigstore/cosign) using keyless signing. To verify: + +```bash +# Download the checksum file and its sigstore bundle from the release +cosign verify-blob \ + --certificate-identity-regexp "https://github.com/lukaszraczylo/claude-mnemonic/.*" \ + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ + --bundle "checksums.txt.sigstore.json" \ + checksums.txt +``` + ## What it does | Feature | Description | diff --git a/internal/update/update.go b/internal/update/update.go new file mode 100644 index 0000000..dc88597 --- /dev/null +++ b/internal/update/update.go @@ -0,0 +1,555 @@ +// 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 = 100 * 1024 * 1024 // 100MB max per extracted file +) + +// Release represents a GitHub release. +type Release struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + PublishedAt time.Time `json:"published_at"` + Assets []Asset `json:"assets"` + Body string `json:"body"` +} + +// 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 { + Available bool `json:"available"` + CurrentVersion string `json:"current_version"` + LatestVersion string `json:"latest_version"` + ReleaseNotes string `json:"release_notes,omitempty"` + PublishedAt time.Time `json:"published_at,omitempty"` + DownloadURL string `json:"download_url,omitempty"` + ChecksumsURL string `json:"checksums_url,omitempty"` + BundleURL string `json:"bundle_url,omitempty"` // Sigstore bundle (.sigstore.json) +} + +// UpdateStatus represents the current update status. +type UpdateStatus struct { + State string `json:"state"` // "idle", "checking", "downloading", "verifying", "applying", "done", "error" + Progress float64 `json:"progress"` + Message string `json:"message"` + Error string `json:"error,omitempty"` +} + +// Updater handles self-updates. +type Updater struct { + currentVersion string + installDir string + httpClient *http.Client + + mu sync.RWMutex + status UpdateStatus + lastCheck time.Time + cachedUpdate *UpdateInfo +} + +// 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(), + } +} + +// 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) + + 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) + } + + 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 { + // Files to replace + binaries := []struct { + src string + dest string + }{ + {"worker", filepath.Join(u.installDir, "worker")}, + {"mcp-server", filepath.Join(u.installDir, "mcp-server")}, + {"hooks/session-start", filepath.Join(u.installDir, "hooks", "session-start")}, + {"hooks/user-prompt", filepath.Join(u.installDir, "hooks", "user-prompt")}, + {"hooks/post-tool-use", filepath.Join(u.installDir, "hooks", "post-tool-use")}, + {"hooks/stop", filepath.Join(u.installDir, "hooks", "stop")}, + {"hooks/subagent-stop", filepath.Join(u.installDir, "hooks", "subagent-stop")}, + } + + for _, b := range binaries { + src := filepath.Join(extractDir, b.src) + if _, err := os.Stat(src); os.IsNotExist(err) { + continue // Skip if not in archive + } + + // Backup existing binary + if _, err := os.Stat(b.dest); err == nil { + backup := b.dest + ".bak" + if err := os.Rename(b.dest, backup); err != nil { + return fmt.Errorf("failed to backup %s: %w", b.dest, err) + } + 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, b.dest) + } + + // Copy new binary + if err := copyFile(src, b.dest); err != nil { + return fmt.Errorf("failed to install %s: %w", b.dest, err) + } + + // Make executable + // #nosec G302 -- executables require 0755 permissions + if err := os.Chmod(b.dest, 0755); err != nil { + return fmt.Errorf("failed to chmod %s: %w", b.dest, err) + } + } + + return nil +} + +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") + + // Handle dev/dirty versions + if strings.Contains(current, "-dirty") || strings.Contains(current, "-dev") { + return true // Always show update available for dev builds + } + + // Simple semver comparison + latestParts := strings.Split(latest, ".") + currentParts := strings.Split(current, ".") + + 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) +} diff --git a/internal/worker/handlers.go b/internal/worker/handlers.go index 1fd1cc9..ad3d35b 100644 --- a/internal/worker/handlers.go +++ b/internal/worker/handlers.go @@ -703,3 +703,50 @@ func (s *Service) handleContextInject(w http.ResponseWriter, r *http.Request) { "duplicates_removed": duplicatesRemoved, }) } + +// handleUpdateCheck checks for available updates. +func (s *Service) handleUpdateCheck(w http.ResponseWriter, r *http.Request) { + info, err := s.updater.CheckForUpdate(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, info) +} + +// handleUpdateApply downloads and applies an available update. +func (s *Service) handleUpdateApply(w http.ResponseWriter, r *http.Request) { + // First check for update + info, err := s.updater.CheckForUpdate(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if !info.Available { + writeJSON(w, map[string]interface{}{ + "success": false, + "message": "No update available", + }) + return + } + + // Apply update in background + go func() { + if err := s.updater.ApplyUpdate(s.ctx, info); err != nil { + log.Error().Err(err).Msg("Update failed") + } + }() + + writeJSON(w, map[string]interface{}{ + "success": true, + "message": "Update started", + "version": info.LatestVersion, + }) +} + +// handleUpdateStatus returns the current update status. +func (s *Service) handleUpdateStatus(w http.ResponseWriter, r *http.Request) { + status := s.updater.GetStatus() + writeJSON(w, status) +} diff --git a/internal/worker/service.go b/internal/worker/service.go index bcb5064..4f14348 100644 --- a/internal/worker/service.go +++ b/internal/worker/service.go @@ -14,6 +14,7 @@ import ( "github.com/go-chi/chi/v5/middleware" "github.com/lukaszraczylo/claude-mnemonic/internal/config" "github.com/lukaszraczylo/claude-mnemonic/internal/db/sqlite" + "github.com/lukaszraczylo/claude-mnemonic/internal/update" "github.com/lukaszraczylo/claude-mnemonic/internal/vector/chroma" "github.com/lukaszraczylo/claude-mnemonic/internal/watcher" "github.com/lukaszraczylo/claude-mnemonic/internal/worker/sdk" @@ -97,6 +98,9 @@ type Service struct { // File watchers for auto-recreation on deletion dbWatcher *watcher.Watcher configWatcher *watcher.Watcher + + // Self-updater + updater *update.Updater } // staleVerifyRequest represents a request to verify a stale observation in background @@ -118,6 +122,10 @@ func NewService(version string) (*Service, error) { router := chi.NewRouter() sseBroadcaster := sse.NewBroadcaster() + // Determine install directory (plugin location) + homeDir, _ := os.UserHomeDir() + installDir := fmt.Sprintf("%s/.claude/plugins/marketplaces/claude-mnemonic", homeDir) + svc := &Service{ version: version, config: cfg, @@ -126,6 +134,7 @@ func NewService(version string) (*Service, error) { ctx: ctx, cancel: cancel, startTime: time.Now(), + updater: update.New(version, installDir), } // Setup middleware and routes (health endpoint works immediately) @@ -587,6 +596,11 @@ func (s *Service) setupRoutes() { // Readiness check - returns 200 only when fully initialized s.router.Get("/api/ready", s.handleReady) + // Update endpoints (work before DB is ready) + s.router.Get("/api/update/check", s.handleUpdateCheck) + s.router.Post("/api/update/apply", s.handleUpdateApply) + s.router.Get("/api/update/status", s.handleUpdateStatus) + // SSE endpoint (works before DB is ready) s.router.Get("/api/events", s.sseBroadcaster.HandleSSE) diff --git a/internal/worker/static/.gitkeep b/internal/worker/static/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/ui/src/App.vue b/ui/src/App.vue index 981bf9e..96e8df3 100644 --- a/ui/src/App.vue +++ b/ui/src/App.vue @@ -1,6 +1,6 @@ diff --git a/ui/src/composables/index.ts b/ui/src/composables/index.ts index a39ccdf..96906d8 100644 --- a/ui/src/composables/index.ts +++ b/ui/src/composables/index.ts @@ -1,3 +1,4 @@ export { useSSE } from './useSSE' export { useStats } from './useStats' export { useTimeline } from './useTimeline' +export { useUpdate } from './useUpdate' diff --git a/ui/src/composables/useUpdate.ts b/ui/src/composables/useUpdate.ts new file mode 100644 index 0000000..6cef711 --- /dev/null +++ b/ui/src/composables/useUpdate.ts @@ -0,0 +1,120 @@ +import { ref, onMounted, onUnmounted } from 'vue' + +export interface UpdateInfo { + available: boolean + current_version: string + latest_version: string + release_notes?: string + published_at?: string +} + +export interface UpdateStatus { + state: 'idle' | 'checking' | 'downloading' | 'verifying' | 'applying' | 'done' | 'error' + progress: number + message: string + error?: string +} + +const CHECK_INTERVAL = 30 * 60 * 1000 // 30 minutes in milliseconds + +export function useUpdate() { + const updateInfo = ref(null) + const updateStatus = ref({ state: 'idle', progress: 0, message: '' }) + const isChecking = ref(false) + const isUpdating = ref(false) + let statusInterval: ReturnType | null = null + let checkInterval: ReturnType | null = null + + const checkForUpdate = async () => { + isChecking.value = true + try { + const response = await fetch('/api/update/check') + if (response.ok) { + updateInfo.value = await response.json() + } + } catch (error) { + console.error('Failed to check for updates:', error) + } finally { + isChecking.value = false + } + } + + const applyUpdate = async () => { + if (!updateInfo.value?.available) return + + isUpdating.value = true + try { + const response = await fetch('/api/update/apply', { method: 'POST' }) + if (response.ok) { + // Start polling for status + startStatusPolling() + } + } catch (error) { + console.error('Failed to apply update:', error) + isUpdating.value = false + } + } + + const fetchStatus = async () => { + try { + const response = await fetch('/api/update/status') + if (response.ok) { + updateStatus.value = await response.json() + + // Stop polling when done or error + if (updateStatus.value.state === 'done' || updateStatus.value.state === 'error') { + stopStatusPolling() + isUpdating.value = false + } + } + } catch (error) { + console.error('Failed to fetch update status:', error) + } + } + + const startStatusPolling = () => { + if (statusInterval) return + statusInterval = setInterval(fetchStatus, 1000) + fetchStatus() + } + + const stopStatusPolling = () => { + if (statusInterval) { + clearInterval(statusInterval) + statusInterval = null + } + } + + const startPeriodicCheck = () => { + if (checkInterval) return + // Check every hour + checkInterval = setInterval(checkForUpdate, CHECK_INTERVAL) + } + + const stopPeriodicCheck = () => { + if (checkInterval) { + clearInterval(checkInterval) + checkInterval = null + } + } + + // Check for updates on mount and start periodic checking + onMounted(() => { + checkForUpdate() + startPeriodicCheck() + }) + + onUnmounted(() => { + stopStatusPolling() + stopPeriodicCheck() + }) + + return { + updateInfo, + updateStatus, + isChecking, + isUpdating, + checkForUpdate, + applyUpdate + } +} diff --git a/ui/tsconfig.tsbuildinfo b/ui/tsconfig.tsbuildinfo index 9c6b5d6..539068d 100644 --- a/ui/tsconfig.tsbuildinfo +++ b/ui/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.ts","./src/vite-env.d.ts","./src/components/index.ts","./src/composables/index.ts","./src/composables/usesse.ts","./src/composables/usestats.ts","./src/composables/usetimeline.ts","./src/types/api.ts","./src/types/index.ts","./src/types/observation.ts","./src/types/prompt.ts","./src/types/summary.ts","./src/utils/api.ts","./src/utils/formatters.ts","./src/app.vue","./src/components/badge.vue","./src/components/card.vue","./src/components/filtertabs.vue","./src/components/header.vue","./src/components/iconbox.vue","./src/components/observationcard.vue","./src/components/projectfilter.vue","./src/components/promptcard.vue","./src/components/sidebar.vue","./src/components/statscards.vue","./src/components/summarycard.vue","./src/components/timeline.vue"],"version":"5.7.3"} \ No newline at end of file +{"root":["./src/main.ts","./src/vite-env.d.ts","./src/components/index.ts","./src/composables/index.ts","./src/composables/usesse.ts","./src/composables/usestats.ts","./src/composables/usetimeline.ts","./src/composables/useupdate.ts","./src/types/api.ts","./src/types/index.ts","./src/types/observation.ts","./src/types/prompt.ts","./src/types/summary.ts","./src/utils/api.ts","./src/utils/formatters.ts","./src/app.vue","./src/components/badge.vue","./src/components/card.vue","./src/components/filtertabs.vue","./src/components/header.vue","./src/components/iconbox.vue","./src/components/observationcard.vue","./src/components/projectfilter.vue","./src/components/promptcard.vue","./src/components/sidebar.vue","./src/components/statscards.vue","./src/components/summarycard.vue","./src/components/timeline.vue"],"version":"5.7.3"} \ No newline at end of file