Merge pull request 'Add Check-Deps pre-flight (mods.toml ground truth)' (#9) from feature/check-deps-preflight into main
This commit was merged in pull request #9.
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
#requires -Version 7
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Audit pack.lock.json for missing required mod dependencies by reading
|
||||
each jar's mods.toml (and any jar-in-jar bundles) directly. Loader-truth,
|
||||
not Modrinth-truth.
|
||||
|
||||
.DESCRIPTION
|
||||
Each NeoForge/Forge mod declares its real dependencies in
|
||||
META-INF/neoforge.mods.toml (or META-INF/mods.toml) inside the jar.
|
||||
This is what the loader actually checks at startup; the Modrinth
|
||||
dependency field is metadata authors fill in (or don't).
|
||||
|
||||
The script also recurses into META-INF/jarjar/ bundles. Big mods like
|
||||
Create ship their deps (Registrate, Flywheel, Ponder) embedded as
|
||||
jar-in-jar -- the loader auto-loads them, so their modIds count as
|
||||
"present" without explicit lockfile entries.
|
||||
|
||||
Slug filter: only entries under "mods/" are treated as mod jars.
|
||||
Shaderpacks and other managed-but-non-mod files are skipped.
|
||||
|
||||
Language-provider jars (e.g. Kotlin for Forge) ship without a standard
|
||||
mods.toml; their modIds are added via a tiny override map.
|
||||
|
||||
Jars are cached in /tmp/bns-check-deps-cache/<sha1>.jar so subsequent
|
||||
runs are instant.
|
||||
|
||||
.PARAMETER LockPath
|
||||
.PARAMETER CacheDir
|
||||
.PARAMETER WarnOnly
|
||||
Print missing deps but exit 0. Deploy-Brass.ps1 calls without it.
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$LockPath = $(Join-Path (Split-Path -Parent $MyInvocation.MyCommand.Path) '..\pack\pack.lock.json'),
|
||||
[string]$CacheDir = '/tmp/bns-check-deps-cache',
|
||||
[switch]$WarnOnly
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
|
||||
$LockPath = (Resolve-Path $LockPath).Path
|
||||
$lock = Get-Content $LockPath -Raw | ConvertFrom-Json -Depth 20
|
||||
if (-not $lock.mods) { throw "pack.lock.json has no 'mods' array." }
|
||||
if (-not (Test-Path $CacheDir)) { New-Item -ItemType Directory -Path $CacheDir | Out-Null }
|
||||
|
||||
# Vanilla / loader IDs are always satisfied -- they're never modpack mods.
|
||||
$vanillaIds = @('minecraft','neoforge','forge','fabric','fabricloader','quilt','java','javafml','lowcodefml')
|
||||
|
||||
# Language-provider / library jars that ship without a standard mods.toml.
|
||||
# Map their lockfile slug to the modId they register at runtime. Add entries
|
||||
# here as new ones are encountered.
|
||||
$slugToImplicitModId = @{
|
||||
'kotlin-for-forge' = 'kotlinforforge'
|
||||
}
|
||||
|
||||
Add-Type -AssemblyName System.IO.Compression
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
|
||||
function Get-JarPath {
|
||||
param($mod)
|
||||
$cached = Join-Path $CacheDir ("$($mod.sha1).jar")
|
||||
if (Test-Path $cached) { return $cached }
|
||||
Write-Host " downloading $($mod.slug) ..." -ForegroundColor DarkGray
|
||||
try { Invoke-WebRequest -Uri $mod.url -OutFile $cached -TimeoutSec 60 }
|
||||
catch { throw "Failed to download $($mod.url): $($_.Exception.Message)" }
|
||||
return $cached
|
||||
}
|
||||
|
||||
# Read a specific entry's bytes from an already-open ZipArchive into memory.
|
||||
function Read-ZipEntry {
|
||||
param([System.IO.Compression.ZipArchive]$Zip, [string]$EntryName)
|
||||
$entry = $Zip.GetEntry($EntryName)
|
||||
if (-not $entry) { return $null }
|
||||
$ms = New-Object System.IO.MemoryStream
|
||||
$s = $entry.Open()
|
||||
try { $s.CopyTo($ms) } finally { $s.Dispose() }
|
||||
return $ms.ToArray()
|
||||
}
|
||||
|
||||
function Read-ZipEntryText {
|
||||
param([System.IO.Compression.ZipArchive]$Zip, [string]$EntryName)
|
||||
$entry = $Zip.GetEntry($EntryName)
|
||||
if (-not $entry) { return $null }
|
||||
$reader = New-Object System.IO.StreamReader($entry.Open())
|
||||
try { return $reader.ReadToEnd() }
|
||||
finally { $reader.Dispose() }
|
||||
}
|
||||
|
||||
# Tiny TOML reader -- only the bits we need (sections + key="value" lines).
|
||||
function Parse-ModsToml {
|
||||
param([string]$Text)
|
||||
$modIds = @()
|
||||
$deps = @()
|
||||
$section = $null
|
||||
$current = $null
|
||||
function Flush-Record {
|
||||
param($refModIds, $refDeps, $sec, $cur)
|
||||
if (-not $cur) { return }
|
||||
if ($sec -eq 'mods' -and $cur.modId) {
|
||||
$refModIds.Value += $cur.modId
|
||||
} elseif ($sec -like 'dep:*' -and $cur.modId) {
|
||||
$refDeps.Value += [pscustomobject]@{
|
||||
parent = $sec.Substring(4)
|
||||
modId = $cur.modId
|
||||
type = $cur.type
|
||||
mandatory = $cur.mandatory
|
||||
versionRange = $cur.versionRange
|
||||
}
|
||||
}
|
||||
}
|
||||
$modIdsRef = [ref]$modIds
|
||||
$depsRef = [ref]$deps
|
||||
foreach ($raw in ($Text -split "`n")) {
|
||||
$line = $raw.Trim()
|
||||
if (-not $line -or $line.StartsWith('#')) { continue }
|
||||
if ($line.StartsWith('[[')) {
|
||||
Flush-Record $modIdsRef $depsRef $section $current
|
||||
$current = $null
|
||||
if ($line -match '^\[\[mods\]\]') {
|
||||
$section = 'mods'; $current = @{}
|
||||
} elseif ($line -match '^\[\[dependencies\.([^\]]+)\]\]') {
|
||||
$section = "dep:$($Matches[1])"; $current = @{}
|
||||
} else {
|
||||
$section = $null
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (-not $current) { continue }
|
||||
if ($line -match '^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+?)\s*$') {
|
||||
$k = $Matches[1]; $v = $Matches[2].Trim()
|
||||
if ($v.StartsWith('"') -and $v.EndsWith('"')) { $v = $v.Substring(1, $v.Length - 2) }
|
||||
$current[$k] = $v
|
||||
}
|
||||
}
|
||||
Flush-Record $modIdsRef $depsRef $section $current
|
||||
return [pscustomobject]@{ ModIds = $modIdsRef.Value; Deps = $depsRef.Value }
|
||||
}
|
||||
|
||||
function Is-Required {
|
||||
param($dep)
|
||||
if ($dep.type) { return ($dep.type -eq 'required' -or $dep.type -eq 'mandatory') }
|
||||
if ($dep.mandatory) { return ($dep.mandatory -eq 'true') }
|
||||
return $true # NeoForge default
|
||||
}
|
||||
|
||||
# Recursively walk a jar (and its META-INF/jarjar/ embedded jars) collecting
|
||||
# every modId declared by mods.toml inside. Used to satisfy deps that ship
|
||||
# bundled (e.g. ponder/registrate/flywheel inside Create).
|
||||
function Collect-Jar-Identity {
|
||||
param([string]$JarPath)
|
||||
$modIds = @()
|
||||
$deps = @()
|
||||
$zip = [System.IO.Compression.ZipFile]::OpenRead($JarPath)
|
||||
try {
|
||||
# 1) This jar's own mods.toml (if any).
|
||||
$toml = Read-ZipEntryText -Zip $zip -EntryName 'META-INF/neoforge.mods.toml'
|
||||
if (-not $toml) { $toml = Read-ZipEntryText -Zip $zip -EntryName 'META-INF/mods.toml' }
|
||||
if ($toml) {
|
||||
$parsed = Parse-ModsToml -Text $toml
|
||||
$modIds += $parsed.ModIds
|
||||
$deps += $parsed.Deps
|
||||
}
|
||||
# 2) jar-in-jar bundles -- recursively extract + collect modIds (we only
|
||||
# need their modIds; their deps are the parent's problem to satisfy).
|
||||
$jarjarEntries = $zip.Entries | Where-Object {
|
||||
$_.FullName -like 'META-INF/jarjar/*.jar'
|
||||
}
|
||||
foreach ($e in $jarjarEntries) {
|
||||
$tmpInner = [System.IO.Path]::GetTempFileName() + '.jar'
|
||||
$s = $e.Open()
|
||||
try {
|
||||
$fs = [System.IO.File]::Open($tmpInner, [System.IO.FileMode]::Create)
|
||||
try { $s.CopyTo($fs) } finally { $fs.Dispose() }
|
||||
} finally { $s.Dispose() }
|
||||
$inner = Collect-Jar-Identity -JarPath $tmpInner
|
||||
$modIds += $inner.ModIds # bundled deps count as present
|
||||
# Don't propagate bundled deps -- they're satisfied within this jar
|
||||
Remove-Item $tmpInner -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
} finally {
|
||||
$zip.Dispose()
|
||||
}
|
||||
return [pscustomobject]@{ ModIds = ($modIds | Select-Object -Unique); Deps = $deps }
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Auditing $($lock.mods.Count) lockfile entries (parsing each jar's mods.toml + jar-in-jar bundles)..."
|
||||
Write-Host ""
|
||||
|
||||
$presentModIds = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
|
||||
foreach ($v in $vanillaIds) { [void]$presentModIds.Add($v) }
|
||||
|
||||
# parent modId -> list of dep records (so the missing report blames a modId, not a slug)
|
||||
$jarDepsByParentModId = @{}
|
||||
$skipped = @() # informational
|
||||
|
||||
$count = 0
|
||||
foreach ($m in $lock.mods) {
|
||||
$count++
|
||||
# Only consider entries that live under mods/ -- shaderpacks, configs etc.
|
||||
# are managed by the launcher but aren't mods.
|
||||
if (-not ($m.path -like 'mods/*')) {
|
||||
$skipped += "$($m.slug): not under mods/ ($($m.path))"
|
||||
continue
|
||||
}
|
||||
# Language-provider override (kotlin-for-forge etc.)
|
||||
if ($slugToImplicitModId.ContainsKey($m.slug)) {
|
||||
[void]$presentModIds.Add($slugToImplicitModId[$m.slug])
|
||||
}
|
||||
try {
|
||||
$jar = Get-JarPath -mod $m
|
||||
$ident = Collect-Jar-Identity -JarPath $jar
|
||||
foreach ($id in $ident.ModIds) {
|
||||
[void]$presentModIds.Add($id)
|
||||
}
|
||||
foreach ($id in $ident.ModIds) {
|
||||
$jarDepsByParentModId[$id] = $ident.Deps
|
||||
}
|
||||
if (-not $ident.ModIds -and -not $slugToImplicitModId.ContainsKey($m.slug)) {
|
||||
$skipped += "$($m.slug): no modId discovered (no mods.toml; consider adding to override map)"
|
||||
}
|
||||
} catch {
|
||||
$skipped += "$($m.slug): $($_.Exception.Message)"
|
||||
}
|
||||
if ($count % 10 -eq 0) { Write-Host " ...processed $count/$($lock.mods.Count)" -ForegroundColor DarkGray }
|
||||
}
|
||||
|
||||
# Pass 2: check each parent's required deps against the present set.
|
||||
$missing = @{}
|
||||
foreach ($parentId in $jarDepsByParentModId.Keys) {
|
||||
foreach ($dep in $jarDepsByParentModId[$parentId]) {
|
||||
if (-not (Is-Required $dep)) { continue }
|
||||
if (-not $presentModIds.Contains($dep.modId)) {
|
||||
if (-not $missing.ContainsKey($dep.modId)) {
|
||||
$missing[$dep.modId] = @{ parents = @() }
|
||||
}
|
||||
$missing[$dep.modId].parents += $parentId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host ("=" * 60)
|
||||
if ($missing.Count -eq 0) {
|
||||
Write-Host "All required deps satisfied. ($($presentModIds.Count) mod IDs in pack, incl. bundled jar-in-jar)" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "MISSING REQUIRED DEPS: $($missing.Count) unique" -ForegroundColor Red
|
||||
Write-Host ""
|
||||
foreach ($depId in ($missing.Keys | Sort-Object)) {
|
||||
$parents = ($missing[$depId].parents | Sort-Object -Unique) -join ', '
|
||||
Write-Host " $depId" -ForegroundColor Red
|
||||
Write-Host " needed by: $parents" -ForegroundColor DarkGray
|
||||
}
|
||||
}
|
||||
|
||||
if ($skipped.Count -gt 0) {
|
||||
Write-Host ""
|
||||
Write-Host "Informational (skipped / unanalysable):" -ForegroundColor DarkGray
|
||||
foreach ($s in $skipped) { Write-Host " - $s" -ForegroundColor DarkGray }
|
||||
}
|
||||
|
||||
if ($missing.Count -gt 0 -and -not $WarnOnly) {
|
||||
throw "Missing required dependencies. Add them to pack/pack.lock.json (or pass -WarnOnly to bypass)."
|
||||
}
|
||||
@@ -118,6 +118,17 @@ if (($shouldRunPack -or $shouldRunLauncher) -and -not $DryRun -and -not $Force)
|
||||
}
|
||||
}
|
||||
|
||||
# ─── Pre-flight: required-dependency check ─────────────────────────────────
|
||||
# Walks each Modrinth mod in pack.lock.json, fetches its 'required'
|
||||
# dependencies, and refuses to deploy if any aren't already in the lockfile.
|
||||
# Catches the failure mode where adding a mod that hard-deps on (say)
|
||||
# kotlinforforge crash-loops the daemon at startup. Only runs when pack
|
||||
# content is changing; -Force skips it (rarely needed).
|
||||
if ($shouldRunPack -and -not $DryRun -and -not $Force) {
|
||||
Write-Host "Pre-flight: validating Modrinth dependency graph..." -ForegroundColor DarkGray
|
||||
& (Join-Path $here 'Check-Deps.ps1')
|
||||
}
|
||||
|
||||
# ─── Pre-flight: was the daemon already running? ───────────────────────────
|
||||
# We don't auto-stop it (kicks active players to fix a problem that isn't
|
||||
# actually broken -- the atomic swap is safe with the daemon running). But
|
||||
|
||||
Reference in New Issue
Block a user