nip/src/nimpak/lockfile_system.nim

1223 lines
42 KiB
Nim
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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