mirror of
https://github.com/lukaszraczylo/claude-mnemonic.git
synced 2026-10-12 04:55:18 +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:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
|
id-token: write
|
||||||
contents: write
|
contents: write
|
||||||
|
packages: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
|
|||||||
+2
-9
@@ -254,17 +254,10 @@ release:
|
|||||||
|
|
||||||
signs:
|
signs:
|
||||||
- cmd: cosign
|
- cmd: cosign
|
||||||
env:
|
signature: "${artifact}.sigstore.json"
|
||||||
- COSIGN_PASSWORD={{ .Env.COSIGN_PASSWORD }}
|
|
||||||
certificate: "${artifact}.pem"
|
|
||||||
args:
|
args:
|
||||||
- sign-blob
|
- sign-blob
|
||||||
- "--key"
|
- "--bundle=${signature}"
|
||||||
- "env://COSIGN_KEY"
|
|
||||||
- "--output-signature"
|
|
||||||
- "${signature}"
|
|
||||||
- "--output-certificate"
|
|
||||||
- "${certificate}"
|
|
||||||
- "${artifact}"
|
- "${artifact}"
|
||||||
- "--yes"
|
- "--yes"
|
||||||
artifacts: checksum
|
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.
|
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
|
## What it does
|
||||||
|
|
||||||
| Feature | Description |
|
| 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,
|
"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/go-chi/chi/v5/middleware"
|
||||||
"github.com/lukaszraczylo/claude-mnemonic/internal/config"
|
"github.com/lukaszraczylo/claude-mnemonic/internal/config"
|
||||||
"github.com/lukaszraczylo/claude-mnemonic/internal/db/sqlite"
|
"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/vector/chroma"
|
||||||
"github.com/lukaszraczylo/claude-mnemonic/internal/watcher"
|
"github.com/lukaszraczylo/claude-mnemonic/internal/watcher"
|
||||||
"github.com/lukaszraczylo/claude-mnemonic/internal/worker/sdk"
|
"github.com/lukaszraczylo/claude-mnemonic/internal/worker/sdk"
|
||||||
@@ -97,6 +98,9 @@ type Service struct {
|
|||||||
// File watchers for auto-recreation on deletion
|
// File watchers for auto-recreation on deletion
|
||||||
dbWatcher *watcher.Watcher
|
dbWatcher *watcher.Watcher
|
||||||
configWatcher *watcher.Watcher
|
configWatcher *watcher.Watcher
|
||||||
|
|
||||||
|
// Self-updater
|
||||||
|
updater *update.Updater
|
||||||
}
|
}
|
||||||
|
|
||||||
// staleVerifyRequest represents a request to verify a stale observation in background
|
// staleVerifyRequest represents a request to verify a stale observation in background
|
||||||
@@ -118,6 +122,10 @@ func NewService(version string) (*Service, error) {
|
|||||||
router := chi.NewRouter()
|
router := chi.NewRouter()
|
||||||
sseBroadcaster := sse.NewBroadcaster()
|
sseBroadcaster := sse.NewBroadcaster()
|
||||||
|
|
||||||
|
// Determine install directory (plugin location)
|
||||||
|
homeDir, _ := os.UserHomeDir()
|
||||||
|
installDir := fmt.Sprintf("%s/.claude/plugins/marketplaces/claude-mnemonic", homeDir)
|
||||||
|
|
||||||
svc := &Service{
|
svc := &Service{
|
||||||
version: version,
|
version: version,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
@@ -126,6 +134,7 @@ func NewService(version string) (*Service, error) {
|
|||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
startTime: time.Now(),
|
startTime: time.Now(),
|
||||||
|
updater: update.New(version, installDir),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup middleware and routes (health endpoint works immediately)
|
// Setup middleware and routes (health endpoint works immediately)
|
||||||
@@ -587,6 +596,11 @@ func (s *Service) setupRoutes() {
|
|||||||
// Readiness check - returns 200 only when fully initialized
|
// Readiness check - returns 200 only when fully initialized
|
||||||
s.router.Get("/api/ready", s.handleReady)
|
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)
|
// SSE endpoint (works before DB is ready)
|
||||||
s.router.Get("/api/events", s.sseBroadcaster.HandleSSE)
|
s.router.Get("/api/events", s.sseBroadcaster.HandleSSE)
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { watch } from 'vue'
|
import { watch } from 'vue'
|
||||||
import { useSSE, useStats, useTimeline } from '@/composables'
|
import { useSSE, useStats, useTimeline, useUpdate } from '@/composables'
|
||||||
import Header from '@/components/Header.vue'
|
import Header from '@/components/Header.vue'
|
||||||
import StatsCards from '@/components/StatsCards.vue'
|
import StatsCards from '@/components/StatsCards.vue'
|
||||||
import FilterTabs from '@/components/FilterTabs.vue'
|
import FilterTabs from '@/components/FilterTabs.vue'
|
||||||
@@ -10,6 +10,7 @@ import Sidebar from '@/components/Sidebar.vue'
|
|||||||
// Composables
|
// Composables
|
||||||
const { isConnected, isProcessing, queueDepth, lastEvent } = useSSE()
|
const { isConnected, isProcessing, queueDepth, lastEvent } = useSSE()
|
||||||
const { stats } = useStats()
|
const { stats } = useStats()
|
||||||
|
const { updateInfo, updateStatus, isUpdating, applyUpdate } = useUpdate()
|
||||||
const {
|
const {
|
||||||
filteredItems,
|
filteredItems,
|
||||||
loading,
|
loading,
|
||||||
@@ -41,7 +42,11 @@ watch(lastEvent, (event) => {
|
|||||||
<Header
|
<Header
|
||||||
:is-connected="isConnected"
|
:is-connected="isConnected"
|
||||||
:is-processing="isProcessing"
|
:is-processing="isProcessing"
|
||||||
|
:update-info="updateInfo"
|
||||||
|
:update-status="updateStatus"
|
||||||
|
:is-updating="isUpdating"
|
||||||
@refresh="refresh"
|
@refresh="refresh"
|
||||||
|
@apply-update="applyUpdate"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
|
|||||||
@@ -1,12 +1,25 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import type { UpdateInfo, UpdateStatus } from '@/composables/useUpdate'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
isConnected: boolean
|
isConnected: boolean
|
||||||
isProcessing: boolean
|
isProcessing: boolean
|
||||||
|
updateInfo: UpdateInfo | null
|
||||||
|
updateStatus: UpdateStatus
|
||||||
|
isUpdating: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
refresh: []
|
refresh: []
|
||||||
|
applyUpdate: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const showUpdateModal = ref(false)
|
||||||
|
|
||||||
|
const reloadPage = () => {
|
||||||
|
globalThis.location.reload()
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -26,6 +39,41 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
<!-- Status & Actions -->
|
<!-- Status & Actions -->
|
||||||
<div class="flex items-center gap-4">
|
<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 -->
|
<!-- Connection Status -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
@@ -51,5 +99,52 @@ const emit = defineEmits<{
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export { useSSE } from './useSSE'
|
export { useSSE } from './useSSE'
|
||||||
export { useStats } from './useStats'
|
export { useStats } from './useStats'
|
||||||
export { useTimeline } from './useTimeline'
|
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