# LFGet-BulkAction.ps1 <# .SYNOPSIS Runs Laserfiche LFGet commands for multiple products with shared defaults and logging. .DESCRIPTION Provides a centralized wrapper around LFGet.exe to install, repair, uninstall, or download Laserfiche products. Supports both online and local-package installation scenarios, auto-discovers local package versions, and adds consistent logging. This script doesn't run silent itself. To invoke it silently, use the companion script Invoke-LFGetBulkAction.ps1 which launches this in a hidden, elevated session. IMPORTANT: The script always runs LFGet install-product and install-local-package commands with the --iacceptlicenseagreement flag, which is equivalent to accepting the click-through End User License Agreement (EULA) for Laserfiche products. .NOTES Authors (human): Samuel Carson (Laserfiche) Authors (AI): GPT-5-Codex (OpenAI) Date: 2025-11-12 Requires: LFGet.exe, Administrator privileges, and access to any directories on remote file shares. The online install-product and download-product commands require outbound network access to: - https://infrapi.laserfiche.com (Laserfiche Support site API; validates installation code, etc.) - https://d1oywsbnq10fc4.cloudfront.net (software package CDN) - https://laserfiche-support-site-downloads.s3.us-west-2.amazonaws.com (other related install files) .PARAMETER LFGetCommand LFGet command to run. Supported values: install-product, install-local-package, download-product, repair-product, uninstall-product. .PARAMETER LFGetDirectory Directory that contains LFGet.exe and its dependencies (dll's, etc.). Default aligns with a standard Laserfiche Installer deployment. Mandatory when using deployment agents, as the default value is a user AppData directory. Script default of C:\Users\${env:USERNAME}\AppData\Local\Laserfiche\LFInstaller\Plugins\LFGet\ is where the Laserfiche Installer puts LFGet.exe and its dependencies. You MUST update this value for use with unattended deployment tools, as client machines generally won't have the full Laserfiche Installer installed, and deployment agents usually don't run in the user context. You MUST update this if running as the local SYSTEM account, as SYSTEM has a special protected user profile directory that doesn't allow executing files within it. E.g., "C:\Temp\LFGet\LFGet" if that's where you place the local copy of the LFGet directory. .PARAMETER CopyLFGetFromSource Optional path to a source directory containing LFGet.exe and its supporting files. When provided, the directory is copied (recursively) to LFGetDirectory before running any LFGet commands. Intended for scenarios where LFGet is staged on a network share or other location and must be copied to the target machine. .PARAMETER InstallationCode Laserfiche Support site installation code. Required for install-product and download-product commands. .PARAMETER DownloadDirectory Destination for downloaded installation packages. Created if it doesn't exist. Used by download-product. .PARAMETER PackageDirectory Directory containing installation packages. Used by install-local-package. Packages can be downloaded using the LFGet download-product command or through the Laserfiche Installer GUI. Do NOT extract the .exe (self-extracting executable) installation packages from the PackageDirectory; LFGet will do that automatically. .PARAMETER CopyPackagesFromSource Optional path to a source directory containing LFGet installation packages. When provided with the install-local-package command, any packages missing from PackageDirectory for specified products are copied from the source. Intended for scenarios where the installation packages are staged on a network share or other location and must be copied to the target machine. .PARAMETER LogDirectory Path where this script and LFGet write log files. The script has a custom default of C:\Temp\LFGet\Logs\ that overrides the LFGet default. .PARAMETER TempDirectory Optional temp directory for package extraction. Default is C:\Temp\. Used by install-product and install-local-package. .PARAMETER Products Optional list of product names (comma or space separated). Uses default product set (just WebToolsAgent, the smallest product) when omitted. .OUTPUTS Writes progress and status messages to the pipeline. Implements PowerShell transcript logging for the script run. LFGet.exe handles logging its own commands outputs. .EXAMPLE .\LFGet-BulkAction.ps1 -LFGetCommand install-product -LFGetDirectory 'C:\Temp\LFGet\LFGet' -InstallationCode 'XXXXXXXXXX' -Products 'WebToolsAgent,WindowsClient' Runs online installs for the specified products using the provided InstallationCode. .EXAMPLE .\LFGet-BulkAction.ps1 -LFGetCommand install-local-package -LFGetDirectory 'C:\Temp\LFGet\LFGet' -PackageDirectory 'C:\Temp\LFGet\Packages' -Products 'WebToolsAgent,WindowsClient' Installs specified products from the latest packages found in the package directory. Note: This differs from standalone LFGet install-local-package in that it accepts a package *directory* and list of products to install, rather than the package file(s). The script will automatically find the latest package file for each specified product in the directory and pass it to LFGet. .EXAMPLE .\LFGet-BulkAction.ps1 -LFGetCommand download-product -LFGetDirectory 'C:\Temp\LFGet\LFGet' -InstallationCode 'XXXXXXXXXX' -DownloadDirectory '\\fileserver\LFGet\Packages' -Products 'WebToolsAgent,WindowsClient' Downloads the latest packages for the specified products to the download directory. .EXAMPLE .\LFGet-BulkAction.ps1 -LFGetCommand repair-product -Products 'WindowsClient' Repairs the specified product using the existing installation media. .EXAMPLE .\LFGet-BulkAction.ps1 -LFGetCommand uninstall-product -LFGetDirectory 'C:\Temp\LFGet\LFGet' -LogDirectory 'C:\Temp\LFGet\Logs' -Products 'WebToolsAgent,ScanningSetup' Uninstalls the listed products and leaves logs in the configured log directory. .LICENSE MIT License Copyright (c) 2025 Laserfiche Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission 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. #> param( # LFGet command to run. Mandatory; see $LFGetCommandValidValues for supported values. [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $LFGetCommand, # Where LFGet.exe and its dependencies (dll's, etc.) are. Always required. [string] $LFGetDirectory = "C:\Users\${env:USERNAME}\AppData\Local\Laserfiche\LFInstaller\Plugins\LFGet\", # Optional LFGet source directory containing LFGet.exe and dependencies. When supplied, the directory is copied to $LFGetDirectory before validation. # Intended for scenarios where LFGet is staged on a network share or other location and must be copied to the target machine. [string] $CopyLFGetFromSource, # Your Laserfiche Support site installation code. Required for the online "install-product" and "download-product" LFGet commands. [string] $InstallationCode = '', # Where downloaded packages will go. Required for the online "download-product" LFGet command. [string] $DownloadDirectory = 'C:\Temp\LFGet\Packages\', # Where the package .exe files are (they're self-extracting executables). Required for the "install-local-package" LFGet command. [string] $PackageDirectory = 'C:\Temp\LFGet\Packages\', # Optional package source directory. When supplied with install-local-package, missing packages are copied into $PackageDirectory before discovery. [string] $CopyPackagesFromSource, # Where logs go, e.g., "C:\Temp\LFGet\Logs\". LFGet creates one shared LFGet.log file at the root dir, and a subfolder for each product's logs. # While LFGet has a dynamic default log directory, an explicit log directory is required by this script. [string] $LogDirectory = 'C:\Temp\LFGet\Logs\', # (Optional) Used for the "install-local-package" LFGet command. The temp directory in which application installation packages will be extracted. Extracted files are automatically deleted from the temp directory after installation. # The default script value is "C:\Temp\", same as the LFGet default. The script always passes the value to the --temp-dir param to avoid needing conditional handling. [string] $TempDirectory = 'C:\Temp\', # Comma-separated (or space-separated) list of product names. Uses $DefaultProducts when not supplied. [string] $Products = '' ) # Refer to available product values at: https://doc.laserfiche.com/laserfiche.documentation/12/userguide/en-us/content/install-installer.htm # Client components (WebToolsAgent, WindowsClient, Snapshot, ScanningSetup, OfficePluginSetup, OCR, RepositoryAdmin, WorkflowDesigner) do not require a product license (lf.licx) from Directory Server or otherwise for installation. # Some server components do. This script does not handle product licenses, which require additional parameters in the LFGet commands. $DefaultProducts = @( 'WebToolsAgent' #'WindowsClient' #'Snapshot' #'ScanningSetup' #'OfficePluginSetup' #'OCR' ) $LFGetCommandValidValues = @( 'install-product' 'install-local-package' 'download-product' 'repair-product' 'uninstall-product' ) $ValidProducts = @( # Client applications 'WebToolsAgent' # Laserfiche Webtools Agent 'WindowsClient' # Laserfiche Repository Desktop Client 'Snapshot' # Laserfiche Snapshot 'SnapshotSharedPrinter' # Laserfiche Snapshot Shared Printer 'ScanningSetup' # Laserfiche Scanning 'OfficePluginSetup' # Laserfiche Office Integration 'OCR' # Laserfiche OCR # Server applications 'AdminHubAgentSetup' # Laserfiche Administration Hub Agent 'AdminHubSetup' # Laserfiche Administration Hub 'DirectoryServiceSetup' # Laserfiche Directory Service 'FormsSetup' # Laserfiche Forms 'FullTextSearch' # Laserfiche Full Text Search 'ImportAgent' # Laserfiche Import Agent 'LfInstallerConfigUtility' # Laserfiche Configuration Hub 'RepositoryAdmin' # Laserfiche Administration Console 'RepositoryServer' # Laserfiche Repository Server 'RepositoryWebClientSetup' # Laserfiche Repository Web Client 'WorkflowDesigner' # Laserfiche Workflow Designer 'WorkflowServer' # Laserfiche Workflow Services ) function ValidateLFGetCommand { param ( [Parameter(Mandatory)] [string]$Command ) if ($LFGetCommandValidValues -notcontains $Command) { Write-Error "Invalid LFGet command: $Command`nValid values are: $($LFGetCommandValidValues -join ', ')" exit 1 } } # install-local-package functions function Get-LatestExeVersions { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$BaseDirectory, [Parameter(Mandatory)] [string[]]$Prefixes ) # Build regex for prefixes (starts-with match) $pattern = '^(' + (($Prefixes | ForEach-Object { [regex]::Escape($_) }) -join '|') + ')\b' # Collect all matching exe files and parse versions $all = Get-ChildItem -Path $BaseDirectory -File -Filter *.exe | Where-Object { $_.BaseName -match $pattern } | ForEach-Object { $token = [regex]::Match($_.BaseName, $pattern).Groups[1].Value $m = [regex]::Matches($_.BaseName, '\d{1,3}(?:\.\d{1,4}){3}') | Select-Object -Last 1 if ($m) { [pscustomobject]@{ Prefix = $token Version = [version]$m.Value File = $_ } } } # Group by prefix and select latest version $rows = $all | Group-Object Prefix | ForEach-Object { $latest = $_.Group | Sort-Object Version -Descending | Select-Object -First 1 [pscustomobject]@{ Name = $_.Name LatestExeVersion = $latest.Version.ToString() PackageName = $latest.File.Name PackagePath = $latest.File.FullName } } return $rows } # Main script $transcriptStarted = $false $transcriptPath = $null try { $logRoot = if ([string]::IsNullOrWhiteSpace($LogDirectory)) { 'C:\Temp\LFGet\Logs\' } else { $LogDirectory } if (-not (Test-Path $logRoot)) { New-Item -ItemType Directory -Force -Path $logRoot | Out-Null } # Prepare transcript logging $safeCommand = if ($LFGetCommand) { $LFGetCommand -replace '[^\w-]', '_' } else { 'LFGet' } $timestamp = Get-Date -Format 'yyyyMMdd_HHmmss' $transcriptFileName = "LFGet-BulkAction_{0}_{1}.log" -f $safeCommand, $timestamp $transcriptPath = Join-Path -Path $logRoot -ChildPath $transcriptFileName Start-Transcript -Path $transcriptPath -Append | Out-Null $transcriptStarted = $true # Ensure downstream usage sees the normalized log directory $LogDirectory = $logRoot if (-not [string]::IsNullOrWhiteSpace($CopyLFGetFromSource)) { if (-not (Test-Path $CopyLFGetFromSource)) { throw "Copy path '$CopyLFGetFromSource' does not exist." } Write-Output "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] Copying LFGet files from '$CopyLFGetFromSource' to '$LFGetDirectory'" # Handles if the LFGet base directory ever gets subdirectories $LFGetSourceDirectory = (Get-Item -Path $CopyLFGetFromSource -ErrorAction Stop).FullName.TrimEnd('\') if (-not (Test-Path $LFGetDirectory)) { New-Item -Path $LFGetDirectory -ItemType Directory -Force | Out-Null } $copiedFileCount = 0 $skippedFileCount = 0 foreach ($file in (Get-ChildItem -Path $CopyLFGetFromSource -Recurse -File -ErrorAction Stop)) { $relativePath = $file.FullName.Substring($LFGetSourceDirectory.Length).TrimStart('\') $destination = Join-Path -Path $LFGetDirectory -ChildPath $relativePath $destinationDirectory = Split-Path -Path $destination -Parent if (-not (Test-Path $destinationDirectory)) { New-Item -Path $destinationDirectory -ItemType Directory -Force | Out-Null } $destInfo = Get-Item -Path $destination -ErrorAction SilentlyContinue $needsCopy = $false if ($null -eq $destInfo) { $needsCopy = $true } elseif ($destInfo.Length -ne $file.Length -or $destInfo.LastWriteTimeUtc -ne $file.LastWriteTimeUtc) { $needsCopy = $true } if ($needsCopy) { Copy-Item -Path $file.FullName -Destination $destination -Force -ErrorAction Stop $copiedFileCount++ } else { $skippedFileCount++ } } if ($skippedFileCount -eq 0) { Write-Output "$copiedFileCount files copied." } else { Write-Output "$copiedFileCount files copied. $skippedFileCount files already present; skipping copy." } Write-Output "`n" } if (-not (Test-Path $LFGetDirectory)) { throw "LFGet directory '$LFGetDirectory' does not exist." } $LFGetExe = Join-Path -Path $LFGetDirectory -ChildPath 'LFGet.exe' if (-not (Test-Path $LFGetExe)) { throw "LFGet.exe not found at '$LFGetExe'." } $LFGetDlls = Get-ChildItem -Path $LFGetDirectory -Filter *.dll -ErrorAction SilentlyContinue if (-not $LFGetDlls) { throw "No DLL dependencies detected in '$LFGetDirectory'. Ensure the full LFGet directory is copied, not just LFGet.exe." } ValidateLFGetCommand -Command $LFGetCommand # Validate required parameters based on command if ($LFGetCommand -in @('install-product', 'download-product')) { if ([string]::IsNullOrWhiteSpace($InstallationCode)) { Write-Error "InstallationCode is required when using the '$LFGetCommand' command." exit 1 } } # Normalize product selection if ([string]::IsNullOrWhiteSpace($Products)) { $Products = $null } [string[]]$ProductNames = if ($Products) { $Products -split '[,\s]+' | Where-Object { $_ } | ForEach-Object { $_.Trim() } } else { $DefaultProducts } $InvalidProducts = $ProductNames | Where-Object { $ValidProducts -cnotcontains $_ } if ($InvalidProducts) { Write-Error "Invalid product name(s): $($InvalidProducts -join ', ')`nValid values (case-sensitive) are: $($ValidProducts -join ', ')" exit 1 } if ($LFGetCommand -eq 'install-local-package' -and -not [string]::IsNullOrWhiteSpace($CopyPackagesFromSource)) { if (-not (Test-Path $CopyPackagesFromSource)) { throw "Copy path '$CopyPackagesFromSource' does not exist." } if (-not (Test-Path $PackageDirectory)) { New-Item -ItemType Directory -Force -Path $PackageDirectory | Out-Null } Write-Output "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] Syncing packages from '$CopyPackagesFromSource' to '$PackageDirectory'" # Build regex pattern to match product names (starts-with match) $productPattern = '^(' + (($ProductNames | ForEach-Object { [regex]::Escape($_) }) -join '|') + ')\b' # Track which products have matching packages $productsWithPackages = @{} foreach ($product in $ProductNames) { $productsWithPackages[$product] = $false } # Only copy packages that match the product names Get-ChildItem -Path $CopyPackagesFromSource -File -Filter *.exe | Where-Object { $_.BaseName -match $productPattern } | ForEach-Object { # Mark which product this package belongs to foreach ($product in $ProductNames) { if ($_.BaseName -match "^$([regex]::Escape($product))\b") { $productsWithPackages[$product] = $true break } } $destination = Join-Path -Path $PackageDirectory -ChildPath $_.Name if (-not (Test-Path $destination)) { Write-Output "Copying package '$($_.Name)'" Copy-Item -Path $_.FullName -Destination $destination -Force -ErrorAction Stop } else { Write-Output "Package '$($_.Name)' already present; skipping copy" } } # Check for products with no matching packages $missingPackages = $productsWithPackages.GetEnumerator() | Where-Object { -not $_.Value } | ForEach-Object { $_.Key } if ($missingPackages) { Write-Warning "No packages found in '$CopyPackagesFromSource' for product(s): $($missingPackages -join ', ')" Write-Output "`n" } } Write-Output "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] Start of $LFGetCommand script`n" switch ($LFGetCommand) { 'install-product' { Set-Location $LFGetDirectory Write-Output "`n[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] Starting installations`n" foreach ($Product in $ProductNames) { Write-Output "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] Start" .\LFGet.exe install-product --product "$Product" --iacceptlicenseagreement --iagreetoprereqs --installation-code "$InstallationCode" --log "$LogDirectory" Write-Output "`n" } } 'install-local-package' { Set-Location $LFGetDirectory Write-Output "`n[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] Discovering packages in directory:`n$($PackageDirectory)`n" # Identify the latest package for each product in the package directory so LFGet receives the full path for each package $PackageInfo = Get-LatestExeVersions -BaseDirectory $PackageDirectory -Prefixes $ProductNames Write-Output "Product packages found:`n" foreach ($Package in $PackageInfo) { Write-Output "$($Package.PackageName)" } Write-Output "`n[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] Starting installations`n" foreach ($Package in $PackageInfo) { Write-Output "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] Start" .\LFGet.exe install-local-package --package "$($Package.PackagePath)" --iacceptlicenseagreement --iagreetoprereqs --log "$LogDirectory" --temp-dir "$TempDirectory" Write-Output "`n" } } 'download-product' { if (-not (Test-Path $DownloadDirectory)) { New-Item -ItemType Directory -Force -Path $DownloadDirectory | Out-Null } Set-Location $LFGetDirectory Write-Output "`n[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] Starting downloads`n" foreach ($Product in $ProductNames) { Write-Output "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] Start" .\LFGet.exe download-product --product "$Product" --installation-code "$InstallationCode" --dest "$DownloadDirectory" --log "$LogDirectory" Write-Output "`n" } } 'repair-product' { Set-Location $LFGetDirectory Write-Output "`n[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] Starting repairs`n" foreach ($Product in $ProductNames) { Write-Output "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] Start" .\LFGet.exe repair-product --product "$Product" --log "$LogDirectory" Write-Output "`n" } } 'uninstall-product' { Set-Location $LFGetDirectory Write-Output "`n[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] Starting uninstalls`n" foreach ($Product in $ProductNames) { Write-Output "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] Start" .\LFGet.exe uninstall-product --product "$Product" --log "$LogDirectory" Write-Output "`n" } } } Write-Output "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] End of $LFGetCommand script`n" Write-Output "Logs are available at $LogDirectory" } finally { if ($transcriptStarted) { Stop-Transcript | Out-Null Write-Output "Transcript saved to $transcriptPath" } } # End script