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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
83b260533a
commit
3f27cd0778
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user