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 }; +}