diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a01293..c2523c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## v1.6.3 - 2026-02-21 +## v1.6.4 - 2026-02-21 ### Added - New shared utility modules under `utils/`: @@ -33,7 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security - Kept release-time git checks and branch/tag validation in shared release flow to reduce accidental publish risk. - + MaksIT.Core - 1.6.3 + 1.6.4 Maksym Sadovnychyy MAKS-IT MaksIT.Core diff --git a/utils/Update-RepoUtils/Update-RepoUtils.ps1 b/utils/Update-RepoUtils/Update-RepoUtils.ps1 index 7e3c2d5..0679578 100644 --- a/utils/Update-RepoUtils/Update-RepoUtils.ps1 +++ b/utils/Update-RepoUtils/Update-RepoUtils.ps1 @@ -7,9 +7,9 @@ .DESCRIPTION This script clones the configured repository into a temporary directory, - removes the current working directory contents, preserves an existing - scriptsettings.json file, and copies the cloned src contents into the - current working directory. + refreshes the parent directory of this script, preserves existing + scriptsettings.json files in subfolders, and copies the cloned source + contents into that parent directory. All configuration is stored in scriptsettings.json. @@ -18,14 +18,20 @@ .NOTES CONFIGURATION (scriptsettings.json): + - dryRun: If true, logs the planned update without modifying files - repository.url: Git repository to clone - repository.sourceSubdirectory: Folder copied into the target directory - - repository.preserveFileName: Existing file in the target directory to keep + - repository.preserveFileName: Existing file name to preserve in subfolders - repository.cloneDepth: Depth used for git clone #> [CmdletBinding()] -param() +param( + [switch]$ContinueAfterSelfUpdate, + [string]$TargetDirectoryOverride, + [string]$ClonedSourceDirectoryOverride, + [string]$TemporaryRootOverride +) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' @@ -34,8 +40,18 @@ $ErrorActionPreference = 'Stop' $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $utilsDir = Split-Path $scriptDir -Parent -# The target is the current working directory, not the script directory. -$targetDirectory = (Get-Location).Path +# Refresh the parent directory that contains the shared modules and sibling tools. +$targetDirectory = if ([string]::IsNullOrWhiteSpace($TargetDirectoryOverride)) { + Split-Path $scriptDir -Parent +} +else { + [System.IO.Path]::GetFullPath($TargetDirectoryOverride) +} +$currentScriptPath = [System.IO.Path]::GetFullPath($MyInvocation.MyCommand.Path) +$selfUpdateDirectory = 'Update-RepoUtils' +$skippedRelativeDirectories = @( + [System.IO.Path]::Combine('Release-Package', 'CustomPlugins') +) #region Import Modules @@ -65,16 +81,17 @@ $settings = Get-ScriptSettings -ScriptDir $scriptDir #region Configuration $repositoryUrl = $settings.repository.url +$dryRun = if ($null -ne $settings.dryRun) { [bool]$settings.dryRun } else { $false } $sourceSubdirectory = if ($settings.repository.sourceSubdirectory) { $settings.repository.sourceSubdirectory } else { 'src' } $preserveFileName = if ($settings.repository.preserveFileName) { $settings.repository.preserveFileName } else { 'scriptsettings.json' } $cloneDepth = if ($settings.repository.cloneDepth) { [int]$settings.repository.cloneDepth } else { 1 } -$currentScriptName = Split-Path -Leaf $MyInvocation.MyCommand.Path #endregion #region Validate CLI Dependencies Assert-Command git +Assert-Command pwsh if ([string]::IsNullOrWhiteSpace($repositoryUrl)) { Write-Error "repository.url is required in scriptsettings.json." @@ -89,54 +106,201 @@ Write-Log -Level "INFO" -Message "========================================" Write-Log -Level "INFO" -Message "Update RepoUtils Script" Write-Log -Level "INFO" -Message "========================================" Write-Log -Level "INFO" -Message "Target directory: $targetDirectory" +Write-Log -Level "INFO" -Message "Dry run: $dryRun" -$temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("maksit-repoutils-update-" + [System.Guid]::NewGuid().ToString('N')) +$ownsTemporaryRoot = [string]::IsNullOrWhiteSpace($TemporaryRootOverride) +$temporaryRoot = if ($ownsTemporaryRoot) { + Join-Path ([System.IO.Path]::GetTempPath()) ("maksit-repoutils-update-" + [System.Guid]::NewGuid().ToString('N')) +} +else { + [System.IO.Path]::GetFullPath($TemporaryRootOverride) +} try { - Write-LogStep "Cloning latest repository snapshot..." - & git clone --depth $cloneDepth $repositoryUrl $temporaryRoot - if ($LASTEXITCODE -ne 0) { - throw "git clone failed with exit code $LASTEXITCODE." - } - Write-Log -Level "OK" -Message "Repository cloned" + $clonedSourceDirectory = if ([string]::IsNullOrWhiteSpace($ClonedSourceDirectoryOverride)) { + Write-LogStep "Cloning latest repository snapshot..." + & git clone --depth $cloneDepth $repositoryUrl $temporaryRoot + if ($LASTEXITCODE -ne 0) { + throw "git clone failed with exit code $LASTEXITCODE." + } + Write-Log -Level "OK" -Message "Repository cloned" + + Join-Path $temporaryRoot $sourceSubdirectory + } + else { + [System.IO.Path]::GetFullPath($ClonedSourceDirectoryOverride) + } - $clonedSourceDirectory = Join-Path $temporaryRoot $sourceSubdirectory if (-not (Test-Path -Path $clonedSourceDirectory -PathType Container)) { throw "The cloned repository does not contain the expected source directory: $clonedSourceDirectory" } - $existingPreservedFile = Join-Path $targetDirectory $preserveFileName - $preservedFileBackup = $null - if (Test-Path -Path $existingPreservedFile -PathType Leaf) { - $preservedFileBackup = Join-Path $temporaryRoot ("preserved-" + $preserveFileName) - Copy-Item -Path $existingPreservedFile -Destination $preservedFileBackup -Force - Write-Log -Level "OK" -Message "Preserved existing $preserveFileName" + if (-not $ContinueAfterSelfUpdate) { + if ($dryRun) { + Write-LogStep "Dry run self-update summary" + Write-Log -Level "INFO" -Message "Would refresh shared modules and $selfUpdateDirectory before relaunching the updater" + } + else { + Write-LogStep "Refreshing updater files..." + $selfUpdateFiles = Get-ChildItem -Path $clonedSourceDirectory -Recurse -Force -File | + Where-Object { + $relativePath = [System.IO.Path]::GetRelativePath($clonedSourceDirectory, $_.FullName) + $isRootFile = -not $relativePath.Contains([System.IO.Path]::DirectorySeparatorChar) + $isUpdaterFile = $relativePath.StartsWith($selfUpdateDirectory + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase) + + $_.Name -ne $preserveFileName -and + ($isRootFile -or $isUpdaterFile) + } + + foreach ($sourceFile in $selfUpdateFiles) { + $relativePath = [System.IO.Path]::GetRelativePath($clonedSourceDirectory, $sourceFile.FullName) + $destinationPath = Join-Path $targetDirectory $relativePath + $destinationDirectory = Split-Path -Parent $destinationPath + if (-not (Test-Path -Path $destinationDirectory -PathType Container)) { + New-Item -ItemType Directory -Path $destinationDirectory -Force | Out-Null + } + + Copy-Item -Path $sourceFile.FullName -Destination $destinationPath -Force + } + + Write-Log -Level "OK" -Message "Updater files refreshed" + } + + if ($dryRun) { + Write-LogStep "Dry run bootstrap completed" + Write-Log -Level "INFO" -Message "Continuing with phase two in the current process because no files were changed" + } + else { + Write-LogStep "Relaunching the updated updater..." + & pwsh -File $currentScriptPath ` + -ContinueAfterSelfUpdate ` + -TargetDirectoryOverride $targetDirectory ` + -ClonedSourceDirectoryOverride $clonedSourceDirectory ` + -TemporaryRootOverride $temporaryRoot + if ($LASTEXITCODE -ne 0) { + throw "Relaunched updater failed with exit code $LASTEXITCODE." + } + + Write-Log -Level "OK" -Message "Bootstrap phase completed" + return + } + } + + $preservedFiles = @() + $updatePhaseSkippedDirectories = $skippedRelativeDirectories + $selfUpdateDirectory + $existingPreservedFiles = Get-ChildItem -Path $targetDirectory -Recurse -File -Filter $preserveFileName -ErrorAction SilentlyContinue + if ($existingPreservedFiles) { + foreach ($file in $existingPreservedFiles) { + $relativePath = [System.IO.Path]::GetRelativePath($targetDirectory, $file.FullName) + $backupPath = Join-Path $temporaryRoot ("preserved-" + ($relativePath -replace '[\\/:*?""<>|]', '_')) + $preservedFiles += [pscustomobject]@{ + RelativePath = $relativePath + BackupPath = $backupPath + } + + if (-not $dryRun) { + Copy-Item -Path $file.FullName -Destination $backupPath -Force + } + } + Write-Log -Level "OK" -Message "Preserved $($preservedFiles.Count) existing $preserveFileName file(s)" } else { - Write-Log -Level "WARN" -Message "No existing $preserveFileName found in target directory" + Write-Log -Level "WARN" -Message "No existing $preserveFileName files found in subfolders" + } + + if ($dryRun) { + Write-LogStep "Dry run summary" + Write-Log -Level "INFO" -Message "Would remove all files under target except preserved $preserveFileName files" + Write-Log -Level "INFO" -Message "Would skip phase-two refresh for: $($updatePhaseSkippedDirectories -join ', ')" + Write-Log -Level "INFO" -Message "Would copy refreshed files from: $clonedSourceDirectory" + if ($preservedFiles.Count -gt 0) { + $preservedList = ($preservedFiles | ForEach-Object { $_.RelativePath }) -join ", " + Write-Log -Level "INFO" -Message "Would restore preserved files: $preservedList" + } + Write-Log -Level "OK" -Message "Dry run completed. No files were modified." + return } Write-LogStep "Cleaning target directory..." - $itemsToRemove = Get-ChildItem -Path $targetDirectory -Force | + $filesToRemove = Get-ChildItem -Path $targetDirectory -Recurse -Force -File | Where-Object { + $relativePath = [System.IO.Path]::GetRelativePath($targetDirectory, $_.FullName) + $isInSkippedDirectory = $false + foreach ($skippedDirectory in $updatePhaseSkippedDirectories) { + if ($relativePath.StartsWith($skippedDirectory + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)) { + $isInSkippedDirectory = $true + break + } + } + $_.Name -ne $preserveFileName -and - $_.Name -ne $currentScriptName + -not $isInSkippedDirectory } - foreach ($item in $itemsToRemove) { - Remove-Item -Path $item.FullName -Recurse -Force + foreach ($file in $filesToRemove) { + Remove-Item -Path $file.FullName -Force + } + + $directoriesToRemove = Get-ChildItem -Path $targetDirectory -Recurse -Force -Directory | + Sort-Object { $_.FullName.Length } -Descending + + foreach ($directory in $directoriesToRemove) { + $remainingItems = Get-ChildItem -Path $directory.FullName -Force -ErrorAction SilentlyContinue + if (-not $remainingItems) { + Remove-Item -Path $directory.FullName -Force + } } Write-Log -Level "OK" -Message "Target directory cleaned" Write-LogStep "Copying refreshed source files..." - Get-ChildItem -Path $clonedSourceDirectory -Force | ForEach-Object { - Copy-Item -Path $_.FullName -Destination $targetDirectory -Recurse -Force + $sourceFilesToCopy = Get-ChildItem -Path $clonedSourceDirectory -Recurse -Force -File | + Where-Object { + $relativePath = [System.IO.Path]::GetRelativePath($clonedSourceDirectory, $_.FullName) + $isInSkippedDirectory = $false + foreach ($skippedDirectory in $updatePhaseSkippedDirectories) { + if ($relativePath.StartsWith($skippedDirectory + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)) { + $isInSkippedDirectory = $true + break + } + } + + -not $isInSkippedDirectory + } + + foreach ($sourceFile in $sourceFilesToCopy) { + $relativePath = [System.IO.Path]::GetRelativePath($clonedSourceDirectory, $sourceFile.FullName) + $destinationPath = Join-Path $targetDirectory $relativePath + $destinationDirectory = Split-Path -Parent $destinationPath + if (-not (Test-Path -Path $destinationDirectory -PathType Container)) { + New-Item -ItemType Directory -Path $destinationDirectory -Force | Out-Null + } + + Copy-Item -Path $sourceFile.FullName -Destination $destinationPath -Force + } + + foreach ($skippedDirectory in $updatePhaseSkippedDirectories) { + $skippedSourcePath = Join-Path $clonedSourceDirectory $skippedDirectory + if (Test-Path -Path $skippedSourcePath) { + Write-Log -Level "INFO" -Message "Skipped refresh for $skippedDirectory" + } } Write-Log -Level "OK" -Message "Source files copied" - if ($preservedFileBackup -and (Test-Path -Path $preservedFileBackup -PathType Leaf)) { - Copy-Item -Path $preservedFileBackup -Destination $existingPreservedFile -Force - Write-Log -Level "OK" -Message "$preserveFileName restored" + if ($preservedFiles.Count -gt 0) { + foreach ($preservedFile in $preservedFiles) { + if (-not (Test-Path -Path $preservedFile.BackupPath -PathType Leaf)) { + continue + } + + $restorePath = Join-Path $targetDirectory $preservedFile.RelativePath + $restoreDirectory = Split-Path -Parent $restorePath + if (-not (Test-Path -Path $restoreDirectory -PathType Container)) { + New-Item -ItemType Directory -Path $restoreDirectory -Force | Out-Null + } + + Copy-Item -Path $preservedFile.BackupPath -Destination $restorePath -Force + } + Write-Log -Level "OK" -Message "$preserveFileName files restored" } Write-Log -Level "OK" -Message "========================================" @@ -144,7 +308,7 @@ try { Write-Log -Level "OK" -Message "========================================" } finally { - if (Test-Path -Path $temporaryRoot) { + if ($ownsTemporaryRoot -and (Test-Path -Path $temporaryRoot)) { Remove-Item -Path $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue } } diff --git a/utils/Update-RepoUtils/scriptsettings.json b/utils/Update-RepoUtils/scriptsettings.json index 475177a..9d55393 100644 --- a/utils/Update-RepoUtils/scriptsettings.json +++ b/utils/Update-RepoUtils/scriptsettings.json @@ -2,10 +2,14 @@ "$schema": "https://json-schema.org/draft-07/schema", "title": "Update RepoUtils Script Settings", "description": "Configuration for the Update-RepoUtils utility.", + "dryRun": true, "repository": { "url": "https://github.com/MAKS-IT-COM/maksit-repoutils.git", "sourceSubdirectory": "src", "preserveFileName": "scriptsettings.json", - "cloneDepth": 1 + "cloneDepth": 1, + "skippedRelativeDirectories": [ + "Release-Package/CustomPlugins" + ] } }