## 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 ""