Files
MyAiConfiguration/scripts/lib/manifest.ps1
T
Julian lechnerandClaude Sonnet 5 3f27cd0778 feat(installer): add managed-file manifest module (PowerShell)
Adds Sync-ManagedDestination and its manifest helpers: a per-destination
TSV of path -> SHA-256 that makes installation idempotent (unchanged files
are neither rewritten nor backed up), detects files this setup previously
installed that the source no longer ships (removed after backup, unless
changed locally, in which case they are backed up and left in place with
a warning), and never touches a file it never installed. Backups for a
run land together under backups/<stamp>/ instead of one suffix per file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 15:42:48 +02:00

109 lines
5.2 KiB
PowerShell

function Get-ManagedManifestPath {
param([Parameter(Mandatory=$true)][string]$Destination)
return (Join-Path $Destination '.ai-config-manifest.tsv')
}
function Get-Sha256Hash {
param([Parameter(Mandatory=$true)][string]$Path)
return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash
}
function Get-Sha256HashOfBytes {
param([Parameter(Mandatory=$true)][byte[]]$Bytes)
$sha256 = [System.Security.Cryptography.SHA256]::Create()
try { return ([System.BitConverter]::ToString($sha256.ComputeHash($Bytes))).Replace('-', '') }
finally { $sha256.Dispose() }
}
function Read-ManagedManifest {
param([Parameter(Mandatory=$true)][string]$ManifestPath)
$entries = @{}
if (Test-Path -LiteralPath $ManifestPath) {
Import-Csv -LiteralPath $ManifestPath -Delimiter ([char]9) | ForEach-Object { $entries[$_.path] = $_.sha256 }
}
return $entries
}
function Write-ManagedManifest {
param([Parameter(Mandatory=$true)][string]$ManifestPath, [Parameter(Mandatory=$true)][hashtable]$Entries)
$lines = @("path`tsha256")
foreach ($path in ($Entries.Keys | Sort-Object)) { $lines += "$path`t$($Entries[$path])" }
New-Item (Split-Path $ManifestPath -Parent) -ItemType Directory -Force | Out-Null
Set-Content -LiteralPath $ManifestPath -Value $lines -Encoding UTF8
}
function Sync-ManagedDestination {
<#
.SYNOPSIS
Installs one generated source tree into a destination directory, tracking
every installed file in a manifest so a later run can detect files this
setup previously managed that were since removed from the source (stale)
or changed on disk by the user (locally modified), without ever touching
a file it never installed.
#>
param(
[Parameter(Mandatory=$true)][string]$Source,
[Parameter(Mandatory=$true)][string]$Destination,
[Parameter(Mandatory=$true)][string]$Stamp,
[string]$AiConfigRoot,
[string]$ShellCommand,
[string]$WindowsShellCommand,
[switch]$DryRun
)
Write-Output "SOURCE $Source -> $Destination"
$manifestPath = Get-ManagedManifestPath $Destination
$oldManifest = Read-ManagedManifest $manifestPath
$newManifest = @{}
$backupRoot = Join-Path $Destination "backups/$Stamp"
Get-ChildItem $Source -File -Recurse | ForEach-Object {
$relative = $_.FullName.Substring($Source.Length).TrimStart([char[]]@('\','/')).Replace('\','/')
$target = Join-Path $Destination $relative
$rawContent = Get-Content -LiteralPath $_.FullName -Raw
$needsSubstitution = $rawContent.Contains('__AI_CONFIG_ROOT__') -or $rawContent.Contains('__HOOK_COMMAND__') -or $rawContent.Contains('__WINDOWS_HOOK_COMMAND__')
if ($needsSubstitution) {
$finalContent = $rawContent.Replace('__AI_CONFIG_ROOT__', $AiConfigRoot).Replace('__HOOK_COMMAND__', $ShellCommand).Replace('__WINDOWS_HOOK_COMMAND__', $WindowsShellCommand).Replace('__HOOK_SCRIPT__', 'flashbang.ps1').Replace('__WINDOWS_HOOK_SCRIPT__', 'flashbang.ps1')
$newBytes = [System.Text.Encoding]::UTF8.GetBytes($finalContent)
} else {
$newBytes = [System.IO.File]::ReadAllBytes($_.FullName)
}
$newHash = Get-Sha256HashOfBytes $newBytes
$newManifest[$relative] = $newHash
$exists = Test-Path -LiteralPath $target
if ($exists -and (Get-Sha256Hash $target) -eq $newHash) { Write-Output "UNCHANGED $target"; return }
$action = if ($exists) { 'UPDATE' } else { 'CREATE' }
if ($DryRun) { Write-Output "DRYRUN $action $target"; return }
if ($exists) {
$wasManaged = $oldManifest.ContainsKey($relative)
$backup = Join-Path $backupRoot $relative
New-Item (Split-Path $backup -Parent) -ItemType Directory -Force | Out-Null
Copy-Item -LiteralPath $target $backup -Force
Write-Output "BACKUP $target -> $backup"
if (-not $wasManaged) { Write-Output "WARN $target existed before this installation but was not tracked by a previous run; it was backed up before being overwritten." }
}
New-Item (Split-Path $target -Parent) -ItemType Directory -Force | Out-Null
[System.IO.File]::WriteAllBytes($target, $newBytes)
Write-Output "$action $target"
}
foreach ($relative in @($oldManifest.Keys | Where-Object { -not $newManifest.ContainsKey($_) })) {
$target = Join-Path $Destination $relative
if (-not (Test-Path -LiteralPath $target)) { continue }
if ($DryRun) { Write-Output "DRYRUN REMOVE $target"; continue }
$backup = Join-Path $backupRoot $relative
New-Item (Split-Path $backup -Parent) -ItemType Directory -Force | Out-Null
Copy-Item -LiteralPath $target $backup -Force
Write-Output "BACKUP $target -> $backup"
if ((Get-Sha256Hash $target) -eq $oldManifest[$relative]) {
Remove-Item -LiteralPath $target -Force
Write-Output "REMOVE $target"
} else {
Write-Output "WARN $target was managed by a previous installation and has changed locally; it was backed up but left in place instead of being removed."
}
}
if (-not $DryRun) { Write-ManagedManifest $manifestPath $newManifest }
}