diff --git a/Build-Plugin.ps1 b/Build-Plugin.ps1
new file mode 100644
index 0000000..2382fff
--- /dev/null
+++ b/Build-Plugin.ps1
@@ -0,0 +1,512 @@
+# Build-Plugin.ps1
+# Builds and packages the AD Compare plugin for Disco ICT import
+#
+# Usage:
+# .\Build-Plugin.ps1 -DiscoBinPath "C:\Program Files\Disco\WebApp\bin"
+
+param(
+ [Parameter(Mandatory=$true)]
+ [string]$DiscoBinPath,
+
+ [string]$Configuration = "Release"
+)
+
+$ErrorActionPreference = "Stop"
+$PluginDir = $PSScriptRoot
+$PluginName = "Disco.Plugins.ADCompare"
+
+Write-Host "=== AD Compare Plugin Builder ===" -ForegroundColor Cyan
+Write-Host "Disco bin path: $DiscoBinPath" -ForegroundColor Gray
+
+# --- Validate ---
+if (-not (Test-Path $DiscoBinPath)) {
+ Write-Error "Disco bin path not found: $DiscoBinPath"
+ exit 1
+}
+
+$requiredDlls = @("Disco.Services.dll", "Disco.Models.dll", "Disco.Data.dll")
+foreach ($dll in $requiredDlls) {
+ $dllPath = Join-Path $DiscoBinPath $dll
+ if (-not (Test-Path $dllPath)) {
+ Write-Error "Required assembly not found: $dllPath"
+ exit 1
+ }
+}
+Write-Host "All required Disco assemblies found" -ForegroundColor Green
+
+# --- Show Disco Assembly Versions ---
+Write-Host "`n--- Disco Assembly Versions ---" -ForegroundColor DarkGray
+foreach ($dll in $requiredDlls) {
+ $dllPath = Join-Path $DiscoBinPath $dll
+ try {
+ $asmName = [System.Reflection.AssemblyName]::GetAssemblyName($dllPath)
+ Write-Host " $dll : v$($asmName.Version)" -ForegroundColor Gray
+ } catch {
+ Write-Warning " $dll : could not read version - $($_.Exception.Message)"
+ }
+}
+
+# --- Find MSBuild ---
+$msbuild = $null
+$vsWhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
+if (Test-Path $vsWhere) {
+ $msbuild = & $vsWhere -latest -requires Microsoft.Component.MSBuild -find "MSBuild\**\Bin\MSBuild.exe" 2>$null | Select-Object -First 1
+}
+if (-not $msbuild -or -not (Test-Path $msbuild)) {
+ $msbuild = "C:\Windows\Microsoft.NET\Framework64\v4.0.30319\MSBuild.exe"
+}
+if (-not (Test-Path $msbuild)) {
+ Write-Error "MSBuild not found."
+ exit 1
+}
+Write-Host "Using MSBuild: $msbuild" -ForegroundColor Gray
+
+# --- Resolve all HintPaths to Disco bin ---
+$csproj = Join-Path $PluginDir "$PluginName.csproj"
+$csprojContent = Get-Content $csproj -Raw
+$csprojContent = [regex]::Replace($csprojContent, '[^<]+\\([^\\<]+\.dll)', "$DiscoBinPath\`$1")
+
+$tempCsproj = Join-Path $PluginDir "$PluginName.build.csproj"
+$csprojContent | Set-Content $tempCsproj -Encoding UTF8
+Write-Host "Assembly references resolved to $DiscoBinPath" -ForegroundColor Green
+
+# --- Build ---
+Write-Host "`nBuilding $PluginName ($Configuration)..." -ForegroundColor Yellow
+$buildOutput = Join-Path $PluginDir "bin\$Configuration"
+
+& $msbuild $tempCsproj /p:Configuration=$Configuration /p:OutputPath=$buildOutput /t:Build /v:minimal
+
+if ($LASTEXITCODE -ne 0) {
+ Remove-Item $tempCsproj -ErrorAction SilentlyContinue
+ Write-Error "Build failed!"
+ exit 1
+}
+Remove-Item $tempCsproj -ErrorAction SilentlyContinue
+
+$pluginDll = Join-Path $buildOutput "$PluginName.dll"
+if (-not (Test-Path $pluginDll)) {
+ Write-Error "Build output not found: $pluginDll"
+ exit 1
+}
+Write-Host "Build successful: $pluginDll" -ForegroundColor Green
+
+# --- Copy Dependencies for Reflection and ManifestGenerator ---
+foreach ($dll in $requiredDlls) {
+ Copy-Item (Join-Path $DiscoBinPath $dll) $buildOutput -Force -ErrorAction SilentlyContinue
+}
+$extraDlls = @("EntityFramework.dll", "System.Web.Mvc.dll", "System.Web.WebPages.dll", "System.Web.Razor.dll", "RazorGenerator.Mvc.dll", "Newtonsoft.Json.dll")
+foreach ($dep in $extraDlls) {
+ $depPath = Join-Path $DiscoBinPath $dep
+ if (Test-Path $depPath) { Copy-Item $depPath $buildOutput -Force -ErrorAction SilentlyContinue }
+}
+
+# --- Reflection-Based Feature Diagnostics ---
+Write-Host "`n--- Plugin Assembly Diagnostics ---" -ForegroundColor Yellow
+
+$reflectionOk = $true
+try {
+ # Set up AssemblyResolve handler so the CLR can find Disco.Services.dll etc.
+ # when we load the plugin assembly for reflection
+ $resolveHandler = [System.ResolveEventHandler]{
+ param($sender, $args)
+ $shortName = ($args.Name -split ',')[0].Trim()
+ $probePaths = @($buildOutput, $DiscoBinPath)
+ foreach ($probe in $probePaths) {
+ $candidate = Join-Path $probe "$shortName.dll"
+ if (Test-Path $candidate) {
+ return [System.Reflection.Assembly]::LoadFrom($candidate)
+ }
+ }
+ return $null
+ }
+ [System.AppDomain]::CurrentDomain.add_AssemblyResolve($resolveHandler)
+
+ $pluginAsm = [System.Reflection.Assembly]::LoadFrom($pluginDll)
+
+ Write-Host " Plugin assembly loaded: $($pluginAsm.FullName)" -ForegroundColor Gray
+
+ # Find all types that extend UIExtensionFeature
+ $uiExtTypes = @()
+ foreach ($t in $pluginAsm.GetExportedTypes()) {
+ $baseType = $t.BaseType
+ if ($baseType -ne $null -and $baseType.IsGenericType) {
+ $genDef = $baseType.GetGenericTypeDefinition()
+ if ($genDef.FullName -eq 'Disco.Services.Plugins.Features.UIExtension.UIExtensionFeature`1') {
+ $uiExtTypes += $t
+ }
+ }
+ }
+
+ if ($uiExtTypes.Count -eq 0) {
+ Write-Warning " NO UIExtensionFeature implementations found in assembly!"
+ Write-Warning " ExecuteAction will never be called without UIExtension features."
+ $reflectionOk = $false
+ } else {
+ Write-Host " Found $($uiExtTypes.Count) UIExtension feature(s):" -ForegroundColor Green
+ }
+
+ foreach ($t in $uiExtTypes) {
+ $baseType = $t.BaseType
+ $genArgs = $baseType.GetGenericArguments()
+ $modelType = $genArgs[0]
+ $genDefFullName = $baseType.GetGenericTypeDefinition().FullName
+
+ Write-Host "`n Feature: $($t.FullName)" -ForegroundColor Cyan
+ Write-Host " Base type: $($baseType.FullName)" -ForegroundColor Gray
+ Write-Host " Generic definition: $genDefFullName" -ForegroundColor Gray
+ Write-Host " Model type: $($modelType.FullName)" -ForegroundColor Gray
+ Write-Host " Model assembly: $($modelType.Assembly.GetName().Name) v$($modelType.Assembly.GetName().Version)" -ForegroundColor Gray
+
+ # Verify [PluginFeature] attribute
+ $attrs = $t.GetCustomAttributes($true) | Where-Object { $_.GetType().Name -eq 'PluginFeatureAttribute' }
+ if ($attrs) {
+ foreach ($attr in $attrs) {
+ Write-Host " [PluginFeature] Id: $($attr.Id)" -ForegroundColor Gray
+ Write-Host " [PluginFeature] Name: $($attr.Name)" -ForegroundColor Gray
+ }
+ } else {
+ Write-Warning " MISSING [PluginFeature] attribute! Disco cannot discover this feature."
+ $reflectionOk = $false
+ }
+
+ # Verify ExecuteAction is overridden (not just the abstract base)
+ $execMethod = $t.GetMethod('ExecuteAction', [System.Reflection.BindingFlags]'Public,Instance')
+ if ($execMethod -and $execMethod.DeclaringType -eq $t) {
+ Write-Host " ExecuteAction: overridden (OK)" -ForegroundColor Green
+ } elseif ($execMethod) {
+ Write-Warning " ExecuteAction: NOT overridden (declared in $($execMethod.DeclaringType.Name))"
+ $reflectionOk = $false
+ } else {
+ Write-Warning " ExecuteAction: NOT FOUND"
+ $reflectionOk = $false
+ }
+
+ # Verify Initialize is overridden and calls Register()
+ $initMethod = $t.GetMethod('Initialize', [System.Reflection.BindingFlags]'Public,Instance', $null, @([Type]'Disco.Data.Repository.DiscoDataContext'), $null)
+ if ($initMethod -and $initMethod.DeclaringType -eq $t) {
+ Write-Host " Initialize: overridden (OK)" -ForegroundColor Green
+ } else {
+ Write-Warning " Initialize: NOT overridden - Register() will never be called!"
+ $reflectionOk = $false
+ }
+
+ # Check that the model type implements BaseUIModel
+ $baseUIModel = $modelType.GetInterface('BaseUIModel')
+ if ($baseUIModel) {
+ Write-Host " BaseUIModel: $($modelType.Name) implements BaseUIModel (OK)" -ForegroundColor Green
+ } else {
+ Write-Warning " BaseUIModel: $($modelType.Name) does NOT implement BaseUIModel!"
+ $reflectionOk = $false
+ }
+ }
+
+ # Check the main plugin class
+ $pluginClass = $pluginAsm.GetExportedTypes() | Where-Object {
+ $_.BaseType -ne $null -and $_.BaseType.FullName -eq 'Disco.Services.Plugins.Plugin'
+ }
+ if ($pluginClass) {
+ Write-Host "`n Plugin class: $($pluginClass.FullName)" -ForegroundColor Cyan
+ $pluginAttrs = $pluginClass.GetCustomAttributes($true) | Where-Object { $_.GetType().Name -eq 'PluginAttribute' }
+ if ($pluginAttrs) {
+ foreach ($attr in $pluginAttrs) {
+ Write-Host " [Plugin] Id: $($attr.Id)" -ForegroundColor Gray
+ Write-Host " [Plugin] Name: $($attr.Name)" -ForegroundColor Gray
+ }
+ } else {
+ Write-Warning " MISSING [Plugin] attribute!"
+ }
+ }
+
+} catch {
+ Write-Warning " Reflection diagnostics failed: $($_.Exception.Message)"
+ if ($_.Exception.InnerException) {
+ Write-Warning " Inner exception: $($_.Exception.InnerException.Message)"
+ }
+ # For ReflectionTypeLoadException, show the loader exceptions
+ if ($_.Exception.InnerException -and $_.Exception.InnerException.GetType().Name -eq 'ReflectionTypeLoadException') {
+ $loaderExceptions = $_.Exception.InnerException.LoaderExceptions
+ if ($loaderExceptions) {
+ Write-Warning " Loader exceptions:"
+ $loaderExceptions | Select-Object -Unique | ForEach-Object {
+ Write-Warning " - $($_.Message)"
+ }
+ }
+ }
+ Write-Warning " This may indicate assembly load issues (missing dependencies, version mismatches)."
+ $reflectionOk = $false
+} finally {
+ # Clean up the AssemblyResolve handler
+ if ($resolveHandler) {
+ [System.AppDomain]::CurrentDomain.remove_AssemblyResolve($resolveHandler)
+ }
+}
+
+if ($reflectionOk) {
+ Write-Host "`n All reflection checks PASSED" -ForegroundColor Green
+} else {
+ Write-Host "`n Some reflection checks FAILED - see warnings above" -ForegroundColor Red
+}
+
+# --- Generate Manifest ---
+Write-Host "`n--- Manifest Generation ---" -ForegroundColor Yellow
+
+$useManifestGen = $false
+$manifestGenExe = Join-Path $DiscoBinPath "Disco.Services.Plugins.ManifestGenerator.exe"
+if (-not (Test-Path $manifestGenExe)) {
+ $manifestGenExe = Join-Path (Split-Path $DiscoBinPath -Parent) "Disco.Services.Plugins.ManifestGenerator.exe"
+}
+if (Test-Path $manifestGenExe) {
+ Write-Host "Found ManifestGenerator: $manifestGenExe" -ForegroundColor Gray
+ $useManifestGen = $true
+
+ # Capture stdout and stderr separately for diagnostics
+ $manifestGenOutput = & $manifestGenExe $pluginDll 2>&1
+ $manifestGenExit = $LASTEXITCODE
+
+ if ($manifestGenOutput) {
+ Write-Host " ManifestGenerator output:" -ForegroundColor DarkGray
+ $manifestGenOutput | ForEach-Object {
+ $line = $_.ToString()
+ if ($_ -is [System.Management.Automation.ErrorRecord]) {
+ Write-Host " [STDERR] $line" -ForegroundColor Red
+ } else {
+ Write-Host " $line" -ForegroundColor DarkGray
+ }
+ }
+ }
+
+ if ($manifestGenExit -ne 0) {
+ Write-Warning "ManifestGenerator exited with code $manifestGenExit, falling back to manual manifest..."
+ $useManifestGen = $false
+ } else {
+ Write-Host "ManifestGenerator completed successfully" -ForegroundColor Green
+ }
+} else {
+ Write-Host "ManifestGenerator not found at:" -ForegroundColor DarkGray
+ Write-Host " $DiscoBinPath" -ForegroundColor DarkGray
+ Write-Host " $(Split-Path $DiscoBinPath -Parent)" -ForegroundColor DarkGray
+ Write-Host "Falling back to manual manifest generation..." -ForegroundColor Yellow
+}
+
+if (-not $useManifestGen) {
+ $version = [System.Reflection.AssemblyName]::GetAssemblyName($pluginDll).Version
+
+ # CategoryTypeName must match the .NET generic type definition FullName.
+ # For UIExtensionFeature, this is UIExtensionFeature`1 (one backtick, then 1).
+ # In PowerShell double-quoted strings, `` (two backticks) produces one literal backtick.
+ $uiExtCategoryTypeName = "Disco.Services.Plugins.Features.UIExtension.UIExtensionFeature``1"
+
+ $manifest = [ordered]@{
+ Id = $PluginName
+ Name = "AD Compare"
+ Author = "Jess Rogerson"
+ Url = "https://gitea.hideawaygaming.com.au/jessikitty/disco-ad-compare-plugin"
+ Version = $version.ToString()
+ AssemblyPath = "$PluginName.dll"
+ TypeName = "Disco.Plugins.ADCompare.ADComparePlugin"
+ ConfigurationHandlerTypeName = "Disco.Plugins.ADCompare.ConfigurationHandler.ADCompareConfigurationHandler"
+ WebHandlerTypeName = "Disco.Plugins.ADCompare.WebHandler.ADCompareWebHandler"
+ Features = @(
+ [ordered]@{
+ Id = "ADCompareDeviceUI"
+ Name = "Device Page - AD Compare"
+ TypeName = "Disco.Plugins.ADCompare.Features.DeviceUIExtension"
+ PrimaryFeature = $false
+ CategoryTypeName = $uiExtCategoryTypeName
+ },
+ [ordered]@{
+ Id = "ADCompareUserUI"
+ Name = "User Page - AD Compare"
+ TypeName = "Disco.Plugins.ADCompare.Features.UserUIExtension"
+ PrimaryFeature = $false
+ CategoryTypeName = $uiExtCategoryTypeName
+ }
+ )
+ }
+ $manifestJson = $manifest | ConvertTo-Json -Depth 5
+ $manifestJson | Set-Content (Join-Path $buildOutput "manifest.json") -Encoding UTF8
+ Write-Host "Manual manifest.json created" -ForegroundColor Green
+}
+
+# --- Validate Manifest ---
+Write-Host "`n--- Manifest Validation ---" -ForegroundColor Yellow
+
+$manifestPath = Join-Path $buildOutput "manifest.json"
+if (-not (Test-Path $manifestPath)) {
+ Write-Error "manifest.json NOT FOUND in build output! Plugin cannot be loaded by Disco."
+ exit 1
+}
+
+$manifestContent = Get-Content $manifestPath -Raw
+$manifestObj = $manifestContent | ConvertFrom-Json
+
+# Basic structure checks
+$manifestErrors = @()
+$manifestWarnings = @()
+
+if (-not $manifestObj.Id) { $manifestErrors += "Missing 'Id'" }
+if (-not $manifestObj.TypeName) { $manifestErrors += "Missing 'TypeName'" }
+if (-not $manifestObj.AssemblyPath) { $manifestErrors += "Missing 'AssemblyPath'" }
+if (-not $manifestObj.Version) { $manifestWarnings += "Missing 'Version'" }
+
+Write-Host " Id: $($manifestObj.Id)" -ForegroundColor Gray
+Write-Host " Version: $($manifestObj.Version)" -ForegroundColor Gray
+Write-Host " TypeName: $($manifestObj.TypeName)" -ForegroundColor Gray
+
+if (-not $manifestObj.Features -or $manifestObj.Features.Count -eq 0) {
+ $manifestErrors += "Features array is EMPTY or MISSING - no UIExtensions will be loaded!"
+} else {
+ Write-Host " Features: $($manifestObj.Features.Count) found" -ForegroundColor Green
+
+ # Expected CategoryTypeName (with literal backtick)
+ $expectedCategory = 'Disco.Services.Plugins.Features.UIExtension.UIExtensionFeature`1'
+
+ foreach ($feature in $manifestObj.Features) {
+ Write-Host "`n Feature: $($feature.Id)" -ForegroundColor Cyan
+ Write-Host " Name: $($feature.Name)" -ForegroundColor Gray
+ Write-Host " TypeName: $($feature.TypeName)" -ForegroundColor Gray
+ Write-Host " CategoryTypeName: $($feature.CategoryTypeName)" -ForegroundColor Gray
+ Write-Host " PrimaryFeature: $($feature.PrimaryFeature)" -ForegroundColor Gray
+
+ if (-not $feature.TypeName) {
+ $manifestErrors += "Feature '$($feature.Id)' is missing TypeName"
+ }
+
+ if (-not $feature.CategoryTypeName) {
+ $manifestErrors += "Feature '$($feature.Id)' is missing CategoryTypeName"
+ } elseif ($feature.CategoryTypeName -ne $expectedCategory) {
+ $manifestErrors += "Feature '$($feature.Id)' CategoryTypeName MISMATCH!"
+ Write-Host " Expected: $expectedCategory" -ForegroundColor Red
+ Write-Host " Got: $($feature.CategoryTypeName)" -ForegroundColor Red
+
+ # Char-by-char comparison to identify the exact difference
+ $expChars = $expectedCategory.ToCharArray()
+ $actChars = $feature.CategoryTypeName.ToCharArray()
+ for ($i = 0; $i -lt [Math]::Max($expChars.Length, $actChars.Length); $i++) {
+ $ec = if ($i -lt $expChars.Length) { $expChars[$i] } else { $null }
+ $ac = if ($i -lt $actChars.Length) { $actChars[$i] } else { $null }
+ if ($ec -ne $ac) {
+ $ecDisplay = if ($ec) { "'$ec' (0x{0:X2})" -f [int][char]$ec } else { '[END]' }
+ $acDisplay = if ($ac) { "'$ac' (0x{0:X2})" -f [int][char]$ac } else { '[END]' }
+ Write-Host " First difference at position ${i}: expected $ecDisplay vs got $acDisplay" -ForegroundColor Red
+ break
+ }
+ }
+ } else {
+ Write-Host " CategoryTypeName: VALID" -ForegroundColor Green
+ }
+ }
+}
+
+# Show raw manifest for manual inspection
+Write-Host "`n --- Raw manifest.json ---" -ForegroundColor DarkGray
+$manifestContent -split "`n" | ForEach-Object {
+ Write-Host " $_" -ForegroundColor DarkGray
+}
+Write-Host " --- End manifest.json ---" -ForegroundColor DarkGray
+
+if ($manifestErrors.Count -gt 0) {
+ Write-Host "`n MANIFEST ERRORS:" -ForegroundColor Red
+ foreach ($err in $manifestErrors) {
+ Write-Host " [ERROR] $err" -ForegroundColor Red
+ }
+}
+if ($manifestWarnings.Count -gt 0) {
+ foreach ($warn in $manifestWarnings) {
+ Write-Host " [WARN] $warn" -ForegroundColor Yellow
+ }
+}
+if ($manifestErrors.Count -eq 0) {
+ Write-Host "`n All manifest checks PASSED" -ForegroundColor Green
+} else {
+ Write-Host "`n Manifest has errors - UIExtensions will NOT render!" -ForegroundColor Red
+ Write-Host " Fix the errors above before installing the plugin." -ForegroundColor Red
+}
+
+# --- Package ---
+Write-Host "`nPackaging .discoPlugin file..." -ForegroundColor Yellow
+
+$includeFiles = @("$PluginName.dll", "$PluginName.pdb", "manifest.json")
+
+if (-not $version) {
+ $version = [System.Reflection.AssemblyName]::GetAssemblyName($pluginDll).Version
+}
+
+$packageName = "$PluginName-$($version.ToString()).discoPlugin"
+$packagePath = Join-Path $PluginDir $packageName
+
+Get-ChildItem $PluginDir -Filter "*.discoPlugin" | Remove-Item -Force
+Get-ChildItem $PluginDir -Filter "*.zip" | Where-Object { $_.Name -like "$PluginName*" } | Remove-Item -Force
+
+$tempPkg = Join-Path $env:TEMP "discoplugin_$([guid]::NewGuid().ToString('N'))"
+New-Item -ItemType Directory -Path $tempPkg -Force | Out-Null
+$missingFiles = @()
+foreach ($f in $includeFiles) {
+ $src = Join-Path $buildOutput $f
+ if (Test-Path $src) {
+ Copy-Item $src $tempPkg -Force
+ } else {
+ $missingFiles += $f
+ }
+}
+
+if ($missingFiles.Count -gt 0) {
+ Write-Warning "Missing files not included in package: $($missingFiles -join ', ')"
+}
+
+Write-Host "Files in package:" -ForegroundColor Gray
+Get-ChildItem $tempPkg -File | ForEach-Object {
+ $size = [math]::Round($_.Length / 1KB, 1)
+ Write-Host " $($_.Name) ($size KB)" -ForegroundColor Gray
+}
+
+$zipPath = Join-Path $PluginDir "$PluginName-$($version.ToString()).zip"
+Compress-Archive -Path "$tempPkg\*" -DestinationPath $zipPath -Force
+Rename-Item $zipPath $packageName -Force
+Remove-Item $tempPkg -Recurse -Force
+
+# --- Final Package Verification ---
+Write-Host "`n--- Package Verification ---" -ForegroundColor Yellow
+try {
+ Add-Type -AssemblyName System.IO.Compression.FileSystem
+ $zip = [System.IO.Compression.ZipFile]::OpenRead($packagePath)
+ $entries = $zip.Entries | Select-Object -ExpandProperty Name
+ $zip.Dispose()
+
+ $requiredEntries = @("$PluginName.dll", "manifest.json")
+ foreach ($req in $requiredEntries) {
+ if ($entries -contains $req) {
+ Write-Host " [OK] $req" -ForegroundColor Green
+ } else {
+ Write-Host " [MISSING] $req" -ForegroundColor Red
+ }
+ }
+ # Show any extras
+ foreach ($entry in $entries) {
+ if ($entry -notin $requiredEntries) {
+ Write-Host " [+] $entry" -ForegroundColor Gray
+ }
+ }
+} catch {
+ Write-Warning " Could not verify package contents: $($_.Exception.Message)"
+}
+
+$packageSize = [math]::Round((Get-Item $packagePath).Length / 1KB, 1)
+Write-Host "`n=== Package created: $packagePath ($($packageSize) KB) ===" -ForegroundColor Green
+Write-Host "Import into Disco ICT via: Configuration > Plugins > Install Plugin" -ForegroundColor Cyan
+
+# --- Summary ---
+$hasIssues = (-not $reflectionOk) -or ($manifestErrors.Count -gt 0)
+if ($hasIssues) {
+ Write-Host "`n=== BUILD COMPLETED WITH WARNINGS ===" -ForegroundColor Yellow
+ Write-Host "Review the diagnostics above before installing." -ForegroundColor Yellow
+ Write-Host "If UIExtensions still don't render after install:" -ForegroundColor Yellow
+ Write-Host " 1. Check Disco logs for feature initialization messages" -ForegroundColor Yellow
+ Write-Host " 2. View page source and search for 'layout_uiExtensions'" -ForegroundColor Yellow
+ Write-Host " - Present but empty = ExecuteAction returned Nothing()/null" -ForegroundColor Yellow
+ Write-Host " - Missing entirely = Feature not registered (Register() not called)" -ForegroundColor Yellow
+ Write-Host " 3. Check the installed manifest at:" -ForegroundColor Yellow
+ Write-Host " App_Data\Plugins\$PluginName\manifest.json" -ForegroundColor Yellow
+} else {
+ Write-Host "`n=== BUILD COMPLETED SUCCESSFULLY ===" -ForegroundColor Green
+}