Files
CVRManager/scripts/migrations/2026-10-05-flatten-install-layout.ps1
Exterrata 9bb4f85802 CVR Manager 1.0.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 02:46:35 -04:00

141 lines
7.7 KiB
PowerShell

<#
One-off migration: old install layout -> current layout.
old: <library>\installs\<Name>-<4 hex>\game\ChilloutVR.exe
new: <library>\installs\<Name>\ChilloutVR.exe
Also registers install folders that exist on disk but are missing from config.json
(name taken from the folder, branch from the game's appmanifest BetaKey).
Dry run by default. Nothing is changed unless -Apply is given. Run with pwsh.
pwsh -File 2026-10-05-flatten-install-layout.ps1 # show the plan
pwsh -File 2026-10-05-flatten-install-layout.ps1 -Apply # do it
Close CVRManager and every game from the library first. config.json is backed up next to itself.
#>
[CmdletBinding()]
param(
[string]$ConfigDir = (Join-Path $env:APPDATA 'CVRManager'),
[switch]$Apply,
[switch]$SkipAppCheck # only for testing against a throwaway copy; skips the 'CVRManager is running' check
)
$ErrorActionPreference = 'Stop'
function Get-SafeFolderName([string]$name) {
$bad = [IO.Path]::GetInvalidFileNameChars()
$clean = -join ($name.ToCharArray() | ForEach-Object { if ($bad -contains $_) { ' ' } else { $_ } })
while ($clean.Contains(' ')) { $clean = $clean.Replace(' ', ' ') }
$clean = $clean.Trim().TrimEnd('.')
if ($clean.Length -gt 60) { $clean = $clean.Substring(0, 60).TrimEnd() }
if ($clean.Length -eq 0) { 'Install' } else { $clean }
}
$configPath = Join-Path $ConfigDir 'config.json'
if (-not (Test-Path $configPath)) { throw "No config at $configPath" }
$cfg = Get-Content $configPath -Raw | ConvertFrom-Json
$lib = if ($cfg.LibraryRoot) { $cfg.LibraryRoot } else { Join-Path $env:LOCALAPPDATA 'CVRManager' }
$installs = Join-Path $lib 'installs'
if (-not (Test-Path $installs)) { 'No installs folder; nothing to do.'; return }
$running = @(if (-not $SkipAppCheck) { Get-Process CVRManager -ErrorAction SilentlyContinue }) + @(Get-Process ChilloutVR -ErrorAction SilentlyContinue | Where-Object { $_.Path -and $_.Path.StartsWith($installs, [StringComparison]::OrdinalIgnoreCase) })
if ($Apply -and $running.Count -gt 0) { throw "Close CVRManager and any game started from the library first (running: $(($running | ForEach-Object { $_.ProcessName + ' ' + $_.Id }) -join ', '))." }
$list = [System.Collections.Generic.List[object]]::new()
foreach ($i in @($cfg.Installs)) { $list.Add($i) }
$newEntries = @()
# orphan folders: on disk, not in config
$known = @($list | ForEach-Object { $_.Folder.ToLowerInvariant() })
foreach ($dir in Get-ChildItem $installs -Directory) {
if ($known -contains $dir.Name.ToLowerInvariant()) { continue }
$hasExe = (Test-Path (Join-Path $dir.FullName 'ChilloutVR.exe')) -or (Test-Path (Join-Path $dir.FullName 'game\ChilloutVR.exe'))
if (-not $hasExe) { continue }
$m = [regex]::Match($dir.Name, '^(.*)-([0-9a-f]{4})$')
$name = if ($m.Success) { $m.Groups[1].Value.Replace('-', ' ').Trim() } else { $dir.Name }
$branch = 'public'
foreach ($acf in @((Join-Path $dir.FullName 'game\steamapps\appmanifest_661130.acf'), (Join-Path $dir.FullName 'steamapps\appmanifest_661130.acf'))) {
if (Test-Path $acf) {
$b = [regex]::Match((Get-Content $acf -Raw), '"UserConfig"\s*\{[^}]*?"BetaKey"\s*"([^"]*)"')
if ($b.Success -and $b.Groups[1].Value) { $branch = $b.Groups[1].Value; break }
}
}
$defaultHooks = @($cfg.Hooks | Where-Object { $_.DefaultOn } | ForEach-Object { $_.Id })
$entry = [pscustomobject]@{
Id = ([guid]::NewGuid().ToString('N')).Substring(0, 8); Name = $name; Folder = $dir.Name; Branch = $branch
BranchPassword = ''; LaunchArgs = ''; LaunchMode = 'desktop'; Mods = @(); HookIds = $defaultHooks; Deployed = [pscustomobject]@{}
}
$list.Add($entry); $newEntries += $entry
}
function Test-Taken([string]$candidate, $self) {
foreach ($o in $list) { if ($o -ne $self -and $o.Folder -ieq $candidate) { return $true } }
if ($candidate -ieq $self.Folder) { return $false }
return (Test-Path (Join-Path $installs $candidate))
}
$plans = @()
foreach ($i in $list) {
$legacy = Join-Path (Join-Path $installs $i.Folder) 'game'
$needsFlatten = (Test-Path $legacy) -and ((Test-Path (Join-Path $legacy 'ChilloutVR.exe')) -or -not (Test-Path (Join-Path (Join-Path $installs $i.Folder) 'ChilloutVR.exe')))
$base = Get-SafeFolderName $i.Name
$candidate = $base; $n = 2
while (Test-Taken $candidate $i) { $candidate = "$base ($n)"; $n++ }
$needsRename = $candidate -cne $i.Folder
if ($needsFlatten -or $needsRename) { $plans += [pscustomobject]@{ Install = $i; From = $i.Folder; To = $candidate; Flatten = $needsFlatten; Rename = $needsRename; Registers = ($newEntries -contains $i) } }
}
"Library: $lib"
if ($newEntries.Count -gt 0) { "Install folders on disk that are missing from the config ($($newEntries.Count)):"; $newEntries | ForEach-Object { " + register '$($_.Name)' (folder '$($_.Folder)', branch '$($_.Branch)')" } }
if ($plans.Count -eq 0 -and $newEntries.Count -eq 0) { 'Nothing to do: every install already uses the current layout.'; return }
"Planned moves:"
foreach ($p in $plans) {
$what = @(); if ($p.Rename) { $what += "rename folder '$($p.From)' -> '$($p.To)'" }; if ($p.Flatten) { $what += "move the contents of 'game' up one level" }
" - $($p.Install.Name): $($what -join '; ')"
}
if (-not $Apply) { ''; 'Dry run only. Re-run with -Apply to make these changes.'; return }
$backup = "$configPath.bak-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
Copy-Item $configPath $backup
"Config backed up to $backup"
function Save-Config([object[]]$installsToWrite) {
$cfg.Installs = @($installsToWrite)
[IO.File]::WriteAllText($configPath, ($cfg | ConvertTo-Json -Depth 30), (New-Object Text.UTF8Encoding($false)))
}
# progress is written after every install, so a failure part-way never leaves the config out of step with the disk
$registered = @($list | Where-Object { $newEntries -notcontains $_ }) # installs that were already in the config stay there
$failed = @()
foreach ($p in $plans) {
$oldDir = Join-Path $installs $p.From
$newDir = Join-Path $installs $p.To
try {
if ($p.Rename) {
if ($oldDir -ieq $newDir) { $tmp = "$oldDir.renaming"; [IO.Directory]::Move($oldDir, $tmp); [IO.Directory]::Move($tmp, $newDir) }
else { [IO.Directory]::Move($oldDir, $newDir) }
$p.Install.Folder = $p.To
}
$legacy = Join-Path $newDir 'game'
if ($p.Flatten -and (Test-Path $legacy)) {
foreach ($entry in Get-ChildItem $legacy -Force) {
$dest = Join-Path $newDir $entry.Name
if (Test-Path $dest) { Write-Warning "Skipped '$($entry.Name)' for $($p.Install.Name): already exists at the destination"; continue }
if ($entry.PSIsContainer) { [IO.Directory]::Move($entry.FullName, $dest) } else { [IO.File]::Move($entry.FullName, $dest) }
}
if (-not (Get-ChildItem $legacy -Force)) { Remove-Item $legacy }
}
if ($newEntries -contains $p.Install) { $registered += $p.Install }
Save-Config (@($list | Where-Object { $newEntries -notcontains $_ -or $registered -contains $_ }))
" done: $($p.Install.Name) -> $newDir"
}
catch [UnauthorizedAccessException], [IO.IOException] {
$failed += $p.Install.Name
Write-Warning "$($p.Install.Name): $($_.Exception.Message)"
Write-Warning "A folder of this install is probably open in Explorer or another program. Close it and run the script again; it continues where it stopped."
Save-Config (@($list | Where-Object { $newEntries -notcontains $_ -or $registered -contains $_ }))
}
}
if ($failed.Count -gt 0) { "Finished with problems for: $($failed -join ', '). Everything else was migrated and saved." }
else { 'Config updated. Start CVRManager again.' }