PwSh.Fw.NodeJs.psm1

## @file PwShNode.psm1
## @brief Cross-platform PowerShell module for managing Node.js installations
## @requires PowerShell 7.0+

$Script:RELEASEURL = "https://api.github.com/repos/nodejs/node/releases"
if ($IsWindows -or $PSVersionTable.Platform -eq 'Win32NT') {
    $Script:configDir = Join-Path $env:LOCALAPPDATA 'PwSh.Fw'
} else {
    # Linux/macOS: use XDG_CONFIG_HOME or ~/.config
    $configHome = $env:XDG_CONFIG_HOME
    if ([string]::IsNullOrEmpty($configHome)) {
        $configHome = Join-Path $HOME '.config'
    }
    $Script:configDir = Join-Path $configHome 'pwsh.fw'
}
if (!(Test-Path $Script:configDir)) {
    New-Item -ItemType Directory -Path $Script:configDir -Force | Out-Null
}
$Script:ConfigFile = Join-Path $Script:configDir 'pwsh.fw.nodejs.json'

<#
.SYNOPSIS
    Retrieves Node.js version information.
 
.DESCRIPTION
    Queries the GitHub API to retrieve the latest available Node.js version,
    either the Current (latest release) or LTS (Long Term Support) version.
 
.PARAMETER Channel
    The release channel to use: 'Current' for the latest version or 'LTS' for long-term support.
    Default: 'LTS'
 
.OUTPUTS
    PSCustomObject containing Version, Name, Channel, PublishedAt and Assets.
 
.EXAMPLE
    Get-PwShNodeVersion -Channel LTS
    Retrieves information about the latest LTS version.
 
.EXAMPLE
    Get-PwShNodeVersion -Channel Current
    Retrieves information about the latest Current version.
 
.NOTES
    This function queries the GitHub API and may be subject to rate limiting.
#>

function Get-PwShNodeVersion {
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    Param (
        [Parameter(Mandatory = $false)]
        [ValidateSet('Current', 'LTS')]
        [string]$Channel = 'LTS'
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        try {
            switch ($Channel) {
                'Current' {
                    Write-Verbose "Fetching latest Current release from GitHub API"
                    $release = Invoke-RestMethod -Uri "$Script:RELEASEURL/latest"
                }
                'LTS' {
                    Write-Verbose "Fetching latest LTS release from GitHub API"
                    $releases = Invoke-RestMethod -Uri $Script:RELEASEURL
                    $release = ($releases | Where-Object { $_.Name -like "*(LTS)*" })[0]
                }
            }

            $versionInfo = [PSCustomObject]@{
                Version = $release.tag_name
                Name = $release.name
                Channel = $Channel
                PublishedAt = $release.published_at
                Assets = $release.assets.name
            }

            return $versionInfo
        }
        catch {
            Write-Error "Failed to retrieve Node.js version: $_"
            throw
        }
    }

    End {
        Write-LeaveFunction
    }
}

<#
.SYNOPSIS
    Sets the default Node.js version in configuration.
 
.DESCRIPTION
    Saves the specified Node.js version as the default version in the configuration file.
    This does not install the version, only marks it as the default.
 
.PARAMETER Version
    The Node.js version to set as default (e.g., 'v20.11.0').
 
.EXAMPLE
    Set-PwShNodeVersion -Version 'v20.11.0'
    Sets v20.11.0 as the default Node.js version.
 
.EXAMPLE
    'v18.19.0' | Set-PwShNodeVersion
    Sets v18.19.0 as the default using pipeline input.
 
.NOTES
    This function creates the configuration directory if it doesn't exist.
#>

function Set-PwShNodeVersion {
    [CmdletBinding()]
    [OutputType([void])]
    Param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [string]$Version
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        try {
            $config = @{
                DefaultVersion = $Version
                LastUpdated = (Get-Date).ToString('o')
            }

            if (Test-Path $Script:ConfigFile) {
                $existingConfig = Get-Content $Script:ConfigFile | ConvertFrom-Json -AsHashtable
                if ($existingConfig.InstallLocation) {
                    $config.InstallLocation = $existingConfig.InstallLocation
                }
            }

            $config | ConvertTo-Json | Set-Content -Path $Script:ConfigFile
            Write-Verbose "Node.js version set to: $Version"
        }
        catch {
            Write-Error "Failed to set Node.js version: $_"
            throw
        }
    }

    End {
        Write-LeaveFunction
    }
}

<#
.SYNOPSIS
    Gets the Node.js installation directory path.
 
.DESCRIPTION
    Retrieves the configured installation directory for Node.js versions.
    Returns the platform-specific default location if not explicitly configured.
 
.OUTPUTS
    String containing the installation directory path.
 
.EXAMPLE
    Get-PwShNodeInstallLocation
    Returns the current installation directory path.
 
.EXAMPLE
    $installPath = Get-PwShNodeInstallLocation
    Stores the installation path in a variable.
 
.NOTES
    Default locations:
    - Windows: %LOCALAPPDATA%\PwShNode
    - Linux/macOS: $XDG_DATA_HOME/pwshnode or ~/.local/share/pwshnode
#>

function Get-PwShNodeInstallLocation {
    [CmdletBinding()]
    [OutputType([string])]
    Param ()
    Begin {
        Write-EnterFunction
    }

    Process {
        try {
            if (Test-Path $Script:ConfigFile) {
                $config = Get-Content $Script:ConfigFile | ConvertFrom-Json
                if ($config.InstallLocation) {
                    return $config.InstallLocation
                }
            }

            # Default location based on platform
            $defaultLocation = Get-PwShNodeDefaultInstallLocation
            Write-Verbose "Using default install location: $defaultLocation"
            return $defaultLocation
        }
        catch {
            Write-Error "Failed to get install location: $_"
            throw
        }
    }

    End {
        Write-LeaveFunction
    }
}

<#
.SYNOPSIS
    Sets the Node.js installation directory path.
 
.DESCRIPTION
    Configures the directory where Node.js versions will be installed.
    Creates the directory if it doesn't exist.
 
.PARAMETER Path
    The directory path where Node.js versions should be installed.
 
.EXAMPLE
    Set-PwShNodeInstallLocation -Path 'C:\NodeJS'
    Sets the installation directory to C:\NodeJS on Windows.
 
.EXAMPLE
    Set-PwShNodeInstallLocation -Path '/opt/nodejs'
    Sets the installation directory to /opt/nodejs on Linux/macOS.
 
.EXAMPLE
    'C:\Dev\NodeJS' | Set-PwShNodeInstallLocation
    Sets the installation directory using pipeline input.
 
.NOTES
    The directory will be created if it doesn't exist.
#>

function Set-PwShNodeInstallLocation {
    [CmdletBinding()]
    [OutputType([void])]
    Param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [string]$Path
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        try {
            if (!(Test-Path $Path)) {
                New-Item -ItemType Directory -Path $Path -Force | Out-Null
                Write-Verbose "Created install directory: $Path"
            }

            $config = @{
                InstallLocation = $Path
                LastUpdated = (Get-Date).ToString('o')
            }

            if (Test-Path $Script:ConfigFile) {
                $existingConfig = Get-Content $Script:ConfigFile | ConvertFrom-Json -AsHashtable
                if ($existingConfig.DefaultVersion) {
                    $config.DefaultVersion = $existingConfig.DefaultVersion
                }
            }

            $config | ConvertTo-Json | Set-Content -Path $Script:ConfigFile
            Write-Verbose "Install location set to: $Path"
        }
        catch {
            Write-Error "Failed to set install location: $_"
            throw
        }
    }

    End {
        Write-LeaveFunction
    }
}

<#
.SYNOPSIS
    Installs a specific Node.js version.
 
.DESCRIPTION
    Downloads and installs a Node.js version for the current or specified platform and architecture.
    Supports automatic platform/architecture detection, caching, and setting as default version.
 
.PARAMETER Version
    The Node.js version to install (e.g., 'v20.11.0').
    If not specified, installs the latest version from the specified Channel.
 
.PARAMETER Channel
    The release channel: 'Current' for latest or 'LTS' for long-term support.
    Only used when Version is not specified.
    Default: 'LTS'
 
.PARAMETER Architecture
    The target architecture: 'x64', 'x86', 'arm64', or 'armv7l'.
    If not specified, auto-detects the current system architecture.
 
.PARAMETER Platform
    The target platform: 'win', 'linux', or 'darwin'.
    If not specified, auto-detects the current platform.
 
.PARAMETER CacheDir
    Directory for caching downloaded archives.
    If not specified, uses a 'cache' subdirectory in the installation location.
 
.PARAMETER Force
    Forces reinstallation even if the version is already installed.
 
.OUTPUTS
    PSCustomObject containing Version, Path, Platform, Architecture, and Installed status.
 
.EXAMPLE
    Install-PwShNodeVersion
    Installs the latest LTS version for the current platform.
 
.EXAMPLE
    Install-PwShNodeVersion -Version 'v20.11.0'
    Installs a specific Node.js version.
 
.EXAMPLE
    Install-PwShNodeVersion -Channel Current -Force
    Installs the latest Current version, forcing reinstallation.
 
.EXAMPLE
    Install-PwShNodeVersion -Platform 'linux' -Architecture 'arm64'
    Installs for a specific platform and architecture.
 
.NOTES
    Downloaded files are cached to avoid re-downloading.
    On Unix systems, executable permissions are automatically set.
#>

function Install-PwShNodeVersion {
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    Param (
        [Parameter(Mandatory = $false, ValueFromPipeline = $true)]
        [string]$Version,

        [Parameter(Mandatory = $false)]
        [ValidateSet('Current', 'LTS')]
        [string]$Channel = 'LTS',

        [Parameter(Mandatory = $false)]
        [string]$Architecture = (Get-PwShNodeArchitecture),

        [Parameter(Mandatory = $false)]
        [string]$Platform = (Get-PwShNodePlatform),

        [Parameter(Mandatory = $false)]
        [string]$CacheDir,

        [Parameter(Mandatory = $false)]
        [switch]$Force
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        try {
            # Get version if not specified
            if ([string]::IsNullOrEmpty($Version)) {
                $versionInfo = Get-PwShNodeVersion -Channel $Channel
                $Version = $versionInfo.Version
                Write-Verbose "Retrieved version: $Version"
            }

            # Determine install location
            $installLocation = Get-PwShNodeInstallLocation
            $versionPath = Join-Path $installLocation $Version

            if ((Test-Path $versionPath) -and !$Force) {
                Write-Warning "Version $Version already installed at $versionPath. Use -Force to reinstall."
                return [PSCustomObject]@{
                    Version = $Version
                    Path = $versionPath
                    AlreadyInstalled = $true
                }
            }

            # Determine filename and extension
            $extension = if ($Platform -eq 'linux' -or $Platform -eq 'darwin') { 'tar.gz' } else { 'zip' }
            $filename = "node-$Version-$Platform-$Architecture.$extension"
            $url = "https://nodejs.org/dist/$Version/$filename"

            # Setup cache directory
            if ([string]::IsNullOrEmpty($CacheDir)) {
                $CacheDir = Join-Path $installLocation 'cache'
            }
            if (!(Test-Path $CacheDir)) {
                New-Item -ItemType Directory -Path $CacheDir -Force | Out-Null
            }

            $cacheFile = Join-Path $CacheDir $filename

            # Download if not cached
            if (!(Test-Path $cacheFile)) {
                Write-Verbose "Downloading $filename from $url"
                Write-Info "Downloading Node.js $Version..."
                Invoke-WebRequest -Uri $url -OutFile $cacheFile -UseBasicParsing
            } else {
                Write-Verbose "Using cached file: $cacheFile"
            }

            # Extract based on platform
            Write-Verbose "Extracting to $installLocation"
            $extractedFolder = Join-Path $installLocation "node-$Version-$Platform-$Architecture"

            if ($extension -eq 'zip') {
                # Windows
                Expand-Archive -Path $cacheFile -DestinationPath $installLocation -Force
            } else {
                # Linux/macOS - using tar
                $tarArgs = @('-xzf', $cacheFile, '-C', $installLocation)
                $tarProcess = Start-Process -FilePath 'tar' -ArgumentList $tarArgs -NoNewWindow -Wait -PassThru

                if ($tarProcess.ExitCode -ne 0) {
                    throw "tar extraction failed with exit code $($tarProcess.ExitCode)"
                }
            }

            # Rename extracted folder to version
            if (Test-Path $extractedFolder) {
                if (Test-Path $versionPath) {
                    Remove-Item $versionPath -Recurse -Force
                }
                Move-Item -Path $extractedFolder -Destination $versionPath
            } else {
                throw "Extraction failed: expected folder not found at $extractedFolder"
            }

            # Set executable permissions on Unix-like systems
            if ($Platform -eq 'linux' -or $Platform -eq 'darwin') {
                $binPath = Join-Path $versionPath 'bin'
                if (Test-Path $binPath) {
                    Get-ChildItem -Path $binPath -File | ForEach-Object {
                        chmod +x $_.FullName
                    }
                }
            }

            # Set as default version
            Set-PwShNodeVersion -Version $Version

            Write-Info "Node.js $Version installed successfully at $versionPath" -ForegroundColor Green

            return [PSCustomObject]@{
                Version = $Version
                Path = $versionPath
                Platform = $Platform
                Architecture = $Architecture
                Installed = $true
            }
        }
        catch {
            Write-Error "Failed to install Node.js version ${Version}: $_"
            throw
        }
    }

    End {
        Write-LeaveFunction
    }
}

<#
.SYNOPSIS
    Removes an installed Node.js version.
 
.DESCRIPTION
    Uninstalls a specific Node.js version by removing its installation directory.
    Supports -WhatIf and -Confirm for safe operations.
 
.PARAMETER Version
    The Node.js version to remove (e.g., 'v20.11.0').
 
.PARAMETER Force
    Bypasses confirmation prompts (use with -Confirm:$false).
 
.EXAMPLE
    Remove-PwShNodeVersion -Version 'v18.19.0'
    Removes the specified version with confirmation prompt.
 
.EXAMPLE
    Remove-PwShNodeVersion -Version 'v18.19.0' -Confirm:$false
    Removes the version without confirmation.
 
.EXAMPLE
    'v16.20.0' | Remove-PwShNodeVersion
    Removes the version using pipeline input.
 
.EXAMPLE
    Remove-PwShNodeVersion -Version 'v20.11.0' -WhatIf
    Shows what would be removed without actually removing it.
 
.NOTES
    This operation cannot be undone. Use -WhatIf to preview changes.
#>

function Remove-PwShNodeVersion {
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    [OutputType([void])]
    Param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [string]$Version,

        [Parameter(Mandatory = $false)]
        [switch]$Force
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        try {
            $installLocation = Get-PwShNodeInstallLocation
            $versionPath = Join-Path $installLocation $Version

            if (!(Test-Path $versionPath)) {
                Write-Warning "Version $Version is not installed at $versionPath"
                return
            }

            if ($PSCmdlet.ShouldProcess($versionPath, "Remove Node.js version")) {
                Remove-Item -Path $versionPath -Recurse -Force
                Write-Verbose "Removed Node.js version $Version from $versionPath"
                Write-Info "Node.js $Version removed successfully" -ForegroundColor Green
            }
        }
        catch {
            Write-Error "Failed to remove Node.js version ${Version}: $_"
            throw
        }
    }

    End {
        Write-LeaveFunction
    }
}

# Private helper functions

<#
.SYNOPSIS
    Detects the current operating system platform.
 
.DESCRIPTION
    Returns the platform identifier used by Node.js: 'win', 'linux', or 'darwin'.
 
.OUTPUTS
    String containing the platform identifier.
 
.NOTES
    This is an internal helper function.
#>

function Get-PwShNodePlatform {
    [CmdletBinding()]
    [OutputType([string])]
    Param()

    if ($IsWindows -or $PSVersionTable.Platform -eq 'Win32NT') {
        return 'win'
    }
    elseif ($IsLinux) {
        return 'linux'
    }
    elseif ($IsMacOS) {
        return 'darwin'
    }
    else {
        throw "Unsupported platform"
    }
}

<#
.SYNOPSIS
    Detects the current system architecture.
 
.DESCRIPTION
    Returns the architecture identifier used by Node.js: 'x64', 'x86', 'arm64', or 'armv7l'.
 
.OUTPUTS
    String containing the architecture identifier.
 
.NOTES
    This is an internal helper function.
#>

function Get-PwShNodeArchitecture {
    [CmdletBinding()]
    [OutputType([string])]
    Param()

    $arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture

    switch ($arch) {
        'X64' { return 'x64' }
        'X86' { return 'x86' }
        'Arm64' { return 'arm64' }
        'Arm' { return 'armv7l' }
        default { throw "Unsupported architecture: $arch" }
    }
}

<#
.SYNOPSIS
    Gets the platform-specific default installation directory.
 
.DESCRIPTION
    Returns the default installation directory path, respecting platform conventions:
    - Windows: %LOCALAPPDATA%\PwShNode
    - Linux/macOS: $XDG_DATA_HOME/pwshnode or ~/.local/share/pwshnode
 
.OUTPUTS
    String containing the default installation directory path.
 
.NOTES
    This is an internal helper function.
#>

function Get-PwShNodeDefaultInstallLocation {
    [CmdletBinding()]
    [OutputType([string])]
    Param()

    if ($IsWindows -or $PSVersionTable.Platform -eq 'Win32NT') {
        return Join-Path $env:LOCALAPPDATA 'PwShNode'
    }
    else {
        # Linux/macOS: use XDG_DATA_HOME or ~/.local/share
        $dataHome = $env:XDG_DATA_HOME
        if ([string]::IsNullOrEmpty($dataHome)) {
            $dataHome = Join-Path $HOME '.local' 'share'
        }
        return Join-Path $dataHome 'pwshnode'
    }
}

<#
.SYNOPSIS
    Clears the Node.js download cache.
 
.DESCRIPTION
    Removes all cached Node.js installation archives from the cache directory.
    This frees up disk space but will require re-downloading files for future installations.
 
.PARAMETER Force
    Bypasses confirmation prompts (use with -Confirm:$false).
 
.EXAMPLE
    Clear-PwShNodeCache
    Clears the cache with confirmation prompt.
 
.EXAMPLE
    Clear-PwShNodeCache -Confirm:$false
    Clears the cache without confirmation.
 
.EXAMPLE
    Clear-PwShNodeCache -WhatIf
    Shows what would be removed without actually removing it.
 
.NOTES
    This operation cannot be undone. Cached files will need to be re-downloaded.
#>

function Clear-PwShNodeCache {
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
    [OutputType([void])]
    Param (
        [Parameter(Mandatory = $false)]
        [switch]$Force
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        try {
            $installLocation = Get-PwShNodeInstallLocation
            $cacheDir = Join-Path $installLocation 'cache'

            if (!(Test-Path $cacheDir)) {
                Write-Warning "Cache directory does not exist at $cacheDir"
                return
            }

            $cacheFiles = Get-ChildItem -Path $cacheDir -File
            $cacheSize = ($cacheFiles | Measure-Object -Property Length -Sum).Sum
            $cacheSizeMB = [math]::Round($cacheSize / 1MB, 2)
            $fileCount = $cacheFiles.Count

            if ($fileCount -eq 0) {
                Write-Info "Cache is already empty" -ForegroundColor Green
                return
            }

            $message = "Remove $fileCount cached file(s) ($cacheSizeMB MB) from $cacheDir"

            if ($PSCmdlet.ShouldProcess($cacheDir, $message)) {
                Remove-Item -Path $cacheDir -Recurse -Force
                New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null
                Write-Verbose "Cleared cache directory: $cacheDir"
                Write-Info "Cache cleared successfully ($cacheSizeMB MB freed)" -ForegroundColor Green
            }
        }
        catch {
            Write-Error "Failed to clear cache: $_"
            throw
        }
    }

    End {
        Write-LeaveFunction
    }
}