Public/Get-WinEOL.ps1

function Get-WinEOL {
    <#
    .SYNOPSIS
        Retrieves product EOL information and lifecycle status.
 
    .DESCRIPTION
        The Get-WinEOL cmdlet fetches product lifecycle data from the endoflife.date API.
        It supports wildcard searching and rich object output
        including calculated status (Active, NearEOL, EOL) and days remaining.
 
        It also includes smart fallback logic for complex products like 'windows-11' that are part of the 'windows' product availability.
         
        If run without parameters, it attempts to detect the current system's OS version and edition to return relevant EOL information.
 
    .PARAMETER ProductName
        The name of the product to query (e.g., 'windows-11', 'windows-server-2022').
        Supports wildcards (e.g., 'windows-*').
 
    .PARAMETER Release
        A specific release to query.
 
    .PARAMETER Latest
        Switch to return only the latest release.
 
    .PARAMETER Pro
        Filter for 'Pro' edition (implies *-W suffix).
 
    .PARAMETER HomeEdition
        Filter for 'Home' edition (implies *-W suffix). Alias: Home.
 
    .PARAMETER Enterprise
        Filter for 'Enterprise' edition (implies *-E suffix).
 
    .PARAMETER Education
        Filter for 'Education' edition (implies *-E suffix).
 
    .PARAMETER IoT
        Filter for 'IoT' edition (implies *-E suffix).
 
    .PARAMETER Version
        Filter by version/feature release (e.g., '25H2', '24H2', '23H2'). Supports wildcards.
        Filters results where the cycle contains the specified version string.
 
    .PARAMETER Status
        Filter by lifecycle status. Options: 'All', 'Active', 'EOL', 'NearEOL'. Default is 'All'.
 
    .EXAMPLE
        Get-WinEOL -ProductName "windows-11"
        Retrieves all Windows 11 release information.
 
    .EXAMPLE
        Get-WinEOL -ProductName "windows-11" -Version "25H2"
        Retrieves Windows 11 25H2 release information.
 
    .EXAMPLE
        Get-WinEOL -ProductName "windows-server-*" -Status Active
        Retrieves all active Windows Server versions.
 
    .EXAMPLE
        Get-WinEOL -ProductName "windows-server-2022" -Latest
    .PARAMETER ListAvailable
        Forces the listing of all available Windows products (default wildcard search), bypassing auto-detection.
        Alias: List
 
    .EXAMPLE
        Get-WinEOL -ListAvailable
        Lists all Windows products support by the API (windows-*).
 
    .RELATEDLINKS
        https://deepwiki.com/DailenG/WinEOL
    #>

    [CmdletBinding(DefaultParameterSetName = 'Default')]
    param(
        [Parameter(Mandatory = $false, Position = 0, ValueFromPipeline = $true)]
        [string]$ProductName = 'windows-*',

        [Parameter()]
        [Alias('List')]
        [switch]$ListAvailable,

        [Parameter()]
        [string]$Release,

        [Parameter()]
        [switch]$Latest,

        [Parameter()]
        [string]$Version,

        # Edition Filters (Implied naming convention handling)
        [Parameter(ParameterSetName = 'Default')]
        [switch]$Pro,
        [Parameter(ParameterSetName = 'Default')]
        [Alias('Home')]
        [switch]$HomeEdition,
        [Parameter(ParameterSetName = 'Default')]
        [switch]$Workstation,
        [Parameter(ParameterSetName = 'Default')]
        [switch]$Enterprise,
        [Parameter(ParameterSetName = 'Default')]
        [switch]$Education,
        [Parameter(ParameterSetName = 'Default')]
        [switch]$IoT,

        # Status Filter
        [Parameter()]
        [ValidateSet('All', 'Active', 'EOL', 'NearEOL')]
        [string]$Status = 'All'
    )

    process {
        # Input Validation (Security & Ruggedness)
        # Auto-detect system if no param provided
        if (-not $PSBoundParameters.ContainsKey('ProductName') -and -not $ListAvailable -and $ProductName -eq 'windows-*') {
            Write-Verbose "No parameters provided. Detecting current system..."
            try {
                $osInfo = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
                # ProductType: 1 = Client, 2 = Domain Controller, 3 = Server
                if ($osInfo.ProductType -eq 1) {
                    # Client
                    $ver = [System.Environment]::OSVersion.Version
                    # Windows 11 check (Build >= 22000)
                    $clientVer = if ($ver.Build -ge 22000) { "11" } else { "10" }
                    $ProductName = "windows-$clientVer"
                    
                    # Get DisplayVersion (e.g. 22H2)
                    $reg = Get-ItemProperty "hkLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion" -ErrorAction SilentlyContinue
                    if ($reg.DisplayVersion) {
                        $Version = $reg.DisplayVersion
                    }
                    
                    # Edition Check for Suffix
                    # Detect Enterprise/Education vs Consumer
                    # OperatingSystemSKU is reliable.
                    # 4=Enterprise, 27=Enterprise N, 70=Enterprise E, 72=Enterprise Eval, 121=Education, 122=Education N, 125=Enterprise LTSC
                    $sku = $osInfo.OperatingSystemSKU
                    if ($sku -in @(4, 27, 70, 72, 121, 122, 125, 126, 161, 162)) {
                        $Enterprise = $true
                    }
                    else {
                        # Default to Consumer (Pro/Home)
                        $Pro = $true
                    }
                }
                else {
                    # Server
                    # Extract year from Caption e.g. "Microsoft Windows Server 2019 Datacenter"
                    if ($osInfo.Caption -match 'Server\s+(\d{4})') {
                        $year = $matches[1]
                        $ProductName = "windows-server-$year"
                    }
                    else {
                        $ProductName = 'windows-server'
                    }
                }
                Write-Verbose "Auto-detected: $ProductName, Version: $Version, Enterprise: $Enterprise, Pro: $Pro"
            }
            catch {
                Write-Warning "Failed to detect system info: $_. Falling back to default search."
            }
        }

        # Validate ProductName AFTER auto-detection
        # allow alphanumeric, hyphens, and wildcards.
        if ($ProductName -notmatch '^[a-zA-Z0-9\-\*\.]+$') {
            Throw "Invalid ProductName '$ProductName'. Product names must only contain letters, numbers, hyphens, periods, or wildcards (*). This check prevents malformed requests."
        }

        # Handle implied product name suffix (-W / -E)
        $suffixFilter = $null
        if ($Pro -or $HomeEdition -or $Workstation) { $suffixFilter = "*W" }
        if ($Enterprise -or $Education -or $IoT) { $suffixFilter = "*E" }

        # 1. Wildcard Handling
        if ($ProductName -match '\*') {
            Write-Verbose "Wildcard detected in '$ProductName'. Fetching all products to search."
            
            $allProducts = $null
            
            try {
                $response = (Invoke-RestMethod "https://endoflife.date/api/v1/products" -ErrorAction Stop)
                # Extract product names from v1 API response
                $allProducts = $response.result | Select-Object -ExpandProperty name
            }
            catch {
                Write-Error "Failed to fetch product list: $_"
                return
            }

            $foundProducts = $allProducts | Where-Object { $_ -like $ProductName }
            
            # Note: Suffix filter on *Product Names* works if products are named "foo-w".
            # But for Windows 11, the suffix applies to *Releases*.
            # If the product list logic matches "windows-11", we recurse.
            # But "windows-11" isn't in product list.
            # So "windows-*" matches "windows", "windows-server".
            # Then we call Get-WinEOL "windows" ... filtering happens there?
            # Issue: "windows" contains *all* versions.
            # If user asks for "windows-*", they get "windows" product (all releases).
            # We might want to filter the *Output* of Get-WinEOL "windows" based on the Wildcard?
            # Complex. For now, basic wildcard matches Product Slugs.

            if (-not $foundProducts) {
                Write-Warning "No products found matching '$ProductName'."
                return
            }

            foreach ($m in $foundProducts) {
                Get-WinEOL -ProductName $m -Status $Status -Latest:$Latest -Version $Version -Pro:$Pro -HomeEdition:$HomeEdition -Enterprise:$Enterprise -Education:$Education -IoT:$IoT -Workstation:$Workstation
            }
            return
        }

        # 2. Specific Product Handling
        $url = "https://endoflife.date/api/v1/products/$($ProductName)"
        if ($Release) { $url += "/releases/$Release" }
        elseif ($Latest) { $url += "/releases/latest" }

        $fallbackMode = $false
        $fallbackFilter = $null
        $results = @()
        $data = $null

        try {
            $response = Invoke-RestMethod -Uri $url -Method Get -ErrorAction Stop
            $data = $response
            # Normalize API response (Handle 'result.releases' wrapper vs direct array)
            if ($data.result -and $data.result.releases) {
                $data = $data.result.releases
            }

            if ($Latest) { $data = @($data) } 
        }
        catch {
            $err = $_
            if ($err.Exception.Response.StatusCode -eq 404) {
                # Smart Fallback Logic
                if ($ProductName -match '^windows-(\d+(\.\d+)?)$') {
                    Write-Verbose "Detected Windows version '$($matches[1])'. Redirecting to 'windows' product."
                    $fallbackProduct = 'windows'
                    $fallbackFilter = $matches[1] + "*"
                    $fallbackMode = $true
                }
                elseif ($ProductName -match '^windows-server-(.*)$') {
                    Write-Verbose "Detected Windows Server version '$($matches[1])'. Redirecting to 'windows-server' product."
                    $fallbackProduct = 'windows-server'
                    $fallbackFilter = "*" + $matches[1] + "*" 
                    # Note: Server versions are like "2019", "2012-r2". Regex capture needs match.
                    # windows-server-2019 -> match 1 = 2019. Filter *2019*.
                    $fallbackMode = $true
                }
                    
                if ($fallbackMode) {
                    # Recursive call with the base product, then we filter results
                    # BUT we can't easily recurse and filter inside.
                    # We will fetch the base product data manually here.
                    try {
                        $url = "https://endoflife.date/api/v1/products/$fallbackProduct"
                        $data = Invoke-RestMethod -Uri $url -ErrorAction Stop
                        
                        # Normalize Fallback Data
                        if ($data.result -and $data.result.releases) {
                            $data = $data.result.releases
                        }
                    }
                    catch {
                        Write-Error "Failed to fetch fallback product '$fallbackProduct': $_"
                        return
                    }
                }
                else {
                    Write-Warning "Product '$ProductName' not found."
                    # Fuzzy (simplified)
                    return
                }
            }
            else {
                Write-Error "API Error: $($err.Message)"
                return
            }
        }


        foreach ($item in $data) {
            # Normalize Cycle/Name
            $cycle = if ($item.cycle) { $item.cycle } else { $item.name }
             
            # Apply Fallback Filter (e.g. only show "11" cycle for "windows-11" request)
            if ($fallbackMode) {
                if ($cycle -notlike $fallbackFilter -and $item.name -notlike $fallbackFilter) { continue }
            }
             
            # Apply Suffix Filter (Pro/Home/etc)
            if ($suffixFilter) {
                if ($item.name -notlike $suffixFilter) { continue }
            }

            # Apply Version Filter
            if ($Version) {
                $versionPattern = "*$Version*"
                if ($cycle -notlike $versionPattern) { continue }
            }

            # Calculate Days Remaining
            $eolDate = $null
            $days = 0
            $statusStr = "Active"
            $isSupported = $true
             
            # Handle both API formats: v1 API uses 'eolFrom', direct API uses 'eol'
            $eolValue = if ($item.eolFrom) { $item.eolFrom } else { $item.eol }
            
            # Check if already marked as EOL (v1 API)
            if ($item.PSObject.Properties['isEol'] -and $item.isEol -eq $true) {
                $statusStr = "EOL"
                $isSupported = $false
                if ($eolValue -as [DateTime]) {
                    $eolDate = [DateTime]$eolValue
                    $days = ($eolDate - (Get-Date)).Days
                }
            }
            elseif ($eolValue -and $eolValue -ne $true -and $eolValue -ne $false) {
                if ($eolValue -as [DateTime]) {
                    $eolDate = [DateTime]$eolValue
                    $days = ($eolDate - (Get-Date)).Days
                    
                    if ($days -lt 0) { 
                        $statusStr = "EOL" 
                        $isSupported = $false
                    }
                    elseif ($days -le 60) { $statusStr = "NearEOL" }
                }
            }
            elseif ($eolValue -eq $true) {
                # Boolean true usually means EOL in the past or simple "Yes"
                $statusStr = "EOL"
                $isSupported = $false
            }

            # Add Properties by creating new object with all properties
            $objProps = [ordered]@{}
            
            $releaseDate = $null
            if ($item.releaseDate -and ($item.releaseDate -as [DateTime])) {
                $releaseDate = [DateTime]$item.releaseDate
            }

            # Copy existing properties except ones we'll override
            $excludeProps = @('Cycle', 'EOL', 'DaysRemaining', 'Status', 'IsSupported', 'Product', 'ReleaseDate')
            foreach ($prop in $item.PSObject.Properties) {
                if ($prop.Name -notin $excludeProps) {
                    $objProps[$prop.Name] = $prop.Value
                }
            }
            
            # Add calculated properties
            $objProps['Cycle'] = $cycle
            $objProps['ReleaseDate'] = $releaseDate
            $objProps['EOL'] = $eolDate
            $objProps['DaysRemaining'] = $days
            $objProps['Status'] = $statusStr
            $objProps['IsSupported'] = $isSupported
            $objProps['Product'] = $ProductName
            
            $obj = [PSCustomObject]$objProps
             
            # Add TypeName
            $obj.PSTypeNames.Insert(0, "WinEOL.ProductInfo")
             
            # Status Filter
            if ($Status -ne 'All' -and $statusStr -ne $Status) { continue }

            $results += $obj
        }
        
        return $results
    }
}