diff --git a/AGENTS.md b/AGENTS.md
index 0bc9843c..11bd1012 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,6 +1,6 @@
# Agent Guide (PSPublishModule / PowerForge.Web + Websites)
-Last updated: 2026-03-01
+Last updated: 2026-03-11
This file is the "start here" context for any agent working on the PowerForge.Web engine and the three websites that use it.
@@ -74,6 +74,15 @@ need per-user global skill installs.
## Working Agreements (Best Practices)
- Prefer engine fixes over theme hacks when the same issue can recur across sites.
+- Keep `PSPublishModule` cmdlets thin:
+ - parameter binding
+ - `ShouldProcess` / prompting / PowerShell UX
+ - output mapping back to PowerShell-facing contract types
+- Move reusable logic into shared services first:
+ - `PowerForge` for host-agnostic logic
+ - `PowerForge.PowerShell` for logic that still needs PowerShell-host/runtime concepts
+- Do not add new business logic to `PSPublishModule\Cmdlets\` when the same behavior could be reused by `PowerForge.Cli`, PowerForge Studio, tests, or another C# host.
+- If a public PSPublishModule result type must stay stable, keep the reusable internal model in `PowerForge`/`PowerForge.PowerShell` and map it back in `PSPublishModule` instead of forcing cmdlet-specific types into shared layers.
- CI/release should fail on regressions; dev should warn and summarize:
- Verify: use baselines + `failOnNewWarnings:true` in CI.
- Audit: use baselines + `failOnNewIssues:true` in CI.
@@ -81,6 +90,42 @@ need per-user global skill installs.
- Scriban: use `pf.nav_links` / `pf.nav_actions` / `pf.menu_tree` (avoid `navigation.menus[0]`).
- Commit frequently. Avoid "big bang" diffs that mix unrelated changes.
+## Module Layering
+
+When touching the PowerShell module stack, prefer this boundary:
+
+- `PSPublishModule\Cmdlets\`
+ - PowerShell-only surface area
+ - minimal orchestration
+ - no reusable build/publish/install rules unless they are truly cmdlet-specific
+- `PSPublishModule\Services\`
+ - cmdlet host adapters or PowerShell-facing compatibility mappers only
+- `PowerForge\`
+ - reusable domain logic, pipelines, models, filesystem/process/network orchestration
+- `PowerForge.PowerShell\`
+ - reusable services that still depend on PowerShell-host concepts, module registration, manifest editing, or other SMA-adjacent behavior
+
+Quick smell test before adding code to a cmdlet:
+
+1. Could this be called from a test, CLI, Studio app, or another C# host?
+2. Could two cmdlets share it?
+3. Does it manipulate files, versions, dependencies, repositories, GitHub, NuGet, or build plans?
+
+If the answer to any of those is yes, the code probably belongs in `PowerForge` or `PowerForge.PowerShell`, not directly in the cmdlet.
+
+Stop extracting when the remaining code is only:
+
+- PowerShell parameter binding and parameter-set branching
+- `ShouldProcess`, `WhatIf`, credential prompts, and PowerShell stream routing
+- host-only rendering such as `Host.UI.Write*`, Spectre.Console tables/rules, or pipeline-friendly `WriteObject` behavior
+- compatibility adapters that intentionally map shared models back to stable cmdlet-facing contracts
+
+Preferred pattern for the last 10-20%:
+
+- extract reusable workflow, validation, planning, summary-shaping, and display-line composition into `PowerForge` / `PowerForge.PowerShell`
+- keep the final host-specific rendering in the cmdlet when the rendering technology itself is PowerShell- or Spectre-specific
+- avoid creating fake abstractions just to move `AnsiConsole.Write`, `Host.UI.WriteLine`, or `WriteObject` calls out of cmdlets
+
## Quality Gates (Pattern)
Each website should have:
diff --git a/Module/Docs/Export-CertificateForNuGet.md b/Module/Docs/Export-CertificateForNuGet.md
index b9dfbae5..2e6dcccb 100644
--- a/Module/Docs/Export-CertificateForNuGet.md
+++ b/Module/Docs/Export-CertificateForNuGet.md
@@ -114,7 +114,7 @@ This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable
## OUTPUTS
-- `System.Object`
+- `PowerForge.NuGetCertificateExportResult`
## RELATED LINKS
diff --git a/Module/en-US/PSPublishModule-help.xml b/Module/en-US/PSPublishModule-help.xml
index 8fff7d07..07b757b6 100644
--- a/Module/en-US/PSPublishModule-help.xml
+++ b/Module/en-US/PSPublishModule-help.xml
@@ -1213,7 +1213,7 @@ This cmdlet exports the selected certificate from the local certificate store.
- System.Object
+ PowerForge.NuGetCertificateExportResult
diff --git a/PSPublishModule/Cmdlets/ConnectModuleRepositoryCommand.cs b/PSPublishModule/Cmdlets/ConnectModuleRepositoryCommand.cs
index 663e9753..bdadb269 100644
--- a/PSPublishModule/Cmdlets/ConnectModuleRepositoryCommand.cs
+++ b/PSPublishModule/Cmdlets/ConnectModuleRepositoryCommand.cs
@@ -93,18 +93,19 @@ public sealed class ConnectModuleRepositoryCommand : PSCmdlet
/// Executes the connect/login workflow.
protected override void ProcessRecord()
{
- PrivateGalleryCommandSupport.EnsureProviderSupported(Provider);
+ var host = new CmdletPrivateGalleryHost(this);
+ var service = new PrivateGalleryService(host);
+ service.EnsureProviderSupported(Provider);
var endpoint = AzureArtifactsRepositoryEndpoints.Create(
AzureDevOpsOrganization,
AzureDevOpsProject,
AzureArtifactsFeed,
Name);
- var prerequisiteInstall = PrivateGalleryCommandSupport.EnsureBootstrapPrerequisites(this, InstallPrerequisites.IsPresent);
- var allowInteractivePrompt = !PrivateGalleryCommandSupport.IsWhatIfRequested(this);
+ var prerequisiteInstall = service.EnsureBootstrapPrerequisites(InstallPrerequisites.IsPresent);
+ var allowInteractivePrompt = !host.IsWhatIfRequested;
- var credentialResolution = PrivateGalleryCommandSupport.ResolveCredential(
- this,
+ var credentialResolution = service.ResolveCredential(
endpoint.RepositoryName,
BootstrapMode,
CredentialUserName,
@@ -114,8 +115,7 @@ protected override void ProcessRecord()
prerequisiteInstall.Status,
allowInteractivePrompt);
- var result = PrivateGalleryCommandSupport.EnsureAzureArtifactsRepositoryRegistered(
- this,
+ var result = service.EnsureAzureArtifactsRepositoryRegistered(
AzureDevOpsOrganization,
AzureDevOpsProject,
AzureArtifactsFeed,
@@ -136,18 +136,19 @@ protected override void ProcessRecord()
if (!result.RegistrationPerformed)
{
- PrivateGalleryCommandSupport.WriteRegistrationSummary(this, result);
- WriteObject(result);
+ service.WriteRegistrationSummary(result);
+ WriteObject(ModuleRepositoryRegistrationResultMapper.ToCmdletResult(result));
return;
}
- var probe = PrivateGalleryCommandSupport.ProbeRepositoryAccess(result, credentialResolution.Credential);
+ var probe = service.ProbeRepositoryAccess(result, credentialResolution.Credential);
result.AccessProbePerformed = true;
result.AccessProbeSucceeded = probe.Succeeded;
result.AccessProbeTool = probe.Tool;
result.AccessProbeMessage = probe.Message;
- PrivateGalleryCommandSupport.WriteRegistrationSummary(this, result);
+ service.WriteRegistrationSummary(result);
+ var cmdletResult = ModuleRepositoryRegistrationResultMapper.ToCmdletResult(result);
if (!probe.Succeeded)
{
@@ -160,10 +161,10 @@ protected override void ProcessRecord()
exception,
"ConnectModuleRepositoryProbeFailed",
ErrorCategory.OpenError,
- result.RepositoryName));
+ cmdletResult.RepositoryName));
return;
}
- WriteObject(result);
+ WriteObject(cmdletResult);
}
}
diff --git a/PSPublishModule/Cmdlets/ConvertProjectConsistencyCommand.cs b/PSPublishModule/Cmdlets/ConvertProjectConsistencyCommand.cs
index 9b765ade..06e9124f 100644
--- a/PSPublishModule/Cmdlets/ConvertProjectConsistencyCommand.cs
+++ b/PSPublishModule/Cmdlets/ConvertProjectConsistencyCommand.cs
@@ -1,7 +1,6 @@
using System;
using System.Collections;
-using System.Globalization;
-using System.IO;
+using System.Collections.Generic;
using System.Management.Automation;
using PowerForge;
@@ -166,7 +165,12 @@ protected override void ProcessRecord()
if (shouldProcess)
{
var result = service.ConvertAndAnalyze(request);
- WriteSummary(result.RootPath, result.Report, result.EncodingConversion, result.LineEndingConversion);
+ WriteDisplayLines(new ProjectConsistencyDisplayService().CreateSummary(
+ result.RootPath,
+ result.Report,
+ result.EncodingConversion,
+ result.LineEndingConversion,
+ ExportPath));
WriteObject(new ProjectConsistencyConversionResult(result.Report, result.EncodingConversion, result.LineEndingConversion));
return;
}
@@ -179,36 +183,10 @@ protected override void ProcessRecord()
null));
}
- private void WriteSummary(string root, ProjectConsistencyReport report, ProjectConversionResult? encodingResult, ProjectConversionResult? lineEndingResult)
+ private void WriteDisplayLines(IReadOnlyList lines)
{
- var s = report.Summary;
- HostWriteLineSafe("Project Consistency Conversion", ConsoleColor.Cyan);
- HostWriteLineSafe($"Project: {root}");
- HostWriteLineSafe($"Target encoding: {s.RecommendedEncoding}");
- HostWriteLineSafe($"Target line ending: {s.RecommendedLineEnding}");
-
- if (encodingResult is not null)
- HostWriteLineSafe(
- $"Encoding conversion: {encodingResult.Converted}/{encodingResult.Total} converted, {encodingResult.Skipped} skipped, {encodingResult.Errors} errors",
- encodingResult.Errors == 0 ? ConsoleColor.Green : ConsoleColor.Red);
-
- if (lineEndingResult is not null)
- HostWriteLineSafe(
- $"Line ending conversion: {lineEndingResult.Converted}/{lineEndingResult.Total} converted, {lineEndingResult.Skipped} skipped, {lineEndingResult.Errors} errors",
- lineEndingResult.Errors == 0 ? ConsoleColor.Green : ConsoleColor.Red);
-
- HostWriteLineSafe("");
- HostWriteLineSafe("Consistency summary:", ConsoleColor.Cyan);
- HostWriteLineSafe(
- $" Files compliant: {s.FilesCompliant} ({s.CompliancePercentage.ToString("0.0", CultureInfo.InvariantCulture)}%)",
- s.CompliancePercentage >= 90 ? ConsoleColor.Green : s.CompliancePercentage >= 70 ? ConsoleColor.Yellow : ConsoleColor.Red);
- HostWriteLineSafe($" Files needing attention: {s.FilesWithIssues}", s.FilesWithIssues == 0 ? ConsoleColor.Green : ConsoleColor.Red);
-
- if (!string.IsNullOrWhiteSpace(ExportPath) && File.Exists(ExportPath!))
- {
- HostWriteLineSafe("");
- HostWriteLineSafe($"Detailed report exported to: {ExportPath}", ConsoleColor.Green);
- }
+ foreach (var line in lines)
+ HostWriteLineSafe(line.Text, line.Color);
}
private void HostWriteLineSafe(string text, ConsoleColor? fg = null)
diff --git a/PSPublishModule/Cmdlets/ExportCertificateForNuGetCommand.cs b/PSPublishModule/Cmdlets/ExportCertificateForNuGetCommand.cs
index 7d7a4e5a..85d8c623 100644
--- a/PSPublishModule/Cmdlets/ExportCertificateForNuGetCommand.cs
+++ b/PSPublishModule/Cmdlets/ExportCertificateForNuGetCommand.cs
@@ -1,10 +1,7 @@
using System;
using System.IO;
-using System.Linq;
using System.Management.Automation;
-using System.Security.Cryptography;
-using System.Security.Cryptography.X509Certificates;
-using System.Text.RegularExpressions;
+using PowerForge;
namespace PSPublishModule;
@@ -30,6 +27,7 @@ namespace PSPublishModule;
/// Useful when you have the SHA256 fingerprint but not the Windows thumbprint.
///
[Cmdlet(VerbsData.Export, "CertificateForNuGet", DefaultParameterSetName = ParameterSetThumbprint)]
+[OutputType(typeof(NuGetCertificateExportResult))]
public sealed class ExportCertificateForNuGetCommand : PSCmdlet
{
private const string ParameterSetThumbprint = "Thumbprint";
@@ -56,142 +54,55 @@ public sealed class ExportCertificateForNuGetCommand : PSCmdlet
/// Executes the export.
protected override void ProcessRecord()
{
- X509Store? store = null;
- try
+ var storeLocation = LocalStore == CertificateStoreLocation.LocalMachine
+ ? PowerForge.CertificateStoreLocation.LocalMachine
+ : PowerForge.CertificateStoreLocation.CurrentUser;
+ var currentDirectory = SessionState?.Path?.CurrentFileSystemLocation?.Path ?? Directory.GetCurrentDirectory();
+ var resolvedOutputPath = ResolveOutputPath(OutputPath);
+ var result = new NuGetCertificateExportService().Execute(new NuGetCertificateExportRequest
{
- var location = LocalStore == CertificateStoreLocation.LocalMachine
- ? StoreLocation.LocalMachine
- : StoreLocation.CurrentUser;
-
- store = new X509Store("My", location);
- store.Open(OpenFlags.ReadOnly);
-
- var cert = FindCertificate(store);
- if (cert is null)
- throw new InvalidOperationException(BuildNotFoundMessage());
-
- if (!HasCodeSigningEku(cert))
- {
- WriteWarning("Certificate does not appear to have Code Signing capability. This may not work for NuGet package signing.");
- }
-
- var output = ResolveOutputPath(cert);
- Directory.CreateDirectory(System.IO.Path.GetDirectoryName(output) ?? ".");
-
- var bytes = cert.Export(X509ContentType.Cert);
- File.WriteAllBytes(output, bytes);
-
- HostWriteLineSafe($"Certificate exported successfully to: {output}", ConsoleColor.Green);
- HostWriteLineSafe(string.Empty);
- HostWriteLineSafe("Next steps to register with NuGet.org:", ConsoleColor.Yellow);
- HostWriteLineSafe("1. Go to https://www.nuget.org and sign in");
- HostWriteLineSafe("2. Go to Account Settings > Certificates");
- HostWriteLineSafe("3. Click 'Register new'");
- HostWriteLineSafe($"4. Upload the file: {output}");
- HostWriteLineSafe("5. Once registered, all future packages must be signed with this certificate");
- HostWriteLineSafe(string.Empty);
- HostWriteLineSafe("Certificate details:", ConsoleColor.Cyan);
- HostWriteLineSafe($" Subject: {cert.Subject}");
- HostWriteLineSafe($" Issuer: {cert.Issuer}");
- HostWriteLineSafe($" Thumbprint: {cert.Thumbprint}");
- HostWriteLineSafe($" SHA256: {GetSha256Hex(cert)}");
- HostWriteLineSafe($" Valid From: {cert.NotBefore}");
- HostWriteLineSafe($" Valid To: {cert.NotAfter}");
-
- var ok = new PSObject();
- ok.Properties.Add(new PSNoteProperty("Success", true));
- ok.Properties.Add(new PSNoteProperty("CertificatePath", output));
- ok.Properties.Add(new PSNoteProperty("Certificate", cert));
- WriteObject(ok);
- }
- catch (Exception ex)
- {
- WriteError(new ErrorRecord(ex, "ExportCertificateForNuGetFailed", ErrorCategory.NotSpecified, null));
- var fail = new PSObject();
- fail.Properties.Add(new PSNoteProperty("Success", false));
- fail.Properties.Add(new PSNoteProperty("Error", ex.Message));
- WriteObject(fail);
- }
- finally
+ CertificateThumbprint = ParameterSetName == ParameterSetThumbprint ? CertificateThumbprint : null,
+ CertificateSha256 = ParameterSetName == ParameterSetSha256 ? CertificateSha256 : null,
+ OutputPath = resolvedOutputPath,
+ StoreLocation = storeLocation,
+ WorkingDirectory = currentDirectory
+ });
+
+ if (!result.Success)
{
- try { store?.Close(); }
- catch { /* ignore */ }
+ WriteError(new ErrorRecord(
+ new InvalidOperationException(result.Error ?? "Certificate export failed."),
+ "ExportCertificateForNuGetFailed",
+ ErrorCategory.NotSpecified,
+ null));
+ WriteObject(result);
+ return;
}
- }
- private X509Certificate2? FindCertificate(X509Store store)
- {
- if (ParameterSetName == ParameterSetThumbprint)
+ if (!result.HasCodeSigningEku)
{
- var thumb = (CertificateThumbprint ?? string.Empty).Replace(" ", string.Empty);
- return store.Certificates.Cast()
- .FirstOrDefault(c => string.Equals(c.Thumbprint, thumb, StringComparison.OrdinalIgnoreCase));
+ WriteWarning("Certificate does not appear to have Code Signing capability. This may not work for NuGet package signing.");
}
- var sha = (CertificateSha256 ?? string.Empty).Replace(" ", string.Empty);
- return store.Certificates.Cast()
- .FirstOrDefault(c => string.Equals(GetSha256Hex(c), sha, StringComparison.OrdinalIgnoreCase));
- }
+ foreach (var line in new NuGetCertificateExportDisplayService().CreateSuccessSummary(result))
+ HostWriteLineSafe(line.Text, line.Color);
- private string BuildNotFoundMessage()
- {
- if (ParameterSetName == ParameterSetThumbprint)
- return $"Certificate with thumbprint '{CertificateThumbprint}' not found in {LocalStore}\\My store";
- return $"Certificate with SHA256 '{CertificateSha256}' not found in {LocalStore}\\My store";
+ WriteObject(result);
}
- private string ResolveOutputPath(X509Certificate2 cert)
+ private string? ResolveOutputPath(string? path)
{
- if (!string.IsNullOrWhiteSpace(OutputPath))
+ if (string.IsNullOrWhiteSpace(path))
+ return null;
+
+ try
{
- return SessionState.Path.GetUnresolvedProviderPathFromPSPath(OutputPath);
+ return SessionState?.Path?.GetUnresolvedProviderPathFromPSPath(path) ?? path;
}
-
- var first = (cert.Subject ?? string.Empty).Split(',').FirstOrDefault() ?? string.Empty;
- first = first.Trim();
- if (first.StartsWith("CN=", StringComparison.OrdinalIgnoreCase))
- first = first.Substring(3);
-
- var subjectName = Regex.Replace(first, @"[^\w\s-]", string.Empty);
- if (string.IsNullOrWhiteSpace(subjectName))
- subjectName = "Certificate";
-
- var fileName = $"{subjectName}-CodeSigning.cer";
- var cwd = SessionState?.Path?.CurrentFileSystemLocation?.Path ?? Directory.GetCurrentDirectory();
- return System.IO.Path.GetFullPath(System.IO.Path.Combine(cwd, fileName));
- }
-
- private static bool HasCodeSigningEku(X509Certificate2 cert)
- {
- const string CodeSigningOid = "1.3.6.1.5.5.7.3.3";
-
- foreach (var ext in cert.Extensions)
+ catch
{
- if (ext is X509EnhancedKeyUsageExtension eku)
- {
- foreach (var oid in eku.EnhancedKeyUsages)
- {
- if (string.Equals(oid.Value, CodeSigningOid, StringComparison.OrdinalIgnoreCase) ||
- string.Equals(oid.FriendlyName, "Code Signing", StringComparison.OrdinalIgnoreCase))
- {
- return true;
- }
- }
- }
+ return path;
}
-
- return false;
- }
-
- private static string GetSha256Hex(X509Certificate2 cert)
- {
-#if NET8_0_OR_GREATER
- return cert.GetCertHashString(HashAlgorithmName.SHA256);
-#else
- using var sha = SHA256.Create();
- var hash = sha.ComputeHash(cert.RawData);
- return BitConverter.ToString(hash).Replace("-", string.Empty);
-#endif
}
private void HostWriteLineSafe(string text, ConsoleColor? fg = null)
diff --git a/PSPublishModule/Cmdlets/GetModuleTestFailuresCommand.cs b/PSPublishModule/Cmdlets/GetModuleTestFailuresCommand.cs
index 2949810a..da3a4d46 100644
--- a/PSPublishModule/Cmdlets/GetModuleTestFailuresCommand.cs
+++ b/PSPublishModule/Cmdlets/GetModuleTestFailuresCommand.cs
@@ -1,5 +1,5 @@
using System;
-using System.Globalization;
+using System.Collections.Generic;
using System.IO;
using System.Management.Automation;
using PowerForge;
@@ -94,32 +94,26 @@ protected override void ProcessRecord()
{
try
{
- var analyzer = new ModuleTestFailureAnalyzer();
var serializer = new ModuleTestFailureSerializationService();
- ModuleTestFailureAnalysis analysis;
-
- if (ParameterSetName == ParameterSetTestResults)
- {
- analysis = AnalyzeTestResults(analyzer, TestResults);
- }
- else
+ var display = new ModuleTestFailureDisplayService();
+ var workflow = new ModuleTestFailureWorkflowService();
+ var workflowResult = workflow.Execute(new ModuleTestFailureWorkflowRequest
{
- var projectPath = ResolveProjectPath();
- var resultsPath = ResolveResultsPath(Path, projectPath);
- if (resultsPath is null)
- return;
+ UseTestResultsInput = ParameterSetName == ParameterSetTestResults,
+ TestResults = TestResults,
+ ExplicitPath = Path,
+ ProjectPath = ProjectPath,
+ ModuleBasePath = MyInvocation?.MyCommand?.Module?.ModuleBase,
+ CurrentDirectory = SessionState?.Path?.CurrentFileSystemLocation?.Path ?? Directory.GetCurrentDirectory()
+ });
- if (!File.Exists(resultsPath))
- {
- // Missing results file should be non-fatal (for example: first run, partial pipelines).
- // Use a warning rather than an error to avoid turning this into a terminating exception
- // when ErrorActionPreference is set to Stop (common in CI/test runners).
- WriteWarning($"Test results file not found: {resultsPath}");
- return;
- }
+ foreach (var warning in workflowResult.WarningMessages)
+ WriteWarning(warning);
- analysis = analyzer.AnalyzeFromXmlFile(resultsPath);
- }
+ if (workflowResult.Analysis is null)
+ return;
+
+ var analysis = workflowResult.Analysis;
switch (OutputFormat)
{
@@ -127,10 +121,10 @@ protected override void ProcessRecord()
WriteObject(serializer.ToJson(analysis), enumerateCollection: false);
break;
case ModuleTestFailureOutputFormat.Summary:
- WriteSummary(analysis, ShowSuccessful.IsPresent);
+ WriteDisplayLines(display.CreateSummary(analysis, ShowSuccessful.IsPresent));
break;
case ModuleTestFailureOutputFormat.Detailed:
- WriteDetails(analysis);
+ WriteDisplayLines(display.CreateDetailed(analysis));
break;
}
@@ -146,161 +140,10 @@ protected override void ProcessRecord()
}
}
- private static ModuleTestFailureAnalysis AnalyzeTestResults(
- ModuleTestFailureAnalyzer analyzer,
- object? testResults)
- {
- if (testResults is ModuleTestFailureAnalysis analysis)
- return analysis;
-
- if (testResults is ModuleTestSuiteResult suite)
- {
- if (suite.FailureAnalysis is not null)
- return suite.FailureAnalysis;
-
- var xmlPath = suite.ResultsXmlPath;
- if (xmlPath is not null && File.Exists(xmlPath))
- return analyzer.AnalyzeFromXmlFile(xmlPath);
-
- return new ModuleTestFailureAnalysis
- {
- Source = "ModuleTestSuiteResult",
- Timestamp = DateTime.Now,
- TotalCount = suite.TotalCount,
- PassedCount = suite.PassedCount,
- FailedCount = suite.FailedCount,
- SkippedCount = suite.SkippedCount,
- FailedTests = Array.Empty()
- };
- }
-
- return analyzer.AnalyzeFromPesterResults(testResults);
- }
-
- private string ResolveProjectPath()
- {
- if (!string.IsNullOrWhiteSpace(ProjectPath))
- return System.IO.Path.GetFullPath(ProjectPath!.Trim().Trim('"'));
-
- try
- {
- var moduleBase = MyInvocation?.MyCommand?.Module?.ModuleBase;
- if (!string.IsNullOrWhiteSpace(moduleBase))
- return moduleBase!;
- }
- catch
- {
- // ignore
- }
-
- return SessionState?.Path?.CurrentFileSystemLocation?.Path ?? Directory.GetCurrentDirectory();
- }
-
- private string? ResolveResultsPath(string? explicitPath, string projectPath)
- {
- if (!string.IsNullOrWhiteSpace(explicitPath))
- return System.IO.Path.GetFullPath(explicitPath!.Trim().Trim('"'));
-
- var candidates = new[]
- {
- System.IO.Path.Combine(projectPath, "TestResults.xml"),
- System.IO.Path.Combine(projectPath, "Tests", "TestResults.xml"),
- System.IO.Path.Combine(projectPath, "Test", "TestResults.xml"),
- System.IO.Path.Combine(projectPath, "Tests", "Results", "TestResults.xml")
- };
-
- foreach (var p in candidates)
- {
- if (File.Exists(p))
- return p;
- }
-
- WriteWarning("No test results file found. Searched in:");
- foreach (var p in candidates)
- WriteWarning($" {p}");
-
- return null;
- }
-
- private void WriteSummary(ModuleTestFailureAnalysis analysis, bool showSuccessful)
- {
- HostWriteLineSafe("=== Module Test Results Summary ===", ConsoleColor.Cyan);
- HostWriteLineSafe($"Source: {analysis.Source}", ConsoleColor.DarkGray);
- HostWriteLineSafe(string.Empty);
-
- HostWriteLineSafe("Test Statistics:", ConsoleColor.Yellow);
- HostWriteLineSafe($" Total Tests: {analysis.TotalCount}");
- HostWriteLineSafe($" Passed: {analysis.PassedCount}", ConsoleColor.Green);
- HostWriteLineSafe($" Failed: {analysis.FailedCount}", analysis.FailedCount > 0 ? ConsoleColor.Red : ConsoleColor.Green);
- if (analysis.SkippedCount > 0)
- HostWriteLineSafe($" Skipped: {analysis.SkippedCount}", ConsoleColor.Yellow);
-
- if (analysis.TotalCount > 0)
- {
- var rate = Math.Round((double)analysis.PassedCount / analysis.TotalCount * 100, 1);
- var color = rate == 100 ? ConsoleColor.Green : (rate >= 80 ? ConsoleColor.Yellow : ConsoleColor.Red);
- HostWriteLineSafe($" Success Rate: {rate.ToString("0.0", CultureInfo.InvariantCulture)}%", color);
- }
-
- HostWriteLineSafe(string.Empty);
- if (analysis.FailedCount > 0)
- {
- HostWriteLineSafe("Failed Tests:", ConsoleColor.Red);
- foreach (var f in analysis.FailedTests)
- HostWriteLineSafe($" - {f.Name}", ConsoleColor.Red);
- HostWriteLineSafe(string.Empty);
- }
- else if (showSuccessful && analysis.PassedCount > 0)
- {
- HostWriteLineSafe("All tests passed successfully!", ConsoleColor.Green);
- }
- }
-
- private void WriteDetails(ModuleTestFailureAnalysis analysis)
+ private void WriteDisplayLines(IReadOnlyList lines)
{
- HostWriteLineSafe("=== Module Test Failure Analysis ===", ConsoleColor.Cyan);
- HostWriteLineSafe($"Source: {analysis.Source}", ConsoleColor.DarkGray);
- HostWriteLineSafe($"Analysis Time: {analysis.Timestamp}", ConsoleColor.DarkGray);
- HostWriteLineSafe(string.Empty);
-
- if (analysis.TotalCount == 0)
- {
- HostWriteLineSafe("No test results found", ConsoleColor.Yellow);
- return;
- }
-
- var color = analysis.FailedCount == 0 ? ConsoleColor.Green : ConsoleColor.Yellow;
- HostWriteLineSafe($"Summary: {analysis.PassedCount}/{analysis.TotalCount} tests passed", color);
- HostWriteLineSafe(string.Empty);
-
- if (analysis.FailedCount == 0)
- {
- HostWriteLineSafe("All tests passed successfully!", ConsoleColor.Green);
- return;
- }
-
- HostWriteLineSafe($"Failed Tests ({analysis.FailedCount}):", ConsoleColor.Red);
- HostWriteLineSafe(string.Empty);
-
- foreach (var f in analysis.FailedTests)
- {
- HostWriteLineSafe($"- {f.Name}", ConsoleColor.Red);
- if (!string.IsNullOrWhiteSpace(f.ErrorMessage) && !string.Equals(f.ErrorMessage, "No error message available", StringComparison.Ordinal))
- {
- foreach (var line in f.ErrorMessage.Split(new[] { '\n' }, StringSplitOptions.None))
- {
- var trimmed = line.Trim();
- if (trimmed.Length > 0)
- HostWriteLineSafe($" {trimmed}", ConsoleColor.Yellow);
- }
- }
-
- if (f.Duration.HasValue)
- HostWriteLineSafe($" Duration: {f.Duration.Value}", ConsoleColor.DarkGray);
- HostWriteLineSafe(string.Empty);
- }
-
- HostWriteLineSafe($"=== Summary: {analysis.FailedCount} test{(analysis.FailedCount != 1 ? "s" : string.Empty)} failed ===", ConsoleColor.Red);
+ foreach (var line in lines)
+ HostWriteLineSafe(line.Text, line.Color);
}
private void HostWriteLineSafe(string text, ConsoleColor? fg = null)
diff --git a/PSPublishModule/Cmdlets/GetPowerShellCompatibilityCommand.cs b/PSPublishModule/Cmdlets/GetPowerShellCompatibilityCommand.cs
index 2aad8734..1082c9c5 100644
--- a/PSPublishModule/Cmdlets/GetPowerShellCompatibilityCommand.cs
+++ b/PSPublishModule/Cmdlets/GetPowerShellCompatibilityCommand.cs
@@ -1,8 +1,7 @@
using System;
using System.Collections;
-using System.Globalization;
+using System.Collections.Generic;
using System.IO;
-using System.Linq;
using System.Management.Automation;
using PowerForge;
@@ -73,16 +72,14 @@ protected override void ProcessRecord()
var exportPath = string.IsNullOrWhiteSpace(ExportPath)
? null
: System.IO.Path.GetFullPath(ExportPath!.Trim().Trim('"'));
+ var display = new PowerShellCompatibilityDisplayService();
if (!Internal.IsPresent)
{
- HostWriteLineSafe("🔎 Analyzing PowerShell compatibility...", ConsoleColor.Cyan);
- HostWriteLineSafe($"📁 Path: {inputPath}", ConsoleColor.White);
-
var psVersionTable = SessionState?.PSVariable?.GetValue("PSVersionTable") as Hashtable;
var psVersion = psVersionTable?["PSVersion"]?.ToString() ?? string.Empty;
var psEdition = psVersionTable?["PSEdition"]?.ToString() ?? string.Empty;
- HostWriteLineSafe($"💻 Current PowerShell: {psEdition} {psVersion}", ConsoleColor.White);
+ WriteDisplayLines(display.CreateHeader(inputPath, psEdition, psVersion));
}
else
{
@@ -94,25 +91,26 @@ protected override void ProcessRecord()
isVerbose: MyInvocation.BoundParameters.ContainsKey("Verbose"),
warningsAsVerbose: Internal.IsPresent);
- var analyzer = new PowerShellCompatibilityAnalyzer(logger);
- var spec = new PowerShellCompatibilitySpec(inputPath, Recurse.IsPresent, ExcludeDirectories);
-
- var report = analyzer.Analyze(
- spec,
+ var workflow = new PowerShellCompatibilityWorkflowService(new PowerShellCompatibilityAnalyzer(logger));
+ var result = workflow.Execute(
+ new PowerShellCompatibilityWorkflowRequest
+ {
+ InputPath = inputPath,
+ ExportPath = exportPath,
+ Recurse = Recurse.IsPresent,
+ ExcludeDirectories = ExcludeDirectories,
+ Internal = Internal.IsPresent
+ },
progress: !Internal.IsPresent
? p =>
{
- var percent = p.Total == 0
- ? 0
- : (int)Math.Round((p.Current / (double)p.Total) * 100.0, 0);
-
WriteProgress(new ProgressRecord(1, "Analyzing PowerShell Compatibility", $"Processing {System.IO.Path.GetFileName(p.FilePath)}")
{
- PercentComplete = percent
+ PercentComplete = PowerShellCompatibilityDisplayService.CalculatePercent(p)
});
}
- : null,
- exportPath: exportPath);
+ : null);
+ var report = result.Report;
if (!Internal.IsPresent && report.Files.Length > 0)
WriteProgress(new ProgressRecord(1, "Analyzing PowerShell Compatibility", "Completed") { RecordType = ProgressRecordType.Completed });
@@ -127,128 +125,28 @@ protected override void ProcessRecord()
if (!Internal.IsPresent)
HostWriteLineSafe($"📄 Found {report.Files.Length} PowerShell files to analyze", ConsoleColor.Yellow);
else
- WriteVerbose($"Found {report.Files.Length} PowerShell files to analyze");
-
- WriteSummaryToHost(report.Summary);
- WriteDetailsToHost(report.Files);
- WriteExportToHostIfRequested(exportPath);
-
- WriteObject(report, enumerateCollection: false);
- }
-
- private void WriteSummaryToHost(PowerShellCompatibilitySummary summary)
- {
- if (Internal.IsPresent)
- {
- WriteVerbose($"PowerShell Compatibility: {summary.Status} - {summary.Message}");
- if (summary.Recommendations.Length > 0)
- {
- var joined = string.Join("; ", summary.Recommendations.Where(s => !string.IsNullOrWhiteSpace(s)));
- if (!string.IsNullOrWhiteSpace(joined))
- WriteVerbose($"Recommendations: {joined}");
- }
- return;
- }
-
- HostWriteLineSafe(string.Empty);
-
- var color = summary.Status switch
- {
- CheckStatus.Pass => ConsoleColor.Green,
- CheckStatus.Warning => ConsoleColor.Yellow,
- _ => ConsoleColor.Red
- };
-
- var statusEmoji = summary.Status switch
- {
- CheckStatus.Pass => "✅",
- CheckStatus.Warning => "⚠️",
- _ => "❌"
- };
-
- HostWriteLineSafe($"{statusEmoji} Status: {summary.Status}", color);
- HostWriteLineSafe(summary.Message, ConsoleColor.White);
- HostWriteLineSafe($"PS 5.1 compatible: {summary.PowerShell51Compatible}/{summary.TotalFiles}", ConsoleColor.White);
- HostWriteLineSafe($"PS 7 compatible: {summary.PowerShell7Compatible}/{summary.TotalFiles}", ConsoleColor.White);
- HostWriteLineSafe($"Cross-compatible: {summary.CrossCompatible}/{summary.TotalFiles} ({summary.CrossCompatibilityPercentage.ToString("0.0", CultureInfo.InvariantCulture)}%)", ConsoleColor.White);
-
- if (summary.Recommendations.Length > 0)
- {
- HostWriteLineSafe(string.Empty);
- HostWriteLineSafe("Recommendations:", ConsoleColor.Cyan);
- foreach (var r in summary.Recommendations.Where(s => !string.IsNullOrWhiteSpace(s)))
- HostWriteLineSafe($"- {r}", ConsoleColor.White);
- }
- }
-
- private void WriteDetailsToHost(PowerShellCompatibilityFileResult[] results)
- {
- if (!ShowDetails.IsPresent)
- return;
-
- if (results.Length == 0)
- return;
+ foreach (var message in display.CreateInternalSummaryMessages(report, ShowDetails.IsPresent, exportPath))
+ WriteVerbose(message);
- if (Internal.IsPresent)
+ if (!Internal.IsPresent)
{
- foreach (var r in results)
+ WriteDisplayLines(display.CreateSummary(report.Summary));
+ if (ShowDetails.IsPresent)
+ WriteDisplayLines(display.CreateDetails(report.Files));
+ if (!string.IsNullOrWhiteSpace(exportPath))
{
- if (r.Issues.Length > 0)
- {
- var issueTypes = string.Join(
- ", ",
- r.Issues.Select(i => i.Type.ToString())
- .Where(s => !string.IsNullOrWhiteSpace(s))
- .Distinct(StringComparer.OrdinalIgnoreCase));
-
- WriteVerbose($"Issues in {r.RelativePath}: {issueTypes}");
- }
+ var exportStatus = display.CreateExportStatus(exportPath!, File.Exists(exportPath));
+ HostWriteLineSafe(exportStatus.Text, exportStatus.Color);
}
- return;
}
- HostWriteLineSafe(string.Empty);
- HostWriteLineSafe("Detailed Analysis:", ConsoleColor.Cyan);
-
- foreach (var r in results)
- {
- HostWriteLineSafe(string.Empty);
- HostWriteLineSafe($"{r.RelativePath}", ConsoleColor.White);
-
- HostWriteLineSafe($" PS 5.1: {(r.PowerShell51Compatible ? "Compatible" : "Not compatible")}", r.PowerShell51Compatible ? ConsoleColor.Green : ConsoleColor.Red);
- HostWriteLineSafe($" PS 7: {(r.PowerShell7Compatible ? "Compatible" : "Not compatible")}", r.PowerShell7Compatible ? ConsoleColor.Green : ConsoleColor.Red);
-
- if (r.Issues.Length == 0)
- continue;
-
- HostWriteLineSafe(" Issues:", ConsoleColor.Yellow);
- foreach (var issue in r.Issues)
- {
- HostWriteLineSafe($" - {issue.Type}: {issue.Description}", ConsoleColor.Red);
- if (!string.IsNullOrWhiteSpace(issue.Recommendation))
- HostWriteLineSafe($" - {issue.Recommendation}", ConsoleColor.Cyan);
- }
- }
+ WriteObject(report, enumerateCollection: false);
}
- private void WriteExportToHostIfRequested(string? exportPath)
+ private void WriteDisplayLines(IReadOnlyList lines)
{
- if (string.IsNullOrWhiteSpace(exportPath))
- return;
-
- if (!File.Exists(exportPath))
- {
- if (Internal.IsPresent)
- WriteVerbose($"Failed to export detailed report to: {exportPath}");
- else
- HostWriteLineSafe($"❌ Failed to export detailed report to: {exportPath}", ConsoleColor.Red);
- return;
- }
-
- if (Internal.IsPresent)
- WriteVerbose($"Detailed report exported to: {exportPath}");
- else
- HostWriteLineSafe($"✅ Detailed report exported to: {exportPath}", ConsoleColor.Green);
+ foreach (var line in lines)
+ HostWriteLineSafe(line.Text, line.Color);
}
private void HostWriteLineSafe(string text, ConsoleColor? fg = null)
diff --git a/PSPublishModule/Cmdlets/GetProjectConsistencyCommand.cs b/PSPublishModule/Cmdlets/GetProjectConsistencyCommand.cs
index 8d12638e..9321c73d 100644
--- a/PSPublishModule/Cmdlets/GetProjectConsistencyCommand.cs
+++ b/PSPublishModule/Cmdlets/GetProjectConsistencyCommand.cs
@@ -1,7 +1,5 @@
using System;
-using System.Globalization;
using System.IO;
-using System.Linq;
using System.Management.Automation;
using PowerForge;
@@ -95,10 +93,6 @@ protected override void ProcessRecord()
var patterns = ProjectConsistencyWorkflowService.ResolvePatterns(ProjectType, CustomExtensions);
WriteVerbose($"Project type: {ProjectType} with patterns: {string.Join(", ", patterns)}");
- HostWriteLineSafe("🔎 Analyzing project consistency...", ConsoleColor.Cyan);
- HostWriteLineSafe($"Project: {root}");
- HostWriteLineSafe($"Type: {ProjectType}");
-
ProjectConsistencyWorkflowResult workflow;
try
{
@@ -110,6 +104,7 @@ protected override void ProcessRecord()
}
var report = workflow.Report;
+ var displayService = new ProjectConsistencyDisplayService();
if (report.Summary.TotalFiles == 0)
{
@@ -117,53 +112,8 @@ protected override void ProcessRecord()
return;
}
- var s = report.Summary;
-
- HostWriteLineSafe($"Target encoding: {s.RecommendedEncoding}");
- HostWriteLineSafe($"Target line ending: {s.RecommendedLineEnding}");
-
- // Display summary (preserve existing UX: always prints to host).
- HostWriteLineSafe("");
- HostWriteLineSafe("Project Consistency Summary:", ConsoleColor.Cyan);
- HostWriteLineSafe($" Total files analyzed: {s.TotalFiles}");
- HostWriteLineSafe(
- $" Files compliant with standards: {s.FilesCompliant} ({s.CompliancePercentage.ToString("0.0", CultureInfo.InvariantCulture)}%)",
- s.CompliancePercentage >= 90 ? ConsoleColor.Green : s.CompliancePercentage >= 70 ? ConsoleColor.Yellow : ConsoleColor.Red);
- HostWriteLineSafe($" Files needing attention: {s.FilesWithIssues}", s.FilesWithIssues == 0 ? ConsoleColor.Green : ConsoleColor.Red);
-
- HostWriteLineSafe("");
- HostWriteLineSafe("Encoding Issues:", ConsoleColor.Cyan);
- HostWriteLineSafe(
- $" Files needing encoding conversion: {s.FilesNeedingEncodingConversion}",
- s.FilesNeedingEncodingConversion == 0 ? ConsoleColor.Green : ConsoleColor.Yellow);
- HostWriteLineSafe($" Target encoding: {s.RecommendedEncoding}");
-
- HostWriteLineSafe("");
- HostWriteLineSafe("Line Ending Issues:", ConsoleColor.Cyan);
- HostWriteLineSafe(
- $" Files needing line ending conversion: {s.FilesNeedingLineEndingConversion}",
- s.FilesNeedingLineEndingConversion == 0 ? ConsoleColor.Green : ConsoleColor.Yellow);
- HostWriteLineSafe(
- $" Files with mixed line endings: {s.FilesWithMixedLineEndings}",
- s.FilesWithMixedLineEndings == 0 ? ConsoleColor.Green : ConsoleColor.Red);
- HostWriteLineSafe(
- $" Files missing final newline: {s.FilesMissingFinalNewline}",
- s.FilesMissingFinalNewline == 0 ? ConsoleColor.Green : ConsoleColor.Yellow);
- HostWriteLineSafe($" Target line ending: {s.RecommendedLineEnding}");
-
- if (s.ExtensionIssues.Length > 0)
- {
- HostWriteLineSafe("");
- HostWriteLineSafe("Extensions with Issues:", ConsoleColor.Yellow);
- foreach (var issue in s.ExtensionIssues.OrderByDescending(i => i.Total))
- HostWriteLineSafe($" {issue.Extension}: {issue.Total} files");
- }
-
- if (!string.IsNullOrWhiteSpace(ExportPath) && File.Exists(ExportPath!))
- {
- HostWriteLineSafe("");
- HostWriteLineSafe($"Detailed report exported to: {ExportPath}", ConsoleColor.Green);
- }
+ foreach (var line in displayService.CreateAnalysisSummary(root, ProjectType, report, ExportPath))
+ HostWriteLineSafe(line.Text, line.Color);
WriteObject(report);
}
diff --git a/PSPublishModule/Cmdlets/InstallPrivateModuleCommand.cs b/PSPublishModule/Cmdlets/InstallPrivateModuleCommand.cs
index 329f878c..4e7692b2 100644
--- a/PSPublishModule/Cmdlets/InstallPrivateModuleCommand.cs
+++ b/PSPublishModule/Cmdlets/InstallPrivateModuleCommand.cs
@@ -117,109 +117,36 @@ public sealed class InstallPrivateModuleCommand : PSCmdlet
/// Executes the install workflow.
protected override void ProcessRecord()
{
- var modules = PrivateGalleryCommandSupport.BuildDependencies(Name);
- var repositoryName = Repository;
- RepositoryCredential? credential;
- var preferPowerShellGet = false;
-
- if (ParameterSetName == ParameterSetAzureArtifacts)
- {
- PrivateGalleryCommandSupport.EnsureProviderSupported(Provider);
-
- var endpoint = AzureArtifactsRepositoryEndpoints.Create(
- AzureDevOpsOrganization,
- AzureDevOpsProject,
- AzureArtifactsFeed,
- RepositoryName);
- var prerequisiteInstall = PrivateGalleryCommandSupport.EnsureBootstrapPrerequisites(this, InstallPrerequisites.IsPresent);
- var allowInteractivePrompt = !PrivateGalleryCommandSupport.IsWhatIfRequested(this);
-
- repositoryName = endpoint.RepositoryName;
- var credentialResolution = PrivateGalleryCommandSupport.ResolveCredential(
- this,
- repositoryName,
- BootstrapMode,
- CredentialUserName,
- CredentialSecret,
- CredentialSecretFilePath,
- PromptForCredential,
- prerequisiteInstall.Status,
- allowInteractivePrompt);
- credential = credentialResolution.Credential;
-
- var registration = PrivateGalleryCommandSupport.EnsureAzureArtifactsRepositoryRegistered(
- this,
- AzureDevOpsOrganization,
- AzureDevOpsProject,
- AzureArtifactsFeed,
- RepositoryName,
- Tool,
- Trusted,
- Priority,
- BootstrapMode,
- credentialResolution.BootstrapModeUsed,
- credentialResolution.CredentialSource,
- credential,
- prerequisiteInstall.Status,
- shouldProcessAction: Tool == RepositoryRegistrationTool.Auto
- ? "Register module repository using Auto (prefer PSResourceGet, fall back to PowerShellGet)"
- : $"Register module repository using {Tool}");
- registration.InstalledPrerequisites = prerequisiteInstall.InstalledPrerequisites;
- registration.PrerequisiteInstallMessages = prerequisiteInstall.Messages;
-
- if (!registration.RegistrationPerformed)
- {
- WriteWarning($"Repository '{registration.RepositoryName}' was not registered because the operation was skipped. Module installation was not attempted.");
- return;
- }
-
- PrivateGalleryCommandSupport.WriteRegistrationSummary(this, registration);
- WriteVerbose($"Repository '{registration.RepositoryName}' is ready for installation.");
-
- if (credential is null &&
- !registration.InstallPSResourceReady &&
- !registration.InstallModuleReady)
+ var host = new CmdletPrivateGalleryHost(this);
+ var logger = new CmdletLogger(this, MyInvocation.BoundParameters.ContainsKey("Verbose"));
+ var result = new PrivateModuleWorkflowService(host, new PrivateGalleryService(host), logger).Execute(
+ new PrivateModuleWorkflowRequest
{
- var hint = string.IsNullOrWhiteSpace(registration.RecommendedBootstrapCommand)
- ? string.Empty
- : $" Recommended next step: {registration.RecommendedBootstrapCommand}";
- throw new InvalidOperationException(
- $"Repository '{registration.RepositoryName}' was registered, but no native install path is ready for bootstrap mode {registration.BootstrapModeUsed}.{hint}");
- }
-
- preferPowerShellGet = credential is null &&
- string.Equals(registration.PreferredInstallCommand, "Install-Module", StringComparison.OrdinalIgnoreCase);
- }
- else
- {
- credential = null;
- }
-
- if (!ShouldProcess($"{modules.Count} module(s) from repository '{repositoryName}'", Force.IsPresent ? "Install or reinstall private modules" : "Install private modules"))
+ Operation = PrivateModuleWorkflowOperation.Install,
+ ModuleNames = Name,
+ UseAzureArtifacts = ParameterSetName == ParameterSetAzureArtifacts,
+ RepositoryName = ParameterSetName == ParameterSetAzureArtifacts ? (RepositoryName ?? string.Empty) : Repository,
+ Provider = Provider,
+ AzureDevOpsOrganization = AzureDevOpsOrganization,
+ AzureDevOpsProject = AzureDevOpsProject,
+ AzureArtifactsFeed = AzureArtifactsFeed,
+ Tool = Tool,
+ BootstrapMode = BootstrapMode,
+ Trusted = Trusted,
+ Priority = Priority,
+ CredentialUserName = CredentialUserName,
+ CredentialSecret = CredentialSecret,
+ CredentialSecretFilePath = CredentialSecretFilePath,
+ PromptForCredential = PromptForCredential,
+ InstallPrerequisites = InstallPrerequisites,
+ Prerelease = Prerelease,
+ Force = Force
+ },
+ (target, action) => ShouldProcess(target, action));
+
+ if (!result.OperationPerformed)
return;
- if (ParameterSetName == ParameterSetRepository)
- {
- credential = PrivateGalleryCommandSupport.ResolveOptionalCredential(
- this,
- repositoryName,
- CredentialUserName,
- CredentialSecret,
- CredentialSecretFilePath,
- PromptForCredential);
- }
-
- var logger = new CmdletLogger(this, MyInvocation.BoundParameters.ContainsKey("Verbose"));
- var installer = new ModuleDependencyInstaller(new PowerShellRunner(), logger);
- var results = installer.EnsureInstalled(
- modules,
- force: Force.IsPresent,
- repository: repositoryName,
- credential: credential,
- prerelease: Prerelease.IsPresent,
- preferPowerShellGet: preferPowerShellGet,
- timeoutPerModule: TimeSpan.FromMinutes(10));
-
- WriteObject(results, enumerateCollection: true);
+ WriteObject(result.DependencyResults, enumerateCollection: true);
}
}
diff --git a/PSPublishModule/Cmdlets/InvokeDotNetRepositoryReleaseCommand.cs b/PSPublishModule/Cmdlets/InvokeDotNetRepositoryReleaseCommand.cs
index 1fd50aec..b257a0dd 100644
--- a/PSPublishModule/Cmdlets/InvokeDotNetRepositoryReleaseCommand.cs
+++ b/PSPublishModule/Cmdlets/InvokeDotNetRepositoryReleaseCommand.cs
@@ -197,22 +197,34 @@ protected override void ProcessRecord()
var executeBuild = ShouldProcess(preparation.RootPath, "Release .NET repository packages");
var result = new DotNetRepositoryReleaseWorkflowService(logger).Execute(preparation, executeBuild);
if (interactive)
- WriteRepositorySummary(result, isPlan: !executeBuild);
+ {
+ var summary = new DotNetRepositoryReleaseSummaryService().CreateSummary(result);
+ var display = new DotNetRepositoryReleaseDisplayService().CreateDisplay(summary, isPlan: !executeBuild);
+ WriteRepositorySummary(display);
+ }
WriteObject(result);
}
- private static void WriteRepositorySummary(DotNetRepositoryReleaseResult result, bool isPlan)
+ private static void WriteRepositorySummary(DotNetRepositoryReleaseDisplayModel display)
{
- if (result is null) return;
+ if (display is null) return;
static string Esc(string? value) => Markup.Escape(value ?? string.Empty);
+ static string ColorTag(ConsoleColor? color)
+ => color switch
+ {
+ ConsoleColor.Green => "green",
+ ConsoleColor.Gray => "grey",
+ ConsoleColor.Red => "red",
+ ConsoleColor.Yellow => "yellow",
+ _ => "white"
+ };
var unicode = AnsiConsole.Profile.Capabilities.Unicode;
var border = unicode ? TableBorder.Rounded : TableBorder.Simple;
- var title = isPlan ? "Plan" : "Summary";
var icon = unicode ? "✅" : "*";
- AnsiConsole.Write(new Rule($"[green]{icon} {title}[/]").LeftJustified());
+ AnsiConsole.Write(new Rule($"[green]{icon} {display.Title}[/]").LeftJustified());
var table = new Table()
.Border(border)
@@ -223,19 +235,15 @@ private static void WriteRepositorySummary(DotNetRepositoryReleaseResult result,
.AddColumn(new TableColumn("Status").NoWrap())
.AddColumn(new TableColumn("Error"));
- foreach (var project in result.Projects.OrderBy(p => p.ProjectName, StringComparer.OrdinalIgnoreCase))
+ foreach (var project in display.Projects)
{
- var packable = project.IsPackable ? "Yes" : "No";
- var version = string.IsNullOrWhiteSpace(project.OldVersion) && string.IsNullOrWhiteSpace(project.NewVersion)
- ? string.Empty
- : $"{project.OldVersion ?? "?"} -> {project.NewVersion ?? "?"}";
- var packages = project.Packages.Count.ToString();
- var status = string.IsNullOrWhiteSpace(project.ErrorMessage)
- ? (project.IsPackable ? "[green]Ok[/]" : "[grey]Skipped[/]")
- : "[red]Fail[/]";
- var error = TrimForTable(project.ErrorMessage);
-
- table.AddRow(Esc(project.ProjectName), packable, Esc(version), packages, status, Esc(error));
+ table.AddRow(
+ Esc(project.ProjectName),
+ project.Packable,
+ Esc(project.VersionDisplay),
+ project.PackageCount,
+ $"[{ColorTag(project.StatusColor)}]{Esc(project.StatusText)}[/]",
+ Esc(project.ErrorPreview));
}
AnsiConsole.Write(table);
@@ -245,34 +253,10 @@ private static void WriteRepositorySummary(DotNetRepositoryReleaseResult result,
.AddColumn(new TableColumn("Item").NoWrap())
.AddColumn(new TableColumn("Value"));
- var totalProjects = result.Projects.Count;
- var packableProjects = result.Projects.Count(p => p.IsPackable);
- var failedProjects = result.Projects.Count(p => !string.IsNullOrWhiteSpace(p.ErrorMessage));
- var totalPackages = result.Projects.Sum(p => p.Packages.Count);
-
- totals.AddRow("Projects", totalProjects.ToString());
- totals.AddRow("Packable", packableProjects.ToString());
- totals.AddRow("Failed", failedProjects.ToString());
- totals.AddRow("Packages", totalPackages.ToString());
- if (result.PublishedPackages.Count > 0)
- totals.AddRow("Published", result.PublishedPackages.Count.ToString());
- if (result.SkippedDuplicatePackages.Count > 0)
- totals.AddRow("Skipped duplicates", result.SkippedDuplicatePackages.Count.ToString());
- if (result.FailedPackages.Count > 0)
- totals.AddRow("Failed publishes", result.FailedPackages.Count.ToString());
- if (!string.IsNullOrWhiteSpace(result.ResolvedVersion))
- totals.AddRow("Resolved version", Esc(result.ResolvedVersion));
+ foreach (var row in display.Totals)
+ totals.AddRow(row.Label, Esc(row.Value));
AnsiConsole.Write(totals);
}
- private static string TrimForTable(string? value)
- {
- if (string.IsNullOrWhiteSpace(value)) return string.Empty;
- var trimmed = value!.Trim();
- const int max = 140;
- if (trimmed.Length <= max) return trimmed;
- return trimmed.Substring(0, max - 3) + "...";
- }
-
}
diff --git a/PSPublishModule/Cmdlets/InvokeModuleBuildCommand.cs b/PSPublishModule/Cmdlets/InvokeModuleBuildCommand.cs
index 6d131a5f..f4883627 100644
--- a/PSPublishModule/Cmdlets/InvokeModuleBuildCommand.cs
+++ b/PSPublishModule/Cmdlets/InvokeModuleBuildCommand.cs
@@ -398,8 +398,8 @@ protected override void ProcessRecord()
#pragma warning disable CA1031 // Legacy cmdlet UX: capture and report errors consistently
BufferedLogger? interactiveBuffer = null;
ModuleBuildWorkflowResult? workflow = null;
- var success = false;
var logSupport = new BufferedLogSupportService();
+ ModuleBuildCompletionOutcome? outcome = null;
if (Legacy.IsPresent && Settings is null && ParameterSetName != ParameterSetConfiguration)
logger.Warn("Legacy PowerShell build pipeline has been removed; using PowerForge pipeline.");
@@ -408,8 +408,8 @@ protected override void ProcessRecord()
{
var jsonFullPath = preparation.JsonOutputPath!;
new ModuleBuildPreparationService().WritePipelineSpecJson(preparation.PipelineSpec, jsonFullPath);
+ workflow = new ModuleBuildWorkflowResult { Succeeded = true };
logger.Success($"Wrote pipeline JSON: {jsonFullPath}");
- success = true;
}
else
{
@@ -425,42 +425,37 @@ protected override void ProcessRecord()
configLabel: configLabel),
writeSummary: SpectrePipelineConsoleUi.WriteSummary)
.Execute(preparation, interactive, useLegacy ? "dsl" : "cmdlet");
-
- success = workflow.Succeeded;
- if (!success)
- {
- var ex = workflow.Error!;
- var emitErrorRecord = !exitCodeMode && !workflow.UsedInteractiveView && workflow.PolicyFailure is null;
- if (emitErrorRecord)
- WriteError(new ErrorRecord(ex, useLegacy ? "InvokeModuleBuildDslFailed" : "InvokeModuleBuildPowerForgeFailed", ErrorCategory.NotSpecified, null));
- if (interactiveBuffer is not null && interactiveBuffer.Entries.Count > 0)
- logSupport.WriteTail(interactiveBuffer.Entries, logger);
- if (workflow.UsedInteractiveView && workflow.Plan is not null && !workflow.WrotePolicySummary)
- {
- try { SpectrePipelineConsoleUi.WriteFailureSummary(workflow.Plan, ex); }
- catch { /* best effort */ }
- }
- }
}
#pragma warning restore CA1031
- var elapsed = sw.Elapsed;
- var elapsedText = logSupport.FormatDuration(elapsed);
+ outcome = new ModuleBuildOutcomeService().Evaluate(
+ workflow,
+ exitCodeMode,
+ JsonOnly.IsPresent,
+ useLegacy,
+ sw.Elapsed);
- if (success)
+ if (!outcome.Succeeded)
{
- logger.Success(JsonOnly.IsPresent
- ? $"Pipeline config generated in {elapsedText}"
- : $"Module build completed in {elapsedText}");
- if (exitCodeMode) Host.SetShouldExit(0);
+ var ex = workflow?.Error!;
+ if (outcome.ShouldEmitErrorRecord && ex is not null)
+ WriteError(new ErrorRecord(ex, outcome.ErrorRecordId, ErrorCategory.NotSpecified, null));
+ if (outcome.ShouldReplayBufferedLogs && interactiveBuffer is not null && interactiveBuffer.Entries.Count > 0)
+ logSupport.WriteTail(interactiveBuffer.Entries, logger);
+ if (outcome.ShouldWriteInteractiveFailureSummary && workflow?.Plan is not null && ex is not null)
+ {
+ try { SpectrePipelineConsoleUi.WriteFailureSummary(workflow.Plan, ex); }
+ catch { /* best effort */ }
+ }
}
+
+ if (outcome.Succeeded)
+ logger.Success(outcome.CompletionMessage);
else
- {
- logger.Error(JsonOnly.IsPresent
- ? $"Pipeline config generation failed in {elapsedText}"
- : $"Module build failed in {elapsedText}");
- if (exitCodeMode) Host.SetShouldExit(1);
- }
+ logger.Error(outcome.CompletionMessage);
+
+ if (outcome.ShouldSetExitCode)
+ Host.SetShouldExit(outcome.ExitCode);
return;
}
diff --git a/PSPublishModule/Cmdlets/InvokeModuleTestSuiteCommand.cs b/PSPublishModule/Cmdlets/InvokeModuleTestSuiteCommand.cs
index 535cd247..56b9fe4f 100644
--- a/PSPublishModule/Cmdlets/InvokeModuleTestSuiteCommand.cs
+++ b/PSPublishModule/Cmdlets/InvokeModuleTestSuiteCommand.cs
@@ -1,7 +1,6 @@
using System;
using System.Collections;
-using System.IO;
-using System.Linq;
+using System.Collections.Generic;
using System.Management.Automation;
using PowerForge;
@@ -130,9 +129,10 @@ protected override void ProcessRecord()
{
try
{
+ var display = new ModuleTestSuiteDisplayService();
var preparation = new ModuleTestSuitePreparationService().Prepare(new ModuleTestSuitePreparationRequest
{
- CurrentPath = SessionState?.Path?.CurrentFileSystemLocation?.Path ?? Directory.GetCurrentDirectory(),
+ CurrentPath = SessionState?.Path?.CurrentFileSystemLocation?.Path ?? Environment.CurrentDirectory,
ProjectPath = ProjectPath,
AdditionalModules = AdditionalModules,
SkipModules = SkipModules,
@@ -149,25 +149,16 @@ protected override void ProcessRecord()
});
var projectRoot = preparation.ProjectRoot;
- HostWriteLineSafe(CICD.IsPresent ? "=== CI/CD Module Testing Pipeline ===" : "=== PowerShell Module Test Suite ===", ConsoleColor.Magenta);
- HostWriteLineSafe($"Project Path: {projectRoot}", ConsoleColor.Cyan);
-
var psVersionTable = SessionState?.PSVariable?.GetValue("PSVersionTable") as Hashtable;
var psVersion = psVersionTable?["PSVersion"]?.ToString() ?? string.Empty;
var psEdition = psVersionTable?["PSEdition"]?.ToString() ?? string.Empty;
- HostWriteLineSafe($"PowerShell Version: {psVersion}", ConsoleColor.Cyan);
- HostWriteLineSafe($"PowerShell Edition: {psEdition}", ConsoleColor.Cyan);
- HostWriteLineSafe(string.Empty);
+ WriteDisplayLines(display.CreateHeader(projectRoot, psVersion, psEdition, CICD.IsPresent));
- HostWriteLineSafe("Step 1: Gathering module information...", ConsoleColor.Yellow);
+ WriteDisplayLines(display.CreateModuleInfoHeader());
var moduleInfo = new ModuleInformationReader().Read(projectRoot);
- HostWriteLineSafe($" Module Name: {moduleInfo.ModuleName}", ConsoleColor.Green);
- HostWriteLineSafe($" Module Version: {moduleInfo.ModuleVersion ?? string.Empty}", ConsoleColor.Green);
- HostWriteLineSafe($" Manifest Path: {moduleInfo.ManifestPath}", ConsoleColor.Green);
- HostWriteLineSafe($" Required Modules: {(moduleInfo.RequiredModules ?? Array.Empty()).Length}", ConsoleColor.Green);
- HostWriteLineSafe(string.Empty);
+ WriteDisplayLines(display.CreateModuleInfoDetails(moduleInfo));
- HostWriteLineSafe("Step 2: Executing test suite (out-of-process)...", ConsoleColor.Yellow);
+ WriteDisplayLines(display.CreateExecutionHeader());
var logger = new CmdletLogger(this, MyInvocation.BoundParameters.ContainsKey("Verbose"), warningsAsVerbose: true);
var workflow = new ModuleTestSuiteWorkflowService(logger).Execute(preparation);
var result = workflow.Result;
@@ -179,32 +170,20 @@ protected override void ProcessRecord()
HostWriteLineSafe(string.Empty);
}
- HostWriteLineSafe("Step 3: Dependency summary...", ConsoleColor.Yellow);
- WriteDependencySummary(result.RequiredModules);
- WriteAdditionalModulesSummary();
- HostWriteLineSafe(string.Empty);
+ WriteDisplayLines(display.CreateDependencySummary(result.RequiredModules, AdditionalModules, SkipModules));
if (!SkipDependencies.IsPresent)
- {
- HostWriteLineSafe("Step 4: Dependency installation results...", ConsoleColor.Yellow);
- WriteDependencyInstallResults(result.DependencyResults);
- HostWriteLineSafe(string.Empty);
- }
+ WriteDisplayLines(display.CreateDependencyInstallResults(result.DependencyResults));
- var successColor = result.FailedCount > 0 ? ConsoleColor.Red : ConsoleColor.Green;
- HostWriteLineSafe(result.FailedCount > 0 ? "=== Test Suite Failed ===" : "=== Test Suite Completed Successfully ===", successColor);
- HostWriteLineSafe($"Module: {result.ModuleName} v{result.ModuleVersion ?? string.Empty}", ConsoleColor.Green);
- HostWriteLineSafe($"Tests: {result.PassedCount}/{result.TotalCount} passed", result.FailedCount > 0 ? ConsoleColor.Yellow : ConsoleColor.Green);
- if (result.Duration.HasValue)
- HostWriteLineSafe($"Duration: {result.Duration.Value}", ConsoleColor.Green);
- HostWriteLineSafe(string.Empty);
+ WriteDisplayLines(display.CreateCompletionSummary(result));
if (result.FailedCount > 0)
{
if (ShowFailureSummary.IsPresent || CICD.IsPresent)
{
- HostWriteLineSafe("=== Test Failure Analysis ===", ConsoleColor.Yellow);
- WriteFailureSummary(result.FailureAnalysis, FailureSummaryFormat);
+ WriteDisplayLines(display.CreateFailureSummary(
+ result.FailureAnalysis,
+ detailed: FailureSummaryFormat == ModuleTestSuiteFailureSummaryFormat.Detailed));
HostWriteLineSafe(string.Empty);
}
@@ -233,127 +212,6 @@ protected override void ProcessRecord()
}
}
- private void WriteDependencySummary(RequiredModuleReference[] requiredModules)
- {
- if (requiredModules.Length == 0)
- {
- HostWriteLineSafe(" No required modules specified in manifest", ConsoleColor.Gray);
- return;
- }
-
- HostWriteLineSafe("Required modules:", ConsoleColor.Cyan);
- foreach (var m in requiredModules)
- {
- var name = m.ModuleName;
- var min = m.ModuleVersion;
- var req = m.RequiredVersion;
- var max = m.MaximumVersion;
-
- var versionInfo = string.Empty;
- if (!string.IsNullOrWhiteSpace(min)) versionInfo += $" (Min: {min})";
- if (!string.IsNullOrWhiteSpace(req)) versionInfo += $" (Required: {req})";
- if (!string.IsNullOrWhiteSpace(max)) versionInfo += $" (Max: {max})";
-
- HostWriteLineSafe($" 📦 {name}{versionInfo}", ConsoleColor.Green);
- }
- }
-
- private void WriteAdditionalModulesSummary()
- {
- if (AdditionalModules.Length == 0)
- return;
-
- HostWriteLineSafe("Additional modules:", ConsoleColor.Cyan);
- foreach (var m in AdditionalModules)
- {
- if (SkipModules.Contains(m, StringComparer.OrdinalIgnoreCase))
- continue;
-
- HostWriteLineSafe($" ✅ {m}", ConsoleColor.Green);
- }
- }
-
- private void WriteDependencyInstallResults(ModuleDependencyInstallResult[] results)
- {
- if (results.Length == 0)
- {
- HostWriteLineSafe(" (no dependency install actions)", ConsoleColor.Gray);
- return;
- }
-
- foreach (var r in results)
- {
- switch (r.Status)
- {
- case ModuleDependencyInstallStatus.Skipped:
- HostWriteLineSafe($" ⏭️ Skipping: {r.Name}", ConsoleColor.Gray);
- break;
- case ModuleDependencyInstallStatus.Satisfied:
- HostWriteLineSafe($" ✅ {r.Name} OK (installed: {r.InstalledVersion ?? "unknown"})", ConsoleColor.Green);
- break;
- case ModuleDependencyInstallStatus.Installed:
- case ModuleDependencyInstallStatus.Updated:
- {
- var icon = r.Status == ModuleDependencyInstallStatus.Updated ? "🔄" : "📥";
- HostWriteLineSafe($" {icon} {r.Name} {r.Status} via {r.Installer ?? "installer"} (resolved: {r.ResolvedVersion ?? "unknown"})", ConsoleColor.Green);
- break;
- }
- case ModuleDependencyInstallStatus.Failed:
- HostWriteLineSafe($" ❌ {r.Name}: {r.Message}", ConsoleColor.Red);
- break;
- }
- }
- }
-
- private void WriteFailureSummary(ModuleTestFailureAnalysis? analysis, ModuleTestSuiteFailureSummaryFormat format)
- {
- if (analysis is null)
- {
- HostWriteLineSafe("No failure analysis available.", ConsoleColor.Yellow);
- return;
- }
-
- if (analysis.TotalCount == 0)
- {
- HostWriteLineSafe("No test results found", ConsoleColor.Yellow);
- return;
- }
-
- var color = analysis.FailedCount == 0 ? ConsoleColor.Green : ConsoleColor.Yellow;
- HostWriteLineSafe($"Summary: {analysis.PassedCount}/{analysis.TotalCount} tests passed", color);
- HostWriteLineSafe(string.Empty);
-
- if (analysis.FailedCount == 0)
- {
- HostWriteLineSafe("All tests passed successfully!", ConsoleColor.Green);
- return;
- }
-
- HostWriteLineSafe($"Failed Tests ({analysis.FailedCount}):", ConsoleColor.Red);
- HostWriteLineSafe(string.Empty);
-
- foreach (var f in analysis.FailedTests)
- {
- HostWriteLineSafe($"- {f.Name}", ConsoleColor.Red);
- if (format == ModuleTestSuiteFailureSummaryFormat.Detailed &&
- !string.IsNullOrWhiteSpace(f.ErrorMessage) &&
- !string.Equals(f.ErrorMessage, "No error message available", StringComparison.Ordinal))
- {
- foreach (var line in f.ErrorMessage.Split(new[] { '\n' }, StringSplitOptions.None))
- {
- var trimmed = line.Trim();
- if (trimmed.Length > 0)
- HostWriteLineSafe($" {trimmed}", ConsoleColor.Yellow);
- }
- }
-
- if (format == ModuleTestSuiteFailureSummaryFormat.Detailed && f.Duration.HasValue)
- HostWriteLineSafe($" Duration: {f.Duration.Value}", ConsoleColor.DarkGray);
-
- HostWriteLineSafe(string.Empty);
- }
- }
-
private static PowerForge.ModuleTestSuiteOutputFormat MapOutputFormat(ModuleTestSuiteOutputFormat format)
{
return format switch
@@ -365,6 +223,12 @@ private static PowerForge.ModuleTestSuiteOutputFormat MapOutputFormat(ModuleTest
};
}
+ private void WriteDisplayLines(IReadOnlyList lines)
+ {
+ foreach (var line in lines)
+ HostWriteLineSafe(line.Text, line.Color);
+ }
+
private void HostWriteLineSafe(string text, ConsoleColor? fg = null)
{
try
diff --git a/PSPublishModule/Cmdlets/InvokeProjectBuildCommand.Helpers.cs b/PSPublishModule/Cmdlets/InvokeProjectBuildCommand.Helpers.cs
index 55e2d95b..1cd99a1e 100644
--- a/PSPublishModule/Cmdlets/InvokeProjectBuildCommand.Helpers.cs
+++ b/PSPublishModule/Cmdlets/InvokeProjectBuildCommand.Helpers.cs
@@ -43,7 +43,18 @@ private static void WriteGitHubSummary(
{
var unicode = AnsiConsole.Profile.Capabilities.Unicode;
var border = unicode ? TableBorder.Rounded : TableBorder.Simple;
- var title = unicode ? "✅ GitHub Summary" : "GitHub Summary";
+ var summary = new ProjectBuildGitHubPublishSummary
+ {
+ PerProject = perProject,
+ SummaryTag = tag,
+ SummaryReleaseUrl = releaseUrl,
+ SummaryAssetsCount = assetsCount
+ };
+ foreach (var result in results)
+ summary.Results.Add(result);
+
+ var display = new ProjectBuildGitHubDisplayService().CreateSummary(summary);
+ var title = unicode ? $"✅ {display.Title}" : display.Title;
AnsiConsole.Write(new Rule($"[green]{title}[/]").LeftJustified());
var table = new Table()
@@ -51,23 +62,8 @@ private static void WriteGitHubSummary(
.AddColumn(new TableColumn("Item").NoWrap())
.AddColumn(new TableColumn("Value"));
- if (!perProject)
- {
- table.AddRow("Mode", "Single");
- table.AddRow("Tag", Markup.Escape(tag ?? string.Empty));
- table.AddRow("Assets", assetsCount.ToString());
- if (!string.IsNullOrWhiteSpace(releaseUrl))
- table.AddRow("Release", Markup.Escape(releaseUrl!));
- }
- else
- {
- var ok = results.Count(result => result.Success);
- var fail = results.Count(result => !result.Success);
- table.AddRow("Mode", "PerProject");
- table.AddRow("Projects", results.Count.ToString());
- table.AddRow("Succeeded", ok.ToString());
- table.AddRow("Failed", fail.ToString());
- }
+ foreach (var row in display.Rows)
+ table.AddRow(row.Label, Markup.Escape(row.Value));
AnsiConsole.Write(table);
}
diff --git a/PSPublishModule/Cmdlets/NewDotNetPublishConfigCommand.cs b/PSPublishModule/Cmdlets/NewDotNetPublishConfigCommand.cs
index 31de6a15..a78bdc74 100644
--- a/PSPublishModule/Cmdlets/NewDotNetPublishConfigCommand.cs
+++ b/PSPublishModule/Cmdlets/NewDotNetPublishConfigCommand.cs
@@ -1,6 +1,5 @@
using System;
using System.IO;
-using System.Linq;
using System.Management.Automation;
using PowerForge;
@@ -102,9 +101,22 @@ public sealed class NewDotNetPublishConfigCommand : PSCmdlet
///
protected override void ProcessRecord()
{
- var cwd = SessionState?.Path?.CurrentFileSystemLocation?.Path ?? Environment.CurrentDirectory;
- var resolvedRoot = ResolvePath(cwd, ProjectRoot);
- var resolvedOutputPath = ResolvePath(resolvedRoot, OutputPath);
+ var request = new DotNetPublishConfigScaffoldRequest
+ {
+ ProjectRoot = ProjectRoot,
+ ProjectPath = ProjectPath,
+ TargetName = TargetName,
+ Framework = Framework,
+ Runtimes = Runtimes,
+ Styles = Styles,
+ Configuration = Configuration,
+ OutputPath = OutputPath,
+ Force = Force.IsPresent,
+ IncludeSchema = !NoSchema.IsPresent,
+ WorkingDirectory = SessionState?.Path?.CurrentFileSystemLocation?.Path ?? Environment.CurrentDirectory
+ };
+ var service = new DotNetPublishConfigScaffoldService();
+ var resolvedOutputPath = service.ResolveOutputPath(request);
var action = File.Exists(resolvedOutputPath)
? "Overwrite DotNet publish configuration"
@@ -117,20 +129,7 @@ protected override void ProcessRecord()
try
{
- var scaffolder = new DotNetPublishConfigScaffolder(logger);
- var result = scaffolder.Generate(new DotNetPublishConfigScaffoldOptions
- {
- ProjectRoot = resolvedRoot,
- ProjectPath = NormalizeNullable(ProjectPath),
- TargetName = NormalizeNullable(TargetName),
- Framework = NormalizeNullable(Framework),
- Runtimes = NormalizeStrings(Runtimes),
- Styles = NormalizeStyles(Styles),
- Configuration = string.IsNullOrWhiteSpace(Configuration) ? "Release" : Configuration.Trim(),
- OutputPath = resolvedOutputPath,
- Overwrite = Force.IsPresent,
- IncludeSchema = !NoSchema.IsPresent
- });
+ var result = service.Generate(request, logger);
if (PassThru.IsPresent)
WriteObject(result);
@@ -143,44 +142,4 @@ protected override void ProcessRecord()
ThrowTerminatingError(record);
}
}
-
- private static string ResolvePath(string basePath, string value)
- {
- var raw = (value ?? string.Empty).Trim().Trim('"');
- if (raw.Length == 0)
- return Path.GetFullPath(basePath);
-
- return Path.IsPathRooted(raw)
- ? Path.GetFullPath(raw)
- : Path.GetFullPath(Path.Combine(basePath, raw));
- }
-
- private static string? NormalizeNullable(string? value)
- {
- var normalized = (value ?? string.Empty).Trim();
- if (normalized.Length == 0)
- return null;
-
- return normalized;
- }
-
- private static string[]? NormalizeStrings(string[]? values)
- {
- if (values is null || values.Length == 0) return null;
- var normalized = values
- .Where(v => !string.IsNullOrWhiteSpace(v))
- .Select(v => v!.Trim())
- .Distinct(StringComparer.OrdinalIgnoreCase)
- .ToArray();
- return normalized.Length == 0 ? null : normalized;
- }
-
- private static DotNetPublishStyle[]? NormalizeStyles(DotNetPublishStyle[]? values)
- {
- if (values is null || values.Length == 0) return null;
- var normalized = values
- .Distinct()
- .ToArray();
- return normalized.Length == 0 ? null : normalized;
- }
}
diff --git a/PSPublishModule/Cmdlets/NewModuleAboutTopicCommand.cs b/PSPublishModule/Cmdlets/NewModuleAboutTopicCommand.cs
index a9a9055f..b81fe991 100644
--- a/PSPublishModule/Cmdlets/NewModuleAboutTopicCommand.cs
+++ b/PSPublishModule/Cmdlets/NewModuleAboutTopicCommand.cs
@@ -1,5 +1,4 @@
using System;
-using System.IO;
using System.Management.Automation;
using PowerForge;
@@ -72,45 +71,35 @@ public sealed class NewModuleAboutTopicCommand : PSCmdlet
///
protected override void ProcessRecord()
{
- var root = SessionState?.Path?.CurrentFileSystemLocation?.Path ?? Environment.CurrentDirectory;
- var outputDirectory = ResolveOutputDirectory(root, OutputPath);
- var normalizedTopic = AboutTopicTemplateGenerator.NormalizeTopicName(TopicName);
- var extension = Format == AboutTopicTemplateFormat.Markdown ? ".md" : ".help.txt";
- var filePath = Path.Combine(outputDirectory, normalizedTopic + extension);
+ var request = new AboutTopicTemplateRequest
+ {
+ TopicName = TopicName,
+ OutputPath = OutputPath,
+ ShortDescription = ShortDescription,
+ Format = Format,
+ Force = Force.IsPresent,
+ WorkingDirectory = SessionState?.Path?.CurrentFileSystemLocation?.Path ?? Environment.CurrentDirectory
+ };
+ var service = new AboutTopicTemplateService();
+ var preview = service.Preview(request);
- var action = File.Exists(filePath) ? "Overwrite about topic template" : "Create about topic template";
- if (!ShouldProcess(filePath, action))
+ var action = preview.Exists ? "Overwrite about topic template" : "Create about topic template";
+ if (!ShouldProcess(preview.FilePath, action))
return;
try
{
- var created = AboutTopicTemplateGenerator.WriteTemplateFile(
- outputDirectory: outputDirectory,
- topicName: normalizedTopic,
- force: Force.IsPresent,
- shortDescription: ShortDescription,
- format: Format);
+ var created = service.Generate(request);
if (PassThru.IsPresent)
- WriteObject(created);
+ WriteObject(created.FilePath);
else
- WriteVerbose($"Created about topic template: {created}");
+ WriteVerbose($"Created about topic template: {created.FilePath}");
}
catch (Exception ex)
{
- var record = new ErrorRecord(ex, "NewModuleAboutTopicFailed", ErrorCategory.WriteError, filePath);
+ var record = new ErrorRecord(ex, "NewModuleAboutTopicFailed", ErrorCategory.WriteError, preview.FilePath);
ThrowTerminatingError(record);
}
}
-
- private static string ResolveOutputDirectory(string currentDirectory, string outputPath)
- {
- var trimmed = (outputPath ?? string.Empty).Trim();
- if (string.IsNullOrWhiteSpace(trimmed))
- return Path.GetFullPath(currentDirectory);
-
- return Path.IsPathRooted(trimmed)
- ? Path.GetFullPath(trimmed)
- : Path.GetFullPath(Path.Combine(currentDirectory, trimmed));
- }
}
diff --git a/PSPublishModule/Cmdlets/RegisterModuleRepositoryCommand.cs b/PSPublishModule/Cmdlets/RegisterModuleRepositoryCommand.cs
index c66cb2c6..5288e74f 100644
--- a/PSPublishModule/Cmdlets/RegisterModuleRepositoryCommand.cs
+++ b/PSPublishModule/Cmdlets/RegisterModuleRepositoryCommand.cs
@@ -100,18 +100,19 @@ public sealed class RegisterModuleRepositoryCommand : PSCmdlet
/// Executes the repository registration.
protected override void ProcessRecord()
{
- PrivateGalleryCommandSupport.EnsureProviderSupported(Provider);
+ var host = new CmdletPrivateGalleryHost(this);
+ var service = new PrivateGalleryService(host);
+ service.EnsureProviderSupported(Provider);
var endpoint = AzureArtifactsRepositoryEndpoints.Create(
AzureDevOpsOrganization,
AzureDevOpsProject,
AzureArtifactsFeed,
Name);
- var prerequisiteInstall = PrivateGalleryCommandSupport.EnsureBootstrapPrerequisites(this, InstallPrerequisites.IsPresent);
- var allowInteractivePrompt = !PrivateGalleryCommandSupport.IsWhatIfRequested(this);
+ var prerequisiteInstall = service.EnsureBootstrapPrerequisites(InstallPrerequisites.IsPresent);
+ var allowInteractivePrompt = !host.IsWhatIfRequested;
- var credentialResolution = PrivateGalleryCommandSupport.ResolveCredential(
- this,
+ var credentialResolution = service.ResolveCredential(
endpoint.RepositoryName,
BootstrapMode,
CredentialUserName,
@@ -121,8 +122,7 @@ protected override void ProcessRecord()
prerequisiteInstall.Status,
allowInteractivePrompt);
- var result = PrivateGalleryCommandSupport.EnsureAzureArtifactsRepositoryRegistered(
- this,
+ var result = service.EnsureAzureArtifactsRepositoryRegistered(
AzureDevOpsOrganization,
AzureDevOpsProject,
AzureArtifactsFeed,
@@ -141,7 +141,7 @@ protected override void ProcessRecord()
result.InstalledPrerequisites = prerequisiteInstall.InstalledPrerequisites;
result.PrerequisiteInstallMessages = prerequisiteInstall.Messages;
- PrivateGalleryCommandSupport.WriteRegistrationSummary(this, result);
- WriteObject(result);
+ service.WriteRegistrationSummary(result);
+ WriteObject(ModuleRepositoryRegistrationResultMapper.ToCmdletResult(result));
}
}
diff --git a/PSPublishModule/Cmdlets/RemoveProjectFilesCommand.cs b/PSPublishModule/Cmdlets/RemoveProjectFilesCommand.cs
index acfacc6b..4570cff4 100644
--- a/PSPublishModule/Cmdlets/RemoveProjectFilesCommand.cs
+++ b/PSPublishModule/Cmdlets/RemoveProjectFilesCommand.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Management.Automation;
@@ -94,6 +95,7 @@ public sealed class RemoveProjectFilesCommand : PSCmdlet
/// Executes the cleanup.
protected override void ProcessRecord()
{
+ var display = new ProjectCleanupDisplayService();
var root = SessionState.Path.GetUnresolvedProviderPathFromPSPath(ProjectPath);
if (!Directory.Exists(root))
throw new PSArgumentException($"Project path '{ProjectPath}' not found or is not a directory");
@@ -120,14 +122,7 @@ protected override void ProcessRecord()
WhatIf = isWhatIf
};
- if (Internal.IsPresent)
- {
- WriteVerbose($"Processing project cleanup for: {Path.GetFullPath(root)}");
- }
- else
- {
- WriteHostLine($"Processing project cleanup for: {Path.GetFullPath(root)}", ConsoleColor.Cyan);
- }
+ WriteDisplayLines(display.CreateHeader(Path.GetFullPath(root)));
var service = new ProjectCleanupService();
var output = service.Clean(
@@ -137,14 +132,11 @@ protected override void ProcessRecord()
if (output.Summary.TotalItems == 0)
{
- if (Internal.IsPresent)
- WriteVerbose("No files or folders found matching the specified criteria.");
- else
- WriteHostLine("No files or folders found matching the specified criteria.", ConsoleColor.Yellow);
+ WriteDisplayLines(display.CreateNoMatchesLines(Internal.IsPresent));
return;
}
- WriteSummary(output, isWhatIf);
+ WriteDisplayLines(display.CreateSummaryLines(output, isWhatIf, Internal.IsPresent));
if (PassThru.IsPresent)
WriteObject(output, enumerateCollection: false);
@@ -152,85 +144,23 @@ protected override void ProcessRecord()
private void OnItemProcessed(int current, int total, ProjectCleanupItemResult item)
{
- if (Internal.IsPresent)
- {
- switch (item.Status)
- {
- case ProjectCleanupStatus.WhatIf:
- WriteVerbose($"Would remove: {item.RelativePath}");
- break;
- case ProjectCleanupStatus.Removed:
- WriteVerbose($"Removed: {item.RelativePath}");
- break;
- case ProjectCleanupStatus.Failed:
- case ProjectCleanupStatus.Error:
- WriteWarning($"Failed to remove: {item.RelativePath}");
- break;
- }
- return;
- }
-
- switch (item.Status)
- {
- case ProjectCleanupStatus.WhatIf:
- WriteHostLine($" [WOULD REMOVE] {item.RelativePath}", ConsoleColor.Yellow);
- break;
- case ProjectCleanupStatus.Removed:
- WriteHostLine($" [{current}/{total}] [REMOVED] {item.RelativePath}", ConsoleColor.Red);
- break;
- case ProjectCleanupStatus.Failed:
- WriteHostLine($" [{current}/{total}] [FAILED] {item.RelativePath}", ConsoleColor.Red);
- break;
- case ProjectCleanupStatus.Error:
- WriteHostLine($" [{current}/{total}] [ERROR] {item.RelativePath}: {item.Error}", ConsoleColor.Red);
- break;
- }
+ WriteDisplayLines(new ProjectCleanupDisplayService().CreateItemLines(item, current, total, Internal.IsPresent));
}
- private void WriteSummary(ProjectCleanupOutput output, bool isWhatIf)
+ private void WriteDisplayLines(IReadOnlyList lines)
{
- if (Internal.IsPresent)
+ foreach (var line in lines)
{
- WriteVerbose($"Cleanup Summary: Project path: {output.Summary.ProjectPath}");
- WriteVerbose($"Cleanup type: {output.Summary.ProjectType}");
- WriteVerbose($"Total items processed: {output.Summary.TotalItems}");
-
- if (isWhatIf)
- {
- WriteVerbose($"Would remove: {output.Summary.TotalItems} items");
- WriteVerbose($"Would free: {Math.Round(output.Results.Where(r => r.Type == ProjectCleanupItemType.File).Sum(r => r.Size) / (1024d * 1024d), 2)} MB");
- }
- else
+ if (Internal.IsPresent)
{
- WriteVerbose($"Successfully removed: {output.Summary.Removed}");
- WriteVerbose($"Errors: {output.Summary.Errors}");
- WriteVerbose($"Space freed: {output.Summary.SpaceFreedMB} MB");
- if (!string.IsNullOrWhiteSpace(output.Summary.BackupDirectory))
- WriteVerbose($"Backups created in: {output.Summary.BackupDirectory}");
+ if (line.IsWarning)
+ WriteWarning(line.Text);
+ else
+ WriteVerbose(line.Text);
+ continue;
}
- return;
- }
-
- WriteHostLine(string.Empty, ConsoleColor.White);
- WriteHostLine("Cleanup Summary:", ConsoleColor.Cyan);
- WriteHostLine($" Project path: {output.Summary.ProjectPath}", ConsoleColor.White);
- WriteHostLine($" Cleanup type: {output.Summary.ProjectType}", ConsoleColor.White);
- WriteHostLine($" Total items processed: {output.Summary.TotalItems}", ConsoleColor.White);
- if (isWhatIf)
- {
- var totalSizeMb = Math.Round(output.Results.Where(r => r.Type == ProjectCleanupItemType.File).Sum(r => r.Size) / (1024d * 1024d), 2);
- WriteHostLine($" Would remove: {output.Summary.TotalItems} items", ConsoleColor.Yellow);
- WriteHostLine($" Would free: {totalSizeMb} MB", ConsoleColor.Yellow);
- WriteHostLine("Run without -WhatIf to actually remove these items.", ConsoleColor.Cyan);
- }
- else
- {
- WriteHostLine($" Successfully removed: {output.Summary.Removed}", ConsoleColor.Green);
- WriteHostLine($" Errors: {output.Summary.Errors}", ConsoleColor.Red);
- WriteHostLine($" Space freed: {output.Summary.SpaceFreedMB} MB", ConsoleColor.Green);
- if (!string.IsNullOrWhiteSpace(output.Summary.BackupDirectory))
- WriteHostLine($" Backups created in: {output.Summary.BackupDirectory}", ConsoleColor.Blue);
+ WriteHostLine(line.Text, line.Color ?? ConsoleColor.White);
}
}
diff --git a/PSPublishModule/Cmdlets/UpdateModuleRepositoryCommand.cs b/PSPublishModule/Cmdlets/UpdateModuleRepositoryCommand.cs
index 44f74c07..67dd92c7 100644
--- a/PSPublishModule/Cmdlets/UpdateModuleRepositoryCommand.cs
+++ b/PSPublishModule/Cmdlets/UpdateModuleRepositoryCommand.cs
@@ -95,18 +95,19 @@ public sealed class UpdateModuleRepositoryCommand : PSCmdlet
/// Executes the repository refresh.
protected override void ProcessRecord()
{
- PrivateGalleryCommandSupport.EnsureProviderSupported(Provider);
+ var host = new CmdletPrivateGalleryHost(this);
+ var service = new PrivateGalleryService(host);
+ service.EnsureProviderSupported(Provider);
var endpoint = AzureArtifactsRepositoryEndpoints.Create(
AzureDevOpsOrganization,
AzureDevOpsProject,
AzureArtifactsFeed,
Name);
- var prerequisiteInstall = PrivateGalleryCommandSupport.EnsureBootstrapPrerequisites(this, InstallPrerequisites.IsPresent);
- var allowInteractivePrompt = !PrivateGalleryCommandSupport.IsWhatIfRequested(this);
+ var prerequisiteInstall = service.EnsureBootstrapPrerequisites(InstallPrerequisites.IsPresent);
+ var allowInteractivePrompt = !host.IsWhatIfRequested;
- var credentialResolution = PrivateGalleryCommandSupport.ResolveCredential(
- this,
+ var credentialResolution = service.ResolveCredential(
endpoint.RepositoryName,
BootstrapMode,
CredentialUserName,
@@ -116,8 +117,7 @@ protected override void ProcessRecord()
prerequisiteInstall.Status,
allowInteractivePrompt);
- var result = PrivateGalleryCommandSupport.EnsureAzureArtifactsRepositoryRegistered(
- this,
+ var result = service.EnsureAzureArtifactsRepositoryRegistered(
AzureDevOpsOrganization,
AzureDevOpsProject,
AzureArtifactsFeed,
@@ -136,7 +136,7 @@ protected override void ProcessRecord()
result.InstalledPrerequisites = prerequisiteInstall.InstalledPrerequisites;
result.PrerequisiteInstallMessages = prerequisiteInstall.Messages;
- PrivateGalleryCommandSupport.WriteRegistrationSummary(this, result);
- WriteObject(result);
+ service.WriteRegistrationSummary(result);
+ WriteObject(ModuleRepositoryRegistrationResultMapper.ToCmdletResult(result));
}
}
diff --git a/PSPublishModule/Cmdlets/UpdatePrivateModuleCommand.cs b/PSPublishModule/Cmdlets/UpdatePrivateModuleCommand.cs
index a4157b21..ca8f6358 100644
--- a/PSPublishModule/Cmdlets/UpdatePrivateModuleCommand.cs
+++ b/PSPublishModule/Cmdlets/UpdatePrivateModuleCommand.cs
@@ -112,108 +112,35 @@ public sealed class UpdatePrivateModuleCommand : PSCmdlet
/// Executes the update workflow.
protected override void ProcessRecord()
{
- var modules = PrivateGalleryCommandSupport.BuildDependencies(Name);
- var repositoryName = Repository;
- RepositoryCredential? credential;
- var preferPowerShellGet = false;
-
- if (ParameterSetName == ParameterSetAzureArtifacts)
- {
- PrivateGalleryCommandSupport.EnsureProviderSupported(Provider);
-
- var endpoint = AzureArtifactsRepositoryEndpoints.Create(
- AzureDevOpsOrganization,
- AzureDevOpsProject,
- AzureArtifactsFeed,
- RepositoryName);
- var prerequisiteInstall = PrivateGalleryCommandSupport.EnsureBootstrapPrerequisites(this, InstallPrerequisites.IsPresent);
- var allowInteractivePrompt = !PrivateGalleryCommandSupport.IsWhatIfRequested(this);
-
- repositoryName = endpoint.RepositoryName;
- var credentialResolution = PrivateGalleryCommandSupport.ResolveCredential(
- this,
- repositoryName,
- BootstrapMode,
- CredentialUserName,
- CredentialSecret,
- CredentialSecretFilePath,
- PromptForCredential,
- prerequisiteInstall.Status,
- allowInteractivePrompt);
- credential = credentialResolution.Credential;
-
- var registration = PrivateGalleryCommandSupport.EnsureAzureArtifactsRepositoryRegistered(
- this,
- AzureDevOpsOrganization,
- AzureDevOpsProject,
- AzureArtifactsFeed,
- RepositoryName,
- Tool,
- Trusted,
- Priority,
- BootstrapMode,
- credentialResolution.BootstrapModeUsed,
- credentialResolution.CredentialSource,
- credential,
- prerequisiteInstall.Status,
- shouldProcessAction: Tool == RepositoryRegistrationTool.Auto
- ? "Update module repository using Auto (prefer PSResourceGet, fall back to PowerShellGet)"
- : $"Update module repository using {Tool}");
- registration.InstalledPrerequisites = prerequisiteInstall.InstalledPrerequisites;
- registration.PrerequisiteInstallMessages = prerequisiteInstall.Messages;
-
- if (!registration.RegistrationPerformed)
- {
- WriteWarning($"Repository '{registration.RepositoryName}' was not refreshed because the operation was skipped. Module update was not attempted.");
- return;
- }
-
- PrivateGalleryCommandSupport.WriteRegistrationSummary(this, registration);
- WriteVerbose($"Repository '{registration.RepositoryName}' is ready for update.");
-
- if (credential is null &&
- !registration.InstallPSResourceReady &&
- !registration.InstallModuleReady)
+ var host = new CmdletPrivateGalleryHost(this);
+ var logger = new CmdletLogger(this, MyInvocation.BoundParameters.ContainsKey("Verbose"));
+ var result = new PrivateModuleWorkflowService(host, new PrivateGalleryService(host), logger).Execute(
+ new PrivateModuleWorkflowRequest
{
- var hint = string.IsNullOrWhiteSpace(registration.RecommendedBootstrapCommand)
- ? string.Empty
- : $" Recommended next step: {registration.RecommendedBootstrapCommand}";
- throw new InvalidOperationException(
- $"Repository '{registration.RepositoryName}' was registered, but no native update path is ready for bootstrap mode {registration.BootstrapModeUsed}.{hint}");
- }
-
- preferPowerShellGet = credential is null &&
- string.Equals(registration.PreferredInstallCommand, "Install-Module", StringComparison.OrdinalIgnoreCase);
- }
- else
- {
- credential = null;
- }
-
- if (!ShouldProcess($"{modules.Count} module(s) from repository '{repositoryName}'", "Update private modules"))
+ Operation = PrivateModuleWorkflowOperation.Update,
+ ModuleNames = Name,
+ UseAzureArtifacts = ParameterSetName == ParameterSetAzureArtifacts,
+ RepositoryName = ParameterSetName == ParameterSetAzureArtifacts ? (RepositoryName ?? string.Empty) : Repository,
+ Provider = Provider,
+ AzureDevOpsOrganization = AzureDevOpsOrganization,
+ AzureDevOpsProject = AzureDevOpsProject,
+ AzureArtifactsFeed = AzureArtifactsFeed,
+ Tool = Tool,
+ BootstrapMode = BootstrapMode,
+ Trusted = Trusted,
+ Priority = Priority,
+ CredentialUserName = CredentialUserName,
+ CredentialSecret = CredentialSecret,
+ CredentialSecretFilePath = CredentialSecretFilePath,
+ PromptForCredential = PromptForCredential,
+ InstallPrerequisites = InstallPrerequisites,
+ Prerelease = Prerelease
+ },
+ (target, action) => ShouldProcess(target, action));
+
+ if (!result.OperationPerformed)
return;
- if (ParameterSetName == ParameterSetRepository)
- {
- credential = PrivateGalleryCommandSupport.ResolveOptionalCredential(
- this,
- repositoryName,
- CredentialUserName,
- CredentialSecret,
- CredentialSecretFilePath,
- PromptForCredential);
- }
-
- var logger = new CmdletLogger(this, MyInvocation.BoundParameters.ContainsKey("Verbose"));
- var installer = new ModuleDependencyInstaller(new PowerShellRunner(), logger);
- var results = installer.EnsureUpdated(
- modules,
- repository: repositoryName,
- credential: credential,
- prerelease: Prerelease.IsPresent,
- preferPowerShellGet: preferPowerShellGet,
- timeoutPerModule: TimeSpan.FromMinutes(10));
-
- WriteObject(results, enumerateCollection: true);
+ WriteObject(result.DependencyResults, enumerateCollection: true);
}
}
diff --git a/PSPublishModule/Services/CmdletPrivateGalleryHost.cs b/PSPublishModule/Services/CmdletPrivateGalleryHost.cs
new file mode 100644
index 00000000..6f34c444
--- /dev/null
+++ b/PSPublishModule/Services/CmdletPrivateGalleryHost.cs
@@ -0,0 +1,39 @@
+using System;
+using System.Management.Automation;
+using PowerForge;
+
+namespace PSPublishModule;
+
+internal sealed class CmdletPrivateGalleryHost : IPrivateGalleryHost
+{
+ private readonly PSCmdlet _cmdlet;
+
+ public CmdletPrivateGalleryHost(PSCmdlet cmdlet)
+ {
+ _cmdlet = cmdlet ?? throw new ArgumentNullException(nameof(cmdlet));
+ }
+
+ public bool ShouldProcess(string target, string action) => _cmdlet.ShouldProcess(target, action);
+
+ public bool IsWhatIfRequested =>
+ _cmdlet.MyInvocation.BoundParameters.TryGetValue("WhatIf", out var whatIfValue) &&
+ whatIfValue is SwitchParameter switchParameter &&
+ switchParameter.IsPresent;
+
+ public RepositoryCredential? PromptForCredential(string caption, string message)
+ {
+ var promptCredential = _cmdlet.Host.UI.PromptForCredential(caption, message, string.Empty, string.Empty);
+ if (promptCredential is null)
+ return null;
+
+ return new RepositoryCredential
+ {
+ UserName = promptCredential.UserName,
+ Secret = promptCredential.GetNetworkCredential().Password
+ };
+ }
+
+ public void WriteVerbose(string message) => _cmdlet.WriteVerbose(message);
+
+ public void WriteWarning(string message) => _cmdlet.WriteWarning(message);
+}
diff --git a/PSPublishModule/Services/ModuleRepositoryRegistrationResultMapper.cs b/PSPublishModule/Services/ModuleRepositoryRegistrationResultMapper.cs
new file mode 100644
index 00000000..5eaf63e2
--- /dev/null
+++ b/PSPublishModule/Services/ModuleRepositoryRegistrationResultMapper.cs
@@ -0,0 +1,52 @@
+using PowerForge;
+
+namespace PSPublishModule;
+
+internal static class ModuleRepositoryRegistrationResultMapper
+{
+ internal static ModuleRepositoryRegistrationResult ToCmdletResult(PowerForge.ModuleRepositoryRegistrationResult result)
+ {
+ return new ModuleRepositoryRegistrationResult
+ {
+ RepositoryName = result.RepositoryName,
+ Provider = result.Provider,
+ BootstrapModeRequested = result.BootstrapModeRequested,
+ BootstrapModeUsed = result.BootstrapModeUsed,
+ CredentialSource = result.CredentialSource,
+ AzureDevOpsOrganization = result.AzureDevOpsOrganization,
+ AzureDevOpsProject = result.AzureDevOpsProject,
+ AzureArtifactsFeed = result.AzureArtifactsFeed,
+ PowerShellGetSourceUri = result.PowerShellGetSourceUri,
+ PowerShellGetPublishUri = result.PowerShellGetPublishUri,
+ PSResourceGetUri = result.PSResourceGetUri,
+ Tool = result.Tool,
+ ToolRequested = result.ToolRequested,
+ ToolUsed = result.ToolUsed,
+ PowerShellGetCreated = result.PowerShellGetCreated,
+ PSResourceGetCreated = result.PSResourceGetCreated,
+ Trusted = result.Trusted,
+ CredentialUsed = result.CredentialUsed,
+ RegistrationPerformed = result.RegistrationPerformed,
+ PSResourceGetRegistered = result.PSResourceGetRegistered,
+ PowerShellGetRegistered = result.PowerShellGetRegistered,
+ PSResourceGetAvailable = result.PSResourceGetAvailable,
+ PSResourceGetVersion = result.PSResourceGetVersion,
+ PSResourceGetMeetsMinimumVersion = result.PSResourceGetMeetsMinimumVersion,
+ PSResourceGetSupportsExistingSessionBootstrap = result.PSResourceGetSupportsExistingSessionBootstrap,
+ PowerShellGetAvailable = result.PowerShellGetAvailable,
+ PowerShellGetVersion = result.PowerShellGetVersion,
+ AzureArtifactsCredentialProviderDetected = result.AzureArtifactsCredentialProviderDetected,
+ AzureArtifactsCredentialProviderPaths = result.AzureArtifactsCredentialProviderPaths,
+ AzureArtifactsCredentialProviderVersion = result.AzureArtifactsCredentialProviderVersion,
+ ReadinessMessages = result.ReadinessMessages,
+ InstalledPrerequisites = result.InstalledPrerequisites,
+ PrerequisiteInstallMessages = result.PrerequisiteInstallMessages,
+ UnavailableTools = result.UnavailableTools,
+ Messages = result.Messages,
+ AccessProbePerformed = result.AccessProbePerformed,
+ AccessProbeSucceeded = result.AccessProbeSucceeded,
+ AccessProbeTool = result.AccessProbeTool,
+ AccessProbeMessage = result.AccessProbeMessage
+ };
+ }
+}
diff --git a/PSPublishModule/Services/PrivateGalleryCommandSupport.cs b/PSPublishModule/Services/PrivateGalleryCommandSupport.cs
deleted file mode 100644
index 0566df72..00000000
--- a/PSPublishModule/Services/PrivateGalleryCommandSupport.cs
+++ /dev/null
@@ -1,755 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using System.Management.Automation;
-using PowerForge;
-
-namespace PSPublishModule;
-
-internal static partial class PrivateGalleryCommandSupport
-{
- private const string MinimumPSResourceGetVersion = "1.1.1";
- private const string MinimumPSResourceGetExistingSessionVersion = "1.2.0-preview5";
- internal const string ReservedPowerShellGalleryRepositoryName = "PSGallery";
-
- internal readonly struct CredentialResolutionResult
- {
- internal CredentialResolutionResult(
- RepositoryCredential? Credential,
- PrivateGalleryBootstrapMode BootstrapModeUsed,
- PrivateGalleryCredentialSource CredentialSource)
- {
- this.Credential = Credential;
- this.BootstrapModeUsed = BootstrapModeUsed;
- this.CredentialSource = CredentialSource;
- }
-
- internal RepositoryCredential? Credential { get; }
- internal PrivateGalleryBootstrapMode BootstrapModeUsed { get; }
- internal PrivateGalleryCredentialSource CredentialSource { get; }
- }
-
- internal readonly struct BootstrapPrerequisiteStatus
- {
- internal BootstrapPrerequisiteStatus(
- bool PSResourceGetAvailable,
- string? PSResourceGetVersion,
- bool PSResourceGetMeetsMinimumVersion,
- bool PSResourceGetSupportsExistingSessionBootstrap,
- string? PSResourceGetMessage,
- bool PowerShellGetAvailable,
- string? PowerShellGetVersion,
- string? PowerShellGetMessage,
- AzureArtifactsCredentialProviderDetectionResult CredentialProviderDetection,
- string[] ReadinessMessages)
- {
- this.PSResourceGetAvailable = PSResourceGetAvailable;
- this.PSResourceGetVersion = PSResourceGetVersion;
- this.PSResourceGetMeetsMinimumVersion = PSResourceGetMeetsMinimumVersion;
- this.PSResourceGetSupportsExistingSessionBootstrap = PSResourceGetSupportsExistingSessionBootstrap;
- this.PSResourceGetMessage = PSResourceGetMessage;
- this.PowerShellGetAvailable = PowerShellGetAvailable;
- this.PowerShellGetVersion = PowerShellGetVersion;
- this.PowerShellGetMessage = PowerShellGetMessage;
- this.CredentialProviderDetection = CredentialProviderDetection;
- this.ReadinessMessages = ReadinessMessages;
- }
-
- internal bool PSResourceGetAvailable { get; }
- internal string? PSResourceGetVersion { get; }
- internal bool PSResourceGetMeetsMinimumVersion { get; }
- internal bool PSResourceGetSupportsExistingSessionBootstrap { get; }
- internal string? PSResourceGetMessage { get; }
- internal bool PowerShellGetAvailable { get; }
- internal string? PowerShellGetVersion { get; }
- internal string? PowerShellGetMessage { get; }
- internal AzureArtifactsCredentialProviderDetectionResult CredentialProviderDetection { get; }
- internal string[] ReadinessMessages { get; }
- }
-
- internal readonly struct BootstrapPrerequisiteInstallResult
- {
- internal BootstrapPrerequisiteInstallResult(
- string[] InstalledPrerequisites,
- string[] Messages,
- BootstrapPrerequisiteStatus Status)
- {
- this.InstalledPrerequisites = InstalledPrerequisites;
- this.Messages = Messages;
- this.Status = Status;
- }
-
- internal string[] InstalledPrerequisites { get; }
- internal string[] Messages { get; }
- internal BootstrapPrerequisiteStatus Status { get; }
- }
-
- internal readonly struct RepositoryAccessProbeResult
- {
- internal RepositoryAccessProbeResult(bool Succeeded, string Tool, string? Message)
- {
- this.Succeeded = Succeeded;
- this.Tool = Tool;
- this.Message = Message;
- }
-
- internal bool Succeeded { get; }
- internal string Tool { get; }
- internal string? Message { get; }
- }
-
- internal static void EnsureProviderSupported(PrivateGalleryProvider provider)
- {
- if (provider != PrivateGalleryProvider.AzureArtifacts)
- throw new PSArgumentException($"Provider '{provider}' is not supported yet. Supported value: AzureArtifacts.");
- }
-
- internal static bool IsWhatIfRequested(PSCmdlet cmdlet)
- {
- return cmdlet.MyInvocation.BoundParameters.TryGetValue("WhatIf", out var whatIfValue) &&
- whatIfValue is SwitchParameter switchParameter &&
- switchParameter.IsPresent;
- }
-
- internal static CredentialResolutionResult ResolveCredential(
- PSCmdlet cmdlet,
- string repositoryName,
- PrivateGalleryBootstrapMode bootstrapMode,
- string? credentialUserName,
- string? credentialSecret,
- string? credentialSecretFilePath,
- SwitchParameter promptForCredential,
- BootstrapPrerequisiteStatus? prerequisiteStatus = null,
- bool allowInteractivePrompt = true)
- {
- var hasCredentialSecretFile = !string.IsNullOrWhiteSpace(credentialSecretFilePath);
- var hasCredentialSecret = !string.IsNullOrWhiteSpace(credentialSecret);
- var hasCredentialUser = !string.IsNullOrWhiteSpace(credentialUserName);
- var hasExplicitCredential = hasCredentialUser && (hasCredentialSecretFile || hasCredentialSecret);
-
- var resolvedSecret = string.Empty;
- if (hasCredentialSecretFile)
- {
- resolvedSecret = File.ReadAllText(credentialSecretFilePath!).Trim();
- }
- else if (hasCredentialSecret)
- {
- resolvedSecret = credentialSecret!.Trim();
- }
-
- if (!string.IsNullOrWhiteSpace(resolvedSecret) &&
- !hasCredentialUser)
- {
- throw new PSArgumentException("CredentialUserName is required when CredentialSecret/CredentialSecretFilePath is provided.");
- }
-
- if (promptForCredential.IsPresent)
- {
- if (!string.IsNullOrWhiteSpace(resolvedSecret) || hasCredentialUser)
- throw new PSArgumentException("PromptForCredential cannot be combined with CredentialUserName/CredentialSecret/CredentialSecretFilePath.");
- }
-
- if (bootstrapMode == PrivateGalleryBootstrapMode.ExistingSession &&
- (promptForCredential.IsPresent || hasExplicitCredential))
- {
- throw new PSArgumentException("BootstrapMode ExistingSession cannot be combined with interactive or explicit credential parameters.");
- }
-
- var effectiveMode = bootstrapMode;
- if (bootstrapMode == PrivateGalleryBootstrapMode.Auto)
- {
- var detectedPrerequisites = prerequisiteStatus ?? GetBootstrapPrerequisiteStatus();
- effectiveMode = promptForCredential.IsPresent || hasExplicitCredential
- ? PrivateGalleryBootstrapMode.CredentialPrompt
- : GetRecommendedBootstrapMode(detectedPrerequisites);
-
- if (effectiveMode == PrivateGalleryBootstrapMode.Auto)
- {
- if (!allowInteractivePrompt)
- effectiveMode = PrivateGalleryBootstrapMode.CredentialPrompt;
- else
- throw new InvalidOperationException(BuildBootstrapUnavailableMessage(repositoryName, detectedPrerequisites));
- }
- }
-
- if (effectiveMode == PrivateGalleryBootstrapMode.ExistingSession)
- {
- return new CredentialResolutionResult(
- Credential: null,
- BootstrapModeUsed: PrivateGalleryBootstrapMode.ExistingSession,
- CredentialSource: PrivateGalleryCredentialSource.None);
- }
-
- if (hasExplicitCredential)
- {
- return new CredentialResolutionResult(
- Credential: new RepositoryCredential
- {
- UserName = credentialUserName!.Trim(),
- Secret = resolvedSecret
- },
- BootstrapModeUsed: PrivateGalleryBootstrapMode.CredentialPrompt,
- CredentialSource: PrivateGalleryCredentialSource.Supplied);
- }
-
- if (!allowInteractivePrompt)
- {
- return new CredentialResolutionResult(
- Credential: null,
- BootstrapModeUsed: PrivateGalleryBootstrapMode.CredentialPrompt,
- CredentialSource: PrivateGalleryCredentialSource.None);
- }
-
- var caption = cmdlet.MyInvocation.MyCommand.Name;
- var message = $"Enter Azure Artifacts credentials or PAT for '{repositoryName}'.";
- var promptCredential = cmdlet.Host.UI.PromptForCredential(caption, message, string.Empty, string.Empty);
- if (promptCredential is null)
- {
- return new CredentialResolutionResult(
- Credential: null,
- BootstrapModeUsed: PrivateGalleryBootstrapMode.CredentialPrompt,
- CredentialSource: PrivateGalleryCredentialSource.None);
- }
-
- return new CredentialResolutionResult(
- Credential: new RepositoryCredential
- {
- UserName = promptCredential.UserName,
- Secret = promptCredential.GetNetworkCredential().Password
- },
- BootstrapModeUsed: PrivateGalleryBootstrapMode.CredentialPrompt,
- CredentialSource: PrivateGalleryCredentialSource.Prompt);
- }
-
- internal static RepositoryCredential? ResolveOptionalCredential(
- PSCmdlet cmdlet,
- string repositoryName,
- string? credentialUserName,
- string? credentialSecret,
- string? credentialSecretFilePath,
- SwitchParameter promptForCredential)
- {
- var hasCredentialUser = !string.IsNullOrWhiteSpace(credentialUserName);
- var hasCredentialSecret = !string.IsNullOrWhiteSpace(credentialSecret);
- var hasCredentialSecretFile = !string.IsNullOrWhiteSpace(credentialSecretFilePath);
-
- if (!promptForCredential.IsPresent &&
- !hasCredentialUser &&
- !hasCredentialSecret &&
- !hasCredentialSecretFile)
- {
- return null;
- }
-
- if (!promptForCredential.IsPresent &&
- hasCredentialUser &&
- !hasCredentialSecret &&
- !hasCredentialSecretFile)
- {
- throw new PSArgumentException("CredentialSecret/CredentialSecretFilePath or PromptForCredential is required when CredentialUserName is provided.");
- }
-
- return ResolveCredential(
- cmdlet,
- repositoryName,
- PrivateGalleryBootstrapMode.CredentialPrompt,
- credentialUserName,
- credentialSecret,
- credentialSecretFilePath,
- promptForCredential).Credential;
- }
-
- internal static ModuleRepositoryRegistrationResult EnsureAzureArtifactsRepositoryRegistered(
- PSCmdlet cmdlet,
- string azureDevOpsOrganization,
- string? azureDevOpsProject,
- string azureArtifactsFeed,
- string? repositoryName,
- RepositoryRegistrationTool tool,
- bool trusted,
- int? priority,
- PrivateGalleryBootstrapMode bootstrapModeRequested,
- PrivateGalleryBootstrapMode bootstrapModeUsed,
- PrivateGalleryCredentialSource credentialSource,
- RepositoryCredential? credential,
- BootstrapPrerequisiteStatus prerequisiteStatus,
- string shouldProcessAction)
- {
- var endpoint = AzureArtifactsRepositoryEndpoints.Create(
- azureDevOpsOrganization,
- azureDevOpsProject,
- azureArtifactsFeed,
- repositoryName);
-
- var effectiveTool = tool;
-
- var result = new ModuleRepositoryRegistrationResult
- {
- RepositoryName = endpoint.RepositoryName,
- AzureDevOpsOrganization = endpoint.Organization,
- AzureDevOpsProject = endpoint.Project,
- AzureArtifactsFeed = endpoint.Feed,
- PowerShellGetSourceUri = endpoint.PowerShellGetSourceUri,
- PowerShellGetPublishUri = endpoint.PowerShellGetPublishUri,
- PSResourceGetUri = endpoint.PSResourceGetUri,
- Trusted = trusted,
- CredentialUsed = credential is not null,
- BootstrapModeRequested = bootstrapModeRequested,
- BootstrapModeUsed = bootstrapModeUsed,
- CredentialSource = credentialSource,
- PSResourceGetAvailable = prerequisiteStatus.PSResourceGetAvailable,
- PSResourceGetVersion = prerequisiteStatus.PSResourceGetVersion,
- PSResourceGetMeetsMinimumVersion = prerequisiteStatus.PSResourceGetMeetsMinimumVersion,
- PSResourceGetSupportsExistingSessionBootstrap = prerequisiteStatus.PSResourceGetSupportsExistingSessionBootstrap,
- PowerShellGetAvailable = prerequisiteStatus.PowerShellGetAvailable,
- PowerShellGetVersion = prerequisiteStatus.PowerShellGetVersion,
- AzureArtifactsCredentialProviderDetected = prerequisiteStatus.CredentialProviderDetection.IsDetected,
- AzureArtifactsCredentialProviderPaths = prerequisiteStatus.CredentialProviderDetection.Paths,
- AzureArtifactsCredentialProviderVersion = prerequisiteStatus.CredentialProviderDetection.Version,
- ReadinessMessages = prerequisiteStatus.ReadinessMessages,
- Tool = effectiveTool,
- ToolRequested = effectiveTool,
- ToolUsed = effectiveTool
- };
-
- if (!cmdlet.ShouldProcess(endpoint.RepositoryName, shouldProcessAction))
- return result;
-
- result.RegistrationPerformed = true;
- var runner = new PowerShellRunner();
- var logger = new CmdletLogger(cmdlet, cmdlet.MyInvocation.BoundParameters.ContainsKey("Verbose"));
- var unavailableTools = new List(2);
- var messages = new List(4);
- var failures = new List(2);
-
- void RegisterPowerShellGet()
- {
- try
- {
- var powerShellGet = new PowerShellGetClient(runner, logger);
- result.PowerShellGetCreated = powerShellGet.EnsureRepositoryRegistered(
- endpoint.RepositoryName,
- endpoint.PowerShellGetSourceUri,
- endpoint.PowerShellGetPublishUri,
- trusted: trusted,
- credential: credential,
- timeout: TimeSpan.FromMinutes(2));
- result.PowerShellGetRegistered = true;
- }
- catch (PowerShellToolNotAvailableException ex)
- {
- unavailableTools.Add("PowerShellGet");
- messages.Add(ex.Message);
- }
- catch (Exception ex)
- {
- failures.Add($"PowerShellGet registration failed: {ex.Message}");
- }
- }
-
- void RegisterPSResourceGet()
- {
- try
- {
- var psResourceGet = new PSResourceGetClient(runner, logger);
- result.PSResourceGetCreated = psResourceGet.EnsureRepositoryRegistered(
- endpoint.RepositoryName,
- endpoint.PSResourceGetUri,
- trusted: trusted,
- priority: priority,
- apiVersion: RepositoryApiVersion.V3,
- timeout: TimeSpan.FromMinutes(2));
- result.PSResourceGetRegistered = true;
- }
- catch (PowerShellToolNotAvailableException ex)
- {
- unavailableTools.Add("PSResourceGet");
- messages.Add(ex.Message);
- }
- catch (Exception ex)
- {
- failures.Add($"PSResourceGet registration failed: {ex.Message}");
- }
- }
-
- if (effectiveTool == RepositoryRegistrationTool.Auto)
- {
- RegisterPSResourceGet();
- if (!result.PSResourceGetRegistered)
- RegisterPowerShellGet();
- }
- else
- {
- if (effectiveTool is RepositoryRegistrationTool.PowerShellGet or RepositoryRegistrationTool.Both)
- RegisterPowerShellGet();
-
- if (effectiveTool is RepositoryRegistrationTool.PSResourceGet or RepositoryRegistrationTool.Both)
- RegisterPSResourceGet();
- }
-
- result.UnavailableTools = unavailableTools
- .Distinct(StringComparer.OrdinalIgnoreCase)
- .OrderBy(static tool => tool, StringComparer.OrdinalIgnoreCase)
- .ToArray();
- result.Messages = messages
- .Concat(failures)
- .Where(static message => !string.IsNullOrWhiteSpace(message))
- .Distinct(StringComparer.Ordinal)
- .ToArray();
-
- if (!result.PSResourceGetRegistered && !result.PowerShellGetRegistered)
- {
- var message = result.Messages.Length > 0
- ? string.Join(" ", result.Messages)
- : $"No repository registration path succeeded for '{endpoint.RepositoryName}'.";
- throw new InvalidOperationException(message);
- }
-
- result.ToolUsed = result.PSResourceGetRegistered && result.PowerShellGetRegistered
- ? RepositoryRegistrationTool.Both
- : result.PSResourceGetRegistered
- ? RepositoryRegistrationTool.PSResourceGet
- : RepositoryRegistrationTool.PowerShellGet;
-
- return result;
- }
-
- internal static BootstrapPrerequisiteInstallResult EnsureBootstrapPrerequisites(
- PSCmdlet cmdlet,
- bool installPrerequisites,
- bool forceInstall = false)
- {
- var initialStatus = GetBootstrapPrerequisiteStatus();
- if (!installPrerequisites)
- {
- return new BootstrapPrerequisiteInstallResult(
- Array.Empty(),
- Array.Empty(),
- initialStatus);
- }
-
- var installed = new List(2);
- var messages = new List(4);
- var runner = new PowerShellRunner();
- var logger = new CmdletLogger(cmdlet, cmdlet.MyInvocation.BoundParameters.ContainsKey("Verbose"));
-
- if (!initialStatus.PSResourceGetAvailable || !initialStatus.PSResourceGetMeetsMinimumVersion || forceInstall)
- {
- if (cmdlet.ShouldProcess("Microsoft.PowerShell.PSResourceGet", "Install private-gallery prerequisite"))
- {
- var installer = new ModuleDependencyInstaller(runner, logger);
- var results = installer.EnsureInstalled(
- new[] { new ModuleDependency("Microsoft.PowerShell.PSResourceGet", minimumVersion: "1.1.1") },
- force: forceInstall,
- prerelease: false,
- timeoutPerModule: TimeSpan.FromMinutes(10));
-
- var result = results.FirstOrDefault();
- if (result is null || result.Status == ModuleDependencyInstallStatus.Failed)
- {
- var failure = result?.Message ?? "PSResourceGet prerequisite installation did not return a result.";
- throw new InvalidOperationException($"Failed to install PSResourceGet prerequisite. {failure}".Trim());
- }
-
- installed.Add("PSResourceGet");
- var resolvedVersion = string.IsNullOrWhiteSpace(result.ResolvedVersion) ? "unknown version" : result.ResolvedVersion;
- messages.Add($"PSResourceGet prerequisite handled via {result.Installer ?? "module installer"} ({result.Status}, resolved {resolvedVersion}).");
- }
- }
-
- var statusAfterPsResourceGet = GetBootstrapPrerequisiteStatus();
- if (installed.Contains("PSResourceGet", StringComparer.OrdinalIgnoreCase) &&
- (!statusAfterPsResourceGet.PSResourceGetAvailable || !statusAfterPsResourceGet.PSResourceGetMeetsMinimumVersion))
- {
- throw new InvalidOperationException(
- $"PSResourceGet prerequisite installation completed, but version {statusAfterPsResourceGet.PSResourceGetVersion ?? "unknown"} does not satisfy minimum {MinimumPSResourceGetVersion}.");
- }
- if (!statusAfterPsResourceGet.CredentialProviderDetection.IsDetected)
- {
- if (Path.DirectorySeparatorChar == '\\')
- {
- if (cmdlet.ShouldProcess("Azure Artifacts Credential Provider", "Install private-gallery prerequisite"))
- {
- var installer = new AzureArtifactsCredentialProviderInstaller(runner, logger);
- var result = installer.InstallForCurrentUser(includeNetFx: true, installNet8: true, force: forceInstall);
- if (!result.Succeeded)
- throw new InvalidOperationException("Azure Artifacts Credential Provider installation did not succeed.");
-
- installed.Add("AzureArtifactsCredentialProvider");
- messages.AddRange(result.Messages);
-
- var statusAfterCredentialProvider = GetBootstrapPrerequisiteStatus();
- if (!statusAfterCredentialProvider.CredentialProviderDetection.IsDetected)
- {
- throw new InvalidOperationException(
- "Azure Artifacts Credential Provider installation completed, but the provider was still not detected afterwards.");
- }
- }
- }
- else
- {
- messages.Add("Automatic Azure Artifacts Credential Provider installation is currently supported on Windows only.");
- }
- }
-
- var finalStatus = GetBootstrapPrerequisiteStatus();
- return new BootstrapPrerequisiteInstallResult(
- installed
- .Distinct(StringComparer.OrdinalIgnoreCase)
- .OrderBy(static item => item, StringComparer.OrdinalIgnoreCase)
- .ToArray(),
- messages
- .Where(static message => !string.IsNullOrWhiteSpace(message))
- .Distinct(StringComparer.Ordinal)
- .ToArray(),
- finalStatus);
- }
-
- internal static RepositoryAccessProbeResult ProbeRepositoryAccess(
- ModuleRepositoryRegistrationResult registration,
- RepositoryCredential? credential)
- {
- if (registration is null)
- throw new ArgumentNullException(nameof(registration));
-
- const string probeName = "__PowerForgePrivateGalleryConnectionProbe__";
- var runner = new PowerShellRunner();
- var logger = new NullLogger();
- var tool = SelectAccessProbeTool(registration, credential);
-
- try
- {
- if (tool == "PSResourceGet")
- {
- var client = new PSResourceGetClient(runner, logger);
- client.Find(
- new PSResourceFindOptions(
- names: new[] { probeName },
- version: null,
- prerelease: false,
- repositories: new[] { registration.RepositoryName },
- credential: credential),
- timeout: TimeSpan.FromMinutes(2));
- }
- else
- {
- var client = new PowerShellGetClient(runner, logger);
- client.Find(
- new PowerShellGetFindOptions(
- names: new[] { probeName },
- prerelease: false,
- repositories: new[] { registration.RepositoryName },
- credential: credential),
- timeout: TimeSpan.FromMinutes(2));
- }
-
- return new RepositoryAccessProbeResult(
- Succeeded: true,
- Tool: tool,
- Message: $"Repository access probe completed successfully via {tool}.");
- }
- catch (Exception ex)
- {
- return new RepositoryAccessProbeResult(
- Succeeded: false,
- Tool: tool,
- Message: ex.Message);
- }
- }
-
- internal static void WriteRegistrationSummary(PSCmdlet cmdlet, ModuleRepositoryRegistrationResult result)
- {
- if (cmdlet is null || result is null)
- return;
-
- foreach (var message in result.Messages.Where(static message => !string.IsNullOrWhiteSpace(message)))
- {
- if (result.UnavailableTools.Length > 0 &&
- result.UnavailableTools.Any(tool => message.IndexOf(tool, StringComparison.OrdinalIgnoreCase) >= 0))
- {
- cmdlet.WriteWarning(message);
- }
- else
- {
- cmdlet.WriteVerbose(message);
- }
- }
-
- foreach (var message in result.ReadinessMessages.Where(static message => !string.IsNullOrWhiteSpace(message)))
- {
- cmdlet.WriteVerbose(message);
- }
-
- foreach (var message in result.PrerequisiteInstallMessages.Where(static message => !string.IsNullOrWhiteSpace(message)))
- {
- cmdlet.WriteVerbose(message);
- }
-
- var ready = result.ReadyCommands;
- if (ready.Length > 0)
- {
- cmdlet.WriteVerbose(
- $"Repository '{result.RepositoryName}' is ready for {string.Join(", ", ready)}.");
- }
-
- cmdlet.WriteVerbose(
- $"Bootstrap readiness: ExistingSession={result.ExistingSessionBootstrapReady}; CredentialPrompt={result.CredentialPromptBootstrapReady}.");
- if (!string.IsNullOrWhiteSpace(result.PSResourceGetVersion))
- {
- cmdlet.WriteVerbose(
- $"Detected PSResourceGet version: {result.PSResourceGetVersion} (meets minimum {MinimumPSResourceGetVersion}: {result.PSResourceGetMeetsMinimumVersion}; supports ExistingSession {MinimumPSResourceGetExistingSessionVersion}+: {result.PSResourceGetSupportsExistingSessionBootstrap}).");
- }
- if (!string.IsNullOrWhiteSpace(result.PowerShellGetVersion))
- {
- cmdlet.WriteVerbose(
- $"Detected PowerShellGet version: {result.PowerShellGetVersion}.");
- }
- if (!string.IsNullOrWhiteSpace(result.AzureArtifactsCredentialProviderVersion))
- {
- cmdlet.WriteVerbose(
- $"Detected Azure Artifacts Credential Provider version: {result.AzureArtifactsCredentialProviderVersion}.");
- }
-
- if (result.InstalledPrerequisites.Length > 0)
- {
- cmdlet.WriteVerbose(
- $"Installed prerequisites: {string.Join(", ", result.InstalledPrerequisites)}.");
- }
-
- if (result.AccessProbePerformed)
- {
- if (result.AccessProbeSucceeded)
- {
- cmdlet.WriteVerbose(result.AccessProbeMessage ?? $"Repository access probe succeeded via {result.AccessProbeTool ?? "unknown"}.");
- }
- else if (!string.IsNullOrWhiteSpace(result.AccessProbeMessage))
- {
- cmdlet.WriteWarning($"Repository access probe failed via {result.AccessProbeTool ?? "unknown"}: {result.AccessProbeMessage}");
- }
- }
-
- cmdlet.WriteVerbose(
- $"Bootstrap mode used: {result.BootstrapModeUsed}; credential source: {result.CredentialSource}.");
-
- cmdlet.WriteVerbose(
- $"Repository registration requested {result.ToolRequested}; successful path: {result.ToolUsed}.");
-
- if (result.ToolRequested == RepositoryRegistrationTool.Auto &&
- result.ToolUsed == RepositoryRegistrationTool.PowerShellGet)
- {
- cmdlet.WriteVerbose(
- "Auto registration fell back to PowerShellGet, so Install-Module is the current native path on this machine.");
- }
-
- if (result.BootstrapModeRequested == PrivateGalleryBootstrapMode.ExistingSession &&
- !result.ExistingSessionBootstrapReady)
- {
- cmdlet.WriteWarning(
- $"ExistingSession bootstrap was requested, but Azure Artifacts ExistingSession support requires PSResourceGet {MinimumPSResourceGetExistingSessionVersion}+ and a detected Azure Artifacts Credential Provider.");
- }
-
- if (!string.IsNullOrWhiteSpace(result.RecommendedBootstrapCommand))
- {
- cmdlet.WriteVerbose(
- $"Bootstrap recommendation: {result.RecommendedBootstrapCommand}");
- }
-
- if (!string.IsNullOrWhiteSpace(result.RecommendedNativeInstallCommand))
- {
- cmdlet.WriteVerbose(
- $"Native install example: {result.RecommendedNativeInstallCommand}");
- }
-
- cmdlet.WriteVerbose(
- $"Wrapper install example: {result.RecommendedWrapperInstallCommand}");
- }
-
- internal static IReadOnlyList BuildDependencies(IEnumerable names)
- {
- var dependencies = (names ?? Array.Empty())
- .Where(static name => !string.IsNullOrWhiteSpace(name))
- .Select(static name => name.Trim())
- .Distinct(StringComparer.OrdinalIgnoreCase)
- .Select(static name => new ModuleDependency(name))
- .ToArray();
-
- if (dependencies.Length == 0)
- throw new PSArgumentException("At least one module name must be provided.");
-
- return dependencies;
- }
-
- internal static BootstrapPrerequisiteStatus GetBootstrapPrerequisiteStatus()
- {
- var runner = new PowerShellRunner();
- var logger = new NullLogger();
- var psResourceGet = new PSResourceGetClient(runner, logger);
- var powerShellGet = new PowerShellGetClient(runner, logger);
-
- var psResourceGetAvailability = psResourceGet.GetAvailability();
- var powerShellGetAvailability = powerShellGet.GetAvailability();
- var psResourceGetAvailable = psResourceGetAvailability.Available;
- var psResourceGetMessage = psResourceGetAvailability.Message;
- var powerShellGetAvailable = powerShellGetAvailability.Available;
- var powerShellGetMessage = powerShellGetAvailability.Message;
- var credentialProviderDetection = AzureArtifactsCredentialProviderLocator.Detect();
- var psResourceGetMeetsMinimumVersion = VersionMeetsMinimum(psResourceGetAvailability.Version, MinimumPSResourceGetVersion);
- var psResourceGetSupportsExistingSessionBootstrap = VersionMeetsMinimum(psResourceGetAvailability.Version, MinimumPSResourceGetExistingSessionVersion);
-
- var readinessMessages = new List(6);
- if (psResourceGetAvailable)
- {
- if (psResourceGetMeetsMinimumVersion)
- {
- readinessMessages.Add($"PSResourceGet is available for private-gallery bootstrap (version {psResourceGetAvailability.Version ?? "unknown"}).");
- if (!psResourceGetSupportsExistingSessionBootstrap)
- {
- readinessMessages.Add(
- $"PSResourceGet version {psResourceGetAvailability.Version ?? "unknown"} supports credential-prompt installs, but Azure Artifacts ExistingSession bootstrap requires {MinimumPSResourceGetExistingSessionVersion} or newer.");
- }
- }
- else
- {
- readinessMessages.Add($"PSResourceGet is installed, but version {psResourceGetAvailability.Version ?? "unknown"} is below the private-gallery minimum {MinimumPSResourceGetVersion}.");
- }
- }
- else if (!string.IsNullOrWhiteSpace(psResourceGetMessage))
- {
- readinessMessages.Add(psResourceGetMessage!);
- }
-
- if (powerShellGetAvailable)
- {
- readinessMessages.Add($"PowerShellGet is available for compatibility/fallback registration (version {powerShellGetAvailability.Version ?? "unknown"}).");
- }
- else if (!string.IsNullOrWhiteSpace(powerShellGetMessage))
- {
- readinessMessages.Add(powerShellGetMessage!);
- }
-
- if (credentialProviderDetection.IsDetected)
- {
- readinessMessages.Add(
- $"Azure Artifacts Credential Provider detected ({credentialProviderDetection.Paths.Length} path(s), version {credentialProviderDetection.Version ?? "unknown"}).");
- }
- else
- {
- readinessMessages.Add(
- "Azure Artifacts Credential Provider was not detected in NUGET_PLUGIN_PATHS, %UserProfile%\\.nuget\\plugins, or Visual Studio NuGet plugin locations.");
- }
-
- return new BootstrapPrerequisiteStatus(
- psResourceGetAvailable,
- psResourceGetAvailability.Version,
- psResourceGetMeetsMinimumVersion,
- psResourceGetSupportsExistingSessionBootstrap,
- psResourceGetMessage,
- powerShellGetAvailable,
- powerShellGetAvailability.Version,
- powerShellGetMessage,
- credentialProviderDetection,
- readinessMessages.ToArray());
- }
-
-}
diff --git a/PowerForge.PowerShell/Models/ModuleBuildCompletionOutcome.cs b/PowerForge.PowerShell/Models/ModuleBuildCompletionOutcome.cs
new file mode 100644
index 00000000..21e183f3
--- /dev/null
+++ b/PowerForge.PowerShell/Models/ModuleBuildCompletionOutcome.cs
@@ -0,0 +1,13 @@
+namespace PowerForge;
+
+internal sealed class ModuleBuildCompletionOutcome
+{
+ public bool Succeeded { get; set; }
+ public bool ShouldSetExitCode { get; set; }
+ public int ExitCode { get; set; }
+ public bool ShouldEmitErrorRecord { get; set; }
+ public string ErrorRecordId { get; set; } = string.Empty;
+ public bool ShouldReplayBufferedLogs { get; set; }
+ public bool ShouldWriteInteractiveFailureSummary { get; set; }
+ public string CompletionMessage { get; set; } = string.Empty;
+}
diff --git a/PowerForge.PowerShell/Models/ModuleRepositoryRegistrationResult.cs b/PowerForge.PowerShell/Models/ModuleRepositoryRegistrationResult.cs
new file mode 100644
index 00000000..2cdbb9b6
--- /dev/null
+++ b/PowerForge.PowerShell/Models/ModuleRepositoryRegistrationResult.cs
@@ -0,0 +1,120 @@
+using System;
+using System.Collections.Generic;
+
+namespace PowerForge;
+
+///
+/// Result returned when registering or refreshing a private module repository.
+///
+internal sealed class ModuleRepositoryRegistrationResult
+{
+ public string RepositoryName { get; set; } = string.Empty;
+ public string Provider { get; set; } = "AzureArtifacts";
+ public PrivateGalleryBootstrapMode BootstrapModeRequested { get; set; }
+ public PrivateGalleryBootstrapMode BootstrapModeUsed { get; set; }
+ public PrivateGalleryCredentialSource CredentialSource { get; set; }
+ public string AzureDevOpsOrganization { get; set; } = string.Empty;
+ public string? AzureDevOpsProject { get; set; }
+ public string AzureArtifactsFeed { get; set; } = string.Empty;
+ public string PowerShellGetSourceUri { get; set; } = string.Empty;
+ public string PowerShellGetPublishUri { get; set; } = string.Empty;
+ public string PSResourceGetUri { get; set; } = string.Empty;
+ public RepositoryRegistrationTool Tool { get; set; }
+ public RepositoryRegistrationTool ToolRequested { get; set; }
+ public RepositoryRegistrationTool ToolUsed { get; set; }
+ public bool PowerShellGetCreated { get; set; }
+ public bool PSResourceGetCreated { get; set; }
+ public bool Trusted { get; set; }
+ public bool CredentialUsed { get; set; }
+ public bool RegistrationPerformed { get; set; }
+ public bool PSResourceGetRegistered { get; set; }
+ public bool PowerShellGetRegistered { get; set; }
+ public bool PSResourceGetAvailable { get; set; }
+ public string? PSResourceGetVersion { get; set; }
+ public bool PSResourceGetMeetsMinimumVersion { get; set; }
+ public bool PSResourceGetSupportsExistingSessionBootstrap { get; set; }
+ public bool PowerShellGetAvailable { get; set; }
+ public string? PowerShellGetVersion { get; set; }
+ public bool AzureArtifactsCredentialProviderDetected { get; set; }
+ public string[] AzureArtifactsCredentialProviderPaths { get; set; } = Array.Empty();
+ public string? AzureArtifactsCredentialProviderVersion { get; set; }
+ public string[] ReadinessMessages { get; set; } = Array.Empty();
+ public string[] InstalledPrerequisites { get; set; } = Array.Empty();
+ public string[] PrerequisiteInstallMessages { get; set; } = Array.Empty();
+ public string[] UnavailableTools { get; set; } = Array.Empty();
+ public string[] Messages { get; set; } = Array.Empty();
+ public bool AccessProbePerformed { get; set; }
+ public bool AccessProbeSucceeded { get; set; }
+ public string? AccessProbeTool { get; set; }
+ public string? AccessProbeMessage { get; set; }
+
+ public bool ExistingSessionBootstrapReady => PSResourceGetSupportsExistingSessionBootstrap && AzureArtifactsCredentialProviderDetected;
+ public bool CredentialPromptBootstrapReady => (PSResourceGetAvailable && PSResourceGetMeetsMinimumVersion) || PowerShellGetAvailable;
+ public bool InstallPrerequisitesRecommended => !PSResourceGetAvailable || !PSResourceGetMeetsMinimumVersion || !AzureArtifactsCredentialProviderDetected;
+ public PrivateGalleryBootstrapMode RecommendedBootstrapMode
+ => ExistingSessionBootstrapReady ? PrivateGalleryBootstrapMode.ExistingSession
+ : CredentialPromptBootstrapReady ? PrivateGalleryBootstrapMode.CredentialPrompt
+ : PrivateGalleryBootstrapMode.Auto;
+ public bool InstallPSResourceReady => PSResourceGetRegistered && ExistingSessionBootstrapReady;
+ public bool InstallModuleReady => PowerShellGetRegistered;
+ public string[] ReadyCommands
+ {
+ get
+ {
+ var ready = new List(2);
+ if (InstallPSResourceReady) ready.Add("Install-PSResource");
+ if (InstallModuleReady) ready.Add("Install-Module");
+ return ready.ToArray();
+ }
+ }
+
+ public string PreferredInstallCommand => InstallPSResourceReady ? "Install-PSResource" : InstallModuleReady ? "Install-Module" : string.Empty;
+ public string RecommendedWrapperInstallCommand
+ => string.IsNullOrWhiteSpace(RepositoryName) ? "Install-PrivateModule -Name " : $"Install-PrivateModule -Name -Repository '{RepositoryName}'";
+
+ public string RecommendedNativeInstallCommand
+ => string.IsNullOrWhiteSpace(RepositoryName) || string.IsNullOrWhiteSpace(PreferredInstallCommand)
+ ? string.Empty
+ : PreferredInstallCommand == "Install-PSResource"
+ ? $"Install-PSResource -Name -Repository '{RepositoryName}'"
+ : $"Install-Module -Name -Repository '{RepositoryName}'";
+
+ public string RecommendedBootstrapCommand
+ {
+ get
+ {
+ if (string.IsNullOrWhiteSpace(AzureDevOpsOrganization) || string.IsNullOrWhiteSpace(AzureArtifactsFeed))
+ return string.Empty;
+
+ var parts = new List
+ {
+ "Register-ModuleRepository",
+ $"-AzureDevOpsOrganization '{AzureDevOpsOrganization}'"
+ };
+
+ if (!string.IsNullOrWhiteSpace(AzureDevOpsProject))
+ parts.Add($"-AzureDevOpsProject '{AzureDevOpsProject}'");
+
+ parts.Add($"-AzureArtifactsFeed '{AzureArtifactsFeed}'");
+
+ if (!string.IsNullOrWhiteSpace(RepositoryName) &&
+ !string.Equals(RepositoryName, AzureArtifactsFeed, StringComparison.OrdinalIgnoreCase))
+ {
+ parts.Add($"-Name '{RepositoryName}'");
+ }
+
+ if (InstallPrerequisitesRecommended)
+ parts.Add("-InstallPrerequisites");
+
+ if (RecommendedBootstrapMode == PrivateGalleryBootstrapMode.ExistingSession)
+ parts.Add("-BootstrapMode ExistingSession");
+ else if (RecommendedBootstrapMode == PrivateGalleryBootstrapMode.CredentialPrompt)
+ {
+ parts.Add("-BootstrapMode CredentialPrompt");
+ parts.Add("-Interactive");
+ }
+
+ return string.Join(" ", parts);
+ }
+ }
+}
diff --git a/PowerForge.PowerShell/Models/ModuleTestSuiteDisplayModels.cs b/PowerForge.PowerShell/Models/ModuleTestSuiteDisplayModels.cs
new file mode 100644
index 00000000..0dff6412
--- /dev/null
+++ b/PowerForge.PowerShell/Models/ModuleTestSuiteDisplayModels.cs
@@ -0,0 +1,9 @@
+using System;
+
+namespace PowerForge;
+
+internal sealed class ModuleTestSuiteDisplayLine
+{
+ internal string Text { get; set; } = string.Empty;
+ internal ConsoleColor? Color { get; set; }
+}
diff --git a/PowerForge.PowerShell/Models/PrivateGalleryModels.cs b/PowerForge.PowerShell/Models/PrivateGalleryModels.cs
new file mode 100644
index 00000000..a115b65e
--- /dev/null
+++ b/PowerForge.PowerShell/Models/PrivateGalleryModels.cs
@@ -0,0 +1,87 @@
+namespace PowerForge;
+
+internal readonly struct CredentialResolutionResult
+{
+ internal CredentialResolutionResult(
+ RepositoryCredential? credential,
+ PrivateGalleryBootstrapMode bootstrapModeUsed,
+ PrivateGalleryCredentialSource credentialSource)
+ {
+ Credential = credential;
+ BootstrapModeUsed = bootstrapModeUsed;
+ CredentialSource = credentialSource;
+ }
+
+ internal RepositoryCredential? Credential { get; }
+ internal PrivateGalleryBootstrapMode BootstrapModeUsed { get; }
+ internal PrivateGalleryCredentialSource CredentialSource { get; }
+}
+
+internal readonly struct BootstrapPrerequisiteStatus
+{
+ internal BootstrapPrerequisiteStatus(
+ bool psResourceGetAvailable,
+ string? psResourceGetVersion,
+ bool psResourceGetMeetsMinimumVersion,
+ bool psResourceGetSupportsExistingSessionBootstrap,
+ string? psResourceGetMessage,
+ bool powerShellGetAvailable,
+ string? powerShellGetVersion,
+ string? powerShellGetMessage,
+ AzureArtifactsCredentialProviderDetectionResult credentialProviderDetection,
+ string[] readinessMessages)
+ {
+ PSResourceGetAvailable = psResourceGetAvailable;
+ PSResourceGetVersion = psResourceGetVersion;
+ PSResourceGetMeetsMinimumVersion = psResourceGetMeetsMinimumVersion;
+ PSResourceGetSupportsExistingSessionBootstrap = psResourceGetSupportsExistingSessionBootstrap;
+ PSResourceGetMessage = psResourceGetMessage;
+ PowerShellGetAvailable = powerShellGetAvailable;
+ PowerShellGetVersion = powerShellGetVersion;
+ PowerShellGetMessage = powerShellGetMessage;
+ CredentialProviderDetection = credentialProviderDetection;
+ ReadinessMessages = readinessMessages;
+ }
+
+ internal bool PSResourceGetAvailable { get; }
+ internal string? PSResourceGetVersion { get; }
+ internal bool PSResourceGetMeetsMinimumVersion { get; }
+ internal bool PSResourceGetSupportsExistingSessionBootstrap { get; }
+ internal string? PSResourceGetMessage { get; }
+ internal bool PowerShellGetAvailable { get; }
+ internal string? PowerShellGetVersion { get; }
+ internal string? PowerShellGetMessage { get; }
+ internal AzureArtifactsCredentialProviderDetectionResult CredentialProviderDetection { get; }
+ internal string[] ReadinessMessages { get; }
+}
+
+internal readonly struct BootstrapPrerequisiteInstallResult
+{
+ internal BootstrapPrerequisiteInstallResult(
+ string[] installedPrerequisites,
+ string[] messages,
+ BootstrapPrerequisiteStatus status)
+ {
+ InstalledPrerequisites = installedPrerequisites;
+ Messages = messages;
+ Status = status;
+ }
+
+ internal string[] InstalledPrerequisites { get; }
+ internal string[] Messages { get; }
+ internal BootstrapPrerequisiteStatus Status { get; }
+}
+
+internal readonly struct RepositoryAccessProbeResult
+{
+ internal RepositoryAccessProbeResult(bool succeeded, string tool, string? message)
+ {
+ Succeeded = succeeded;
+ Tool = tool;
+ Message = message;
+ }
+
+ internal bool Succeeded { get; }
+ internal string Tool { get; }
+ internal string? Message { get; }
+}
diff --git a/PowerForge.PowerShell/Models/PrivateModuleWorkflowModels.cs b/PowerForge.PowerShell/Models/PrivateModuleWorkflowModels.cs
new file mode 100644
index 00000000..5540b977
--- /dev/null
+++ b/PowerForge.PowerShell/Models/PrivateModuleWorkflowModels.cs
@@ -0,0 +1,50 @@
+using System.Collections.Generic;
+
+namespace PowerForge;
+
+internal enum PrivateModuleWorkflowOperation
+{
+ Install,
+ Update
+}
+
+internal sealed class PrivateModuleWorkflowRequest
+{
+ internal PrivateModuleWorkflowOperation Operation { get; set; }
+ internal IReadOnlyList ModuleNames { get; set; } = System.Array.Empty();
+ internal bool UseAzureArtifacts { get; set; }
+ internal string RepositoryName { get; set; } = string.Empty;
+ internal PrivateGalleryProvider Provider { get; set; } = PrivateGalleryProvider.AzureArtifacts;
+ internal string AzureDevOpsOrganization { get; set; } = string.Empty;
+ internal string? AzureDevOpsProject { get; set; }
+ internal string AzureArtifactsFeed { get; set; } = string.Empty;
+ internal RepositoryRegistrationTool Tool { get; set; } = RepositoryRegistrationTool.Auto;
+ internal PrivateGalleryBootstrapMode BootstrapMode { get; set; } = PrivateGalleryBootstrapMode.Auto;
+ internal bool Trusted { get; set; } = true;
+ internal int? Priority { get; set; }
+ internal string? CredentialUserName { get; set; }
+ internal string? CredentialSecret { get; set; }
+ internal string? CredentialSecretFilePath { get; set; }
+ internal bool PromptForCredential { get; set; }
+ internal bool InstallPrerequisites { get; set; }
+ internal bool Prerelease { get; set; }
+ internal bool Force { get; set; }
+}
+
+internal sealed class PrivateModuleWorkflowResult
+{
+ internal bool OperationPerformed { get; set; }
+ internal string RepositoryName { get; set; } = string.Empty;
+ internal IReadOnlyList DependencyResults { get; set; } = System.Array.Empty();
+}
+
+internal sealed class PrivateModuleDependencyExecutionRequest
+{
+ internal PrivateModuleWorkflowOperation Operation { get; set; }
+ internal IReadOnlyList Modules { get; set; } = System.Array.Empty();
+ internal string RepositoryName { get; set; } = string.Empty;
+ internal RepositoryCredential? Credential { get; set; }
+ internal bool Prerelease { get; set; }
+ internal bool Force { get; set; }
+ internal bool PreferPowerShellGet { get; set; }
+}
diff --git a/PowerForge.PowerShell/Models/ProjectCleanupDisplayModels.cs b/PowerForge.PowerShell/Models/ProjectCleanupDisplayModels.cs
new file mode 100644
index 00000000..f0e69eee
--- /dev/null
+++ b/PowerForge.PowerShell/Models/ProjectCleanupDisplayModels.cs
@@ -0,0 +1,10 @@
+using System;
+
+namespace PowerForge;
+
+internal sealed class ProjectCleanupDisplayLine
+{
+ internal string Text { get; set; } = string.Empty;
+ internal ConsoleColor? Color { get; set; }
+ internal bool IsWarning { get; set; }
+}
diff --git a/PowerForge.PowerShell/PowerForge.PowerShell.csproj b/PowerForge.PowerShell/PowerForge.PowerShell.csproj
index 8009af58..6ae5092b 100644
--- a/PowerForge.PowerShell/PowerForge.PowerShell.csproj
+++ b/PowerForge.PowerShell/PowerForge.PowerShell.csproj
@@ -63,6 +63,7 @@
+
diff --git a/PowerForge.PowerShell/Services/IPrivateGalleryHost.cs b/PowerForge.PowerShell/Services/IPrivateGalleryHost.cs
new file mode 100644
index 00000000..c4b0c95b
--- /dev/null
+++ b/PowerForge.PowerShell/Services/IPrivateGalleryHost.cs
@@ -0,0 +1,10 @@
+namespace PowerForge;
+
+internal interface IPrivateGalleryHost
+{
+ bool ShouldProcess(string target, string action);
+ bool IsWhatIfRequested { get; }
+ RepositoryCredential? PromptForCredential(string caption, string message);
+ void WriteVerbose(string message);
+ void WriteWarning(string message);
+}
diff --git a/PowerForge.PowerShell/Services/ModuleBuildOutcomeService.cs b/PowerForge.PowerShell/Services/ModuleBuildOutcomeService.cs
new file mode 100644
index 00000000..ff515707
--- /dev/null
+++ b/PowerForge.PowerShell/Services/ModuleBuildOutcomeService.cs
@@ -0,0 +1,48 @@
+using System;
+
+namespace PowerForge;
+
+internal sealed class ModuleBuildOutcomeService
+{
+ private readonly BufferedLogSupportService _logSupport;
+
+ public ModuleBuildOutcomeService(BufferedLogSupportService? logSupport = null)
+ {
+ _logSupport = logSupport ?? new BufferedLogSupportService();
+ }
+
+ public ModuleBuildCompletionOutcome Evaluate(
+ ModuleBuildWorkflowResult? workflow,
+ bool exitCodeMode,
+ bool jsonOnly,
+ bool useLegacy,
+ TimeSpan elapsed)
+ {
+ var succeeded = workflow?.Succeeded ?? false;
+ var duration = _logSupport.FormatDuration(elapsed);
+
+ return new ModuleBuildCompletionOutcome
+ {
+ Succeeded = succeeded,
+ ShouldSetExitCode = exitCodeMode,
+ ExitCode = succeeded ? 0 : 1,
+ ShouldEmitErrorRecord = !succeeded &&
+ !exitCodeMode &&
+ workflow?.UsedInteractiveView != true &&
+ workflow?.PolicyFailure is null,
+ ErrorRecordId = useLegacy ? "InvokeModuleBuildDslFailed" : "InvokeModuleBuildPowerForgeFailed",
+ ShouldReplayBufferedLogs = !succeeded,
+ ShouldWriteInteractiveFailureSummary = !succeeded &&
+ workflow?.UsedInteractiveView == true &&
+ workflow?.Plan is not null &&
+ !workflow.WrotePolicySummary,
+ CompletionMessage = succeeded
+ ? (jsonOnly
+ ? $"Pipeline config generated in {duration}"
+ : $"Module build completed in {duration}")
+ : (jsonOnly
+ ? $"Pipeline config generation failed in {duration}"
+ : $"Module build failed in {duration}")
+ };
+ }
+}
diff --git a/PowerForge.PowerShell/Services/ModuleTestSuiteDisplayService.cs b/PowerForge.PowerShell/Services/ModuleTestSuiteDisplayService.cs
new file mode 100644
index 00000000..a6755828
--- /dev/null
+++ b/PowerForge.PowerShell/Services/ModuleTestSuiteDisplayService.cs
@@ -0,0 +1,183 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace PowerForge;
+
+internal sealed class ModuleTestSuiteDisplayService
+{
+ private readonly ModuleTestFailureDisplayService _failureDisplayService;
+
+ public ModuleTestSuiteDisplayService(ModuleTestFailureDisplayService? failureDisplayService = null)
+ {
+ _failureDisplayService = failureDisplayService ?? new ModuleTestFailureDisplayService();
+ }
+
+ public IReadOnlyList CreateHeader(string projectRoot, string psVersion, string psEdition, bool ciMode)
+ {
+ return new[]
+ {
+ Line(ciMode ? "=== CI/CD Module Testing Pipeline ===" : "=== PowerShell Module Test Suite ===", ConsoleColor.Magenta),
+ Line($"Project Path: {projectRoot}", ConsoleColor.Cyan),
+ Line($"PowerShell Version: {psVersion}", ConsoleColor.Cyan),
+ Line($"PowerShell Edition: {psEdition}", ConsoleColor.Cyan),
+ Line(string.Empty)
+ };
+ }
+
+ public IReadOnlyList CreateModuleInfoHeader() => new[]
+ {
+ Line("Step 1: Gathering module information...", ConsoleColor.Yellow)
+ };
+
+ public IReadOnlyList CreateModuleInfoDetails(ModuleInformation info)
+ {
+ if (info is null)
+ throw new ArgumentNullException(nameof(info));
+
+ return new[]
+ {
+ Line($" Module Name: {info.ModuleName}", ConsoleColor.Green),
+ Line($" Module Version: {info.ModuleVersion ?? string.Empty}", ConsoleColor.Green),
+ Line($" Manifest Path: {info.ManifestPath}", ConsoleColor.Green),
+ Line($" Required Modules: {(info.RequiredModules ?? Array.Empty()).Length}", ConsoleColor.Green),
+ Line(string.Empty)
+ };
+ }
+
+ public IReadOnlyList CreateExecutionHeader() => new[]
+ {
+ Line("Step 2: Executing test suite (out-of-process)...", ConsoleColor.Yellow)
+ };
+
+ public IReadOnlyList CreateDependencySummary(
+ RequiredModuleReference[] requiredModules,
+ string[] additionalModules,
+ string[] skipModules)
+ {
+ var lines = new List
+ {
+ Line("Step 3: Dependency summary...", ConsoleColor.Yellow)
+ };
+
+ if (requiredModules.Length == 0)
+ {
+ lines.Add(Line(" No required modules specified in manifest", ConsoleColor.Gray));
+ }
+ else
+ {
+ lines.Add(Line("Required modules:", ConsoleColor.Cyan));
+ foreach (var module in requiredModules)
+ {
+ var versionInfo = string.Empty;
+ if (!string.IsNullOrWhiteSpace(module.ModuleVersion)) versionInfo += $" (Min: {module.ModuleVersion})";
+ if (!string.IsNullOrWhiteSpace(module.RequiredVersion)) versionInfo += $" (Required: {module.RequiredVersion})";
+ if (!string.IsNullOrWhiteSpace(module.MaximumVersion)) versionInfo += $" (Max: {module.MaximumVersion})";
+ lines.Add(Line($" 📦 {module.ModuleName}{versionInfo}", ConsoleColor.Green));
+ }
+ }
+
+ if (additionalModules.Length > 0)
+ {
+ lines.Add(Line("Additional modules:", ConsoleColor.Cyan));
+ foreach (var module in additionalModules)
+ {
+ if (skipModules.Contains(module, StringComparer.OrdinalIgnoreCase))
+ continue;
+
+ lines.Add(Line($" ✅ {module}", ConsoleColor.Green));
+ }
+ }
+
+ lines.Add(Line(string.Empty));
+ return lines;
+ }
+
+ public IReadOnlyList CreateDependencyInstallResults(ModuleDependencyInstallResult[] results)
+ {
+ var lines = new List
+ {
+ Line("Step 4: Dependency installation results...", ConsoleColor.Yellow)
+ };
+
+ if (results.Length == 0)
+ {
+ lines.Add(Line(" (no dependency install actions)", ConsoleColor.Gray));
+ lines.Add(Line(string.Empty));
+ return lines;
+ }
+
+ foreach (var result in results)
+ {
+ switch (result.Status)
+ {
+ case ModuleDependencyInstallStatus.Skipped:
+ lines.Add(Line($" ⏭️ Skipping: {result.Name}", ConsoleColor.Gray));
+ break;
+ case ModuleDependencyInstallStatus.Satisfied:
+ lines.Add(Line($" ✅ {result.Name} OK (installed: {result.InstalledVersion ?? "unknown"})", ConsoleColor.Green));
+ break;
+ case ModuleDependencyInstallStatus.Installed:
+ case ModuleDependencyInstallStatus.Updated:
+ {
+ var icon = result.Status == ModuleDependencyInstallStatus.Updated ? "🔄" : "📥";
+ lines.Add(Line($" {icon} {result.Name} {result.Status} via {result.Installer ?? "installer"} (resolved: {result.ResolvedVersion ?? "unknown"})", ConsoleColor.Green));
+ break;
+ }
+ case ModuleDependencyInstallStatus.Failed:
+ lines.Add(Line($" ❌ {result.Name}: {result.Message}", ConsoleColor.Red));
+ break;
+ }
+ }
+
+ lines.Add(Line(string.Empty));
+ return lines;
+ }
+
+ public IReadOnlyList CreateCompletionSummary(ModuleTestSuiteResult result)
+ {
+ if (result is null)
+ throw new ArgumentNullException(nameof(result));
+
+ var successColor = result.FailedCount > 0 ? ConsoleColor.Red : ConsoleColor.Green;
+ var testColor = result.FailedCount > 0 ? ConsoleColor.Yellow : ConsoleColor.Green;
+ var lines = new List
+ {
+ Line(result.FailedCount > 0 ? "=== Test Suite Failed ===" : "=== Test Suite Completed Successfully ===", successColor),
+ Line($"Module: {result.ModuleName} v{result.ModuleVersion ?? string.Empty}", ConsoleColor.Green),
+ Line($"Tests: {result.PassedCount}/{result.TotalCount} passed", testColor)
+ };
+
+ if (result.Duration.HasValue)
+ lines.Add(Line($"Duration: {result.Duration.Value}", ConsoleColor.Green));
+
+ lines.Add(Line(string.Empty));
+ return lines;
+ }
+
+ public IReadOnlyList CreateFailureSummary(ModuleTestFailureAnalysis? analysis, bool detailed)
+ {
+ if (analysis is null)
+ {
+ return new[]
+ {
+ Line("No failure analysis available.", ConsoleColor.Yellow)
+ };
+ }
+
+ var sourceLines = detailed
+ ? _failureDisplayService.CreateDetailed(analysis)
+ : _failureDisplayService.CreateSummary(analysis, showSuccessful: true);
+
+ return sourceLines
+ .Select(line => new ModuleTestSuiteDisplayLine
+ {
+ Text = line.Text,
+ Color = line.Color
+ })
+ .ToArray();
+ }
+
+ private static ModuleTestSuiteDisplayLine Line(string text, ConsoleColor? color = null)
+ => new() { Text = text, Color = color };
+}
diff --git a/PowerForge.PowerShell/Services/PrivateGalleryHostLogger.cs b/PowerForge.PowerShell/Services/PrivateGalleryHostLogger.cs
new file mode 100644
index 00000000..5cd41280
--- /dev/null
+++ b/PowerForge.PowerShell/Services/PrivateGalleryHostLogger.cs
@@ -0,0 +1,43 @@
+using System;
+
+namespace PowerForge;
+
+internal sealed class PrivateGalleryHostLogger : ILogger
+{
+ private readonly IPrivateGalleryHost _host;
+
+ public PrivateGalleryHostLogger(IPrivateGalleryHost host)
+ => _host = host ?? throw new ArgumentNullException(nameof(host));
+
+ public bool IsVerbose => true;
+
+ public void Verbose(string message)
+ {
+ if (!string.IsNullOrWhiteSpace(message))
+ _host.WriteVerbose(message);
+ }
+
+ public void Info(string message)
+ {
+ if (!string.IsNullOrWhiteSpace(message))
+ _host.WriteVerbose(message);
+ }
+
+ public void Warn(string message)
+ {
+ if (!string.IsNullOrWhiteSpace(message))
+ _host.WriteWarning(message);
+ }
+
+ public void Error(string message)
+ {
+ if (!string.IsNullOrWhiteSpace(message))
+ _host.WriteWarning(message);
+ }
+
+ public void Success(string message)
+ {
+ if (!string.IsNullOrWhiteSpace(message))
+ _host.WriteVerbose(message);
+ }
+}
diff --git a/PowerForge.PowerShell/Services/PrivateGalleryService.cs b/PowerForge.PowerShell/Services/PrivateGalleryService.cs
new file mode 100644
index 00000000..c32e872b
--- /dev/null
+++ b/PowerForge.PowerShell/Services/PrivateGalleryService.cs
@@ -0,0 +1,579 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+
+namespace PowerForge;
+
+internal sealed class PrivateGalleryService
+{
+ private const string MinimumPSResourceGetVersion = "1.1.1";
+ private const string MinimumPSResourceGetExistingSessionVersion = "1.2.0-preview5";
+
+ private readonly IPrivateGalleryHost _host;
+
+ public PrivateGalleryService(IPrivateGalleryHost host)
+ {
+ _host = host ?? throw new ArgumentNullException(nameof(host));
+ }
+
+ public void EnsureProviderSupported(PrivateGalleryProvider provider)
+ {
+ if (provider != PrivateGalleryProvider.AzureArtifacts)
+ throw new ArgumentException($"Provider '{provider}' is not supported yet. Supported value: AzureArtifacts.", nameof(provider));
+ }
+
+ public IReadOnlyList BuildDependencies(IEnumerable names)
+ {
+ var dependencies = (names ?? Array.Empty())
+ .Where(static name => !string.IsNullOrWhiteSpace(name))
+ .Select(static name => name.Trim())
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .Select(static name => new ModuleDependency(name))
+ .ToArray();
+
+ if (dependencies.Length == 0)
+ throw new ArgumentException("At least one module name must be provided.", nameof(names));
+
+ return dependencies;
+ }
+
+ public CredentialResolutionResult ResolveCredential(
+ string repositoryName,
+ PrivateGalleryBootstrapMode bootstrapMode,
+ string? credentialUserName,
+ string? credentialSecret,
+ string? credentialSecretFilePath,
+ bool promptForCredential,
+ BootstrapPrerequisiteStatus? prerequisiteStatus = null,
+ bool allowInteractivePrompt = true)
+ {
+ var hasCredentialSecretFile = !string.IsNullOrWhiteSpace(credentialSecretFilePath);
+ var hasCredentialSecret = !string.IsNullOrWhiteSpace(credentialSecret);
+ var hasCredentialUser = !string.IsNullOrWhiteSpace(credentialUserName);
+ var hasAnyCredentialSecret = hasCredentialSecretFile || hasCredentialSecret;
+ var hasExplicitCredential = hasCredentialUser && hasAnyCredentialSecret;
+
+ if (hasAnyCredentialSecret && !hasCredentialUser)
+ throw new ArgumentException("CredentialUserName is required when CredentialSecret/CredentialSecretFilePath is provided.", nameof(credentialUserName));
+
+ if (promptForCredential && (hasAnyCredentialSecret || hasCredentialUser))
+ throw new ArgumentException("PromptForCredential cannot be combined with CredentialUserName/CredentialSecret/CredentialSecretFilePath.", nameof(promptForCredential));
+
+ if (bootstrapMode == PrivateGalleryBootstrapMode.ExistingSession &&
+ (promptForCredential || hasExplicitCredential))
+ {
+ throw new ArgumentException("BootstrapMode ExistingSession cannot be combined with interactive or explicit credential parameters.", nameof(bootstrapMode));
+ }
+
+ var resolvedSecret = string.Empty;
+ if (hasCredentialSecretFile)
+ {
+ resolvedSecret = File.ReadAllText(credentialSecretFilePath!).Trim();
+ }
+ else if (hasCredentialSecret)
+ {
+ resolvedSecret = credentialSecret!.Trim();
+ }
+
+ var effectiveMode = bootstrapMode;
+ if (bootstrapMode == PrivateGalleryBootstrapMode.Auto)
+ {
+ var detectedPrerequisites = prerequisiteStatus ?? GetBootstrapPrerequisiteStatus();
+ effectiveMode = promptForCredential || hasExplicitCredential
+ ? PrivateGalleryBootstrapMode.CredentialPrompt
+ : PrivateGalleryVersionPolicy.GetRecommendedBootstrapMode(detectedPrerequisites);
+
+ if (effectiveMode == PrivateGalleryBootstrapMode.Auto)
+ {
+ if (!allowInteractivePrompt)
+ effectiveMode = PrivateGalleryBootstrapMode.CredentialPrompt;
+ else
+ throw new InvalidOperationException(PrivateGalleryVersionPolicy.BuildBootstrapUnavailableMessage(repositoryName, detectedPrerequisites));
+ }
+ }
+
+ if (effectiveMode == PrivateGalleryBootstrapMode.ExistingSession)
+ {
+ return new CredentialResolutionResult(
+ credential: null,
+ bootstrapModeUsed: PrivateGalleryBootstrapMode.ExistingSession,
+ credentialSource: PrivateGalleryCredentialSource.None);
+ }
+
+ if (hasExplicitCredential)
+ {
+ return new CredentialResolutionResult(
+ credential: new RepositoryCredential
+ {
+ UserName = credentialUserName!.Trim(),
+ Secret = resolvedSecret
+ },
+ bootstrapModeUsed: PrivateGalleryBootstrapMode.CredentialPrompt,
+ credentialSource: PrivateGalleryCredentialSource.Supplied);
+ }
+
+ if (!allowInteractivePrompt)
+ {
+ return new CredentialResolutionResult(
+ credential: null,
+ bootstrapModeUsed: PrivateGalleryBootstrapMode.CredentialPrompt,
+ credentialSource: PrivateGalleryCredentialSource.None);
+ }
+
+ var promptedCredential = _host.PromptForCredential("Private gallery authentication", $"Enter Azure Artifacts credentials or PAT for '{repositoryName}'.");
+ if (promptedCredential is null)
+ {
+ return new CredentialResolutionResult(
+ credential: null,
+ bootstrapModeUsed: PrivateGalleryBootstrapMode.CredentialPrompt,
+ credentialSource: PrivateGalleryCredentialSource.None);
+ }
+
+ return new CredentialResolutionResult(
+ credential: promptedCredential,
+ bootstrapModeUsed: PrivateGalleryBootstrapMode.CredentialPrompt,
+ credentialSource: PrivateGalleryCredentialSource.Prompt);
+ }
+
+ public RepositoryCredential? ResolveOptionalCredential(
+ string repositoryName,
+ string? credentialUserName,
+ string? credentialSecret,
+ string? credentialSecretFilePath,
+ bool promptForCredential)
+ {
+ var hasCredentialUser = !string.IsNullOrWhiteSpace(credentialUserName);
+ var hasCredentialSecret = !string.IsNullOrWhiteSpace(credentialSecret);
+ var hasCredentialSecretFile = !string.IsNullOrWhiteSpace(credentialSecretFilePath);
+
+ if (!promptForCredential && !hasCredentialUser && !hasCredentialSecret && !hasCredentialSecretFile)
+ return null;
+
+ if (!promptForCredential && hasCredentialUser && !hasCredentialSecret && !hasCredentialSecretFile)
+ throw new ArgumentException("CredentialSecret/CredentialSecretFilePath or PromptForCredential is required when CredentialUserName is provided.", nameof(credentialUserName));
+
+ return ResolveCredential(
+ repositoryName,
+ PrivateGalleryBootstrapMode.CredentialPrompt,
+ credentialUserName,
+ credentialSecret,
+ credentialSecretFilePath,
+ promptForCredential).Credential;
+ }
+
+ public ModuleRepositoryRegistrationResult EnsureAzureArtifactsRepositoryRegistered(
+ string azureDevOpsOrganization,
+ string? azureDevOpsProject,
+ string azureArtifactsFeed,
+ string? repositoryName,
+ RepositoryRegistrationTool tool,
+ bool trusted,
+ int? priority,
+ PrivateGalleryBootstrapMode bootstrapModeRequested,
+ PrivateGalleryBootstrapMode bootstrapModeUsed,
+ PrivateGalleryCredentialSource credentialSource,
+ RepositoryCredential? credential,
+ BootstrapPrerequisiteStatus prerequisiteStatus,
+ string shouldProcessAction)
+ {
+ var endpoint = AzureArtifactsRepositoryEndpoints.Create(
+ azureDevOpsOrganization,
+ azureDevOpsProject,
+ azureArtifactsFeed,
+ repositoryName);
+
+ var effectiveTool = tool;
+ var result = new ModuleRepositoryRegistrationResult
+ {
+ RepositoryName = endpoint.RepositoryName,
+ Provider = "AzureArtifacts",
+ BootstrapModeRequested = bootstrapModeRequested,
+ BootstrapModeUsed = bootstrapModeUsed,
+ CredentialSource = credentialSource,
+ AzureDevOpsOrganization = endpoint.Organization,
+ AzureDevOpsProject = endpoint.Project,
+ AzureArtifactsFeed = endpoint.Feed,
+ PowerShellGetSourceUri = endpoint.PowerShellGetSourceUri,
+ PowerShellGetPublishUri = endpoint.PowerShellGetPublishUri,
+ PSResourceGetUri = endpoint.PSResourceGetUri,
+ Trusted = trusted,
+ CredentialUsed = credential is not null,
+ ToolRequested = tool,
+ Tool = tool,
+ PSResourceGetAvailable = prerequisiteStatus.PSResourceGetAvailable,
+ PSResourceGetVersion = prerequisiteStatus.PSResourceGetVersion,
+ PSResourceGetMeetsMinimumVersion = prerequisiteStatus.PSResourceGetMeetsMinimumVersion,
+ PSResourceGetSupportsExistingSessionBootstrap = prerequisiteStatus.PSResourceGetSupportsExistingSessionBootstrap,
+ PowerShellGetAvailable = prerequisiteStatus.PowerShellGetAvailable,
+ PowerShellGetVersion = prerequisiteStatus.PowerShellGetVersion,
+ AzureArtifactsCredentialProviderDetected = prerequisiteStatus.CredentialProviderDetection.IsDetected,
+ AzureArtifactsCredentialProviderPaths = prerequisiteStatus.CredentialProviderDetection.Paths,
+ AzureArtifactsCredentialProviderVersion = prerequisiteStatus.CredentialProviderDetection.Version,
+ ReadinessMessages = prerequisiteStatus.ReadinessMessages,
+ };
+
+ if (effectiveTool == RepositoryRegistrationTool.PSResourceGet &&
+ (!prerequisiteStatus.PSResourceGetAvailable || !prerequisiteStatus.PSResourceGetMeetsMinimumVersion))
+ {
+ throw new InvalidOperationException($"PSResourceGet {MinimumPSResourceGetVersion}+ is required when Tool is PSResourceGet. Detected version: {prerequisiteStatus.PSResourceGetVersion ?? "not installed"}.");
+ }
+ else if (effectiveTool == RepositoryRegistrationTool.PowerShellGet &&
+ !prerequisiteStatus.PowerShellGetAvailable)
+ {
+ throw new InvalidOperationException("PowerShellGet is required when Tool is PowerShellGet.");
+ }
+ else if (effectiveTool == RepositoryRegistrationTool.Both &&
+ (!prerequisiteStatus.PSResourceGetAvailable ||
+ !prerequisiteStatus.PSResourceGetMeetsMinimumVersion ||
+ !prerequisiteStatus.PowerShellGetAvailable))
+ {
+ throw new InvalidOperationException(
+ $"Both PSResourceGet {MinimumPSResourceGetVersion}+ and PowerShellGet are required when Tool is Both. " +
+ $"Detected PSResourceGet: {prerequisiteStatus.PSResourceGetVersion ?? "not installed"}, PowerShellGet: {prerequisiteStatus.PowerShellGetVersion ?? "not installed"}.");
+ }
+
+ result.Tool = effectiveTool;
+
+ if (!_host.ShouldProcess(result.RepositoryName, shouldProcessAction))
+ return result;
+
+ result.RegistrationPerformed = true;
+ var runner = new PowerShellRunner();
+ var logger = new PrivateGalleryHostLogger(_host);
+ var unavailableTools = new List(2);
+ var messages = new List(8);
+ var failures = new List(2);
+
+ void RegisterPSResourceGet()
+ {
+ try
+ {
+ var client = new PSResourceGetClient(runner, logger);
+ var created = client.EnsureRepositoryRegistered(
+ result.RepositoryName,
+ endpoint.PSResourceGetUri,
+ trusted,
+ priority,
+ apiVersion: RepositoryApiVersion.V3,
+ timeout: TimeSpan.FromMinutes(2));
+
+ result.PSResourceGetRegistered = true;
+ result.PSResourceGetCreated = created;
+ messages.Add(created
+ ? $"Registered PSResourceGet repository '{result.RepositoryName}'."
+ : $"PSResourceGet repository '{result.RepositoryName}' already existed and was refreshed.");
+ }
+ catch (PowerShellToolNotAvailableException ex)
+ {
+ unavailableTools.Add("PSResourceGet");
+ messages.Add(ex.Message);
+ }
+ catch (Exception ex)
+ {
+ failures.Add($"PSResourceGet registration failed: {ex.Message}");
+ }
+ }
+
+ void RegisterPowerShellGet()
+ {
+ try
+ {
+ var client = new PowerShellGetClient(runner, logger);
+ var created = client.EnsureRepositoryRegistered(
+ result.RepositoryName,
+ endpoint.PowerShellGetSourceUri,
+ endpoint.PowerShellGetPublishUri,
+ trusted,
+ credential,
+ timeout: TimeSpan.FromMinutes(2));
+
+ result.PowerShellGetRegistered = true;
+ result.PowerShellGetCreated = created;
+ messages.Add(created
+ ? $"Registered PowerShellGet repository '{result.RepositoryName}'."
+ : $"PowerShellGet repository '{result.RepositoryName}' already existed and was refreshed.");
+ }
+ catch (PowerShellToolNotAvailableException ex)
+ {
+ unavailableTools.Add("PowerShellGet");
+ messages.Add(ex.Message);
+ }
+ catch (Exception ex)
+ {
+ failures.Add($"PowerShellGet registration failed: {ex.Message}");
+ }
+ }
+
+ if (effectiveTool == RepositoryRegistrationTool.Auto)
+ {
+ RegisterPSResourceGet();
+ if (!result.PSResourceGetRegistered)
+ RegisterPowerShellGet();
+ }
+ else
+ {
+ if (effectiveTool is RepositoryRegistrationTool.PowerShellGet or RepositoryRegistrationTool.Both)
+ RegisterPowerShellGet();
+
+ if (effectiveTool is RepositoryRegistrationTool.PSResourceGet or RepositoryRegistrationTool.Both)
+ RegisterPSResourceGet();
+ }
+
+ result.UnavailableTools = unavailableTools
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .OrderBy(static toolName => toolName, StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+ result.Messages = messages
+ .Concat(failures)
+ .Where(static message => !string.IsNullOrWhiteSpace(message))
+ .Distinct(StringComparer.Ordinal)
+ .ToArray();
+
+ if (!result.PSResourceGetRegistered && !result.PowerShellGetRegistered)
+ {
+ var message = result.Messages.Length > 0
+ ? string.Join(" ", result.Messages)
+ : $"No repository registration path succeeded for '{endpoint.RepositoryName}'.";
+ throw new InvalidOperationException(message);
+ }
+
+ result.ToolUsed = result.PSResourceGetRegistered && result.PowerShellGetRegistered
+ ? RepositoryRegistrationTool.Both
+ : result.PSResourceGetRegistered
+ ? RepositoryRegistrationTool.PSResourceGet
+ : RepositoryRegistrationTool.PowerShellGet;
+
+ return result;
+ }
+
+ public BootstrapPrerequisiteInstallResult EnsureBootstrapPrerequisites(bool installPrerequisites, bool forceInstall = false)
+ {
+ var initialStatus = GetBootstrapPrerequisiteStatus();
+ if (!installPrerequisites)
+ return new BootstrapPrerequisiteInstallResult(Array.Empty(), Array.Empty(), initialStatus);
+
+ var installed = new List(2);
+ var messages = new List(4);
+ var runner = new PowerShellRunner();
+ var logger = new PrivateGalleryHostLogger(_host);
+
+ if (!initialStatus.PSResourceGetAvailable || !initialStatus.PSResourceGetMeetsMinimumVersion || forceInstall)
+ {
+ if (_host.ShouldProcess("Microsoft.PowerShell.PSResourceGet", "Install private-gallery prerequisite"))
+ {
+ var installer = new ModuleDependencyInstaller(runner, logger);
+ var results = installer.EnsureInstalled(
+ new[] { new ModuleDependency("Microsoft.PowerShell.PSResourceGet", minimumVersion: MinimumPSResourceGetVersion) },
+ force: forceInstall,
+ prerelease: false,
+ timeoutPerModule: TimeSpan.FromMinutes(10));
+
+ var result = results.FirstOrDefault();
+ if (result is null || result.Status == ModuleDependencyInstallStatus.Failed)
+ {
+ var failure = result?.Message ?? "PSResourceGet prerequisite installation did not return a result.";
+ throw new InvalidOperationException($"Failed to install PSResourceGet prerequisite. {failure}".Trim());
+ }
+
+ installed.Add("PSResourceGet");
+ var resolvedVersion = string.IsNullOrWhiteSpace(result.ResolvedVersion) ? "unknown version" : result.ResolvedVersion;
+ messages.Add($"PSResourceGet prerequisite handled via {result.Installer ?? "module installer"} ({result.Status}, resolved {resolvedVersion}).");
+ }
+ }
+
+ var statusAfterPsResourceGet = GetBootstrapPrerequisiteStatus();
+ if (installed.Contains("PSResourceGet", StringComparer.OrdinalIgnoreCase) &&
+ (!statusAfterPsResourceGet.PSResourceGetAvailable || !statusAfterPsResourceGet.PSResourceGetMeetsMinimumVersion))
+ {
+ throw new InvalidOperationException($"PSResourceGet prerequisite installation completed, but version {statusAfterPsResourceGet.PSResourceGetVersion ?? "unknown"} does not satisfy minimum {MinimumPSResourceGetVersion}.");
+ }
+
+ if (!statusAfterPsResourceGet.CredentialProviderDetection.IsDetected)
+ {
+ if (Path.DirectorySeparatorChar == '\\')
+ {
+ if (_host.ShouldProcess("Azure Artifacts Credential Provider", "Install private-gallery prerequisite"))
+ {
+ var installer = new AzureArtifactsCredentialProviderInstaller(runner, logger);
+ var result = installer.InstallForCurrentUser(includeNetFx: true, installNet8: true, force: forceInstall);
+ if (!result.Succeeded)
+ throw new InvalidOperationException("Azure Artifacts Credential Provider installation did not succeed.");
+
+ installed.Add("AzureArtifactsCredentialProvider");
+ messages.AddRange(result.Messages);
+
+ var statusAfterCredentialProvider = GetBootstrapPrerequisiteStatus();
+ if (!statusAfterCredentialProvider.CredentialProviderDetection.IsDetected)
+ {
+ throw new InvalidOperationException("Azure Artifacts Credential Provider installation completed, but the provider was still not detected afterwards.");
+ }
+ }
+ }
+ else
+ {
+ messages.Add("Automatic Azure Artifacts Credential Provider installation is currently supported on Windows only.");
+ }
+ }
+
+ var finalStatus = GetBootstrapPrerequisiteStatus();
+ return new BootstrapPrerequisiteInstallResult(
+ installed.Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(static item => item, StringComparer.OrdinalIgnoreCase).ToArray(),
+ messages.Where(static message => !string.IsNullOrWhiteSpace(message)).Distinct(StringComparer.Ordinal).ToArray(),
+ finalStatus);
+ }
+
+ public RepositoryAccessProbeResult ProbeRepositoryAccess(ModuleRepositoryRegistrationResult registration, RepositoryCredential? credential)
+ {
+ if (registration is null)
+ throw new ArgumentNullException(nameof(registration));
+
+ const string probeName = "__PowerForgePrivateGalleryConnectionProbe__";
+ var runner = new PowerShellRunner();
+ var logger = new PrivateGalleryHostLogger(_host);
+ var tool = PrivateGalleryVersionPolicy.SelectAccessProbeTool(registration, credential);
+
+ try
+ {
+ if (tool == "PSResourceGet")
+ {
+ var client = new PSResourceGetClient(runner, logger);
+ client.Find(new PSResourceFindOptions(new[] { probeName }, null, false, new[] { registration.RepositoryName }, credential), timeout: TimeSpan.FromMinutes(2));
+ }
+ else
+ {
+ var client = new PowerShellGetClient(runner, logger);
+ client.Find(new PowerShellGetFindOptions(new[] { probeName }, false, new[] { registration.RepositoryName }, credential), timeout: TimeSpan.FromMinutes(2));
+ }
+
+ return new RepositoryAccessProbeResult(true, tool, $"Repository access probe completed successfully via {tool}.");
+ }
+ catch (Exception ex)
+ {
+ return new RepositoryAccessProbeResult(false, tool, ex.Message);
+ }
+ }
+
+ public void WriteRegistrationSummary(ModuleRepositoryRegistrationResult result)
+ {
+ if (result is null)
+ return;
+
+ foreach (var message in result.Messages.Where(static message => !string.IsNullOrWhiteSpace(message)))
+ {
+ if (result.UnavailableTools.Length > 0 &&
+ result.UnavailableTools.Any(tool => message.IndexOf(tool, StringComparison.OrdinalIgnoreCase) >= 0))
+ {
+ _host.WriteWarning(message);
+ }
+ else
+ {
+ _host.WriteVerbose(message);
+ }
+ }
+
+ foreach (var message in result.ReadinessMessages.Where(static message => !string.IsNullOrWhiteSpace(message)))
+ _host.WriteVerbose(message);
+ foreach (var message in result.PrerequisiteInstallMessages.Where(static message => !string.IsNullOrWhiteSpace(message)))
+ _host.WriteVerbose(message);
+
+ var ready = result.ReadyCommands;
+ if (ready.Length > 0)
+ _host.WriteVerbose($"Repository '{result.RepositoryName}' is ready for {string.Join(", ", ready)}.");
+
+ _host.WriteVerbose($"Bootstrap readiness: ExistingSession={result.ExistingSessionBootstrapReady}; CredentialPrompt={result.CredentialPromptBootstrapReady}.");
+ if (!string.IsNullOrWhiteSpace(result.PSResourceGetVersion))
+ _host.WriteVerbose($"Detected PSResourceGet version: {result.PSResourceGetVersion} (meets minimum {MinimumPSResourceGetVersion}: {result.PSResourceGetMeetsMinimumVersion}; supports ExistingSession {MinimumPSResourceGetExistingSessionVersion}+: {result.PSResourceGetSupportsExistingSessionBootstrap}).");
+ if (!string.IsNullOrWhiteSpace(result.PowerShellGetVersion))
+ _host.WriteVerbose($"Detected PowerShellGet version: {result.PowerShellGetVersion}.");
+ if (!string.IsNullOrWhiteSpace(result.AzureArtifactsCredentialProviderVersion))
+ _host.WriteVerbose($"Detected Azure Artifacts Credential Provider version: {result.AzureArtifactsCredentialProviderVersion}.");
+
+ if (result.InstalledPrerequisites.Length > 0)
+ _host.WriteVerbose($"Installed prerequisites: {string.Join(", ", result.InstalledPrerequisites)}.");
+
+ if (result.AccessProbePerformed)
+ {
+ if (result.AccessProbeSucceeded)
+ _host.WriteVerbose(result.AccessProbeMessage ?? $"Repository access probe succeeded via {result.AccessProbeTool ?? "unknown"}.");
+ else if (!string.IsNullOrWhiteSpace(result.AccessProbeMessage))
+ _host.WriteWarning($"Repository access probe failed via {result.AccessProbeTool ?? "unknown"}: {result.AccessProbeMessage}");
+ }
+
+ _host.WriteVerbose($"Bootstrap mode used: {result.BootstrapModeUsed}; credential source: {result.CredentialSource}.");
+ _host.WriteVerbose($"Repository registration requested {result.ToolRequested}; successful path: {result.ToolUsed}.");
+
+ if (result.ToolRequested == RepositoryRegistrationTool.Auto &&
+ result.ToolUsed == RepositoryRegistrationTool.PowerShellGet)
+ {
+ _host.WriteVerbose("Auto registration fell back to PowerShellGet, so Install-Module is the current native path on this machine.");
+ }
+
+ if (result.BootstrapModeRequested == PrivateGalleryBootstrapMode.ExistingSession &&
+ !result.ExistingSessionBootstrapReady)
+ {
+ _host.WriteWarning($"ExistingSession bootstrap was requested, but Azure Artifacts ExistingSession support requires PSResourceGet {MinimumPSResourceGetExistingSessionVersion}+ and a detected Azure Artifacts Credential Provider.");
+ }
+
+ if (!string.IsNullOrWhiteSpace(result.RecommendedBootstrapCommand))
+ _host.WriteVerbose($"Bootstrap recommendation: {result.RecommendedBootstrapCommand}");
+ if (!string.IsNullOrWhiteSpace(result.RecommendedNativeInstallCommand))
+ _host.WriteVerbose($"Native install example: {result.RecommendedNativeInstallCommand}");
+ _host.WriteVerbose($"Wrapper install example: {result.RecommendedWrapperInstallCommand}");
+ }
+
+ public BootstrapPrerequisiteStatus GetBootstrapPrerequisiteStatus()
+ {
+ var runner = new PowerShellRunner();
+ var logger = new NullLogger();
+ var psResourceGet = new PSResourceGetClient(runner, logger);
+ var powerShellGet = new PowerShellGetClient(runner, logger);
+
+ var psResourceGetAvailability = psResourceGet.GetAvailability();
+ var powerShellGetAvailability = powerShellGet.GetAvailability();
+ var credentialProviderDetection = AzureArtifactsCredentialProviderLocator.Detect();
+ var psResourceGetMeetsMinimumVersion = PrivateGalleryVersionPolicy.VersionMeetsMinimum(psResourceGetAvailability.Version, MinimumPSResourceGetVersion);
+ var psResourceGetSupportsExistingSessionBootstrap = PrivateGalleryVersionPolicy.VersionMeetsMinimum(psResourceGetAvailability.Version, MinimumPSResourceGetExistingSessionVersion);
+
+ var readinessMessages = new List(6);
+ if (psResourceGetAvailability.Available)
+ {
+ if (psResourceGetMeetsMinimumVersion)
+ {
+ readinessMessages.Add($"PSResourceGet is available for private-gallery bootstrap (version {psResourceGetAvailability.Version ?? "unknown"}).");
+ if (!psResourceGetSupportsExistingSessionBootstrap)
+ readinessMessages.Add($"PSResourceGet version {psResourceGetAvailability.Version ?? "unknown"} supports credential-prompt installs, but Azure Artifacts ExistingSession bootstrap requires {MinimumPSResourceGetExistingSessionVersion} or newer.");
+ }
+ else
+ {
+ readinessMessages.Add($"PSResourceGet is installed, but version {psResourceGetAvailability.Version ?? "unknown"} is below the private-gallery minimum {MinimumPSResourceGetVersion}.");
+ }
+ }
+ else if (!string.IsNullOrWhiteSpace(psResourceGetAvailability.Message))
+ {
+ readinessMessages.Add(psResourceGetAvailability.Message!);
+ }
+
+ if (powerShellGetAvailability.Available)
+ readinessMessages.Add($"PowerShellGet is available for compatibility/fallback registration (version {powerShellGetAvailability.Version ?? "unknown"}).");
+ else if (!string.IsNullOrWhiteSpace(powerShellGetAvailability.Message))
+ readinessMessages.Add(powerShellGetAvailability.Message!);
+
+ if (credentialProviderDetection.IsDetected)
+ readinessMessages.Add($"Azure Artifacts Credential Provider detected ({credentialProviderDetection.Paths.Length} path(s), version {credentialProviderDetection.Version ?? "unknown"}).");
+ else
+ readinessMessages.Add("Azure Artifacts Credential Provider was not detected in NUGET_PLUGIN_PATHS, %UserProfile%\\.nuget\\plugins, or Visual Studio NuGet plugin locations.");
+
+ return new BootstrapPrerequisiteStatus(
+ psResourceGetAvailability.Available,
+ psResourceGetAvailability.Version,
+ psResourceGetMeetsMinimumVersion,
+ psResourceGetSupportsExistingSessionBootstrap,
+ psResourceGetAvailability.Message,
+ powerShellGetAvailability.Available,
+ powerShellGetAvailability.Version,
+ powerShellGetAvailability.Message,
+ credentialProviderDetection,
+ readinessMessages.ToArray());
+ }
+}
diff --git a/PSPublishModule/Services/PrivateGalleryCommandSupport.Versioning.cs b/PowerForge.PowerShell/Services/PrivateGalleryVersionPolicy.cs
similarity index 90%
rename from PSPublishModule/Services/PrivateGalleryCommandSupport.Versioning.cs
rename to PowerForge.PowerShell/Services/PrivateGalleryVersionPolicy.cs
index ebb143f9..6a26c883 100644
--- a/PSPublishModule/Services/PrivateGalleryCommandSupport.Versioning.cs
+++ b/PowerForge.PowerShell/Services/PrivateGalleryVersionPolicy.cs
@@ -1,19 +1,18 @@
using System;
using System.Linq;
-using PowerForge;
-namespace PSPublishModule;
+namespace PowerForge;
-internal static partial class PrivateGalleryCommandSupport
+internal static class PrivateGalleryVersionPolicy
{
- private static PrivateGalleryBootstrapMode GetRecommendedBootstrapMode(BootstrapPrerequisiteStatus status)
+ internal static PrivateGalleryBootstrapMode GetRecommendedBootstrapMode(BootstrapPrerequisiteStatus status)
=> IsExistingSessionBootstrapReady(status)
? PrivateGalleryBootstrapMode.ExistingSession
: IsCredentialPromptBootstrapReady(status)
? PrivateGalleryBootstrapMode.CredentialPrompt
: PrivateGalleryBootstrapMode.Auto;
- private static string BuildBootstrapUnavailableMessage(string repositoryName, BootstrapPrerequisiteStatus status)
+ internal static string BuildBootstrapUnavailableMessage(string repositoryName, BootstrapPrerequisiteStatus status)
{
var message = $"No supported private-gallery bootstrap path is ready for repository '{repositoryName}'.";
var reasons = status.ReadinessMessages
@@ -27,13 +26,13 @@ private static string BuildBootstrapUnavailableMessage(string repositoryName, Bo
return message;
}
- private static bool IsExistingSessionBootstrapReady(BootstrapPrerequisiteStatus status)
+ internal static bool IsExistingSessionBootstrapReady(BootstrapPrerequisiteStatus status)
=> status.PSResourceGetSupportsExistingSessionBootstrap && status.CredentialProviderDetection.IsDetected;
- private static bool IsCredentialPromptBootstrapReady(BootstrapPrerequisiteStatus status)
+ internal static bool IsCredentialPromptBootstrapReady(BootstrapPrerequisiteStatus status)
=> (status.PSResourceGetAvailable && status.PSResourceGetMeetsMinimumVersion) || status.PowerShellGetAvailable;
- private static string SelectAccessProbeTool(ModuleRepositoryRegistrationResult registration, RepositoryCredential? credential)
+ internal static string SelectAccessProbeTool(ModuleRepositoryRegistrationResult registration, RepositoryCredential? credential)
{
if (credential is null)
{
diff --git a/PowerForge.PowerShell/Services/PrivateModuleWorkflowService.cs b/PowerForge.PowerShell/Services/PrivateModuleWorkflowService.cs
new file mode 100644
index 00000000..b3442074
--- /dev/null
+++ b/PowerForge.PowerShell/Services/PrivateModuleWorkflowService.cs
@@ -0,0 +1,193 @@
+using System;
+using System.Collections.Generic;
+
+namespace PowerForge;
+
+internal sealed class PrivateModuleWorkflowService
+{
+ private readonly IPrivateGalleryHost _host;
+ private readonly PrivateGalleryService _privateGalleryService;
+ private readonly ILogger _logger;
+ private readonly Func> _dependencyExecutor;
+
+ public PrivateModuleWorkflowService(
+ IPrivateGalleryHost host,
+ PrivateGalleryService privateGalleryService,
+ ILogger logger,
+ Func>? dependencyExecutor = null)
+ {
+ _host = host ?? throw new ArgumentNullException(nameof(host));
+ _privateGalleryService = privateGalleryService ?? throw new ArgumentNullException(nameof(privateGalleryService));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _dependencyExecutor = dependencyExecutor ?? ExecuteDependencies;
+ }
+
+ public PrivateModuleWorkflowResult Execute(PrivateModuleWorkflowRequest request, Func shouldProcess)
+ {
+ if (request is null)
+ throw new ArgumentNullException(nameof(request));
+ if (shouldProcess is null)
+ throw new ArgumentNullException(nameof(shouldProcess));
+
+ var modules = _privateGalleryService.BuildDependencies(request.ModuleNames);
+ var repositoryName = request.RepositoryName;
+ RepositoryCredential? credential = null;
+ var preferPowerShellGet = false;
+ var useAzureArtifacts = request.UseAzureArtifacts;
+
+ if (useAzureArtifacts)
+ {
+ _privateGalleryService.EnsureProviderSupported(request.Provider);
+
+ var endpoint = AzureArtifactsRepositoryEndpoints.Create(
+ request.AzureDevOpsOrganization,
+ request.AzureDevOpsProject,
+ request.AzureArtifactsFeed,
+ request.RepositoryName);
+ var prerequisiteInstall = _privateGalleryService.EnsureBootstrapPrerequisites(request.InstallPrerequisites);
+ repositoryName = endpoint.RepositoryName;
+
+ var credentialResolution = _privateGalleryService.ResolveCredential(
+ repositoryName,
+ request.BootstrapMode,
+ request.CredentialUserName,
+ request.CredentialSecret,
+ request.CredentialSecretFilePath,
+ request.PromptForCredential,
+ prerequisiteInstall.Status,
+ !_host.IsWhatIfRequested);
+ credential = credentialResolution.Credential;
+
+ var registration = _privateGalleryService.EnsureAzureArtifactsRepositoryRegistered(
+ request.AzureDevOpsOrganization,
+ request.AzureDevOpsProject,
+ request.AzureArtifactsFeed,
+ request.RepositoryName,
+ request.Tool,
+ request.Trusted,
+ request.Priority,
+ request.BootstrapMode,
+ credentialResolution.BootstrapModeUsed,
+ credentialResolution.CredentialSource,
+ credential,
+ prerequisiteInstall.Status,
+ shouldProcessAction: GetRepositoryAction(request.Operation, request.Tool));
+ registration.InstalledPrerequisites = prerequisiteInstall.InstalledPrerequisites;
+ registration.PrerequisiteInstallMessages = prerequisiteInstall.Messages;
+
+ if (!registration.RegistrationPerformed)
+ {
+ _host.WriteWarning(GetSkippedRegistrationMessage(request.Operation, registration.RepositoryName));
+ return new PrivateModuleWorkflowResult
+ {
+ OperationPerformed = false,
+ RepositoryName = registration.RepositoryName,
+ DependencyResults = Array.Empty()
+ };
+ }
+
+ _privateGalleryService.WriteRegistrationSummary(registration);
+ _host.WriteVerbose($"Repository '{registration.RepositoryName}' is ready for {GetOperationNoun(request.Operation)}.");
+
+ if (credential is null &&
+ !registration.InstallPSResourceReady &&
+ !registration.InstallModuleReady)
+ {
+ var hint = string.IsNullOrWhiteSpace(registration.RecommendedBootstrapCommand)
+ ? string.Empty
+ : $" Recommended next step: {registration.RecommendedBootstrapCommand}";
+ throw new InvalidOperationException(
+ $"Repository '{registration.RepositoryName}' was registered, but no native {GetOperationNoun(request.Operation)} path is ready for bootstrap mode {registration.BootstrapModeUsed}.{hint}");
+ }
+
+ preferPowerShellGet = credential is null &&
+ string.Equals(registration.PreferredInstallCommand, "Install-Module", StringComparison.OrdinalIgnoreCase);
+ }
+ if (!shouldProcess(
+ $"{modules.Count} module(s) from repository '{repositoryName}'",
+ GetFinalAction(request.Operation, request.Force)))
+ {
+ return new PrivateModuleWorkflowResult
+ {
+ OperationPerformed = false,
+ RepositoryName = repositoryName,
+ DependencyResults = Array.Empty()
+ };
+ }
+
+ if (!useAzureArtifacts)
+ {
+ credential = _privateGalleryService.ResolveOptionalCredential(
+ repositoryName,
+ request.CredentialUserName,
+ request.CredentialSecret,
+ request.CredentialSecretFilePath,
+ request.PromptForCredential);
+ }
+
+ var results = _dependencyExecutor(new PrivateModuleDependencyExecutionRequest
+ {
+ Operation = request.Operation,
+ Modules = modules,
+ RepositoryName = repositoryName,
+ Credential = credential,
+ Prerelease = request.Prerelease,
+ Force = request.Force,
+ PreferPowerShellGet = preferPowerShellGet
+ });
+
+ return new PrivateModuleWorkflowResult
+ {
+ OperationPerformed = true,
+ RepositoryName = repositoryName,
+ DependencyResults = results
+ };
+ }
+
+ private IReadOnlyList ExecuteDependencies(PrivateModuleDependencyExecutionRequest request)
+ {
+ var installer = new ModuleDependencyInstaller(new PowerShellRunner(), _logger);
+ return request.Operation == PrivateModuleWorkflowOperation.Install
+ ? installer.EnsureInstalled(
+ request.Modules,
+ force: request.Force,
+ repository: request.RepositoryName,
+ credential: request.Credential,
+ prerelease: request.Prerelease,
+ preferPowerShellGet: request.PreferPowerShellGet,
+ timeoutPerModule: TimeSpan.FromMinutes(10))
+ : installer.EnsureUpdated(
+ request.Modules,
+ repository: request.RepositoryName,
+ credential: request.Credential,
+ prerelease: request.Prerelease,
+ preferPowerShellGet: request.PreferPowerShellGet,
+ timeoutPerModule: TimeSpan.FromMinutes(10));
+ }
+
+ private static string GetRepositoryAction(PrivateModuleWorkflowOperation operation, RepositoryRegistrationTool tool)
+ {
+ var verb = operation == PrivateModuleWorkflowOperation.Install ? "Register" : "Update";
+ return tool == RepositoryRegistrationTool.Auto
+ ? $"{verb} module repository using Auto (prefer PSResourceGet, fall back to PowerShellGet)"
+ : $"{verb} module repository using {tool}";
+ }
+
+ private static string GetSkippedRegistrationMessage(PrivateModuleWorkflowOperation operation, string repositoryName)
+ {
+ return operation == PrivateModuleWorkflowOperation.Install
+ ? $"Repository '{repositoryName}' was not registered because the operation was skipped. Module installation was not attempted."
+ : $"Repository '{repositoryName}' was not refreshed because the operation was skipped. Module update was not attempted.";
+ }
+
+ private static string GetOperationNoun(PrivateModuleWorkflowOperation operation)
+ => operation == PrivateModuleWorkflowOperation.Install ? "installation" : "update";
+
+ private static string GetFinalAction(PrivateModuleWorkflowOperation operation, bool force)
+ {
+ if (operation == PrivateModuleWorkflowOperation.Install)
+ return force ? "Install or reinstall private modules" : "Install private modules";
+
+ return "Update private modules";
+ }
+}
diff --git a/PowerForge.PowerShell/Services/ProjectCleanupDisplayService.cs b/PowerForge.PowerShell/Services/ProjectCleanupDisplayService.cs
new file mode 100644
index 00000000..d92f9777
--- /dev/null
+++ b/PowerForge.PowerShell/Services/ProjectCleanupDisplayService.cs
@@ -0,0 +1,115 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace PowerForge;
+
+internal sealed class ProjectCleanupDisplayService
+{
+ public IReadOnlyList CreateHeader(string projectPath)
+ {
+ return new[]
+ {
+ Line($"Processing project cleanup for: {projectPath}", ConsoleColor.Cyan)
+ };
+ }
+
+ public IReadOnlyList CreateNoMatchesLines(bool internalMode)
+ {
+ return new[]
+ {
+ internalMode
+ ? Line("No files or folders found matching the specified criteria.")
+ : Line("No files or folders found matching the specified criteria.", ConsoleColor.Yellow)
+ };
+ }
+
+ public IReadOnlyList CreateItemLines(ProjectCleanupItemResult item, int current, int total, bool internalMode)
+ {
+ if (item is null)
+ throw new ArgumentNullException(nameof(item));
+
+ if (internalMode)
+ {
+ return item.Status switch
+ {
+ ProjectCleanupStatus.WhatIf => new[] { Line($"Would remove: {item.RelativePath}") },
+ ProjectCleanupStatus.Removed => new[] { Line($"Removed: {item.RelativePath}") },
+ ProjectCleanupStatus.Failed => new[] { Warning($"Failed to remove: {item.RelativePath}") },
+ ProjectCleanupStatus.Error => new[] { Warning($"Failed to remove: {item.RelativePath}") },
+ _ => Array.Empty()
+ };
+ }
+
+ return item.Status switch
+ {
+ ProjectCleanupStatus.WhatIf => new[] { Line($" [WOULD REMOVE] {item.RelativePath}", ConsoleColor.Yellow) },
+ ProjectCleanupStatus.Removed => new[] { Line($" [{current}/{total}] [REMOVED] {item.RelativePath}", ConsoleColor.Red) },
+ ProjectCleanupStatus.Failed => new[] { Line($" [{current}/{total}] [FAILED] {item.RelativePath}", ConsoleColor.Red) },
+ ProjectCleanupStatus.Error => new[] { Line($" [{current}/{total}] [ERROR] {item.RelativePath}: {item.Error}", ConsoleColor.Red) },
+ _ => Array.Empty()
+ };
+ }
+
+ public IReadOnlyList CreateSummaryLines(ProjectCleanupOutput output, bool isWhatIf, bool internalMode)
+ {
+ if (output is null)
+ throw new ArgumentNullException(nameof(output));
+
+ var summary = output.Summary;
+ var totalSizeMb = Math.Round(output.Results.Where(r => r.Type == ProjectCleanupItemType.File).Sum(r => r.Size) / (1024d * 1024d), 2);
+ var lines = new List();
+
+ if (internalMode)
+ {
+ lines.Add(Line($"Cleanup Summary: Project path: {summary.ProjectPath}"));
+ lines.Add(Line($"Cleanup type: {summary.ProjectType}"));
+ lines.Add(Line($"Total items processed: {summary.TotalItems}"));
+
+ if (isWhatIf)
+ {
+ lines.Add(Line($"Would remove: {summary.TotalItems} items"));
+ lines.Add(Line($"Would free: {totalSizeMb} MB"));
+ }
+ else
+ {
+ lines.Add(Line($"Successfully removed: {summary.Removed}"));
+ lines.Add(Line($"Errors: {summary.Errors}"));
+ lines.Add(Line($"Space freed: {summary.SpaceFreedMB} MB"));
+ if (!string.IsNullOrWhiteSpace(summary.BackupDirectory))
+ lines.Add(Line($"Backups created in: {summary.BackupDirectory}"));
+ }
+
+ return lines;
+ }
+
+ lines.Add(Line(string.Empty, ConsoleColor.White));
+ lines.Add(Line("Cleanup Summary:", ConsoleColor.Cyan));
+ lines.Add(Line($" Project path: {summary.ProjectPath}", ConsoleColor.White));
+ lines.Add(Line($" Cleanup type: {summary.ProjectType}", ConsoleColor.White));
+ lines.Add(Line($" Total items processed: {summary.TotalItems}", ConsoleColor.White));
+
+ if (isWhatIf)
+ {
+ lines.Add(Line($" Would remove: {summary.TotalItems} items", ConsoleColor.Yellow));
+ lines.Add(Line($" Would free: {totalSizeMb} MB", ConsoleColor.Yellow));
+ lines.Add(Line("Run without -WhatIf to actually remove these items.", ConsoleColor.Cyan));
+ }
+ else
+ {
+ lines.Add(Line($" Successfully removed: {summary.Removed}", ConsoleColor.Green));
+ lines.Add(Line($" Errors: {summary.Errors}", ConsoleColor.Red));
+ lines.Add(Line($" Space freed: {summary.SpaceFreedMB} MB", ConsoleColor.Green));
+ if (!string.IsNullOrWhiteSpace(summary.BackupDirectory))
+ lines.Add(Line($" Backups created in: {summary.BackupDirectory}", ConsoleColor.Blue));
+ }
+
+ return lines;
+ }
+
+ private static ProjectCleanupDisplayLine Line(string text, ConsoleColor? color = null)
+ => new() { Text = text, Color = color };
+
+ private static ProjectCleanupDisplayLine Warning(string text)
+ => new() { Text = text, IsWarning = true };
+}
diff --git a/PowerForge.Tests/AboutTopicTemplateServiceTests.cs b/PowerForge.Tests/AboutTopicTemplateServiceTests.cs
new file mode 100644
index 00000000..3f0c7762
--- /dev/null
+++ b/PowerForge.Tests/AboutTopicTemplateServiceTests.cs
@@ -0,0 +1,75 @@
+using System;
+using System.IO;
+using Xunit;
+
+namespace PowerForge.Tests;
+
+public sealed class AboutTopicTemplateServiceTests
+{
+ [Fact]
+ public void Preview_NormalizesTopicAndResolvesRelativeOutputPath()
+ {
+ var root = CreateTempRoot();
+ try
+ {
+ var service = new AboutTopicTemplateService();
+ var result = service.Preview(new AboutTopicTemplateRequest
+ {
+ TopicName = "Troubleshooting Guide",
+ OutputPath = @".\Help\About",
+ WorkingDirectory = root
+ });
+
+ Assert.Equal("about_Troubleshooting_Guide", result.TopicName);
+ Assert.Equal(Path.Combine(root, "Help", "About"), result.OutputDirectory);
+ Assert.Equal(Path.Combine(root, "Help", "About", "about_Troubleshooting_Guide.help.txt"), result.FilePath);
+ Assert.False(result.Exists);
+ }
+ finally
+ {
+ TryDelete(root);
+ }
+ }
+
+ [Fact]
+ public void Generate_WritesMarkdownTemplateAndPreservesPreviewExistence()
+ {
+ var root = CreateTempRoot();
+ try
+ {
+ var service = new AboutTopicTemplateService();
+ var request = new AboutTopicTemplateRequest
+ {
+ TopicName = "about_Configuration",
+ OutputPath = "Help\\About",
+ ShortDescription = "Configuration guidance.",
+ Format = AboutTopicTemplateFormat.Markdown,
+ WorkingDirectory = root
+ };
+
+ var result = service.Generate(request);
+
+ Assert.False(result.Exists);
+ Assert.True(File.Exists(result.FilePath));
+ var content = File.ReadAllText(result.FilePath);
+ Assert.Contains("topic: about_Configuration", content, StringComparison.Ordinal);
+ Assert.Contains("Configuration guidance.", content, StringComparison.Ordinal);
+ }
+ finally
+ {
+ TryDelete(root);
+ }
+ }
+
+ private static string CreateTempRoot()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(root);
+ return root;
+ }
+
+ private static void TryDelete(string path)
+ {
+ try { Directory.Delete(path, recursive: true); } catch { }
+ }
+}
diff --git a/PowerForge.Tests/DotNetPublishConfigScaffoldServiceTests.cs b/PowerForge.Tests/DotNetPublishConfigScaffoldServiceTests.cs
new file mode 100644
index 00000000..952cbb1d
--- /dev/null
+++ b/PowerForge.Tests/DotNetPublishConfigScaffoldServiceTests.cs
@@ -0,0 +1,103 @@
+using System;
+using System.IO;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Xunit;
+
+namespace PowerForge.Tests;
+
+public sealed class DotNetPublishConfigScaffoldServiceTests
+{
+ private static readonly JsonSerializerOptions ReadOptions = CreateReadOptions();
+
+ [Fact]
+ public void ResolveOutputPath_UsesWorkingDirectoryAndProjectRoot()
+ {
+ var root = CreateTempRoot();
+ try
+ {
+ var service = new DotNetPublishConfigScaffoldService();
+ var path = service.ResolveOutputPath(new DotNetPublishConfigScaffoldRequest
+ {
+ WorkingDirectory = root,
+ ProjectRoot = ".\\src\\Repo",
+ OutputPath = ".\\Artifacts\\publish.json"
+ });
+
+ Assert.Equal(Path.Combine(root, "src", "Repo", "Artifacts", "publish.json"), path);
+ }
+ finally
+ {
+ TryDelete(root);
+ }
+ }
+
+ [Fact]
+ public void Generate_NormalizesInputsBeforeScaffolding()
+ {
+ var root = CreateTempRoot();
+ try
+ {
+ var projectPath = Path.Combine(root, "src", "App", "App.csproj");
+ Directory.CreateDirectory(Path.GetDirectoryName(projectPath)!);
+ File.WriteAllText(
+ projectPath,
+ "net10.0");
+
+ var service = new DotNetPublishConfigScaffoldService();
+ var result = service.Generate(
+ new DotNetPublishConfigScaffoldRequest
+ {
+ WorkingDirectory = root,
+ ProjectRoot = ".",
+ ProjectPath = " src\\App\\App.csproj ",
+ TargetName = " AppTarget ",
+ Framework = " net10.0 ",
+ Runtimes = ["win-x64", " win-x64 ", "linux-x64", ""],
+ Styles = [DotNetPublishStyle.PortableCompat, DotNetPublishStyle.PortableCompat, DotNetPublishStyle.AotSize],
+ Configuration = " ",
+ OutputPath = "Artifacts\\powerforge.dotnetpublish.json"
+ },
+ new NullLogger());
+
+ Assert.Equal(Path.Combine(root, "Artifacts", "powerforge.dotnetpublish.json"), result.ConfigPath);
+ Assert.Equal("AppTarget", result.TargetName);
+ Assert.Equal("net10.0", result.Framework);
+ Assert.Equal(["win-x64", "linux-x64"], result.Runtimes);
+ Assert.Equal([DotNetPublishStyle.PortableCompat, DotNetPublishStyle.AotSize], result.Styles);
+
+ var json = File.ReadAllText(result.ConfigPath);
+ var spec = JsonSerializer.Deserialize(json, ReadOptions);
+ Assert.NotNull(spec);
+ Assert.Equal("Release", spec!.DotNet.Configuration);
+ Assert.Equal(["win-x64", "linux-x64"], spec.DotNet.Runtimes);
+ Assert.Equal(["win-x64", "linux-x64"], spec.Targets[0].Publish.Runtimes);
+ }
+ finally
+ {
+ TryDelete(root);
+ }
+ }
+
+ private static string CreateTempRoot()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(root);
+ return root;
+ }
+
+ private static void TryDelete(string path)
+ {
+ try { Directory.Delete(path, recursive: true); } catch { }
+ }
+
+ private static JsonSerializerOptions CreateReadOptions()
+ {
+ var options = new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true
+ };
+ options.Converters.Add(new JsonStringEnumConverter());
+ return options;
+ }
+}
diff --git a/PowerForge.Tests/DotNetRepositoryReleaseDisplayServiceTests.cs b/PowerForge.Tests/DotNetRepositoryReleaseDisplayServiceTests.cs
new file mode 100644
index 00000000..b266e85e
--- /dev/null
+++ b/PowerForge.Tests/DotNetRepositoryReleaseDisplayServiceTests.cs
@@ -0,0 +1,71 @@
+using System;
+using Xunit;
+
+namespace PowerForge.Tests;
+
+public sealed class DotNetRepositoryReleaseDisplayServiceTests
+{
+ [Fact]
+ public void CreateDisplay_FormatsProjectRowsAndConditionalTotals()
+ {
+ var summary = new DotNetRepositoryReleaseSummary
+ {
+ Projects = new[]
+ {
+ new DotNetRepositoryReleaseProjectSummaryRow
+ {
+ ProjectName = "LibraryA",
+ IsPackable = true,
+ VersionDisplay = "1.0.0 -> 1.0.1",
+ PackageCount = 2,
+ Status = DotNetRepositoryReleaseProjectStatus.Ok,
+ ErrorPreview = string.Empty
+ },
+ new DotNetRepositoryReleaseProjectSummaryRow
+ {
+ ProjectName = "LibraryB",
+ IsPackable = false,
+ VersionDisplay = string.Empty,
+ PackageCount = 0,
+ Status = DotNetRepositoryReleaseProjectStatus.Skipped,
+ ErrorPreview = string.Empty
+ },
+ new DotNetRepositoryReleaseProjectSummaryRow
+ {
+ ProjectName = "LibraryC",
+ IsPackable = true,
+ VersionDisplay = "1.0.0 -> 1.0.1",
+ PackageCount = 1,
+ Status = DotNetRepositoryReleaseProjectStatus.Failed,
+ ErrorPreview = "signing failed"
+ }
+ },
+ Totals = new DotNetRepositoryReleaseSummaryTotals
+ {
+ ProjectCount = 3,
+ PackableCount = 2,
+ FailedProjectCount = 1,
+ PackageCount = 3,
+ PublishedPackageCount = 2,
+ SkippedDuplicatePackageCount = 1,
+ FailedPublishCount = 1,
+ ResolvedVersion = "1.0.1"
+ }
+ };
+
+ var display = new DotNetRepositoryReleaseDisplayService().CreateDisplay(summary, isPlan: false);
+
+ Assert.Equal("Summary", display.Title);
+ Assert.Equal("Yes", display.Projects[0].Packable);
+ Assert.Equal("Ok", display.Projects[0].StatusText);
+ Assert.Equal(ConsoleColor.Green, display.Projects[0].StatusColor);
+ Assert.Equal("Skipped", display.Projects[1].StatusText);
+ Assert.Equal(ConsoleColor.Gray, display.Projects[1].StatusColor);
+ Assert.Equal("Fail", display.Projects[2].StatusText);
+ Assert.Equal(ConsoleColor.Red, display.Projects[2].StatusColor);
+ Assert.Contains(display.Totals, row => row.Label == "Published" && row.Value == "2");
+ Assert.Contains(display.Totals, row => row.Label == "Skipped duplicates" && row.Value == "1");
+ Assert.Contains(display.Totals, row => row.Label == "Failed publishes" && row.Value == "1");
+ Assert.Contains(display.Totals, row => row.Label == "Resolved version" && row.Value == "1.0.1");
+ }
+}
diff --git a/PowerForge.Tests/DotNetRepositoryReleaseSummaryServiceTests.cs b/PowerForge.Tests/DotNetRepositoryReleaseSummaryServiceTests.cs
new file mode 100644
index 00000000..7823c5a1
--- /dev/null
+++ b/PowerForge.Tests/DotNetRepositoryReleaseSummaryServiceTests.cs
@@ -0,0 +1,58 @@
+using Xunit;
+
+namespace PowerForge.Tests;
+
+public sealed class DotNetRepositoryReleaseSummaryServiceTests
+{
+ [Fact]
+ public void CreateSummary_ComputesRowsAndTotals()
+ {
+ var result = new DotNetRepositoryReleaseResult
+ {
+ ResolvedVersion = "2.0.5"
+ };
+ result.Projects.Add(new DotNetRepositoryProjectResult
+ {
+ ProjectName = "LibraryA",
+ IsPackable = true,
+ OldVersion = "2.0.4",
+ NewVersion = "2.0.5"
+ });
+ result.Projects[0].Packages.Add("LibraryA.2.0.5.nupkg");
+ result.Projects.Add(new DotNetRepositoryProjectResult
+ {
+ ProjectName = "LibraryB",
+ IsPackable = false
+ });
+ result.Projects.Add(new DotNetRepositoryProjectResult
+ {
+ ProjectName = "LibraryC",
+ IsPackable = true,
+ ErrorMessage = "Package signing failed because the configured certificate was not found."
+ });
+ result.PublishedPackages.Add("LibraryA.2.0.5.nupkg");
+ result.SkippedDuplicatePackages.Add("LibraryOld.2.0.5.nupkg");
+ result.FailedPackages.Add("LibraryC.2.0.5.nupkg");
+
+ var summary = new DotNetRepositoryReleaseSummaryService().CreateSummary(result, maxErrorLength: 32);
+
+ Assert.Equal(3, summary.Projects.Count);
+ Assert.Equal("LibraryA", summary.Projects[0].ProjectName);
+ Assert.Equal(DotNetRepositoryReleaseProjectStatus.Ok, summary.Projects[0].Status);
+ Assert.Equal("2.0.4 -> 2.0.5", summary.Projects[0].VersionDisplay);
+ Assert.Equal("LibraryB", summary.Projects[1].ProjectName);
+ Assert.Equal(DotNetRepositoryReleaseProjectStatus.Skipped, summary.Projects[1].Status);
+ Assert.Equal("LibraryC", summary.Projects[2].ProjectName);
+ Assert.Equal(DotNetRepositoryReleaseProjectStatus.Failed, summary.Projects[2].Status);
+ Assert.Equal("Package signing failed becaus...", summary.Projects[2].ErrorPreview);
+
+ Assert.Equal(3, summary.Totals.ProjectCount);
+ Assert.Equal(2, summary.Totals.PackableCount);
+ Assert.Equal(1, summary.Totals.FailedProjectCount);
+ Assert.Equal(1, summary.Totals.PackageCount);
+ Assert.Equal(1, summary.Totals.PublishedPackageCount);
+ Assert.Equal(1, summary.Totals.SkippedDuplicatePackageCount);
+ Assert.Equal(1, summary.Totals.FailedPublishCount);
+ Assert.Equal("2.0.5", summary.Totals.ResolvedVersion);
+ }
+}
diff --git a/PowerForge.Tests/ModuleBuildOutcomeServiceTests.cs b/PowerForge.Tests/ModuleBuildOutcomeServiceTests.cs
new file mode 100644
index 00000000..96fe66cb
--- /dev/null
+++ b/PowerForge.Tests/ModuleBuildOutcomeServiceTests.cs
@@ -0,0 +1,87 @@
+using System;
+using Xunit;
+
+namespace PowerForge.Tests;
+
+public sealed class ModuleBuildOutcomeServiceTests
+{
+ [Fact]
+ public void Evaluate_ReturnsSuccessCompletionDetails()
+ {
+ var result = new ModuleBuildOutcomeService().Evaluate(
+ new ModuleBuildWorkflowResult
+ {
+ Succeeded = true
+ },
+ exitCodeMode: true,
+ jsonOnly: false,
+ useLegacy: false,
+ elapsed: TimeSpan.FromSeconds(2.5));
+
+ Assert.True(result.Succeeded);
+ Assert.True(result.ShouldSetExitCode);
+ Assert.Equal(0, result.ExitCode);
+ Assert.False(result.ShouldEmitErrorRecord);
+ Assert.False(result.ShouldReplayBufferedLogs);
+ Assert.False(result.ShouldWriteInteractiveFailureSummary);
+ Assert.Equal("Module build completed in 2s 500ms", result.CompletionMessage);
+ }
+
+ [Fact]
+ public void Evaluate_ReturnsFailureDecisionsForNonInteractiveNonPolicyFailure()
+ {
+ var workflow = new ModuleBuildWorkflowResult
+ {
+ Succeeded = false,
+ UsedInteractiveView = false,
+ Error = new InvalidOperationException("boom")
+ };
+
+ var result = new ModuleBuildOutcomeService().Evaluate(
+ workflow,
+ exitCodeMode: false,
+ jsonOnly: true,
+ useLegacy: true,
+ elapsed: TimeSpan.FromMinutes(1.0));
+
+ Assert.False(result.Succeeded);
+ Assert.False(result.ShouldSetExitCode);
+ Assert.True(result.ShouldEmitErrorRecord);
+ Assert.Equal("InvokeModuleBuildDslFailed", result.ErrorRecordId);
+ Assert.True(result.ShouldReplayBufferedLogs);
+ Assert.False(result.ShouldWriteInteractiveFailureSummary);
+ Assert.Equal("Pipeline config generation failed in 1m 0s", result.CompletionMessage);
+ }
+
+ [Fact]
+ public void Evaluate_SuppressesErrorRecordAndRequestsInteractiveSummary_WhenInteractiveFailureNeedsFollowup()
+ {
+ var workflow = new ModuleBuildWorkflowResult
+ {
+ Succeeded = false,
+ UsedInteractiveView = true,
+ WrotePolicySummary = false,
+ Plan = CreateUninitializedPlan(),
+ Error = new InvalidOperationException("boom")
+ };
+
+ var result = new ModuleBuildOutcomeService().Evaluate(
+ workflow,
+ exitCodeMode: false,
+ jsonOnly: false,
+ useLegacy: false,
+ elapsed: TimeSpan.FromMilliseconds(250));
+
+ Assert.False(result.ShouldEmitErrorRecord);
+ Assert.True(result.ShouldReplayBufferedLogs);
+ Assert.True(result.ShouldWriteInteractiveFailureSummary);
+ Assert.Equal("Module build failed in 250ms", result.CompletionMessage);
+ }
+
+ private static ModulePipelinePlan CreateUninitializedPlan()
+ {
+#pragma warning disable SYSLIB0050
+ return (ModulePipelinePlan)System.Runtime.Serialization.FormatterServices.GetUninitializedObject(typeof(ModulePipelinePlan));
+#pragma warning restore SYSLIB0050
+ }
+}
diff --git a/PowerForge.Tests/ModuleTestFailureDisplayServiceTests.cs b/PowerForge.Tests/ModuleTestFailureDisplayServiceTests.cs
new file mode 100644
index 00000000..ef7cfc26
--- /dev/null
+++ b/PowerForge.Tests/ModuleTestFailureDisplayServiceTests.cs
@@ -0,0 +1,66 @@
+using System;
+using Xunit;
+
+namespace PowerForge.Tests;
+
+public sealed class ModuleTestFailureDisplayServiceTests
+{
+ [Fact]
+ public void CreateSummary_IncludesFailedTestsAndStatistics()
+ {
+ var service = new ModuleTestFailureDisplayService();
+ var analysis = new ModuleTestFailureAnalysis
+ {
+ Source = "PesterResults",
+ Timestamp = DateTime.Now,
+ TotalCount = 3,
+ PassedCount = 2,
+ FailedCount = 1,
+ FailedTests = new[]
+ {
+ new ModuleTestFailureInfo
+ {
+ Name = "Broken.Test",
+ ErrorMessage = "boom"
+ }
+ }
+ };
+
+ var lines = service.CreateSummary(analysis, showSuccessful: false);
+
+ Assert.Contains(lines, line => line.Text == "=== Module Test Results Summary ===");
+ Assert.Contains(lines, line => line.Text == " Total Tests: 3");
+ Assert.Contains(lines, line => line.Text == " Failed: 1");
+ Assert.Contains(lines, line => line.Text == " - Broken.Test");
+ }
+
+ [Fact]
+ public void CreateDetailed_IncludesFailureBodyAndFooter()
+ {
+ var service = new ModuleTestFailureDisplayService();
+ var analysis = new ModuleTestFailureAnalysis
+ {
+ Source = "PesterResults",
+ Timestamp = DateTime.Now,
+ TotalCount = 1,
+ PassedCount = 0,
+ FailedCount = 1,
+ FailedTests = new[]
+ {
+ new ModuleTestFailureInfo
+ {
+ Name = "Broken.Test",
+ ErrorMessage = "line one\nline two",
+ Duration = TimeSpan.FromSeconds(1)
+ }
+ }
+ };
+
+ var lines = service.CreateDetailed(analysis);
+
+ Assert.Contains(lines, line => line.Text == "- Broken.Test");
+ Assert.Contains(lines, line => line.Text == " line one");
+ Assert.Contains(lines, line => line.Text == " line two");
+ Assert.Contains(lines, line => line.Text.StartsWith("=== Summary: 1 test failed ===", StringComparison.Ordinal));
+ }
+}
diff --git a/PowerForge.Tests/ModuleTestFailureWorkflowServiceTests.cs b/PowerForge.Tests/ModuleTestFailureWorkflowServiceTests.cs
new file mode 100644
index 00000000..faeb9905
--- /dev/null
+++ b/PowerForge.Tests/ModuleTestFailureWorkflowServiceTests.cs
@@ -0,0 +1,77 @@
+using System;
+using System.IO;
+using Xunit;
+
+namespace PowerForge.Tests;
+
+public sealed class ModuleTestFailureWorkflowServiceTests
+{
+ [Fact]
+ public void ResolvePath_ReturnsExistingCandidateWhenExplicitPathIsNotProvided()
+ {
+ var root = CreateTempPath();
+ try
+ {
+ var testsRoot = Path.Combine(root, "Tests");
+ Directory.CreateDirectory(testsRoot);
+ var resultsPath = Path.Combine(testsRoot, "TestResults.xml");
+ File.WriteAllText(resultsPath, "");
+
+ var service = new ModuleTestFailureWorkflowService();
+ var resolution = service.ResolvePath(new ModuleTestFailureWorkflowRequest
+ {
+ CurrentDirectory = root
+ });
+
+ Assert.Equal(root, resolution.ProjectPath);
+ Assert.Equal(resultsPath, resolution.ResultsPath);
+ Assert.False(resolution.ExplicitPathProvided);
+ Assert.Contains(resultsPath, resolution.SearchedPaths, StringComparer.OrdinalIgnoreCase);
+ }
+ finally
+ {
+ TryDelete(root);
+ }
+ }
+
+ [Fact]
+ public void Execute_ReturnsWarningMessagesWhenNoResultsFileIsFound()
+ {
+ var root = CreateTempPath();
+ try
+ {
+ var service = new ModuleTestFailureWorkflowService();
+ var result = service.Execute(new ModuleTestFailureWorkflowRequest
+ {
+ CurrentDirectory = root
+ });
+
+ Assert.Null(result.Analysis);
+ Assert.NotEmpty(result.WarningMessages);
+ Assert.Equal("No test results file found. Searched in:", result.WarningMessages[0]);
+ }
+ finally
+ {
+ TryDelete(root);
+ }
+ }
+
+ private static string CreateTempPath()
+ {
+ var path = Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(path);
+ return path;
+ }
+
+ private static void TryDelete(string path)
+ {
+ try
+ {
+ if (Directory.Exists(path))
+ Directory.Delete(path, recursive: true);
+ }
+ catch
+ {
+ }
+ }
+}
diff --git a/PowerForge.Tests/ModuleTestSuiteDisplayServiceTests.cs b/PowerForge.Tests/ModuleTestSuiteDisplayServiceTests.cs
new file mode 100644
index 00000000..bbb3589d
--- /dev/null
+++ b/PowerForge.Tests/ModuleTestSuiteDisplayServiceTests.cs
@@ -0,0 +1,91 @@
+using System;
+using Xunit;
+
+namespace PowerForge.Tests;
+
+public sealed class ModuleTestSuiteDisplayServiceTests
+{
+ [Fact]
+ public void CreateDependencySummary_IncludesRequiredAndNonSkippedAdditionalModules()
+ {
+ var service = new ModuleTestSuiteDisplayService();
+ var lines = service.CreateDependencySummary(
+ requiredModules:
+ [
+ new RequiredModuleReference("Pester", moduleVersion: "5.7.1")
+ ],
+ additionalModules: ["PSWriteColor", "Pester"],
+ skipModules: ["Pester"]);
+
+ Assert.Contains(lines, line => line.Text == "Step 3: Dependency summary...");
+ Assert.Contains(lines, line => line.Text == "Required modules:");
+ Assert.Contains(lines, line => line.Text == " 📦 Pester (Min: 5.7.1)");
+ Assert.Contains(lines, line => line.Text == "Additional modules:");
+ Assert.Contains(lines, line => line.Text == " ✅ PSWriteColor");
+ Assert.DoesNotContain(lines, line => line.Text == " ✅ Pester");
+ }
+
+ [Fact]
+ public void CreateCompletionSummary_ReflectsFailureState()
+ {
+ var result = new ModuleTestSuiteResult(
+ projectPath: @"C:\Repo",
+ testPath: @"C:\Repo\Tests",
+ moduleName: "MyModule",
+ moduleVersion: "1.0.0",
+ manifestPath: @"C:\Repo\MyModule.psd1",
+ requiredModules: Array.Empty(),
+ dependencyResults: Array.Empty(),
+ moduleImported: true,
+ exportedFunctionCount: null,
+ exportedCmdletCount: null,
+ exportedAliasCount: null,
+ pesterVersion: null,
+ totalCount: 10,
+ passedCount: 8,
+ failedCount: 2,
+ skippedCount: 0,
+ duration: TimeSpan.FromSeconds(12),
+ coveragePercent: null,
+ failureAnalysis: null,
+ exitCode: 1,
+ stdOut: string.Empty,
+ stdErr: string.Empty,
+ resultsXmlPath: null);
+
+ var lines = new ModuleTestSuiteDisplayService().CreateCompletionSummary(result);
+
+ Assert.Contains(lines, line => line.Text == "=== Test Suite Failed ===" && line.Color == ConsoleColor.Red);
+ Assert.Contains(lines, line => line.Text == "Tests: 8/10 passed" && line.Color == ConsoleColor.Yellow);
+ Assert.Contains(lines, line => line.Text == "Duration: 00:00:12" && line.Color == ConsoleColor.Green);
+ }
+
+ [Fact]
+ public void CreateFailureSummary_UsesDetailedFailureDisplayServiceOutput()
+ {
+ var analysis = new ModuleTestFailureAnalysis
+ {
+ Source = "TestResults.xml",
+ Timestamp = DateTime.Now,
+ TotalCount = 2,
+ PassedCount = 1,
+ FailedCount = 1,
+ SkippedCount = 0,
+ FailedTests =
+ [
+ new ModuleTestFailureInfo
+ {
+ Name = "It fails",
+ ErrorMessage = "Boom",
+ Duration = TimeSpan.FromSeconds(1)
+ }
+ ]
+ };
+
+ var lines = new ModuleTestSuiteDisplayService().CreateFailureSummary(analysis, detailed: true);
+
+ Assert.Contains(lines, line => line.Text == "=== Module Test Failure Analysis ===");
+ Assert.Contains(lines, line => line.Text == "- It fails");
+ Assert.Contains(lines, line => line.Text == " Boom");
+ }
+}
diff --git a/PowerForge.Tests/NuGetCertificateExportDisplayServiceTests.cs b/PowerForge.Tests/NuGetCertificateExportDisplayServiceTests.cs
new file mode 100644
index 00000000..e56c60eb
--- /dev/null
+++ b/PowerForge.Tests/NuGetCertificateExportDisplayServiceTests.cs
@@ -0,0 +1,43 @@
+using System;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using Xunit;
+
+namespace PowerForge.Tests;
+
+public sealed class NuGetCertificateExportDisplayServiceTests
+{
+ [Fact]
+ public void CreateSuccessSummary_FormatsRegistrationGuidanceAndCertificateDetails()
+ {
+ using var certificate = CreateCertificate("CN=NuGet Test");
+ var result = new NuGetCertificateExportResult
+ {
+ Success = true,
+ CertificatePath = @"C:\Temp\NuGetSigning.cer",
+ Certificate = certificate,
+ Subject = "CN=NuGet Test",
+ Issuer = "CN=NuGet Issuer",
+ Sha256 = "ABC123",
+ NotBefore = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
+ NotAfter = new DateTime(2027, 1, 1, 0, 0, 0, DateTimeKind.Utc)
+ };
+
+ var lines = new NuGetCertificateExportDisplayService().CreateSuccessSummary(result);
+
+ Assert.Contains(lines, line => line.Text == @"Certificate exported successfully to: C:\Temp\NuGetSigning.cer" && line.Color == ConsoleColor.Green);
+ Assert.Contains(lines, line => line.Text == "Next steps to register with NuGet.org:" && line.Color == ConsoleColor.Yellow);
+ Assert.Contains(lines, line => line.Text == @"4. Upload the file: C:\Temp\NuGetSigning.cer");
+ Assert.Contains(lines, line => line.Text == "Certificate details:" && line.Color == ConsoleColor.Cyan);
+ Assert.Contains(lines, line => line.Text == " Subject: CN=NuGet Test");
+ Assert.Contains(lines, line => line.Text == " Issuer: CN=NuGet Issuer");
+ Assert.Contains(lines, line => line.Text == " SHA256: ABC123");
+ }
+
+ private static X509Certificate2 CreateCertificate(string subject)
+ {
+ using var rsa = RSA.Create(2048);
+ var request = new CertificateRequest(subject, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+ return request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1));
+ }
+}
diff --git a/PowerForge.Tests/NuGetCertificateExportServiceTests.cs b/PowerForge.Tests/NuGetCertificateExportServiceTests.cs
new file mode 100644
index 00000000..35339e5d
--- /dev/null
+++ b/PowerForge.Tests/NuGetCertificateExportServiceTests.cs
@@ -0,0 +1,82 @@
+using System;
+using System.IO;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using Xunit;
+
+namespace PowerForge.Tests;
+
+public sealed class NuGetCertificateExportServiceTests
+{
+ [Fact]
+ public void Execute_ExportsCertificateUsingThumbprint()
+ {
+ using var certificate = CreateCertificate("CN=NuGet Test", includeCodeSigningEku: true);
+ var tempPath = Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(tempPath);
+
+ try
+ {
+ var service = new NuGetCertificateExportService(_ => new[] { certificate });
+
+ var result = service.Execute(new NuGetCertificateExportRequest
+ {
+ CertificateThumbprint = certificate.Thumbprint,
+ WorkingDirectory = tempPath
+ });
+
+ Assert.True(result.Success);
+ Assert.NotNull(result.CertificatePath);
+ Assert.True(File.Exists(result.CertificatePath));
+ Assert.True(result.HasCodeSigningEku);
+ Assert.Equal(certificate.Subject, result.Subject);
+ Assert.Equal(certificate.Issuer, result.Issuer);
+ Assert.Equal(certificate.Thumbprint, result.Certificate?.Thumbprint);
+ Assert.False(string.IsNullOrWhiteSpace(result.Sha256));
+ }
+ finally
+ {
+ TryDelete(tempPath);
+ }
+ }
+
+ [Fact]
+ public void Execute_ReturnsFailureWhenCertificateIsMissing()
+ {
+ var service = new NuGetCertificateExportService(_ => Array.Empty());
+
+ var result = service.Execute(new NuGetCertificateExportRequest
+ {
+ CertificateThumbprint = "ABC123",
+ StoreLocation = CertificateStoreLocation.CurrentUser
+ });
+
+ Assert.False(result.Success);
+ Assert.Contains("thumbprint 'ABC123'", result.Error, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static X509Certificate2 CreateCertificate(string subject, bool includeCodeSigningEku)
+ {
+ using var rsa = RSA.Create(2048);
+ var request = new CertificateRequest(subject, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+ if (includeCodeSigningEku)
+ {
+ var usages = new OidCollection { new("1.3.6.1.5.5.7.3.3") };
+ request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(usages, critical: false));
+ }
+
+ return request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1));
+ }
+
+ private static void TryDelete(string path)
+ {
+ try
+ {
+ if (Directory.Exists(path))
+ Directory.Delete(path, recursive: true);
+ }
+ catch
+ {
+ }
+ }
+}
diff --git a/PowerForge.Tests/PowerShellCompatibilityDisplayServiceTests.cs b/PowerForge.Tests/PowerShellCompatibilityDisplayServiceTests.cs
new file mode 100644
index 00000000..ae74bd27
--- /dev/null
+++ b/PowerForge.Tests/PowerShellCompatibilityDisplayServiceTests.cs
@@ -0,0 +1,93 @@
+using System;
+using System.Linq;
+using Xunit;
+
+namespace PowerForge.Tests;
+
+public sealed class PowerShellCompatibilityDisplayServiceTests
+{
+ [Fact]
+ public void CreateSummary_FormatsStatusAndRecommendations()
+ {
+ var service = new PowerShellCompatibilityDisplayService();
+ var summary = new PowerShellCompatibilitySummary(
+ CheckStatus.Warning,
+ DateTime.Now,
+ totalFiles: 3,
+ powerShell51Compatible: 2,
+ powerShell7Compatible: 2,
+ crossCompatible: 1,
+ filesWithIssues: 2,
+ crossCompatibilityPercentage: 33.3,
+ message: "Compatibility issues found.",
+ recommendations: new[] { "Use cross-platform APIs." });
+
+ var lines = service.CreateSummary(summary);
+
+ Assert.Contains(lines, line => line.Text.Contains("Status: Warning", StringComparison.Ordinal));
+ Assert.Contains(lines, line => line.Text == "Recommendations:");
+ Assert.Contains(lines, line => line.Text == "- Use cross-platform APIs.");
+ }
+
+ [Fact]
+ public void CreateDetails_FormatsPerFileIssues()
+ {
+ var service = new PowerShellCompatibilityDisplayService();
+ var results = new[]
+ {
+ new PowerShellCompatibilityFileResult(
+ fullPath: @"C:\Repo\a.ps1",
+ relativePath: "a.ps1",
+ powerShell51Compatible: false,
+ powerShell7Compatible: true,
+ encoding: null,
+ issues: new[]
+ {
+ new PowerShellCompatibilityIssue(
+ PowerShellCompatibilityIssueType.PowerShell7Feature,
+ "using namespace requires PS 7+.",
+ "Avoid using namespace.",
+ PowerShellCompatibilitySeverity.Medium)
+ })
+ };
+
+ var lines = service.CreateDetails(results);
+
+ Assert.Contains(lines, line => line.Text == "Detailed Analysis:");
+ Assert.Contains(lines, line => line.Text == "a.ps1");
+ Assert.Contains(lines, line => line.Text.Contains("PowerShell7Feature", StringComparison.Ordinal));
+ Assert.Contains(lines, line => line.Text.Contains("Avoid using namespace.", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void CreateInternalSummaryMessages_IncludesExportOutcomeAndIssueTypes()
+ {
+ var service = new PowerShellCompatibilityDisplayService();
+ var report = new PowerShellCompatibilityReport(
+ new PowerShellCompatibilitySummary(CheckStatus.Pass, DateTime.Now, 1, 1, 1, 1, 1, 100, "ok", Array.Empty()),
+ new[]
+ {
+ new PowerShellCompatibilityFileResult(
+ fullPath: @"C:\Repo\a.ps1",
+ relativePath: "a.ps1",
+ powerShell51Compatible: false,
+ powerShell7Compatible: true,
+ encoding: null,
+ issues: new[]
+ {
+ new PowerShellCompatibilityIssue(
+ PowerShellCompatibilityIssueType.PowerShell7Feature,
+ "using namespace requires PS 7+.",
+ "Avoid using namespace.",
+ PowerShellCompatibilitySeverity.Medium)
+ })
+ },
+ exportPath: @"C:\Repo\compat.csv");
+
+ var messages = service.CreateInternalSummaryMessages(report, showDetails: true, exportPath: @"C:\Repo\compat.csv");
+
+ Assert.Contains(messages, message => message.Contains("Found 1 PowerShell files", StringComparison.Ordinal));
+ Assert.Contains(messages, message => message.Contains("Issues in a.ps1", StringComparison.Ordinal));
+ Assert.Contains(messages, message => message.Contains("Detailed report exported to", StringComparison.Ordinal));
+ }
+}
diff --git a/PowerForge.Tests/PowerShellCompatibilityWorkflowServiceTests.cs b/PowerForge.Tests/PowerShellCompatibilityWorkflowServiceTests.cs
new file mode 100644
index 00000000..4712df6d
--- /dev/null
+++ b/PowerForge.Tests/PowerShellCompatibilityWorkflowServiceTests.cs
@@ -0,0 +1,40 @@
+using System;
+using System.IO;
+using Xunit;
+
+namespace PowerForge.Tests;
+
+public sealed class PowerShellCompatibilityWorkflowServiceTests
+{
+ [Fact]
+ public void Execute_ReturnsReportAndEmitsProgress()
+ {
+ var root = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "PowerForge.Tests", Guid.NewGuid().ToString("N")));
+ try
+ {
+ File.WriteAllText(Path.Combine(root.FullName, "a.ps1"), "using namespace System.Text\n");
+ PowerShellCompatibilityProgress? captured = null;
+ var service = new PowerShellCompatibilityWorkflowService(new PowerShellCompatibilityAnalyzer(new NullLogger()));
+
+ var result = service.Execute(
+ new PowerShellCompatibilityWorkflowRequest
+ {
+ InputPath = root.FullName,
+ Recurse = false,
+ ExcludeDirectories = Array.Empty()
+ },
+ progress => captured = progress);
+
+ Assert.NotNull(result.Report);
+ Assert.Single(result.Report.Files);
+ Assert.NotNull(captured);
+ Assert.Equal(1, captured!.Current);
+ Assert.Equal(1, captured.Total);
+ }
+ finally
+ {
+ try { root.Delete(recursive: true); } catch { }
+ }
+ }
+}
+
diff --git a/PowerForge.Tests/PrivateGalleryPrerequisiteVersionPolicyTests.cs b/PowerForge.Tests/PrivateGalleryPrerequisiteVersionPolicyTests.cs
index 448eedca..7fb2e51d 100644
--- a/PowerForge.Tests/PrivateGalleryPrerequisiteVersionPolicyTests.cs
+++ b/PowerForge.Tests/PrivateGalleryPrerequisiteVersionPolicyTests.cs
@@ -17,7 +17,7 @@ public sealed class PrivateGalleryPrerequisiteVersionPolicyTests
[InlineData("", "1.1.1", false)]
public void VersionMeetsMinimum_EvaluatesExpectedValues(string versionText, string minimumVersion, bool expected)
{
- var actual = PSPublishModule.PrivateGalleryCommandSupport.VersionMeetsMinimum(versionText, minimumVersion);
+ var actual = PrivateGalleryVersionPolicy.VersionMeetsMinimum(versionText, minimumVersion);
Assert.Equal(expected, actual);
}
diff --git a/PowerForge.Tests/PrivateGalleryServiceTests.cs b/PowerForge.Tests/PrivateGalleryServiceTests.cs
new file mode 100644
index 00000000..7d40b8ae
--- /dev/null
+++ b/PowerForge.Tests/PrivateGalleryServiceTests.cs
@@ -0,0 +1,41 @@
+using System;
+using Xunit;
+
+namespace PowerForge.Tests;
+
+public sealed class PrivateGalleryServiceTests
+{
+ [Fact]
+ public void ResolveCredential_ValidatesMissingUserBeforeReadingSecretFile()
+ {
+ var service = new PrivateGalleryService(new FakePrivateGalleryHost());
+
+ var ex = Assert.Throws(() => service.ResolveCredential(
+ repositoryName: "Company",
+ bootstrapMode: PrivateGalleryBootstrapMode.CredentialPrompt,
+ credentialUserName: null,
+ credentialSecret: null,
+ credentialSecretFilePath: "missing-secret.txt",
+ promptForCredential: false));
+
+ Assert.Contains("CredentialUserName is required", ex.Message, StringComparison.OrdinalIgnoreCase);
+ Assert.Equal("credentialUserName", ex.ParamName);
+ }
+
+ private sealed class FakePrivateGalleryHost : IPrivateGalleryHost
+ {
+ public bool ShouldProcess(string target, string action) => true;
+
+ public bool IsWhatIfRequested => false;
+
+ public RepositoryCredential? PromptForCredential(string caption, string message) => null;
+
+ public void WriteVerbose(string message)
+ {
+ }
+
+ public void WriteWarning(string message)
+ {
+ }
+ }
+}
diff --git a/PowerForge.Tests/PrivateModuleWorkflowServiceTests.cs b/PowerForge.Tests/PrivateModuleWorkflowServiceTests.cs
new file mode 100644
index 00000000..9c2baca8
--- /dev/null
+++ b/PowerForge.Tests/PrivateModuleWorkflowServiceTests.cs
@@ -0,0 +1,150 @@
+using System;
+using System.Collections.Generic;
+using Xunit;
+
+namespace PowerForge.Tests;
+
+public sealed class PrivateModuleWorkflowServiceTests
+{
+ [Fact]
+ public void Execute_InstallRepositoryMode_UsesDependencyExecutorWithExpectedRequest()
+ {
+ var host = new FakePrivateGalleryHost();
+ var galleryService = new PrivateGalleryService(host);
+ PrivateModuleDependencyExecutionRequest? capturedRequest = null;
+ string? capturedTarget = null;
+ string? capturedAction = null;
+ var expected = new[]
+ {
+ new ModuleDependencyInstallResult("ModuleA", null, "1.0.0", null, ModuleDependencyInstallStatus.Installed, "PSResourceGet", null)
+ };
+
+ var service = new PrivateModuleWorkflowService(
+ host,
+ galleryService,
+ new NullLogger(),
+ request =>
+ {
+ capturedRequest = request;
+ return expected;
+ });
+
+ var result = service.Execute(
+ new PrivateModuleWorkflowRequest
+ {
+ Operation = PrivateModuleWorkflowOperation.Install,
+ ModuleNames = new[] { "ModuleA", "ModuleA", "ModuleB" },
+ UseAzureArtifacts = false,
+ RepositoryName = "Company",
+ Prerelease = true,
+ Force = true
+ },
+ (target, action) =>
+ {
+ capturedTarget = target;
+ capturedAction = action;
+ return true;
+ });
+
+ Assert.True(result.OperationPerformed);
+ Assert.Equal("Company", result.RepositoryName);
+ Assert.Same(expected, result.DependencyResults);
+ Assert.NotNull(capturedRequest);
+ Assert.Equal(PrivateModuleWorkflowOperation.Install, capturedRequest!.Operation);
+ Assert.Equal("Company", capturedRequest.RepositoryName);
+ Assert.True(capturedRequest.Prerelease);
+ Assert.True(capturedRequest.Force);
+ Assert.False(capturedRequest.PreferPowerShellGet);
+ Assert.Null(capturedRequest.Credential);
+ Assert.Collection(
+ capturedRequest.Modules,
+ first => Assert.Equal("ModuleA", first.Name),
+ second => Assert.Equal("ModuleB", second.Name));
+ Assert.Equal("2 module(s) from repository 'Company'", capturedTarget);
+ Assert.Equal("Install or reinstall private modules", capturedAction);
+ }
+
+ [Fact]
+ public void Execute_UpdateRepositoryMode_SkipsDependencyExecutionWhenShouldProcessDeclines()
+ {
+ var host = new FakePrivateGalleryHost();
+ var galleryService = new PrivateGalleryService(host);
+ var executorCalled = false;
+
+ var service = new PrivateModuleWorkflowService(
+ host,
+ galleryService,
+ new NullLogger(),
+ _ =>
+ {
+ executorCalled = true;
+ return Array.Empty();
+ });
+
+ var result = service.Execute(
+ new PrivateModuleWorkflowRequest
+ {
+ Operation = PrivateModuleWorkflowOperation.Update,
+ ModuleNames = new[] { "ModuleA" },
+ UseAzureArtifacts = false,
+ RepositoryName = "Company"
+ },
+ (_, _) => false);
+
+ Assert.False(result.OperationPerformed);
+ Assert.Equal("Company", result.RepositoryName);
+ Assert.Empty(result.DependencyResults);
+ Assert.False(executorCalled);
+ }
+
+ [Fact]
+ public void Execute_RepositoryMode_DoesNotResolveOptionalCredentialsWhenShouldProcessDeclines()
+ {
+ var host = new FakePrivateGalleryHost();
+ var galleryService = new PrivateGalleryService(host);
+ var executorCalled = false;
+
+ var service = new PrivateModuleWorkflowService(
+ host,
+ galleryService,
+ new NullLogger(),
+ _ =>
+ {
+ executorCalled = true;
+ return Array.Empty();
+ });
+
+ var result = service.Execute(
+ new PrivateModuleWorkflowRequest
+ {
+ Operation = PrivateModuleWorkflowOperation.Install,
+ ModuleNames = new[] { "ModuleA" },
+ UseAzureArtifacts = false,
+ RepositoryName = "Company",
+ CredentialSecretFilePath = "missing-secret.txt"
+ },
+ (_, _) => false);
+
+ Assert.False(result.OperationPerformed);
+ Assert.Equal("Company", result.RepositoryName);
+ Assert.Empty(result.DependencyResults);
+ Assert.False(executorCalled);
+ }
+
+ private sealed class FakePrivateGalleryHost : IPrivateGalleryHost
+ {
+ public bool ShouldProcess(string target, string action) => true;
+
+ public bool IsWhatIfRequested => false;
+
+ public RepositoryCredential? PromptForCredential(string caption, string message) => null;
+
+ public void WriteVerbose(string message)
+ {
+ }
+
+ public void WriteWarning(string message)
+ {
+ }
+ }
+}
diff --git a/PowerForge.Tests/ProjectBuildGitHubDisplayServiceTests.cs b/PowerForge.Tests/ProjectBuildGitHubDisplayServiceTests.cs
new file mode 100644
index 00000000..1b97db62
--- /dev/null
+++ b/PowerForge.Tests/ProjectBuildGitHubDisplayServiceTests.cs
@@ -0,0 +1,44 @@
+using Xunit;
+
+namespace PowerForge.Tests;
+
+public sealed class ProjectBuildGitHubDisplayServiceTests
+{
+ [Fact]
+ public void CreateSummary_FormatsSingleReleaseSummary()
+ {
+ var summary = new ProjectBuildGitHubPublishSummary
+ {
+ PerProject = false,
+ SummaryTag = "v1.2.3",
+ SummaryReleaseUrl = "https://example.test/release/v1.2.3",
+ SummaryAssetsCount = 4
+ };
+
+ var display = new ProjectBuildGitHubDisplayService().CreateSummary(summary);
+
+ Assert.Equal("GitHub Summary", display.Title);
+ Assert.Contains(display.Rows, row => row.Label == "Mode" && row.Value == "Single");
+ Assert.Contains(display.Rows, row => row.Label == "Tag" && row.Value == "v1.2.3");
+ Assert.Contains(display.Rows, row => row.Label == "Assets" && row.Value == "4");
+ Assert.Contains(display.Rows, row => row.Label == "Release" && row.Value == "https://example.test/release/v1.2.3");
+ }
+
+ [Fact]
+ public void CreateSummary_FormatsPerProjectSummary()
+ {
+ var summary = new ProjectBuildGitHubPublishSummary
+ {
+ PerProject = true
+ };
+ summary.Results.Add(new ProjectBuildGitHubResult { ProjectName = "A", Success = true });
+ summary.Results.Add(new ProjectBuildGitHubResult { ProjectName = "B", Success = false });
+
+ var display = new ProjectBuildGitHubDisplayService().CreateSummary(summary);
+
+ Assert.Contains(display.Rows, row => row.Label == "Mode" && row.Value == "PerProject");
+ Assert.Contains(display.Rows, row => row.Label == "Projects" && row.Value == "2");
+ Assert.Contains(display.Rows, row => row.Label == "Succeeded" && row.Value == "1");
+ Assert.Contains(display.Rows, row => row.Label == "Failed" && row.Value == "1");
+ }
+}
diff --git a/PowerForge.Tests/ProjectCleanupDisplayServiceTests.cs b/PowerForge.Tests/ProjectCleanupDisplayServiceTests.cs
new file mode 100644
index 00000000..349df9a5
--- /dev/null
+++ b/PowerForge.Tests/ProjectCleanupDisplayServiceTests.cs
@@ -0,0 +1,77 @@
+using Xunit;
+
+namespace PowerForge.Tests;
+
+public sealed class ProjectCleanupDisplayServiceTests
+{
+ [Fact]
+ public void CreateItemLines_ReturnsInternalWarningForFailure()
+ {
+ var service = new ProjectCleanupDisplayService();
+ var lines = service.CreateItemLines(
+ new ProjectCleanupItemResult
+ {
+ RelativePath = "bin\\App.dll",
+ Status = ProjectCleanupStatus.Failed
+ },
+ current: 1,
+ total: 3,
+ internalMode: true);
+
+ var line = Assert.Single(lines);
+ Assert.Equal("Failed to remove: bin\\App.dll", line.Text);
+ Assert.True(line.IsWarning);
+ }
+
+ [Fact]
+ public void CreateSummaryLines_UsesWhatIfSizeForHostOutput()
+ {
+ var output = new ProjectCleanupOutput
+ {
+ Summary = new ProjectCleanupSummary
+ {
+ ProjectPath = "C:\\Repo",
+ ProjectType = "Build",
+ TotalItems = 2
+ },
+ Results =
+ [
+ new ProjectCleanupItemResult { Type = ProjectCleanupItemType.File, Size = 1048576 },
+ new ProjectCleanupItemResult { Type = ProjectCleanupItemType.File, Size = 524288 }
+ ]
+ };
+
+ var lines = new ProjectCleanupDisplayService().CreateSummaryLines(output, isWhatIf: true, internalMode: false);
+
+ Assert.Contains(lines, line => line.Text == "Cleanup Summary:");
+ Assert.Contains(lines, line => line.Text == " Would remove: 2 items");
+ Assert.Contains(lines, line => line.Text == " Would free: 1.5 MB");
+ Assert.Contains(lines, line => line.Text == "Run without -WhatIf to actually remove these items.");
+ }
+
+ [Fact]
+ public void CreateSummaryLines_IncludesBackupLineForInternalOutput()
+ {
+ var output = new ProjectCleanupOutput
+ {
+ Summary = new ProjectCleanupSummary
+ {
+ ProjectPath = "C:\\Repo",
+ ProjectType = "Custom",
+ TotalItems = 3,
+ Removed = 2,
+ Errors = 1,
+ SpaceFreedMB = 12.25,
+ BackupDirectory = "C:\\Backups"
+ }
+ };
+
+ var lines = new ProjectCleanupDisplayService().CreateSummaryLines(output, isWhatIf: false, internalMode: true);
+
+ Assert.Contains(lines, line => line.Text == "Cleanup Summary: Project path: C:\\Repo");
+ Assert.Contains(lines, line => line.Text == "Successfully removed: 2");
+ Assert.Contains(lines, line => line.Text == "Errors: 1");
+ Assert.Contains(lines, line => line.Text == "Space freed: 12.25 MB");
+ Assert.Contains(lines, line => line.Text == "Backups created in: C:\\Backups");
+ }
+}
diff --git a/PowerForge.Tests/ProjectConsistencyDisplayServiceTests.cs b/PowerForge.Tests/ProjectConsistencyDisplayServiceTests.cs
new file mode 100644
index 00000000..38f217a0
--- /dev/null
+++ b/PowerForge.Tests/ProjectConsistencyDisplayServiceTests.cs
@@ -0,0 +1,164 @@
+using System;
+using System.IO;
+using Xunit;
+
+namespace PowerForge.Tests;
+
+public sealed class ProjectConsistencyDisplayServiceTests
+{
+ [Fact]
+ public void CreateAnalysisSummary_FormatsIssueBreakdownAndExportDetails()
+ {
+ var report = CreateReport(
+ filesCompliant: 7,
+ filesWithIssues: 3,
+ compliancePercentage: 70.0,
+ filesNeedingEncodingConversion: 2,
+ filesNeedingLineEndingConversion: 1,
+ filesWithMixedLineEndings: 1,
+ filesMissingFinalNewline: 1,
+ extensionIssues: new[]
+ {
+ new ProjectConsistencyExtensionIssue(".ps1", 2, 1, 1, 0, 1),
+ new ProjectConsistencyExtensionIssue(".cs", 1, 1, 0, 1, 0)
+ });
+ var exportPath = Path.Combine(Path.GetTempPath(), $"consistency-{Guid.NewGuid():N}.csv");
+ File.WriteAllText(exportPath, "report");
+
+ try
+ {
+ var lines = new ProjectConsistencyDisplayService().CreateAnalysisSummary(
+ rootPath: @"C:\Repo",
+ projectType: "Mixed",
+ report: report,
+ exportPath: exportPath);
+
+ Assert.Contains(lines, line => line.Text == "Analyzing project consistency..." && line.Color == ConsoleColor.Cyan);
+ Assert.Contains(lines, line => line.Text == "Project: C:\\Repo");
+ Assert.Contains(lines, line => line.Text == "Type: Mixed");
+ Assert.Contains(lines, line => line.Text == " Total files analyzed: 10");
+ Assert.Contains(lines, line => line.Text == " Files compliant with standards: 7 (70.0%)" && line.Color == ConsoleColor.Yellow);
+ Assert.Contains(lines, line => line.Text == " Files needing attention: 3" && line.Color == ConsoleColor.Red);
+ Assert.Contains(lines, line => line.Text == " Files needing encoding conversion: 2" && line.Color == ConsoleColor.Yellow);
+ Assert.Contains(lines, line => line.Text == " Files with mixed line endings: 1" && line.Color == ConsoleColor.Red);
+ Assert.Contains(lines, line => line.Text == "Extensions with Issues:" && line.Color == ConsoleColor.Yellow);
+ Assert.Contains(lines, line => line.Text == " .ps1: 2 files");
+ Assert.Contains(lines, line => line.Text == $"Detailed report exported to: {exportPath}" && line.Color == ConsoleColor.Green);
+ }
+ finally
+ {
+ File.Delete(exportPath);
+ }
+ }
+
+ [Fact]
+ public void CreateSummary_FormatsConversionAndComplianceDetails()
+ {
+ var report = CreateReport(filesCompliant: 9, filesWithIssues: 1, compliancePercentage: 90.0);
+ var encoding = new ProjectConversionResult(4, 3, 1, 0, Array.Empty());
+ var lineEndings = new ProjectConversionResult(2, 1, 0, 1, Array.Empty());
+
+ var lines = new ProjectConsistencyDisplayService().CreateSummary(
+ rootPath: @"C:\Repo",
+ report: report,
+ encodingResult: encoding,
+ lineEndingResult: lineEndings,
+ exportPath: null);
+
+ Assert.Contains(lines, line => line.Text == "Project Consistency Conversion" && line.Color == ConsoleColor.Cyan);
+ Assert.Contains(lines, line => line.Text == "Project: C:\\Repo");
+ Assert.Contains(lines, line => line.Text == "Encoding conversion: 3/4 converted, 1 skipped, 0 errors" && line.Color == ConsoleColor.Green);
+ Assert.Contains(lines, line => line.Text == "Line ending conversion: 1/2 converted, 0 skipped, 1 errors" && line.Color == ConsoleColor.Red);
+ Assert.Contains(lines, line => line.Text == " Files compliant: 9 (90.0%)" && line.Color == ConsoleColor.Green);
+ Assert.Contains(lines, line => line.Text == " Files needing attention: 1" && line.Color == ConsoleColor.Red);
+ }
+
+ [Fact]
+ public void CreateSummary_UsesYellowComplianceBand()
+ {
+ var report = CreateReport(filesCompliant: 7, filesWithIssues: 3, compliancePercentage: 75.0);
+
+ var lines = new ProjectConsistencyDisplayService().CreateSummary(
+ rootPath: @"C:\Repo",
+ report: report,
+ encodingResult: null,
+ lineEndingResult: null,
+ exportPath: null);
+
+ Assert.Contains(lines, line => line.Text == " Files compliant: 7 (75.0%)" && line.Color == ConsoleColor.Yellow);
+ }
+
+ private static ProjectConsistencyReport CreateReport(
+ int filesCompliant,
+ int filesWithIssues,
+ double compliancePercentage,
+ int filesNeedingEncodingConversion = 0,
+ int filesNeedingLineEndingConversion = 0,
+ int filesWithMixedLineEndings = 0,
+ int filesMissingFinalNewline = 0,
+ ProjectConsistencyExtensionIssue[]? extensionIssues = null)
+ {
+ var summary = new ProjectConsistencySummary(
+ projectPath: @"C:\Repo",
+ projectType: "Mixed",
+ kind: ProjectKind.Mixed,
+ analysisDate: DateTime.Now,
+ totalFiles: filesCompliant + filesWithIssues,
+ filesCompliant: filesCompliant,
+ filesWithIssues: filesWithIssues,
+ compliancePercentage: compliancePercentage,
+ currentEncodingDistribution: Array.Empty(),
+ filesNeedingEncodingConversion: filesNeedingEncodingConversion,
+ recommendedEncoding: TextEncodingKind.UTF8BOM,
+ currentLineEndingDistribution: Array.Empty(),
+ filesNeedingLineEndingConversion: filesNeedingLineEndingConversion,
+ filesWithMixedLineEndings: filesWithMixedLineEndings,
+ filesMissingFinalNewline: filesMissingFinalNewline,
+ recommendedLineEnding: FileConsistencyLineEnding.CRLF,
+ extensionIssues: extensionIssues ?? Array.Empty());
+
+ return new ProjectConsistencyReport(
+ summary,
+ new ProjectEncodingReport(
+ new ProjectEncodingSummary(
+ projectPath: summary.ProjectPath,
+ projectType: summary.ProjectType,
+ kind: summary.Kind,
+ totalFiles: summary.TotalFiles,
+ errorFiles: 0,
+ mostCommonEncoding: summary.RecommendedEncoding,
+ uniqueEncodings: Array.Empty(),
+ inconsistentExtensions: Array.Empty(),
+ distribution: Array.Empty(),
+ extensionMap: Array.Empty(),
+ analysisDate: summary.AnalysisDate),
+ files: null,
+ groupedByEncoding: null,
+ exportPath: null),
+ new ProjectLineEndingReport(
+ new ProjectLineEndingSummary(
+ status: CheckStatus.Pass,
+ projectPath: summary.ProjectPath,
+ projectType: summary.ProjectType,
+ kind: summary.Kind,
+ totalFiles: summary.TotalFiles,
+ errorFiles: 0,
+ mostCommonLineEnding: DetectedLineEndingKind.CRLF,
+ uniqueLineEndings: Array.Empty(),
+ inconsistentExtensions: Array.Empty(),
+ problemFiles: summary.FilesWithMixedLineEndings,
+ filesMissingFinalNewline: summary.FilesMissingFinalNewline,
+ message: string.Empty,
+ recommendations: Array.Empty(),
+ distribution: Array.Empty(),
+ extensionMap: Array.Empty(),
+ analysisDate: summary.AnalysisDate),
+ files: null,
+ problemFiles: Array.Empty(),
+ groupedByLineEnding: null,
+ exportPath: null),
+ files: null,
+ problematicFiles: Array.Empty(),
+ exportPath: null);
+ }
+}
diff --git a/PowerForge/Models/AboutTopicTemplateRequest.cs b/PowerForge/Models/AboutTopicTemplateRequest.cs
new file mode 100644
index 00000000..4e344c12
--- /dev/null
+++ b/PowerForge/Models/AboutTopicTemplateRequest.cs
@@ -0,0 +1,39 @@
+using System;
+
+namespace PowerForge;
+
+///
+/// Describes a request to scaffold an about-topic source file.
+///
+public sealed class AboutTopicTemplateRequest
+{
+ ///
+ /// Gets or sets the topic name. The about_ prefix is added automatically when missing.
+ ///
+ public string TopicName { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the output directory path. Relative paths are resolved from .
+ ///
+ public string OutputPath { get; set; } = ".";
+
+ ///
+ /// Gets or sets an optional short description seed for the generated template.
+ ///
+ public string? ShortDescription { get; set; }
+
+ ///
+ /// Gets or sets the output format for the scaffolded file.
+ ///
+ public AboutTopicTemplateFormat Format { get; set; } = AboutTopicTemplateFormat.HelpText;
+
+ ///
+ /// Gets or sets whether an existing file can be overwritten.
+ ///
+ public bool Force { get; set; }
+
+ ///
+ /// Gets or sets the working directory used to resolve relative paths.
+ ///
+ public string WorkingDirectory { get; set; } = Environment.CurrentDirectory;
+}
diff --git a/PowerForge/Models/AboutTopicTemplateResult.cs b/PowerForge/Models/AboutTopicTemplateResult.cs
new file mode 100644
index 00000000..a8878143
--- /dev/null
+++ b/PowerForge/Models/AboutTopicTemplateResult.cs
@@ -0,0 +1,32 @@
+namespace PowerForge;
+
+///
+/// Describes a resolved or generated about-topic scaffold file.
+///
+public sealed class AboutTopicTemplateResult
+{
+ ///
+ /// Gets or sets the normalized topic name.
+ ///
+ public string TopicName { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the resolved output directory.
+ ///
+ public string OutputDirectory { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the resolved file path.
+ ///
+ public string FilePath { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the output format.
+ ///
+ public AboutTopicTemplateFormat Format { get; set; }
+
+ ///
+ /// Gets or sets whether the target file already existed when the request was evaluated.
+ ///
+ public bool Exists { get; set; }
+}
diff --git a/PowerForge/Models/DotNetPublishConfigScaffoldRequest.cs b/PowerForge/Models/DotNetPublishConfigScaffoldRequest.cs
new file mode 100644
index 00000000..71a7d80d
--- /dev/null
+++ b/PowerForge/Models/DotNetPublishConfigScaffoldRequest.cs
@@ -0,0 +1,64 @@
+using System;
+
+namespace PowerForge;
+
+///
+/// Describes a request to scaffold a starter dotnet publish configuration file.
+///
+public sealed class DotNetPublishConfigScaffoldRequest
+{
+ ///
+ /// Gets or sets the project root used to resolve relative paths.
+ ///
+ public string ProjectRoot { get; set; } = ".";
+
+ ///
+ /// Gets or sets an optional path to a specific project file.
+ ///
+ public string? ProjectPath { get; set; }
+
+ ///
+ /// Gets or sets an optional target name override.
+ ///
+ public string? TargetName { get; set; }
+
+ ///
+ /// Gets or sets an optional framework override.
+ ///
+ public string? Framework { get; set; }
+
+ ///
+ /// Gets or sets optional runtime identifiers override.
+ ///
+ public string[]? Runtimes { get; set; }
+
+ ///
+ /// Gets or sets optional publish styles override.
+ ///
+ public DotNetPublishStyle[]? Styles { get; set; }
+
+ ///
+ /// Gets or sets the build configuration.
+ ///
+ public string Configuration { get; set; } = "Release";
+
+ ///
+ /// Gets or sets the output config path.
+ ///
+ public string OutputPath { get; set; } = "powerforge.dotnetpublish.json";
+
+ ///
+ /// Gets or sets whether an existing config file can be overwritten.
+ ///
+ public bool Force { get; set; }
+
+ ///
+ /// Gets or sets whether the generated JSON should include the schema property.
+ ///
+ public bool IncludeSchema { get; set; } = true;
+
+ ///
+ /// Gets or sets the working directory used to resolve relative paths.
+ ///
+ public string WorkingDirectory { get; set; } = Environment.CurrentDirectory;
+}
diff --git a/PowerForge/Models/DotNetRepositoryReleaseDisplayModels.cs b/PowerForge/Models/DotNetRepositoryReleaseDisplayModels.cs
new file mode 100644
index 00000000..389ea950
--- /dev/null
+++ b/PowerForge/Models/DotNetRepositoryReleaseDisplayModels.cs
@@ -0,0 +1,30 @@
+using System;
+using System.Collections.Generic;
+
+namespace PowerForge;
+
+internal sealed class DotNetRepositoryReleaseDisplayModel
+{
+ internal string Title { get; set; } = string.Empty;
+ internal IReadOnlyList Projects { get; set; } =
+ Array.Empty();
+ internal IReadOnlyList Totals { get; set; } =
+ Array.Empty();
+}
+
+internal sealed class DotNetRepositoryReleaseProjectDisplayRow
+{
+ internal string ProjectName { get; set; } = string.Empty;
+ internal string Packable { get; set; } = string.Empty;
+ internal string VersionDisplay { get; set; } = string.Empty;
+ internal string PackageCount { get; set; } = string.Empty;
+ internal string StatusText { get; set; } = string.Empty;
+ internal ConsoleColor? StatusColor { get; set; }
+ internal string ErrorPreview { get; set; } = string.Empty;
+}
+
+internal sealed class DotNetRepositoryReleaseTotalsDisplayRow
+{
+ internal string Label { get; set; } = string.Empty;
+ internal string Value { get; set; } = string.Empty;
+}
diff --git a/PowerForge/Models/DotNetRepositoryReleaseSummary.cs b/PowerForge/Models/DotNetRepositoryReleaseSummary.cs
new file mode 100644
index 00000000..108f2f8f
--- /dev/null
+++ b/PowerForge/Models/DotNetRepositoryReleaseSummary.cs
@@ -0,0 +1,128 @@
+using System.Collections.Generic;
+
+namespace PowerForge;
+
+///
+/// Reusable summary model for repository-wide .NET release results.
+///
+public sealed class DotNetRepositoryReleaseSummary
+{
+ ///
+ /// Gets or sets the per-project summary rows.
+ ///
+ public IReadOnlyList Projects { get; set; } =
+ new List();
+
+ ///
+ /// Gets or sets the aggregate totals.
+ ///
+ public DotNetRepositoryReleaseSummaryTotals Totals { get; set; } = new();
+}
+
+///
+/// Reusable per-project summary row for repository releases.
+///
+public sealed class DotNetRepositoryReleaseProjectSummaryRow
+{
+ ///
+ /// Gets or sets the project name.
+ ///
+ public string ProjectName { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets whether the project was packable.
+ ///
+ public bool IsPackable { get; set; }
+
+ ///
+ /// Gets or sets the display version transition.
+ ///
+ public string VersionDisplay { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the number of produced packages.
+ ///
+ public int PackageCount { get; set; }
+
+ ///
+ /// Gets or sets the status classification used for summary rendering.
+ ///
+ public DotNetRepositoryReleaseProjectStatus Status { get; set; }
+
+ ///
+ /// Gets or sets the raw project error message.
+ ///
+ public string ErrorMessage { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the trimmed error preview used by compact summary renderers.
+ ///
+ public string ErrorPreview { get; set; } = string.Empty;
+}
+
+///
+/// Aggregate totals for repository release summaries.
+///
+public sealed class DotNetRepositoryReleaseSummaryTotals
+{
+ ///
+ /// Gets or sets the total project count.
+ ///
+ public int ProjectCount { get; set; }
+
+ ///
+ /// Gets or sets the packable project count.
+ ///
+ public int PackableCount { get; set; }
+
+ ///
+ /// Gets or sets the failed project count.
+ ///
+ public int FailedProjectCount { get; set; }
+
+ ///
+ /// Gets or sets the total produced package count.
+ ///
+ public int PackageCount { get; set; }
+
+ ///
+ /// Gets or sets the published package count.
+ ///
+ public int PublishedPackageCount { get; set; }
+
+ ///
+ /// Gets or sets the skipped-duplicate package count.
+ ///
+ public int SkippedDuplicatePackageCount { get; set; }
+
+ ///
+ /// Gets or sets the failed publish count.
+ ///
+ public int FailedPublishCount { get; set; }
+
+ ///
+ /// Gets or sets the resolved release version.
+ ///
+ public string ResolvedVersion { get; set; } = string.Empty;
+}
+
+///
+/// Status bucket used by repository release summaries.
+///
+public enum DotNetRepositoryReleaseProjectStatus
+{
+ ///
+ /// The project completed successfully.
+ ///
+ Ok = 0,
+
+ ///
+ /// The project was skipped because it is not packable.
+ ///
+ Skipped = 1,
+
+ ///
+ /// The project failed.
+ ///
+ Failed = 2
+}
diff --git a/PowerForge/Models/ModuleTestFailureWorkflowModels.cs b/PowerForge/Models/ModuleTestFailureWorkflowModels.cs
new file mode 100644
index 00000000..c22ee0d8
--- /dev/null
+++ b/PowerForge/Models/ModuleTestFailureWorkflowModels.cs
@@ -0,0 +1,33 @@
+using System;
+
+namespace PowerForge;
+
+internal sealed class ModuleTestFailureWorkflowRequest
+{
+ internal bool UseTestResultsInput { get; set; }
+ internal object? TestResults { get; set; }
+ internal string? ExplicitPath { get; set; }
+ internal string? ProjectPath { get; set; }
+ internal string? ModuleBasePath { get; set; }
+ internal string CurrentDirectory { get; set; } = Environment.CurrentDirectory;
+}
+
+internal sealed class ModuleTestFailureWorkflowResult
+{
+ internal ModuleTestFailureAnalysis? Analysis { get; set; }
+ internal string[] WarningMessages { get; set; } = Array.Empty();
+}
+
+internal sealed class ModuleTestFailurePathResolution
+{
+ internal string ProjectPath { get; set; } = string.Empty;
+ internal string? ResultsPath { get; set; }
+ internal string[] SearchedPaths { get; set; } = Array.Empty();
+ internal bool ExplicitPathProvided { get; set; }
+}
+
+internal sealed class ModuleTestFailureDisplayLine
+{
+ internal string Text { get; set; } = string.Empty;
+ internal ConsoleColor? Color { get; set; }
+}
diff --git a/PowerForge/Models/NuGetCertificateExportDisplayModels.cs b/PowerForge/Models/NuGetCertificateExportDisplayModels.cs
new file mode 100644
index 00000000..2f5bc70c
--- /dev/null
+++ b/PowerForge/Models/NuGetCertificateExportDisplayModels.cs
@@ -0,0 +1,9 @@
+using System;
+
+namespace PowerForge;
+
+internal sealed class NuGetCertificateExportDisplayLine
+{
+ internal string Text { get; set; } = string.Empty;
+ internal ConsoleColor? Color { get; set; }
+}
diff --git a/PowerForge/Models/NuGetCertificateExportRequest.cs b/PowerForge/Models/NuGetCertificateExportRequest.cs
new file mode 100644
index 00000000..e85e977e
--- /dev/null
+++ b/PowerForge/Models/NuGetCertificateExportRequest.cs
@@ -0,0 +1,24 @@
+using System;
+
+namespace PowerForge;
+
+///
+/// Request used to export a public signing certificate for NuGet.org registration.
+///
+public sealed class NuGetCertificateExportRequest
+{
+ /// Certificate thumbprint to look up.
+ public string? CertificateThumbprint { get; set; }
+
+ /// Certificate SHA256 hash to look up.
+ public string? CertificateSha256 { get; set; }
+
+ /// Output path for the exported certificate file.
+ public string? OutputPath { get; set; }
+
+ /// Certificate store location to search.
+ public CertificateStoreLocation StoreLocation { get; set; } = CertificateStoreLocation.CurrentUser;
+
+ /// Working directory used when output path is not specified.
+ public string WorkingDirectory { get; set; } = Environment.CurrentDirectory;
+}
diff --git a/PowerForge/Models/NuGetCertificateExportResult.cs b/PowerForge/Models/NuGetCertificateExportResult.cs
new file mode 100644
index 00000000..e94e9f0b
--- /dev/null
+++ b/PowerForge/Models/NuGetCertificateExportResult.cs
@@ -0,0 +1,40 @@
+using System;
+using System.Security.Cryptography.X509Certificates;
+
+namespace PowerForge;
+
+///
+/// Result returned by the NuGet certificate export service.
+///
+public sealed class NuGetCertificateExportResult
+{
+ /// Whether the export completed successfully.
+ public bool Success { get; set; }
+
+ /// Error message returned when the export fails.
+ public string? Error { get; set; }
+
+ /// Full path to the exported certificate file.
+ public string? CertificatePath { get; set; }
+
+ /// The matched certificate.
+ public X509Certificate2? Certificate { get; set; }
+
+ /// Whether the certificate appears to have Code Signing EKU.
+ public bool HasCodeSigningEku { get; set; }
+
+ /// SHA256 fingerprint of the matched certificate.
+ public string? Sha256 { get; set; }
+
+ /// Certificate subject.
+ public string? Subject { get; set; }
+
+ /// Certificate issuer.
+ public string? Issuer { get; set; }
+
+ /// Certificate validity start date.
+ public DateTime? NotBefore { get; set; }
+
+ /// Certificate validity end date.
+ public DateTime? NotAfter { get; set; }
+}
diff --git a/PowerForge/Models/PowerShellCompatibilityWorkflowModels.cs b/PowerForge/Models/PowerShellCompatibilityWorkflowModels.cs
new file mode 100644
index 00000000..2b1bdb1e
--- /dev/null
+++ b/PowerForge/Models/PowerShellCompatibilityWorkflowModels.cs
@@ -0,0 +1,27 @@
+using System;
+
+namespace PowerForge;
+
+internal sealed class PowerShellCompatibilityWorkflowRequest
+{
+ internal string InputPath { get; set; } = string.Empty;
+ internal string? ExportPath { get; set; }
+ internal bool Recurse { get; set; }
+ internal string[] ExcludeDirectories { get; set; } = Array.Empty();
+ internal bool Internal { get; set; }
+}
+
+internal sealed class PowerShellCompatibilityWorkflowResult
+{
+ internal PowerShellCompatibilityReport Report { get; set; } = new(
+ new PowerShellCompatibilitySummary(CheckStatus.Pass, DateTime.Now, 0, 0, 0, 0, 0, 0, string.Empty, Array.Empty()),
+ Array.Empty(),
+ null);
+ internal bool HasFiles => Report.Files.Length > 0;
+}
+
+internal sealed class PowerShellCompatibilityDisplayLine
+{
+ internal string Text { get; set; } = string.Empty;
+ internal ConsoleColor? Color { get; set; }
+}
diff --git a/PowerForge/Models/ProjectBuildGitHubDisplayModels.cs b/PowerForge/Models/ProjectBuildGitHubDisplayModels.cs
new file mode 100644
index 00000000..991f21dd
--- /dev/null
+++ b/PowerForge/Models/ProjectBuildGitHubDisplayModels.cs
@@ -0,0 +1,15 @@
+using System.Collections.Generic;
+
+namespace PowerForge;
+
+internal sealed class ProjectBuildGitHubDisplayModel
+{
+ internal string Title { get; set; } = string.Empty;
+ internal IReadOnlyList Rows { get; set; } = System.Array.Empty();
+}
+
+internal sealed class ProjectBuildGitHubDisplayRow
+{
+ internal string Label { get; set; } = string.Empty;
+ internal string Value { get; set; } = string.Empty;
+}
diff --git a/PowerForge/Models/ProjectConsistencyDisplayModels.cs b/PowerForge/Models/ProjectConsistencyDisplayModels.cs
new file mode 100644
index 00000000..9029ff1e
--- /dev/null
+++ b/PowerForge/Models/ProjectConsistencyDisplayModels.cs
@@ -0,0 +1,9 @@
+using System;
+
+namespace PowerForge;
+
+internal sealed class ProjectConsistencyDisplayLine
+{
+ internal string Text { get; set; } = string.Empty;
+ internal ConsoleColor? Color { get; set; }
+}
diff --git a/PowerForge/PowerForge.csproj b/PowerForge/PowerForge.csproj
index c4dd0db7..323868c3 100644
--- a/PowerForge/PowerForge.csproj
+++ b/PowerForge/PowerForge.csproj
@@ -80,6 +80,7 @@
+
diff --git a/PowerForge/Services/AboutTopicTemplateService.cs b/PowerForge/Services/AboutTopicTemplateService.cs
new file mode 100644
index 00000000..a4dc0f3c
--- /dev/null
+++ b/PowerForge/Services/AboutTopicTemplateService.cs
@@ -0,0 +1,73 @@
+using System;
+using System.IO;
+
+namespace PowerForge;
+
+///
+/// Resolves and scaffolds about-topic template files.
+///
+public sealed class AboutTopicTemplateService
+{
+ ///
+ /// Resolves the target about-topic scaffold path without writing the file.
+ ///
+ public AboutTopicTemplateResult Preview(AboutTopicTemplateRequest request)
+ {
+ if (request is null)
+ throw new ArgumentNullException(nameof(request));
+
+ var outputDirectory = ResolveOutputDirectory(request.WorkingDirectory, request.OutputPath);
+ var normalizedTopic = AboutTopicTemplateGenerator.NormalizeTopicName(request.TopicName);
+ var extension = request.Format == AboutTopicTemplateFormat.Markdown ? ".md" : ".help.txt";
+ var filePath = Path.Combine(outputDirectory, normalizedTopic + extension);
+
+ return new AboutTopicTemplateResult
+ {
+ TopicName = normalizedTopic,
+ OutputDirectory = outputDirectory,
+ FilePath = filePath,
+ Format = request.Format,
+ Exists = File.Exists(filePath)
+ };
+ }
+
+ ///
+ /// Generates the about-topic scaffold file and returns the resolved result.
+ ///
+ public AboutTopicTemplateResult Generate(AboutTopicTemplateRequest request)
+ {
+ if (request is null)
+ throw new ArgumentNullException(nameof(request));
+
+ var preview = Preview(request);
+ var created = AboutTopicTemplateGenerator.WriteTemplateFile(
+ outputDirectory: preview.OutputDirectory,
+ topicName: preview.TopicName,
+ force: request.Force,
+ shortDescription: request.ShortDescription,
+ format: request.Format);
+
+ return new AboutTopicTemplateResult
+ {
+ TopicName = preview.TopicName,
+ OutputDirectory = preview.OutputDirectory,
+ FilePath = created,
+ Format = preview.Format,
+ Exists = preview.Exists
+ };
+ }
+
+ private static string ResolveOutputDirectory(string workingDirectory, string outputPath)
+ {
+ var basePath = string.IsNullOrWhiteSpace(workingDirectory)
+ ? Environment.CurrentDirectory
+ : workingDirectory;
+ var trimmed = (outputPath ?? string.Empty).Trim().Trim('"');
+ if (trimmed.Length == 0)
+ return Path.GetFullPath(basePath);
+
+ return Path.IsPathRooted(trimmed)
+ ? Path.GetFullPath(trimmed)
+ : Path.GetFullPath(Path.Combine(basePath, trimmed));
+ }
+}
diff --git a/PowerForge/Services/DotNetPublishConfigScaffoldService.cs b/PowerForge/Services/DotNetPublishConfigScaffoldService.cs
new file mode 100644
index 00000000..859a6df3
--- /dev/null
+++ b/PowerForge/Services/DotNetPublishConfigScaffoldService.cs
@@ -0,0 +1,119 @@
+using System;
+using System.IO;
+using System.Linq;
+
+namespace PowerForge;
+
+///
+/// Resolves and scaffolds starter dotnet publish configuration files.
+///
+public sealed class DotNetPublishConfigScaffoldService
+{
+ private readonly Func _createScaffolder;
+
+ ///
+ /// Creates a new scaffold service.
+ ///
+ public DotNetPublishConfigScaffoldService(Func? createScaffolder = null)
+ {
+ _createScaffolder = createScaffolder ?? (logger => new DotNetPublishConfigScaffolder(logger));
+ }
+
+ ///
+ /// Resolves the output config path without writing the file.
+ ///
+ public string ResolveOutputPath(DotNetPublishConfigScaffoldRequest request)
+ {
+ if (request is null)
+ throw new ArgumentNullException(nameof(request));
+
+ var resolvedRoot = ResolvePath(request.WorkingDirectory, request.ProjectRoot);
+ return ResolvePath(resolvedRoot, request.OutputPath);
+ }
+
+ ///
+ /// Generates the starter config file.
+ ///
+ public DotNetPublishConfigScaffoldResult Generate(
+ DotNetPublishConfigScaffoldRequest request,
+ ILogger? logger = null)
+ {
+ if (request is null)
+ throw new ArgumentNullException(nameof(request));
+
+ var resolvedRoot = ResolvePath(request.WorkingDirectory, request.ProjectRoot);
+ var scaffolder = _createScaffolder(logger ?? new NullLogger());
+ return scaffolder.Generate(new DotNetPublishConfigScaffoldOptions
+ {
+ ProjectRoot = resolvedRoot,
+ ProjectPath = NormalizePathNullable(request.ProjectPath),
+ TargetName = NormalizeNullable(request.TargetName),
+ Framework = NormalizeNullable(request.Framework),
+ Runtimes = NormalizeStrings(request.Runtimes),
+ Styles = NormalizeStyles(request.Styles),
+ Configuration = string.IsNullOrWhiteSpace(request.Configuration) ? "Release" : request.Configuration.Trim(),
+ OutputPath = ResolvePath(resolvedRoot, request.OutputPath),
+ Overwrite = request.Force,
+ IncludeSchema = request.IncludeSchema
+ });
+ }
+
+ private static string ResolvePath(string basePath, string value)
+ {
+ var fallback = string.IsNullOrWhiteSpace(basePath) ? Environment.CurrentDirectory : basePath;
+ var raw = NormalizePathValue(value);
+ if (raw.Length == 0)
+ return Path.GetFullPath(fallback);
+
+ return Path.IsPathRooted(raw)
+ ? Path.GetFullPath(raw)
+ : Path.GetFullPath(Path.Combine(fallback, raw));
+ }
+
+ private static string NormalizePathValue(string? value)
+ {
+ var raw = (value ?? string.Empty).Trim().Trim('"');
+ if (raw.Length == 0)
+ return string.Empty;
+
+ return raw
+ .Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar)
+ .Replace(Path.DirectorySeparatorChar == '/' ? '\\' : '/', Path.DirectorySeparatorChar);
+ }
+
+ private static string? NormalizeNullable(string? value)
+ {
+ var normalized = (value ?? string.Empty).Trim();
+ return normalized.Length == 0 ? null : normalized;
+ }
+
+ private static string? NormalizePathNullable(string? value)
+ {
+ var normalized = NormalizePathValue(value);
+ return normalized.Length == 0 ? null : normalized;
+ }
+
+ private static string[]? NormalizeStrings(string[]? values)
+ {
+ if (values is null || values.Length == 0)
+ return null;
+
+ var normalized = values
+ .Where(v => !string.IsNullOrWhiteSpace(v))
+ .Select(v => v.Trim())
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+ return normalized.Length == 0 ? null : normalized;
+ }
+
+ private static DotNetPublishStyle[]? NormalizeStyles(DotNetPublishStyle[]? values)
+ {
+ if (values is null || values.Length == 0)
+ return null;
+
+ var normalized = values
+ .Distinct()
+ .ToArray();
+ return normalized.Length == 0 ? null : normalized;
+ }
+}
diff --git a/PowerForge/Services/DotNetRepositoryReleaseDisplayService.cs b/PowerForge/Services/DotNetRepositoryReleaseDisplayService.cs
new file mode 100644
index 00000000..508c5b70
--- /dev/null
+++ b/PowerForge/Services/DotNetRepositoryReleaseDisplayService.cs
@@ -0,0 +1,71 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace PowerForge;
+
+internal sealed class DotNetRepositoryReleaseDisplayService
+{
+ public DotNetRepositoryReleaseDisplayModel CreateDisplay(DotNetRepositoryReleaseSummary summary, bool isPlan)
+ {
+ if (summary is null)
+ throw new ArgumentNullException(nameof(summary));
+
+ return new DotNetRepositoryReleaseDisplayModel
+ {
+ Title = isPlan ? "Plan" : "Summary",
+ Projects = summary.Projects.Select(project => new DotNetRepositoryReleaseProjectDisplayRow
+ {
+ ProjectName = project.ProjectName,
+ Packable = project.IsPackable ? "Yes" : "No",
+ VersionDisplay = project.VersionDisplay,
+ PackageCount = project.PackageCount.ToString(),
+ StatusText = ResolveStatusText(project.Status),
+ StatusColor = ResolveStatusColor(project.Status),
+ ErrorPreview = project.ErrorPreview
+ }).ToArray(),
+ Totals = CreateTotals(summary.Totals)
+ };
+ }
+
+ private static IReadOnlyList CreateTotals(DotNetRepositoryReleaseSummaryTotals totals)
+ {
+ var rows = new List
+ {
+ Row("Projects", totals.ProjectCount),
+ Row("Packable", totals.PackableCount),
+ Row("Failed", totals.FailedProjectCount),
+ Row("Packages", totals.PackageCount)
+ };
+
+ if (totals.PublishedPackageCount > 0)
+ rows.Add(Row("Published", totals.PublishedPackageCount));
+ if (totals.SkippedDuplicatePackageCount > 0)
+ rows.Add(Row("Skipped duplicates", totals.SkippedDuplicatePackageCount));
+ if (totals.FailedPublishCount > 0)
+ rows.Add(Row("Failed publishes", totals.FailedPublishCount));
+ if (!string.IsNullOrWhiteSpace(totals.ResolvedVersion))
+ rows.Add(new DotNetRepositoryReleaseTotalsDisplayRow { Label = "Resolved version", Value = totals.ResolvedVersion });
+
+ return rows;
+ }
+
+ private static string ResolveStatusText(DotNetRepositoryReleaseProjectStatus status)
+ => status switch
+ {
+ DotNetRepositoryReleaseProjectStatus.Ok => "Ok",
+ DotNetRepositoryReleaseProjectStatus.Skipped => "Skipped",
+ _ => "Fail"
+ };
+
+ private static ConsoleColor ResolveStatusColor(DotNetRepositoryReleaseProjectStatus status)
+ => status switch
+ {
+ DotNetRepositoryReleaseProjectStatus.Ok => ConsoleColor.Green,
+ DotNetRepositoryReleaseProjectStatus.Skipped => ConsoleColor.Gray,
+ _ => ConsoleColor.Red
+ };
+
+ private static DotNetRepositoryReleaseTotalsDisplayRow Row(string label, int value)
+ => new() { Label = label, Value = value.ToString() };
+}
diff --git a/PowerForge/Services/DotNetRepositoryReleaseSummaryService.cs b/PowerForge/Services/DotNetRepositoryReleaseSummaryService.cs
new file mode 100644
index 00000000..d1a86db7
--- /dev/null
+++ b/PowerForge/Services/DotNetRepositoryReleaseSummaryService.cs
@@ -0,0 +1,79 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace PowerForge;
+
+///
+/// Builds reusable summary models for repository-wide .NET release results.
+///
+public sealed class DotNetRepositoryReleaseSummaryService
+{
+ ///
+ /// Creates a summary view for a repository release result.
+ ///
+ public DotNetRepositoryReleaseSummary CreateSummary(DotNetRepositoryReleaseResult result, int maxErrorLength = 140)
+ {
+ if (result is null)
+ throw new ArgumentNullException(nameof(result));
+
+ var rows = result.Projects
+ .OrderBy(p => p.ProjectName, StringComparer.OrdinalIgnoreCase)
+ .Select(project => new DotNetRepositoryReleaseProjectSummaryRow
+ {
+ ProjectName = project.ProjectName,
+ IsPackable = project.IsPackable,
+ VersionDisplay = BuildVersionDisplay(project),
+ PackageCount = project.Packages.Count,
+ Status = ResolveStatus(project),
+ ErrorMessage = project.ErrorMessage?.Trim() ?? string.Empty,
+ ErrorPreview = TrimForSummary(project.ErrorMessage, maxErrorLength)
+ })
+ .ToArray();
+
+ return new DotNetRepositoryReleaseSummary
+ {
+ Projects = rows,
+ Totals = new DotNetRepositoryReleaseSummaryTotals
+ {
+ ProjectCount = result.Projects.Count,
+ PackableCount = result.Projects.Count(p => p.IsPackable),
+ FailedProjectCount = result.Projects.Count(p => !string.IsNullOrWhiteSpace(p.ErrorMessage)),
+ PackageCount = result.Projects.Sum(p => p.Packages.Count),
+ PublishedPackageCount = result.PublishedPackages.Count,
+ SkippedDuplicatePackageCount = result.SkippedDuplicatePackages.Count,
+ FailedPublishCount = result.FailedPackages.Count,
+ ResolvedVersion = result.ResolvedVersion ?? string.Empty
+ }
+ };
+ }
+
+ private static string BuildVersionDisplay(DotNetRepositoryProjectResult project)
+ {
+ if (string.IsNullOrWhiteSpace(project.OldVersion) && string.IsNullOrWhiteSpace(project.NewVersion))
+ return string.Empty;
+
+ return $"{project.OldVersion ?? "?"} -> {project.NewVersion ?? "?"}";
+ }
+
+ private static DotNetRepositoryReleaseProjectStatus ResolveStatus(DotNetRepositoryProjectResult project)
+ {
+ if (!string.IsNullOrWhiteSpace(project.ErrorMessage))
+ return DotNetRepositoryReleaseProjectStatus.Failed;
+
+ return project.IsPackable
+ ? DotNetRepositoryReleaseProjectStatus.Ok
+ : DotNetRepositoryReleaseProjectStatus.Skipped;
+ }
+
+ private static string TrimForSummary(string? value, int maxLength)
+ {
+ var trimmed = value?.Trim() ?? string.Empty;
+ if (trimmed.Length == 0)
+ return string.Empty;
+ if (maxLength < 4 || trimmed.Length <= maxLength)
+ return trimmed;
+
+ return trimmed.Substring(0, maxLength - 3) + "...";
+ }
+}
diff --git a/PowerForge/Services/ModuleScaffoldService.cs b/PowerForge/Services/ModuleScaffoldService.cs
index f56a1690..f4924650 100644
--- a/PowerForge/Services/ModuleScaffoldService.cs
+++ b/PowerForge/Services/ModuleScaffoldService.cs
@@ -97,17 +97,17 @@ private void EnsureAboutTopicSeed(string projectRoot, string moduleName)
if (string.IsNullOrWhiteSpace(projectRoot) || string.IsNullOrWhiteSpace(moduleName))
return;
- var aboutDir = Path.Combine(projectRoot, "Help", "About");
- Directory.CreateDirectory(aboutDir);
-
- var topicName = $"about_{moduleName}_Overview";
+ var service = new AboutTopicTemplateService();
try
{
- AboutTopicTemplateGenerator.WriteTemplateFile(
- outputDirectory: aboutDir,
- topicName: topicName,
- force: false,
- shortDescription: $"Overview for {moduleName} module.");
+ service.Generate(new AboutTopicTemplateRequest
+ {
+ TopicName = $"about_{moduleName}_Overview",
+ OutputPath = Path.Combine("Help", "About"),
+ Force = false,
+ ShortDescription = $"Overview for {moduleName} module.",
+ WorkingDirectory = projectRoot
+ });
}
catch (IOException)
{
diff --git a/PowerForge/Services/ModuleTestFailureDisplayService.cs b/PowerForge/Services/ModuleTestFailureDisplayService.cs
new file mode 100644
index 00000000..9ea6f684
--- /dev/null
+++ b/PowerForge/Services/ModuleTestFailureDisplayService.cs
@@ -0,0 +1,110 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+
+namespace PowerForge;
+
+internal sealed class ModuleTestFailureDisplayService
+{
+ public IReadOnlyList CreateSummary(ModuleTestFailureAnalysis analysis, bool showSuccessful)
+ {
+ if (analysis is null)
+ throw new ArgumentNullException(nameof(analysis));
+
+ var lines = new List
+ {
+ Line("=== Module Test Results Summary ===", ConsoleColor.Cyan),
+ Line($"Source: {analysis.Source}", ConsoleColor.DarkGray),
+ Line(string.Empty),
+ Line("Test Statistics:", ConsoleColor.Yellow),
+ Line($" Total Tests: {analysis.TotalCount}"),
+ Line($" Passed: {analysis.PassedCount}", ConsoleColor.Green),
+ Line($" Failed: {analysis.FailedCount}", analysis.FailedCount > 0 ? ConsoleColor.Red : ConsoleColor.Green)
+ };
+
+ if (analysis.SkippedCount > 0)
+ lines.Add(Line($" Skipped: {analysis.SkippedCount}", ConsoleColor.Yellow));
+
+ if (analysis.TotalCount > 0)
+ {
+ var rate = Math.Round((double)analysis.PassedCount / analysis.TotalCount * 100, 1);
+ var color = rate == 100 ? ConsoleColor.Green : (rate >= 80 ? ConsoleColor.Yellow : ConsoleColor.Red);
+ lines.Add(Line($" Success Rate: {rate.ToString("0.0", CultureInfo.InvariantCulture)}%", color));
+ }
+
+ lines.Add(Line(string.Empty));
+ if (analysis.FailedCount > 0)
+ {
+ lines.Add(Line("Failed Tests:", ConsoleColor.Red));
+ foreach (var failure in analysis.FailedTests)
+ lines.Add(Line($" - {failure.Name}", ConsoleColor.Red));
+ lines.Add(Line(string.Empty));
+ }
+ else if (showSuccessful && analysis.PassedCount > 0)
+ {
+ lines.Add(Line("All tests passed successfully!", ConsoleColor.Green));
+ }
+
+ return lines;
+ }
+
+ public IReadOnlyList CreateDetailed(ModuleTestFailureAnalysis analysis)
+ {
+ if (analysis is null)
+ throw new ArgumentNullException(nameof(analysis));
+
+ var lines = new List
+ {
+ Line("=== Module Test Failure Analysis ===", ConsoleColor.Cyan),
+ Line($"Source: {analysis.Source}", ConsoleColor.DarkGray),
+ Line($"Analysis Time: {analysis.Timestamp}", ConsoleColor.DarkGray),
+ Line(string.Empty)
+ };
+
+ if (analysis.TotalCount == 0)
+ {
+ lines.Add(Line("No test results found", ConsoleColor.Yellow));
+ return lines;
+ }
+
+ var summaryColor = analysis.FailedCount == 0 ? ConsoleColor.Green : ConsoleColor.Yellow;
+ lines.Add(Line($"Summary: {analysis.PassedCount}/{analysis.TotalCount} tests passed", summaryColor));
+ lines.Add(Line(string.Empty));
+
+ if (analysis.FailedCount == 0)
+ {
+ lines.Add(Line("All tests passed successfully!", ConsoleColor.Green));
+ return lines;
+ }
+
+ lines.Add(Line($"Failed Tests ({analysis.FailedCount}):", ConsoleColor.Red));
+ lines.Add(Line(string.Empty));
+
+ foreach (var failure in analysis.FailedTests)
+ {
+ lines.Add(Line($"- {failure.Name}", ConsoleColor.Red));
+
+ if (!string.IsNullOrWhiteSpace(failure.ErrorMessage) &&
+ !string.Equals(failure.ErrorMessage, "No error message available", StringComparison.Ordinal))
+ {
+ foreach (var text in failure.ErrorMessage.Split(new[] { '\n' }, StringSplitOptions.None))
+ {
+ var trimmed = text.Trim();
+ if (trimmed.Length > 0)
+ lines.Add(Line($" {trimmed}", ConsoleColor.Yellow));
+ }
+ }
+
+ if (failure.Duration.HasValue)
+ lines.Add(Line($" Duration: {failure.Duration.Value}", ConsoleColor.DarkGray));
+
+ lines.Add(Line(string.Empty));
+ }
+
+ lines.Add(Line($"=== Summary: {analysis.FailedCount} test{(analysis.FailedCount != 1 ? "s" : string.Empty)} failed ===", ConsoleColor.Red));
+ return lines;
+ }
+
+ private static ModuleTestFailureDisplayLine Line(string text, ConsoleColor? color = null)
+ => new() { Text = text, Color = color };
+}
diff --git a/PowerForge/Services/ModuleTestFailureWorkflowService.cs b/PowerForge/Services/ModuleTestFailureWorkflowService.cs
new file mode 100644
index 00000000..7301d381
--- /dev/null
+++ b/PowerForge/Services/ModuleTestFailureWorkflowService.cs
@@ -0,0 +1,129 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+
+namespace PowerForge;
+
+internal sealed class ModuleTestFailureWorkflowService
+{
+ private readonly ModuleTestFailureAnalyzer _analyzer;
+
+ public ModuleTestFailureWorkflowService(ModuleTestFailureAnalyzer? analyzer = null)
+ {
+ _analyzer = analyzer ?? new ModuleTestFailureAnalyzer();
+ }
+
+ public ModuleTestFailureWorkflowResult Execute(ModuleTestFailureWorkflowRequest request)
+ {
+ if (request is null)
+ throw new ArgumentNullException(nameof(request));
+
+ if (request.UseTestResultsInput)
+ {
+ return new ModuleTestFailureWorkflowResult
+ {
+ Analysis = AnalyzeTestResults(request.TestResults),
+ WarningMessages = Array.Empty()
+ };
+ }
+
+ var resolution = ResolvePath(request);
+ if (string.IsNullOrWhiteSpace(resolution.ResultsPath))
+ {
+ return new ModuleTestFailureWorkflowResult
+ {
+ WarningMessages = resolution.ExplicitPathProvided
+ ? new[] { $"Test results file not found: {resolution.SearchedPaths[0]}" }
+ : new[] { "No test results file found. Searched in:" }.Concat(resolution.SearchedPaths.Select(path => $" {path}")).ToArray()
+ };
+ }
+
+ if (!File.Exists(resolution.ResultsPath))
+ {
+ return new ModuleTestFailureWorkflowResult
+ {
+ WarningMessages = new[] { $"Test results file not found: {resolution.ResultsPath}" }
+ };
+ }
+
+ return new ModuleTestFailureWorkflowResult
+ {
+ Analysis = _analyzer.AnalyzeFromXmlFile(resolution.ResultsPath!)
+ };
+ }
+
+ internal ModuleTestFailurePathResolution ResolvePath(ModuleTestFailureWorkflowRequest request)
+ {
+ var projectPath = ResolveProjectPath(request.ProjectPath, request.ModuleBasePath, request.CurrentDirectory);
+ if (!string.IsNullOrWhiteSpace(request.ExplicitPath))
+ {
+ var explicitPath = Path.GetFullPath(request.ExplicitPath!.Trim().Trim('"'));
+ return new ModuleTestFailurePathResolution
+ {
+ ProjectPath = projectPath,
+ ResultsPath = explicitPath,
+ SearchedPaths = new[] { explicitPath },
+ ExplicitPathProvided = true
+ };
+ }
+
+ var candidates = new[]
+ {
+ Path.Combine(projectPath, "TestResults.xml"),
+ Path.Combine(projectPath, "Tests", "TestResults.xml"),
+ Path.Combine(projectPath, "Test", "TestResults.xml"),
+ Path.Combine(projectPath, "Tests", "Results", "TestResults.xml")
+ };
+
+ return new ModuleTestFailurePathResolution
+ {
+ ProjectPath = projectPath,
+ ResultsPath = candidates.FirstOrDefault(File.Exists),
+ SearchedPaths = candidates,
+ ExplicitPathProvided = false
+ };
+ }
+
+ internal ModuleTestFailureAnalysis AnalyzeTestResults(object? testResults)
+ {
+ if (testResults is ModuleTestFailureAnalysis analysis)
+ return analysis;
+
+ if (testResults is ModuleTestSuiteResult suite)
+ {
+ if (suite.FailureAnalysis is not null)
+ return suite.FailureAnalysis;
+
+ var xmlPath = suite.ResultsXmlPath;
+ if (xmlPath is not null && File.Exists(xmlPath))
+ return _analyzer.AnalyzeFromXmlFile(xmlPath);
+
+ return new ModuleTestFailureAnalysis
+ {
+ Source = "ModuleTestSuiteResult",
+ Timestamp = DateTime.Now,
+ TotalCount = suite.TotalCount,
+ PassedCount = suite.PassedCount,
+ FailedCount = suite.FailedCount,
+ SkippedCount = suite.SkippedCount,
+ FailedTests = Array.Empty()
+ };
+ }
+
+ return _analyzer.AnalyzeFromPesterResults(testResults);
+ }
+
+ internal static string ResolveProjectPath(string? explicitProjectPath, string? moduleBasePath, string currentDirectory)
+ {
+ if (!string.IsNullOrWhiteSpace(explicitProjectPath))
+ return Path.GetFullPath(explicitProjectPath!.Trim().Trim('"'));
+
+ if (!string.IsNullOrWhiteSpace(moduleBasePath))
+ return moduleBasePath!;
+
+ return string.IsNullOrWhiteSpace(currentDirectory)
+ ? Directory.GetCurrentDirectory()
+ : currentDirectory;
+ }
+}
diff --git a/PowerForge/Services/NuGetCertificateExportDisplayService.cs b/PowerForge/Services/NuGetCertificateExportDisplayService.cs
new file mode 100644
index 00000000..0d6e04d0
--- /dev/null
+++ b/PowerForge/Services/NuGetCertificateExportDisplayService.cs
@@ -0,0 +1,40 @@
+using System;
+using System.Collections.Generic;
+
+namespace PowerForge;
+
+internal sealed class NuGetCertificateExportDisplayService
+{
+ public IReadOnlyList CreateSuccessSummary(NuGetCertificateExportResult result)
+ {
+ if (result is null)
+ throw new ArgumentNullException(nameof(result));
+ if (!result.Success)
+ throw new ArgumentException("A successful export result is required.", nameof(result));
+ if (string.IsNullOrWhiteSpace(result.CertificatePath))
+ throw new ArgumentException("Certificate path is required for display output.", nameof(result));
+
+ return new List
+ {
+ Line($"Certificate exported successfully to: {result.CertificatePath}", ConsoleColor.Green),
+ Line(string.Empty),
+ Line("Next steps to register with NuGet.org:", ConsoleColor.Yellow),
+ Line("1. Go to https://www.nuget.org and sign in"),
+ Line("2. Go to Account Settings > Certificates"),
+ Line("3. Click 'Register new'"),
+ Line($"4. Upload the file: {result.CertificatePath}"),
+ Line("5. Once registered, all future packages must be signed with this certificate"),
+ Line(string.Empty),
+ Line("Certificate details:", ConsoleColor.Cyan),
+ Line($" Subject: {result.Subject}"),
+ Line($" Issuer: {result.Issuer}"),
+ Line($" Thumbprint: {result.Certificate?.Thumbprint}"),
+ Line($" SHA256: {result.Sha256}"),
+ Line($" Valid From: {result.NotBefore}"),
+ Line($" Valid To: {result.NotAfter}")
+ };
+ }
+
+ private static NuGetCertificateExportDisplayLine Line(string text, ConsoleColor? color = null)
+ => new() { Text = text, Color = color };
+}
diff --git a/PowerForge/Services/NuGetCertificateExportService.cs b/PowerForge/Services/NuGetCertificateExportService.cs
new file mode 100644
index 00000000..b012a7e7
--- /dev/null
+++ b/PowerForge/Services/NuGetCertificateExportService.cs
@@ -0,0 +1,164 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using System.Text.RegularExpressions;
+
+namespace PowerForge;
+
+///
+/// Exports public certificates for NuGet.org package signing registration.
+///
+public sealed class NuGetCertificateExportService
+{
+ private readonly Func> _loadCertificates;
+
+ ///
+ /// Creates a new export service.
+ ///
+ public NuGetCertificateExportService(Func>? loadCertificates = null)
+ {
+ _loadCertificates = loadCertificates ?? LoadCertificates;
+ }
+
+ ///
+ /// Executes the certificate export request.
+ ///
+ public NuGetCertificateExportResult Execute(NuGetCertificateExportRequest request)
+ {
+ if (request is null)
+ throw new ArgumentNullException(nameof(request));
+
+ try
+ {
+ var certificates = _loadCertificates(request.StoreLocation);
+ var certificate = FindCertificate(certificates, request);
+ if (certificate is null)
+ {
+ return new NuGetCertificateExportResult
+ {
+ Success = false,
+ Error = BuildNotFoundMessage(request)
+ };
+ }
+
+ var outputPath = ResolveOutputPath(request, certificate);
+ Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? ".");
+
+ File.WriteAllBytes(outputPath, certificate.Export(X509ContentType.Cert));
+
+ return new NuGetCertificateExportResult
+ {
+ Success = true,
+ CertificatePath = outputPath,
+ Certificate = certificate,
+ HasCodeSigningEku = HasCodeSigningEku(certificate),
+ Sha256 = GetSha256Hex(certificate),
+ Subject = certificate.Subject,
+ Issuer = certificate.Issuer,
+ NotBefore = certificate.NotBefore,
+ NotAfter = certificate.NotAfter
+ };
+ }
+ catch (Exception ex)
+ {
+ return new NuGetCertificateExportResult
+ {
+ Success = false,
+ Error = ex.Message
+ };
+ }
+ }
+
+ private static IReadOnlyList LoadCertificates(CertificateStoreLocation storeLocation)
+ {
+ var location = storeLocation == CertificateStoreLocation.LocalMachine
+ ? StoreLocation.LocalMachine
+ : StoreLocation.CurrentUser;
+
+ using var store = new X509Store(StoreName.My, location);
+ store.Open(OpenFlags.ReadOnly);
+ return store.Certificates.Cast().ToArray();
+ }
+
+ private static X509Certificate2? FindCertificate(IReadOnlyList certificates, NuGetCertificateExportRequest request)
+ {
+ if (!string.IsNullOrWhiteSpace(request.CertificateThumbprint))
+ {
+ var thumbprint = request.CertificateThumbprint!.Replace(" ", string.Empty);
+ return certificates.FirstOrDefault(c => string.Equals(c.Thumbprint, thumbprint, StringComparison.OrdinalIgnoreCase));
+ }
+
+ if (!string.IsNullOrWhiteSpace(request.CertificateSha256))
+ {
+ var sha = request.CertificateSha256!.Replace(" ", string.Empty);
+ return certificates.FirstOrDefault(c => string.Equals(GetSha256Hex(c), sha, StringComparison.OrdinalIgnoreCase));
+ }
+
+ throw new InvalidOperationException("Either CertificateThumbprint or CertificateSha256 must be provided.");
+ }
+
+ private static string BuildNotFoundMessage(NuGetCertificateExportRequest request)
+ {
+ if (!string.IsNullOrWhiteSpace(request.CertificateThumbprint))
+ return $"Certificate with thumbprint '{request.CertificateThumbprint}' not found in {request.StoreLocation}\\My store";
+
+ return $"Certificate with SHA256 '{request.CertificateSha256}' not found in {request.StoreLocation}\\My store";
+ }
+
+ private static string ResolveOutputPath(NuGetCertificateExportRequest request, X509Certificate2 certificate)
+ {
+ if (!string.IsNullOrWhiteSpace(request.OutputPath))
+ return Path.GetFullPath(request.OutputPath!.Trim().Trim('"'));
+
+ var first = (certificate.Subject ?? string.Empty).Split(',').FirstOrDefault() ?? string.Empty;
+ first = first.Trim();
+ if (first.StartsWith("CN=", StringComparison.OrdinalIgnoreCase))
+ first = first.Substring(3);
+
+ var subjectName = Regex.Replace(first, @"[^\w\s-]", string.Empty);
+ if (string.IsNullOrWhiteSpace(subjectName))
+ subjectName = "Certificate";
+
+ var fileName = $"{subjectName}-CodeSigning.cer";
+ var workingDirectory = string.IsNullOrWhiteSpace(request.WorkingDirectory)
+ ? Environment.CurrentDirectory
+ : request.WorkingDirectory;
+ return Path.GetFullPath(Path.Combine(workingDirectory, fileName));
+ }
+
+ private static bool HasCodeSigningEku(X509Certificate2 certificate)
+ {
+ const string codeSigningOid = "1.3.6.1.5.5.7.3.3";
+
+ foreach (var extension in certificate.Extensions)
+ {
+ if (extension is not X509EnhancedKeyUsageExtension eku)
+ continue;
+
+ foreach (var oid in eku.EnhancedKeyUsages)
+ {
+ if (string.Equals(oid.Value, codeSigningOid, StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(oid.FriendlyName, "Code Signing", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ private static string GetSha256Hex(X509Certificate2 certificate)
+ {
+#if NET8_0_OR_GREATER
+ return certificate.GetCertHashString(HashAlgorithmName.SHA256);
+#else
+ using var sha = SHA256.Create();
+ var hash = sha.ComputeHash(certificate.RawData);
+ return BitConverter.ToString(hash).Replace("-", string.Empty);
+#endif
+ }
+}
diff --git a/PowerForge/Services/PowerShellCompatibilityDisplayService.cs b/PowerForge/Services/PowerShellCompatibilityDisplayService.cs
new file mode 100644
index 00000000..eed5e518
--- /dev/null
+++ b/PowerForge/Services/PowerShellCompatibilityDisplayService.cs
@@ -0,0 +1,155 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+
+namespace PowerForge;
+
+internal sealed class PowerShellCompatibilityDisplayService
+{
+ public IReadOnlyList CreateHeader(string inputPath, string psEdition, string psVersion)
+ {
+ return new[]
+ {
+ Line("🔎 Analyzing PowerShell compatibility...", ConsoleColor.Cyan),
+ Line($"📁 Path: {inputPath}", ConsoleColor.White),
+ Line($"💻 Current PowerShell: {psEdition} {psVersion}".TrimEnd(), ConsoleColor.White)
+ };
+ }
+
+ public IReadOnlyList CreateInternalSummaryMessages(PowerShellCompatibilityReport report, bool showDetails, string? exportPath)
+ {
+ var messages = new List
+ {
+ $"Found {report.Files.Length} PowerShell files to analyze",
+ $"PowerShell Compatibility: {report.Summary.Status} - {report.Summary.Message}"
+ };
+
+ if (report.Summary.Recommendations.Length > 0)
+ {
+ var joined = string.Join("; ", report.Summary.Recommendations.Where(s => !string.IsNullOrWhiteSpace(s)));
+ if (!string.IsNullOrWhiteSpace(joined))
+ messages.Add($"Recommendations: {joined}");
+ }
+
+ if (showDetails)
+ {
+ foreach (var file in report.Files)
+ {
+ if (file.Issues.Length == 0)
+ continue;
+
+ var issueTypes = string.Join(
+ ", ",
+ file.Issues.Select(i => i.Type.ToString())
+ .Where(s => !string.IsNullOrWhiteSpace(s))
+ .Distinct(StringComparer.OrdinalIgnoreCase));
+ messages.Add($"Issues in {file.RelativePath}: {issueTypes}");
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(exportPath))
+ {
+ messages.Add(report.ExportPath is not null
+ ? $"Detailed report exported to: {exportPath}"
+ : $"Failed to export detailed report to: {exportPath}");
+ }
+
+ return messages;
+ }
+
+ public IReadOnlyList CreateSummary(PowerShellCompatibilitySummary summary)
+ {
+ var lines = new List
+ {
+ Line(string.Empty)
+ };
+
+ var color = summary.Status switch
+ {
+ CheckStatus.Pass => ConsoleColor.Green,
+ CheckStatus.Warning => ConsoleColor.Yellow,
+ _ => ConsoleColor.Red
+ };
+
+ var statusEmoji = summary.Status switch
+ {
+ CheckStatus.Pass => "✅",
+ CheckStatus.Warning => "⚠️",
+ _ => "❌"
+ };
+
+ lines.Add(Line($"{statusEmoji} Status: {summary.Status}", color));
+ lines.Add(Line(summary.Message, ConsoleColor.White));
+ lines.Add(Line($"PS 5.1 compatible: {summary.PowerShell51Compatible}/{summary.TotalFiles}", ConsoleColor.White));
+ lines.Add(Line($"PS 7 compatible: {summary.PowerShell7Compatible}/{summary.TotalFiles}", ConsoleColor.White));
+ lines.Add(Line(
+ $"Cross-compatible: {summary.CrossCompatible}/{summary.TotalFiles} ({summary.CrossCompatibilityPercentage.ToString("0.0", CultureInfo.InvariantCulture)}%)",
+ ConsoleColor.White));
+
+ if (summary.Recommendations.Length > 0)
+ {
+ lines.Add(Line(string.Empty));
+ lines.Add(Line("Recommendations:", ConsoleColor.Cyan));
+ foreach (var recommendation in summary.Recommendations.Where(s => !string.IsNullOrWhiteSpace(s)))
+ lines.Add(Line($"- {recommendation}", ConsoleColor.White));
+ }
+
+ return lines;
+ }
+
+ public IReadOnlyList CreateDetails(PowerShellCompatibilityFileResult[] results)
+ {
+ var lines = new List();
+ if (results.Length == 0)
+ return lines;
+
+ lines.Add(Line(string.Empty));
+ lines.Add(Line("Detailed Analysis:", ConsoleColor.Cyan));
+
+ foreach (var result in results)
+ {
+ lines.Add(Line(string.Empty));
+ lines.Add(Line(result.RelativePath, ConsoleColor.White));
+ lines.Add(Line(
+ $" PS 5.1: {(result.PowerShell51Compatible ? "Compatible" : "Not compatible")}",
+ result.PowerShell51Compatible ? ConsoleColor.Green : ConsoleColor.Red));
+ lines.Add(Line(
+ $" PS 7: {(result.PowerShell7Compatible ? "Compatible" : "Not compatible")}",
+ result.PowerShell7Compatible ? ConsoleColor.Green : ConsoleColor.Red));
+
+ if (result.Issues.Length == 0)
+ continue;
+
+ lines.Add(Line(" Issues:", ConsoleColor.Yellow));
+ foreach (var issue in result.Issues)
+ {
+ lines.Add(Line($" - {issue.Type}: {issue.Description}", ConsoleColor.Red));
+ if (!string.IsNullOrWhiteSpace(issue.Recommendation))
+ lines.Add(Line($" - {issue.Recommendation}", ConsoleColor.Cyan));
+ }
+ }
+
+ return lines;
+ }
+
+ public PowerShellCompatibilityDisplayLine CreateExportStatus(string exportPath, bool exportSucceeded)
+ {
+ return exportSucceeded
+ ? Line($"✅ Detailed report exported to: {exportPath}", ConsoleColor.Green)
+ : Line($"❌ Failed to export detailed report to: {exportPath}", ConsoleColor.Red);
+ }
+
+ public static int CalculatePercent(PowerShellCompatibilityProgress progress)
+ {
+ if (progress is null)
+ throw new ArgumentNullException(nameof(progress));
+
+ return progress.Total == 0
+ ? 0
+ : (int)Math.Round((progress.Current / (double)progress.Total) * 100.0, 0);
+ }
+
+ private static PowerShellCompatibilityDisplayLine Line(string text, ConsoleColor? color = null)
+ => new() { Text = text, Color = color };
+}
diff --git a/PowerForge/Services/PowerShellCompatibilityWorkflowService.cs b/PowerForge/Services/PowerShellCompatibilityWorkflowService.cs
new file mode 100644
index 00000000..fe216be0
--- /dev/null
+++ b/PowerForge/Services/PowerShellCompatibilityWorkflowService.cs
@@ -0,0 +1,31 @@
+using System;
+
+namespace PowerForge;
+
+internal sealed class PowerShellCompatibilityWorkflowService
+{
+ private readonly PowerShellCompatibilityAnalyzer _analyzer;
+
+ public PowerShellCompatibilityWorkflowService(PowerShellCompatibilityAnalyzer analyzer)
+ {
+ _analyzer = analyzer ?? throw new ArgumentNullException(nameof(analyzer));
+ }
+
+ public PowerShellCompatibilityWorkflowResult Execute(
+ PowerShellCompatibilityWorkflowRequest request,
+ Action? progress = null)
+ {
+ if (request is null)
+ throw new ArgumentNullException(nameof(request));
+
+ var report = _analyzer.Analyze(
+ new PowerShellCompatibilitySpec(request.InputPath, request.Recurse, request.ExcludeDirectories),
+ progress,
+ request.ExportPath);
+
+ return new PowerShellCompatibilityWorkflowResult
+ {
+ Report = report
+ };
+ }
+}
diff --git a/PowerForge/Services/ProjectBuildGitHubDisplayService.cs b/PowerForge/Services/ProjectBuildGitHubDisplayService.cs
new file mode 100644
index 00000000..726ce7d3
--- /dev/null
+++ b/PowerForge/Services/ProjectBuildGitHubDisplayService.cs
@@ -0,0 +1,42 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace PowerForge;
+
+internal sealed class ProjectBuildGitHubDisplayService
+{
+ public ProjectBuildGitHubDisplayModel CreateSummary(ProjectBuildGitHubPublishSummary summary)
+ {
+ if (summary is null)
+ throw new ArgumentNullException(nameof(summary));
+
+ var rows = new List();
+ if (!summary.PerProject)
+ {
+ rows.Add(Row("Mode", "Single"));
+ rows.Add(Row("Tag", summary.SummaryTag ?? string.Empty));
+ rows.Add(Row("Assets", summary.SummaryAssetsCount.ToString()));
+ if (!string.IsNullOrWhiteSpace(summary.SummaryReleaseUrl))
+ rows.Add(Row("Release", summary.SummaryReleaseUrl!));
+ }
+ else
+ {
+ var ok = summary.Results.Count(result => result.Success);
+ var fail = summary.Results.Count(result => !result.Success);
+ rows.Add(Row("Mode", "PerProject"));
+ rows.Add(Row("Projects", summary.Results.Count.ToString()));
+ rows.Add(Row("Succeeded", ok.ToString()));
+ rows.Add(Row("Failed", fail.ToString()));
+ }
+
+ return new ProjectBuildGitHubDisplayModel
+ {
+ Title = "GitHub Summary",
+ Rows = rows
+ };
+ }
+
+ private static ProjectBuildGitHubDisplayRow Row(string label, string value)
+ => new() { Label = label, Value = value };
+}
diff --git a/PowerForge/Services/ProjectConsistencyDisplayService.cs b/PowerForge/Services/ProjectConsistencyDisplayService.cs
new file mode 100644
index 00000000..52e630c1
--- /dev/null
+++ b/PowerForge/Services/ProjectConsistencyDisplayService.cs
@@ -0,0 +1,141 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+
+namespace PowerForge;
+
+internal sealed class ProjectConsistencyDisplayService
+{
+ public IReadOnlyList CreateAnalysisSummary(
+ string rootPath,
+ string projectType,
+ ProjectConsistencyReport report,
+ string? exportPath)
+ {
+ if (string.IsNullOrWhiteSpace(rootPath))
+ throw new ArgumentException("Root path is required.", nameof(rootPath));
+ if (string.IsNullOrWhiteSpace(projectType))
+ throw new ArgumentException("Project type is required.", nameof(projectType));
+ if (report is null)
+ throw new ArgumentNullException(nameof(report));
+
+ var summary = report.Summary;
+ var lines = new List
+ {
+ Line("Analyzing project consistency...", ConsoleColor.Cyan),
+ Line($"Project: {rootPath}"),
+ Line($"Type: {projectType}"),
+ Line($"Target encoding: {summary.RecommendedEncoding}"),
+ Line($"Target line ending: {summary.RecommendedLineEnding}"),
+ Line(string.Empty),
+ Line("Project Consistency Summary:", ConsoleColor.Cyan),
+ Line($" Total files analyzed: {summary.TotalFiles}"),
+ Line(
+ $" Files compliant with standards: {summary.FilesCompliant} ({summary.CompliancePercentage.ToString("0.0", CultureInfo.InvariantCulture)}%)",
+ ResolveComplianceColor(summary.CompliancePercentage)),
+ Line(
+ $" Files needing attention: {summary.FilesWithIssues}",
+ summary.FilesWithIssues == 0 ? ConsoleColor.Green : ConsoleColor.Red),
+ Line(string.Empty),
+ Line("Encoding Issues:", ConsoleColor.Cyan),
+ Line(
+ $" Files needing encoding conversion: {summary.FilesNeedingEncodingConversion}",
+ summary.FilesNeedingEncodingConversion == 0 ? ConsoleColor.Green : ConsoleColor.Yellow),
+ Line($" Target encoding: {summary.RecommendedEncoding}"),
+ Line(string.Empty),
+ Line("Line Ending Issues:", ConsoleColor.Cyan),
+ Line(
+ $" Files needing line ending conversion: {summary.FilesNeedingLineEndingConversion}",
+ summary.FilesNeedingLineEndingConversion == 0 ? ConsoleColor.Green : ConsoleColor.Yellow),
+ Line(
+ $" Files with mixed line endings: {summary.FilesWithMixedLineEndings}",
+ summary.FilesWithMixedLineEndings == 0 ? ConsoleColor.Green : ConsoleColor.Red),
+ Line(
+ $" Files missing final newline: {summary.FilesMissingFinalNewline}",
+ summary.FilesMissingFinalNewline == 0 ? ConsoleColor.Green : ConsoleColor.Yellow),
+ Line($" Target line ending: {summary.RecommendedLineEnding}")
+ };
+
+ if (summary.ExtensionIssues.Length > 0)
+ {
+ lines.Add(Line(string.Empty));
+ lines.Add(Line("Extensions with Issues:", ConsoleColor.Yellow));
+ foreach (var issue in summary.ExtensionIssues.OrderByDescending(i => i.Total))
+ lines.Add(Line($" {issue.Extension}: {issue.Total} files"));
+ }
+
+ if (!string.IsNullOrWhiteSpace(exportPath) && File.Exists(exportPath))
+ {
+ lines.Add(Line(string.Empty));
+ lines.Add(Line($"Detailed report exported to: {exportPath}", ConsoleColor.Green));
+ }
+
+ return lines;
+ }
+
+ public IReadOnlyList CreateSummary(
+ string rootPath,
+ ProjectConsistencyReport report,
+ ProjectConversionResult? encodingResult,
+ ProjectConversionResult? lineEndingResult,
+ string? exportPath)
+ {
+ if (string.IsNullOrWhiteSpace(rootPath))
+ throw new ArgumentException("Root path is required.", nameof(rootPath));
+ if (report is null)
+ throw new ArgumentNullException(nameof(report));
+
+ var summary = report.Summary;
+ var lines = new List
+ {
+ Line("Project Consistency Conversion", ConsoleColor.Cyan),
+ Line($"Project: {rootPath}"),
+ Line($"Target encoding: {summary.RecommendedEncoding}"),
+ Line($"Target line ending: {summary.RecommendedLineEnding}")
+ };
+
+ if (encodingResult is not null)
+ {
+ lines.Add(Line(
+ $"Encoding conversion: {encodingResult.Converted}/{encodingResult.Total} converted, {encodingResult.Skipped} skipped, {encodingResult.Errors} errors",
+ encodingResult.Errors == 0 ? ConsoleColor.Green : ConsoleColor.Red));
+ }
+
+ if (lineEndingResult is not null)
+ {
+ lines.Add(Line(
+ $"Line ending conversion: {lineEndingResult.Converted}/{lineEndingResult.Total} converted, {lineEndingResult.Skipped} skipped, {lineEndingResult.Errors} errors",
+ lineEndingResult.Errors == 0 ? ConsoleColor.Green : ConsoleColor.Red));
+ }
+
+ lines.Add(Line(string.Empty));
+ lines.Add(Line("Consistency summary:", ConsoleColor.Cyan));
+ lines.Add(Line(
+ $" Files compliant: {summary.FilesCompliant} ({summary.CompliancePercentage.ToString("0.0", CultureInfo.InvariantCulture)}%)",
+ ResolveComplianceColor(summary.CompliancePercentage)));
+ lines.Add(Line(
+ $" Files needing attention: {summary.FilesWithIssues}",
+ summary.FilesWithIssues == 0 ? ConsoleColor.Green : ConsoleColor.Red));
+
+ if (!string.IsNullOrWhiteSpace(exportPath) && File.Exists(exportPath))
+ {
+ lines.Add(Line(string.Empty));
+ lines.Add(Line($"Detailed report exported to: {exportPath}", ConsoleColor.Green));
+ }
+
+ return lines;
+ }
+
+ private static ConsoleColor ResolveComplianceColor(double compliancePercentage)
+ {
+ if (compliancePercentage >= 90)
+ return ConsoleColor.Green;
+ if (compliancePercentage >= 70)
+ return ConsoleColor.Yellow;
+ return ConsoleColor.Red;
+ }
+
+ private static ProjectConsistencyDisplayLine Line(string text, ConsoleColor? color = null)
+ => new() { Text = text, Color = color };
+}