Release to the world.

This commit is contained in:
2025-12-15 00:50:04 +00:00
parent d7c20cea54
commit 85e1dfa7f3
13 changed files with 858 additions and 11 deletions
+2
View File
@@ -0,0 +1,2 @@
github: [lukaszraczylo]
custom: [monzo.me/lukaszraczylo]
+2
View File
@@ -10,7 +10,9 @@ on:
workflow_dispatch:
permissions:
id-token: write
contents: write
packages: write
jobs:
release:
+2 -9
View File
@@ -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
+13
View File
@@ -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 |
+555
View File
@@ -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)
}
+47
View File
@@ -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
View File
@@ -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)
View File
+6 -1
View File
@@ -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 -->
+95
View File
@@ -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
View File
@@ -1,3 +1,4 @@
export { useSSE } from './useSSE'
export { useStats } from './useStats'
export { useTimeline } from './useTimeline'
export { useUpdate } from './useUpdate'
+120
View File
@@ -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
View File
@@ -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"}