AcumaticaNodeHelper.psm1

function Invoke-NodeBuild {
    <#
    .SYNOPSIS
    Builds Acumatica FrontendSources using the build-dev npm script.
     
    .DESCRIPTION
    The Invoke-NodeBuild cmdlet builds Acumatica FrontendSources for specified pages and/or modules.
    It reads the Node.js path from the site's Web.config and executes the build-dev npm script in the
    FrontendSources directory. The cmdlet supports filtering by page IDs, modules, and can optionally
    use a custom development folder.
     
    .PARAMETER Pages
    Space-separated or comma-separated list of page IDs to build.
    Example: "AR303000" or "AR303000 AR304000"
     
    .PARAMETER Modules
    Space-separated or comma-separated list of modules to build.
    Example: "AR" or "AR AP GL"
     
    .PARAMETER Development
    When specified, adds the customFolder=development flag to the build command.
     
    .PARAMETER SiteDirectory
    Path to the Acumatica site directory containing Web.config.
    Defaults to the current directory.
     
    .EXAMPLE
    Invoke-NodeBuild
    Builds all pages without any filters.
     
    .EXAMPLE
    Invoke-NodeBuild -Pages "AR303000"
    Builds only the AR303000 page.
     
    .EXAMPLE
    Invoke-NodeBuild -Pages "AR303000 AR304000"
    Builds multiple pages (AR303000 and AR304000).
     
    .EXAMPLE
    Invoke-NodeBuild -Modules "AR AP"
    Builds all pages in the AR and AP modules.
     
    .EXAMPLE
    Invoke-NodeBuild -Pages "AR303000" -Development
    Builds AR303000 page with customFolder=development flag.
     
    .EXAMPLE
    Invoke-NodeBuild -Pages "AR303000" -Modules "AR" -Development
    Builds AR303000 page and AR module with development flag.
     
    .EXAMPLE
    Invoke-NodeBuild -Pages "AR303000" -SiteDirectory "C:\inetpub\Acumatica\MySite"
    Builds from a specific site directory.
     
    .OUTPUTS
    System.Boolean
    Returns $true if the build succeeds, $false otherwise.
     
    .NOTES
    The cmdlet requires:
    - Web.config with NodeJs:NodeJsPath app setting
    - FrontendSources directory in the site root
    - npm.cmd in the Node.js installation path
     
    .LINK
    Invoke-NodeWatch
    Invoke-NodeGetModules
    #>

    [CmdletBinding()]
    param(
        [Parameter(Position = 0)]
        [ValidateScript({
                if ($_ -match '^--') {
                    throw "Invalid value '$_'. Did you mean to use '-Modules' or '-Pages' parameter?"
                }
                $true
            })]
        [string]$Pages = "",
        
        [Parameter(Position = 1)]
        [ValidateScript({
                if ($_ -match '^--') {
                    throw "Invalid value '$_'. Did you mean to use a parameter name with single dash (-)?"
                }
                $true
            })]
        [string]$Modules = "",
        
        [Parameter()]
        [switch]$Development,
        
        [Parameter()]
        [string]$SiteDirectory = "."
    )
    
    try {
        $env = Get-NodeEnvironment -SiteDirectory $SiteDirectory
        if ($null -eq $env) {
            return $false
        }
        
        # Build script arguments
        $scriptArgs = @{}
        $paramOrder = @()
        
        if (-not [string]::IsNullOrWhiteSpace($Pages)) {
            # Convert space-separated to comma-separated
            $scriptArgs['pageIds'] = $Pages -replace '\s+', ','
            $paramOrder += 'pageIds'
        }
        
        if (-not [string]::IsNullOrWhiteSpace($Modules)) {
            # Convert space-separated to comma-separated
            $scriptArgs['modules'] = $Modules -replace '\s+', ','
            $paramOrder += 'modules'
        }
        
        # Add customFolder last
        if ($Development) {
            $scriptArgs['customFolder'] = 'development'
            $paramOrder += 'customFolder'
        }
        
        # Build action message
        $messageParts = @()
        if ($scriptArgs.ContainsKey('pageIds')) {
            $messageParts += "pageIds '$($scriptArgs['pageIds'])'"
        }
        if ($scriptArgs.ContainsKey('modules')) {
            $messageParts += "modules '$($scriptArgs['modules'])'"
        }
        if ($Development) {
            $messageParts += "customFolder 'development'"
        }
        
        $actionMessage = if ($messageParts.Count -gt 0) {
            "Building node pages with $($messageParts -join ', ')"
        }
        else {
            "Building node pages"
        }
        
        return Invoke-NpmCommand -Environment $env -Script "build-dev" `
            -ScriptArguments $scriptArgs `
            -ParameterOrder $paramOrder `
            -UseTripleDash `
            -SuccessMessage "Successfully built node pages" `
            -ActionMessage $actionMessage
    }
    catch {
        Write-Error "Error building node pages: $($_.Exception.Message)"
        return $false
    }
}

function Invoke-NodeWatch {
    <#
    .SYNOPSIS
    Starts watch mode for automatic rebuilding of Acumatica FrontendSources.
     
    .DESCRIPTION
    The Invoke-NodeWatch cmdlet starts a file watcher that automatically rebuilds FrontendSources
    when source files are modified and saved. This is useful during Modern UI development to see
    changes immediately without manual rebuilds. The watch mode continues running until stopped
    with Ctrl+C.
     
    At least one of -ScreenIds or -Modules must be specified. Running watch without parameters
    may behave in an unstable manner.
     
    .PARAMETER ScreenIds
    Space-separated or comma-separated list of screen IDs to watch.
    Example: "SO301000" or "SO301000 FS305100"
    This parameter is mandatory (either this or Modules must be provided).
     
    .PARAMETER Modules
    Space-separated or comma-separated list of modules to watch.
    Example: "AR" or "AR AP GL"
     
    .PARAMETER SiteDirectory
    Path to the Acumatica site directory containing Web.config.
    Defaults to the current directory.
     
    .EXAMPLE
    Invoke-NodeWatch -ScreenIds "SO301000"
    Watches the Sales Orders (SO301000) form for changes.
     
    .EXAMPLE
    Invoke-NodeWatch -ScreenIds "SO301000 FS305100"
    Watches multiple forms (SO301000 and FS305100) for changes.
     
    .EXAMPLE
    Invoke-NodeWatch -Modules "AR AP GL"
    Watches all forms in the AR, AP, and GL modules for changes.
     
    .EXAMPLE
    Invoke-NodeWatch -ScreenIds "SO301000" -Modules "AR"
    Watches specific screen and module for changes.
     
    .EXAMPLE
    Invoke-NodeWatch "SO301000"
    Uses positional parameter to watch SO301000.
     
    .OUTPUTS
    System.Boolean
    Returns $true if watch mode starts successfully, $false otherwise.
     
    .NOTES
    - Press Ctrl+C to stop watch mode
    - The cmdlet requires at least one of -ScreenIds or -Modules to be specified
    - Running without parameters may cause unstable behavior
    - The watch command uses --prefix to target FrontendSources\screen directory
    - The cmdlet requires:
      * Web.config with NodeJs:NodeJsPath app setting
      * FrontendSources\screen directory in the site root
      * npm.cmd in the Node.js installation path
     
    .LINK
    Invoke-NodeBuild
    Invoke-NodeGetModules
    #>

    [CmdletBinding()]
    param(
        [Parameter(Position = 0, Mandatory)]
        [ValidateScript({
                if ($_ -match '^--') {
                    throw "Invalid value '$_'. Did you mean to use '-Modules' or '-ScreenIds' parameter?"
                }
                $true
            })]
        [string]$ScreenIds = "",
        
        [Parameter(Position = 1)]
        [ValidateScript({
                if ($_ -match '^--') {
                    throw "Invalid value '$_'. Did you mean to use a parameter name with single dash (-)?"
                }
                $true
            })]
        [string]$Modules = "",
        
        [Parameter()]
        [string]$SiteDirectory = "."
    )
    
    # Validate that at least one parameter is provided
    if ([string]::IsNullOrWhiteSpace($ScreenIds) -and [string]::IsNullOrWhiteSpace($Modules)) {
        Write-Error "You must specify either -ScreenIds or -Modules parameter. Running watch without parameters may behave in an unstable manner."
        return $false
    }
    
    try {
        $env = Get-NodeEnvironment -SiteDirectory $SiteDirectory
        if ($null -eq $env) {
            return $false
        }
        
        # Validate FrontendSources\screen directory exists
        $screenDirectory = Join-Path $env.FrontendSources "screen"
        if (-not (Test-Path $screenDirectory)) {
            throw "FrontendSources\screen directory not found at: $screenDirectory"
        }
        
        # Build script arguments
        $scriptArgs = @{}
        $paramOrder = @()
        
        if (-not [string]::IsNullOrWhiteSpace($ScreenIds)) {
            # Convert space-separated to comma-separated
            $scriptArgs['screenIds'] = $ScreenIds -replace '\s+', ','
            $paramOrder += 'screenIds'
        }
        
        if (-not [string]::IsNullOrWhiteSpace($Modules)) {
            # Convert space-separated to comma-separated
            $scriptArgs['modules'] = $Modules -replace '\s+', ','
            $paramOrder += 'modules'
        }
        
        # Build action message
        $messageParts = @()
        if ($scriptArgs.ContainsKey('screenIds')) {
            $messageParts += "screenIds '$($scriptArgs['screenIds'])'"
        }
        if ($scriptArgs.ContainsKey('modules')) {
            $messageParts += "modules '$($scriptArgs['modules'])'"
        }
        
        $actionMessage = "Starting watch mode with $($messageParts -join ', '). Press Ctrl+C to stop."
        
        return Invoke-NpmCommand -Environment $env -Script "watch" `
            -ScriptArguments $scriptArgs `
            -ParameterOrder $paramOrder `
            -UsePrefix ".\FrontendSources\screen\" `
            -UseTripleDash `
            -SuccessMessage "Watch mode ended" `
            -ActionMessage $actionMessage
    }
    catch {
        Write-Error "Error starting watch mode: $($_.Exception.Message)"
        return $false
    }
}
function Invoke-NodeGetModules {
    <#
    .SYNOPSIS
    Retrieves node modules for the Acumatica site.
     
    .DESCRIPTION
    The Invoke-NodeGetModules cmdlet executes the getmodules npm script to retrieve
    and install necessary node modules for the Acumatica FrontendSources.
     
    .PARAMETER SiteDirectory
    Path to the Acumatica site directory containing Web.config.
    Defaults to the current directory.
     
    .EXAMPLE
    Invoke-NodeGetModules
    Retrieves node modules from the current directory.
     
    .EXAMPLE
    Invoke-NodeGetModules -SiteDirectory "C:\inetpub\Acumatica\MySite"
    Retrieves node modules from a specific site directory.
     
    .OUTPUTS
    System.Boolean
    Returns $true if the operation succeeds, $false otherwise.
     
    .NOTES
    The cmdlet requires:
    - Web.config with NodeJs:NodeJsPath app setting
    - FrontendSources directory in the site root
    - npm.cmd in the Node.js installation path
    - getmodules script defined in FrontendSources/package.json
     
    .LINK
    Invoke-NodeBuild
    Invoke-NodeWatch
    #>

    [CmdletBinding()]
    param(
        [Parameter()]
        [string]$SiteDirectory = "."
    )
    
    try {
        $env = Get-NodeEnvironment -SiteDirectory $SiteDirectory
        if ($null -eq $env) {
            return $false
        }
        
        return Invoke-NpmCommand -Environment $env -Script "getmodules" `
            -SuccessMessage "Successfully retrieved node modules" `
            -ActionMessage "Getting node modules..."
    }
    catch {
        Write-Error "Error getting node modules: $($_.Exception.Message)"
        return $false
    }
}

function Get-NodeEnvironment {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$SiteDirectory
    )
    
    try {
        # Get current directory as site root
        $siteRoot = Resolve-Path $SiteDirectory -ErrorAction Stop
        
        # Find Web.config
        $webConfigPath = Join-Path $siteRoot "Web.config"
        if (-not (Test-Path $webConfigPath)) {
            throw "Web.config not found at: $webConfigPath"
        }
        
        # Extract NodeJS path from Web.config
        [xml]$webConfig = Get-Content $webConfigPath
        $nodeJsPath = $webConfig.configuration.appSettings.add | 
        Where-Object { $_.key -eq "NodeJs:NodeJsPath" } | 
        Select-Object -ExpandProperty value
            
        if ([string]::IsNullOrEmpty($nodeJsPath)) {
            throw "NodeJs:NodeJsPath not found in web.config"
        }
        
        Write-Host "Found NodeJs:NodeJsPath: $nodeJsPath in web.config" -ForegroundColor Green
        
        # Validate paths
        $frontendSources = Join-Path $siteRoot "FrontendSources"
        if (-not (Test-Path $frontendSources)) {
            throw "FrontendSources directory not found at: $frontendSources"
        }
        
        $npmPath = Join-Path $nodeJsPath "npm.cmd"
        if (-not (Test-Path $npmPath)) {
            throw "npm.cmd not found at: $npmPath"
        }
        
        return @{
            SiteRoot        = $siteRoot
            NodeJsPath      = $nodeJsPath
            FrontendSources = $frontendSources
            NpmPath         = $npmPath
        }
    }
    catch {
        Write-Error $_.Exception.Message
        return $null
    }
}

function Invoke-NpmCommand {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$Environment,
        
        [Parameter(Mandatory)]
        [string]$Script,
        
        [Parameter()]
        [hashtable]$ScriptArguments = @{},
        
        [Parameter()]
        [string[]]$ParameterOrder = @(),
        
        [Parameter()]
        [string]$UsePrefix = "",
        
        [Parameter()]
        [switch]$UseTripleDash,
        
        [Parameter(Mandatory)]
        [string]$SuccessMessage,
        
        [Parameter(Mandatory)]
        [string]$ActionMessage
    )
    
    Write-Host $ActionMessage -ForegroundColor Yellow
    
    try {
        Push-Location $Environment.SiteRoot
        
        # Build arguments: npm run <script> [--prefix path] [---] [--env key=value,key=value,...]
        $argsList = "run $Script"
        
        # Add --prefix if specified
        if (-not [string]::IsNullOrWhiteSpace($UsePrefix)) {
            $argsList += " --prefix $UsePrefix"
        }
        
        # Add triple dash and env arguments
        if ($UseTripleDash -and $ScriptArguments.Count -gt 0) {
            # If parameter order is specified, use it; otherwise use hashtable keys
            $keysToIterate = if ($ParameterOrder.Count -gt 0) { $ParameterOrder } else { $ScriptArguments.Keys }
            
            # Build comma-separated key=value pairs
            $envPairs = @()
            foreach ($key in $keysToIterate) {
                if ($ScriptArguments.ContainsKey($key)) {
                    $envPairs += "$key=$($ScriptArguments[$key])"
                }
            }
            
            $argsList += " --- --env $($envPairs -join ',')"
        }
        
        # Log the full command being executed
        Write-Host "Executing: $($Environment.NpmPath) $argsList" -ForegroundColor Cyan
        
        $process = Start-Process -FilePath $Environment.NpmPath -ArgumentList $argsList -NoNewWindow -Wait -PassThru
        
        if ($process.ExitCode -eq 0) {
            Write-Host $SuccessMessage -ForegroundColor Green
            return $true
        }
        else {
            Write-Error "npm command failed with exit code: $($process.ExitCode)"
            return $false
        }
    }
    finally {
        Pop-Location
    }
}