diff --git a/scripts/Check-Deps.ps1 b/scripts/Check-Deps.ps1 new file mode 100644 index 0000000..ac98f1e --- /dev/null +++ b/scripts/Check-Deps.ps1 @@ -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/.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)." +} diff --git a/scripts/Deploy-Brass.ps1 b/scripts/Deploy-Brass.ps1 index eab37c3..5eb247e 100644 --- a/scripts/Deploy-Brass.ps1 +++ b/scripts/Deploy-Brass.ps1 @@ -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