Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions cmd/mnemonic/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import (
"github.com/appsprout-dev/mnemonic/internal/backup"
"github.com/appsprout-dev/mnemonic/internal/mcp"
"github.com/appsprout-dev/mnemonic/internal/store"
"github.com/appsprout-dev/mnemonic/internal/updater"

clipwatcher "github.com/appsprout-dev/mnemonic/internal/watcher/clipboard"
fswatcher "github.com/appsprout-dev/mnemonic/internal/watcher/filesystem"
Expand Down Expand Up @@ -176,6 +177,10 @@ func main() {
diagnoseCommand(*configPath)
case "generate-token":
generateTokenCommand()
case "check-update":
checkUpdateCommand()
case "update":
updateCommand()
case "version":
fmt.Printf("mnemonic v%s\n", Version)
default:
Expand Down Expand Up @@ -284,6 +289,75 @@ func startCommand(configPath string) {
}

// generateTokenCommand generates a random API token and prints it.
// ============================================================================
// Update Commands (check-update / update)
// ============================================================================

func checkUpdateCommand() {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()

fmt.Printf("Checking for updates...\n")
info, err := updater.CheckForUpdate(ctx, Version)
if err != nil {
die(exitNetwork, "Update check failed", err.Error())
}

if info.UpdateAvailable {
fmt.Printf("\n Current: v%s\n", info.CurrentVersion)
fmt.Printf(" Latest: %sv%s%s\n\n", colorGreen, info.LatestVersion, colorReset)
fmt.Printf(" Run %smnemonic update%s to install.\n", colorBold, colorReset)
fmt.Printf(" Release: %s\n", info.ReleaseURL)
} else {
fmt.Printf("\n %sYou're up to date!%s (v%s)\n", colorGreen, colorReset, info.CurrentVersion)
}
}

func updateCommand() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()

fmt.Printf("Checking for updates...\n")
info, err := updater.CheckForUpdate(ctx, Version)
if err != nil {
die(exitNetwork, "Update check failed", err.Error())
}

if !info.UpdateAvailable {
fmt.Printf("%sAlready up to date%s (v%s)\n", colorGreen, colorReset, info.CurrentVersion)
return
}

fmt.Printf("Downloading v%s...\n", info.LatestVersion)
result, err := updater.PerformUpdate(ctx, info)
if err != nil {
die(exitGeneral, "Update failed", err.Error())
}

fmt.Printf("%sUpdated: v%s → v%s%s\n", colorGreen, result.PreviousVersion, result.NewVersion, colorReset)

// Restart daemon if it's running
svc := daemon.NewServiceManager()
if svc.IsInstalled() {
running, _ := svc.IsRunning()
if running {
fmt.Printf("Restarting daemon...\n")
if err := svc.Stop(); err != nil {
fmt.Fprintf(os.Stderr, "%sWarning:%s failed to stop daemon: %v\n", colorYellow, colorReset, err)
fmt.Printf("Restart manually: mnemonic restart\n")
return
}
time.Sleep(1 * time.Second)
if err := svc.Start(); err != nil {
fmt.Fprintf(os.Stderr, "%sWarning:%s failed to start daemon: %v\n", colorYellow, colorReset, err)
fmt.Printf("Start manually: mnemonic start\n")
return
}
fmt.Printf("%sDaemon restarted with v%s%s\n", colorGreen, result.NewVersion, colorReset)
}
}
}

func generateTokenCommand() {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
Expand Down Expand Up @@ -1553,6 +1627,7 @@ func serveCommand(configPath string) {
IngestExcludePatterns: cfg.Perception.Filesystem.ExcludePatterns,
IngestMaxContentBytes: cfg.Perception.Filesystem.MaxContentBytes,
Version: Version,
ServiceRestarter: daemon.NewServiceManager(),
Log: log,
}
// Only set Consolidator if it's non-nil (avoids Go nil-interface trap)
Expand Down Expand Up @@ -2529,6 +2604,10 @@ MONITORING COMMANDS:
diagnose Run health checks (config, DB, LLM, disk)
watch Live stream of daemon events

UPDATE COMMANDS:
check-update Check if a newer version is available
update Download and install the latest version

SETUP COMMANDS:
install Install as system service (auto-start on login)
uninstall Remove system service
Expand Down
134 changes: 134 additions & 0 deletions internal/api/routes/update.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package routes

import (
"context"
"log/slog"
"net/http"
"time"

"github.com/appsprout-dev/mnemonic/internal/updater"
)

// UpdateCheckResponse is the JSON response for the update check endpoint.
type UpdateCheckResponse struct {
CurrentVersion string `json:"current_version"`
LatestVersion string `json:"latest_version"`
UpdateAvailable bool `json:"update_available"`
ReleaseURL string `json:"release_url"`
}

// UpdateResponse is the JSON response for the update endpoint.
type UpdateResponse struct {
Status string `json:"status"`
PreviousVersion string `json:"previous_version,omitempty"`
NewVersion string `json:"new_version,omitempty"`
RestartPending bool `json:"restart_pending"`
Message string `json:"message,omitempty"`
}

// ServiceRestarter can stop and start the daemon service.
// If nil is passed to HandleUpdate, the handler will still perform the update
// but cannot restart the daemon automatically.
type ServiceRestarter interface {
IsInstalled() bool
Stop() error
Start() error
}

// HandleUpdateCheck returns an HTTP handler that checks for available updates
// by querying the GitHub Releases API. No authentication required.
func HandleUpdateCheck(version string, log *slog.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log.Debug("update check requested")

ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()

info, err := updater.CheckForUpdate(ctx, version)
if err != nil {
log.Error("update check failed", "error", err)
writeError(w, http.StatusBadGateway, "failed to check for updates: "+err.Error(), "UPDATE_CHECK_ERROR")
return
}

resp := UpdateCheckResponse{
CurrentVersion: info.CurrentVersion,
LatestVersion: info.LatestVersion,
UpdateAvailable: info.UpdateAvailable,
ReleaseURL: info.ReleaseURL,
}

log.Info("update check completed", "current", info.CurrentVersion, "latest", info.LatestVersion, "available", info.UpdateAvailable)
writeJSON(w, http.StatusOK, resp)
}
}

// HandleUpdate returns an HTTP handler that downloads and installs an available update.
// If svc is non-nil and installed, the daemon will be restarted after the update.
func HandleUpdate(version string, svc ServiceRestarter, log *slog.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log.Info("update requested via API")

// Use a generous timeout for download (5 minutes)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()

info, err := updater.CheckForUpdate(ctx, version)
if err != nil {
log.Error("update check failed", "error", err)
writeError(w, http.StatusBadGateway, "failed to check for updates: "+err.Error(), "UPDATE_CHECK_ERROR")
return
}

if !info.UpdateAvailable {
resp := UpdateResponse{
Status: "up_to_date",
Message: "already running the latest version",
}
writeJSON(w, http.StatusOK, resp)
return
}

result, err := updater.PerformUpdate(ctx, info)
if err != nil {
log.Error("update failed", "error", err)
writeError(w, http.StatusInternalServerError, "update failed: "+err.Error(), "UPDATE_ERROR")
return
}

log.Info("update installed", "previous", result.PreviousVersion, "new", result.NewVersion, "binary", result.BinaryPath)

// Determine if we can restart
canRestart := svc != nil && svc.IsInstalled()

resp := UpdateResponse{
Status: "updated",
PreviousVersion: result.PreviousVersion,
NewVersion: result.NewVersion,
RestartPending: canRestart,
}

if !canRestart {
resp.Message = "update installed — restart the daemon manually to use the new version"
}

// Send response before restarting
writeJSON(w, http.StatusOK, resp)

// Restart the daemon in the background if possible
if canRestart {
go func() {
time.Sleep(500 * time.Millisecond)
log.Info("restarting daemon after update")
if err := svc.Stop(); err != nil {
log.Error("failed to stop daemon for restart", "error", err)
return
}
time.Sleep(1 * time.Second)
if err := svc.Start(); err != nil {
log.Error("failed to start daemon after update", "error", err)
}
}()
}
}
}
5 changes: 5 additions & 0 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type ServerDeps struct {
IngestExcludePatterns []string
IngestMaxContentBytes int
Version string
ServiceRestarter routes.ServiceRestarter // can be nil if not installed as service
Log *slog.Logger
}

Expand Down Expand Up @@ -78,6 +79,10 @@ func (s *Server) registerRoutes() {
s.mux.HandleFunc("GET /api/v1/health", routes.HandleHealth(s.deps.Store, s.deps.LLM, s.deps.Version, s.deps.Log))
s.mux.HandleFunc("GET /api/v1/stats", routes.HandleStats(s.deps.Store, s.deps.Log))

// Self-update
s.mux.HandleFunc("GET /api/v1/system/update-check", routes.HandleUpdateCheck(s.deps.Version, s.deps.Log))
s.mux.HandleFunc("POST /api/v1/system/update", routes.HandleUpdate(s.deps.Version, s.deps.ServiceRestarter, s.deps.Log))

// Memory CRUD
s.mux.HandleFunc("POST /api/v1/memories", routes.HandleCreateMemory(s.deps.Store, s.deps.Bus, s.deps.Log))
s.mux.HandleFunc("GET /api/v1/memories", routes.HandleListMemories(s.deps.Store, s.deps.Log))
Expand Down
Loading
Loading