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>
109 lines
5.2 KiB
PowerShell
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 }
|
|
}
|