# SPDX-License-Identifier: LSL-1.0 # Copyright (c) 2026 Markus Maiwald # Stewardship: Self Sovereign Society Foundation # # This file is part of the Nexus Sovereign Core. # See legal/LICENSE_SOVEREIGN.md for license terms. ## nimpak/lockfile_system.nim ## Lockfile generation and reproducibility system for NimPak ## ## This module implements the lockfile system for environment reproducibility ## and CI/CD integration, providing exact package version tracking and ## system state capture. import std/[os, strutils, times, json, tables, sequtils, algorithm, hashes] type LockfileError* = object of CatchableError lockfilePath*: string LockfileManager* = object lockfilePath*: string ## Path to nip.lock file generationsRoot*: string ## /System/Generations - Generation metadata programsRoot*: string ## /Programs - Package installation directory format*: LockfileFormat ## Output format (JSON, YAML, KDL) includeSource*: bool ## Include source attribution includeChecksums*: bool ## Include package checksums includeGeneration*: bool ## Include generation information LockfileFormat* = enum LockfileJson, ## JSON format (default) LockfileYaml, ## YAML format LockfileKdl ## KDL format PackageLockEntry* = object name*: string ## Package name version*: string ## Exact version stream*: string ## Package stream (stable, testing, etc.) source*: PackageSource ## Source information checksum*: string ## Package checksum (BLAKE3) dependencies*: seq[string] ## Direct dependencies installedPath*: string ## Installation path installedSize*: int64 ## Installed size in bytes installTime*: times.DateTime ## Installation timestamp PackageSource* = object sourceMethod*: string ## Source method (grafted-pacman, native, etc.) url*: string ## Source URL or identifier hash*: string ## Source hash timestamp*: times.DateTime ## Source timestamp attribution*: string ## Source attribution SystemLockfile* = object version*: string ## Lockfile format version generated*: times.DateTime ## Generation timestamp generator*: string ## Generator tool (nip) systemGeneration*: string ## System generation ID architecture*: string ## Target architecture packages*: seq[PackageLockEntry] ## Locked packages metadata*: LockfileMetadata ## Additional metadata LockfileMetadata* = object description*: string ## Lockfile description environment*: string ## Environment name (production, development, etc.) creator*: string ## Creator information tags*: seq[string] ## Tags for categorization totalSize*: int64 ## Total installed size packageCount*: int ## Number of packages # ============================================================================= # LockfileManager Creation and Configuration # ============================================================================= proc newLockfileManager*(lockfilePath: string = "nip.lock", generationsRoot: string = "/System/Generations", programsRoot: string = "/Programs", format: LockfileFormat = LockfileJson, includeSource: bool = true, includeChecksums: bool = true, includeGeneration: bool = true): LockfileManager = ## Create a new LockfileManager with specified configuration LockfileManager( lockfilePath: lockfilePath, generationsRoot: generationsRoot, programsRoot: programsRoot, format: format, includeSource: includeSource, includeChecksums: includeChecksums, includeGeneration: includeGeneration ) # ============================================================================= # Package Information Gathering # ============================================================================= proc gatherPackageInfo*(lm: LockfileManager, packageName: string, version: string): PackageLockEntry = ## Gather comprehensive information about an installed package let packageDir = lm.programsRoot / packageName / version var entry = PackageLockEntry( name: packageName, version: version, stream: "stable", # Default - would be read from package metadata installedPath: packageDir, installedSize: 0, installTime: now(), dependencies: @[], checksum: "", source: PackageSource( sourceMethod: "unknown", url: "", hash: "", timestamp: now(), attribution: "" ) ) # Calculate installed size if dirExists(packageDir): proc calculateDirSize(path: string): int64 = var totalSize: int64 = 0 for kind, subpath in walkDir(path): if kind == pcFile: try: totalSize += getFileSize(subpath) except: discard elif kind == pcDir: totalSize += calculateDirSize(subpath) return totalSize entry.installedSize = calculateDirSize(packageDir) # Try to read package metadata if available let metadataFile = packageDir / "package.json" if fileExists(metadataFile): try: let metadata = parseJson(readFile(metadataFile)) if metadata.hasKey("stream"): entry.stream = metadata["stream"].getStr() if metadata.hasKey("dependencies"): entry.dependencies = metadata["dependencies"].getElems().mapIt(it.getStr()) if metadata.hasKey("checksum"): entry.checksum = metadata["checksum"].getStr() if metadata.hasKey("source"): let sourceNode = metadata["source"] entry.source = PackageSource( sourceMethod: sourceNode.getOrDefault("method").getStr("unknown"), url: sourceNode.getOrDefault("url").getStr(""), hash: sourceNode.getOrDefault("hash").getStr(""), timestamp: if sourceNode.hasKey("timestamp"): parse(sourceNode["timestamp"].getStr(), "yyyy-MM-dd'T'HH:mm:ss'.'fff'Z'", utc()) else: now(), attribution: sourceNode.getOrDefault("attribution").getStr("") ) if metadata.hasKey("install_time"): entry.installTime = parse(metadata["install_time"].getStr(), "yyyy-MM-dd'T'HH:mm:ss'.'fff'Z'", utc()) except: # Use defaults if metadata parsing fails discard return entry proc scanInstalledPackages*(lm: LockfileManager): seq[PackageLockEntry] = ## Scan all installed packages and gather their information var packages: seq[PackageLockEntry] = @[] if not dirExists(lm.programsRoot): return packages # Scan /Programs directory for installed packages for kind, packagePath in walkDir(lm.programsRoot): if kind == pcDir: let packageName = extractFilename(packagePath) # Skip system directories if packageName.startsWith("."): continue # Scan versions for this package for versionKind, versionPath in walkDir(packagePath): if versionKind == pcDir: let version = extractFilename(versionPath) # Skip system directories if version.startsWith("."): continue let packageInfo = lm.gatherPackageInfo(packageName, version) packages.add(packageInfo) # Sort packages by name and version for consistent output packages.sort do (a, b: PackageLockEntry) -> int: let nameCompare = cmp(a.name, b.name) if nameCompare != 0: nameCompare else: cmp(a.version, b.version) return packages # ============================================================================= # System State Capture # ============================================================================= proc getCurrentGeneration*(lm: LockfileManager): string = ## Get the current system generation ID try: let currentGenFile = lm.generationsRoot / "current" if fileExists(currentGenFile): return readFile(currentGenFile).strip() else: return "" except: return "" proc getSystemArchitecture*(): string = ## Get the system architecture try: when defined(amd64) or defined(x86_64): return "x86_64" elif defined(i386) or defined(x86): return "i386" elif defined(arm64) or defined(aarch64): return "aarch64" elif defined(arm): return "arm" else: return "unknown" except: return "unknown" proc createSystemLockfile*(lm: LockfileManager, description: string = "", environment: string = "", creator: string = "", tags: seq[string] = @[]): SystemLockfile = ## Create a complete system lockfile with all installed packages let packages = lm.scanInstalledPackages() let totalSize = packages.mapIt(it.installedSize).foldl(a + b, 0'i64) let metadata = LockfileMetadata( description: if description.len > 0: description else: "System lockfile generated by nip", environment: if environment.len > 0: environment else: "default", creator: if creator.len > 0: creator else: "nip", tags: if tags.len > 0: tags else: @["system", "lockfile"], totalSize: totalSize, packageCount: packages.len ) SystemLockfile( version: "1.0", generated: now(), generator: "nip", systemGeneration: lm.getCurrentGeneration(), architecture: getSystemArchitecture(), packages: packages, metadata: metadata ) # ============================================================================= # Lockfile Serialization # ============================================================================= proc serializeLockfileToJson*(lockfile: SystemLockfile, pretty: bool = true): string = ## Serialize lockfile to JSON format let jsonNode = %*{ "lockfile": { "version": lockfile.version, "generated": $lockfile.generated, "generator": lockfile.generator, "system_generation": lockfile.systemGeneration, "architecture": lockfile.architecture }, "metadata": { "description": lockfile.metadata.description, "environment": lockfile.metadata.environment, "creator": lockfile.metadata.creator, "tags": lockfile.metadata.tags, "total_size": lockfile.metadata.totalSize, "package_count": lockfile.metadata.packageCount }, "packages": lockfile.packages.mapIt(%*{ "name": it.name, "version": it.version, "stream": it.stream, "checksum": it.checksum, "installed_path": it.installedPath, "installed_size": it.installedSize, "install_time": $it.installTime, "dependencies": it.dependencies, "source": { "method": it.source.sourceMethod, "url": it.source.url, "hash": it.source.hash, "timestamp": $it.source.timestamp, "attribution": it.source.attribution } }) } if pretty: return jsonNode.pretty() else: return $jsonNode proc serializeLockfileToKdl*(lockfile: SystemLockfile): string = ## Serialize lockfile to KDL format result = "// NimPak System Lockfile\n" result.add("// Generated: " & $lockfile.generated & "\n\n") result.add("lockfile {\n") result.add(" version \"" & lockfile.version & "\"\n") result.add(" generated \"" & $lockfile.generated & "\"\n") result.add(" generator \"" & lockfile.generator & "\"\n") result.add(" system_generation \"" & lockfile.systemGeneration & "\"\n") result.add(" architecture \"" & lockfile.architecture & "\"\n") result.add("}\n\n") result.add("metadata {\n") result.add(" description \"" & lockfile.metadata.description & "\"\n") result.add(" environment \"" & lockfile.metadata.environment & "\"\n") result.add(" creator \"" & lockfile.metadata.creator & "\"\n") result.add(" tags") for tag in lockfile.metadata.tags: result.add(" \"" & tag & "\"") result.add("\n") result.add(" total_size " & $lockfile.metadata.totalSize & "\n") result.add(" package_count " & $lockfile.metadata.packageCount & "\n") result.add("}\n\n") for pkg in lockfile.packages: result.add("package \"" & pkg.name & "\" {\n") result.add(" version \"" & pkg.version & "\"\n") result.add(" stream \"" & pkg.stream & "\"\n") result.add(" checksum \"" & pkg.checksum & "\"\n") result.add(" installed_path \"" & pkg.installedPath & "\"\n") result.add(" installed_size " & $pkg.installedSize & "\n") result.add(" install_time \"" & $pkg.installTime & "\"\n") if pkg.dependencies.len > 0: result.add(" dependencies") for dep in pkg.dependencies: result.add(" \"" & dep & "\"") result.add("\n") result.add(" source {\n") result.add(" method \"" & pkg.source.sourceMethod & "\"\n") result.add(" url \"" & pkg.source.url & "\"\n") result.add(" hash \"" & pkg.source.hash & "\"\n") result.add(" timestamp \"" & $pkg.source.timestamp & "\"\n") result.add(" attribution \"" & pkg.source.attribution & "\"\n") result.add(" }\n") result.add("}\n\n") # ============================================================================= # Lockfile Generation and Saving # ============================================================================= proc generateLockfile*(lm: LockfileManager, description: string = "", environment: string = "", creator: string = "", tags: seq[string] = @[]): bool = ## Generate and save a lockfile for the current system state try: echo "šŸ”’ Generating system lockfile..." # Create the lockfile let lockfile = lm.createSystemLockfile(description, environment, creator, tags) echo "šŸ“Š Lockfile statistics:" echo " Packages: ", lockfile.metadata.packageCount echo " Total size: ", lockfile.metadata.totalSize, " bytes" echo " Generation: ", lockfile.systemGeneration echo " Architecture: ", lockfile.architecture # Serialize based on format let content = case lm.format: of LockfileJson: serializeLockfileToJson(lockfile, pretty = true) of LockfileKdl: serializeLockfileToKdl(lockfile) of LockfileYaml: # YAML serialization would be implemented here # For now, fall back to JSON serializeLockfileToJson(lockfile, pretty = true) # Ensure parent directory exists let parentDir = parentDir(lm.lockfilePath) if parentDir.len > 0 and not dirExists(parentDir): createDir(parentDir) # Write the lockfile writeFile(lm.lockfilePath, content) echo "āœ… Lockfile saved to: ", lm.lockfilePath return true except Exception as e: echo "āŒ Failed to generate lockfile: ", e.msg return false proc validateLockfile*(lm: LockfileManager): bool = ## Validate an existing lockfile against current system state try: if not fileExists(lm.lockfilePath): echo "āŒ Lockfile not found: ", lm.lockfilePath return false echo "šŸ” Validating lockfile: ", lm.lockfilePath # Load lockfile let content = readFile(lm.lockfilePath) let lockfileJson = parseJson(content) # Get current system state let currentPackages = lm.scanInstalledPackages() let currentGeneration = lm.getCurrentGeneration() # Validate generation let lockfileGeneration = lockfileJson["lockfile"]["system_generation"].getStr() if lockfileGeneration != currentGeneration: echo "āš ļø Generation mismatch:" echo " Lockfile: ", lockfileGeneration echo " Current: ", currentGeneration # Validate packages let lockfilePackages = lockfileJson["packages"].getElems() var missingPackages: seq[string] = @[] var extraPackages: seq[string] = @[] var versionMismatches: seq[string] = @[] # Create lookup tables var lockfilePackageMap = initTable[string, JsonNode]() for pkg in lockfilePackages: let key = pkg["name"].getStr() & "-" & pkg["version"].getStr() lockfilePackageMap[key] = pkg var currentPackageMap = initTable[string, PackageLockEntry]() for pkg in currentPackages: let key = pkg.name & "-" & pkg.version currentPackageMap[key] = pkg # Check for missing packages for key, lockfilePkg in lockfilePackageMap: if key notin currentPackageMap: missingPackages.add(lockfilePkg["name"].getStr() & "-" & lockfilePkg["version"].getStr()) # Check for extra packages for key, currentPkg in currentPackageMap: if key notin lockfilePackageMap: extraPackages.add(currentPkg.name & "-" & currentPkg.version) # Report validation results if missingPackages.len == 0 and extraPackages.len == 0: echo "āœ… Lockfile validation passed - system matches lockfile exactly" return true else: echo "āŒ Lockfile validation failed:" if missingPackages.len > 0: echo " Missing packages (", missingPackages.len, "):" for pkg in missingPackages: echo " - ", pkg if extraPackages.len > 0: echo " Extra packages (", extraPackages.len, "):" for pkg in extraPackages: echo " + ", pkg return false except Exception as e: echo "āŒ Failed to validate lockfile: ", e.msg return false # ============================================================================= # Lockfile Comparison and Diff # ============================================================================= proc compareLockfiles*(lockfile1Path: string, lockfile2Path: string): bool = ## Compare two lockfiles and show differences try: if not fileExists(lockfile1Path): echo "āŒ Lockfile not found: ", lockfile1Path return false if not fileExists(lockfile2Path): echo "āŒ Lockfile not found: ", lockfile2Path return false echo "šŸ” Comparing lockfiles:" echo " File 1: ", lockfile1Path echo " File 2: ", lockfile2Path let content1 = readFile(lockfile1Path) let content2 = readFile(lockfile2Path) let lockfile1 = parseJson(content1) let lockfile2 = parseJson(content2) # Compare metadata let gen1 = lockfile1["lockfile"]["system_generation"].getStr() let gen2 = lockfile2["lockfile"]["system_generation"].getStr() if gen1 != gen2: echo "šŸ“Š Generation difference:" echo " File 1: ", gen1 echo " File 2: ", gen2 # Compare packages let packages1 = lockfile1["packages"].getElems() let packages2 = lockfile2["packages"].getElems() var packages1Map = initTable[string, JsonNode]() var packages2Map = initTable[string, JsonNode]() for pkg in packages1: let key = pkg["name"].getStr() & "-" & pkg["version"].getStr() packages1Map[key] = pkg for pkg in packages2: let key = pkg["name"].getStr() & "-" & pkg["version"].getStr() packages2Map[key] = pkg var onlyIn1: seq[string] = @[] var onlyIn2: seq[string] = @[] var common: seq[string] = @[] for key in packages1Map.keys: if key in packages2Map: common.add(key) else: onlyIn1.add(key) for key in packages2Map.keys: if key notin packages1Map: onlyIn2.add(key) echo "šŸ“Š Package comparison:" echo " Common packages: ", common.len echo " Only in file 1: ", onlyIn1.len echo " Only in file 2: ", onlyIn2.len if onlyIn1.len > 0: echo " Packages only in ", extractFilename(lockfile1Path), ":" for pkg in onlyIn1: echo " - ", pkg if onlyIn2.len > 0: echo " Packages only in ", extractFilename(lockfile2Path), ":" for pkg in onlyIn2: echo " + ", pkg let identical = onlyIn1.len == 0 and onlyIn2.len == 0 if identical: echo "āœ… Lockfiles are identical" else: echo "āŒ Lockfiles differ" return identical except Exception as e: echo "āŒ Failed to compare lockfiles: ", e.msg return false # ============================================================================= # Lockfile Restoration System # ============================================================================= proc restoreFromLockfile*(lm: LockfileManager, lockfilePath: string, dryRun: bool = false): bool = ## Restore system state from a lockfile try: if not fileExists(lockfilePath): echo "āŒ Lockfile not found: ", lockfilePath return false echo "šŸ”„ Restoring system from lockfile: ", lockfilePath # Load lockfile let content = readFile(lockfilePath) let lockfileJson = parseJson(content) # Get target state let targetGeneration = lockfileJson["lockfile"]["system_generation"].getStr() let targetArchitecture = lockfileJson["lockfile"]["architecture"].getStr() let targetPackages = lockfileJson["packages"].getElems() # Verify architecture compatibility let currentArch = getSystemArchitecture() if targetArchitecture != currentArch: echo "āš ļø Architecture mismatch:" echo " Target: ", targetArchitecture echo " Current: ", cu echo " Proceeding anyway..." echo "šŸ“Š Restoration plan:" echo " Target generation: ", targetGeneration echo " Target packages: ", targetPackages.len # Get current system state let currentPackages = lm.scanInstalledPackages() # Create restoration plan var packagesToInstall: seq[string] = @[] var packagesToRemove: seq[string] = @[] var packagesToUpdate: seq[string] = @[] # Build lookup tables var targetPackageMap = initTable[string, JsonNode]() for pkg in targetPackages: let key = pkg["name"].getStr() targetPackageMap[key] = pkg var currentPackageMap = initTable[string, PackageLockEntry]() for pkg in currentPackages: currentPackageMap[pkg.name] = pkg # Determine required actions for packageName, targetPkg in targetPackageMap: let targetVersion = targetPkg["version"].getStr() if packageName in currentPackageMap: let currentVersion = currentPackageMap[packageName].version if currentVersion != targetVersion: packagesToUpdate.add(packageName & "-" & currentVersion & " → " & targetVersion) else: packagesToInstall.add(packageName & "-" & targetVersion) for packageName, currentPkg in currentPackageMap: if packageName notin targetPackageMap: packagesToRemove.add(packageName & "-" & currentPkg.version) # Display restoration plan ifagesToInstall.len > 0: echo "šŸ“¦ Packages to install (", packagesToInstall.len, "):" for pkg in packagesToInstall: echo " + ", pkg if packagesToUpdate.len > 0: echo "šŸ”„ Packages to update (", packagesToUpdate.len, "):" for pkg in packagesToUpdate: echo " ↗ ", pkg if packagesToRemove.len > 0: echo "šŸ—‘ļø Packages to remove (", packagesToRemove.len, "):" for pkg in packagesToRemove: echo " - ", pkg if packagesToInstall.len == 0 and packagesToUpdate.len == 0 and packagesToRemove.len == 0: echo "āœ… System already matches lockfile - no changes needed" return true if dryRun: echo "šŸ” DRY RUN: Would perform the above changes" return true # TODO: Implement actual package installation/removal/update # This would integrate with the package management system echo "āš ļø Actual package operations not yet implemented" echo " This would require integration with the package installation system" return true except Exception as e: echo "āŒ Failed to restore from lockfile: ", e.msg return false proc showLockfileDrift*(lm: LockfileManager, lockfilePath: string): bool = ## Show drift between current system state and lockfile try: if not fileExists(lockfilePath): echo "āŒ Lockfile not found: ", lockfilePath return false echo "šŸ” Analyzing system drift from lockfile: ", lockfilePath # Load lockfile let content = readFile(lockfilePath) let lockfileJson = parseJson(content) # Get lockfile metadata let lockfileGenerated = lockfileJson["lockfile"]["generated"].getStr() let lockfileGeneration = lockfileJson["lockfile"]["system_generation"].getStr() let lockfilePackages = lockfileJson["packages"].getElems() # Get current system state let currentPackages = lm.scanInstalledPackages() let currentGeneration = lm.getCurrentGeneration() echo "šŸ“Š Drift Analysis:" echo " Lockfile generated: ", lockfileGenerated echo " Lockfile generation: ", lockfileGeneration echo " Current generation: ", currentGeneration # Analyze generation drift if lockfileGeneration != currentGeneration: echo "āš ļø Generation drift detected:" echo " Expected: ", lockfileGeneration echo " Current: ", currentGeneration else: echo "āœ… Generation matches lockfile" # Analyze package drift var lockfilePackageMap = initTable[string, JsonNode]() for pkg in lockfilePackages: let key = pkg["name"].getStr() & "-" & pkg["version"].getStr() lockfilePackageMap[key] = pkg var currentPackageMap = initTable[string, PackageLockEntry]() for pkgentPackages: let key = pkg.name & "-" & pkg.version currentPackageMap[key] = pkg var driftDetected = false var missingPackages: seq[string] = @[] var extraPackages: seq[string] = @[] var modifiedPackages: seq[string] = @[] # Check for missing packages for key, lockfilePkg in lockfilePackageMap: if key notin currentPackageMap: missingPackages.add(key) driftDetected = true # Check for extra packages for key, currentPkg in currentPackageMap: if key notin lockfilePackageMap: extraPackages.add(key) driftDetected = true # Check for modified packages (same name-version but different checksums) for key, lockfilePkg in lockfilePackageMap: if key in currentPackageMap: let lockfileChecksum = lockfilePkg["checksum"].getStr() let currentChecksum = currentPackageMap[key].checksum if lockfileChecksum.len > 0 and currentChecksum.len > 0 and lockfileChecksum != currentChecksum: modifiedPackages.add(key & " (checksum mismatch)") driftDetected = true # Report drift results if not driftDetected: echo "āœ… No package drift detected - system matches lockfile exactly" else: echo "āš ļø Package drift detected:" if missingPackages.len > 0: echo " Missing packages (", missingPackages.len, "):" for pkg in missingPackages: echo " - ", pkg if extraPackages.len > 0: echo " Extra packages (", extraPackages.len, "):" for pkg in extraPackages: echo " + ", pkg if modifiedPackages.len > 0: echo " Modified packages (", modifiedPackages.len, "):" for pkg in modifiedPackages: echo " ~ ", pkg return not driftDetected except Exception as e: echo "āŒ Failed to analyze drift: ", e.msg return false proc mergeLockfiles*(lockfile1Path: string, lockfile2Path: string, outputPath: string, strategy: string = "union"): bool = ## Merge two lockfiles using specified strategy try: if not fileExists(lockfile1Path): echo "āŒ Lockfile 1 not found: ", lockfile1Path return false if not fileExists(lockfile2Path): echo "āŒ Lockfile 2 not found: ", lockfile2Path return false echo "šŸ”„ Merging lockfiles:" echo " Base: ", lockfile1Path echo " Merge: ", lockfile2Path echo " Output: ", outputPath echo " Strategy: ", strategy # Load both lockfiles let content1 = readFile(lockfile1Path) let content2 = readFile(lockfile2Path) let lockfile1 = parseJson(content1) let lockfile2 = parseJson(content2) # Create merged lockfile structure var mergedLockfile = lockfile1.copy() # Update metadata mergedLockfile["lockfile"]["generated"] = %($now()) mergedLockfile["lockfile"]["generator"] = %"nip- p # Get package lists let packages1 = lockfile1["packages"].getElems() let packages2 = lockfile2["packages"].getElems() # Build package maps var packages1Map = initTable[string, JsonNode]() var packages2Map = initTable[string, JsonNode]() for pkg in packages1: let key = pkg["name"].getStr() packages1Map[key] = pkg for pkg in packages2: let key = pkg["name"].getStr() packages2Map[key] = pkg # Merge packages based on strategy var mergedPackages: seq[JsonNode] = @[] case strategy: of "union": # Include all packages from both lockfiles, prefer lockfile2 for conflicts var allPackageNames: seq[string] = @[] for name in packages1Map.keys: allPackageNames.add(name) for name in packages2Map.keys: if name notin allPackageNames: allPackageNames.add(name) for name in allPackageNames: if name in packages2Map: mergedPackages.add(packages2Map[name]) else: mergedPackages.add(packages1Map[name]) of "intersection": # Include only packages present in both lockfiles, prefer lockfile2 versions for name in packages1Map.keys: if name in packages2Map: mergedPackages.add(packages2Map[name]) of "base-only": # Include only packages from lockfile1 mergedPackages = packages1 of "merge-only": # Include only packages from lockfile2 mergedPackages = packages2 else: echo "āŒ Unknown merge strategy: ", strategy return false # Update merged lockfile mergedLockfile["packages"] = %mergedPackages mergedLockfile["metadata"]["package_count"] = %mergedPackages.len mergedLockfile["metadata"]["description"] = %("Merged lockfile using " & strategy & " strategy") # Calculate total size var totalSize: int64 = 0 for pkg in mergedPackages: totalSize += pkg["installed_size"].getInt() mergedLockfile["metadata"]["total_size"] = %totalSize # Write merged lockfile let parentDir = parentDir(outputPath) if parentDir.len > 0 and not dirExists(parentDir): createDir(parentDir) writeFile(outputPath, mergedLockfile.pretty()) echo "āœ… Lockfiles merged successfully" echo " Merged packages: ", mergedPackages.len echo " Total size: ", totalSize, " bytes" return true except Exception as e: echo "āŒ Failed to merge lockfiles: ", e.msg return false proc updateLockfile*(lm: LockfileManager, lockfilePath: string, packageUpdates: seq[string] = @[]): bool = ## Update an existing lockfile with current system state or specific package changes try: if not fileExists(lockfilePath): echo "āŒ Lockfile not found: ", lockfilePath return false echo "šŸ”„ Updating lockfile: ", lockfilePath # Load existing lockfile let content = readFile(lockfilePath) let existingLockfile = parseJson(content) # Get current system state let currentPackages = lm.scanInstalledPackages() let currentGeneration = lm.getCurrentGeneration() # Create updated lockfile var updatedLockfile = existingLroc printLoc() # Update metadata updatedLockfile["lockfile"]["generated"] = %($now()) updatedLockfile["lockfile"]["system_generation"] = %currentGeneration # Update packages if packageUpdates.len == 0: # Full update - replace all packages with current state let newPackages = currentPackages.mapIt(%*{ "name": it.name, "version": it.version, "stream": it.stream, "checksum": it.checksum, "installed_path": it.installedPath, "installed_size": it.installedSize, "install_time": $it.installTime, "dependencies": it.dependencies, "source": { "method": it.source.sourceMethod, "url": it.source.url, "hash": it.source.hash, "timestamp": $it.source.timestamp, "attribution": it.source.attribution } }) updatedLockfile["packages"] = %newPackages updatedLockfile["metadata"]["package_count"] = %newPackages.len let totalSize = currentPackages.mapIt(it.installedSize).foldl(a + b, 0'i64) updatedLockfile["metadata"]["total_size"] = %totalSize echo "āœ… Full lockfile update completed" echo " Updated packages: ", newPackages.len echo " Total size: ", totalSize, " bytes" else: # Selective update - update only specified packages var existingPackages = existingLockfile["packages"].getElems() var updatedCount = 0 # Build current package lookup var currentPackageMap = initTable[string, PackageLockEntry]() for pkg in currentPackages: currentPackageMap[pkg.name] = pkg # Update specified packages for i, pkg in existingPackages.mpairs: let packageName = pkg["name"].getStr() if packageName in packageUpdates and packageName in currentPackageMap: let currentPkg = currentPackageMap[packageName] # Update package information pkg["version"] = %currentPkg.version pkg["stream"] = %currentPkg.stream pkg["checksum"] = %currentPkg.checksum pkg["installed_size"] = %currentPkg.installedSize pkg["install_time"] = %($currentPkg.installTime) pkg["dependencies"] = %currentPkg.dependencies # Update source information pkg["source"]["method"] = %currentPkg.source.sourceMethod pkg["source"]["url"] = %currentPkg.source.url pkg["source"]["hash"] = %currentPkg.source.hash pkg["source"]["timestamp"] = %($currentPkg.source.timestamp) pkg["source"]["attribution"] = %currentPkg.source.attribution updatedCount += 1 updatedLockfile["packages"] = %existingPackages echo "āœ… Selective lockfile update completed" echo " Updated packages: ", updatedCount, "/", packageUpdates.len # Write updated lockfile writeFile(lockfilePath, updatedLockfile.pretty()) return true except Exception as e: echo "āŒ Failed to update lockfilkfi", e.msg return false # ============================================================================= # Advanced Diff Functionality # ============================================================================= proc detailedLockfileDiff*(lockfile1Path: string, lockfile2Path: string): bool = ## Show detailed differences between two lockfiles try: if not fileExists(lockfile1Path): echo "āŒ Lockfile 1 not found: ", lockfile1Path return false if not fileExists(lockfile2Path): echo "āŒ Lockfile 2 not found: ", lockfile2Path return false echo "šŸ” Detailed lockfile comparison:" echo " File 1: ", lockfile1Path echo " File 2: ", lockfile2Path let content1 = readFile(lockfile1Path) let content2 = readFile(lockfile2Path) let lockfile1 = parseJson(content1) let lockfile2 = parseJson(content2) # Compare metadata echo "\nšŸ“Š Metadata Comparison:" let gen1 = lockfile1["lockfile"]["system_generation"].getStr() let gen2 = lockfile2["lockfile"]["system_generation"].getStr() let arch1 = lockfile1["lockfile"]["architecture"].getStr() let arch2 = lockfile2["lockfile"]["architecture"].getStr() let generated1 = lockfile1["lockfile"]["generated"].getStr() let generated2 = lockfile2["lockfile"]["generated"].getStr() if gen1 != gen2: echo " Generation: ", gen1, " → ", gen2 else: echo " Generation: ", gen1, " (same)" if arch1 != arch2: echo " Architecture: ", arch1, " → ", arch2 else: echo " Architecture: ", arch1, " (same)" echo " Generated: ", generated1, " → ", generated2 # Compare packages in detail echo "\nšŸ“¦ Package Comparison:" let packages1 = lockfile1["packages"].getElems() let packages2 = lockfile2["packages"].getElems() var packages1Map = initTable[string, JsonNode]() var packages2Map = initTable[string, JsonNode]() for pkg in packages1: let key = pkg["name"].getStr() packages1Map[key] = pkg for pkg in packages2: let key = pkg["name"].getStr() packages2Map[key] = pkg # Analyze changes var added: seq[string] = @[] var removed: seq[string] = @[] var modified: seq[string] = @[] var unchanged: seq[string] = @[] # Find added packages for name in packages2Map.keys: if nale notin packages1Map: let pkg = packages2Map[name] added.add(name & "-" & pkg["version"].getStr()) # Find removed packages for name in packages1Map.keys: if name notin packages2Map: let pkg = packages1Map[name] removed.add(name & "-" & pkg["version"].getStr()) # Find modified and unchanged packages for name in packages1Map.keys: if name in packages2Map: let pkg1 = packages1Map[name] let pkg2 = packages2Map[name] let version1 = pkg1["version"].getStr() let version2 = pkg2["version"].getStr() let checksum1 = pkg1["checksum"].getStr() let checksum2 = pkg2["checksum"].getStr() let stream1 = pkg1["stream"].getStr() let stream2 = pkg2["stream"].getStr() if version1 != version2 or checksum1 != checksum2 or stream1 != stream2: var changes: seq[string] = @[] if version1 != version2: changes.add("version: " & version1 & " → " & version2) if stream1 != stream2: changes.add("stream: " & stream1 & " → " & stream2) if checksum1 != checksum2: changes.add("checksum: " & checksum1[0..7] & "... → " & checksum2[0..7] & "...") modified.add(name & " (" & changes.join(", ") & ")") else: unchanged.add(name & "-" & version1) # Display results echo " Summary:" echo " Added: ", added.len echo " Removed: ", removed.len echo " Modified: ", modified.len echo " Unchanged: ", unchanged.len if added.len > 0: echo "\n āž• Added packages:" for pkg in added: echo " + ", pkg if removed.len > 0: echo "\n āž– Removed packages:" for pkg in removed: echo " - ", pkg if modified.len > 0: echo "\n šŸ”„ Modified packages:" for pkg in modified: echo " ~ ", pkg let identical = added.len == 0 and removed.len == 0 and modified.len == 0 if identical: echo "\nāœ… Lockfiles are functionally identical" else: echo "\nāŒ Lockfiles differ significantly" return identical except Exception as e: echo "āŒ Failed to compare lockfiles: ", e.msg return false # ============================================================================= # CLI Integration Functions # =============================================================================Info*(lockfilePath: string) = ## Print information about a lockfile try: if not fileExists(lockfilePath): echo "āŒ Lockfile not found: ", lockfilePath return let content = readFile(lockfilePath) let lockfile = parseJson(content) echo "=== Lockfile Information ===" echo "File: ", lockfilePath echo "Version: ", lockfile["lockfile"]["version"].getStr() echo "Generated: ", lockfile["lockfile"]["generated"].getStr() echo "Generator: ", lockfile["lockfile"]["generator"].getStr() echo "System Generation: ", lockfile["lockfile"]["system_generation"].getStr() echo "Architecture: ", lockfile["lockfile"]["architecture"].getStr() echo "\n=== Metadata ===" let metadata = lockfile["metadata"] echo "Description: ", metadata["description"].getStr() echo "Environment: ", metadata["environment"].getStr() echo "Creator: ", metadata["creator"].getStr() echo "Tags: ", metadata["tags"].getElems().mapIt(it.getStr()).join(", ") echo "Total Size: ", metadata["total_size"].getInt(), " bytes" echo "Package Count: ", metadata["package_count"].getInt() echo "\n=== Packages ===" let packages = lockfile["packages"].getElems() for pkg in packages: let name = pkg["name"].getStr() let version = pkg["version"].getStr() let stream = pkg["stream"].getStr() let size = pkg["installed_size"].getInt() echo " ", name, "-", version, " (", stream, ") - ", size, " bytes" except Exception as e: echo "āŒ Failed to read lockfile: ", e.msg proc generateLockfileCommand*(lockfilePath: string = "nip.lock", format: string = "json", description: string = "", environment: string = "", creator: string = "", tags: seq[string] = @[]): bool = ## CLI command to generate a lockfile let lockfileFormat = case format.toLowerAscii(): of "json": LockfileJson of "kdl": LockfileKdl of "yaml": LockfileYaml else: LockfileJson let lm = newLockfileManager( lockfilePath = lockfilePath, format = lockfileFormat ) return lm.generateLockfile(description, environment, creator, tags)proc r estoreLockfileCommand*(lockfilePath: string = "nip.lock", dryRun: bool = false): bool = ## CLI command to restore from a lockfile let lm = newLockfileManager() return lm.restoreFromLockfile(lockfilePath, dryRun) proc validateLockfileCommand*(lockfilePath: string = "nip.lock"): bool = ## CLI command to validate a lockfile let lm = newLockfileManager(lockfilePath = lockfilePath) return lm.validateLockfile() proc diffLockfileCommand*(lockfile1Path: string, lockfile2Path: string, detailed: bool = false): bool = ## CLI command to compare two lockfiles if detailed: return detailedLockfileDiff(lockfile1Path, lockfile2Path) else: return compareLockfiles(lockfile1Path, lockfile2Path) proc driftLockfileCommand*(lockfilePath: string = "nip.lock"): bool = ## CLI command to show system drift from lockfile let lm = newLockfileManager() return lm.showLockfileDrift(lockfilePath) proc mergeLockfileCommand*(lockfile1Path: string, lockfile2Path: string, outputPath: string, strategy: string = "union"): bool = ## CLI command to merge two lockfiles return mergeLockfiles(lockfile1Path, lockfile2Path, outputPath, strategy) proc updateLockfileCommand*(lockfilePath: string = "nip.lock", packages: seq[string] = @[]): bool = ## CLI command to update an existing lockfile let lm = newLockfileManager() return lm.updateLockfile(lockfilePath, packages)