mirror of
https://github.com/lukaszraczylo/claude-mnemonic.git
synced 2026-06-05 23:03:55 +00:00
Release to the world.
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
github: [lukaszraczylo]
|
||||
custom: [monzo.me/lukaszraczylo]
|
||||
@@ -10,7 +10,9 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
|
||||
+2
-9
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+6
-1
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { watch } from 'vue'
|
||||
import { useSSE, useStats, useTimeline } from '@/composables'
|
||||
import { useSSE, useStats, useTimeline, useUpdate } from '@/composables'
|
||||
import Header from '@/components/Header.vue'
|
||||
import StatsCards from '@/components/StatsCards.vue'
|
||||
import FilterTabs from '@/components/FilterTabs.vue'
|
||||
@@ -10,6 +10,7 @@ import Sidebar from '@/components/Sidebar.vue'
|
||||
// Composables
|
||||
const { isConnected, isProcessing, queueDepth, lastEvent } = useSSE()
|
||||
const { stats } = useStats()
|
||||
const { updateInfo, updateStatus, isUpdating, applyUpdate } = useUpdate()
|
||||
const {
|
||||
filteredItems,
|
||||
loading,
|
||||
@@ -41,7 +42,11 @@ watch(lastEvent, (event) => {
|
||||
<Header
|
||||
:is-connected="isConnected"
|
||||
:is-processing="isProcessing"
|
||||
:update-info="updateInfo"
|
||||
:update-status="updateStatus"
|
||||
:is-updating="isUpdating"
|
||||
@refresh="refresh"
|
||||
@apply-update="applyUpdate"
|
||||
/>
|
||||
|
||||
<!-- Main Content -->
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { UpdateInfo, UpdateStatus } from '@/composables/useUpdate'
|
||||
|
||||
defineProps<{
|
||||
isConnected: boolean
|
||||
isProcessing: boolean
|
||||
updateInfo: UpdateInfo | null
|
||||
updateStatus: UpdateStatus
|
||||
isUpdating: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: []
|
||||
applyUpdate: []
|
||||
}>()
|
||||
|
||||
const showUpdateModal = ref(false)
|
||||
|
||||
const reloadPage = () => {
|
||||
globalThis.location.reload()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -26,6 +39,41 @@ const emit = defineEmits<{
|
||||
|
||||
<!-- Status & Actions -->
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Update Available Indicator -->
|
||||
<div v-if="updateInfo?.available && !isUpdating && updateStatus.state === 'idle'" class="relative">
|
||||
<button
|
||||
class="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-amber-500/20 border border-amber-500/50 text-amber-400 hover:bg-amber-500/30 transition-colors text-sm"
|
||||
@click="showUpdateModal = true"
|
||||
>
|
||||
<i class="fas fa-arrow-circle-up" />
|
||||
<span>v{{ updateInfo.latest_version }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Update In Progress -->
|
||||
<div v-else-if="isUpdating" class="flex items-center gap-2 text-amber-400 text-sm">
|
||||
<i class="fas fa-spinner animate-spin" />
|
||||
<span>{{ updateStatus.message || 'Updating...' }}</span>
|
||||
<span class="text-slate-500">{{ Math.round(updateStatus.progress * 100) }}%</span>
|
||||
</div>
|
||||
|
||||
<!-- Update Complete -->
|
||||
<div v-else-if="updateStatus.state === 'done'" class="flex items-center gap-2">
|
||||
<button
|
||||
class="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-green-500/20 border border-green-500/50 text-green-400 hover:bg-green-500/30 transition-colors text-sm"
|
||||
@click="reloadPage"
|
||||
>
|
||||
<i class="fas fa-check-circle" />
|
||||
<span>Restart</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Update Error -->
|
||||
<div v-else-if="updateStatus.state === 'error'" class="flex items-center gap-2 text-red-400 text-sm" :title="updateStatus.error">
|
||||
<i class="fas fa-exclamation-circle" />
|
||||
<span>Update failed</span>
|
||||
</div>
|
||||
|
||||
<!-- Connection Status -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
@@ -51,5 +99,52 @@ const emit = defineEmits<{
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Update Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showUpdateModal" class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<!-- Backdrop -->
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" @click="showUpdateModal = false" />
|
||||
|
||||
<!-- Modal -->
|
||||
<div class="relative glass border border-white/10 rounded-2xl p-6 max-w-md w-full shadow-2xl">
|
||||
<button
|
||||
class="absolute top-4 right-4 text-slate-400 hover:text-white"
|
||||
@click="showUpdateModal = false"
|
||||
>
|
||||
<i class="fas fa-times" />
|
||||
</button>
|
||||
|
||||
<div class="text-center mb-6">
|
||||
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-amber-500 to-orange-600 flex items-center justify-center">
|
||||
<i class="fas fa-arrow-circle-up text-3xl text-white" />
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-white mb-1">Update Available</h3>
|
||||
<p class="text-slate-400 text-sm">
|
||||
v{{ updateInfo?.current_version }} → v{{ updateInfo?.latest_version }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<button
|
||||
class="w-full py-3 rounded-xl bg-amber-500 text-slate-900 font-semibold hover:bg-amber-400 transition-colors"
|
||||
@click="emit('applyUpdate'); showUpdateModal = false"
|
||||
>
|
||||
Update Now
|
||||
</button>
|
||||
<button
|
||||
class="w-full py-3 rounded-xl bg-white/5 text-slate-400 hover:bg-white/10 hover:text-white transition-colors"
|
||||
@click="showUpdateModal = false"
|
||||
>
|
||||
Later
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-slate-500 text-xs mt-4">
|
||||
Updates are verified with cosign signatures
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { useSSE } from './useSSE'
|
||||
export { useStats } from './useStats'
|
||||
export { useTimeline } from './useTimeline'
|
||||
export { useUpdate } from './useUpdate'
|
||||
|
||||
@@ -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<UpdateInfo | null>(null)
|
||||
const updateStatus = ref<UpdateStatus>({ state: 'idle', progress: 0, message: '' })
|
||||
const isChecking = ref(false)
|
||||
const isUpdating = ref(false)
|
||||
let statusInterval: ReturnType<typeof setInterval> | null = null
|
||||
let checkInterval: ReturnType<typeof setInterval> | 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
|
||||
}
|
||||
}
|
||||
@@ -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"}
|
||||
{"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"}
|
||||
Reference in New Issue
Block a user