Eigenverft.Manifested.Drydock.Powershell.ps1
function Test-InstallationScopeCapability { <# .SYNOPSIS Resolves the effective installation scope from current privileges (no parameters). .DESCRIPTION Returns exactly one string: - "AllUsers" if the session is elevated (Administrator), - "CurrentUser" otherwise. .EXAMPLE Test-InstallationScopeCapability .OUTPUTS System.String #> [CmdletBinding()] param() $isAdmin = $false try { $id = [Security.Principal.WindowsIdentity]::GetCurrent() $pri = [Security.Principal.WindowsPrincipal]$id $isAdmin = $pri.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } catch { $isAdmin = $false } if ($isAdmin) { 'AllUsers' } else { 'CurrentUser' } } function Set-PSGalleryTrust { <# .SYNOPSIS Ensures the 'PSGallery' repository exists locally and is trusted (parameterless). .DESCRIPTION - Parameterless on purpose: resolves the effective scope internally via Test-InstallationScopeCapability. - Prefers PowerShellGet repository cmdlets; falls back to PackageManagement if needed. - Local operations only; does not force a network call. .EXAMPLE Set-PSGalleryTrust #> [CmdletBinding()] param() $effectiveScope = Test-InstallationScopeCapability Write-Host "[Info] Ensuring PSGallery trust at effective scope: '$effectiveScope'." # Prefer PSRepository (PowerShellGet) path $hasPsRepositoryCmdlets = $false try { if (Get-Command Get-PSRepository -ErrorAction SilentlyContinue) { $hasPsRepositoryCmdlets = $true } } catch {} if ($hasPsRepositoryCmdlets) { try { $repo = Get-PSRepository -Name 'PSGallery' -ErrorAction Stop if ($repo.InstallationPolicy -ne 'Trusted') { Write-Host "[Action] Setting PSGallery InstallationPolicy to 'Trusted'..." Set-PSRepository -Name 'PSGallery' -InstallationPolicy Trusted -ErrorAction Stop Write-Host "[Success] PSGallery is now trusted." } else { Write-Host "[OK] PSGallery is already trusted." } return } catch { Write-Host "[Action] Registering PSGallery locally..." try { Register-PSRepository -Name 'PSGallery' ` -SourceLocation 'https://www.powershellgallery.com/api/v2' ` -ScriptSourceLocation 'https://www.powershellgallery.com/api/v2' ` -InstallationPolicy Trusted -ErrorAction Stop Write-Host "[Success] PSGallery registered and trusted." } catch { Write-Host "Error: Failed to register PSGallery via Register-PSRepository: $($_.Exception.Message)" -ForegroundColor Red } return } } # Fallback: PackageManagement path try { $pkgSrc = Get-PackageSource -Name 'PSGallery' -ProviderName 'PowerShellGet' -ErrorAction SilentlyContinue if ($pkgSrc) { if (-not $pkgSrc.IsTrusted) { Write-Host "[Action] Marking PSGallery as trusted via Set-PackageSource..." Set-PackageSource -Name 'PSGallery' -Trusted -ProviderName 'PowerShellGet' -ErrorAction Stop | Out-Null Write-Host "[Success] PSGallery is now trusted." } else { Write-Host "[OK] PSGallery is already trusted (PackageManagement)." } } else { Write-Host "[Action] Adding PSGallery (fallback path)..." try { Register-PSRepository -Name 'PSGallery' ` -SourceLocation 'https://www.powershellgallery.com/api/v2' ` -ScriptSourceLocation 'https://www.powershellgallery.com/api/v2' ` -InstallationPolicy Trusted -ErrorAction Stop Write-Host "[Success] PSGallery registered and trusted." } catch { Write-Host "Error: Could not register PSGallery (fallback path): $($_.Exception.Message)" -ForegroundColor Red } } } catch { Write-Host "Error: Failed to evaluate or set PSGallery trust state: $($_.Exception.Message)" -ForegroundColor Red } } function Use-Tls12 { <# .SYNOPSIS Ensures TLS 1.2 for outbound HTTPS in Windows PowerShell 5.x. .DESCRIPTION Adds TLS 1.2 to [Net.ServicePointManager]::SecurityProtocol for the current session. Prevents "Could not create SSL/TLS secure channel" when using PowerShellGet/NuGet. .EXAMPLE Use-Tls12 #> [CmdletBinding()] param() $tls12 = [Net.SecurityProtocolType]::Tls12 if (([Net.ServicePointManager]::SecurityProtocol -band $tls12) -eq 0) { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor $tls12 } } function Test-PSGalleryConnectivity { <# .SYNOPSIS Fast connectivity test to PowerShell Gallery with HEAD→GET fallback. .DESCRIPTION Attempts a HEAD request to https://www.powershellgallery.com/api/v2/. If the server returns 405 (Method Not Allowed), retries with GET. Considers HTTP 200–399 as reachable. Writes status and returns $true/$false. .EXAMPLE Test-PSGalleryConnectivity .OUTPUTS System.Boolean #> [CmdletBinding()] param() $url = 'https://www.powershellgallery.com/api/v2/' $timeoutMs = 5000 function Invoke-WebCheck { param([string]$Method) try { $req = [System.Net.HttpWebRequest]::Create($url) $req.Method = $Method $req.Timeout = $timeoutMs $req.ReadWriteTimeout = $timeoutMs $req.AllowAutoRedirect = $true $req.UserAgent = 'WindowsPowerShell/5.1 PSGalleryConnectivityCheck' # NOTE: No proxy credential munging here—use system defaults. $res = $req.GetResponse() $status = [int]$res.StatusCode $res.Close() if ($status -ge 200 -and $status -lt 400) { Write-Host "[OK] PSGallery reachable via $Method (HTTP $status)." return $true } else { Write-Host "Error: PSGallery returned HTTP $status on $Method." -ForegroundColor Red return $false } } catch [System.Net.WebException] { $wex = $_.Exception $resp = $wex.Response if ($resp -and $resp -is [System.Net.HttpWebResponse]) { $status = [int]$resp.StatusCode $resp.Close() if ($status -eq 405 -and $Method -eq 'HEAD') { # Fallback handled by caller return $null } Write-Host "Error: PSGallery $Method failed (HTTP $status): $($wex.Message)" -ForegroundColor Red return $false } else { Write-Host "Error: PSGallery $Method failed: $($wex.Message)" -ForegroundColor Red return $false } } catch { Write-Host "Error: PSGallery $Method failed: $($_.Exception.Message)" -ForegroundColor Red return $false } } # Try HEAD first for speed; if 405, fall back to GET. $headResult = Invoke-WebCheck -Method 'HEAD' if ($headResult -eq $true) { return $true } if ($null -eq $headResult) { # 405 from HEAD → retry with GET $getResult = Invoke-WebCheck -Method 'GET' return [bool]$getResult } return $false } function Initialize-NugetPackageProvider { <# .SYNOPSIS Ensures the NuGet package provider (>= 2.8.5.201) is available for the exact scope. .DESCRIPTION - Exact scope handling (AllUsers | CurrentUser). - If -Scope is omitted, resolves scope via Test-InstallationScopeCapability. - Local-first: only installs/updates when needed. - Write-Host only (PS5-compatible). .PARAMETER Scope Exact scope ('AllUsers' or 'CurrentUser'). If omitted, chosen automatically. .EXAMPLE Initialize-NugetPackageProvider .EXAMPLE Initialize-NugetPackageProvider -Scope AllUsers #> [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")] [CmdletBinding()] param( [Parameter()] [ValidateSet('AllUsers','CurrentUser')] [string]$Scope = 'CurrentUser' ) # 1) Resolve scope $resolvedScope = if ($PSBoundParameters.ContainsKey('Scope')) { Write-Host "[Init] Using explicitly provided scope: $Scope" $Scope } else { $auto = Test-InstallationScopeCapability Write-Host "[Default] No scope provided; using '$auto' based on permission check." $auto } # 2) Gate explicit AllUsers if not elevated if ($PSBoundParameters.ContainsKey('Scope') -and $resolvedScope -eq 'AllUsers' -and (Test-InstallationScopeCapability) -ne 'AllUsers') { Write-Host "Error: Requested 'AllUsers' but session is not elevated. Start PowerShell as Administrator or omit -Scope." -ForegroundColor Red Write-Host "[Result] Aborted: insufficient privileges for 'AllUsers'." return } Write-Host "[OK] Operating with scope '$resolvedScope'." # 3) Minimum required version $requiredMinVersion = [Version]'2.8.5.201' Write-Host "[Check] Minimum required NuGet provider version: $requiredMinVersion" # 4) Local detection try { Write-Host "[Check] Inspecting existing NuGet provider..." $installedProvider = Get-PackageProvider -ListAvailable -ErrorAction SilentlyContinue | Where-Object { $_.Name -ieq 'NuGet' } | Select-Object -First 1 } catch { Write-Host "Error: Failed to query package providers: $($_.Exception.Message)" -ForegroundColor Red Write-Host "[Result] Aborted: provider enumeration failed." return } $needsInstall = $true if ($installedProvider) { try { $currentVersion = [Version]$installedProvider.Version Write-Host "[Info] Found NuGet provider version: $currentVersion" $needsInstall = ($currentVersion -lt $requiredMinVersion) } catch { Write-Host "[Warn] Could not interpret provider version; will attempt reinstallation." $needsInstall = $true } } else { Write-Host "[Info] NuGet provider not found." } # 5) Install/Update if needed if ($needsInstall) { Write-Host "[Action] Installing/updating NuGet provider to >= $requiredMinVersion (Scope: $resolvedScope)..." $originalProgressPreference = $global:ProgressPreference try { $global:ProgressPreference = 'SilentlyContinue' $installCmdlet = Get-Command Install-PackageProvider -ErrorAction SilentlyContinue $installParams = @{ Name = 'NuGet' MinimumVersion = $requiredMinVersion Force = $true ErrorAction = 'Stop' } if ($installCmdlet -and $installCmdlet.Parameters.ContainsKey('Scope')) { $installParams['Scope'] = $resolvedScope } Install-PackageProvider @installParams | Out-Null Write-Host "[Success] NuGet provider installed/updated for '$resolvedScope'." Write-Host "[Result] Compliant: provider version >= $requiredMinVersion." } catch { Write-Host "Error: Installation in scope '$resolvedScope' failed: $($_.Exception.Message)" -ForegroundColor Red Write-Host "[Result] Failed: installation/update did not complete." } finally { $global:ProgressPreference = $originalProgressPreference } return } Write-Host "[Skip] Provider already meets minimum ($requiredMinVersion); no action required." Write-Host "[Result] No changes necessary." } function Initialize-PowerShellGet { <# .SYNOPSIS Ensures the PowerShellGet module is present/updated with PSGallery trusted; resolves scope automatically when omitted. .DESCRIPTION - Exact scope handling (AllUsers | CurrentUser). If -Scope not provided, resolves via Test-InstallationScopeCapability. - Local-first: if installed PowerShellGet >= minimum, no online contact is made. - Calls Initialize-NugetPackageProvider (prereq) and Set-PSGalleryTrust (trust). - Write-Host only (PS5-compatible). .PARAMETER Scope Exact scope ('AllUsers' or 'CurrentUser'). If omitted, chosen automatically. .EXAMPLE Initialize-PowerShellGet .EXAMPLE Initialize-PowerShellGet -Scope AllUsers #> [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")] [CmdletBinding()] param( [Parameter()] [ValidateSet('AllUsers','CurrentUser')] [string]$Scope = 'CurrentUser' ) # 1) Resolve scope $resolvedScope = if ($PSBoundParameters.ContainsKey('Scope')) { Write-Host "[Init] Using explicitly provided scope: $Scope" $Scope } else { $auto = Test-InstallationScopeCapability Write-Host "[Default] No scope provided; using '$auto' based on permission check." $auto } # 2) Gate explicit AllUsers if not elevated if ($PSBoundParameters.ContainsKey('Scope') -and $resolvedScope -eq 'AllUsers' -and (Test-InstallationScopeCapability) -ne 'AllUsers') { Write-Host "Error: Requested 'AllUsers' but session is not elevated. Start PowerShell as Administrator or omit -Scope." -ForegroundColor Red Write-Host "[Result] Aborted: insufficient privileges for 'AllUsers'." return } Write-Host "[OK] Operating with scope '$resolvedScope'." # 3) Minimum required version $requiredMinVersion = [Version]'2.2.5.1' Write-Host "[Check] Minimum required PowerShellGet version: $requiredMinVersion" # 4) Local detection $installed = $null try { $installed = Get-Module -ListAvailable -Name 'PowerShellGet' | Sort-Object Version -Descending | Select-Object -First 1 } catch { Write-Host "[Warn] Failed to enumerate installed PowerShellGet: $($_.Exception.Message)" } if ($installed) { Write-Host "[Info] Found PowerShellGet version: $($installed.Version) at $($installed.ModuleBase)" if ([Version]$installed.Version -ge $requiredMinVersion) { Set-PSGalleryTrust Write-Host "[Skip] Installed PowerShellGet meets minimum; no online update performed." Write-Host "[Result] No changes necessary." return } Write-Host "[Info] Installed version is below minimum; update will be attempted." } else { Write-Host "[Info] PowerShellGet not found; installation will be attempted." } # 5) Prep: Ensure NuGet provider, then trust PSGallery try { Write-Host "[Prep] Ensuring NuGet provider via Initialize-NugetPackageProvider..." Initialize-NugetPackageProvider -Scope $resolvedScope } catch { Write-Host "[Warn] Initialize-NugetPackageProvider reported an issue: $($_.Exception.Message)" } Set-PSGalleryTrust # 6) Install/Update (online only when needed) Write-Host "[Action] Installing/Updating PowerShellGet (Scope: $resolvedScope)..." $originalProgressPreference = $global:ProgressPreference try { $global:ProgressPreference = 'SilentlyContinue' $installCmdlet = Get-Command Install-Module -ErrorAction SilentlyContinue if (-not $installCmdlet) { Write-Host "Error: Install-Module is not available. Ensure PowerShellGet cmdlets are loaded." -ForegroundColor Red Write-Host "[Result] Failed: cannot proceed with installation." return } $installParams = @{ Name = 'PowerShellGet' Repository = 'PSGallery' Force = $true AllowClobber = $true ErrorAction = 'Stop' } if ($installCmdlet.Parameters.ContainsKey('Scope')) { $installParams['Scope'] = $resolvedScope } Install-Module @installParams Write-Host "[Success] PowerShellGet installed/updated successfully." Write-Host "[Result] PowerShellGet is compliant (>= $requiredMinVersion)." } catch { Write-Host "Error: Installing/Updating PowerShellGet failed: $($_.Exception.Message)" -ForegroundColor Red Write-Host "[Result] Failed: PowerShellGet could not be installed/updated." } finally { $global:ProgressPreference = $originalProgressPreference } } function Initialize-PackageManagement { <# .SYNOPSIS Ensures the PackageManagement module is present/updated for the exact scope with local-first behavior. .DESCRIPTION - Exact scope handling (AllUsers | CurrentUser). If -Scope is omitted, resolves via Test-InstallationScopeCapability. - Local-first: if installed PackageManagement >= minimum baseline, no online call is made. - Preps NuGet provider via Initialize-NugetPackageProvider; ensures PSGallery is trusted via Set-PSGalleryTrust. - Write-Host only (PS5-compatible). .PARAMETER Scope Exact scope name ('AllUsers' or 'CurrentUser'). If omitted, chosen automatically. .EXAMPLE Initialize-PackageManagement .EXAMPLE Initialize-PackageManagement -Scope AllUsers #> [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")] [CmdletBinding()] param( [Parameter()] [ValidateSet('AllUsers','CurrentUser')] [string]$Scope = 'CurrentUser' ) # 1) Resolve scope $resolvedScope = if ($PSBoundParameters.ContainsKey('Scope')) { Write-Host "[Init] Using explicitly provided scope: $Scope" $Scope } else { $auto = Test-InstallationScopeCapability Write-Host "[Default] No scope provided; using '$auto' based on permission check." $auto } # 2) Gate explicit AllUsers if not elevated if ($PSBoundParameters.ContainsKey('Scope') -and $resolvedScope -eq 'AllUsers' -and (Test-InstallationScopeCapability) -ne 'AllUsers') { Write-Host "Error: Requested 'AllUsers' but session is not elevated. Start PowerShell as Administrator or omit -Scope." -ForegroundColor Red Write-Host "[Result] Aborted: insufficient privileges for 'AllUsers'." return } Write-Host "[OK] Operating with scope '$resolvedScope'." # 3) Minimum required version $requiredMinVersion = [Version]'1.4.8.1' # Adjust baseline if your estate requires a different floor Write-Host "[Check] Minimum required PackageManagement version: $requiredMinVersion" # 4) Local detection $installed = $null try { $installed = Get-Module -ListAvailable -Name 'PackageManagement' | Sort-Object Version -Descending | Select-Object -First 1 } catch { Write-Host "[Warn] Failed to enumerate installed PackageManagement: $($_.Exception.Message)" } if ($installed) { Write-Host "[Info] Found PackageManagement version: $($installed.Version) at $($installed.ModuleBase)" if ([Version]$installed.Version -ge $requiredMinVersion) { Set-PSGalleryTrust Write-Host "[Skip] Installed PackageManagement meets minimum; no online update performed." Write-Host "[Result] No changes necessary." return } Write-Host "[Info] Installed version is below minimum; update will be attempted." } else { Write-Host "[Info] PackageManagement not found; installation will be attempted." } # 5) Prep: Ensure NuGet provider, then trust PSGallery try { Write-Host "[Prep] Ensuring NuGet provider via Initialize-NugetPackageProvider..." Initialize-NugetPackageProvider -Scope $resolvedScope } catch { Write-Host "[Warn] Initialize-NugetPackageProvider reported an issue: $($_.Exception.Message)" } Set-PSGalleryTrust # 6) Install/Update (online only when needed) Write-Host "[Action] Installing/Updating PackageManagement (Scope: $resolvedScope)..." $originalProgressPreference = $global:ProgressPreference try { $global:ProgressPreference = 'SilentlyContinue' $installCmdlet = Get-Command Install-Module -ErrorAction SilentlyContinue if (-not $installCmdlet) { Write-Host "Error: Install-Module is not available. Ensure PowerShellGet cmdlets are loaded." -ForegroundColor Red Write-Host "[Result] Failed: cannot proceed with installation." return } # Intentionally avoid Find-Module to keep offline unless installation is required. $installParams = @{ Name = 'PackageManagement' Repository = 'PSGallery' Force = $true AllowClobber = $true SkipPublisherCheck = $true ErrorAction = 'Stop' } if ($installCmdlet.Parameters.ContainsKey('Scope')) { $installParams['Scope'] = $resolvedScope } try { Install-Module @installParams Write-Host "[Success] PackageManagement installed/updated successfully." Write-Host "[Result] PackageManagement is compliant (>= $requiredMinVersion)." } catch { Write-Host "Error: Install-Module for PackageManagement failed: $($_.Exception.Message)" -ForegroundColor Red # Fallback in case the module exists but is locked/older in certain paths try { Write-Host "[Fallback] Attempting Update-Module -Name PackageManagement -Force ..." Update-Module -Name 'PackageManagement' -Force -ErrorAction Stop Write-Host "[Success] Update-Module completed." Write-Host "[Result] PackageManagement updated." } catch { Write-Host "Error: Update-Module failed: $($_.Exception.Message)" -ForegroundColor Red Write-Host "[Result] Failed: PackageManagement not updated." } } } finally { $global:ProgressPreference = $originalProgressPreference } } function Initialize-PowerShellBootstrap { <# .SYNOPSIS Runs the initialization sequence on Windows PowerShell 5.x only (skips on PS 6/7+). .DESCRIPTION - Detects edition/version; exits early on PowerShell Core (6/7+). - On PS5.x: - Enables TLS 1.2 (local, idempotent). - Resolves effective scope (or honors -Scope). - Applies PSGallery trust (local-only). - Proceeds with NuGet → PowerShellGet → PackageManagement only if PSGallery connectivity succeeds. .PARAMETER Scope Optional exact scope ('AllUsers' or 'CurrentUser'). If omitted, scope is resolved via Test-InstallationScopeCapability. .EXAMPLE Initialize-PowerShellBootstrap .EXAMPLE Initialize-PowerShellBootstrap -Scope AllUsers #> [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")] [CmdletBinding()] param( [Parameter()] [ValidateSet('AllUsers','CurrentUser')] [string]$Scope ) Write-Host "[Bootstrap] Starting PowerShell environment initialization..." $psVer = $PSVersionTable.PSVersion $psEd = $PSVersionTable.PSEdition $isWinPS5 = ($psEd -eq 'Desktop' -and $psVer.Major -eq 5) if (-not $isWinPS5) { Write-Host "[Bootstrap] Detected PowerShell $psVer ($psEd). Skipping Windows PowerShell 5.x bootstrap; nothing to do." return } Write-Host "[Bootstrap] Detected Windows PowerShell $psVer ($psEd). Continuing with PS5-specific bootstrap..." # 1) TLS 1.2 for PS5 sessions (local, safe) Use-Tls12 # 2) Resolve scope once (info only; initializers still enforce their own gates) $resolvedScope = if ($PSBoundParameters.ContainsKey('Scope')) { Write-Host "[Bootstrap] Using explicit scope: $Scope" $Scope } else { $auto = Test-InstallationScopeCapability Write-Host "[Bootstrap] No scope provided; resolved effective scope: $auto" $auto } # 3) Local-only step first (no network) Write-Host "[Bootstrap] Applying local PSGallery trust state..." Set-PSGalleryTrust # 4) Connectivity gate for online steps Write-Host "[Bootstrap] Checking PSGallery connectivity..." if (-not (Test-PSGalleryConnectivity)) { Write-Host "Error: PSGallery not reachable. Online initialization steps will be skipped." -ForegroundColor Red Write-Host "[Bootstrap] Result: Partial (local trust applied)." return } # 5) Online steps in recommended order Write-Host "[Bootstrap] Connectivity OK. Proceeding with online steps..." Initialize-NugetPackageProvider -Scope $resolvedScope Initialize-PowerShellGet -Scope $resolvedScope Initialize-PackageManagement -Scope $resolvedScope Write-Host "[Bootstrap] Completed successfully." } function Initialize-PowerShellMiniBootstrap { <# .SYNOPSIS Performs a minimal, non-interactive bootstrap for Windows PowerShell 5.x (CurrentUser scope): enables TLS 1.2, ensures the NuGet provider (>= 2.8.5.201), trusts PSGallery, installs/updates PowerShellGet and PackageManagement if newer, and imports them; silently skips on PowerShell 6/7+. #> param() $Install=@('PowerShellGet','PackageManagement');$Scope='CurrentUser';if($PSVersionTable.PSVersion.Major -ne 5){return};[Net.ServicePointManager]::SecurityProtocol=[Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12; $minNuget=[Version]'2.8.5.201'; Install-PackageProvider -Name NuGet -MinimumVersion $minNuget -Scope $Scope -Force -ForceBootstrap | Out-Null; try { Set-PSRepository -Name PSGallery -InstallationPolicy Trusted -ErrorAction Stop } catch { Register-PSRepository -Name PSGallery -SourceLocation 'https://www.powershellgallery.com/api/v2' -ScriptSourceLocation 'https://www.powershellgallery.com/api/v2' -InstallationPolicy Trusted -ErrorAction Stop }; Find-Module -Name $Install -Repository PSGallery | Select-Object Name,Version | Where-Object { -not (Get-Module -ListAvailable -Name $_.Name | Sort-Object Version -Descending | Select-Object -First 1 | Where-Object Version -eq $_.Version) } | ForEach-Object { Install-Module -Name $_.Name -RequiredVersion $_.Version -Repository PSGallery -Scope $Scope -Force -AllowClobber; try { Remove-Module -Name $_.Name -ErrorAction SilentlyContinue } catch {}; Import-Module -Name $_.Name -MinimumVersion $_.Version -Force } } function Import-Script { <# .SYNOPSIS Optionally dot-sources one or more scripts if they exist (PowerShell 5 compatible). .DESCRIPTION Checks each provided path. If the file exists, it is dot-sourced so any functions/variables defined inside become available in the current scope. To place them in the caller (script) scope, dot-invoke this function. .PARAMETER File One or more script paths to import. Variables like $PSScriptRoot are expanded. .PARAMETER ErrorIfMissing If set, emits a non-terminating error for each missing file (continues processing others). .EXAMPLE . Import-Script -File "$PSScriptRoot\psutility\common.ps1","$PSScriptRoot\psutility\dotnetlist.ps1" .NOTES Dot-invoke this function (leading '.') to ensure imported definitions land in the caller's scope. #> [CmdletBinding()] param( [Parameter(Mandatory=$true, Position=0)] [string[]]$File, [switch]$ErrorIfMissing ) foreach ($f in $File) { if ([string]::IsNullOrWhiteSpace($f)) { continue } # External reviewer note: Expand variables (e.g., $PSScriptRoot) before existence check. $expanded = $ExecutionContext.InvokeCommand.ExpandString($f) if (Test-Path -LiteralPath $expanded) { Write-Host "Import-Script: dot-sourcing '$expanded'." . $expanded } else { Write-Host "Import-Script: not found, skipped -> '$expanded'." if ($ErrorIfMissing) { Write-Error "Import-Script: file not found: $expanded" } } } } function Export-OfflineModuleBundle { <# .SYNOPSIS Stage PSGallery modules into Root\Nuget, copy NuGet provider into Root\Provider, and emit an offline installer script. (PS 5.1) .DESCRIPTION Resolves modules (including dependencies) using Find-Module -IncludeDependencies, then downloads each as a .nupkg via Save-Package into Root\Nuget. Copies the local NuGet provider DLLs into Root\Provider so an offline machine can bootstrap the provider. Always emits "Install-ModulesFromRepoFolder.ps1" in the root folder, which contains the installer function plus a ready-to-run invocation that targets the folder it resides in. .REQUIREMENTS Machine A (online, where you run this Save function): - Windows PowerShell 5.1. - Working internet access to https://www.powershellgallery.com/api/v2 . - PackageManagement module available (built-in on PS 5.1). - PowerShellGet v2 available (built-in on PS 5.1; can be upgraded but not required). - NuGet package provider already installed and functional (Save-Package must work). - TLS 1.2 allowed outbound (this function enables TLS 1.2 for the process if needed). - Write permissions to the specified -Folder path. Artifacts created under the root -Folder: - Nuget\ : contains the downloaded .nupkg files for the specified modules and their dependencies. - Provider\: contains NuGet provider DLLs copied from the local machine (used to bootstrap offline). - Install-ModulesFromRepoFolder.ps1: the self-contained offline installer and invocation line. Machine B (offline, where you will run the emitted installer): - Windows PowerShell 5.1. - PackageManagement and PowerShellGet present (the old in-box versions are fine; they will be upgraded). - Local admin rights required ONLY if you intend to install for AllUsers; otherwise CurrentUser is fine. - ExecutionPolicy must allow running the emitted .ps1 (e.g., set to RemoteSigned/Bypass as appropriate). - No internet is required; all content comes from the copied Root folder. - Write permissions to ProgramFiles (if installing for AllUsers) or to user Documents (CurrentUser). Failure cases to be aware of: - If NuGet provider is not present on Machine B and Provider\ is missing or incomplete, install will fail. - If the Nuget\ folder does not contain a requested module (name mismatch or missing package), only that module fails. - Locked module directories or insufficient permissions can prevent installation (especially AllUsers scope). .PARAMETER Folder Root folder that will contain "Nuget" and "Provider". Created if missing. .PARAMETER Name One or more module names to stage. .PARAMETER Version (Optional) Exact version to stage for all names; latest if omitted. .EXAMPLE PS> Export-OfflineModuleBundle -Folder C:\temp\export -Name @('PowerShellGet','PackageManagement','Pester','PSScriptAnalyzer','Eigenverft.Manifested.Drydock') .TROUBLESHOOTING - On Machine B, if the script reports missing NuGet provider, verify the "Provider" folder exists and contains NuGet*.dll. - If Install-Module errors with repository issues, confirm the "Nuget" folder exists and holds the .nupkg files. - If execution is blocked, set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass for the current session. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Folder, [Parameter(Mandatory, Position=1)] [string[]]$Name, [string]$Version ) # TLS 1.2 for PSGallery on PS 5.1 try { if (-not ([Net.ServicePointManager]::SecurityProtocol -band [Net.SecurityProtocolType]::Tls12)) { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } } catch { } if (-not (Test-Path -LiteralPath $Folder)) { New-Item -ItemType Directory -Path $Folder -Force | Out-Null } $nugetDir = Join-Path $Folder "Nuget" $providerDir = Join-Path $Folder "Provider" if (-not (Test-Path -LiteralPath $nugetDir)) { New-Item -ItemType Directory -Path $nugetDir -Force | Out-Null } if (-not (Test-Path -LiteralPath $providerDir)) { New-Item -ItemType Directory -Path $providerDir -Force | Out-Null } $feed = "https://www.powershellgallery.com/api/v2" $repo = "PSGallery" # Resolve full dependency closure with PowerShellGet (works on PS5.1) # Use a name->version map so we can Save-Package without IncludeDependencies. $needed = @{} foreach ($n in $Name) { try { $findParams = @{ Name = $n; Repository = $repo; ErrorAction = "Stop"; IncludeDependencies = $true } if ($Version) { $findParams["RequiredVersion"] = $Version } $mods = Find-Module @findParams foreach ($m in $mods) { # Record the highest version seen for each name (simple dedupe) if (-not $needed.ContainsKey($m.Name)) { $needed[$m.Name] = $m.Version } else { try { # Compare as [version]; fallback to string compare if needed $cur = [version]$needed[$m.Name] $new = [version]$m.Version if ($new -gt $cur) { $needed[$m.Name] = $m.Version } } catch { if ($m.Version -gt $needed[$m.Name]) { $needed[$m.Name] = $m.Version } } } } } catch { Write-Error "Failed to resolve '$n' from $($repo): $($_.Exception.Message)" } } # Fall back: if dependency resolution returned nothing, at least try the requested names if ($needed.Count -eq 0) { foreach ($n in $Name) { $needed[$n] = $Version } } # Download each required module version via Save-Package (no IncludeDependencies for compatibility) foreach ($pair in $needed.GetEnumerator()) { $mn = $pair.Key $mv = $pair.Value try { $p = @{ Name = $mn Path = $nugetDir ProviderName = "NuGet" Source = $feed ErrorAction = "Stop" } if ($mv) { $p["RequiredVersion"] = $mv } [void](Save-Package @p) } catch { Write-Error "Failed to save '$mn' into '$nugetDir': $($_.Exception.Message)" } } # Copy NuGet provider DLLs for offline bootstrap (search ProgramFiles, LocalAppData, ProgramData) $providerCandidates = @( (Join-Path $Env:ProgramFiles "PackageManagement\ProviderAssemblies\NuGet"), (Join-Path $Env:LOCALAPPDATA "PackageManagement\ProviderAssemblies\NuGet"), (Join-Path $Env:ProgramData "PackageManagement\ProviderAssemblies\NuGet") ) | Where-Object { Test-Path -LiteralPath $_ } foreach ($src in $providerCandidates) { try { Copy-Item -Path (Join-Path $src "*") -Destination $providerDir -Recurse -Force -ErrorAction SilentlyContinue } catch { Write-Verbose "Provider copy from '$src' failed: $($_.Exception.Message)" } } # Emit installer script with function + invocation using $PSScriptRoot (UTF-8 for path safety) $installerPath = Join-Path $Folder "Install-ModulesFromRepoFolder.ps1" $functionText = @' function Install-ModulesFromRepoFolder { <# .SYNOPSIS Install modules from Root\Nuget using a temporary PSRepository; bootstrap NuGet provider from Root\Provider. (PS 5.1) .REQUIREMENTS - Windows PowerShell 5.1. - Root folder contains: - Provider\ with NuGet*.dll for offline bootstrap (if provider is missing). - Nuget\ with staged .nupkg files. - If installing for AllUsers, run elevated. .DESCRIPTION 1) If NuGet provider is missing, copy DLLs from Root\Provider to the proper provider path: - AllUsers (admin): %ProgramFiles%\PackageManagement\ProviderAssemblies\NuGet - CurrentUser (non-admin): %LocalAppData%\PackageManagement\ProviderAssemblies\NuGet 2) Register Root\Nuget as a temporary repository. 3) Install PackageManagement, then PowerShellGet, then remaining modules. Use -AllowClobber and -SkipPublisherCheck to handle in-box command collisions and publisher changes. 4) Unregister the temporary repository. .PARAMETER Folder Root folder containing Nuget and optionally Provider. .PARAMETER Name Module names to install from Root\Nuget. .PARAMETER Scope CurrentUser (default) or AllUsers. .EXAMPLE PS> Install-ModulesFromRepoFolder -Folder C:\repo -Name Pester,PSScriptAnalyzer #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Folder, [Parameter(Mandatory, Position=1)] [string[]]$Name, [ValidateSet("CurrentUser","AllUsers")] [string]$Scope = "CurrentUser" ) Write-Host "[INFO] Starting offline installation..." Write-Host ("[INFO] Root folder: {0}" -f $Folder) if (-not (Test-Path -LiteralPath $Folder)) { throw "Folder not found: $Folder" } $nugetDir = Join-Path $Folder "Nuget" $providerDir = Join-Path $Folder "Provider" if (-not (Test-Path -LiteralPath $nugetDir)) { throw "Required subfolder missing: $nugetDir" } # Determine elevation and pick provider target accordingly $isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent() ).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) $targetProviderRoot = if ($isAdmin -or $Scope -eq "AllUsers") { Join-Path $Env:ProgramFiles "PackageManagement\ProviderAssemblies\NuGet" } else { Join-Path $Env:LOCALAPPDATA "PackageManagement\ProviderAssemblies\NuGet" } # Ensure NuGet provider from Provider if missing $nuget = Get-PackageProvider -Name NuGet -ListAvailable -ErrorAction SilentlyContinue if (-not $nuget) { Write-Host "[INFO] NuGet provider not found. Attempting offline bootstrap from Provider folder..." if (-not (Test-Path -LiteralPath $providerDir)) { throw ("NuGet provider not found. Expected staged provider under '{0}'." -f $providerDir) } if (-not (Test-Path -LiteralPath $targetProviderRoot)) { New-Item -ItemType Directory -Path $targetProviderRoot -Force | Out-Null } Copy-Item -Path (Join-Path $providerDir "*") -Destination $targetProviderRoot -Recurse -Force -ErrorAction Stop $nuget = Get-PackageProvider -Name NuGet -ListAvailable -ErrorAction SilentlyContinue if (-not $nuget) { throw "NuGet provider bootstrap failed after copy." } Write-Host ("[OK] NuGet provider bootstrapped to: {0}" -f $targetProviderRoot) } else { Write-Host "[OK] NuGet provider is available." } # Register temp repo at Root\Nuget $repoName = ("TempRepo_{0}" -f ([Guid]::NewGuid().ToString("N").Substring(0,8))) Write-Host ("[INFO] Registering temporary repository '{0}' at: {1}" -f $repoName, $nugetDir) Register-PSRepository -Name $repoName -SourceLocation $nugetDir -PublishLocation $nugetDir -InstallationPolicy Trusted try { # Priority install: PackageManagement, then PowerShellGet $priority = @("PackageManagement","PowerShellGet") function Test-PackagePresent([string]$moduleName, [string]$rootNuget) { $pattern = Join-Path $rootNuget ("{0}*.nupkg" -f $moduleName) return (Test-Path -Path $pattern) } foreach ($m in $priority) { if (($Name -contains $m) -or (Test-PackagePresent -moduleName $m -rootNuget $nugetDir)) { Write-Host ("[INFO] Installing priority module: {0}" -f $m) try { Install-Module -Name $m -Repository $repoName -Scope $Scope -Force -AllowClobber -SkipPublisherCheck -ErrorAction Stop Write-Host ("[OK] Installed: {0}" -f $m) } catch { Write-Error ("Failed to install priority module '{0}' from '{1}': {2}" -f $m, $nugetDir, $_.Exception.Message) } } } # Install remaining requested modules $remaining = $Name | Where-Object { $priority -notcontains $_ } foreach ($n in $remaining) { Write-Host ("[INFO] Installing module: {0}" -f $n) try { Install-Module -Name $n -Repository $repoName -Scope $Scope -Force -AllowClobber -SkipPublisherCheck -ErrorAction Stop Write-Host ("[OK] Installed: {0}" -f $n) } catch { Write-Error ("Failed to install '{0}' from '{1}': {2}" -f $n, $nugetDir, $_.Exception.Message) } } Write-Host "[OK] Installation sequence completed." } finally { Write-Host ("[INFO] Unregistering temporary repository: {0}" -f $repoName) try { Unregister-PSRepository -Name $repoName -ErrorAction SilentlyContinue } catch { } } [void](Read-Host "Press Enter to continue") } '@ $namesList = ($Name -join ",") $usageLine = 'Install-ModulesFromRepoFolder -Folder "$PSScriptRoot" -Name ' + $namesList ($functionText + "`r`n" + $usageLine + "`r`n") | Out-File -FilePath $installerPath -Encoding utf8 -Force # NEW: emit a convenience CMD launcher in the root that runs the PS1 $cmdPath = Join-Path $Folder "Install-ModulesFromRepoFolder.cmd" $cmdText = "@echo off`r`n" + "setlocal`r`n" + "powershell.exe -NoProfile -ExecutionPolicy Unrestricted -File ""%~dp0Install-ModulesFromRepoFolder.ps1""`r`n" + "endlocal`r`n" $cmdText | Out-File -FilePath $cmdPath -Encoding ASCII -Force # Return staged package paths for confirmation Get-ChildItem -LiteralPath $nugetDir -Filter *.nupkg | Select-Object -ExpandProperty FullName } |