Initial commit: Brass & Sigil monorepo

Self-hosted Minecraft modpack distribution + administration system.

- launcher/  Avalonia 12 desktop client; single-file win-x64 publish.
             Microsoft auth via XboxAuthNet, manifest+SHA-1 mod sync,
             portable install path, sidecar settings.
- server/    brass-sigil-server daemon (.NET 8, linux-x64). Wraps the
             MC subprocess, embedded Kestrel admin panel with cookie
             auth + rate limiting, RCON bridge, scheduled backups,
             BlueMap CLI integration with player markers + skin proxy,
             friend-side whitelist request flow, world wipe with seed
             selection (keep current / random / custom).
- pack/      pack.lock.json (Modrinth + manual CurseForge entries),
             data-only tweak source under tweaks/, build outputs in
             overrides/ (gitignored).
- scripts/   Build-Pack / Build-Tweaks / Update-Pack / Check-Updates
             plus Deploy-Brass.ps1 unified one-shot deploy with
             version-bump pre-flight and daemon-state detection.
This commit is contained in:
Matt Sijbers
2026-05-05 00:19:05 +01:00
commit a1331212cb
99 changed files with 12640 additions and 0 deletions
+183
View File
@@ -0,0 +1,183 @@
#requires -Version 5
<#
.SYNOPSIS
Generates manifest.json deterministically from pack.lock.json.
.DESCRIPTION
The lockfile (pack.lock.json) is the source of truth for every mod's exact
version, URL, SHA-1, and size. Running this script does not change versions.
To intentionally bump versions, use Update-Pack.ps1 with the -Refresh flag.
To see what new versions are available, use Check-Updates.ps1.
Workflow:
1. Edit pack.lock.json (manually or via Update-Pack.ps1 -Refresh)
2. Run Build-Pack.ps1 -OutputPath ...\manifest.json
3. Update the server to match
4. Deploy manifest to your hosted URL
.PARAMETER OutputPath
Where to write manifest.json.
.PARAMETER LocalPackSource
Optional folder containing local override files (configs, resourcepacks, etc.).
.PARAMETER SelfHostBaseUrl
Public URL prefix for self-hosted files (only used when LocalPackSource is set).
#>
[CmdletBinding()]
param(
[string]$OutputPath = ".\manifest.json",
[string]$LocalPackSource = "",
[string]$SelfHostBaseUrl = "https://sijbers.uk/pack/files",
# Optional -- point at a published launcher .exe to embed launcherVersion + launcherUrl
# in the manifest. The launcher displays a "newer version available" banner when
# its embedded version is lower than this. Skip entirely to leave both fields out.
[string]$LauncherExePath = "",
[string]$LauncherPublicUrl = "https://sijbers.uk/pack/BrassAndSigil-Launcher.exe",
# Skip the auto-invoke of Build-Tweaks.ps1 (only touch this if you're debugging).
[switch]$SkipTweaks
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$lockPath = Join-Path $here '..\pack\pack.lock.json'
# Build any data-only tweak jars first, then fold the resulting overrides
# folder into the manifest. Set -SkipTweaks to bypass. The "overrides" name
# matches CurseForge/Modrinth modpack conventions: files that override or
# augment the standard mod set, hosted by us.
$tweaksRoot = Join-Path $here '..\pack\tweaks'
$overridesRoot = Join-Path $here '..\pack\overrides'
if (-not $SkipTweaks -and (Test-Path $tweaksRoot)) {
& (Join-Path $here 'Build-Tweaks.ps1') -TweaksRoot $tweaksRoot -OutputRoot (Join-Path $overridesRoot 'mods')
}
if (-not $LocalPackSource -and (Test-Path $overridesRoot)) {
$LocalPackSource = $overridesRoot
}
if (-not (Test-Path $lockPath)) {
throw "pack.lock.json not found at $lockPath. Run Update-Pack.ps1 -Refresh first to bootstrap it."
}
$lock = Get-Content $lockPath -Raw | ConvertFrom-Json
Write-Host ""
Write-Host "Building manifest from pack.lock.json:"
Write-Host (" Pack: {0} v{1}" -f $lock.name, $lock.version)
Write-Host (" MC: {0} ({1} {2})" -f $lock.minecraft, $lock.loader.type, $lock.loader.version)
Write-Host (" Locked: {0}" -f $lock.lockedAt)
Write-Host (" Mods: {0}" -f $lock.mods.Count)
Write-Host ""
$files = @()
$totalBytes = 0L
foreach ($mod in $lock.mods) {
$files += [pscustomobject]@{
path = $mod.path
url = $mod.url
sha1 = $mod.sha1
size = $mod.size
}
$totalBytes += $mod.size
Write-Host (" [{0}] {1,-26} {2,-22} {3,8:N0} KB" -f $mod.source.PadRight(7).Substring(0,7), $mod.slug, $mod.version, ($mod.size/1KB))
}
# Local overrides (configs, custom files not on Modrinth/CurseForge)
if ($LocalPackSource -and (Test-Path $LocalPackSource)) {
Write-Host ""
Write-Host "Adding local overrides from $LocalPackSource..."
$managedRoots = @('mods', 'config', 'resourcepacks', 'shaderpacks', 'kubejs', 'defaultconfigs')
$base = $SelfHostBaseUrl.TrimEnd('/')
$sourceFull = (Resolve-Path $LocalPackSource).Path.TrimEnd('\','/')
foreach ($root in $managedRoots) {
$rootDir = Join-Path $LocalPackSource $root
if (-not (Test-Path $rootDir)) { continue }
Get-ChildItem -Path $rootDir -Recurse -File | ForEach-Object {
$rel = $_.FullName.Substring($sourceFull.Length).TrimStart('\','/') -replace '\\','/'
$sha1 = (Get-FileHash -Algorithm SHA1 -Path $_.FullName).Hash.ToLowerInvariant()
$files += [pscustomobject]@{
path = $rel
url = "$base/$rel"
sha1 = $sha1
size = $_.Length
}
$totalBytes += $_.Length
Write-Host (" [local] {0}" -f $rel)
}
}
}
$manifest = [ordered]@{
name = $lock.name
version = $lock.version
minecraft = [ordered]@{ version = $lock.minecraft }
loader = [ordered]@{ type = $lock.loader.type; version = $lock.loader.version }
files = $files
}
# Optional: copy a defaultServer block through from the lockfile so the launcher
# can pre-populate friends' multiplayer list. Lockfile schema:
# "defaultServer": { "name": "Brass & Sigil", "ip": "bns.sijbers.uk" }
if ($lock.PSObject.Properties.Name -contains "defaultServer" -and $lock.defaultServer) {
$manifest.defaultServer = [ordered]@{
name = $lock.defaultServer.name
ip = $lock.defaultServer.ip
}
Write-Host (" Default server: {0} ({1})" -f $lock.defaultServer.name, $lock.defaultServer.ip)
}
# Optional: shader pack to enable by default on fresh installs. Lockfile schema:
# "defaultShader": "ComplementaryReimagined_r5.7.1.zip"
if ($lock.PSObject.Properties.Name -contains "defaultShader" -and $lock.defaultShader) {
$manifest.defaultShader = $lock.defaultShader
Write-Host (" Default shader: {0}" -f $lock.defaultShader)
}
# Optional: public URL of the brass-sigil-server panel. Used by the launcher
# to send friend-side whitelist requests. Lockfile schema:
# "panelUrl": "https://bns-admin.sijbers.uk"
if ($lock.PSObject.Properties.Name -contains "panelUrl" -and $lock.panelUrl) {
$manifest.panelUrl = $lock.panelUrl
Write-Host (" Panel URL: {0}" -f $lock.panelUrl)
}
# Optional launcher metadata: when -LauncherExePath is supplied, read the exe's
# FileVersion (set via <Version> in ModpackLauncher.csproj) and embed it. The
# launcher compares its assembly version to this value and shows an upgrade
# banner pointing at LauncherPublicUrl when older.
if ($LauncherExePath) {
if (-not (Test-Path $LauncherExePath)) {
throw "LauncherExePath '$LauncherExePath' does not exist."
}
$launcherFile = Get-Item $LauncherExePath
$launcherVersion = $launcherFile.VersionInfo.FileVersion
if ([string]::IsNullOrWhiteSpace($launcherVersion)) {
throw "Launcher exe has no FileVersion -- set <Version> in ModpackLauncher.csproj and republish."
}
# FileVersion is the four-component form (e.g. "0.1.0.0" for csproj <Version>0.1.0</Version>).
# Embed as-is -- the launcher's Version.Parse handles it directly and avoids ambiguous
# comparisons between trimmed and untrimmed forms.
$manifest.launcherVersion = $launcherVersion
$manifest.launcherUrl = $LauncherPublicUrl
Write-Host ""
Write-Host ("Launcher metadata embedded:")
Write-Host (" Version: {0}" -f $launcherVersion)
Write-Host (" Url: {0}" -f $LauncherPublicUrl)
Write-Host (" Source: {0}" -f $launcherFile.FullName)
}
$json = $manifest | ConvertTo-Json -Depth 10
$outDir = Split-Path -Parent $OutputPath
if ($outDir -and -not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir | Out-Null }
[System.IO.File]::WriteAllText($OutputPath, $json, [System.Text.UTF8Encoding]::new($false))
Write-Host ""
Write-Host "Manifest written: $OutputPath"
Write-Host (" Files: {0}" -f $files.Count)
Write-Host (" Total: {0:N1} MB (download size)" -f ($totalBytes / 1MB))
+115
View File
@@ -0,0 +1,115 @@
#requires -Version 5
<#
.SYNOPSIS
Builds every tweak source folder under ..\pack\tweaks\ into a data-only
mod jar and drops it into ..\pack\overrides\mods\.
.DESCRIPTION
Each subfolder of ..\pack\tweaks\ is treated as one self-contained
"data-only" NeoForge mod. Its modId, version, and display name come from
the folder's META-INF\neoforge.mods.toml. The script:
1. Reads modId + version from neoforge.mods.toml.
2. Zips the folder contents (data\, META-INF\, pack.mcmeta) into a jar
named "<modId>-<version>.jar".
3. Removes any older jars for the same modId from the output folder so
stale versions don't get bundled into the manifest.
Run this before Build-Pack.ps1, or let Build-Pack.ps1 invoke it for you.
.PARAMETER TweaksRoot
Folder containing tweak source subfolders. Defaults to ..\pack\tweaks\.
.PARAMETER OutputRoot
Where built jars go. Defaults to ..\pack\overrides\mods\. This path
becomes Build-Pack.ps1's -LocalPackSource (one level up).
#>
[CmdletBinding()]
param(
[string]$TweaksRoot = "",
[string]$OutputRoot = ""
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
if (-not $TweaksRoot) { $TweaksRoot = Join-Path $here '..\pack\tweaks' }
if (-not $OutputRoot) { $OutputRoot = Join-Path $here '..\pack\overrides\mods' }
if (-not (Test-Path $TweaksRoot)) {
Write-Host "No tweaks folder at $TweaksRoot -- nothing to build."
return
}
if (-not (Test-Path $OutputRoot)) {
New-Item -ItemType Directory -Path $OutputRoot -Force | Out-Null
}
# Read modId + version out of a neoforge.mods.toml. We do this with a tiny regex
# parser instead of a full TOML library because we only need two scalars and we
# control the file format. Lines like: modId = "foo" version = "1.2.3"
function Get-ModMeta {
param([string]$TomlPath)
if (-not (Test-Path $TomlPath)) {
throw "Missing $TomlPath -- every tweak folder needs META-INF\neoforge.mods.toml."
}
$content = Get-Content $TomlPath -Raw
$modIdMatch = [regex]::Match($content, '(?m)^\s*modId\s*=\s*"([^"]+)"')
$versionMatch = [regex]::Match($content, '(?m)^\s*version\s*=\s*"([^"]+)"')
if (-not $modIdMatch.Success) { throw "Could not parse modId from $TomlPath" }
if (-not $versionMatch.Success) { throw "Could not parse version from $TomlPath" }
return [pscustomobject]@{
ModId = $modIdMatch.Groups[1].Value
Version = $versionMatch.Groups[1].Value
}
}
Add-Type -AssemblyName System.IO.Compression.FileSystem
$tweakDirs = Get-ChildItem -Path $TweaksRoot -Directory
if ($tweakDirs.Count -eq 0) {
Write-Host "No tweak folders found under $TweaksRoot."
return
}
Write-Host ""
Write-Host "Building tweak jars from $TweaksRoot"
Write-Host ""
foreach ($dir in $tweakDirs) {
$tomlPath = Join-Path $dir.FullName 'META-INF\neoforge.mods.toml'
$meta = Get-ModMeta -TomlPath $tomlPath
$jarName = "$($meta.ModId)-$($meta.Version).jar"
$jarPath = Join-Path $OutputRoot $jarName
# Wipe stale jars for this modId so old versions don't get bundled into
# the manifest alongside the new one.
Get-ChildItem -Path $OutputRoot -Filter "$($meta.ModId)-*.jar" -ErrorAction SilentlyContinue |
Where-Object { $_.Name -ne $jarName } |
ForEach-Object {
Write-Host (" removing stale: {0}" -f $_.Name)
Remove-Item $_.FullName -Force
}
if (Test-Path $jarPath) { Remove-Item $jarPath -Force }
[System.IO.Compression.ZipFile]::CreateFromDirectory(
$dir.FullName,
$jarPath,
[System.IO.Compression.CompressionLevel]::Optimal,
$false
)
$size = (Get-Item $jarPath).Length
Write-Host (" built {0,-40} {1,8:N0} bytes" -f $jarName, $size)
}
Write-Host ""
Write-Host "Tweak jars are in: $OutputRoot"
+101
View File
@@ -0,0 +1,101 @@
#requires -Version 5
<#
.SYNOPSIS
Read-only update checker. Diffs the locked Modrinth versions against the
latest available on Modrinth. Prints a report. Does not change anything.
.DESCRIPTION
Run this periodically to see what mod updates are available without
committing to anything. Decide which to bump, then either edit pack.lock.json
by hand or re-run Update-Pack.ps1 to refresh everything.
CurseForge mods (FTB family) aren't auto-checked here -- CF requires an API
key for proper version listing. Manually monitor those at:
https://www.curseforge.com/minecraft/mc-mods/ftb-chunks-forge
https://www.curseforge.com/minecraft/mc-mods/ftb-library-forge
https://www.curseforge.com/minecraft/mc-mods/ftb-teams-forge
#>
[CmdletBinding()]
param()
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$lockPath = Join-Path $here '..\pack\pack.lock.json'
if (-not (Test-Path $lockPath)) {
throw "pack.lock.json not found. Run Update-Pack.ps1 first to bootstrap it."
}
$lock = Get-Content $lockPath -Raw | ConvertFrom-Json
$mc = $lock.minecraft
$lt = $lock.loader.type
Write-Host ""
Write-Host "Checking Modrinth for newer versions..."
Write-Host (" Pack: $($lock.name) v$($lock.version)")
Write-Host (" MC: $mc ($lt $($lock.loader.version))")
Write-Host (" Locked: $($lock.lockedAt)")
Write-Host ""
Add-Type -AssemblyName System.Web
function Invoke-Modrinth {
param([string]$Path)
Invoke-RestMethod -Uri "https://api.modrinth.com/v2$Path" -Headers @{ 'User-Agent' = 'BrassAndSigil-Launcher/0.1 (matt@sijbers.uk)' }
}
$updatesAvailable = 0
$upToDate = 0
$skipped = 0
foreach ($mod in $lock.mods) {
if ($mod.source -ne "modrinth") {
Write-Host (" [cforge] $($mod.slug.PadRight(22)) $($mod.version.PadRight(34)) (manual check)")
$skipped++
continue
}
try {
$encGv = [System.Web.HttpUtility]::UrlEncode('["' + $mc + '"]')
$encL = [System.Web.HttpUtility]::UrlEncode('["' + $lt + '"]')
$versions = Invoke-Modrinth ("/project/$($mod.slug)/version?game_versions=$encGv" + "&" + "loaders=$encL")
} catch {
Write-Warning " [error] $($mod.slug) - $($_.Exception.Message)"
continue
}
if (-not $versions -or $versions.Count -eq 0) {
Write-Warning " [gone] $($mod.slug) - no versions found on Modrinth"
continue
}
$latest = $versions[0]
$latestVer = $latest.version_number
$latestType = $latest.version_type
if ($latest.id -eq $mod.versionId) {
Write-Host (" [ok] $($mod.slug.PadRight(22)) $($mod.version.PadRight(34)) (current)")
$upToDate++
}
else {
$tag = if ($latestType -eq 'beta') { '[update*]' } else { '[update] ' }
Write-Host (" $tag $($mod.slug.PadRight(22)) $($mod.version.PadRight(34)) -> $latestVer") -ForegroundColor Yellow
$updatesAvailable++
}
}
Write-Host ""
Write-Host "Summary:"
Write-Host (" Up to date: $upToDate")
$updateColor = if ($updatesAvailable -gt 0) { 'Yellow' } else { 'White' }
Write-Host (" Updates: $updatesAvailable") -ForegroundColor $updateColor
Write-Host (" Manual check: $skipped (CurseForge)")
Write-Host ""
if ($updatesAvailable -gt 0) {
Write-Host "[update*] = beta release. Bump only if you specifically want it."
Write-Host ""
Write-Host "To take all updates: run Update-Pack.ps1 (then Build-Pack.ps1 + deploy server)."
Write-Host "To pick selectively: edit pack.lock.json by hand, then run Build-Pack.ps1."
}
+231
View File
@@ -0,0 +1,231 @@
#requires -Version 5
<#
.SYNOPSIS
One-shot deploy: build launcher + server, regenerate manifest, mirror the
deploy share, scp the server binary.
.DESCRIPTION
Reads `deploy.config.ps1` (sibling file, gitignored) for local paths +
SSH details. Stages run in order; -Stage limits which stages run.
The script does NOT auto-restart the production daemon. After a server
binary deploy it prompts you to do that yourself.
.PARAMETER Stage
All | Launcher | Server | Pack. Defaults to All.
Launcher = build launcher + regenerate manifest + push to deploy share
Server = build server + scp binary (atomic swap)
Pack = regenerate manifest + mirror pack/overrides/* to share
All = everything, in order
.PARAMETER SkipBuild
Skip dotnet publish steps. Use when you've already built and just want
to push artifacts.
.PARAMETER DryRun
Print each action without executing. No files copied, no SSH, no build.
#>
[CmdletBinding()]
param(
[ValidateSet('All','Launcher','Server','Pack')]
[string]$Stage = 'All',
[switch]$SkipBuild,
[switch]$DryRun,
# Skip the version-bump check. Use only for cosmetic/internal-only changes
# where you're SURE clients don't need to re-sync. The default is to refuse
# if the local pack/launcher version matches what's already deployed.
[switch]$Force
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
# ─── Resolve repo + load config ────────────────────────────────────────────
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$repoRoot = Resolve-Path (Join-Path $here '..')
$cfgPath = Join-Path $here 'deploy.config.ps1'
if (-not (Test-Path $cfgPath)) {
throw "Missing $cfgPath. Copy deploy.config.template.ps1 -> deploy.config.ps1 and fill in real values."
}
. $cfgPath
# Sanity-check required vars actually got set (template ships with CHANGE_ME).
foreach ($v in 'DeployShare','ServerSshHost','ServerSshKey','ServerBinaryRemote') {
$val = Get-Variable -Name $v -ValueOnly -ErrorAction Stop
if ($val -match 'CHANGE_ME' -or [string]::IsNullOrWhiteSpace($val)) {
throw "deploy.config.ps1 has placeholder/empty `$$v. Fill it in."
}
}
$shouldRunLauncher = $Stage -in @('All','Launcher')
$shouldRunServer = $Stage -in @('All','Server')
$shouldRunPack = $Stage -in @('All','Launcher','Pack') # manifest needs launcher exe meta
# ─── Pre-flight: version-bump check ────────────────────────────────────────
# The launcher caches the pack by version: a client that already synced
# pack v0.9.2 will short-circuit at "already on 0.9.2" if you re-deploy
# under the same version, even if the file list / SHAs changed. Same idea
# applies to launcherVersion (drives the in-launcher upgrade banner).
# We fetch the currently-deployed manifest and refuse to deploy if the
# matching version field hasn't been bumped. Use -Force to override (e.g.
# for cosmetic-only changes where re-sync isn't needed).
if (($shouldRunPack -or $shouldRunLauncher) -and -not $DryRun -and -not $Force) {
if (-not $ManifestPublicUrl -or $ManifestPublicUrl -match 'CHANGE_ME') {
throw "deploy.config.ps1 is missing `$ManifestPublicUrl. Set it to the deployed manifest URL (e.g. https://example.com/pack/manifest.json)."
}
$deployed = $null
try {
$deployed = Invoke-RestMethod -Uri $ManifestPublicUrl -TimeoutSec 8
} catch {
Write-Host "Pre-flight: couldn't fetch deployed manifest at $ManifestPublicUrl -- version check skipped (probably first deploy)." -ForegroundColor DarkGray
}
if ($deployed) {
$errs = @()
# Pack version check applies only when the user is explicitly deploying
# pack content (Stage = All or Pack). A launcher-only deploy intentionally
# leaves pack content alone, so the pack version SHOULD stay constant.
$strictPackCheck = $Stage -in @('All','Pack')
if ($strictPackCheck) {
$lock = Get-Content (Join-Path $repoRoot 'pack\pack.lock.json') -Raw | ConvertFrom-Json
if ($deployed.version -eq $lock.version) {
$errs += "Pack version is unchanged ($($lock.version)). Clients cached at that version will SKIP the sync -- they won't pick up your pack changes. Bump 'version' in pack/pack.lock.json before deploying."
}
}
if ($shouldRunLauncher) {
$csprojPath = Join-Path $repoRoot 'launcher\ModpackLauncher.csproj'
[xml]$csproj = Get-Content $csprojPath
$localLauncherVersion = ($csproj.Project.PropertyGroup.Version | Where-Object { $_ }) | Select-Object -First 1
# Manifest stores 4-part FileVersion (e.g. "0.4.4.0"); csproj <Version> is 3-part ("0.4.4"). Compare normalised.
$deployedNorm = ($deployed.launcherVersion -replace '\.0+$','')
$localNorm = ($localLauncherVersion -replace '\.0+$','')
if ($deployedNorm -eq $localNorm -and $localNorm) {
$errs += "Launcher version is unchanged ($localLauncherVersion). Existing 0.4.x installs won't see an upgrade prompt -- bump <Version> in launcher/ModpackLauncher.csproj before deploying."
}
}
if ($errs.Count -gt 0) {
Write-Host ""
Write-Host "VERSION-BUMP CHECK FAILED:" -ForegroundColor Red
foreach ($e in $errs) { Write-Host " - $e" -ForegroundColor Red }
Write-Host ""
Write-Host "If you're re-deploying without any user-visible changes, pass -Force to skip this check." -ForegroundColor DarkGray
exit 1
}
}
}
# ─── 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
# knowing the state up front lets us tailor the final "next steps" message
# so a deploy success doesn't silently leave you on the old code.
$daemonWasRunning = $false
if ($shouldRunServer) {
Write-Host "Pre-flight: checking daemon state on $ServerSshHost..." -ForegroundColor DarkGray
# Single-quoted PS string so PowerShell doesn't try to interpret the
# bash-side metacharacters. The remote shell sees the literal pgrep
# command; the trailing $ anchors so we don't match run.sh wrappers.
$remoteCmd = 'pgrep -f /brass-sigil-server$ 2>/dev/null'
$pgrepOut = & ssh -i $ServerSshKey -o ConnectTimeout=5 -o BatchMode=yes $ServerSshHost $remoteCmd 2>$null
$daemonWasRunning = -not [string]::IsNullOrWhiteSpace($pgrepOut)
if ($daemonWasRunning) {
Write-Host " Daemon is RUNNING. Atomic swap is safe -- but you'll need to" -ForegroundColor Yellow
Write-Host " stop+start it after deploy for the new code to take effect." -ForegroundColor Yellow
} else {
Write-Host " Daemon is stopped. New binary will run as soon as you start it." -ForegroundColor DarkGray
}
}
# ─── Helpers ───────────────────────────────────────────────────────────────
$stepNum = 0
function Step($desc, [scriptblock]$body) {
$script:stepNum++
$start = Get-Date
Write-Host ""
Write-Host ("[{0}] {1}" -f $script:stepNum, $desc) -ForegroundColor Cyan
if ($DryRun) {
Write-Host " (dry-run, skipping)" -ForegroundColor DarkGray
return
}
& $body
$elapsed = (Get-Date) - $start
Write-Host (" done in {0:N1}s" -f $elapsed.TotalSeconds) -ForegroundColor DarkGray
}
# ─── Stage 1: build launcher ───────────────────────────────────────────────
$launcherExe = Join-Path $repoRoot (Join-Path $LauncherPublishDir $LauncherExeName)
if ($shouldRunLauncher -and -not $SkipBuild) {
Step "Build launcher (dotnet publish launcher\)" {
Push-Location (Join-Path $repoRoot 'launcher')
try { dotnet publish -c Release -nologo | Out-Host }
finally { Pop-Location }
if (-not (Test-Path $launcherExe)) { throw "Launcher publish didn't produce $launcherExe" }
}
}
# ─── Stage 2: build server ─────────────────────────────────────────────────
$serverExe = Join-Path $repoRoot (Join-Path $ServerPublishDir $ServerExeName)
if ($shouldRunServer -and -not $SkipBuild) {
Step "Build server (dotnet publish server\ -r linux-x64)" {
Push-Location (Join-Path $repoRoot 'server')
try { dotnet publish -c Release -r linux-x64 --self-contained true -nologo | Out-Host }
finally { Pop-Location }
if (-not (Test-Path $serverExe)) { throw "Server publish didn't produce $serverExe" }
}
}
# ─── Stage 3: regenerate manifest ──────────────────────────────────────────
$manifestPath = Join-Path $here 'manifest.json'
if ($shouldRunPack) {
Step "Regenerate manifest (Build-Pack.ps1)" {
$args = @{ OutputPath = $manifestPath }
if ($shouldRunLauncher -and (Test-Path $launcherExe)) { $args.LauncherExePath = $launcherExe }
& (Join-Path $here 'Build-Pack.ps1') @args | Out-Host
}
}
# ─── Stage 4: mirror pack overrides to share ──────────────────────────────
$overridesLocal = Join-Path $repoRoot 'pack\overrides'
$shareFiles = Join-Path $DeployShare 'files'
if ($shouldRunPack -and (Test-Path $overridesLocal)) {
Step "Mirror pack/overrides/ -> $shareFiles" {
# /MIR makes destination match source (deletes orphan files in $shareFiles).
# /XJ skips junctions, /R:1 /W:1 keeps retry behaviour sane on flaky shares.
robocopy $overridesLocal $shareFiles /MIR /XJ /R:1 /W:1 /NFL /NDL /NJH /NJS /NP | Out-Host
# Robocopy returns 0-7 for success-with-info; 8+ is real failure.
if ($LASTEXITCODE -ge 8) { throw "robocopy failed with exit $LASTEXITCODE" }
}
}
# ─── Stage 5: deploy launcher exe + manifest to share ──────────────────────
if ($shouldRunLauncher) {
Step "Copy launcher.exe + manifest to $DeployShare" {
Copy-Item $launcherExe (Join-Path $DeployShare $LauncherDeployedAs) -Force
Copy-Item $manifestPath (Join-Path $DeployShare 'manifest.json') -Force
}
}
# ─── Stage 6: scp + atomic swap server binary ──────────────────────────────
if ($shouldRunServer) {
Step "scp server binary -> $ServerSshHost`:$ServerBinaryRemote (atomic swap)" {
$remoteNew = "$ServerBinaryRemote.new"
& scp -i $ServerSshKey -o ConnectTimeout=15 $serverExe "$ServerSshHost`:$remoteNew"
if ($LASTEXITCODE -ne 0) { throw "scp failed with exit $LASTEXITCODE" }
$cmd = "chmod +x '$remoteNew' && mv '$remoteNew' '$ServerBinaryRemote' && md5sum '$ServerBinaryRemote'"
& ssh -i $ServerSshKey -o ConnectTimeout=10 $ServerSshHost $cmd
if ($LASTEXITCODE -ne 0) { throw "ssh swap failed with exit $LASTEXITCODE" }
}
Write-Host ""
if ($daemonWasRunning) {
Write-Host "Server binary swapped on disk, but the daemon was running before this" -ForegroundColor Yellow
Write-Host "deploy and is still on the OLD code in memory (Linux preserves the running" -ForegroundColor Yellow
Write-Host "inode through rename). Stop + start the daemon to pick up the new build." -ForegroundColor Yellow
} else {
Write-Host "Server binary swapped on disk. Daemon was stopped -- start it whenever" -ForegroundColor Green
Write-Host "you're ready and it'll run the new build." -ForegroundColor Green
}
}
Write-Host ""
Write-Host "Deploy finished." -ForegroundColor Green
+207
View File
@@ -0,0 +1,207 @@
#requires -Version 5
<#
.SYNOPSIS
Refreshes pack.lock.json by querying Modrinth + CurseForge for the latest
compatible version of each mod listed below.
.DESCRIPTION
THIS SCRIPT MUTATES pack.lock.json. Run only when you intentionally want to
bump versions (and remember to update the server to match).
For non-mutating "what's available?" reports, run Check-Updates.ps1.
.PARAMETER PackName
.PARAMETER PackVersion
.PARAMETER MinecraftVersion
.PARAMETER LoaderType
.PARAMETER LoaderVersion
Override pack metadata. Defaults read from existing lockfile if present.
#>
[CmdletBinding()]
param(
[string]$PackName = "",
[string]$PackVersion = "",
[string]$MinecraftVersion = "1.21.1",
[string]$LoaderType = "neoforge",
[string]$LoaderVersion = ""
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$lockPath = Join-Path $here '..\pack\pack.lock.json'
# Read existing lock for defaults
$existing = if (Test-Path $lockPath) { Get-Content $lockPath -Raw | ConvertFrom-Json } else { $null }
if (-not $PackName -and $existing) { $PackName = $existing.name }
if (-not $PackVersion -and $existing) { $PackVersion = $existing.version }
if (-not $LoaderVersion -and $existing) { $LoaderVersion = $existing.loader.version }
if (-not $PackName) { $PackName = "Brass and Sigil" }
if (-not $PackVersion) { $PackVersion = "0.6.0" }
if (-not $LoaderVersion) { $LoaderVersion = "21.1.228" }
# ---------------------------------------------------------------------------
# Mod definitions: edit here when adding/removing mods. allowBeta=true allows
# fallback to beta if no release exists.
# ---------------------------------------------------------------------------
$modrinthMods = @(
@{ slug = "create"; allowBeta = $false }
@{ slug = "create-aeronautics"; allowBeta = $false }
@{ slug = "sable"; allowBeta = $false }
@{ slug = "create-big-cannons"; allowBeta = $false }
@{ slug = "create-tfmg"; allowBeta = $false }
@{ slug = "distanthorizons"; allowBeta = $true }
@{ slug = "sodium"; allowBeta = $false }
@{ slug = "iris"; allowBeta = $false }
@{ slug = "modernfix"; allowBeta = $false }
@{ slug = "ferrite-core"; allowBeta = $false }
@{ slug = "architectury-api"; allowBeta = $false }
@{ slug = "rhino"; allowBeta = $true }
@{ slug = "rpl"; allowBeta = $false }
@{ slug = "kubejs"; allowBeta = $false }
@{ slug = "jei"; allowBeta = $true }
@{ slug = "jade"; allowBeta = $false }
@{ slug = "chunky"; allowBeta = $false }
)
# CurseForge mods aren't auto-resolved (no public API without a key).
# Update fileId/version/filename here when bumping.
$curseforgeMods = @(
@{
slug = "ftb-library"
version = "2101.1.31"
fileId = "7746959"
path = "mods/ftb-library-neoforge-2101.1.31.jar"
url = "https://mediafilez.forgecdn.net/files/7746/959/ftb-library-neoforge-2101.1.31.jar"
}
@{
slug = "ftb-teams"
version = "2101.1.9"
fileId = "7369021"
path = "mods/ftb-teams-neoforge-2101.1.9.jar"
url = "https://mediafilez.forgecdn.net/files/7369/21/ftb-teams-neoforge-2101.1.9.jar"
}
@{
slug = "ftb-chunks"
version = "2101.1.14"
fileId = "7608681"
path = "mods/ftb-chunks-neoforge-2101.1.14.jar"
url = "https://mediafilez.forgecdn.net/files/7608/681/ftb-chunks-neoforge-2101.1.14.jar"
}
)
Add-Type -AssemblyName System.Web
function Invoke-Modrinth {
param([string]$Path)
Invoke-RestMethod -Uri "https://api.modrinth.com/v2$Path" -Headers @{ 'User-Agent' = 'BrassAndSigil-Launcher/0.1 (matt@sijbers.uk)' }
}
function Get-LatestModrinthVersion {
param([string]$Slug, [bool]$AllowBeta)
$encGv = [System.Web.HttpUtility]::UrlEncode('["' + $MinecraftVersion + '"]')
$encL = [System.Web.HttpUtility]::UrlEncode('["' + $LoaderType + '"]')
$versions = Invoke-Modrinth ("/project/$Slug/version?game_versions=$encGv" + "&" + "loaders=$encL")
if (-not $versions -or $versions.Count -eq 0) { return $null }
if ($AllowBeta) {
# Take the newest version regardless of stability (Modrinth orders newest first).
return $versions[0]
}
# Stable-only: latest release version, or null if there's no release at all.
return $versions | Where-Object { $_.version_type -eq 'release' } | Select-Object -First 1
}
$lockedMods = @()
$missing = @()
Write-Host ""
Write-Host "Querying Modrinth for $($modrinthMods.Count) mods (MC $MinecraftVersion / $LoaderType)..."
Write-Host ""
foreach ($mod in $modrinthMods) {
try {
$version = Get-LatestModrinthVersion -Slug $mod.slug -AllowBeta $mod.allowBeta
} catch {
Write-Warning " [error] $($mod.slug) - $($_.Exception.Message)"
$missing += $mod.slug
continue
}
if (-not $version) {
Write-Warning " [missing] $($mod.slug) - no compatible version"
$missing += $mod.slug
continue
}
$primary = $version.files | Where-Object { $_.primary -eq $true } | Select-Object -First 1
if (-not $primary) { $primary = $version.files[0] }
$lockedMods += [ordered]@{
source = "modrinth"
slug = $mod.slug
versionId = $version.id
version = $version.version_number
path = "mods/$($primary.filename)"
url = $primary.url
sha1 = $primary.hashes.sha1
size = $primary.size
}
$tag = if ($version.version_type -eq 'beta') { '[beta] ' } else { '[release]' }
Write-Host (" $tag $($mod.slug.PadRight(22)) $($version.version_number.PadRight(28))")
}
Write-Host ""
Write-Host "Hashing $($curseforgeMods.Count) CurseForge mods (manual entries)..."
$tmpDir = Join-Path $env:TEMP "brassandsigil-cf-cache"
if (-not (Test-Path $tmpDir)) { New-Item -ItemType Directory -Path $tmpDir | Out-Null }
foreach ($cf in $curseforgeMods) {
$fname = Split-Path -Leaf $cf.path
$tmpFile = Join-Path $tmpDir $fname
try {
if (-not (Test-Path $tmpFile)) {
Invoke-WebRequest -Uri $cf.url -OutFile $tmpFile -UseBasicParsing -Headers @{ 'User-Agent' = 'BrassAndSigil-Launcher/0.1 (matt@sijbers.uk)' }
}
$sha1 = (Get-FileHash -Algorithm SHA1 -Path $tmpFile).Hash.ToLowerInvariant()
$size = (Get-Item $tmpFile).Length
$lockedMods += [ordered]@{
source = "curseforge"
slug = $cf.slug
fileId = $cf.fileId
version = $cf.version
path = $cf.path
url = $cf.url
sha1 = $sha1
size = $size
}
Write-Host (" [cforge] $($cf.slug.PadRight(22)) $($cf.version)")
}
catch {
Write-Warning " [error] $fname - $($_.Exception.Message)"
$missing += $fname
}
}
$lock = [ordered]@{
'$schema' = "Brass-and-Sigil pack.lock.json - generated, do not edit by hand unless you know what you are doing"
name = $PackName
version = $PackVersion
minecraft = $MinecraftVersion
loader = [ordered]@{ type = $LoaderType; version = $LoaderVersion }
lockedAt = (Get-Date -Format 'o')
mods = $lockedMods
}
$json = $lock | ConvertTo-Json -Depth 10
[System.IO.File]::WriteAllText($lockPath, $json, [System.Text.UTF8Encoding]::new($false))
Write-Host ""
Write-Host "Lockfile written: $lockPath"
Write-Host (" Total mods: {0}" -f $lockedMods.Count)
if ($missing.Count -gt 0) {
Write-Warning "Missing: $($missing -join ', ')"
}
Write-Host ""
Write-Host "Run Build-Pack.ps1 next to generate the manifest."
+37
View File
@@ -0,0 +1,37 @@
# Local deploy configuration. Copy this file to `deploy.config.ps1` and fill in
# real values. `deploy.config.ps1` is gitignored so your local paths and the
# server hostname never end up in version control.
#
# Deploy-Brass.ps1 dot-sources this file. Every variable below is required.
# ─── Public file hosting ───────────────────────────────────────────────────
# Local path that maps (via SMB or similar) to the public docroot that hosts
# `manifest.json` and the launcher .exe. Files copied here become reachable at
# the public URLs embedded in the manifest (`launcherUrl`, file URLs, etc.).
# Example: 'Z:\www\html\example.com\public\pack'
$DeployShare = 'CHANGE_ME'
# Public URL of the deployed manifest. Used for the version-bump pre-flight:
# Deploy-Brass.ps1 fetches this before deploying so it can refuse if you
# changed pack content but forgot to bump pack/pack.lock.json's version
# (clients with a cached pack at that version would skip the sync).
$ManifestPublicUrl = 'https://CHANGE_ME/pack/manifest.json'
# ─── Server (brass-sigil-server daemon host) ───────────────────────────────
# user@host for the Linux box running the daemon.
$ServerSshHost = 'user@CHANGE_ME'
# Path to the local SSH private key authorised on the server.
$ServerSshKey = "$env:USERPROFILE\.ssh\id_ed25519"
# Absolute path on the Linux box where the brass-sigil-server binary lives.
# `Deploy-Brass.ps1` uploads to "<this>.new" then `mv` over for atomic swap.
$ServerBinaryRemote = '/home/user/brass-sigil-server/brass-sigil-server'
# ─── Build outputs (don't normally need to change) ─────────────────────────
$LauncherPublishDir = 'launcher\bin\Release\net8.0-windows\win-x64\publish'
$LauncherExeName = 'ModpackLauncher.exe' # what dotnet publish produces
$LauncherDeployedAs = 'BrassAndSigil-Launcher.exe' # filename on the public host
$ServerPublishDir = 'server\bin\Release\net8.0\linux-x64\publish'
$ServerExeName = 'brass-sigil-server'