diff --git a/docs/cli-schema.json b/docs/cli-schema.json index 05a1e390..23c174b5 100644 --- a/docs/cli-schema.json +++ b/docs/cli-schema.json @@ -547,7 +547,7 @@ } }, "manifest": { - "description": "Create and modify appxmanifest.xml files for package identity and MSIX packaging. Use 'manifest generate' to create a new manifest, or 'manifest update-assets' to regenerate app icons from a source image.", + "description": "Create and modify appxmanifest.xml files for package identity and MSIX packaging. Use 'manifest generate' to create a new manifest, 'manifest update-assets' to regenerate app icons from a source image, or 'manifest validate' to check if a manifest is valid.", "hidden": false, "subcommands": { "generate": { @@ -766,6 +766,57 @@ "recursive": false } } + }, + "validate": { + "description": "Validate an AppxManifest.xml file against the schema", + "hidden": false, + "arguments": { + "manifest-path": { + "description": "Path to AppxManifest.xml or Package.appxmanifest file to validate", + "order": 0, + "hidden": false, + "valueType": "System.IO.FileInfo", + "hasDefaultValue": false, + "arity": { + "minimum": 1, + "maximum": 1 + } + } + }, + "options": { + "--quiet": { + "description": "Suppress progress messages", + "hidden": false, + "aliases": [ + "-q" + ], + "valueType": "System.Boolean", + "hasDefaultValue": true, + "defaultValue": false, + "arity": { + "minimum": 0, + "maximum": 1 + }, + "required": false, + "recursive": false + }, + "--verbose": { + "description": "Enable verbose output", + "hidden": false, + "aliases": [ + "-v" + ], + "valueType": "System.Boolean", + "hasDefaultValue": true, + "defaultValue": false, + "arity": { + "minimum": 0, + "maximum": 1 + }, + "required": false, + "recursive": false + } + } } } }, diff --git a/docs/llm-context.md b/docs/llm-context.md index 24767799..bf0915db 100644 --- a/docs/llm-context.md +++ b/docs/llm-context.md @@ -93,7 +93,7 @@ Start here for initializing a Windows app with required setup. Sets up everythin - `--verbose` / `-v` - Enable verbose output ### `winapp manifest` -Create and modify appxmanifest.xml files for package identity and MSIX packaging. Use 'manifest generate' to create a new manifest, or 'manifest update-assets' to regenerate app icons from a source image. +Create and modify appxmanifest.xml files for package identity and MSIX packaging. Use 'manifest generate' to create a new manifest, 'manifest update-assets' to regenerate app icons from a source image, or 'manifest validate' to check if a manifest is valid. #### `winapp manifest generate` @@ -125,6 +125,17 @@ Generate new assets for images referenced in an appxmanifest.xml from a single s - `--manifest` - Path to AppxManifest.xml file (default: search current directory) - `--quiet` / `-q` - Suppress progress messages - `--verbose` / `-v` - Enable verbose output + +#### `winapp manifest validate` + +Validate an AppxManifest.xml file against the schema + +**Arguments:** +- `` *(required)* - Path to AppxManifest.xml or Package.appxmanifest file to validate + +**Options:** +- `--quiet` / `-q` - Suppress progress messages +- `--verbose` / `-v` - Enable verbose output ### `winapp package` Create MSIX installer from your built app. Run after building your app. appxmanifest.xml is required for packaging - it must be in current working directory, passed as --manifest or be in the input folder. Use --cert devcert.pfx to sign for testing. Example: winapp package ./dist --manifest appxmanifest.xml --cert ./devcert.pfx diff --git a/docs/usage.md b/docs/usage.md index d8ac9e0d..e6824c65 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -299,6 +299,39 @@ winapp manifest update-assets mylogo.png --manifest ./dist/appxmanifest.xml winapp manifest update-assets mylogo.png --verbose ``` +#### manifest validate + +Validate an AppxManifest.xml file against the official Windows App SDK schemas using MakeAppx.exe. + +```bash +winapp manifest validate +``` + +**Arguments:** + +- `manifest-path` - Path to AppxManifest.xml or Package.appxmanifest file to validate + +**Description:** + +Validates an AppxManifest.xml file to ensure it conforms to the official Microsoft MSIX schemas. The command: + +- Uses MakeAppx.exe from the Windows SDK BuildTools package for complete schema validation +- Validates the XML structure, required elements, and attribute values against official schemas +- Provides detailed error messages with line numbers +- Suggests fixes for common validation errors (e.g., invalid Application Id format, Publisher format) + +The command automatically downloads the required Windows SDK BuildTools package if not already installed. + +**Examples:** + +```bash +# Validate a manifest file +winapp manifest validate ./appxmanifest.xml + +# Validate with verbose output +winapp manifest validate ./appxmanifest.xml --verbose +``` + --- ### cert diff --git a/src/winapp-CLI/WinApp.Cli.Tests/ManifestValidateCommandTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/ManifestValidateCommandTests.cs new file mode 100644 index 00000000..fc34e4fb --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli.Tests/ManifestValidateCommandTests.cs @@ -0,0 +1,495 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.DependencyInjection; +using WinApp.Cli.Commands; +using WinApp.Cli.Services; + +namespace WinApp.Cli.Tests; + +[TestClass] +public class ManifestValidateCommandTests : BaseCommandTests +{ + protected override IServiceCollection ConfigureServices(IServiceCollection services) + { + return services + .AddSingleton(); + } + + [TestMethod] + public void ManifestCommandShouldHaveValidateSubcommand() + { + // Arrange & Act + var manifestCommand = GetRequiredService(); + + // Assert + Assert.IsNotNull(manifestCommand, "ManifestCommand should be created"); + Assert.AreEqual("manifest", manifestCommand.Name, "Command name should be 'manifest'"); + Assert.IsTrue(manifestCommand.Subcommands.Any(c => c.Name == "validate"), "Should have 'validate' subcommand"); + } + + [TestMethod] + public async Task ManifestValidateCommand_WithValidManifest_ShouldSucceed() + { + // Arrange + var validateCommand = GetRequiredService(); + + // Create a valid manifest + var manifestPath = Path.Combine(_tempDirectory.FullName, "appxmanifest.xml"); + var validManifest = """ + + + + + + + Test App + Test Publisher + Assets\StoreLogo.png + + + + + + + + + + + + + + + + + + + + + + """; + await File.WriteAllTextAsync(manifestPath, validManifest, TestContext.CancellationToken); + + var args = new[] { manifestPath }; + + // Act + var exitCode = await ParseAndInvokeWithCaptureAsync(validateCommand, args); + + // Assert + Assert.AreEqual(0, exitCode, "Validate command should succeed for valid manifest"); + // Output may go to TestAnsiConsole or ConsoleStdOut, check both + var combinedOutput = ConsoleStdOut.ToString() + TestAnsiConsole.Output; + StringAssert.Contains(combinedOutput, "valid", "Output should indicate manifest is valid"); + } + + [TestMethod] + public async Task ManifestValidateCommand_WithMalformedXml_ShouldFail() + { + // Arrange + var validateCommand = GetRequiredService(); + + // Create a malformed manifest (unclosed tag) + var manifestPath = Path.Combine(_tempDirectory.FullName, "appxmanifest.xml"); + var malformedManifest = """ + + + + + Test App + + """; + await File.WriteAllTextAsync(manifestPath, malformedManifest, TestContext.CancellationToken); + + var args = new[] { manifestPath }; + + // Act + var exitCode = await ParseAndInvokeWithCaptureAsync(validateCommand, args); + + // Assert + Assert.AreEqual(1, exitCode, "Validate command should fail for malformed XML"); + } + + [TestMethod] + public async Task ManifestValidateCommand_WithMissingRequiredElements_ShouldFail() + { + // Arrange + var validateCommand = GetRequiredService(); + + // Create a manifest missing required elements (no Properties, Dependencies, Resources, Applications) + var manifestPath = Path.Combine(_tempDirectory.FullName, "appxmanifest.xml"); + var invalidManifest = """ + + + + + """; + await File.WriteAllTextAsync(manifestPath, invalidManifest, TestContext.CancellationToken); + + var args = new[] { manifestPath }; + + // Act + var exitCode = await ParseAndInvokeWithCaptureAsync(validateCommand, args); + + // Assert + Assert.AreEqual(1, exitCode, "Validate command should fail for manifest missing required elements"); + } + + [TestMethod] + public async Task ManifestValidateCommand_WithInvalidVersionFormat_ShouldFail() + { + // Arrange + var validateCommand = GetRequiredService(); + + // Create a manifest with invalid version format + var manifestPath = Path.Combine(_tempDirectory.FullName, "appxmanifest.xml"); + var invalidManifest = """ + + + + + + + Test App + Test Publisher + Assets\StoreLogo.png + + + + + + + + + + + + + + + + + + + + + + """; + await File.WriteAllTextAsync(manifestPath, invalidManifest, TestContext.CancellationToken); + + var args = new[] { manifestPath }; + + // Act + var exitCode = await ParseAndInvokeWithCaptureAsync(validateCommand, args); + + // Assert + Assert.AreEqual(1, exitCode, "Validate command should fail for manifest with invalid version format"); + // Errors now go through status service rendering to stdout + var output = ConsoleStdOut.ToString() + TestAnsiConsole.Output; + // MakeAppx reports pattern constraint violations for invalid versions + StringAssert.Contains(output, "invalid-version", "Error message should show the invalid version value"); + } + + [TestMethod] + public async Task ManifestValidateCommand_WithInvalidPublisherFormat_ShouldFail() + { + // Arrange + var validateCommand = GetRequiredService(); + + // Create a manifest with invalid publisher format (missing CN=) + var manifestPath = Path.Combine(_tempDirectory.FullName, "appxmanifest.xml"); + var invalidManifest = """ + + + + + + + Test App + Test Publisher + Assets\StoreLogo.png + + + + + + + + + + + + + + + + + + + + + + """; + await File.WriteAllTextAsync(manifestPath, invalidManifest, TestContext.CancellationToken); + + var args = new[] { manifestPath }; + + // Act + var exitCode = await ParseAndInvokeWithCaptureAsync(validateCommand, args); + + // Assert + Assert.AreEqual(1, exitCode, "Validate command should fail for manifest with invalid publisher format"); + // Errors now go through status service rendering to stdout + var output = ConsoleStdOut.ToString() + TestAnsiConsole.Output; + // MakeAppx reports pattern constraint violations for invalid publisher + StringAssert.Contains(output, "InvalidPublisher", "Error message should show the invalid publisher value"); + } + + [TestMethod] + public async Task ManifestValidateCommand_WithInvalidApplicationId_ShouldShowFriendlyError() + { + // Arrange + var validateCommand = GetRequiredService(); + + // Create a manifest with invalid Application Id (contains hyphen) + var manifestPath = Path.Combine(_tempDirectory.FullName, "appxmanifest.xml"); + var invalidManifest = """ + + + + + + + Test App + Test Publisher + Assets\StoreLogo.png + + + + + + + + + + + + + + + + + + + + + + """; + await File.WriteAllTextAsync(manifestPath, invalidManifest, TestContext.CancellationToken); + + var args = new[] { manifestPath }; + + // Act + var exitCode = await ParseAndInvokeWithCaptureAsync(validateCommand, args); + + // Assert + Assert.AreEqual(1, exitCode, "Validate command should fail for manifest with invalid Application Id"); + // Errors now go through status service rendering to stdout + var output = ConsoleStdOut.ToString() + TestAnsiConsole.Output; + // Should show friendly error with tip (merged from structural validation) + StringAssert.Contains(output, "Application Id", "Error message should mention Application Id"); + StringAssert.Contains(output, "Tip", "Error should include a tip for how to fix"); + } + + [TestMethod] + public async Task ManifestValidateCommand_WithNonexistentFile_ShouldFailToParse() + { + // Arrange + var validateCommand = GetRequiredService(); + var args = new[] { Path.Combine(_tempDirectory.FullName, "nonexistent.xml") }; + + // Act + var parseResult = validateCommand.Parse(args); + var hasErrors = parseResult.Errors.Any(); + + // Assert + Assert.IsTrue(hasErrors, "Parse should fail for nonexistent file"); + } + + [TestMethod] + public async Task ManifestValidateCommand_WithDesktopNamespaces_ShouldValidate() + { + // Arrange + var validateCommand = GetRequiredService(); + + // Create a manifest with desktop namespaces + var manifestPath = Path.Combine(_tempDirectory.FullName, "appxmanifest.xml"); + var validManifest = """ + + + + + + + Desktop App + Test Publisher + Assets\StoreLogo.png + + + + + + + + + + + + + + + + + + + + + + """; + await File.WriteAllTextAsync(manifestPath, validManifest, TestContext.CancellationToken); + + var args = new[] { manifestPath }; + + // Act + var exitCode = await ParseAndInvokeWithCaptureAsync(validateCommand, args); + + // Assert + Assert.AreEqual(0, exitCode, "Validate command should succeed for valid manifest with desktop namespaces"); + } + + [TestMethod] + public async Task ManifestValidateCommand_ShowsLineNumberOnError() + { + // Arrange + var validateCommand = GetRequiredService(); + + // Create a manifest with an error that has a specific line + var manifestPath = Path.Combine(_tempDirectory.FullName, "appxmanifest.xml"); + var invalidManifest = """ + + + + + Test App + Test Publisher + Assets\StoreLogo.png + + + + + + + + + """; + await File.WriteAllTextAsync(manifestPath, invalidManifest, TestContext.CancellationToken); + + var args = new[] { manifestPath }; + + // Act + var exitCode = await ParseAndInvokeWithCaptureAsync(validateCommand, args); + + // Assert + Assert.AreEqual(1, exitCode, "Validate command should fail for invalid manifest"); + // Errors now go through status service rendering to stdout + var output = ConsoleStdOut.ToString() + TestAnsiConsole.Output; + // Should contain line number information in error output ("at line X") + StringAssert.Contains(output, "line", "Error should contain line number information"); + } +} diff --git a/src/winapp-CLI/WinApp.Cli.Tests/SignCommandTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/SignCommandTests.cs index 23c8f08c..c46533de 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/SignCommandTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/SignCommandTests.cs @@ -604,9 +604,11 @@ await _certificateService.GenerateDevCertificateAsync( // Assert Assert.AreEqual(1, exitCode, "Sign command should fail when publishers don't match"); - var errorMessage = ConsoleStdErr.ToString().Trim(); + // Errors should be logged to stderr for CLI convention + var errorOutput = ConsoleStdErr.ToString(); - Assert.Contains("Failed to sign file: error 0x8007000B: The app manifest publisher name (CN=Right) must match the subject name of the signing certificate (CN=Wrong).", errorMessage, - "Expected specific error message about publisher mismatch with error code 0x8007000B"); + Assert.Contains("error 0x8007000B", errorOutput, "Expected error code 0x8007000B in stderr"); + Assert.Contains("CN=Right", errorOutput, "Expected manifest publisher CN=Right in stderr"); + Assert.Contains("CN=Wrong", errorOutput, "Expected certificate publisher CN=Wrong in stderr"); } } diff --git a/src/winapp-CLI/WinApp.Cli/Commands/ManifestCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/ManifestCommand.cs index 5c3ec4d9..61496fcb 100644 --- a/src/winapp-CLI/WinApp.Cli/Commands/ManifestCommand.cs +++ b/src/winapp-CLI/WinApp.Cli/Commands/ManifestCommand.cs @@ -7,10 +7,11 @@ namespace WinApp.Cli.Commands; internal class ManifestCommand : Command { - public ManifestCommand(ManifestGenerateCommand manifestGenerateCommand, ManifestUpdateAssetsCommand manifestUpdateAssetsCommand) - : base("manifest", "Create and modify appxmanifest.xml files for package identity and MSIX packaging. Use 'manifest generate' to create a new manifest, or 'manifest update-assets' to regenerate app icons from a source image.") + public ManifestCommand(ManifestGenerateCommand manifestGenerateCommand, ManifestUpdateAssetsCommand manifestUpdateAssetsCommand, ManifestValidateCommand manifestValidateCommand) + : base("manifest", "Create and modify appxmanifest.xml files for package identity and MSIX packaging. Use 'manifest generate' to create a new manifest, 'manifest update-assets' to regenerate app icons from a source image, or 'manifest validate' to check if a manifest is valid.") { Subcommands.Add(manifestGenerateCommand); Subcommands.Add(manifestUpdateAssetsCommand); + Subcommands.Add(manifestValidateCommand); } } diff --git a/src/winapp-CLI/WinApp.Cli/Commands/ManifestValidateCommand.cs b/src/winapp-CLI/WinApp.Cli/Commands/ManifestValidateCommand.cs new file mode 100644 index 00000000..991838e0 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Commands/ManifestValidateCommand.cs @@ -0,0 +1,667 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using System.CommandLine; +using System.CommandLine.Invocation; +using System.Text.RegularExpressions; +using System.Xml; +using System.Xml.Linq; +using WinApp.Cli.ConsoleTasks; +using WinApp.Cli.Helpers; +using WinApp.Cli.Services; +using WinApp.Cli.Tools; +using static WinApp.Cli.Services.BuildToolsService; + +namespace WinApp.Cli.Commands; + +internal partial class ManifestValidateCommand : Command +{ + public static Argument ManifestArgument { get; } + + static ManifestValidateCommand() + { + ManifestArgument = new Argument("manifest-path") + { + Description = "Path to AppxManifest.xml or Package.appxmanifest file to validate" + }; + ManifestArgument.AcceptExistingOnly(); + } + + public ManifestValidateCommand() : base("validate", "Validate an AppxManifest.xml file against the schema") + { + Arguments.Add(ManifestArgument); + } + + /// + /// Represents a validation error with line number and optional suggestion. + /// + private sealed record ValidationError(int LineNumber, string Message, string? Suggestion = null) + { + public string Format() + { + var result = LineNumber > 0 + ? $"Error at Line {LineNumber}:\n {Message}" + : $"Error:\n {Message}"; + if (!string.IsNullOrEmpty(Suggestion)) + { + result += $"\n Suggestion: {Suggestion}"; + } + return result; + } + } + + public partial class Handler( + IStatusService statusService, + IBuildToolsService buildToolsService, + ILogger logger) : AsynchronousCommandLineAction + { + // AppxManifest namespaces + private static readonly XNamespace FoundationNs = "http://schemas.microsoft.com/appx/manifest/foundation/windows10"; + private static readonly XNamespace UapNs = "http://schemas.microsoft.com/appx/manifest/uap/windows10"; + + // Version pattern: Major.Minor.Build.Revision (all non-negative integers) + [GeneratedRegex(@"^\d+\.\d+\.\d+\.\d+$")] + private static partial Regex VersionPattern(); + + // Publisher pattern: Must start with CN= or other X.500 distinguished name attributes + [GeneratedRegex(@"^(CN|O|OU|E|C|S|L|STREET|T|G|I|SN|DC|SERIALNUMBER|Description|PostalCode|POBox|Phone|X21Address|dnQualifier|)=.+", RegexOptions.IgnoreCase)] + private static partial Regex PublisherPattern(); + + // Package name pattern: letters, numbers, dots, and hyphens + [GeneratedRegex(@"^[A-Za-z0-9\.\-]+$")] + private static partial Regex PackageNamePattern(); + + // Application ID pattern: starts with letter, contains only letters and numbers + [GeneratedRegex(@"^[A-Za-z][A-Za-z0-9]*$")] + private static partial Regex ApplicationIdPattern(); + + // MakeAppx line number pattern: "Line X, Column Y" or "Line X," + [GeneratedRegex(@"Line\s+(\d+)", RegexOptions.IgnoreCase)] + private static partial Regex MakeAppxLinePattern(); + + // Error code pattern for MakeAppx output + [GeneratedRegex(@"^error [A-Z0-9]+:\s*")] + private static partial Regex ErrorCodePattern(); + + public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) + { + var manifestPath = parseResult.GetRequiredValue(ManifestArgument); + + logger.LogDebug("Validating manifest at: {ManifestPath}", manifestPath.FullName); + + return await statusService.ExecuteWithStatusAsync("Validating AppxManifest", async (taskContext, cancellationToken) => + { + try + { + // Run MakeAppx validation (source of truth) + var makeAppxErrors = await ValidateWithMakeAppxAsync(manifestPath, taskContext, cancellationToken); + + if (makeAppxErrors.Count == 0) + { + // MakeAppx says valid - we're done! + return (0, $"{UiSymbols.Check} Manifest is valid: {manifestPath.Name}"); + } + + // MakeAppx found errors - run structural validation to get friendly messages + logger.LogDebug("MakeAppx found {Count} error(s), running structural validation for friendly messages", makeAppxErrors.Count); + var structuralErrors = await ValidateStructuralAsync(manifestPath, logger, cancellationToken); + logger.LogDebug("Structural validation found {Count} error(s)", structuralErrors.Count); + + // Merge errors: use our friendly message if line numbers match, otherwise use MakeAppx message + var mergedErrors = MergeErrors(makeAppxErrors, structuralErrors, logger); + + // Build error output + var errorLines = new List(); + foreach (var error in mergedErrors) + { + var lineInfo = error.LineNumber > 0 ? $" at line {error.LineNumber}" : ""; + errorLines.Add($"{UiSymbols.Error} Error{lineInfo}: {error.Message}"); + + if (!string.IsNullOrEmpty(error.Suggestion)) + { + errorLines.Add($" Tip: {error.Suggestion}"); + } + } + errorLines.Add($"{UiSymbols.Error} Manifest validation failed with {mergedErrors.Count} error(s)."); + + // Return the error output as the completed message (starts with [ so no prefix added) + return (1, string.Join("\n", errorLines)); + } + catch (XmlException ex) + { + var errorMessage = FormatXmlError(ex); + var output = $"{errorMessage}\n{UiSymbols.Error} Manifest is not valid XML."; + return (1, output); + } + catch (Exception ex) + { + return (1, $"{UiSymbols.Error} Error validating manifest: {ex.Message}"); + } + }, cancellationToken); + } + + /// + /// Merges MakeAppx errors with structural validation errors. + /// If a structural error has the same line number as a MakeAppx error, use the structural error (friendlier message). + /// Otherwise, use the MakeAppx error. + /// + private static List MergeErrors(List makeAppxErrors, List structuralErrors, ILogger logger) + { + var result = new List(); + var structuralByLine = structuralErrors + .Where(e => e.LineNumber > 0) + .GroupBy(e => e.LineNumber) + .ToDictionary(g => g.Key, g => g.First()); + + logger.LogDebug("Merging errors - structural errors available at lines: {Lines}", + string.Join(", ", structuralByLine.Keys.OrderBy(k => k))); + + foreach (var makeAppxError in makeAppxErrors) + { + if (makeAppxError.LineNumber > 0 && structuralByLine.TryGetValue(makeAppxError.LineNumber, out var structuralError)) + { + // We have a matching structural error - use it for the friendly message + logger.LogDebug("Line {Line}: Using friendly message '{FriendlyMessage}' instead of MakeAppx message '{MakeAppxMessage}'", + makeAppxError.LineNumber, structuralError.Message, makeAppxError.Message); + result.Add(structuralError); + } + else + { + // No matching structural error - use MakeAppx error as-is + logger.LogDebug("Line {Line}: No friendly message available, using MakeAppx message '{Message}'", + makeAppxError.LineNumber, makeAppxError.Message); + result.Add(makeAppxError); + } + } + + return result; + } + + /// + /// Validates the manifest using MakeAppx.exe with /nv flag (skips file validation but keeps schema validation). + /// + private async Task> ValidateWithMakeAppxAsync( + FileInfo manifestPath, + ConsoleTasks.TaskContext taskContext, + CancellationToken cancellationToken) + { + var errors = new List(); + + // Create a temporary directory for validation + var tempDir = Directory.CreateTempSubdirectory("winapp-manifest-validate-"); + var tempMsix = Path.Combine(tempDir.FullName, "validate.msix"); + logger.LogDebug("Created temp directory for validation: {TempDir}", tempDir.FullName); + + try + { + // Copy the manifest to the temp directory as AppxManifest.xml + var destManifest = Path.Combine(tempDir.FullName, "AppxManifest.xml"); + File.Copy(manifestPath.FullName, destManifest, overwrite: true); + logger.LogDebug("Copied manifest to temp location: {DestManifest}", destManifest); + + // Run MakeAppx.exe with /nv to skip file existence validation but keep schema validation + // The /nv flag skips: file existence checks, ContentGroupMap validation, Protocol/FileTypeAssociation semantic checks + // But it STILL validates the manifest schema! + var arguments = $@"pack /nv /o /d ""{tempDir.FullName}"" /p ""{tempMsix}"""; + + logger.LogDebug("Running MakeAppx pack with /nv flag (validates schema, skips file checks): {Arguments}", arguments); + + var (stdout, stderr) = await buildToolsService.RunBuildToolAsync( + new MakeAppxTool(), + arguments, + taskContext, + printErrors: false, // We'll parse and format errors ourselves + cancellationToken: cancellationToken); + + // Parse MakeAppx output for validation errors + var combinedOutput = stdout + "\n" + stderr; + errors.AddRange(ParseMakeAppxErrors(combinedOutput)); + logger.LogDebug("MakeAppx completed successfully, parsed {Count} error(s) from output", errors.Count); + } + catch (InvalidBuildToolException ex) + { + // MakeAppx returned non-zero exit code - parse errors from output + logger.LogDebug("MakeAppx returned non-zero exit code, parsing errors from output"); + var combinedOutput = ex.Stdout + "\n" + ex.Stderr; + var parsedErrors = ParseMakeAppxErrors(combinedOutput); + + if (parsedErrors.Count > 0) + { + logger.LogDebug("Parsed {Count} error(s) from MakeAppx output", parsedErrors.Count); + errors.AddRange(parsedErrors); + } + else + { + // Couldn't parse specific errors, show generic message + logger.LogDebug("Could not parse specific errors, using generic message"); + errors.Add(new ValidationError(0, $"MakeAppx validation failed: {ex.Message}")); + if (!string.IsNullOrWhiteSpace(ex.Stderr)) + { + errors.Add(new ValidationError(0, ex.Stderr.Trim())); + } + } + } + finally + { + // Clean up temp directory + try + { + logger.LogDebug("Cleaning up temp directory: {TempDir}", tempDir.FullName); + tempDir.Delete(recursive: true); + } + catch + { + // Ignore cleanup errors + } + } + + return errors; + } + + /// + /// Parses MakeAppx.exe output for validation error messages. + /// + private static List ParseMakeAppxErrors(string output) + { + var errors = new List(); + + // MakeAppx error patterns: + // "MakeAppx : error: Manifest validation error: Line X, Column Y, Reason: ..." + // "MakeAppx : error: Error info: error CXXXXXX: App manifest validation error: ..." + var lines = output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + + foreach (var line in lines) + { + if (line.Contains("MakeAppx : error:", StringComparison.OrdinalIgnoreCase)) + { + // Extract the error message after "MakeAppx : error:" + var errorIndex = line.IndexOf("MakeAppx : error:", StringComparison.OrdinalIgnoreCase); + var errorMessage = line[(errorIndex + "MakeAppx : error:".Length)..].Trim(); + + // Skip generic "Package creation failed" messages + if (errorMessage.StartsWith("Package creation failed", StringComparison.OrdinalIgnoreCase) || + errorMessage.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + // Extract line number if present + int lineNumber = 0; + var lineMatch = MakeAppxLinePattern().Match(errorMessage); + if (lineMatch.Success && int.TryParse(lineMatch.Groups[1].Value, out var parsedLine)) + { + lineNumber = parsedLine; + } + + // Format the error nicely + if (errorMessage.StartsWith("Manifest validation error:", StringComparison.OrdinalIgnoreCase)) + { + errorMessage = errorMessage["Manifest validation error:".Length..].Trim(); + } + else if (errorMessage.StartsWith("Error info:", StringComparison.OrdinalIgnoreCase)) + { + errorMessage = errorMessage["Error info:".Length..].Trim(); + // Remove error code prefix like "error C00CE169:" + var codeMatch = ErrorCodePattern().Match(errorMessage); + if (codeMatch.Success) + { + errorMessage = errorMessage[codeMatch.Length..]; + } + } + + if (!string.IsNullOrWhiteSpace(errorMessage)) + { + errors.Add(new ValidationError(lineNumber, errorMessage)); + } + } + } + + return errors; + } + + private static async Task> ValidateStructuralAsync( + FileInfo manifestPath, + ILogger logger, + CancellationToken cancellationToken) + { + logger.LogDebug("Starting structural validation of manifest"); + var errors = new List(); + + // Load the XML with line info for better error reporting + XDocument doc; + using (var stream = manifestPath.OpenRead()) + { + var settings = new XmlReaderSettings + { + Async = true, + IgnoreWhitespace = false, + IgnoreComments = true + }; + + using var reader = XmlReader.Create(stream, settings); + doc = await XDocument.LoadAsync(reader, LoadOptions.SetLineInfo, cancellationToken); + } + + var root = doc.Root; + if (root == null) + { + errors.Add(new ValidationError(1, "Empty XML document")); + return errors; + } + + // Validate root element is Package + if (root.Name.LocalName != "Package") + { + AddError(errors, root, $"Root element must be 'Package', found '{root.Name.LocalName}'"); + return errors; + } + + // Validate required elements + ValidateIdentity(root, errors); + ValidateProperties(root, errors); + ValidateDependencies(root, errors); + ValidateResources(root, errors); + ValidateApplications(root, errors); + + return errors; + } + + private static void ValidateIdentity(XElement root, List errors) + { + var identity = root.Element(FoundationNs + "Identity"); + if (identity == null) + { + AddError(errors, root, "Missing required element: Identity", + "Add an element with Name, Publisher, and Version attributes."); + return; + } + + // Validate Name attribute + var name = identity.Attribute("Name")?.Value; + if (string.IsNullOrWhiteSpace(name)) + { + AddError(errors, identity, "Identity element missing required 'Name' attribute", + "Add a Name attribute (e.g., Name=\"MyApp\")."); + } + else if (name.Length > 50 || !PackageNamePattern().IsMatch(name)) + { + AddError(errors, identity, $"Invalid package name: '{name}'", + "Package name must be 1-50 characters and contain only letters, numbers, dots, and hyphens."); + } + + // Validate Publisher attribute + var publisher = identity.Attribute("Publisher")?.Value; + if (string.IsNullOrWhiteSpace(publisher)) + { + AddError(errors, identity, "Identity element missing required 'Publisher' attribute", + "Add a Publisher attribute (e.g., Publisher=\"CN=YourName\")."); + } + else if (!PublisherPattern().IsMatch(publisher)) + { + AddError(errors, identity, $"Invalid publisher format: '{publisher}'", + "Publisher must be a valid X.500 distinguished name starting with CN=, O=, etc. (e.g., CN=Contoso)."); + } + + // Validate Version attribute + var version = identity.Attribute("Version")?.Value; + if (string.IsNullOrWhiteSpace(version)) + { + AddError(errors, identity, "Identity element missing required 'Version' attribute", + "Add a Version attribute (e.g., Version=\"1.0.0.0\")."); + } + else if (!VersionPattern().IsMatch(version)) + { + AddError(errors, identity, $"Invalid version format: '{version}'", + "Version must be in format 'Major.Minor.Build.Revision' (e.g., 1.0.0.0)."); + } + } + + private static void ValidateProperties(XElement root, List errors) + { + var properties = root.Element(FoundationNs + "Properties"); + if (properties == null) + { + AddError(errors, root, "Missing required element: Properties", + "Add a element with DisplayName, PublisherDisplayName, and Logo."); + return; + } + + // Validate DisplayName + var displayName = properties.Element(FoundationNs + "DisplayName"); + if (displayName == null || string.IsNullOrWhiteSpace(displayName.Value)) + { + AddError(errors, properties, "Properties element missing required 'DisplayName' element", + "Add a element with your app's display name."); + } + else if (displayName.Value.Length > 256) + { + AddError(errors, displayName, "DisplayName exceeds maximum length of 256 characters"); + } + + // Validate PublisherDisplayName + var publisherDisplayName = properties.Element(FoundationNs + "PublisherDisplayName"); + if (publisherDisplayName == null || string.IsNullOrWhiteSpace(publisherDisplayName.Value)) + { + AddError(errors, properties, "Properties element missing required 'PublisherDisplayName' element", + "Add a element with your publisher's display name."); + } + + // Validate Logo + var logo = properties.Element(FoundationNs + "Logo"); + if (logo == null || string.IsNullOrWhiteSpace(logo.Value)) + { + AddError(errors, properties, "Properties element missing required 'Logo' element", + "Add a element with path to your store logo (e.g., Assets\\StoreLogo.png)."); + } + } + + private static void ValidateDependencies(XElement root, List errors) + { + var dependencies = root.Element(FoundationNs + "Dependencies"); + if (dependencies == null) + { + AddError(errors, root, "Missing required element: Dependencies", + "Add a element with TargetDeviceFamily."); + return; + } + + // Validate TargetDeviceFamily + var targetDeviceFamily = dependencies.Element(FoundationNs + "TargetDeviceFamily"); + if (targetDeviceFamily == null) + { + AddError(errors, dependencies, "Dependencies element missing required 'TargetDeviceFamily' element", + "Add a element specifying the target Windows version."); + return; + } + + var familyName = targetDeviceFamily.Attribute("Name")?.Value; + if (string.IsNullOrWhiteSpace(familyName)) + { + AddError(errors, targetDeviceFamily, "TargetDeviceFamily missing required 'Name' attribute", + "Add Name attribute (e.g., Name=\"Windows.Desktop\" or Name=\"Windows.Universal\")."); + } + + var minVersion = targetDeviceFamily.Attribute("MinVersion")?.Value; + if (string.IsNullOrWhiteSpace(minVersion)) + { + AddError(errors, targetDeviceFamily, "TargetDeviceFamily missing required 'MinVersion' attribute", + "Add MinVersion attribute (e.g., MinVersion=\"10.0.18362.0\")."); + } + else if (!VersionPattern().IsMatch(minVersion)) + { + AddError(errors, targetDeviceFamily, $"Invalid MinVersion format: '{minVersion}'", + "MinVersion must be in format 'Major.Minor.Build.Revision' (e.g., 10.0.18362.0)."); + } + + var maxVersionTested = targetDeviceFamily.Attribute("MaxVersionTested")?.Value; + if (string.IsNullOrWhiteSpace(maxVersionTested)) + { + AddError(errors, targetDeviceFamily, "TargetDeviceFamily missing required 'MaxVersionTested' attribute", + "Add MaxVersionTested attribute (e.g., MaxVersionTested=\"10.0.26100.0\")."); + } + else if (!VersionPattern().IsMatch(maxVersionTested)) + { + AddError(errors, targetDeviceFamily, $"Invalid MaxVersionTested format: '{maxVersionTested}'", + "MaxVersionTested must be in format 'Major.Minor.Build.Revision' (e.g., 10.0.26100.0)."); + } + } + + private static void ValidateResources(XElement root, List errors) + { + var resources = root.Element(FoundationNs + "Resources"); + if (resources == null) + { + AddError(errors, root, "Missing required element: Resources", + "Add a element with at least one Resource element."); + return; + } + + var resourceElements = resources.Elements(FoundationNs + "Resource").ToList(); + if (resourceElements.Count == 0) + { + AddError(errors, resources, "Resources element must contain at least one Resource element", + "Add a element."); + } + } + + private static void ValidateApplications(XElement root, List errors) + { + var applications = root.Element(FoundationNs + "Applications"); + if (applications == null) + { + AddError(errors, root, "Missing required element: Applications", + "Add an element with at least one Application."); + return; + } + + var appElements = applications.Elements(FoundationNs + "Application").ToList(); + if (appElements.Count == 0) + { + AddError(errors, applications, "Applications element must contain at least one Application element", + "Add an element defining your app's entry point."); + return; + } + + foreach (var app in appElements) + { + ValidateApplication(app, errors); + } + } + + private static void ValidateApplication(XElement app, List errors) + { + // Validate Id + var id = app.Attribute("Id")?.Value; + if (string.IsNullOrWhiteSpace(id)) + { + AddError(errors, app, "Application element missing required 'Id' attribute", + "Add an Id attribute (e.g., Id=\"App\")."); + } + else if (!ApplicationIdPattern().IsMatch(id)) + { + AddError(errors, app, $"Invalid Application Id: '{id}'", + "Application Id must start with a letter and contain only letters and numbers."); + } + + // Validate Executable + var executable = app.Attribute("Executable")?.Value; + if (string.IsNullOrWhiteSpace(executable)) + { + AddError(errors, app, "Application element missing required 'Executable' attribute", + "Add an Executable attribute (e.g., Executable=\"MyApp.exe\")."); + } + + // Validate EntryPoint + var entryPoint = app.Attribute("EntryPoint")?.Value; + if (string.IsNullOrWhiteSpace(entryPoint)) + { + AddError(errors, app, "Application element missing required 'EntryPoint' attribute", + "Add an EntryPoint attribute (e.g., EntryPoint=\"Windows.FullTrustApplication\")."); + } + + // Validate VisualElements + var visualElements = app.Element(UapNs + "VisualElements"); + if (visualElements == null) + { + AddError(errors, app, "Application element missing required 'uap:VisualElements' element", + "Add a element with DisplayName, Description, and logo attributes."); + return; + } + + var veDisplayName = visualElements.Attribute("DisplayName")?.Value; + if (string.IsNullOrWhiteSpace(veDisplayName)) + { + AddError(errors, visualElements, "VisualElements missing required 'DisplayName' attribute", + "Add a DisplayName attribute to VisualElements."); + } + + var description = visualElements.Attribute("Description")?.Value; + if (string.IsNullOrWhiteSpace(description)) + { + AddError(errors, visualElements, "VisualElements missing required 'Description' attribute", + "Add a Description attribute to VisualElements."); + } + + var square150 = visualElements.Attribute("Square150x150Logo")?.Value; + if (string.IsNullOrWhiteSpace(square150)) + { + AddError(errors, visualElements, "VisualElements missing required 'Square150x150Logo' attribute", + "Add a Square150x150Logo attribute (e.g., Square150x150Logo=\"Assets\\Square150x150Logo.png\")."); + } + + var square44 = visualElements.Attribute("Square44x44Logo")?.Value; + if (string.IsNullOrWhiteSpace(square44)) + { + AddError(errors, visualElements, "VisualElements missing required 'Square44x44Logo' attribute", + "Add a Square44x44Logo attribute (e.g., Square44x44Logo=\"Assets\\Square44x44Logo.png\")."); + } + } + + private static void AddError(List errors, XElement element, string message, string? suggestion = null) + { + var lineInfo = (IXmlLineInfo)element; + var lineNumber = lineInfo.HasLineInfo() ? lineInfo.LineNumber : 0; + errors.Add(new ValidationError(lineNumber, message, suggestion)); + } + + private static string FormatXmlError(XmlException ex) + { + var suggestion = GetXmlFixSuggestion(ex.Message); + if (ex.LineNumber > 0) + { + return $"XML Error at line {ex.LineNumber}, position {ex.LinePosition}:\n" + + $" {ex.Message}\n" + + (string.IsNullOrEmpty(suggestion) ? "" : $" Suggestion: {suggestion}"); + } + + return $"XML Error: {ex.Message}"; + } + + private static string GetXmlFixSuggestion(string errorMessage) + { + if (errorMessage.Contains("unexpected end", StringComparison.OrdinalIgnoreCase)) + { + return "The XML file appears to be truncated. Ensure all elements are properly closed."; + } + + if (errorMessage.Contains("encoding", StringComparison.OrdinalIgnoreCase)) + { + return "The XML file should use UTF-8 encoding. Check the XML declaration at the top of the file."; + } + + if (errorMessage.Contains("invalid character", StringComparison.OrdinalIgnoreCase)) + { + return "Remove or encode invalid characters. Special characters should use XML entities (e.g., & for &)."; + } + + if (errorMessage.Contains("not closed", StringComparison.OrdinalIgnoreCase) || + errorMessage.Contains("end tag", StringComparison.OrdinalIgnoreCase)) + { + return "Check that all XML elements have matching opening and closing tags."; + } + + return "Check the XML syntax. Ensure proper tag formatting, attribute quoting, and character encoding."; + } + } +} diff --git a/src/winapp-CLI/WinApp.Cli/ConsoleTasks/GroupableTask.cs b/src/winapp-CLI/WinApp.Cli/ConsoleTasks/GroupableTask.cs index 810fc8e9..360e5623 100644 --- a/src/winapp-CLI/WinApp.Cli/ConsoleTasks/GroupableTask.cs +++ b/src/winapp-CLI/WinApp.Cli/ConsoleTasks/GroupableTask.cs @@ -80,19 +80,15 @@ public IRenderable Render() int maxDepth = _logger.IsEnabled(LogLevel.Debug) ? int.MaxValue : 1; RenderTask(this, sb, 0, string.Empty, maxDepth); var allTasksString = sb.ToString().TrimEnd([.. Environment.NewLine]); + + // Add trailing newline only for single-line content to ensure proper rendering if (!allTasksString.Contains(Environment.NewLine)) { allTasksString = $"{allTasksString}{Environment.NewLine}"; } - var panel = new Panel(allTasksString) - { - Border = BoxBorder.None, - Padding = new Padding(0, 0), - Expand = true - }; - - return panel; + // Use Text widget to avoid Spectre markup parsing issues with content containing brackets + return new Text(allTasksString); } private static void RenderSubTasks(GroupableTask task, StringBuilder sb, int indentLevel, int maxForcedDepth) @@ -116,8 +112,14 @@ private static void RenderTask(GroupableTask task, StringBuilder sb, int indentL if (task.IsCompleted) { - static string FormatCheckMarkMessage(string indentStr, string message) + static string FormatCompletedMessage(string indentStr, string message, bool isError) { + // If message is empty, return empty string (don't show just a checkmark) + if (string.IsNullOrEmpty(message)) + { + return string.Empty; + } + bool firstCharIsEmojiOrOpenBracket = false; if (message.Length > 0) { @@ -126,20 +128,31 @@ static string FormatCheckMarkMessage(string indentStr, string message) || char.GetUnicodeCategory(firstChar) == System.Globalization.UnicodeCategory.OtherSymbol || firstChar == '['; } - return firstCharIsEmojiOrOpenBracket ? $"{indentStr}{message}" : $"{indentStr}[green]{Emoji.Known.CheckMarkButton}[/] {message}"; + + // Don't add checkmark if it's an error or already has emoji/bracket prefix + if (isError || firstCharIsEmojiOrOpenBracket) + { + return $"{indentStr}{message}"; + } + + return $"{indentStr}[green]{Emoji.Known.CheckMarkButton}[/] {message}"; } - msg = task switch + // Extract message and check if it's an error (return code != 0) + var (message, isError) = task switch { - StatusMessageTask statusMessageTask => $"{indentStr}{Markup.Escape(statusMessageTask.CompletedMessage ?? string.Empty)}", - GroupableTask genericTask => FormatCheckMarkMessage(indentStr, (genericTask.CompletedMessage as ITuple) switch + StatusMessageTask statusMessageTask => (Markup.Escape(statusMessageTask.CompletedMessage ?? string.Empty), false), + GroupableTask genericTask => (genericTask.CompletedMessage as ITuple) switch { - ITuple tuple when tuple.Length > 0 && tuple[0] is string str => str, - ITuple tuple when tuple.Length > 0 && tuple[1] is string str2 => str2, - _ => genericTask.CompletedMessage?.ToString() ?? string.Empty - }), - GroupableTask _ => FormatCheckMarkMessage(indentStr, Markup.Escape(task.InProgressMessage)), + ITuple tuple when tuple.Length >= 2 && tuple[0] is int returnCode && tuple[1] is string str => (str, returnCode != 0), + ITuple tuple when tuple.Length > 0 && tuple[0] is string str => (str, false), + ITuple tuple when tuple.Length > 0 && tuple[1] is string str2 => (str2, false), + _ => (genericTask.CompletedMessage?.ToString() ?? string.Empty, false) + }, + GroupableTask _ => (Markup.Escape(task.InProgressMessage), false), }; + + msg = FormatCompletedMessage(indentStr, message, isError); } else { diff --git a/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs b/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs index 3b53914b..7ad242c5 100644 --- a/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs +++ b/src/winapp-CLI/WinApp.Cli/Helpers/HostBuilderExtensions.cs @@ -50,6 +50,7 @@ public static IServiceCollection ConfigureCommands(this IServiceCollection servi .ConfigureCommand() .UseCommandHandler() .UseCommandHandler() + .UseCommandHandler() .UseCommandHandler() .UseCommandHandler() .UseCommandHandler() diff --git a/src/winapp-CLI/WinApp.Cli/Services/StatusService.cs b/src/winapp-CLI/WinApp.Cli/Services/StatusService.cs index 36870db7..3c41c7cd 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/StatusService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/StatusService.cs @@ -83,7 +83,8 @@ await ansiConsole.Live(rendered) { if (result.Value.ReturnCode != 0) { - logger.LogError("Task failed with return code {ReturnCode}, message: {CompletedMessage}", result.Value.ReturnCode, result.Value.CompletedMessage); + // Log errors to stderr for CLI convention (scripts/CI can capture stderr) + logger.LogError("Task failed with return code {ReturnCode}: {CompletedMessage}", result.Value.ReturnCode, result.Value.CompletedMessage); } else {