nip/src/nimpak/update/update_checker.nim

297 lines
8.5 KiB
Nim

## update_checker.nim
## Automatic update checking for NIP, recipes, and tools
import std/[os, times, json, httpclient, strutils, options, tables, osproc]
type
UpdateChannel* = enum
Stable = "stable"
Beta = "beta"
Nightly = "nightly"
UpdateFrequency* = enum
Never = "never"
Daily = "daily"
Weekly = "weekly"
Monthly = "monthly"
UpdateConfig* = object
enabled*: bool
channel*: UpdateChannel
frequency*: UpdateFrequency
lastCheck*: Time
notifyRecipes*: bool
notifyTools*: bool
notifyNip*: bool
UpdateInfo* = object
component*: string # "recipes", "nix", "gentoo", "nip"
currentVersion*: string
latestVersion*: string
updateAvailable*: bool
changelog*: string
downloadUrl*: string
UpdateChecker* = ref object
config*: UpdateConfig
configPath*: string
cacheDir*: string
const
DefaultUpdateUrl = "https://updates.nip.example.com/v1"
DefaultConfigPath = ".config/nip/update-config.json"
proc getConfigPath*(): string =
## Get update config path
let xdgConfig = getEnv("XDG_CONFIG_HOME", getHomeDir() / ".config")
result = xdgConfig / "nip" / "update-config.json"
proc loadConfig*(path: string = ""): UpdateConfig =
## Load update configuration
result = UpdateConfig()
result.enabled = true
result.channel = Stable
result.frequency = Weekly
result.lastCheck = fromUnix(0)
result.notifyRecipes = true
result.notifyTools = true
result.notifyNip = true
var configPath = path
if configPath.len == 0:
configPath = getConfigPath()
if not fileExists(configPath):
return
try:
let data = readFile(configPath)
let config = parseJson(data)
if config.hasKey("enabled"):
result.enabled = config["enabled"].getBool()
if config.hasKey("channel"):
let channelStr = config["channel"].getStr()
case channelStr
of "stable": result.channel = Stable
of "beta": result.channel = Beta
of "nightly": result.channel = Nightly
else: discard
if config.hasKey("frequency"):
let freqStr = config["frequency"].getStr()
case freqStr
of "never": result.frequency = Never
of "daily": result.frequency = Daily
of "weekly": result.frequency = Weekly
of "monthly": result.frequency = Monthly
else: discard
if config.hasKey("lastCheck"):
result.lastCheck = fromUnix(config["lastCheck"].getInt())
if config.hasKey("notifyRecipes"):
result.notifyRecipes = config["notifyRecipes"].getBool()
if config.hasKey("notifyTools"):
result.notifyTools = config["notifyTools"].getBool()
if config.hasKey("notifyNip"):
result.notifyNip = config["notifyNip"].getBool()
except:
echo "Warning: Failed to load update config: ", getCurrentExceptionMsg()
proc saveConfig*(config: UpdateConfig, path: string = "") =
## Save update configuration
var configPath = path
if configPath.len == 0:
configPath = getConfigPath()
# Create config directory
createDir(configPath.parentDir())
var configJson = newJObject()
configJson["enabled"] = %config.enabled
configJson["channel"] = %($config.channel)
configJson["frequency"] = %($config.frequency)
configJson["lastCheck"] = %config.lastCheck.toUnix()
configJson["notifyRecipes"] = %config.notifyRecipes
configJson["notifyTools"] = %config.notifyTools
configJson["notifyNip"] = %config.notifyNip
writeFile(configPath, $configJson)
proc newUpdateChecker*(config: UpdateConfig = loadConfig()): UpdateChecker =
## Create a new update checker
result = UpdateChecker()
result.config = config
result.configPath = getConfigPath()
let xdgCache = getEnv("XDG_CACHE_HOME", getHomeDir() / ".cache")
result.cacheDir = xdgCache / "nip" / "updates"
createDir(result.cacheDir)
proc shouldCheck*(uc: UpdateChecker): bool =
## Check if we should check for updates based on frequency
if not uc.config.enabled:
return false
if uc.config.frequency == Never:
return false
let now = getTime()
let timeSinceLastCheck = now - uc.config.lastCheck
case uc.config.frequency
of Never:
return false
of Daily:
return timeSinceLastCheck.inDays >= 1
of Weekly:
return timeSinceLastCheck.inDays >= 7
of Monthly:
return timeSinceLastCheck.inDays >= 30
proc checkRecipeUpdates*(uc: UpdateChecker): Option[UpdateInfo] =
## Check for recipe repository updates
if not uc.config.notifyRecipes:
return none(UpdateInfo)
try:
# Check Git repository for updates
let recipesDir = getEnv("XDG_DATA_HOME", getHomeDir() / ".local/share") / "nip" / "recipes"
if not dirExists(recipesDir / ".git"):
return none(UpdateInfo)
# Get current commit
let currentCommit = execProcess("git -C " & recipesDir & " rev-parse HEAD").strip()
# Fetch latest
discard execProcess("git -C " & recipesDir & " fetch origin main 2>&1")
# Get latest commit
let latestCommit = execProcess("git -C " & recipesDir & " rev-parse origin/main").strip()
if currentCommit != latestCommit:
# Get changelog
let changelog = execProcess("git -C " & recipesDir & " log --oneline " & currentCommit & ".." & latestCommit).strip()
var info = UpdateInfo()
info.component = "recipes"
info.currentVersion = currentCommit[0..7]
info.latestVersion = latestCommit[0..7]
info.updateAvailable = true
info.changelog = changelog
return some(info)
except:
discard
return none(UpdateInfo)
proc checkToolUpdates*(uc: UpdateChecker, toolName: string): Option[UpdateInfo] =
## Check for tool updates (Nix, Gentoo, PKGSRC)
if not uc.config.notifyTools:
return none(UpdateInfo)
# For now, tools are updated via recipes
# This could be extended to check tool-specific update mechanisms
return none(UpdateInfo)
proc checkNipUpdates*(uc: UpdateChecker): Option[UpdateInfo] =
## Check for NIP updates
if not uc.config.notifyNip:
return none(UpdateInfo)
try:
let client = newHttpClient()
let url = DefaultUpdateUrl & "/nip/latest?channel=" & $uc.config.channel
let response = client.get(url)
if response.code == Http200:
let data = parseJson(response.body)
if data.hasKey("version"):
let latestVersion = data["version"].getStr()
let currentVersion = "0.1.0" # TODO: Get from build info
if latestVersion != currentVersion:
var info = UpdateInfo()
info.component = "nip"
info.currentVersion = currentVersion
info.latestVersion = latestVersion
info.updateAvailable = true
if data.hasKey("changelog"):
info.changelog = data["changelog"].getStr()
if data.hasKey("downloadUrl"):
info.downloadUrl = data["downloadUrl"].getStr()
return some(info)
except:
discard
return none(UpdateInfo)
proc checkAllUpdates*(uc: UpdateChecker): seq[UpdateInfo] =
## Check for all available updates
result = @[]
# Check recipes
let recipeUpdate = uc.checkRecipeUpdates()
if recipeUpdate.isSome:
result.add(recipeUpdate.get())
# Check NIP
let nipUpdate = uc.checkNipUpdates()
if nipUpdate.isSome:
result.add(nipUpdate.get())
# Update last check time
uc.config.lastCheck = getTime()
saveConfig(uc.config)
proc formatUpdateNotification*(info: UpdateInfo): string =
## Format update notification for display
result = "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
result.add("📦 Update Available: " & info.component & "\n")
result.add("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n")
result.add("Current Version: " & info.currentVersion & "\n")
result.add("Latest Version: " & info.latestVersion & "\n")
if info.changelog.len > 0:
result.add("\nChangelog:\n")
result.add(info.changelog & "\n")
result.add("\nTo update, run:\n")
case info.component
of "recipes":
result.add(" nip update recipes\n")
of "nip":
result.add(" nip update self\n")
else:
result.add(" nip update " & info.component & "\n")
proc showUpdateNotifications*(updates: seq[UpdateInfo], quiet: bool = false) =
## Show update notifications to user
if updates.len == 0:
if not quiet:
echo "✅ All components are up to date"
return
echo ""
for update in updates:
echo formatUpdateNotification(update)
echo ""
if updates.len > 1:
echo "To update all components:"
echo " nip update --all"
echo ""