Private/Settings.ps1

# Copyright (c) 2026 Broadcom. All Rights Reserved.
# Broadcom Confidential. The term "Broadcom" refers to Broadcom Inc.
# and/or its subsidiaries.
#
# =============================================================================
#
# SOFTWARE LICENSE AGREEMENT
#
# Copyright (c) CA, Inc. All rights reserved.
#
# You are hereby granted a non-exclusive, worldwide, royalty-free license
# under CA, Inc.'s copyrights to use, copy, modify, and distribute this
# software in source code or binary form for use in connection with CA, Inc.
# products.
#
# This copyright notice shall be included in all copies or substantial
# portions of the software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
#
# =============================================================================
#region Settings

function Resolve-VcfCheckAllowInsecureTls {

    <#
        .SYNOPSIS
        Resolves whether this run should accept untrusted/self-signed TLS certificates.

        .DESCRIPTION
        VcfCheck has no setting of its own for this - the decision is derived entirely from
        PowerCLI's own InvalidCertificateAction setting (Get-PowerCLIConfiguration -Scope
        Session), so every connector (Connect-VIServer, Connect-VcfOpsServer, and the hand-written
        REST helpers for Aria Automation/VRSLCM/NSX Manager) makes the same choice an operator
        already made once via `Set-PowerCLIConfiguration -Scope User -InvalidCertificateAction
        Ignore` (lab, self-signed certificates) or the default `Fail`/`Warn` (production). Logs
        the resolved value unconditionally (INFO), not only when insecure TLS is allowed, since
        that is the most common source of "a check accepted/rejected an untrusted cert
        unexpectedly". Never throws - a failed read defaults to $false (secure).

        .OUTPUTS
        [Bool] $true when untrusted/self-signed certificates should be accepted this run.

        .EXAMPLE
        $allowInsecureTls = Resolve-VcfCheckAllowInsecureTls
    #>


    [CmdletBinding()]
    [OutputType([Bool])]
    Param ()

    $allowInsecureTls = $false
    try {
        $invalidCertificateAction = (Get-PowerCLIConfiguration -Scope Session -ErrorAction Stop).InvalidCertificateAction
        $allowInsecureTls = $invalidCertificateAction -eq 'Ignore'
        Write-LogMessage -Type INFO -Message "PowerCLI InvalidCertificateAction is `"$invalidCertificateAction`" - untrusted/self-signed certificates on SDDC Manager, vCenter, NSX Manager, Aria Operations, Aria Automation, and Aria Suite Lifecycle Manager will $(if ($allowInsecureTls) { 'be accepted' } else { 'NOT be accepted' }) this run. Change with `"Set-PowerCLIConfiguration -Scope User -InvalidCertificateAction Ignore`" (lab environments with self-signed certificates only) or `"...-InvalidCertificateAction Fail`" (production)."
    } catch {
        Write-LogMessage -Type DEBUG -Message "Could not read PowerCLI's InvalidCertificateAction (defaulting to secure - untrusted certificates will NOT be accepted this run): $($_.Exception.Message)"
    }

    return $allowInsecureTls
}
function Get-VcfCheckSettings {

    <#
        .SYNOPSIS
        Loads and validates settings.json.

        .DESCRIPTION
        settings.json may only carry SddcManagerFqdn and SddcManagerUser - per requirements,
        the password is never persisted to disk and is always resolved separately (see
        Get-VcfCheckCredential). Returns $null if Path does not exist so callers can fall
        back entirely to interactive/explicit-parameter input.

        Whether untrusted/self-signed certificates are accepted is not a settings.json key at
        all - it is derived from PowerCLI's own InvalidCertificateAction setting (see
        Invoke-VcfCheck in Orchestrator.ps1), so it is intentionally absent from this function's
        output.

        .PARAMETER Path
        Path to settings.json. When omitted, resolved from
        $env:VcfCheckBaseDirectory\Config\settings.json.

        .OUTPUTS
        [PSCustomObject] with SddcManagerFqdn/SddcManagerUser, or $null if no settings file
        exists.

        .EXAMPLE
        $settings = Get-VcfCheckSettings -Path '~/VcfCheck/Config/settings.json'
    #>


    [CmdletBinding()]
    [OutputType([PSObject])]
    Param (
        [Parameter(Mandatory = $false)] [AllowEmptyString()] [String]$Path = ''
    )

    $resolvedPath = $Path
    if ([String]::IsNullOrWhiteSpace($resolvedPath)) {
        if ([String]::IsNullOrWhiteSpace($env:VcfCheckBaseDirectory)) {
            return $null
        }
        $resolvedPath = Join-Path -Path $env:VcfCheckBaseDirectory.Trim() -ChildPath (Join-Path -Path $Script:CHECK_CONFIG_DIR_NAME -ChildPath $Script:CHECK_SETTINGS_FILE_NAME)
    }

    if (-not (Test-Path -LiteralPath $resolvedPath -PathType Leaf)) {
        Write-LogMessage -Type DEBUG -Message "No settings file found at `"$resolvedPath`" - will rely on explicit parameters or interactive prompts."
        return $null
    }

    try {
        $raw = Get-Content -LiteralPath $resolvedPath -Raw -ErrorAction Stop | ConvertFrom-Json -Depth 10 -ErrorAction Stop
    } catch {
        throw [System.InvalidOperationException]::new("Failed to parse settings file `"$resolvedPath`": $($_.Exception.Message)")
    }

    if ('SddcManagerPassword' -in $raw.PSObject.Properties.Name) {
        Write-LogMessage -Type WARNING -Message "settings.json at `"$resolvedPath`" contains an SddcManagerPassword field. This value will be ignored - VcfCheck never reads a password from disk."
    }

    $missingKeys = @('SddcManagerFqdn', 'SddcManagerUser') | Where-Object { [String]::IsNullOrWhiteSpace($raw.$_) }
    if ($missingKeys.Count -gt 0) {
        throw [System.InvalidOperationException]::new("Settings file `"$resolvedPath`" is missing required key(s): $($missingKeys -join ', ')")
    }

    return [PSCustomObject]@{
        SddcManagerFqdn = $raw.SddcManagerFqdn
        SddcManagerUser = $raw.SddcManagerUser
    }
}
function Get-VcfCheckThemePreference {

    <#
        .SYNOPSIS
        Resolves the browser's saved dark/light theme preference from settings.json.

        .DESCRIPTION
        Tools/vcf-check-ui.html persists the user's theme toggle to settings.json's Theme key via
        /api/settings so the browser report reopens in the same theme. Export-VcfCheckReportHtml
        reads it back through this function so the static Findings-folder report matches - without
        it, the static report always renders in the CSS default (dark) regardless of what the user
        last chose. Falls back to 'dark' (the CSS default) whenever settings.json is missing,
        unreadable, or has no recognised Theme value, so a bad read degrades to the existing
        behaviour rather than throwing.

        .PARAMETER Path
        Path to settings.json. When omitted, resolved from
        $env:VcfCheckBaseDirectory\Config\settings.json.

        .OUTPUTS
        [String] either 'light' or 'dark'.
    #>


    [CmdletBinding()]
    [OutputType([String])]
    Param (
        [Parameter(Mandatory = $false)] [AllowEmptyString()] [String]$Path = ''
    )

    $resolvedPath = $Path
    if ([String]::IsNullOrWhiteSpace($resolvedPath)) {
        if ([String]::IsNullOrWhiteSpace($env:VcfCheckBaseDirectory)) {
            return 'dark'
        }
        $resolvedPath = Join-Path -Path $env:VcfCheckBaseDirectory.Trim() -ChildPath (Join-Path -Path $Script:CHECK_CONFIG_DIR_NAME -ChildPath $Script:CHECK_SETTINGS_FILE_NAME)
    }

    if (-not (Test-Path -LiteralPath $resolvedPath -PathType Leaf)) {
        return 'dark'
    }

    try {
        $raw = Get-Content -LiteralPath $resolvedPath -Raw -ErrorAction Stop | ConvertFrom-Json -Depth 10 -ErrorAction Stop
    } catch {
        Write-LogMessage -Type DEBUG -Message "Failed to read theme preference from `"$resolvedPath`": $($_.Exception.Message). Defaulting to dark."
        return 'dark'
    }

    if ($raw.Theme -eq 'light') {
        return 'light'
    }
    return 'dark'
}
function Get-VcfCheckCredential {

    <#
        .SYNOPSIS
        Resolves the SDDC Manager FQDN, username, and password to connect with.

        .DESCRIPTION
        Precedence for Fqdn/User: explicit parameter > settings.json > interactive Read-Host.
        The Password is NEVER read from or written to settings.json - if not passed explicitly
        as a SecureString, the operator is always prompted via Read-Host -AsSecureString.

        .PARAMETER Settings
        Result of Get-VcfCheckSettings, or $null.

        .PARAMETER SddcManagerFqdn
        Explicit FQDN override.

        .PARAMETER SddcManagerUser
        Explicit username override.

        .PARAMETER SddcManagerPassword
        Explicit SecureString override. When omitted, the operator is prompted.

        .OUTPUTS
        [PSCustomObject] with Fqdn, User, Password (SecureString).

        .EXAMPLE
        $cred = Get-VcfCheckCredential -Settings $settings
    #>


    [CmdletBinding()]
    [OutputType([PSObject])]
    Param (
        [Parameter(Mandatory = $false)] [AllowNull()] [PSObject]$Settings = $null,
        [Parameter(Mandatory = $false)] [AllowEmptyString()] [String]$SddcManagerFqdn = '',
        [Parameter(Mandatory = $false)] [AllowEmptyString()] [String]$SddcManagerUser = '',
        [Parameter(Mandatory = $false)] [AllowNull()] [SecureString]$SddcManagerPassword = $null
    )

    $fqdn = $SddcManagerFqdn
    if ([String]::IsNullOrWhiteSpace($fqdn)) { $fqdn = $Settings.SddcManagerFqdn }
    while ([String]::IsNullOrWhiteSpace($fqdn)) {
        $fqdn = Read-Host -Prompt 'Enter the SDDC Manager FQDN'
    }

    $user = $SddcManagerUser
    if ([String]::IsNullOrWhiteSpace($user)) { $user = $Settings.SddcManagerUser }
    while ([String]::IsNullOrWhiteSpace($user)) {
        $user = Read-Host -Prompt 'Enter the SDDC Manager username'
    }

    $password = $SddcManagerPassword
    if ($null -eq $password) {
        $password = Read-Host -Prompt "Enter the password for $user@$fqdn" -AsSecureString
    }
    if ($password.Length -eq 0) {
        throw [System.InvalidOperationException]::new('SDDC Manager password must not be empty.')
    }

    return [PSCustomObject]@{
        Fqdn     = $fqdn
        User     = $user
        Password = $password
    }
}
function Test-VcfCheckDependencies {

    <#
        .SYNOPSIS
        Checks that VcfCheck's prerequisites are present before initializing.

        .DESCRIPTION
        Collects every unmet requirement before returning, rather than failing fast on the first miss, so
        a user sees the full remediation list in one pass. PowerShell version, VCF.PowerCLI, and
        PowerCLI's DefaultVIServerMode session setting are hard requirements (checks cannot run
        without them - VCF Check connects to more than one vCenter Server in the same session);
        Python is a soft requirement (only needed to view reports via Start-VcfCheckServer -
        Invoke-VcfCheck itself does not need it) and is reported as a Warning-level finding, not
        a failure.

        .OUTPUTS
        [PSObject] with IsSatisfied (bool, considers only hard requirements) and Findings
        (string[], one line per unmet requirement including soft ones).

        .EXAMPLE
        $deps = Test-VcfCheckDependencies
        if (-not $deps.IsSatisfied) { $deps.Findings | ForEach-Object { Write-Warning $_ } }
    #>


    [CmdletBinding()]
    [OutputType([PSObject])]
    Param ()

    $findings = [System.Collections.Generic.List[String]]::new()
    $hardRequirementsMet = $true
    $minimumPowerShellVersion = [Version]'7.4'
    $minimumPythonVersion = [Version]'3.13'

    if ($PSVersionTable.PSVersion -lt $minimumPowerShellVersion) {
        $findings.Add("PowerShell $minimumPowerShellVersion or later is required (found $($PSVersionTable.PSVersion)). Install a current PowerShell release from https://github.com/PowerShell/PowerShell.")
        $hardRequirementsMet = $false
    }

    $vcfPowerCli = Get-Module -ListAvailable -Name 'VCF.PowerCLI' -ErrorAction SilentlyContinue | Sort-Object -Property Version -Descending | Select-Object -First 1
    if (-not $vcfPowerCli) {
        $vmwarePowerCli = Get-Module -ListAvailable -Name 'VMware.PowerCLI' -ErrorAction SilentlyContinue | Select-Object -First 1
        if ($vmwarePowerCli) {
            $findings.Add('VCF.PowerCLI is required and was not found, but the conflicting VMware.PowerCLI module is installed. Remove it first with: Uninstall-Module -Name VMware.PowerCLI -AllVersions, then install VCF.PowerCLI with: Install-Module -Name VCF.PowerCLI -Scope CurrentUser')
        } else {
            $findings.Add('VCF.PowerCLI is required and was not found. Install it with: Install-Module -Name VCF.PowerCLI -Scope CurrentUser')
        }
        $hardRequirementsMet = $false
    }

    $viServerMode = (Get-PowerCLIConfiguration -Scope Session -ErrorAction SilentlyContinue).DefaultVIServerMode
    if ($viServerMode -ne 'Multiple') {
        $findings.Add("PowerCLI DefaultVIServerMode is `"$viServerMode`", not `"Multiple`" (required to connect to more than one vCenter Server in the same session). Run: Set-PowerCLIConfiguration -DefaultVIServerMode Multiple -Scope Session")
        $hardRequirementsMet = $false
    }

    $pythonCommand = Get-Command -Name python3 -ErrorAction SilentlyContinue
    if (-not $pythonCommand) {
        $pythonCommand = Get-Command -Name python -ErrorAction SilentlyContinue
    }
    if (-not $pythonCommand) {
        $findings.Add('(Optional) Python 3.13+ was not found - the web UI (Start-VcfCheckServer) will not be usable until it is installed.')
    } else {
        try {
            $versionOutput = & $pythonCommand.Source '--version' 2>&1
            if ($versionOutput -match '(\d+)\.(\d+)\.(\d+)') {
                $foundVersion = [Version]"$($Matches[1]).$($Matches[2]).$($Matches[3])"
                if ($foundVersion -lt $minimumPythonVersion) {
                    $findings.Add("(Optional) Python $minimumPythonVersion or later is recommended for the web UI (found $foundVersion).")
                }
            }
        } catch {
            $findings.Add("(Optional) Could not determine the Python version at `"$($pythonCommand.Source)`": $($_.Exception.Message)")
        }
    }

    return [PSCustomObject]@{
        IsSatisfied = $hardRequirementsMet
        Findings    = $findings.ToArray()
    }
}
function Test-VcfCheckModuleUpdate {

    <#
        .SYNOPSIS
        Checks whether a newer VcfCheck version is published on the PowerShell Gallery.

        .DESCRIPTION
        Queries the PowerShell Gallery (NuGet v2 OData query against
        https://www.powershellgallery.com/api/v2/FindPackagesById()). This is explicitly
        provisional: the project's actual plan is a private depot for distributing this module,
        not the public PowerShell Gallery (VcfCheck may never be published there at all) - so
        this check fails silently (returns UpdateAvailable=$false, Error populated) on any
        network/parse failure rather than treating "can't reach the Gallery" as noteworthy in an
        air-gapped lab environment, which is the expected common case, not an error condition
        worth alarming the user about. Never called automatically - opt-in only.

        .OUTPUTS
        [PSObject] with CurrentVersion, LatestVersion, UpdateAvailable, GalleryUrl, Error.

        .EXAMPLE
        Test-VcfCheckModuleUpdate
    #>


    [CmdletBinding()]
    [OutputType([PSObject])]
    Param ()

    $currentVersion = $Script:VcfCheckVersion
    $galleryUrl = 'https://www.powershellgallery.com/packages/VcfCheck'

    try {
        $apiUrl = "https://www.powershellgallery.com/api/v2/FindPackagesById()?id='VcfCheck'&`$orderby=Version%20desc&`$top=1"
        [Xml]$response = Invoke-RestMethod -Uri $apiUrl -Method Get -ErrorAction Stop
        $versionNode = $response.feed.entry.properties.Version
        if (-not $versionNode) {
            return [PSCustomObject]@{ CurrentVersion = $currentVersion; LatestVersion = $null; UpdateAvailable = $false; GalleryUrl = $galleryUrl; Error = 'VcfCheck is not published on the PowerShell Gallery (expected - this project plans to use a private depot instead).' }
        }

        $latestVersion = [Version]$versionNode
        $updateAvailable = $latestVersion -gt [Version]$currentVersion

        return [PSCustomObject]@{
            CurrentVersion  = $currentVersion
            LatestVersion   = $versionNode
            UpdateAvailable = $updateAvailable
            GalleryUrl      = $galleryUrl
            Error           = $null
        }
    } catch {
        return [PSCustomObject]@{ CurrentVersion = $currentVersion; LatestVersion = $null; UpdateAvailable = $false; GalleryUrl = $galleryUrl; Error = $_.Exception.Message }
    }
}
function New-VcfCheckOwnerOnlyDirectory {

    <#
        .SYNOPSIS
        Creates a directory restricted to the current user on both Windows and non-Windows platforms.

        .DESCRIPTION
        Wraps New-Item -ItemType Directory so every directory this module creates under the base
        working directory is non-world-readable from the moment it exists, with no TOCTOU window.
        On non-Windows platforms, sets mode 0700 immediately after creation. On Windows, disables
        inherited permissions and grants only the current user FullControl, with container/object
        inheritance so files and subdirectories created later inherit the same restriction.
        Permissions are re-applied every time this function is called for a given path - including
        when the directory already exists - so a directory created by an older version of this
        module is self-healed the next time Initialize-VcfCheck touches it. Permission-hardening
        failures are logged as WARNING and are non-fatal; directory-creation failures propagate
        exactly as New-Item would.

        .PARAMETER Path
        Full path of the directory to create.

        .PARAMETER Force
        Suppresses the error when the directory already exists and creates any missing
        intermediate parent directories, matching New-Item -Force semantics.

        .OUTPUTS
        [System.IO.DirectoryInfo]

        .EXAMPLE
        $null = New-VcfCheckOwnerOnlyDirectory -Path $findingsDir -Force
    #>


    [CmdletBinding()]
    [OutputType([System.IO.DirectoryInfo])]
    Param (
        [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$Path,
        [Parameter(Mandatory = $false)] [Switch]$Force
    )

    $directoryInfo = New-Item -ItemType Directory -Path $Path -Force:$Force.IsPresent

    if (-not $IsWindows) {
        & chmod 700 $Path 2>$null
        if ($LASTEXITCODE -ne 0) {
            Write-LogMessage -Type WARNING -Message "New-VcfCheckOwnerOnlyDirectory: chmod 700 failed (exit $LASTEXITCODE) on `"$Path`". Directory may be readable by other OS users."
        }
    } else {
        try {
            $acl = Get-Acl -Path $Path
            $acl.SetAccessRuleProtection($true, $false)
            $currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
            $rule = [System.Security.AccessControl.FileSystemAccessRule]::new(
                $currentUser,
                [System.Security.AccessControl.FileSystemRights]::FullControl,
                ([System.Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [System.Security.AccessControl.InheritanceFlags]::ObjectInherit),
                [System.Security.AccessControl.PropagationFlags]::None,
                [System.Security.AccessControl.AccessControlType]::Allow
            )
            $acl.AddAccessRule($rule)
            Set-Acl -Path $Path -AclObject $acl
        } catch {
            Write-LogMessage -Type WARNING -Message "New-VcfCheckOwnerOnlyDirectory: Could not restrict ACL on `"$Path`": $($_.Exception.Message). Directory may be readable by other OS users."
        }
    }

    return $directoryInfo
}
function Resolve-VcfCheckBaseDirectory {

    <#
        .SYNOPSIS
        Resolves the VcfCheck base directory interactively.

        .DESCRIPTION
        Handles three cases in order:
          1. $env:VcfCheckBaseDirectory is set and the path does not exist - clears the stale
             session value and falls through to the prompt.
          2. $env:VcfCheckBaseDirectory is set and is a valid directory - offers the operator
             the choice to keep it or pick a different one.
          3. No env var set - prompts with the default path as the proposed value.

        Returns the operator-chosen (or defaulted) absolute path, or $null when the session is
        non-interactive (Read-Host throws) or when the chosen path falls outside $HOME.

        .PARAMETER DefaultBaseDirectory
        Default directory path shown to the operator at the prompt.

        .OUTPUTS
        [String] Absolute resolved base directory path, or $null on failure.

        .NOTES
        Write-Host is the primary output mechanism in this function; all Write-Host calls are
        intentional interactive console output. Use Write-LogMessage for diagnostic logging.

        .EXAMPLE
        $baseDir = Resolve-VcfCheckBaseDirectory -DefaultBaseDirectory "$HOME/VcfCheck"
    #>


    [CmdletBinding()]
    [OutputType([String])]
    Param (
        [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$DefaultBaseDirectory
    )

    $envRaw = $env:VcfCheckBaseDirectory

    if (-not [String]::IsNullOrWhiteSpace($envRaw)) {
        $trimmed = $envRaw.Trim()

        if (-not (Test-Path -LiteralPath $trimmed)) {
            Write-Host ''
            Write-Host " Note: `$env:VcfCheckBaseDirectory pointed at a path that does not exist:" -ForegroundColor Yellow
            Write-Host " $trimmed" -ForegroundColor White
            $env:VcfCheckBaseDirectory = $null
            Write-Host ' Stale value cleared from session. Choose a folder below.' -ForegroundColor Green
        } elseif (Test-Path -LiteralPath $trimmed -PathType Container) {
            Write-Host " Detected: `$env:VcfCheckBaseDirectory is set to $trimmed" -ForegroundColor Green
            try {
                $response = Read-Host ' Keep this directory or set a different one? [(K)eep / (C)hange, default: K]'
            } catch {
                Write-LogMessage -Type ERROR -Message "Initialize-VcfCheck requires an interactive session to change the base directory. $($_.Exception.Message)"
                return $null
            }
            if ($response.Trim() -inotmatch '^c(hange)?$') {
                # K, Enter, or anything other than C -> keep the existing directory.
                return (Resolve-Path -LiteralPath $trimmed -ErrorAction Stop).Path
            }
            # C -> fall through to the path prompt so the operator can choose a new directory.
        }
    }

    Write-Host " Default base directory: $DefaultBaseDirectory" -ForegroundColor White
    Write-Host ''
    try {
        $inputValue = Read-Host 'Press Enter to use the default, or type a full directory path'
    } catch {
        Write-LogMessage -Type ERROR -Message "Initialize-VcfCheck requires an interactive session. $($_.Exception.Message)"
        return $null
    }

    $chosen = if ([String]::IsNullOrWhiteSpace($inputValue)) { $DefaultBaseDirectory } else { $inputValue.Trim() }

    if (-not [System.IO.Path]::IsPathRooted($chosen)) {
        $chosen = Join-Path -Path $HOME -ChildPath $chosen
    }
    $chosen = [System.IO.Path]::GetFullPath($chosen)

    $homeFull = [System.IO.Path]::GetFullPath($HOME)
    $separator = [System.IO.Path]::DirectorySeparatorChar
    if (-not $chosen.StartsWith($homeFull + $separator, [StringComparison]::OrdinalIgnoreCase) -and $chosen -ine $homeFull) {
        Write-LogMessage -Type ERROR -Message "BaseDirectory must be within the home directory. Chosen: `"$chosen`"."
        return $null
    }

    return $chosen
}
function Resolve-VcfCheckActiveBaseDirectory {

    <#
        .SYNOPSIS
        Resolves the current session's VcfCheck base directory, offering to run
        Initialize-VcfCheck on the operator's behalf if none is configured.

        .DESCRIPTION
        Server-lifecycle entry points (Start-VcfCheckServer, Invoke-VcfCheck) need
        $env:VcfCheckBaseDirectory to be a valid directory before they can do anything useful.
        Rather than throwing a raw exception - which surfaces a PowerShell stack trace for what is
        really a one-time setup step - this offers to run Initialize-VcfCheck right there in an
        interactive session, and falls back to a plain, actionable log message otherwise.

        .OUTPUTS
        [String] The resolved base directory path, or $null if none is configured and the
        operator declined setup (or the session is non-interactive).

        .NOTES
        Write-Host is the primary output mechanism in this function; all Write-Host calls are
        intentional interactive console output. Use Write-LogMessage for diagnostic logging.

        .EXAMPLE
        $baseDirectory = Resolve-VcfCheckActiveBaseDirectory
    #>


    [CmdletBinding()]
    [OutputType([String])]
    Param ()

    $existing = ([String]$env:VcfCheckBaseDirectory).Trim()
    if (-not [String]::IsNullOrWhiteSpace($existing) -and (Test-Path -LiteralPath $existing -PathType Container)) {
        return $existing
    }

    Write-Host ''
    Write-Host ' VcfCheck has not been set up in this session yet.' -ForegroundColor Yellow
    try {
        $response = Read-Host ' Run initial setup now (Initialize-VcfCheck)? [(Y)es / (N)o, default: Y]'
    } catch {
        Write-LogMessage -Type ERROR -Message "VcfCheck has not been set up. Run Initialize-VcfCheck first, or pass -BaseDirectory/-OutputPath explicitly. $($_.Exception.Message)"
        return $null
    }

    if ($response.Trim() -imatch '^n(o)?$') {
        Write-LogMessage -Type WARNING -Message 'Setup skipped. Run Initialize-VcfCheck when you are ready.'
        return $null
    }

    return Initialize-VcfCheck
}
function Invoke-VcfCheckPersistBaseDirectory {

    <#
        .SYNOPSIS
        Persists $env:VcfCheckBaseDirectory for the current session and future sessions.

        .DESCRIPTION
        Sets the environment variable for the current session. On Windows, also writes it to the
        user environment registry via [System.Environment]::SetEnvironmentVariable so new
        sessions and Explorer-launched processes inherit it. On all platforms, writes or updates
        the assignment in $PROFILE, replacing any stale prior value via regex rather than blindly
        appending - ensuring clean configuration over time.

        .PARAMETER ResolvedBaseDirectory
        Fully resolved absolute path to persist.

        .PARAMETER ProfilePath
        Overrides $PROFILE - exists solely so tests can redirect profile writes to a temp file
        instead of the real one. Defaults to $PROFILE when omitted.

        .OUTPUTS
        None. Mutates $env:VcfCheckBaseDirectory, the Windows user environment (Windows only),
        and $PROFILE (or -ProfilePath).
    #>


    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$ResolvedBaseDirectory,
        [Parameter(Mandatory = $false)] [AllowEmptyString()] [String]$ProfilePath = ''
    )

    $resolvedProfilePath = $ProfilePath
    if ([String]::IsNullOrWhiteSpace($resolvedProfilePath)) {
        $resolvedProfilePath = $PROFILE
    }

    $env:VcfCheckBaseDirectory = $ResolvedBaseDirectory

    if ($IsWindows) {
        try {
            [System.Environment]::SetEnvironmentVariable($Script:VCF_CHECK_ENV_VAR, $ResolvedBaseDirectory, [System.EnvironmentVariableTarget]::User)
            $verifyValue = [System.Environment]::GetEnvironmentVariable($Script:VCF_CHECK_ENV_VAR, [System.EnvironmentVariableTarget]::User)
            if ($verifyValue -ne $ResolvedBaseDirectory) {
                Write-LogMessage -Type WARNING -Message "VcfCheckBaseDirectory registry write appeared to succeed but read-back returned `"$verifyValue`"."
            }
        } catch {
            Write-LogMessage -Type WARNING -Message "Could not persist VcfCheckBaseDirectory to the user environment: $($_.Exception.Message)"
        }
    }

    $profileLine = "`$env:$($Script:VCF_CHECK_ENV_VAR) = `"$ResolvedBaseDirectory`""
    try {
        $profileDir = Split-Path -Path $resolvedProfilePath -Parent
        if (-not (Test-Path -LiteralPath $profileDir)) {
            $null = New-VcfCheckOwnerOnlyDirectory -Path $profileDir -Force
        }
        if (-not (Test-Path -LiteralPath $resolvedProfilePath)) {
            $null = New-Item -ItemType File -Path $resolvedProfilePath -Force
        }

        $existingContent = Get-Content -LiteralPath $resolvedProfilePath -Raw -ErrorAction SilentlyContinue
        if ($null -eq $existingContent) { $existingContent = '' }

        if ($existingContent -notmatch [Regex]::Escape($profileLine)) {
            $stalePattern = "(?m)^\`$env:$($Script:VCF_CHECK_ENV_VAR)\s*=\s*[`"'][^`"']*[`"']\r?\n?"
            $cleanedContent = $existingContent -replace $stalePattern, ''
            Set-Content -LiteralPath $resolvedProfilePath -Value ($cleanedContent.TrimEnd() + "`n$profileLine") -Encoding UTF8 -NoNewline
        }
    } catch {
        Write-LogMessage -Type WARNING -Message "Could not update `$PROFILE (`"$resolvedProfilePath`"): $($_.Exception.Message)"
    }
}
function Initialize-VcfCheck {

    <#
        .SYNOPSIS
        Sets up the user working directory for VcfCheck on first use.

        .DESCRIPTION
        Copies the module's bundled Data/, Docs/, Config/, and Tools/ files into a user working
        directory (default ~/VcfCheck) and persists the base directory to
        $env:VcfCheckBaseDirectory for the current session, the Windows user environment (on
        Windows), and $PROFILE (for future sessions on any platform). The module tree itself
        stays read-only/versioned; the user copy is where settings.json, findings, logs, and
        server state files actually live.

        When -BaseDirectory is omitted (and this isn't a -RefreshTools/-RefreshData-only run),
        prompts interactively via Resolve-VcfCheckBaseDirectory instead of silently defaulting
        - offering to keep an already-configured directory, change it, or pick one from scratch.
        Pass -BaseDirectory explicitly to skip the prompt entirely (e.g. from a script or test).

        .PARAMETER BaseDirectory
        Target working directory. When omitted, resolved interactively (default proposed:
        "$HOME/VcfCheck") unless -RefreshTools/-RefreshData is specified and
        $env:VcfCheckBaseDirectory already points at a valid directory, in which case that
        existing directory is used directly.

        .PARAMETER RefreshTools
        When specified, overwrites Tools/ in the working directory with the module's bundled
        copy even if the working directory already exists (use after a module upgrade). Implies
        partial-refresh mode: skips the dependency check and the interactive directory prompt -
        unless $env:VcfCheckBaseDirectory is not set or no longer exists, in which case it
        falls back to full interactive setup instead of failing.

        .PARAMETER RefreshData
        When specified, overwrites Data/ and Docs/ in the working directory with the module's
        bundled copy.
        Implies partial-refresh mode: skips the dependency check and the interactive directory prompt -
        unless $env:VcfCheckBaseDirectory is not set or no longer exists, in which case it
        falls back to full interactive setup instead of failing.

        .PARAMETER SkipDependencyCheck
        Skip Test-VcfCheckDependencies. Intended for -RefreshTools/-RefreshData-only re-runs
        where the environment was already validated on first init.

        .OUTPUTS
        [String] The resolved base directory path, or $null if requirements were not met or no
        base directory could be resolved (already logged via Write-LogMessage -Type ERROR).

        .NOTES
        Write-Host is the primary output mechanism in this function; all Write-Host calls are
        intentional interactive console output. Use Write-LogMessage for diagnostic logging.

        .EXAMPLE
        Initialize-VcfCheck

        .EXAMPLE
        Initialize-VcfCheck -BaseDirectory ~/VcfCheckLab -SkipDependencyCheck

        .EXAMPLE
        Initialize-VcfCheck -RefreshTools -RefreshData
    #>


    [CmdletBinding()]
    [OutputType([String])]
    Param (
        [Parameter(Mandatory = $false)] [AllowEmptyString()] [String]$BaseDirectory = '',
        [Parameter(Mandatory = $false)] [Switch]$RefreshTools,
        [Parameter(Mandatory = $false)] [Switch]$RefreshData,
        [Parameter(Mandatory = $false)] [Switch]$SkipDependencyCheck
    )

    $isPartialRefresh = $RefreshTools.IsPresent -or $RefreshData.IsPresent

    if (-not $SkipDependencyCheck.IsPresent -and -not $isPartialRefresh) {
        $dependencies = Test-VcfCheckDependencies
        foreach ($finding in $dependencies.Findings) {
            Write-LogMessage -Type WARNING -Message $finding
        }
        if (-not $dependencies.IsSatisfied) {
            Write-LogMessage -Type ERROR -Message "VcfCheck's requirements are not met - see the warnings above for remediation steps."
            return $null
        }
        if ($dependencies.Findings.Count -gt 0) {
            # All findings reaching here are soft (Python-only) - IsSatisfied would have already
            # returned above otherwise. Still worth an explicit choice, not a silent warn-and-
            Write-Host ''
            Write-Host ' Setup can continue, but the web UI will not be usable until Python is installed (see the warning above).' -ForegroundColor Yellow
            try {
                $response = Read-Host ' Continue setup anyway? [(Y)es / (N)o, default: N]'
            } catch {
                $response = 'N'
            }
            if ($response.Trim() -inotmatch '^y(es)?$') {
                Write-LogMessage -Type WARNING -Message 'Setup cancelled. Install Python 3.13+, then re-run Initialize-VcfCheck.'
                return $null
            }
        }
    }

    $resolvedBase = $BaseDirectory
    if ([String]::IsNullOrWhiteSpace($resolvedBase)) {
        $existingBase = ([String]$env:VcfCheckBaseDirectory).Trim()
        $existingBaseIsValid = (-not [String]::IsNullOrWhiteSpace($existingBase)) -and (Test-Path -LiteralPath $existingBase -PathType Container)

        if ($isPartialRefresh -and $existingBaseIsValid) {
            $resolvedBase = $existingBase
        } else {
            if ($isPartialRefresh) {
                Write-LogMessage -Type WARNING -Message '$env:VcfCheckBaseDirectory is not set or does not exist - running full setup instead of a partial refresh.'
            }
            $defaultBase = Join-Path -Path $HOME -ChildPath $Script:VCF_CHECK_DEFAULT_DIR
            $resolvedBase = Resolve-VcfCheckBaseDirectory -DefaultBaseDirectory $defaultBase
            if ([String]::IsNullOrWhiteSpace($resolvedBase)) {
                return $null
            }
        }
    }

    $moduleRoot = Split-Path -Parent $PSScriptRoot

    if (-not (Test-Path -LiteralPath $resolvedBase -PathType Container)) {
        $null = New-VcfCheckOwnerOnlyDirectory -Path $resolvedBase -Force
    }

    $subDirs = @($Script:CHECK_CONFIG_DIR_NAME, $Script:CHECK_DATA_DIR_NAME, $Script:CHECK_DOCS_DIR_NAME, $Script:CHECK_FINDINGS_DIR_NAME, $Script:CHECK_LOGS_DIR_NAME, $Script:CHECK_RUN_DIR_NAME, $Script:CHECK_TOOLS_DIR_NAME)
    foreach ($subDir in $subDirs) {
        $target = Join-Path -Path $resolvedBase -ChildPath $subDir
        if (-not (Test-Path -LiteralPath $target -PathType Container)) {
            $null = New-VcfCheckOwnerOnlyDirectory -Path $target -Force
        }
    }

    $dataSource = Join-Path -Path $moduleRoot -ChildPath $Script:CHECK_DATA_DIR_NAME
    $dataTarget = Join-Path -Path $resolvedBase -ChildPath $Script:CHECK_DATA_DIR_NAME
    if ((Test-Path -LiteralPath $dataSource) -and ($RefreshData -or -not (Test-Path -LiteralPath (Join-Path -Path $dataTarget -ChildPath 'CheckCatalog.json')))) {
        Copy-Item -Path (Join-Path -Path $dataSource -ChildPath '*') -Destination $dataTarget -Recurse -Force
        Write-LogMessage -Type INFO -Message "Copied check catalog data to `"$dataTarget`"."
    }

    $docsSource = Join-Path -Path $moduleRoot -ChildPath $Script:CHECK_DOCS_DIR_NAME
    $docsTarget = Join-Path -Path $resolvedBase -ChildPath $Script:CHECK_DOCS_DIR_NAME
    $readmeSource = Join-Path -Path $docsSource -ChildPath 'README.html'
    if ((Test-Path -LiteralPath $readmeSource) -and ($RefreshData -or -not (Test-Path -LiteralPath (Join-Path -Path $docsTarget -ChildPath 'README.html')))) {
        Copy-Item -Path $readmeSource -Destination $docsTarget -Force
        Write-LogMessage -Type DEBUG -Message "Copied documentation to `"$docsTarget`"."
    }

    $configSource = Join-Path -Path $moduleRoot -ChildPath $Script:CHECK_CONFIG_DIR_NAME
    $configTarget = Join-Path -Path $resolvedBase -ChildPath $Script:CHECK_CONFIG_DIR_NAME
    $settingsExample = Join-Path -Path $configSource -ChildPath 'settings.example.json'
    if (Test-Path -LiteralPath $settingsExample) {
        Copy-Item -Path $settingsExample -Destination $configTarget -Force
    }

    $toolsSource = Join-Path -Path $moduleRoot -ChildPath $Script:CHECK_TOOLS_DIR_NAME
    $toolsTarget = Join-Path -Path $resolvedBase -ChildPath $Script:CHECK_TOOLS_DIR_NAME
    foreach ($toolFile in $Script:CHECK_TOOL_FILE_NAMES) {
        $source = Join-Path -Path $toolsSource -ChildPath $toolFile
        $target = Join-Path -Path $toolsTarget -ChildPath $toolFile
        if ((Test-Path -LiteralPath $source) -and ($RefreshTools -or -not (Test-Path -LiteralPath $target))) {
            $targetDir = Split-Path -Path $target -Parent
            if (-not (Test-Path -LiteralPath $targetDir -PathType Container)) {
                $null = New-VcfCheckOwnerOnlyDirectory -Path $targetDir -Force
            }
            Copy-Item -Path $source -Destination $target -Force
            Write-LogMessage -Type DEBUG -Message "Copied tool file `"$toolFile`" to `"$toolsTarget`"."
        }
    }

    Invoke-VcfCheckPersistBaseDirectory -ResolvedBaseDirectory $resolvedBase

    Write-LogMessage -Type INFO -Message "VcfCheck initialized at `"$resolvedBase`"."
    return $resolvedBase
}

#endregion Settings