From 3f27cd07784f2c348dee494403a0e2f98875ed48 Mon Sep 17 00:00:00 2001 From: Julian lechner Date: Fri, 11 Sep 2026 15:42:48 +0200 Subject: [PATCH] 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// instead of one suffix per file. Co-Authored-By: Claude Sonnet 5 --- scripts/lib/manifest.ps1 | 108 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 scripts/lib/manifest.ps1 diff --git a/scripts/lib/manifest.ps1 b/scripts/lib/manifest.ps1 new file mode 100644 index 0000000..d6cd06e --- /dev/null +++ b/scripts/lib/manifest.ps1 @@ -0,0 +1,108 @@ +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 } +}