diff --git a/Squirrel.sln b/Squirrel.sln index 2354522bb..679367bc3 100644 --- a/Squirrel.sln +++ b/Squirrel.sln @@ -12,12 +12,12 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SolutionLevel", "SolutionLevel", "{ED657D2C-F8A0-4012-A64F-7367D41BE4D2}" ProjectSection(SolutionItems) = preProject .editorconfig = .editorconfig + build.ps1 = build.ps1 + .github\workflows\build.yml = .github\workflows\build.yml src\Directory.Build.props = src\Directory.Build.props + Squirrel.entitlements = Squirrel.entitlements vendor\wix\template.wxs = vendor\wix\template.wxs version.json = version.json - .github\workflows\build.yml = .github\workflows\build.yml - build.ps1 = build.ps1 - Squirrel.entitlements = Squirrel.entitlements EndProjectSection EndProject Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Setup", "src\Setup\Setup.vcxproj", "{6B406985-B2E1-4FED-A405-BD0694D68E93}" @@ -26,14 +26,16 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Squirrel.CommandLine", "src EndProject Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "StubExecutable", "src\StubExecutable\StubExecutable.vcxproj", "{611A03D4-4CDE-4DA0-B151-DE6FAFFB8B8C}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Update.OSX", "src\Update.OSX\Update.OSX.csproj", "{A63B2CDA-5ECC-461C-9B1F-54CF4709ACBD}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Update.OSX", "src\Update.OSX\Update.OSX.csproj", "{A63B2CDA-5ECC-461C-9B1F-54CF4709ACBD}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Squirrel.Tool", "src\Squirrel.Tool\Squirrel.Tool.csproj", "{9E769C7E-A54C-4844-8362-727D37BB1578}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Squirrel.Tool", "src\Squirrel.Tool\Squirrel.Tool.csproj", "{9E769C7E-A54C-4844-8362-727D37BB1578}" ProjectSection(ProjectDependencies) = postProject {6B406985-B2E1-4FED-A405-BD0694D68E93} = {6B406985-B2E1-4FED-A405-BD0694D68E93} {611A03D4-4CDE-4DA0-B151-DE6FAFFB8B8C} = {611A03D4-4CDE-4DA0-B151-DE6FAFFB8B8C} EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Squirrel.CommandLine.Tests", "test\Squirrel.CommandLine.Tests\Squirrel.CommandLine.Tests.csproj", "{519EAB50-47B8-425F-8B20-AB9548F220B4}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -72,6 +74,10 @@ Global {9E769C7E-A54C-4844-8362-727D37BB1578}.Debug|Any CPU.Build.0 = Debug|Any CPU {9E769C7E-A54C-4844-8362-727D37BB1578}.Release|Any CPU.ActiveCfg = Release|Any CPU {9E769C7E-A54C-4844-8362-727D37BB1578}.Release|Any CPU.Build.0 = Release|Any CPU + {519EAB50-47B8-425F-8B20-AB9548F220B4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {519EAB50-47B8-425F-8B20-AB9548F220B4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {519EAB50-47B8-425F-8B20-AB9548F220B4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {519EAB50-47B8-425F-8B20-AB9548F220B4}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/Squirrel.CommandLine/BaseCommand.cs b/src/Squirrel.CommandLine/BaseCommand.cs new file mode 100644 index 000000000..f84f7d055 --- /dev/null +++ b/src/Squirrel.CommandLine/BaseCommand.cs @@ -0,0 +1,28 @@ +using System.CommandLine; +using System.CommandLine.Invocation; +using System.IO; +using Squirrel.SimpleSplat; + +namespace Squirrel.CommandLine +{ + public class BaseCommand : Command + { + protected static IFullLogger Log = SquirrelLocator.CurrentMutable.GetService().GetLogger(typeof(BaseOptions)); + + public Option ReleaseDirectory { get; } + + protected BaseCommand(string name, string description) + : base(name, description) + { + ReleaseDirectory = new Option(new[] { "-r", "--releaseDir" }, "Output directory for Squirrel packages") { + ArgumentHelpName = "DIRECTORY" + }; + Add(ReleaseDirectory); + } + + private protected void SetOptionsValues(InvocationContext context, BaseOptions options) + { + options.releaseDir = context.ParseResult.GetValueForOption(ReleaseDirectory)?.FullName; + } + } +} \ No newline at end of file diff --git a/src/Squirrel.CommandLine/Bitness.cs b/src/Squirrel.CommandLine/Bitness.cs new file mode 100644 index 000000000..e7b62730e --- /dev/null +++ b/src/Squirrel.CommandLine/Bitness.cs @@ -0,0 +1,9 @@ +namespace Squirrel.CommandLine +{ + public enum Bitness + { + Unknown, + x86, + x64 + } +} \ No newline at end of file diff --git a/src/Squirrel.CommandLine/Deployment/GitHubCommands.cs b/src/Squirrel.CommandLine/Deployment/GitHubCommands.cs new file mode 100644 index 000000000..e264323c1 --- /dev/null +++ b/src/Squirrel.CommandLine/Deployment/GitHubCommands.cs @@ -0,0 +1,95 @@ +using System.CommandLine; +using Squirrel.CommandLine.Sync; +using System.CommandLine.Invocation; +using System.Threading.Tasks; +using System; + +namespace Squirrel.CommandLine.Deployment +{ + public class GitHubBaseCommand : BaseCommand + { + public Option RepoUrl { get; } + public Option Token { get; } + + protected GitHubBaseCommand(string name, string description) + : base(name, description) + { + RepoUrl = new Option("--repoUrl", "Full url to the github repository\nexample: 'https://github.com/myname/myrepo'.") { + IsRequired = true + }; + RepoUrl.MustBeValidHttpUri(); + Add(RepoUrl); + + Token = new Option("--token", "OAuth token to use as login credentials."); + Add(Token); + } + + private protected virtual void SetOptionsValues(InvocationContext context, SyncGithubOptions options) + { + base.SetOptionsValues(context, options); + options.repoUrl = context.ParseResult.GetValueForOption(RepoUrl)?.AbsoluteUri; + options.token = context.ParseResult.GetValueForOption(Token); + } + } + + public class GitHubDownloadCommand : GitHubBaseCommand + { + public Option Pre { get; } + + public GitHubDownloadCommand() + : base("github", "Download latest release from GitHub repository.") + { + Pre = new Option("--pre", "Get latest pre-release instead of stable."); + Add(Pre); + + this.SetHandler(Execute); + } + + private protected override void SetOptionsValues(InvocationContext context, SyncGithubOptions options) + { + base.SetOptionsValues(context, options); + options.pre = context.ParseResult.GetValueForOption(Pre); + } + + private async Task Execute(InvocationContext context) + { + SyncGithubOptions options = new(); + SetOptionsValues(context, options); + await new GitHubRepository(options).DownloadRecentPackages(); + } + } + + public class GitHubUploadCommand : GitHubBaseCommand + { + public Option Publish { get; } + public Option ReleaseName { get; } + + public GitHubUploadCommand() + : base("github", "Upload latest release to a GitHub repository.") + { + Publish = new Option("--publish", "Publish release instead of creating draft."); + Add(Publish); + + ReleaseName = new Option("--releaseName", "A custom {NAME} for created release.") { + ArgumentHelpName = "NAME" + }; + Add(ReleaseName); + + this.SetHandler(Execute); + } + + private protected override void SetOptionsValues(InvocationContext context, SyncGithubOptions options) + { + base.SetOptionsValues(context, options); + options.publish = context.ParseResult.GetValueForOption(Publish); + options.releaseName = context.ParseResult.GetValueForOption(ReleaseName); + } + + private async Task Execute(InvocationContext context) + { + SyncGithubOptions options = new(); + SetOptionsValues(context, options); + await new GitHubRepository(options).UploadMissingPackages(); + } + } +} diff --git a/src/Squirrel.CommandLine/Deployment/HttpCommands.cs b/src/Squirrel.CommandLine/Deployment/HttpCommands.cs new file mode 100644 index 000000000..496c5d8dc --- /dev/null +++ b/src/Squirrel.CommandLine/Deployment/HttpCommands.cs @@ -0,0 +1,39 @@ +using System; +using System.CommandLine; +using System.CommandLine.Invocation; +using System.Threading.Tasks; +using Squirrel.CommandLine.Sync; + +namespace Squirrel.CommandLine.Deployment +{ + public class HttpDownloadCommand : BaseCommand + { + public Option Url { get; } + + public HttpDownloadCommand() + : base("http", "Download latest release from a HTTP source.") + { + Url = new Option("--url", "Url to download remote releases from.") { + ArgumentHelpName = "URL", + IsRequired = true, + }; + Url.MustBeValidHttpUri(); + Add(Url); + + this.SetHandler(Execute); + } + + private protected void SetOptionsValues(InvocationContext context, SyncHttpOptions options) + { + base.SetOptionsValues(context, options); + options.url = context.ParseResult.GetValueForOption(Url)?.AbsoluteUri; + } + + public async Task Execute(InvocationContext context) + { + SyncHttpOptions options = new(); + SetOptionsValues(context, options); + await new SimpleWebRepository(options).DownloadRecentPackages(); + } + } +} diff --git a/src/Squirrel.CommandLine/Deployment/S3Commands.cs b/src/Squirrel.CommandLine/Deployment/S3Commands.cs new file mode 100644 index 000000000..16f3041cf --- /dev/null +++ b/src/Squirrel.CommandLine/Deployment/S3Commands.cs @@ -0,0 +1,131 @@ +using System.CommandLine; +using System.CommandLine.Invocation; +using System.Threading.Tasks; +using Squirrel.CommandLine.Sync; + +namespace Squirrel.CommandLine.Deployment +{ + public class S3BaseCommand : BaseCommand + { + public Option KeyId { get; } + public Option Secret { get; } + public Option Region { get; } + public Option Endpoint { get; } + public Option Bucket { get; } + public Option PathPrefix { get; } + + protected S3BaseCommand(string name, string description) + : base(name, description) + { + KeyId = new Option("--keyId", "Authentication identifier or access key.") { + ArgumentHelpName = "IDENTIFIER", + IsRequired = true + }; + Add(KeyId); + + Secret = new Option("--secret", "Authentication secret key.") { + ArgumentHelpName = "KEY", + IsRequired = true + }; + Add(Secret); + + Region = new Option("--region", "AWS service region (eg. us-west-1).") { + ArgumentHelpName = "REGION" + }; + Region.AddValidator(result => { + for (var i = 0; i < result.Tokens.Count; i++) { + var region = result.Tokens[i].Value; + if (!string.IsNullOrWhiteSpace(region)) { + var r = Amazon.RegionEndpoint.GetBySystemName(result.Tokens[0].Value); + if (r is null || r.DisplayName == "Unknown") { + result.ErrorMessage = $"Region '{region}' lookup failed, is this a valid AWS region?"; + } + } else { + result.ErrorMessage = "A region value is required"; + } + } + }); + Add(Region); + + Endpoint = new Option("--endpoint", "Custom service url (backblaze, digital ocean, etc).") { + ArgumentHelpName = "URL" + }; + Add(Endpoint); + + Bucket = new Option("--bucket", "Name of the S3 bucket.") { + ArgumentHelpName = "NAME", + IsRequired = true + }; + Add(Bucket); + + PathPrefix = new Option("--pathPrefix", "A sub-folder used for files in the bucket, for creating release channels (eg. 'stable' or 'dev').") { + ArgumentHelpName = "PREFIX" + }; + Add(PathPrefix); + + this.AreMutuallyExclusive(Region, Endpoint) + .AtLeastOneRequired(Region, Endpoint); + } + + private protected virtual void SetOptionsValues(InvocationContext context, SyncS3Options options) + { + base.SetOptionsValues(context, options); + options.keyId = context.ParseResult.GetValueForOption(KeyId); + options.secret = context.ParseResult.GetValueForOption(Secret); + options.region = context.ParseResult.GetValueForOption(Region); + options.endpoint = context.ParseResult.GetValueForOption(Endpoint); + options.bucket = context.ParseResult.GetValueForOption(Bucket); + options.pathPrefix = context.ParseResult.GetValueForOption(PathPrefix); + } + } + + public class S3DownloadCommand : S3BaseCommand + { + public S3DownloadCommand() + : base("s3", "Download latest release from an S3 bucket.") + { + this.SetHandler(Execute); + } + + private async Task Execute(InvocationContext context) + { + SyncS3Options options = new(); + SetOptionsValues(context, options); + await new S3Repository(options).DownloadRecentPackages(); + } + } + + public class S3UploadCommand : S3BaseCommand + { + public Option Overwrite { get; } + public Option KeepMaxReleases { get; } + + public S3UploadCommand() + : base("s3", "Upload releases to an S3 bucket.") + { + Overwrite = new Option("--overwrite", "Replace remote files if local files have changed."); + Add(Overwrite); + + KeepMaxReleases = new Option("--keepMaxReleases", "Apply a retention policy which keeps only the specified number of old versions in remote source.") { + ArgumentHelpName = "NUMBER" + }; + Add(KeepMaxReleases); + + this.SetHandler(Execute); + } + + private protected override void SetOptionsValues(InvocationContext context, SyncS3Options options) + { + base.SetOptionsValues(context, options); + options.overwrite = context.ParseResult.GetValueForOption(Overwrite); + options.keepMaxReleases = context.ParseResult.GetValueForOption(KeepMaxReleases); + } + + private async Task Execute(InvocationContext context) + { + SyncS3Options options = new(); + SetOptionsValues(context, options); + await new S3Repository(options).UploadMissingPackages(); + } + } +} diff --git a/src/Squirrel.CommandLine/OSX/Commands.cs b/src/Squirrel.CommandLine/OSX/Commands.cs index 1fbd07bbe..c78563d76 100644 --- a/src/Squirrel.CommandLine/OSX/Commands.cs +++ b/src/Squirrel.CommandLine/OSX/Commands.cs @@ -1,14 +1,10 @@ using System; using System.Collections.Generic; +using System.CommandLine; using System.IO; -using System.Linq; -using System.Runtime.Versioning; using System.Text; using System.Text.RegularExpressions; -using Microsoft.NET.HostModel.AppHost; using NuGet.Versioning; -using Squirrel.NuGet; -using Squirrel.PropertyList; using Squirrel.SimpleSplat; namespace Squirrel.CommandLine.OSX @@ -17,15 +13,12 @@ class Commands { static IFullLogger Log => SquirrelLocator.Current.GetService().GetLogger(typeof(Commands)); - public static CommandSet GetCommands() + public static IEnumerable GetCommands() { - return new CommandSet { - "[ Package Authoring ]", - { "pack", "Convert a build or '.app' dir into a Squirrel release", new PackOptions(), Pack }, - }; + yield return new PackCommand(); } - private static void Pack(PackOptions options) + public static void Pack(PackOptions options) { var releaseDir = options.GetReleaseDirectory(); string appBundlePath; @@ -55,7 +48,7 @@ private static void Pack(PackOptions options) Log.Info("Pack directory is not a bundle. Will generate new '.app' bundle from a directory of application files."); if (options.icon == null || !File.Exists(options.icon)) - throw new OptionValidationException("--icon is required when generating a new app bundle."); + throw new ArgumentException("--icon is required when generating a new app bundle."); // auto-discover exe if it's the same as packId var exeName = options.mainExe; @@ -63,11 +56,11 @@ private static void Pack(PackOptions options) exeName = options.packId; if (exeName == null) - throw new OptionValidationException("--exeName is required when generating a new app bundle."); + throw new ArgumentException("--exeName is required when generating a new app bundle."); var mainExePath = Path.Combine(options.packDirectory, exeName); if (!File.Exists(mainExePath) || !PlatformUtil.IsMachOImage(mainExePath)) - throw new OptionValidationException($"--exeName '{mainExePath}' does not exist or is not a mach-o executable."); + throw new ArgumentException($"--exeName '{mainExePath}' does not exist or is not a mach-o executable."); var appleId = $"com.{options.packAuthors ?? options.packId}.{options.packId}"; var escapedAppleId = Regex.Replace(appleId, @"[^\w\.]", "_"); diff --git a/src/Squirrel.CommandLine/OSX/HelperExe.cs b/src/Squirrel.CommandLine/OSX/HelperExe.cs index e07a8e206..78c94915c 100644 --- a/src/Squirrel.CommandLine/OSX/HelperExe.cs +++ b/src/Squirrel.CommandLine/OSX/HelperExe.cs @@ -59,7 +59,7 @@ public static void SpctlAssess(string filePath) } [SupportedOSPlatform("osx")] - public static void CreateInstallerPkg(string appBundlePath, string appTitle, KeyValuePair[] extraContent, + public static void CreateInstallerPkg(string appBundlePath, string appTitle, IEnumerable> extraContent, string pkgOutputPath, string signIdentity) { // https://matthew-brett.github.io/docosx/flat_packages.html diff --git a/src/Squirrel.CommandLine/OSX/Options.cs b/src/Squirrel.CommandLine/OSX/Options.cs index 683c15d4e..ea3dd5bf9 100644 --- a/src/Squirrel.CommandLine/OSX/Options.cs +++ b/src/Squirrel.CommandLine/OSX/Options.cs @@ -1,84 +1,25 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Microsoft.NET.HostModel.AppHost; +using System.Collections.Generic; namespace Squirrel.CommandLine.OSX { internal class PackOptions : BaseOptions { - public string packId { get; private set; } - public string packTitle { get; private set; } - public string packVersion { get; private set; } - public string packAuthors { get; private set; } - public string packDirectory { get; private set; } - public bool includePdb { get; private set; } - public string releaseNotes { get; private set; } - public string icon { get; private set; } - public string mainExe { get; private set; } - public bool noDelta { get; private set; } - public string signAppIdentity { get; private set; } - public string signInstallIdentity { get; private set; } - public string signEntitlements { get; private set; } - public string notaryProfile { get; private set; } - public string bundleId { get; private set; } - public bool noPkg { get; private set; } - public KeyValuePair[] pkgContent => _pkgContent.ToArray(); - - private Dictionary _pkgContent = new Dictionary(); - - public PackOptions() - { - Add("u=|packId=", "Unique {ID} for bundle", v => packId = v); - Add("v=|packVersion=", "Current {VERSION} for bundle", v => packVersion = v); - Add("p=|packDir=", "{DIRECTORY} containing application files to bundle, or a " + - "directory ending in '.app' to convert to a release.", v => packDirectory = v); - Add("packAuthors=", "Optional company or list of release {AUTHORS}", v => packAuthors = v); - Add("packTitle=", "Optional display/friendly {NAME} for bundle", v => packTitle = v); - Add("includePdb", "Add *.pdb files to release package", v => includePdb = true); - Add("releaseNotes=", "{PATH} to file with markdown notes for version", v => releaseNotes = v); - Add("e=|mainExe=", "The file {NAME} of the main executable", v => mainExe = v); - Add("i=|icon=", "{PATH} to the .icns file for this bundle", v => icon = v); - Add("bundleId=", "Override the apple unique {ID} when generating bundles", v => bundleId = v); - Add("noDelta", "Skip the generation of delta packages", v => noDelta = true); - Add("noPkg", "Skip generating a .pkg installer", v => noPkg = true); - Add("pkgContent=", "Add content files (eg. readme, license) to pkg installer.", (v1, v2) => _pkgContent.Add(v1, v2)); - - if (SquirrelRuntimeInfo.IsOSX) { - Add("signAppIdentity=", "The {SUBJECT} name of the cert to use for app code signing", v => signAppIdentity = v); - Add("signInstallIdentity=", "The {SUBJECT} name of the cert to use for installation packages", v => signInstallIdentity = v); - Add("signEntitlements=", "{PATH} to entitlements file for hardened runtime", v => signEntitlements = v); - Add("notaryProfile=", "{NAME} of profile containing Apple credentials stored with notarytool", v => notaryProfile = v); - } - } - - public override void Validate() - { - IsRequired(nameof(packId), nameof(packVersion), nameof(packDirectory)); - IsValidFile(nameof(signEntitlements), "entitlements"); - NuGet.NugetUtil.ThrowIfInvalidNugetId(packId); - NuGet.NugetUtil.ThrowIfVersionNotSemverCompliant(packVersion); - IsValidDirectory(nameof(packDirectory), true); - - var validContentKeys = new string[] { - "welcome", - "readme", - "license", - "conclusion", - }; - - foreach (var kvp in _pkgContent) { - if (!validContentKeys.Contains(kvp.Key)) { - throw new OptionValidationException($"Invalid pkgContent key: {kvp.Key}. Must be one of: " + string.Join(", ", validContentKeys)); - } - - if (!File.Exists(kvp.Value)) { - throw new OptionValidationException("pkgContent file not found: " + kvp.Value); - } - } - } + public string packId { get; set; } + public string packTitle { get; set; } + public string packVersion { get; set; } + public string packAuthors { get; set; } + public string packDirectory { get; set; } + public bool includePdb { get; set; } + public string releaseNotes { get; set; } + public string icon { get; set; } + public string mainExe { get; set; } + public bool noDelta { get; set; } + public string signAppIdentity { get; set; } + public string signInstallIdentity { get; set; } + public string signEntitlements { get; set; } + public string notaryProfile { get; set; } + public string bundleId { get; set; } + public bool noPkg { get; set; } + public Dictionary pkgContent { get; } = new(); } } \ No newline at end of file diff --git a/src/Squirrel.CommandLine/OSX/PackCommand.cs b/src/Squirrel.CommandLine/OSX/PackCommand.cs new file mode 100644 index 000000000..73b062d9c --- /dev/null +++ b/src/Squirrel.CommandLine/OSX/PackCommand.cs @@ -0,0 +1,188 @@ +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.CommandLine.Invocation; +using System.CommandLine.Parsing; +using System.IO; + +namespace Squirrel.CommandLine.OSX +{ + public class PackCommand : BaseCommand + { + public Option PackId { get; } + public Option PackVersion { get; } + public Option PackDirectory { get; } + public Option PackAuthors { get; } + public Option PackTitle { get; } + public Option IncludePdb { get; } + public Option ReleaseNotes { get; } + public Option SquirrelAwareExecutable { get; } + public Option Icon { get; } + public Option BundleId { get; } + public Option NoDelta { get; } + public Option NoPackage { get; } + public Option[]> PackageContent { get; } + public Option SigningAppIdentity { get; } + public Option SigningInstallIdentity { get; } + public Option SigningEntitlements { get; } + public Option NotaryProfile { get; } + + public PackCommand() + : base("pack", "Creates a Squirrel release from a folder containing application files") + { + PackId = new Option(new[] { "--packId", "-u" }, "Unique {ID} for bundle") { + ArgumentHelpName = "ID", + IsRequired = true + }; + PackId.RequiresValidNuGetId(); + Add(PackId); + + PackVersion = new Option(new[] { "--packVersion", "-v" }, "Current {VERSION} for bundle") { + ArgumentHelpName = "VERSION", + IsRequired = true + }; + PackVersion.RequiresSemverCompliant(); + Add(PackVersion); + + PackDirectory = new Option(new[] { "--packDir", "-p" }, "{DIRECTORY} containing application files for release") { + ArgumentHelpName = "DIRECTORY", + IsRequired = true + }; + PackDirectory.MustNotBeEmpty(); + Add(PackDirectory); + + PackAuthors = new Option("--packAuthors", "Optional company or list of release {AUTHORS}") { + ArgumentHelpName = "AUTHORS" + }; + Add(PackAuthors); + + PackTitle = new Option("--packTitle", "Optional display/friendly {NAME} for release") { + ArgumentHelpName = "NAME" + }; + Add(PackTitle); + + IncludePdb = new Option("--includePdb", "Add *.pdb files to release package"); + Add(IncludePdb); + + ReleaseNotes = new Option("--releaseNotes", "{PATH} to file with markdown notes for version") { + ArgumentHelpName = "PATH" + }; + ReleaseNotes.ExistingOnly(); + Add(ReleaseNotes); + + SquirrelAwareExecutable = new Option(new[] { "-e", "--mainExe" }, "The file {NAME} of the main executable") { + ArgumentHelpName = "NAME" + }; + Add(SquirrelAwareExecutable); + + Icon = new Option(new[] { "-i", "--icon" }, "{PATH} to .ico for Setup.exe and Update.exe") { + ArgumentHelpName = "PATH" + }; + Icon.ExistingOnly().RequiresExtension(".ico"); + Add(Icon); + + BundleId = new Option("--bundleId", "Override the apple unique {ID} when generating bundles") { + ArgumentHelpName = "ID" + }; + Add(BundleId); + + NoDelta = new Option("--noDelta", "Skip the generation of delta packages"); + Add(NoDelta); + + NoPackage = new Option("--noPkg", "Skip generating a .pkg installer"); + Add(NoPackage); + + //TODO: Would be nice to setup completions at least for the keys of this option + PackageContent = new Option[]>("--pkgContent", (ArgumentResult value) => { + var splitCharacters = new[] { '=', ':' }; + var results = new List>(); + for (int i = 0; i < value.Tokens.Count; i++) { + string token = value.Tokens[i].Value; + string[] parts = token.Split(splitCharacters, 2); + switch(parts.Length) { + case 1: + results.Add(new KeyValuePair(parts[0], new FileInfo(""))); + break; + case 2: + results.Add(new KeyValuePair(parts[0], new FileInfo(parts[1]))); + break; + } + } + return results.ToArray(); + }, false, "Add content files (eg. readme, license) to pkg installer."); + PackageContent.AddValidator((OptionResult result) => { + var validContentKeys = new HashSet { + "welcome", + "readme", + "license", + "conclusion", + }; + foreach (var kvp in result.GetValueForOption(PackageContent)) { + if (!validContentKeys.Contains(kvp.Key)) { + result.ErrorMessage = $"Invalid {PackageContent.Name} key: {kvp.Key}. Must be one of: " + string.Join(", ", validContentKeys); + } + + if (!kvp.Value.Exists) { + result.ErrorMessage = $"{PackageContent.Name} file not found: {kvp.Value}"; + } + } + }); + PackageContent.ArgumentHelpName = "key="; + Add(PackageContent); + + SigningAppIdentity = new Option("--signAppIdentity", "The {SUBJECT} name of the cert to use for app code signing") { + ArgumentHelpName = "SUBJECT" + }; + Add(SigningAppIdentity); + + SigningInstallIdentity = new Option("--signInstallIdentity", "The {SUBJECT} name of the cert to use for installation packages") { + ArgumentHelpName = "SUBJECT" + }; + Add(SigningInstallIdentity); + + SigningEntitlements = new Option("--signEntitlements", "{PATH} to entitlements file for hardened runtime") { + ArgumentHelpName = "PATH" + }; + SigningEntitlements.ExistingOnly().RequiresExtension(".entitlements"); + Add(SigningEntitlements); + + NotaryProfile = new Option("--notaryProfile", "{NAME} of profile containing Apple credentials stored with notarytool") { + ArgumentHelpName = "NAME" + }; + Add(NotaryProfile); + + this.SetHandler(Execute); + } + + private protected void SetOptionsValues(InvocationContext context, PackOptions options) + { + base.SetOptionsValues(context, options); + options.packId = context.ParseResult.GetValueForOption(PackId); + options.packVersion = context.ParseResult.GetValueForOption(PackVersion); + options.packDirectory = context.ParseResult.GetValueForOption(PackDirectory)?.FullName; + options.packAuthors = context.ParseResult.GetValueForOption(PackAuthors); + options.packTitle = context.ParseResult.GetValueForOption(PackTitle); + options.includePdb = context.ParseResult.GetValueForOption(IncludePdb); + options.releaseNotes = context.ParseResult.GetValueForOption(ReleaseNotes)?.FullName; + options.mainExe = context.ParseResult.GetValueForOption(SquirrelAwareExecutable); + options.icon = context.ParseResult.GetValueForOption(Icon)?.FullName; + options.bundleId = context.ParseResult.GetValueForOption(BundleId); + options.noDelta = context.ParseResult.GetValueForOption(NoDelta); + options.noPkg = context.ParseResult.GetValueForOption(NoPackage); + if(context.ParseResult.GetValueForOption(PackageContent) is { } pkgContent) { + foreach(var kvp in pkgContent) { + //NB: If the same key is specified multiple times; last in wins + options.pkgContent[kvp.Key] = kvp.Value.FullName; + } + } + } + + private void Execute(InvocationContext context) + { + var packOptions = new PackOptions(); + SetOptionsValues(context, packOptions); + + Commands.Pack(packOptions); + } + } +} \ No newline at end of file diff --git a/src/Squirrel.CommandLine/Squirrel.CommandLine.csproj b/src/Squirrel.CommandLine/Squirrel.CommandLine.csproj index e5006269c..c023750cb 100644 --- a/src/Squirrel.CommandLine/Squirrel.CommandLine.csproj +++ b/src/Squirrel.CommandLine/Squirrel.CommandLine.csproj @@ -16,6 +16,7 @@ + diff --git a/src/Squirrel.CommandLine/SquirrelHost.cs b/src/Squirrel.CommandLine/SquirrelHost.cs index b1800ff82..4aabd4ddf 100644 --- a/src/Squirrel.CommandLine/SquirrelHost.cs +++ b/src/Squirrel.CommandLine/SquirrelHost.cs @@ -1,111 +1,93 @@ using System; using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Mono.Options; -using Squirrel.CommandLine.Sync; +using System.CommandLine; +using System.CommandLine.Builder; +using System.CommandLine.Help; +using System.CommandLine.Parsing; +using Squirrel.CommandLine.Deployment; using Squirrel.SimpleSplat; namespace Squirrel.CommandLine { public class SquirrelHost { + public static Option PlatformOption { get; } + = new Option(new[] { "-x", "--xplat" }, "Select {PLATFORM} to cross-compile for (eg. win, osx)") { + ArgumentHelpName = "PLATFORM" + }; + public static Option VerboseOption { get; } = new Option("--verbose", "Print diagnostic messages."); + public static int Main(string[] args) { var logger = ConsoleLogger.RegisterLogger(); - bool help = false; - bool verbose = false; - string xplat = null; - var globalOptions = new OptionSet() { - { "h|?|help", "Ignores all other arguments and shows help text", _ => help = true }, - { "x|xplat=", "Select {PLATFORM} to cross-compile for (eg. win, osx)", v => xplat = v }, - { "verbose", "Print all diagnostic messages", _ => verbose = true }, + RootCommand platformRootCommand = new RootCommand() { + PlatformOption, + VerboseOption }; + platformRootCommand.TreatUnmatchedTokensAsErrors = false; + + ParseResult parseResult = platformRootCommand.Parse(args); + + string xplat = parseResult.GetValueForOption(PlatformOption) ?? SquirrelRuntimeInfo.SystemOsName; + bool verbose = parseResult.GetValueForOption(VerboseOption); + + IEnumerable packageCommands; + + switch (xplat.ToLower()) { + case "win": + case "windows": + if (!SquirrelRuntimeInfo.IsWindows) + logger.Write("Cross-compiling will cause some features of Squirrel to be disabled.", LogLevel.Warn); + packageCommands = Windows.Commands.GetCommands(); + break; - string sqUsage = $"Squirrel {SquirrelRuntimeInfo.SquirrelDisplayVersion} for creating and distributing Squirrel releases."; - Console.WriteLine(sqUsage); - - try { - var restArgs = globalOptions.Parse(args); - - if (xplat == null) - xplat = SquirrelRuntimeInfo.SystemOsName; - - CommandSet packageCommands; - - switch (xplat.ToLower()) { - case "win": - case "windows": - if (!SquirrelRuntimeInfo.IsWindows) - logger.Write("Cross-compiling will cause some features of Squirrel to be disabled.", LogLevel.Warn); - packageCommands = Windows.Commands.GetCommands(); - break; - - case "mac": - case "osx": - case "macos": - if (!SquirrelRuntimeInfo.IsOSX) - logger.Write("Cross-compiling will cause some features of Squirrel to be disabled.", LogLevel.Warn); - packageCommands = OSX.Commands.GetCommands(); - break; - - default: - throw new NotSupportedException("Unsupported OS platform: " + xplat); - } - - var commands = new CommandSet { - "", - "[ Global Options ]", - globalOptions.GetHelpText().TrimEnd(), - "", - packageCommands, - "", - "[ Package Deployment / Syncing ]", - { "http-down", "Download latest release from HTTP", new SyncHttpOptions(), o => Download(new SimpleWebRepository(o)) }, - { "s3-down", "Download latest release from S3 API", new SyncS3Options(), o => Download(new S3Repository(o)) }, - { "s3-up", "Upload releases to S3 API", new SyncS3Options(), o => Upload(new S3Repository(o)) }, - { "github-down", "Download latest release from GitHub", new SyncGithubOptions(), o => Download(new GitHubRepository(o)) }, - { "github-up", "Upload latest release to GitHub", new SyncGithubOptions(), o => Upload(new GitHubRepository(o)) }, - }; - - if (verbose) { - logger.Level = LogLevel.Debug; - } - - if (help) { - commands.WriteHelp(); - return 0; - } - - try { - // parse cli and run command - commands.Execute(restArgs.ToArray()); - return 0; - } catch (Exception ex) when (ex is OptionValidationException || ex is OptionException) { - // if the arguments fail to validate, print argument help - Console.WriteLine(); - logger.Write(ex.Message, LogLevel.Error); - commands.WriteHelp(); - Console.WriteLine(); - logger.Write(ex.Message, LogLevel.Error); - return -1; - } - } catch (Exception ex) { - // for other errors, just print the error and short usage instructions - Console.WriteLine(); - logger.Write(ex.ToString(), LogLevel.Error); - Console.WriteLine(); - Console.WriteLine(sqUsage); - Console.WriteLine($" > 'csq -h' to see program help."); - return -1; + case "mac": + case "osx": + case "macos": + if (!SquirrelRuntimeInfo.IsOSX) + logger.Write("Cross-compiling will cause some features of Squirrel to be disabled.", LogLevel.Warn); + packageCommands = OSX.Commands.GetCommands(); + break; + + default: + throw new NotSupportedException("Unsupported OS platform: " + xplat); + } + + if (verbose) { + logger.Level = LogLevel.Debug; } - } - static void Upload(T repo) where T : IPackageRepository => repo.UploadMissingPackages().GetAwaiter().GetResult(); + RootCommand rootCommand = new RootCommand($"Squirrel {SquirrelRuntimeInfo.SquirrelDisplayVersion} for creating and distributing Squirrel releases."); + rootCommand.AddGlobalOption(PlatformOption); + rootCommand.AddGlobalOption(VerboseOption); - static void Download(T repo) where T : IPackageRepository => repo.DownloadRecentPackages().GetAwaiter().GetResult(); + foreach (var command in packageCommands) { + rootCommand.Add(command); + } + + Command uploadCommand = new("upload", "Upload local package(s) to a remote update source.") { + new S3UploadCommand(), + new GitHubUploadCommand(), + }; + + Command downloadCommand = new("download", "Download's the latest release from a remote update source.") { + new HttpDownloadCommand(), + new S3DownloadCommand(), + new GitHubDownloadCommand(), + }; + + rootCommand.Add(uploadCommand); + rootCommand.Add(downloadCommand); + + + var builder = new CommandLineBuilder(rootCommand) + //This is to work around an issue with UseHelp(80) not properly overriding the max width + //https://github.com/dotnet/command-line-api/pull/1864 + .UseHelpBuilder(context => new HelpBuilder(context.ParseResult.Parser.Configuration.LocalizationResources, 80)) + .UseDefaults(); + var parser = builder.Build(); + return parser.Invoke(args); + } } } \ No newline at end of file diff --git a/src/Squirrel.CommandLine/Sync/SimpleWebRepository.cs b/src/Squirrel.CommandLine/Sync/SimpleWebRepository.cs index d62daba9a..c707fa8dd 100644 --- a/src/Squirrel.CommandLine/Sync/SimpleWebRepository.cs +++ b/src/Squirrel.CommandLine/Sync/SimpleWebRepository.cs @@ -3,7 +3,6 @@ using System.Linq; using System.Net.Http; using System.Reflection; -using System.Runtime.InteropServices.ComTypes; using System.Threading.Tasks; namespace Squirrel.CommandLine.Sync diff --git a/src/Squirrel.CommandLine/SyncOptions.cs b/src/Squirrel.CommandLine/SyncOptions.cs index 81f9a3900..627f35cf3 100644 --- a/src/Squirrel.CommandLine/SyncOptions.cs +++ b/src/Squirrel.CommandLine/SyncOptions.cs @@ -3,9 +3,9 @@ namespace Squirrel.CommandLine { - internal abstract class BaseOptions : ValidatedOptionSet + internal abstract class BaseOptions { - protected string releaseDir { get; private set; } + public string releaseDir { get; set; } protected static IFullLogger Log = SquirrelLocator.CurrentMutable.GetService().GetLogger(typeof(BaseOptions)); @@ -16,91 +16,31 @@ public DirectoryInfo GetReleaseDirectory(bool createIfMissing = true) if (!di.Exists && createIfMissing) di.Create(); return di; } - - public BaseOptions() - { - Add("r=|releaseDir=", "Output {DIRECTORY} for releasified packages", v => releaseDir = v); - } } internal class SyncS3Options : BaseOptions { - public string keyId { get; private set; } - public string secret { get; private set; } - public string region { get; private set; } - public string endpoint { get; private set; } - public string bucket { get; private set; } - public string pathPrefix { get; private set; } - public bool overwrite { get; private set; } - public int keepMaxReleases { get; private set; } - - public SyncS3Options() - { - Add("keyId=", "Authentication {IDENTIFIER} or access key", v => keyId = v); - Add("secret=", "Authentication secret {KEY}", v => secret = v); - Add("region=", "AWS service {REGION} (eg. us-west-1)", v => region = v); - Add("endpoint=", "Custom service {URL} (backblaze, digital ocean, etc)", v => endpoint = v); - Add("bucket=", "{NAME} of the S3 bucket", v => bucket = v); - Add("pathPrefix=", "A sub-folder {PATH} used for files in the bucket, for creating release channels (eg. 'stable' or 'dev')", v => pathPrefix = v); - Add("overwrite", "(up only) Replace existing files if source has changed", v => overwrite = true); - Add("keepMaxReleases=", "(up only) Applies a retention policy during upload which keeps only the specified {NUMBER} of old versions", - v => keepMaxReleases = ParseIntArg(nameof(keepMaxReleases), v)); - } - - public override void Validate() - { - IsRequired(nameof(secret), nameof(keyId), nameof(bucket)); - - if ((region == null) == (endpoint == null)) { - throw new OptionValidationException( - "One of 'region' and 'endpoint' arguments is required and are also mutually exclusive. Specify only one of these. "); - } - - if (region != null) { - var r = Amazon.RegionEndpoint.GetBySystemName(region); - if (r.DisplayName == "Unknown") - Log.Warn($"Region '{region}' lookup failed, is this a valid AWS region?"); - } - } + public string keyId { get; set; } + public string secret { get; set; } + public string region { get; set; } + public string endpoint { get; set; } + public string bucket { get; set; } + public string pathPrefix { get; set; } + public bool overwrite { get; set; } + public int keepMaxReleases { get; set; } } internal class SyncHttpOptions : BaseOptions { - public string url { get; private set; } - - public SyncHttpOptions() - { - Add("url=", "Base url to the http location with hosted releases", v => url = v); - } - - public override void Validate() - { - IsRequired(nameof(url)); - IsValidUrl(nameof(url)); - } + public string url { get; set; } } internal class SyncGithubOptions : BaseOptions { - public string repoUrl { get; private set; } - public string token { get; private set; } - public bool pre { get; private set; } - public bool publish { get; private set; } - public string releaseName { get; private set; } - - public SyncGithubOptions() - { - Add("repoUrl=", "Full url to the github repository\nexample: 'https://github.com/myname/myrepo'", v => repoUrl = v); - Add("token=", "OAuth token to use as login credentials", v => token = v); - Add("pre", "(down only) Get latest pre-release instead of stable", v => pre = true); - Add("publish", "(up only) Publish release instead of creating draft", v => publish = true); - Add("releaseName=", "(up only) A custom {NAME} for created release", v => releaseName = v); - } - - public override void Validate() - { - IsRequired(nameof(repoUrl)); - IsValidUrl(nameof(repoUrl)); - } + public string repoUrl { get; set; } + public string token { get; set; } + public bool pre { get; set; } + public bool publish { get; set; } + public string releaseName { get; set; } } } \ No newline at end of file diff --git a/src/Squirrel.CommandLine/SystemCommandLineExtensions.cs b/src/Squirrel.CommandLine/SystemCommandLineExtensions.cs new file mode 100644 index 000000000..729a35dd3 --- /dev/null +++ b/src/Squirrel.CommandLine/SystemCommandLineExtensions.cs @@ -0,0 +1,213 @@ +using System; +using System.CommandLine; +using System.CommandLine.Parsing; +using System.IO; +using System.Linq; +using NuGet.Versioning; +using Squirrel.NuGet; + +namespace Squirrel.CommandLine +{ + internal static class SystemCommandLineExtensions + { + public static Option MustBeBetween(this Option option, int minimum, int maximum) + { + option.AddValidator(x => Validate.MustBeBetween(x, minimum, maximum)); + return option; + } + + public static Option MustBeValidHttpUri(this Option option) + { + option.RequiresScheme(Uri.UriSchemeHttp, Uri.UriSchemeHttps).RequiresAbsolute(); + return option; + } + + public static Option RequiresExtension(this Option option, string extension) + { + option.AddValidator(x => Validate.RequiresExtension(x, extension)); + return option; + } + + public static Command AreMutuallyExclusive(this Command command, params Option[] options) + { + command.AddValidator(x => Validate.AreMutuallyExclusive(x, options)); + return command; + } + + public static Command RequiredAllowObsoleteFallback(this Command command, Option option, Option obsoleteOption) + { + command.AddValidator(x => Validate.AtLeastOneRequired(x, new[] { option, obsoleteOption }, true)); + return command; + } + + public static Command AtLeastOneRequired(this Command command, params Option[] options) + { + command.AddValidator(x => Validate.AtLeastOneRequired(x, options, false)); + return command; + } + + public static Option MustContain(this Option option, string value) + { + option.AddValidator(x => Validate.MustContain(x, value)); + return option; + } + + public static Option RequiresScheme(this Option option, params string[] validSchemes) + { + option.AddValidator(x => Validate.RequiresScheme(x, validSchemes)); + return option; + } + + public static Option RequiresAbsolute(this Option option, params string[] validSchemes) + { + option.AddValidator(Validate.RequiresAbsolute); + return option; + } + + public static Option RequiresValidNuGetId(this Option option) + { + option.AddValidator(Validate.RequiresValidNuGetId); + return option; + } + + //TODO: Could setup the options to accept type SemanticVersion and apply an appropriate parser for it + public static Option RequiresSemverCompliant(this Option option) + { + option.AddValidator(Validate.RequiresSemverCompliant); + return option; + } + + public static Option MustNotBeEmpty(this Option option) + { + option.AddValidator(Validate.MustNotBeEmpty); + return option; + } + + private static class Validate + { + public static void MustBeBetween(OptionResult result, int minimum, int maximum) + { + for (int i = 0; i < result.Tokens.Count; i++) { + if (int.TryParse(result.Tokens[i].Value, out int value)) { + if (value is < 1 or > 1000) { + result.ErrorMessage = $"The value for {result.Token.Value} must be greater than {minimum} and less than {maximum}"; + break; + } + } else { + result.ErrorMessage = $"{result.Tokens[i].Value} is not a valid integer for {result.Token.Value}"; + break; + } + } + } + + public static void RequiresExtension(OptionResult result, string extension) + { + for (int i = 0; i < result.Tokens.Count; i++) { + if (!string.Equals(Path.GetExtension(result.Tokens[i].Value), extension, StringComparison.InvariantCultureIgnoreCase)) { + result.ErrorMessage = $"{result.Token.Value} does not have an {extension} extension"; + break; + } + } + } + + public static void AreMutuallyExclusive(CommandResult result, Option[] options) + { + var specifiedOptions = options + .Where(x => result.FindResultFor(x) is not null) + .ToList(); + if (specifiedOptions.Count > 1) { + string optionsString = string.Join(" and ", specifiedOptions.Select(x => $"'{x.Aliases.First()}'")); + result.ErrorMessage = $"Cannot use {optionsString} options together, please choose one."; + } + } + + public static void AtLeastOneRequired(CommandResult result, Option[] options, bool onlyShowFirst = false) + { + var anySpecifiedOptions = options + .Any(x => result.FindResultFor(x) is not null); + if (!anySpecifiedOptions) { + if (onlyShowFirst) { + result.ErrorMessage = $"Required argument missing for option: {options.First().Aliases.First()}"; + } else { + string optionsString = string.Join(" and ", options.Select(x => $"'{x.Aliases.First()}'")); + result.ErrorMessage = $"At least one of the following options are required {optionsString}."; + } + } + } + + public static void MustContain(OptionResult result, string value) + { + for (int i = 0; i < result.Tokens.Count; i++) { + if (result.Tokens[i].Value?.Contains(value) == false) { + result.ErrorMessage = $"{result.Token.Value} must contain '{value}'. Current value is '{result.Tokens[i].Value}'"; + break; + } + } + } + + public static void RequiresScheme(OptionResult result, string[] validSchemes) + { + for (int i = 0; i < result.Tokens.Count; i++) { + if (Uri.TryCreate(result.Tokens[i].Value, UriKind.RelativeOrAbsolute, out Uri uri) && + uri.IsAbsoluteUri && + !validSchemes.Contains(uri.Scheme)) { + result.ErrorMessage = $"{result.Token.Value} must contain a Uri with one of the following schems: {string.Join(", ", validSchemes)}. Current value is '{result.Tokens[i].Value}'"; + break; + } + } + } + + public static void RequiresAbsolute(OptionResult result) + { + for (int i = 0; i < result.Tokens.Count; i++) { + if (!Uri.TryCreate(result.Tokens[i].Value, UriKind.Absolute, out Uri _)) { + result.ErrorMessage = $"{result.Token.Value} must contain an absolute Uri. Current value is '{result.Tokens[i].Value}'"; + break; + } + } + } + + public static void RequiresValidNuGetId(OptionResult result) + { + for (int i = 0; i < result.Tokens.Count; i++) { + if (!NugetUtil.IsValidNuGetId(result.Tokens[i].Value)) { + result.ErrorMessage = $"{result.Token.Value} is an invalid NuGet package id. It must contain only alphanumeric characters, underscores, dashes, and dots.. Current value is '{result.Tokens[i].Value}'"; + break; + } + } + } + + public static void RequiresSemverCompliant(OptionResult result) + { + string specifiedAlias = result.Token.Value; + + for (int i = 0; i < result.Tokens.Count; i++) { + string version = result.Tokens[i].Value; + //TODO: This is duplicating NugetUtil.ThrowIfVersionNotSemverCompliant + if (SemanticVersion.TryParse(version, out var parsed)) { + if (parsed < new SemanticVersion(0, 0, 1)) { + result.ErrorMessage = $"{result.Token.Value} contains an invalid package version '{version}', it must be >= 0.0.1."; + break; + } + } else { + result.ErrorMessage = $"{result.Token.Value} contains an invalid package version '{version}', it must be a 3-part SemVer2 compliant version string."; + break; + } + } + } + + public static void MustNotBeEmpty(OptionResult result) + { + for (int i = 0; i < result.Tokens.Count; i++) { + var token = result.Tokens[i]; + + if (!Directory.Exists(token.Value) || + !Directory.EnumerateFileSystemEntries(token.Value).Any()) { + result.ErrorMessage = $"{result.Token.Value} must a non-empty directory, but the specified directory '{token.Value}' was empty."; + return; + } + } + } + } + } +} diff --git a/src/Squirrel.CommandLine/ValidatedOptionSet.cs b/src/Squirrel.CommandLine/ValidatedOptionSet.cs deleted file mode 100644 index f31d035c4..000000000 --- a/src/Squirrel.CommandLine/ValidatedOptionSet.cs +++ /dev/null @@ -1,247 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using Mono.Options; - -namespace Squirrel.CommandLine -{ - internal class OptionValidationException : Exception - { - public OptionValidationException(string message) : base(message) - { - } - - public OptionValidationException(string propertyName, string message) : base($"Argument '{propertyName}': {message}") - { - - } - } - - internal abstract class ValidatedOptionSet : OptionSet - { - public OptionSet InsertAt(int index, string prototype, string description, Action action) - { - return InsertAt(index, prototype, description, action, false); - } - - public OptionSet InsertAt(int index, string prototype, string description, Action action, bool hidden) - { - if (action == null) - throw new ArgumentNullException("action"); - Option p = new ActionOption(prototype, description, 1, delegate (OptionValueCollection v) { action(v[0]); }, hidden); - base.InsertItem(index, p); - return this; - } - protected virtual bool IsNullOrDefault(string propertyName) - { - var p = this.GetType().GetProperty(propertyName); - object argument = p.GetValue(this, null); - - // deal with normal scenarios - if (argument == null) return true; - - // deal with non-null nullables - Type methodType = argument.GetType(); - if (Nullable.GetUnderlyingType(methodType) != null) return false; - - // deal with boxed value types - Type argumentType = argument.GetType(); - if (argumentType.IsValueType && argumentType != methodType) { - object obj = Activator.CreateInstance(argument.GetType()); - return obj.Equals(argument); - } - - return false; - } - - protected virtual void IsRequired(params string[] propertyNames) - { - foreach (var property in propertyNames) { - IsRequired(property); - } - } - - protected virtual void IsRequired(string propertyName) - { - if (IsNullOrDefault(propertyName)) - throw new OptionValidationException($"Argument '{propertyName}' is required"); - } - - protected virtual void IsValidFile(string propertyName, string forcedExtension = null) - { - var p = this.GetType().GetProperty(propertyName); - var path = p.GetValue(this, null) as string; - if (path != null) { - if (!File.Exists(path)) { - throw new OptionValidationException($"Argument '{propertyName}': Expected file to exist at this location but no file was found"); - } else if (forcedExtension != null && !Path.GetExtension(path).TrimStart('.').Equals(forcedExtension.TrimStart('.'), StringComparison.InvariantCultureIgnoreCase)) { - throw new OptionValidationException($"Argument '{propertyName}': File must be of type '{forcedExtension}'."); - } - } - } - - protected virtual void IsValidDirectory(string propertyName, bool verifyIsNotEmpty) - { - var p = this.GetType().GetProperty(propertyName); - var path = p.GetValue(this, null) as string; - if (path != null) { - if (!Directory.Exists(path)) { - throw new OptionValidationException($"Argument '{propertyName}': Expected directory to exist at this location but none was found"); - } - - if (verifyIsNotEmpty && !Directory.EnumerateFileSystemEntries(path).Any()) { - throw new OptionValidationException($"Argument '{propertyName}': Expected non-empty directory, but the specified directory was empty."); - } - } - } - - protected virtual void IsValidUrl(string propertyName) - { - var p = this.GetType().GetProperty(propertyName); - var val = p.GetValue(this, null) as string; - if (val != null) - if (!Utility.IsHttpUrl(val)) - throw new OptionValidationException(propertyName, "Must start with http or https and be a valid URI."); - } - - protected virtual int ParseIntArg(string propertyName, string v, int min = Int32.MinValue, int max = Int32.MaxValue) - { - if (!int.TryParse(v, out var i) || i < min || i > max) { - throw new OptionValidationException(propertyName, $"Must be an integer between {min} and {max}."); - } - - return i; - } - - public abstract void Validate(); - - public virtual void WriteOptionDescriptions() - { - WriteOptionDescriptions(Console.Out); - } - } - - internal abstract class CommandAction - { - public string Command { get; protected set; } - public string Description { get; protected set; } - public abstract void Execute(IEnumerable args); - public abstract void PrintHelp(); - } - - internal class HelpText : CommandAction - { - public HelpText(string text) - { - Description = text; - } - - public override void Execute(IEnumerable args) - { - throw new NotSupportedException(); - } - - public override void PrintHelp() - { - Console.WriteLine(Description); - } - } - - internal class CommandAction : CommandAction where T : ValidatedOptionSet, new() - { - public T Options { get; } - public Action Action { get; } - - public CommandAction(string command, string description, T options, Action action) - { - Command = command; - Description = description; - Options = options; - Action = action; - } - - public override void Execute(IEnumerable args) - { - Options.Parse(args); - Options.Validate(); - Action(Options); - } - - public override void PrintHelp() - { - Options.WriteOptionDescriptions(); - } - } - - internal class CommandSet : List - { - public void Add(string helpText) - { - this.Add(new HelpText(helpText)); - } - - public void Add(string command, string description, T options, Action action) where T : ValidatedOptionSet, new() - { - this.Add(new CommandAction(command, description, options, action)); - } - - public void Add(CommandSet commands) - { - AddRange(commands); - } - - public virtual void Execute(string[] args) - { - if (args.Length == 0) - throw new OptionValidationException("No arguments provided. Must specify a command to execute."); - - var commands = this.Where(k => !String.IsNullOrWhiteSpace(k.Command)); - CommandAction cmd = null; - foreach (var k in commands.OrderByDescending(k => k.Command.Length)) { - if (args[0].Equals(k.Command, StringComparison.InvariantCultureIgnoreCase)) { - cmd = k; - break; - } - } - - if (cmd == null) { - throw new OptionValidationException( - $"Command '{args[0]}' does not exist. First argument must be one of the following: " - + String.Join(", ", commands.Select(s => s.Command)) + "."); - } - - cmd.Execute(args.Skip(1)); - } - - public virtual void WriteHelp() - { - var array = this.ToArray(); - for (var i = 0; i < array.Length; i++) { - var c = array[i]; - - if (c is HelpText) { - c.PrintHelp(); - continue; - } - - // print command name + desc - Console.WriteLine(); - Utility.ConsoleWriteWithColor(c.Command, ConsoleColor.Magenta); - if (!String.IsNullOrWhiteSpace(c.Description)) - Console.Write(": " + c.Description); - - // group similar command parameters together - if (i + 1 < array.Length) { - if (c.GetType() == array[i + 1].GetType()) { - continue; - } - - } - - Console.WriteLine(); - c.PrintHelp(); - } - } - } -} diff --git a/src/Squirrel.CommandLine/Windows/Commands.cs b/src/Squirrel.CommandLine/Windows/Commands.cs index d8996940f..d36f466f0 100644 --- a/src/Squirrel.CommandLine/Windows/Commands.cs +++ b/src/Squirrel.CommandLine/Windows/Commands.cs @@ -1,17 +1,16 @@ using System; using System.Collections.Generic; +using System.CommandLine; using System.Drawing; using System.Globalization; using System.IO; using System.Linq; using System.Runtime.Versioning; -using System.Security; using System.Text; using System.Text.RegularExpressions; -using System.Threading.Tasks; -using NuGet.Versioning; using Squirrel.NuGet; using Squirrel.SimpleSplat; +using FileMode = System.IO.FileMode; namespace Squirrel.CommandLine.Windows { @@ -19,16 +18,13 @@ class Commands : IEnableLogger { static IFullLogger Log => SquirrelLocator.Current.GetService().GetLogger(typeof(Commands)); - public static CommandSet GetCommands() + public static IEnumerable GetCommands() { - return new CommandSet { - "[ Package Authoring ]", - { "pack", "Creates a Squirrel release from a folder containing application files", new PackOptions(), Pack }, - { "releasify", "Take an existing nuget package and convert it into a Squirrel release", new ReleasifyOptions(), Releasify }, - }; + yield return new PackCommand(); + yield return new ReleasifyCommand(); } - static void Pack(PackOptions options) + public static void Pack(PackOptions options) { using (Utility.GetTempDirectory(out var tmp)) { var nupkgPath = NugetConsole.CreatePackageFromMetadata( @@ -40,7 +36,7 @@ static void Pack(PackOptions options) } } - static void Releasify(ReleasifyOptions options) + public static void Releasify(ReleasifyOptions options) { var targetDir = options.GetReleaseDirectory(); var package = options.package; @@ -143,9 +139,9 @@ RuntimeCpu parseMachine(PeNet.Header.Pe.MachineType machine) } var peArch = from pe in peparsed - let machine = pe.Value?.ImageNtHeaders?.FileHeader?.Machine ?? 0 - let arch = parseMachine(machine) - select new { Name = Path.GetFileName(pe.Key), Architecture = arch }; + let machine = pe.Value?.ImageNtHeaders?.FileHeader?.Machine ?? 0 + let arch = parseMachine(machine) + select new { Name = Path.GetFileName(pe.Key), Architecture = arch }; if (awareExes.Count > 0) { Log.Info($"There are {awareExes.Count} SquirrelAwareApp's. Binaries will be executed during install/update/uninstall hooks."); @@ -182,13 +178,13 @@ RuntimeCpu parseMachine(PeNet.Header.Pe.MachineType machine) // and do it before signing so that Update.exe will also be signed. It is renamed to // 'Squirrel.exe' only because Squirrel.Windows expects it to be called this. File.Copy(updatePath, Path.Combine(libDir, "Squirrel.exe"), true); - + // sign all exe's in this package var filesToSign = new DirectoryInfo(libDir).GetAllFilesRecursively() .Where(x => options.signSkipDll ? Utility.PathPartEndsWith(x.Name, ".exe") : Utility.FileIsLikelyPEImage(x.Name)) .Select(x => x.FullName) .ToArray(); - + options.SignFiles(libDir, filesToSign); // copy app icon to 'lib/fx/app.ico' @@ -265,7 +261,7 @@ RuntimeCpu parseMachine(PeNet.Header.Pe.MachineType machine) Log.Info("Bundle package offset is " + bundleOffset); List setupFilesToSign = new() { targetSetupExe }; - + Log.Info($"Setup bundle created at '{targetSetupExe}'."); // this option is used for debugging a local Setup.exe @@ -283,8 +279,8 @@ RuntimeCpu parseMachine(PeNet.Header.Pe.MachineType machine) Log.Warn("Unable to create MSI (only supported on windows)."); } } - - options.SignFiles(targetDir.FullName, setupFilesToSign.ToArray()); + + options.SignFiles(targetDir.FullName, setupFilesToSign.ToArray()); Log.Info("Done"); } diff --git a/src/Squirrel.CommandLine/Windows/Options.cs b/src/Squirrel.CommandLine/Windows/Options.cs index b3e3c7fc9..9156d9bb2 100644 --- a/src/Squirrel.CommandLine/Windows/Options.cs +++ b/src/Squirrel.CommandLine/Windows/Options.cs @@ -1,32 +1,16 @@ -using System; +using System; using System.Collections.Generic; -using System.Diagnostics; -using System.Runtime.Versioning; -using System.Text.RegularExpressions; -using System.Threading; -using Squirrel.Lib; namespace Squirrel.CommandLine.Windows { internal class SigningOptions : BaseOptions { - public string signParams { get; private set; } - public string signTemplate { get; private set; } - public bool signSkipDll { get; private set; } - public int signParallel { get; private set; } = 10; + public const int SignParallelDefault = 10; - public SigningOptions() - { - if (SquirrelRuntimeInfo.IsWindows) { - Add("n=|signParams=", "Sign files via signtool.exe using these {PARAMETERS}", - v => signParams = v); - Add("signSkipDll", "Only signs EXE files, and skips signing DLL files.", v => signSkipDll = true); - Add("signParallel=", "The number of files to sign in each call to signtool.exe", - v => signParallel = ParseIntArg(nameof(signParallel), v, 1, 1000)); - } - - Add("signTemplate=", "Use a custom signing {COMMAND}. '{{{{file}}}}' will be replaced by the path of the file to sign.", v => signTemplate = v); - } + public string signParams { get; set; } + public string signTemplate { get; set; } + public bool signSkipDll { get; set; } + public int signParallel { get; set; } public void SignFiles(string rootDir, params string[] filePaths) { @@ -51,124 +35,31 @@ public void SignFiles(string rootDir, params string[] filePaths) HelperExe.SignPEFilesWithSignTool(rootDir, filePaths, signParams, signParallel); } } - - public override void Validate() - { - if (!String.IsNullOrEmpty(signParams) && !String.IsNullOrEmpty(signTemplate)) { - throw new OptionValidationException($"Cannot use 'signParams' and 'signTemplate' options together, please choose one or the other."); - } - - if (!String.IsNullOrEmpty(signTemplate) && !signTemplate.Contains("{{file}}")) { - throw new OptionValidationException( - $"Argument 'signTemplate': Must contain '{{{{file}}}}' in template string (replaced with the file to sign). Current value is '{signTemplate}'"); - } - } } internal class ReleasifyOptions : SigningOptions { public string package { get; set; } - public string baseUrl { get; private set; } - public string framework { get; private set; } - public string splashImage { get; private set; } - public string icon { get; private set; } - public string appIcon { get; private set; } - public bool noDelta { get; private set; } - public string msi { get; private set; } - public string debugSetupExe { get; private set; } - public string[] mainExes => _mainExes.ToArray(); - - private List _mainExes = new(); - - public ReleasifyOptions() - { - // hidden arguments - Add("b=|baseUrl=", "Provides a base URL to prefix the RELEASES file packages with", v => baseUrl = v, true); - Add("addSearchPath=", "Add additional search directories when looking for helper exe's such as Setup.exe, Update.exe, etc", - HelperExe.AddSearchPath, true); - Add("debugSetupExe=", "Uses the Setup.exe at this {PATH} to create the bundle, and then replaces it with the bundle. " + - "Used for locally debugging Setup.exe with a real bundle attached.", v => debugSetupExe = v, true); - - // public arguments - InsertAt(1, "p=|package=", "{PATH} to a '.nupkg' package to releasify", v => package = v); - Add("noDelta", "Skip the generation of delta packages", v => noDelta = true); - Add("f=|framework=", "List of required {RUNTIMES} to install during setup\nexample: 'net6,vcredist143'", v => framework = v); - Add("s=|splashImage=", "{PATH} to image/gif displayed during installation", v => splashImage = v); - Add("i=|icon=", "{PATH} to .ico for Setup.exe and Update.exe", v => icon = v); - Add("e=|mainExe=", "{NAME} of one or more SquirrelAware executables", _mainExes.Add); - Add("appIcon=", "{PATH} to .ico for 'Apps and Features' list", v => appIcon = v); - if (SquirrelRuntimeInfo.IsWindows) { - Add("msi=", "Compile a .msi machine-wide deployment tool with the specified {BITNESS}. (either 'x86' or 'x64')", v => msi = v.ToLower()); - } - } - - public override void Validate() - { - ValidateInternal(true); - } - - protected virtual void ValidateInternal(bool checkPackage) - { - IsValidFile(nameof(appIcon), ".ico"); - IsValidFile(nameof(icon), ".ico"); - IsValidFile(nameof(splashImage)); - IsValidUrl(nameof(baseUrl)); - - if (checkPackage) { - IsRequired(nameof(package)); - IsValidFile(nameof(package), ".nupkg"); - } - - if (!String.IsNullOrEmpty(msi)) - if (!msi.Equals("x86") && !msi.Equals("x64")) - throw new OptionValidationException($"Argument 'msi': File must be either 'x86' or 'x64'. Actual value was '{msi}'."); - - base.Validate(); - } + public string baseUrl { get; set; } + public string framework { get; set; } + public string splashImage { get; set; } + public string icon { get; set; } + public string appIcon { get; set; } + public bool noDelta { get; set; } + public string msi { get; set; } + public string debugSetupExe { get; set; } + + public List mainExes { get; } = new(); } internal class PackOptions : ReleasifyOptions { - public string packId { get; private set; } - public string packTitle { get; private set; } - public string packVersion { get; private set; } - public string packAuthors { get; private set; } - public string packDirectory { get; private set; } - public bool includePdb { get; private set; } - public string releaseNotes { get; private set; } - - public PackOptions() - { - // remove 'package' argument from ReleasifyOptions - Remove("package"); - Remove("p"); - - // hidden arguments - Add("packName=", "The name of the package to create", - v => { - packId = v; - Log.Warn("--packName is deprecated. Use --packId instead."); - }, true); - Add("packDirectory=", "", v => packDirectory = v, true); - - // public arguments, with indexes so they appear before ReleasifyOptions - InsertAt(1, "u=|packId=", "Unique {ID} for release", v => packId = v); - InsertAt(2, "v=|packVersion=", "Current {VERSION} for release", v => packVersion = v); - InsertAt(3, "p=|packDir=", "{DIRECTORY} containing application files for release", v => packDirectory = v); - InsertAt(4, "packTitle=", "Optional display/friendly {NAME} for release", v => packTitle = v); - InsertAt(5, "packAuthors=", "Optional company or list of release {AUTHORS}", v => packAuthors = v); - InsertAt(6, "includePdb", "Add *.pdb files to release package", v => includePdb = true); - InsertAt(7, "releaseNotes=", "{PATH} to file with markdown notes for version", v => releaseNotes = v); - } - - public override void Validate() - { - IsRequired(nameof(packId), nameof(packVersion), nameof(packDirectory)); - Squirrel.NuGet.NugetUtil.ThrowIfInvalidNugetId(packId); - Squirrel.NuGet.NugetUtil.ThrowIfVersionNotSemverCompliant(packVersion); - IsValidDirectory(nameof(packDirectory), true); - IsValidFile(nameof(releaseNotes)); - base.ValidateInternal(false); - } + public string packId { get; set; } + public string packTitle { get; set; } + public string packVersion { get; set; } + public string packAuthors { get; set; } + public string packDirectory { get; set; } + public bool includePdb { get; set; } + public string releaseNotes { get; set; } } } \ No newline at end of file diff --git a/src/Squirrel.CommandLine/Windows/PackCommand.cs b/src/Squirrel.CommandLine/Windows/PackCommand.cs new file mode 100644 index 000000000..d51f53574 --- /dev/null +++ b/src/Squirrel.CommandLine/Windows/PackCommand.cs @@ -0,0 +1,103 @@ +using System; +using System.CommandLine; +using System.CommandLine.Invocation; +using System.IO; + +namespace Squirrel.CommandLine.Windows +{ + public class PackCommand : ReleaseCommand + { + public Option PackName { get; } + public Option PackDirectoryObsolete { get; } + public Option PackDirectory { get; } + public Option PackId { get; } + public Option PackVersion { get; } + public Option PackTitle { get; } + public Option PackAuthors { get; } + public Option IncludePdb { get; } + public Option ReleaseNotes { get; } + + public PackCommand() + : base("pack", "Creates a Squirrel release from a folder containing application files") + { + PackId = new Option(new[] { "-u", "--packId" }, "Unique {ID} for release") { + ArgumentHelpName = "ID" + }; + PackId.RequiresValidNuGetId(); + Add(PackId); + + PackName = new Option("--packName", $"The name of the package to create. This is deprecated, use {PackId.Name} instead.") { + IsHidden = true, + }; + Add(PackName); + + this.RequiredAllowObsoleteFallback(PackId, PackName); + + PackDirectoryObsolete = new Option("--packDirectory", "Obsolete, use --packDir instead"); + PackDirectoryObsolete.MustNotBeEmpty(); + Add(PackDirectoryObsolete); + + PackDirectory = new Option(new[] { "--packDir", "-p" }, "{DIRECTORY} containing application files for release") { + ArgumentHelpName = "DIRECTORY" + }; + PackDirectory.MustNotBeEmpty(); + Add(PackDirectory); + + PackVersion = new Option(new[] { "-v", "--packVersion" }, "Current {VERSION} for release") { + ArgumentHelpName = "VERSION", + IsRequired = true, + }; + PackVersion.RequiresSemverCompliant(); + Add(PackVersion); + + PackTitle = new Option("--packTitle", "Optional display/friendly {NAME} for release") { + ArgumentHelpName = "NAME" + }; + Add(PackTitle); + + PackAuthors = new Option("--packAuthors", "Optional company or list of release {AUTHORS}") { + ArgumentHelpName = "AUTHORS" + }; + Add(PackAuthors); + + IncludePdb = new Option("--includePdb", "Add *.pdb files to release package"); + Add(IncludePdb); + + ReleaseNotes = new Option("--releaseNotes", "{PATH} to file with markdown notes for version") { + ArgumentHelpName = "PATH" + }; + ReleaseNotes.ExistingOnly(); + Add(ReleaseNotes); + + + this.SetHandler(Execute); + } + + private protected void SetOptionsValues(InvocationContext context, PackOptions options) + { + options.packId = context.ParseResult.GetValueForOption(PackId); + if (string.IsNullOrEmpty(options.packId) && context.ParseResult.GetValueForOption(PackName) is { } packName) { + options.packId = packName; + Log.Warn("--packName is deprecated. Use --packId instead."); + } + options.packVersion = context.ParseResult.GetValueForOption(PackVersion); + options.packDirectory = context.ParseResult.GetValueForOption(PackDirectory)?.FullName; + options.packTitle = context.ParseResult.GetValueForOption(PackTitle); + options.packAuthors = context.ParseResult.GetValueForOption(PackAuthors); + options.includePdb = context.ParseResult.GetValueForOption(IncludePdb); + options.releaseNotes = context.ParseResult.GetValueForOption(ReleaseNotes)?.FullName; + } + + private void Execute(InvocationContext context) + { + var packOptions = new PackOptions(); + SetOptionsValues(context, packOptions); + //TODO: Would be nice to just be able to make the option required, but the requirement from the + // previous code allows multiple options to set the packId property. + if (string.IsNullOrEmpty(packOptions.packId)) + throw new InvalidOperationException($"'{PackId.Name}' is required"); + + Commands.Pack(packOptions); + } + } +} \ No newline at end of file diff --git a/src/Squirrel.CommandLine/Windows/ReleaseCommand.cs b/src/Squirrel.CommandLine/Windows/ReleaseCommand.cs new file mode 100644 index 000000000..e9a345295 --- /dev/null +++ b/src/Squirrel.CommandLine/Windows/ReleaseCommand.cs @@ -0,0 +1,114 @@ +using System; +using System.CommandLine; +using System.CommandLine.Invocation; +using System.IO; + +namespace Squirrel.CommandLine.Windows +{ + public class ReleaseCommand : SigningCommand + { + public Option BaseUrl { get; } + public Option AddSearchPath { get; } + public Option DebugSetupExe { get; } + + public Option NoDelta { get; } + public Option Runtimes { get; } + public Option SplashImage { get; } + public Option Icon { get; } + public Option SquirrelAwareExecutable { get; } + public Option AppIcon { get; } + public Option BuildMsi { get; } + + protected ReleaseCommand(string name, string description) + : base(name, description) + { + BaseUrl = new Option(new[] { "-b", "--baseUrl" }, "Provides a base URL to prefix the RELEASES file packages with") { + IsHidden = true + }; + BaseUrl.MustBeValidHttpUri(); + Add(BaseUrl); + + AddSearchPath = new Option("--addSearchPath", "Add additional search directories when looking for helper exe's such as Setup.exe, Update.exe, etc") { + IsHidden = true + }; + Add(AddSearchPath); + + DebugSetupExe = new Option("--debugSetupExe", "Uses the Setup.exe at this {PATH} to create the bundle, and then replaces it with the bundle. " + + "Used for locally debugging Setup.exe with a real bundle attached.") { + ArgumentHelpName = "PATH", + IsHidden = true + }; + Add(DebugSetupExe); + + NoDelta = new Option("--noDelta", "Skip the generation of delta packages"); + Add(NoDelta); + + Runtimes = new Option(new[] { "-f", "--framework" }, "List of required {RUNTIMES} to install during setup. example: 'net6,vcredist143'") { + ArgumentHelpName = "RUNTIMES" + }; + Add(Runtimes); + + SplashImage = new Option(new[] { "-s", "--splashImage" }, "{PATH} to image/gif displayed during installation") { + ArgumentHelpName = "PATH" + }; + SplashImage.ExistingOnly(); + Add(SplashImage); + + Icon = new Option(new[] { "-i", "--icon" }, "{PATH} to .ico for Setup.exe and Update.exe") { + ArgumentHelpName = "PATH" + }; + Icon.ExistingOnly().RequiresExtension(".ico"); + Add(Icon); + + SquirrelAwareExecutable = new Option(new[] { "-e", "--mainExe" }, "{NAME} of one or more SquirrelAware executables") { + ArgumentHelpName = "NAME" + }; + Add(SquirrelAwareExecutable); + + AppIcon = new Option("--appIcon", "{PATH} to .ico for 'Apps and Features' list") { + ArgumentHelpName = "PATH" + }; + AppIcon.ExistingOnly().RequiresExtension(".ico"); + Add(AppIcon); + + if (SquirrelRuntimeInfo.IsWindows) { + BuildMsi = new Option("--msi", "Compile a .msi machine-wide deployment tool with the specified {BITNESS}.") { + ArgumentHelpName = "BITNESS" + }; + Add(BuildMsi); + } + } + + private protected void SetOptionsValues(InvocationContext context, ReleasifyOptions options) + { + base.SetOptionsValues(context, options); + + options.baseUrl = context.ParseResult.GetValueForOption(BaseUrl)?.AbsoluteUri; + //TODO: This is a little awkward to set a value as part of parsing + if (context.ParseResult.GetValueForOption(AddSearchPath) is { } searchPath) { + HelperFile.AddSearchPath(searchPath); + } + options.debugSetupExe = context.ParseResult.GetValueForOption(DebugSetupExe)?.FullName; + options.noDelta = context.ParseResult.GetValueForOption(NoDelta); + options.framework = context.ParseResult.GetValueForOption(Runtimes); + options.splashImage = context.ParseResult.GetValueForOption(SplashImage)?.FullName; + options.icon = context.ParseResult.GetValueForOption(Icon)?.FullName; + //TODO: This is a little awkward to set a value as part of parsing + if (context.ParseResult.GetValueForOption(SquirrelAwareExecutable) is { } mainExes) { + options.mainExes.AddRange(mainExes); + } + options.appIcon = context.ParseResult.GetValueForOption(AppIcon)?.FullName; + + if (SquirrelRuntimeInfo.IsWindows) { + switch (context.ParseResult.GetValueForOption(BuildMsi)) { + case Bitness.x86: + options.msi = "x86"; + break; + case Bitness.x64: + options.msi = "x64"; + break; + } + } + } + } +} \ No newline at end of file diff --git a/src/Squirrel.CommandLine/Windows/ReleasifyCommand.cs b/src/Squirrel.CommandLine/Windows/ReleasifyCommand.cs new file mode 100644 index 000000000..e1dd8757b --- /dev/null +++ b/src/Squirrel.CommandLine/Windows/ReleasifyCommand.cs @@ -0,0 +1,39 @@ +using System.CommandLine; +using System.CommandLine.Invocation; +using System.IO; + +namespace Squirrel.CommandLine.Windows +{ + public class ReleasifyCommand : ReleaseCommand + { + public Option Package { get; } + + public ReleasifyCommand() + : base("releasify", "Take an existing nuget package and convert it into a Squirrel release") + { + Package = new Option(new[] { "-p", "--package" }, "{PATH} to a '.nupkg' package to releasify") { + ArgumentHelpName = "PATH", + IsRequired = true, + }; + Package.ExistingOnly().RequiresExtension(".nupkg"); + Add(Package); + + this.SetHandler(Execute); + } + + //NB: Intentionally hiding base member here. + private protected new void SetOptionsValues(InvocationContext context, ReleasifyOptions options) + { + base.SetOptionsValues(context, options); + options.package = context.ParseResult.GetValueForOption(Package)?.FullName; + } + + private void Execute(InvocationContext context) + { + var releasifyOptions = new ReleasifyOptions(); + SetOptionsValues(context, releasifyOptions); + + Commands.Releasify(releasifyOptions); + } + } +} \ No newline at end of file diff --git a/src/Squirrel.CommandLine/Windows/SigningCommand.cs b/src/Squirrel.CommandLine/Windows/SigningCommand.cs new file mode 100644 index 000000000..718842b07 --- /dev/null +++ b/src/Squirrel.CommandLine/Windows/SigningCommand.cs @@ -0,0 +1,50 @@ +using System.CommandLine; +using System.CommandLine.Invocation; + +namespace Squirrel.CommandLine.Windows +{ + public class SigningCommand : BaseCommand + { + public Option SignParameters { get; } + public Option SignSkipDll { get; } + public Option SignParallel { get; } + public Option SignTemplate { get; } + + protected SigningCommand(string name, string description) + : base(name, description) + { + SignTemplate = new Option("--signTemplate", "Use a custom signing {COMMAND}. '{{file}}' will be replaced by the path of the file to sign.") { + ArgumentHelpName = "COMMAND" + }; + SignTemplate.MustContain("{{file}}"); + + if (SquirrelRuntimeInfo.IsWindows) { + SignParameters = new Option(new[] { "--signParams", "-n" }, "Sign files via signtool.exe using these {PARAMETERS}") { + ArgumentHelpName = "PARAMETERS" + }; + this.AreMutuallyExclusive(SignParameters, SignTemplate); + Add(SignParameters); + + SignSkipDll = new Option("--signSkipDll", "Only signs EXE files, and skips signing DLL files."); + Add(SignSkipDll); + + SignParallel = new Option("--signParallel", () => SigningOptions.SignParallelDefault, "The number of files to sign in each call to signtool.exe"); + SignParallel.MustBeBetween(1, 1000); + Add(SignParallel); + } + + Add(SignTemplate); + } + + private protected void SetOptionsValues(InvocationContext context, SigningOptions options) + { + base.SetOptionsValues(context, options); + if (SquirrelRuntimeInfo.IsWindows) { + options.signParams = context.ParseResult.GetValueForOption(SignParameters); + options.signSkipDll = context.ParseResult.GetValueForOption(SignSkipDll); + options.signParallel = context.ParseResult.GetValueForOption(SignParallel); + } + options.signTemplate = context.ParseResult.GetValueForOption(SignTemplate); + } + } +} \ No newline at end of file diff --git a/src/Squirrel.Tool/NugetDownloader.cs b/src/Squirrel.Tool/NugetDownloader.cs index bcd9f2479..30acadcda 100644 --- a/src/Squirrel.Tool/NugetDownloader.cs +++ b/src/Squirrel.Tool/NugetDownloader.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Text.RegularExpressions; using System.Threading; +using System.Threading.Tasks; using NuGet.Common; using NuGet.Configuration; using NuGet.Packaging.Core; @@ -27,18 +28,18 @@ public NugetDownloader(ILogger logger) _sourceCacheContext = new SourceCacheContext(); } - public IPackageSearchMetadata GetPackageMetadata(string packageName, string version) + public async Task GetPackageMetadata(string packageName, string version, CancellationToken cancellationToken) { PackageMetadataResource packageMetadataResource = _sourceRepository.GetResource(); FindPackageByIdResource packageByIdResource = _sourceRepository.GetResource(); IPackageSearchMetadata package = null; var prerelease = version?.Equals("pre", StringComparison.InvariantCultureIgnoreCase) == true; - if (version == null || version.Equals("latest", StringComparison.InvariantCultureIgnoreCase) || prerelease) { + if (version is null || version.Equals("latest", StringComparison.InvariantCultureIgnoreCase) || prerelease) { // get latest (or prerelease) version - IEnumerable metadata = packageMetadataResource - .GetMetadataAsync(packageName, true, true, _sourceCacheContext, _logger, CancellationToken.None) - .GetAwaiter().GetResult(); + IEnumerable metadata = await packageMetadataResource + .GetMetadataAsync(packageName, true, true, _sourceCacheContext, _logger, cancellationToken) + .ConfigureAwait(false); package = metadata .Where(x => x.IsListed) .Where(x => prerelease || !x.Identity.Version.IsPrerelease) @@ -46,30 +47,30 @@ public IPackageSearchMetadata GetPackageMetadata(string packageName, string vers .FirstOrDefault(); } else { // resolve version ranges and wildcards - var versions = packageByIdResource.GetAllVersionsAsync(packageName, _sourceCacheContext, _logger, CancellationToken.None) - .GetAwaiter().GetResult(); + var versions = await packageByIdResource.GetAllVersionsAsync(packageName, _sourceCacheContext, _logger, cancellationToken) + .ConfigureAwait(false); var resolved = versions.FindBestMatch(VersionRange.Parse(version), version => version); // get exact version var packageIdentity = new PackageIdentity(packageName, resolved); - package = packageMetadataResource - .GetMetadataAsync(packageIdentity, _sourceCacheContext, _logger, CancellationToken.None) - .GetAwaiter().GetResult(); + package = await packageMetadataResource + .GetMetadataAsync(packageIdentity, _sourceCacheContext, _logger, cancellationToken) + .ConfigureAwait(false); } - if (package == null) { + if (package is null) { throw new Exception($"Unable to locate {packageName} {version} on NuGet.org"); } return package; } - public void DownloadPackageToStream(IPackageSearchMetadata package, Stream targetStream) + public async Task DownloadPackageToStream(IPackageSearchMetadata package, Stream targetStream, CancellationToken cancellationToken) { FindPackageByIdResource packageByIdResource = _sourceRepository.GetResource(); - packageByIdResource - .CopyNupkgToStreamAsync(package.Identity.Id, package.Identity.Version, targetStream, _sourceCacheContext, _logger, CancellationToken.None) - .GetAwaiter().GetResult(); + await packageByIdResource + .CopyNupkgToStreamAsync(package.Identity.Id, package.Identity.Version, targetStream, _sourceCacheContext, _logger, cancellationToken) + .ConfigureAwait(false); } } } \ No newline at end of file diff --git a/src/Squirrel.Tool/Program.cs b/src/Squirrel.Tool/Program.cs index fe55946ba..74f47a4a8 100644 --- a/src/Squirrel.Tool/Program.cs +++ b/src/Squirrel.Tool/Program.cs @@ -1,12 +1,17 @@ using System; using System.Collections.Generic; +using System.CommandLine; +using System.CommandLine.Builder; +using System.CommandLine.Invocation; +using System.CommandLine.Parsing; using System.Diagnostics; using System.IO; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using System.Xml; using System.Xml.Linq; using Microsoft.Build.Construction; -using Mono.Options; using Squirrel.CommandLine; using LogLevel = Squirrel.SimpleSplat.LogLevel; @@ -18,44 +23,68 @@ class Program private static ConsoleLogger _logger; - static int Main(string[] inargs) + private static Option CsqVersion { get; } + = new Option("--csq-version"); + private static Option CsqSolutionPath { get; } + = new Option(new[] { "--csq-sln", "--csq-solution" }).ExistingOnly(); + private static Option Verbose { get; } + = new Option("--verbose"); + + static Task Main(string[] inargs) { _logger = ConsoleLogger.RegisterLogger(); - try { - return MainInner(inargs); - } catch (Exception ex) { - Console.WriteLine("csq error: " + ex.Message); - return -1; + + RootCommand rootCommand = new RootCommand() { + CsqVersion, + CsqSolutionPath, + Verbose + }; + rootCommand.TreatUnmatchedTokensAsErrors = false; + + rootCommand.SetHandler(MainInner); + + ParseResult parseResult = rootCommand.Parse(inargs); + + CommandLineBuilder builder = new CommandLineBuilder(rootCommand); + + if (parseResult.Directives.Contains("local")) { + builder.UseDefaults(); + } else { + builder + .UseParseErrorReporting() + .UseExceptionHandler() + .CancelOnProcessTermination(); } + + + return builder.Build().InvokeAsync(inargs); } - static int MainInner(string[] inargs) + static async Task MainInner(InvocationContext context) { - bool verbose = false; - string explicitSolutionPath = null; - string explicitSquirrelVersion = null; - var toolOptions = new OptionSet() { - { "csq-version=", v => explicitSquirrelVersion = v }, - { "csq-sln=", v => explicitSolutionPath = v }, - { "verbose", _ => verbose = true }, - }; + bool verbose = context.ParseResult.GetValueForOption(Verbose); + FileSystemInfo explicitSolutionPath = context.ParseResult.GetValueForOption(CsqSolutionPath); + string explicitSquirrelVersion = context.ParseResult.GetValueForOption(CsqVersion); // we want to forward the --verbose argument to Squirrel, too. - var verboseArgs = verbose ? new string[] { "--verbose" } : new string[0]; - string[] restArgs = toolOptions.Parse(inargs).Concat(verboseArgs).ToArray(); + var verboseArgs = verbose ? new string[] { "--verbose" } : Array.Empty(); + string[] restArgs = context.ParseResult.UnmatchedTokens + .Concat(verboseArgs) + .ToArray(); if (verbose) { _logger.Level = LogLevel.Debug; } - Console.WriteLine($"Squirrel Locator 'csq' {SquirrelRuntimeInfo.SquirrelDisplayVersion}"); + context.Console.WriteLine($"Squirrel Locator 'csq' {SquirrelRuntimeInfo.SquirrelDisplayVersion}"); _logger.Write($"Entry EXE: {SquirrelRuntimeInfo.EntryExePath}", LogLevel.Debug); + CancellationToken cancellationToken = context.GetCancellationToken(); + + await CheckForUpdates(cancellationToken).ConfigureAwait(false); - CheckForUpdates(); - - var solutionDir = FindSolutionDirectory(explicitSolutionPath); + var solutionDir = FindSolutionDirectory(explicitSolutionPath?.FullName); var nugetPackagesDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".nuget", "packages"); - var cacheDir = Path.GetFullPath(solutionDir == null ? ".squirrel" : Path.Combine(solutionDir, ".squirrel")); + var cacheDir = Path.GetFullPath(solutionDir is null ? ".squirrel" : Path.Combine(solutionDir, ".squirrel")); Dictionary packageSearchPaths = new(); packageSearchPaths.Add("nuget user profile cache", Path.Combine(nugetPackagesDir, CLOWD_PACKAGE_NAME.ToLower(), "{0}", "tools")); @@ -63,7 +92,7 @@ static int MainInner(string[] inargs) packageSearchPaths.Add("visual studio packages cache", Path.Combine(solutionDir, "packages", CLOWD_PACKAGE_NAME + ".{0}", "tools")); packageSearchPaths.Add("squirrel cache", Path.Combine(cacheDir, "{0}", "tools")); - int runSquirrel(string version) + async Task runSquirrel(string version, CancellationToken cancellationToken) { foreach (var kvp in packageSearchPaths) { var path = String.Format(kvp.Value, version); @@ -75,7 +104,7 @@ int runSquirrel(string version) // we did not find it locally on first pass, search for the package online var dl = new NugetDownloader(_logger); - var package = dl.GetPackageMetadata(CLOWD_PACKAGE_NAME, version); + var package = await dl.GetPackageMetadata(CLOWD_PACKAGE_NAME, version, cancellationToken).ConfigureAwait(false); // search one more time now that we've potentially resolved the nuget version foreach (var kvp in packageSearchPaths) { @@ -88,14 +117,15 @@ int runSquirrel(string version) // let's try to download it from NuGet.org var versionDir = Path.Combine(cacheDir, package.Identity.Version.ToString()); - if (!Directory.Exists(cacheDir)) Directory.CreateDirectory(cacheDir); - if (!Directory.Exists(versionDir)) Directory.CreateDirectory(versionDir); + Directory.CreateDirectory(cacheDir); + Directory.CreateDirectory(versionDir); _logger.Write($"Downloading {package.Identity} from NuGet.", LogLevel.Info); var filePath = Path.Combine(versionDir, package.Identity + ".nupkg"); - using (var fs = File.Create(filePath)) - dl.DownloadPackageToStream(package, fs); + using (var fs = File.Create(filePath)) { + await dl.DownloadPackageToStream(package, fs, cancellationToken).ConfigureAwait(false); + } EasyZip.ExtractZipToDirectory(filePath, versionDir); @@ -104,11 +134,12 @@ int runSquirrel(string version) } if (explicitSquirrelVersion != null) { - return runSquirrel(explicitSquirrelVersion); + context.ExitCode = await runSquirrel(explicitSquirrelVersion, cancellationToken).ConfigureAwait(false); + return; } - if (solutionDir == null) { - throw new Exception("Could not find '.sln'. Specify solution with '--csq-sln=', or specify version of squirrel to use with '--csq-version='."); + if (solutionDir is null) { + throw new Exception($"Could not find '.sln'. Specify solution with '{CsqSolutionPath.Aliases.First()}=', or specify version of squirrel to use with '{CsqVersion.Aliases.First()}='."); } _logger.Write("Solution dir found at: " + solutionDir, LogLevel.Debug); @@ -117,28 +148,28 @@ int runSquirrel(string version) var dependencies = GetPackageVersionsFromDir(solutionDir, CLOWD_PACKAGE_NAME).Distinct().ToArray(); if (dependencies.Length == 0) { - throw new Exception("Clowd.Squirrel nuget package was not found installed in solution."); + throw new Exception($"{CLOWD_PACKAGE_NAME} nuget package was not found installed in solution."); } if (dependencies.Length > 1) { - throw new Exception($"Found multiple versions of Clowd.Squirrel installed in solution ({string.Join(", ", dependencies)}). " + - "Please consolidate the following to a single version, or specify the version to use with '--csq-version='"); + throw new Exception($"Found multiple versions of {CLOWD_PACKAGE_NAME} installed in solution ({string.Join(", ", dependencies)}). " + + $"Please consolidate the following to a single version, or specify the version to use with '{CsqVersion.Aliases.First()}='"); } var targetVersion = dependencies.Single(); - return runSquirrel(targetVersion); + context.ExitCode = await runSquirrel(targetVersion, cancellationToken).ConfigureAwait(false); } - static void CheckForUpdates() + static async Task CheckForUpdates(CancellationToken cancellationToken) { try { var myVer = SquirrelRuntimeInfo.SquirrelNugetVersion; var dl = new NugetDownloader(_logger); - var package = dl.GetPackageMetadata("csq", (myVer.IsPrerelease || myVer.HasMetadata) ? "pre" : "latest"); + var package = await dl.GetPackageMetadata("csq", (myVer.IsPrerelease || myVer.HasMetadata) ? "pre" : "latest", cancellationToken).ConfigureAwait(false); if (package.Identity.Version > myVer) _logger.Write($"There is a new version of csq available ({package.Identity.Version})", LogLevel.Warn); - } catch { ; } + } catch { } } static string FindSolutionDirectory(string slnArgument) @@ -217,7 +248,7 @@ static IEnumerable GetPackageVersionsFromDir(string rootDir, string pack _logger.Write($"{packageName} {ver.Value} referenced in {packagesFile}", LogLevel.Debug); - if (ver.Value.Contains("*")) + if (ver.Value.Contains('*')) throw new Exception( $"Wildcard versions are not supported in packages.config. Remove wildcard or upgrade csproj format to use PackageReference."); @@ -228,7 +259,7 @@ static IEnumerable GetPackageVersionsFromDir(string rootDir, string pack foreach (var projFile in EnumerateFilesUntilSpecificDepth(rootDir, "*.csproj", 3)) { var proj = ProjectRootElement.Open(projFile); if (proj == null) continue; - + ProjectItemElement item = proj.Items.FirstOrDefault(i => i.ItemType == "PackageReference" && i.Include == packageName); if (item == null) continue; diff --git a/src/Squirrel.Tool/Squirrel.Tool.csproj b/src/Squirrel.Tool/Squirrel.Tool.csproj index dfc335255..172d8aead 100644 --- a/src/Squirrel.Tool/Squirrel.Tool.csproj +++ b/src/Squirrel.Tool/Squirrel.Tool.csproj @@ -1,4 +1,4 @@ - + Exe @@ -23,6 +23,7 @@ + diff --git a/src/Squirrel/NuGet/NugetUtil.cs b/src/Squirrel/NuGet/NugetUtil.cs index 668ea4c76..f109ce7c4 100644 --- a/src/Squirrel/NuGet/NugetUtil.cs +++ b/src/Squirrel/NuGet/NugetUtil.cs @@ -23,10 +23,13 @@ internal static class NugetUtil public static void ThrowIfInvalidNugetId(string id) { - if (!System.Text.RegularExpressions.Regex.IsMatch(id, @"^[\w\.-]*$")) + if (!IsValidNuGetId(id)) throw new ArgumentException($"Invalid package Id '{id}', it must contain only alphanumeric characters, underscores, dashes, and dots."); } + public static bool IsValidNuGetId(string id) + => System.Text.RegularExpressions.Regex.IsMatch(id, @"^[\w\.-]*$"); + public static void ThrowIfVersionNotSemverCompliant(string version) { if (SemanticVersion.TryParse(version, out var parsed)) { @@ -38,10 +41,7 @@ public static void ThrowIfVersionNotSemverCompliant(string version) } } - public static string SafeTrim(this string value) - { - return value == null ? null : value.Trim(); - } + public static string SafeTrim(this string value) => value?.Trim(); public static string GetOptionalAttributeValue(this XElement element, string localName, string namespaceName = null) { diff --git a/src/Update.OSX/Program.cs b/src/Update.OSX/Program.cs index 602129d64..fd1670b7c 100644 --- a/src/Update.OSX/Program.cs +++ b/src/Update.OSX/Program.cs @@ -1,7 +1,10 @@ using System; -using System.Collections.Generic; -using System.Diagnostics; +using System.CommandLine; +using System.CommandLine.Invocation; +using System.CommandLine.IO; +using System.CommandLine.Parsing; using System.Runtime.Versioning; +using Squirrel.CommandLine; using Squirrel.SimpleSplat; namespace Squirrel.Update @@ -14,34 +17,53 @@ class Program : IEnableLogger static AppDescOsx _app; static ILogger _logger; + private static readonly Option ProcessStartOption = new("--processStart", "Start an executable in the current version of the app package"); + private static readonly Option ProcessStartAndWaitOption = new("--processStartAndWait", "Start an executable in the current version of the app package"); + private static readonly Option ForceLatestOption = new("--forceLatest", "Force updates the current version folder"); + private static readonly Option ProcessStartArgsOption = new(new[] { "-a", "--processStartArgs" }, "Arguments that will be used when starting executable"); + public static int Main(string[] args) { - try { - _app = new AppDescOsx(); - _logger = SetupLogLogger.RegisterLogger(_app.AppId); - - var opt = new StartupOption(args); + RootCommand rootCommand = new() { + ProcessStartOption, + ProcessStartAndWaitOption, + ForceLatestOption, + ProcessStartArgsOption + }; + rootCommand.SetHandler(Execute); + rootCommand.AtLeastOneRequired(ProcessStartOption, ProcessStartAndWaitOption); + return rootCommand.Invoke(args); + } - if (opt.updateAction == UpdateAction.Unset) { - opt.WriteOptionDescriptions(); - return -1; - } + private static void Execute(InvocationContext context) + { + _app = new AppDescOsx(); + _logger = SetupLogLogger.RegisterLogger(_app.AppId); - Log.Info("Starting Squirrel Updater (OSX): " + String.Join(" ", args)); - Log.Info("Updater location is: " + SquirrelRuntimeInfo.EntryExePath); + //Using the Diagram method here shows the structure of the CLI including any defaults + Log.Info("Starting Squirrel Updater (OSX): " + context.ParseResult.Diagram()); + Log.Info("Updater location is: " + SquirrelRuntimeInfo.EntryExePath); - switch (opt.updateAction) { - case UpdateAction.ProcessStart: - ProcessStart(opt.processStart, opt.processStartArgs, opt.shouldWait, opt.forceLatest); - break; - } + + string processStart = null; + bool shouldWait = false; + if (context.ParseResult.HasOption(ProcessStartArgsOption)) { + processStart = context.ParseResult.GetValueForOption(ProcessStartOption); + } else if (context.ParseResult.HasOption(ProcessStartAndWaitOption)) { + processStart = context.ParseResult.GetValueForOption(ProcessStartAndWaitOption); + shouldWait = true; + } + string processStartArgs = context.ParseResult.GetValueForOption(ProcessStartArgsOption); + bool forceLatest = context.ParseResult.GetValueForOption(ForceLatestOption); + try { + + ProcessStart(processStart, processStartArgs, shouldWait, forceLatest); Log.Info("Finished Squirrel Updater (OSX)"); - return 0; } catch (Exception ex) { - Console.Error.WriteLine(ex); + context.Console.Error.WriteLine(ex.ToString()); _logger?.Write(ex.ToString(), LogLevel.Fatal); - return -1; + context.ExitCode = -1; } } @@ -49,7 +71,7 @@ static void ProcessStart(string exeName, string arguments, bool shouldWait, bool { if (_app.CurrentlyInstalledVersion == null) throw new InvalidOperationException("ProcessStart is only valid in an installed application"); - + if (shouldWait) PlatformUtil.WaitForParentProcessToExit(); // todo https://stackoverflow.com/questions/51441576/how-to-run-app-as-sudo @@ -60,9 +82,9 @@ static void ProcessStart(string exeName, string arguments, bool shouldWait, bool var exe = "/usr/bin/open"; var args = $"-n \"{currentDir}\" --args {arguments}"; - + Log.Info($"Running: {exe} {args}"); - + PlatformUtil.StartProcessNonBlocking(exe, args, null); } } diff --git a/src/Update.OSX/StartupOption.cs b/src/Update.OSX/StartupOption.cs deleted file mode 100644 index 493ed5a2b..000000000 --- a/src/Update.OSX/StartupOption.cs +++ /dev/null @@ -1,54 +0,0 @@ -using Mono.Options; -using System; -using System.IO; - -namespace Squirrel.Update -{ - enum UpdateAction - { - Unset = 0, ProcessStart - } - - internal class StartupOption - { - private readonly OptionSet optionSet; - internal UpdateAction updateAction { get; private set; } = default(UpdateAction); - internal string processStart { get; private set; } = default(string); - internal string processStartArgs { get; private set; } = default(string); - internal bool shouldWait { get; private set; } = false; - internal bool forceLatest { get; private set; } = false; - - public StartupOption(string[] args) - { - optionSet = Parse(args); - } - - private OptionSet Parse(string[] args) - { - var exeName = Path.GetFileName(SquirrelRuntimeInfo.EntryExePath); - var opts = new OptionSet() { - "", - $"Squirrel Updater for OSX {SquirrelRuntimeInfo.SquirrelDisplayVersion}", - $"Usage: {exeName} command [OPTS]", - "", - "Commands:", - { "processStart=", "Start an executable in the current version of the app package", v => { updateAction = UpdateAction.ProcessStart; processStart = v; }, true}, - { "processStartAndWait=", "Start an executable in the current version of the app package", v => { updateAction = UpdateAction.ProcessStart; processStart = v; shouldWait = true; }, true}, - "", - "Options:", - { "h|?|help", "Display Help and exit", _ => { } }, - { "forceLatest", "Force updates the current version folder", v => forceLatest = true}, - { "a=|process-start-args=", "Arguments that will be used when starting executable", v => processStartArgs = v, true}, - }; - - opts.Parse(args); - - return opts; - } - - internal void WriteOptionDescriptions() - { - optionSet.WriteOptionDescriptions(Console.Out); - } - } -} \ No newline at end of file diff --git a/src/Update.OSX/Update.OSX.csproj b/src/Update.OSX/Update.OSX.csproj index 59fa00512..4231f643d 100644 --- a/src/Update.OSX/Update.OSX.csproj +++ b/src/Update.OSX/Update.OSX.csproj @@ -1,4 +1,4 @@ - + Exe @@ -13,12 +13,17 @@ $(NoWarn);CA2007 + + + + + diff --git a/src/Update.OSX/UpdateAction.cs b/src/Update.OSX/UpdateAction.cs new file mode 100644 index 000000000..f68d559d7 --- /dev/null +++ b/src/Update.OSX/UpdateAction.cs @@ -0,0 +1,8 @@ +namespace Squirrel.Update +{ + enum UpdateAction + { + Unset = 0, + ProcessStart + } +} \ No newline at end of file diff --git a/test/Squirrel.CommandLine.Tests/BaseCommandTests.cs b/test/Squirrel.CommandLine.Tests/BaseCommandTests.cs new file mode 100644 index 000000000..f6946166c --- /dev/null +++ b/test/Squirrel.CommandLine.Tests/BaseCommandTests.cs @@ -0,0 +1,24 @@ +using System.CommandLine; +using System.CommandLine.Parsing; +using Xunit; + +namespace Squirrel.CommandLine.Tests +{ + public abstract class BaseCommandTests : TempFileTestBase + where T : BaseCommand, new() + { + [Fact] + public void ReleaseDirectory_WithDirectory_ParsesValue() + { + var releaseDirectory = CreateTempDirectory().FullName; + BaseCommand command = new T(); + + var cli = GetRequiredDefaultOptions() + $"--releaseDir \"{releaseDirectory}\""; + var parseResult = command.Parse(cli); + + Assert.Equal(releaseDirectory, parseResult.GetValueForOption(command.ReleaseDirectory)?.FullName); + } + + protected virtual string GetRequiredDefaultOptions() => ""; + } +} diff --git a/test/Squirrel.CommandLine.Tests/Deployment/GitHubBaseCommandTests.cs b/test/Squirrel.CommandLine.Tests/Deployment/GitHubBaseCommandTests.cs new file mode 100644 index 000000000..3df50bec5 --- /dev/null +++ b/test/Squirrel.CommandLine.Tests/Deployment/GitHubBaseCommandTests.cs @@ -0,0 +1,62 @@ +using System.CommandLine; +using System.CommandLine.Parsing; +using Squirrel.CommandLine.Deployment; +using Xunit; + +namespace Squirrel.CommandLine.Tests.Deployment +{ + public abstract class GitHubBaseCommandTests : BaseCommandTests + where T : GitHubBaseCommand, new() + { + [Fact] + public void RepoUrl_WithUrl_ParsesValue() + { + GitHubBaseCommand command = new T(); + + ParseResult parseResult = command.Parse($"--repoUrl \"http://clowd.squirrel.com\""); + + Assert.Empty(parseResult.Errors); + Assert.Equal("http://clowd.squirrel.com/", parseResult.GetValueForOption(command.RepoUrl)?.AbsoluteUri); + } + + [Fact] + public void RepoUrl_WithNonHttpValue_ShowsError() + { + GitHubBaseCommand command = new T(); + + ParseResult parseResult = command.Parse($"--repoUrl \"file://clowd.squirrel.com\""); + + Assert.Equal(1, parseResult.Errors.Count); + Assert.Equal(command.RepoUrl, parseResult.Errors[0].SymbolResult?.Symbol); + Assert.StartsWith("--repoUrl must contain a Uri with one of the following schems: http, https.", parseResult.Errors[0].Message); + } + + [Fact] + public void RepoUrl_WithRelativeUrl_ShowsError() + { + GitHubBaseCommand command = new T(); + + ParseResult parseResult = command.Parse($"--repoUrl \"clowd.squirrel.com\""); + + Assert.Equal(1, parseResult.Errors.Count); + Assert.Equal(command.RepoUrl, parseResult.Errors[0].SymbolResult?.Symbol); + Assert.StartsWith("--repoUrl must contain an absolute Uri.", parseResult.Errors[0].Message); + } + + [Fact] + public void Token_WithValue_ParsesValue() + { + GitHubBaseCommand command = new T(); + + string cli = GetRequiredDefaultOptions() + $"--token \"abc\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal("abc", parseResult.GetValueForOption(command.Token)); + } + + protected override string GetRequiredDefaultOptions() + { + return $"--repoUrl \"https://clowd.squirrel.com\" "; + } + } +} diff --git a/test/Squirrel.CommandLine.Tests/Deployment/GitHubDownloadCommandTests.cs b/test/Squirrel.CommandLine.Tests/Deployment/GitHubDownloadCommandTests.cs new file mode 100644 index 000000000..44fa021a1 --- /dev/null +++ b/test/Squirrel.CommandLine.Tests/Deployment/GitHubDownloadCommandTests.cs @@ -0,0 +1,22 @@ +using System.CommandLine; +using System.CommandLine.Parsing; +using Squirrel.CommandLine.Deployment; +using Squirrel.CommandLine.Windows; +using Xunit; + +namespace Squirrel.CommandLine.Tests.Deployment +{ + public class GitHubDownloadCommandTests : GitHubBaseCommandTests + { + [Fact] + public void Pre_BareOption_SetsFlag() + { + var command = new GitHubDownloadCommand(); + + string cli = GetRequiredDefaultOptions() + "--pre"; + ParseResult parseResult = command.Parse(cli); + + Assert.True(parseResult.GetValueForOption(command.Pre)); + } + } +} diff --git a/test/Squirrel.CommandLine.Tests/Deployment/GitHubUploadCommandTests.cs b/test/Squirrel.CommandLine.Tests/Deployment/GitHubUploadCommandTests.cs new file mode 100644 index 000000000..1b90444d7 --- /dev/null +++ b/test/Squirrel.CommandLine.Tests/Deployment/GitHubUploadCommandTests.cs @@ -0,0 +1,32 @@ +using System.CommandLine; +using System.CommandLine.Parsing; +using Squirrel.CommandLine.Deployment; +using Xunit; + +namespace Squirrel.CommandLine.Tests.Deployment +{ + public class GitHubUploadCommandTests : GitHubBaseCommandTests + { + [Fact] + public void Publish_BareOption_SetsFlag() + { + var command = new GitHubUploadCommand(); + + string cli = GetRequiredDefaultOptions() + "--publish"; + ParseResult parseResult = command.Parse(cli); + + Assert.True(parseResult.GetValueForOption(command.Publish)); + } + + [Fact] + public void ReleaseName_WithName_ParsesValue() + { + var command = new GitHubUploadCommand(); + + string cli = GetRequiredDefaultOptions() + $"--releaseName \"my release\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal("my release", parseResult.GetValueForOption(command.ReleaseName)); + } + } +} diff --git a/test/Squirrel.CommandLine.Tests/Deployment/HttpDownloadCommandTests.cs b/test/Squirrel.CommandLine.Tests/Deployment/HttpDownloadCommandTests.cs new file mode 100644 index 000000000..b8095abda --- /dev/null +++ b/test/Squirrel.CommandLine.Tests/Deployment/HttpDownloadCommandTests.cs @@ -0,0 +1,50 @@ +using System.CommandLine; +using System.CommandLine.Parsing; +using Squirrel.CommandLine.Deployment; +using Xunit; + +namespace Squirrel.CommandLine.Tests.Deployment +{ + public class HttpDownloadCommandTests : BaseCommandTests + { + [Fact] + public void Url_WithUrl_ParsesValue() + { + var command = new HttpDownloadCommand(); + + ParseResult parseResult = command.Parse($"--url \"http://clowd.squirrel.com\""); + + Assert.Empty(parseResult.Errors); + Assert.Equal("http://clowd.squirrel.com/", parseResult.GetValueForOption(command.Url)?.AbsoluteUri); + } + + [Fact] + public void Url_WithNonHttpValue_ShowsError() + { + var command = new HttpDownloadCommand(); + + ParseResult parseResult = command.Parse($"--url \"file://clowd.squirrel.com\""); + + Assert.Equal(1, parseResult.Errors.Count); + Assert.Equal(command.Url, parseResult.Errors[0].SymbolResult?.Symbol); + Assert.StartsWith("--url must contain a Uri with one of the following schems: http, https.", parseResult.Errors[0].Message); + } + + [Fact] + public void Url_WithRelativeUrl_ShowsError() + { + var command = new HttpDownloadCommand(); + + ParseResult parseResult = command.Parse($"--url \"clowd.squirrel.com\""); + + Assert.Equal(1, parseResult.Errors.Count); + Assert.Equal(command.Url, parseResult.Errors[0].SymbolResult?.Symbol); + Assert.StartsWith("--url must contain an absolute Uri.", parseResult.Errors[0].Message); + } + + protected override string GetRequiredDefaultOptions() + { + return $"--url \"https://clowd.squirrel.com\" "; + } + } +} diff --git a/test/Squirrel.CommandLine.Tests/Deployment/S3BaseCommandTests.cs b/test/Squirrel.CommandLine.Tests/Deployment/S3BaseCommandTests.cs new file mode 100644 index 000000000..189f8dbc1 --- /dev/null +++ b/test/Squirrel.CommandLine.Tests/Deployment/S3BaseCommandTests.cs @@ -0,0 +1,96 @@ +using System.CommandLine; +using System.CommandLine.Parsing; +using Squirrel.CommandLine.Deployment; +using Xunit; + +namespace Squirrel.CommandLine.Tests.Deployment +{ + public abstract class S3BaseCommandTests : BaseCommandTests + where T : S3BaseCommand, new() + { + [Fact] + public void Command_WithRequiredEndpointOptions_ParsesValue() + { + S3BaseCommand command = new T(); + + string cli = $"--keyId \"some key\" --secret \"shhhh\" --endpoint \"http://endpoint\" --bucket \"a-bucket\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Empty(parseResult.Errors); + Assert.Equal("some key", parseResult.GetValueForOption(command.KeyId)); + Assert.Equal("shhhh", parseResult.GetValueForOption(command.Secret)); + Assert.Equal("http://endpoint", parseResult.GetValueForOption(command.Endpoint)); + Assert.Equal("a-bucket", parseResult.GetValueForOption(command.Bucket)); + } + + [Fact] + public void Command_WithRequiredRegionOptions_ParsesValue() + { + S3BaseCommand command = new T(); + + string cli = $"--keyId \"some key\" --secret \"shhhh\" --region \"us-west-1\" --bucket \"a-bucket\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Empty(parseResult.Errors); + Assert.Equal("some key", parseResult.GetValueForOption(command.KeyId)); + Assert.Equal("shhhh", parseResult.GetValueForOption(command.Secret)); + Assert.Equal("us-west-1", parseResult.GetValueForOption(command.Region)); + Assert.Equal("a-bucket", parseResult.GetValueForOption(command.Bucket)); + } + + [Fact] + public void Command_WithoutRegionArgumentValue_ShowsError() + { + S3BaseCommand command = new T(); + + string cli = $"--keyId \"some key\" --secret \"shhhh\" --bucket \"a-bucket\" --region \"\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal(1, parseResult.Errors.Count); + Assert.Equal(command.Region, parseResult.Errors[0].SymbolResult?.Symbol); + Assert.StartsWith("A region value is required", parseResult.Errors[0].Message); + } + + [Fact] + public void Command_WithoutRegionAndEndpoint_ShowsError() + { + S3BaseCommand command = new T(); + + string cli = $"--keyId \"some key\" --secret \"shhhh\" --bucket \"a-bucket\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal(1, parseResult.Errors.Count); + Assert.Equal(command, parseResult.Errors[0].SymbolResult?.Symbol); + Assert.StartsWith("At least one of the following options are required '--region' and '--endpoint'", parseResult.Errors[0].Message); + } + + [Fact] + public void Command_WithBothRegionAndEndpoint_ShowsError() + { + S3BaseCommand command = new T(); + + string cli = $"--keyId \"some key\" --secret \"shhhh\" --region \"us-west-1\" --endpoint \"http://endpoint\" --bucket \"a-bucket\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal(1, parseResult.Errors.Count); + Assert.Equal(command, parseResult.Errors[0].SymbolResult?.Symbol); + Assert.StartsWith("Cannot use '--region' and '--endpoint' options together", parseResult.Errors[0].Message); + } + + [Fact] + public void PathPrefix_WithPath_ParsesValue() + { + S3BaseCommand command = new T(); + + string cli = GetRequiredDefaultOptions() + $"--pathPrefix \"sub-folder\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal("sub-folder", parseResult.GetValueForOption(command.PathPrefix)); + } + + protected override string GetRequiredDefaultOptions() + { + return $"--keyId \"some key\" --secret \"shhhh\" --endpoint \"http://endpoint\" --bucket \"a-bucket\" "; + } + } +} diff --git a/test/Squirrel.CommandLine.Tests/Deployment/S3DownloadCommandTests.cs b/test/Squirrel.CommandLine.Tests/Deployment/S3DownloadCommandTests.cs new file mode 100644 index 000000000..589342d7d --- /dev/null +++ b/test/Squirrel.CommandLine.Tests/Deployment/S3DownloadCommandTests.cs @@ -0,0 +1,7 @@ +using Squirrel.CommandLine.Deployment; + +namespace Squirrel.CommandLine.Tests.Deployment +{ + public class S3DownloadCommandTests : S3BaseCommandTests + { } +} diff --git a/test/Squirrel.CommandLine.Tests/Deployment/S3UploadCommandTests.cs b/test/Squirrel.CommandLine.Tests/Deployment/S3UploadCommandTests.cs new file mode 100644 index 000000000..9b787454d --- /dev/null +++ b/test/Squirrel.CommandLine.Tests/Deployment/S3UploadCommandTests.cs @@ -0,0 +1,32 @@ +using System.CommandLine.Parsing; +using System.CommandLine; +using Squirrel.CommandLine.Deployment; +using Xunit; + +namespace Squirrel.CommandLine.Tests.Deployment +{ + public class S3UploadCommandTests : S3BaseCommandTests + { + [Fact] + public void Overwrite_BareOption_SetsFlag() + { + var command = new S3UploadCommand(); + + string cli = GetRequiredDefaultOptions() + "--overwrite"; + ParseResult parseResult = command.Parse(cli); + + Assert.True(parseResult.GetValueForOption(command.Overwrite)); + } + + [Fact] + public void KeepMaxReleases_WithNumber_ParsesValue() + { + var command = new S3UploadCommand(); + + string cli = GetRequiredDefaultOptions() + "--keepMaxReleases 42"; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal(42, parseResult.GetValueForOption(command.KeepMaxReleases)); + } + } +} diff --git a/test/Squirrel.CommandLine.Tests/OSX/PackCommandTests.cs b/test/Squirrel.CommandLine.Tests/OSX/PackCommandTests.cs new file mode 100644 index 000000000..db3f45b40 --- /dev/null +++ b/test/Squirrel.CommandLine.Tests/OSX/PackCommandTests.cs @@ -0,0 +1,325 @@ +using Xunit; +using Squirrel.CommandLine.OSX; +using System.CommandLine; +using System.CommandLine.Parsing; + +namespace Squirrel.CommandLine.Tests.OSX +{ + public class PackCommandTests : BaseCommandTests + { + [Fact] + public void Command_WithValidRequiredArguments_Parses() + { + DirectoryInfo packDir = CreateTempDirectory(); + CreateTempFile(packDir); + var command = new PackCommand(); + + ParseResult parseResult = command.Parse($"--packId Clowd.Squirrel -v 1.2.3 -p \"{packDir.FullName}\""); + + Assert.Empty(parseResult.Errors); + Assert.Equal("Clowd.Squirrel", parseResult.GetValueForOption(command.PackId)); + Assert.Equal("1.2.3", parseResult.GetValueForOption(command.PackVersion)); + Assert.Equal(packDir.FullName, parseResult.GetValueForOption(command.PackDirectory)?.FullName); + } + + [Fact] + public void PackId_WithInvalidNuGetId_ShowsError() + { + DirectoryInfo packDir = CreateTempDirectory(); + CreateTempFile(packDir); + var command = new PackCommand(); + + ParseResult parseResult = command.Parse($"--packId $42@ -v 1.0.0 -p \"{packDir.FullName}\""); + + Assert.Equal(1, parseResult.Errors.Count); + Assert.StartsWith("--packId is an invalid NuGet package id.", parseResult.Errors[0].Message); + Assert.Contains("$42@", parseResult.Errors[0].Message); + } + + [Fact] + public void PackVersion_WithInvalidVersion_ShowsError() + { + DirectoryInfo packDir = CreateTempDirectory(); + CreateTempFile(packDir); + var command = new PackCommand(); + + ParseResult parseResult = command.Parse($"-u Clowd.Squirrel --packVersion 1.a.c -p \"{packDir.FullName}\""); + + Assert.Equal(1, parseResult.Errors.Count); + Assert.StartsWith("--packVersion contains an invalid package version", parseResult.Errors[0].Message); + Assert.Contains("1.a.c", parseResult.Errors[0].Message); + } + + [Fact] + public void PackDirectory_WithEmptyFolder_ShowsError() + { + DirectoryInfo packDir = CreateTempDirectory(); + var command = new PackCommand(); + + ParseResult parseResult = command.Parse($"-u Clowd.Squirrel -v 1.0.0 --packDir \"{packDir.FullName}\""); + + Assert.Equal(1, parseResult.Errors.Count); + Assert.StartsWith("--packDir must a non-empty directory", parseResult.Errors[0].Message); + Assert.Contains(packDir.FullName, parseResult.Errors[0].Message); + } + + [Fact] + public void PackAuthors_WithMultipleAuthors_ParsesValue() + { + var command = new PackCommand(); + + string cli = GetRequiredDefaultOptions() + "--packAuthors Me,mysel,I"; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal("Me,mysel,I", parseResult.GetValueForOption(command.PackAuthors)); + } + + [Fact] + public void PackTitle_WithTitle_ParsesValue() + { + var command = new PackCommand(); + + string cli = GetRequiredDefaultOptions() + "--packTitle \"My Awesome Title\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal("My Awesome Title", parseResult.GetValueForOption(command.PackTitle)); + } + + [Fact] + public void IncludePdb_BareOption_SetsFlag() + { + var command = new PackCommand(); + + string cli = GetRequiredDefaultOptions() + "--includePdb"; + ParseResult parseResult = command.Parse(cli); + + Assert.True(parseResult.GetValueForOption(command.IncludePdb)); + } + + [Fact] + public void ReleaseNotes_WithExistingFile_ParsesValue() + { + FileInfo releaseNotes = CreateTempFile(); + var command = new PackCommand(); + + string cli = GetRequiredDefaultOptions() + $"--releaseNotes \"{releaseNotes.FullName}\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal(releaseNotes.FullName, parseResult.GetValueForOption(command.ReleaseNotes)?.FullName); + } + + [Fact] + public void ReleaseNotes_WithoutFile_ShowsError() + { + string releaseNotes = Path.GetFullPath(Path.GetRandomFileName()); + var command = new PackCommand(); + + string cli = GetRequiredDefaultOptions() + $"--releaseNotes \"{releaseNotes}\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal(1, parseResult.Errors.Count); + Assert.Equal(command.ReleaseNotes, parseResult.Errors[0].SymbolResult?.Symbol.Parents.Single()); + Assert.Contains(releaseNotes, parseResult.Errors[0].Message); + } + + [Fact] + public void SquirrelAwareExecutable_WithFileName_ParsesValue() + { + var command = new PackCommand(); + + string cli = GetRequiredDefaultOptions() + $"--mainExe \"MyApp.exe\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal("MyApp.exe", parseResult.GetValueForOption(command.SquirrelAwareExecutable)); + } + + [Fact] + public void Icon_WithValidFile_ParsesValue() + { + FileInfo fileInfo = CreateTempFile(name: Path.ChangeExtension(Path.GetRandomFileName(), ".ico")); + var command = new PackCommand(); + + string cli = GetRequiredDefaultOptions() + $"--icon \"{fileInfo.FullName}\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal(fileInfo.FullName, parseResult.GetValueForOption(command.Icon)?.FullName); + } + + [Fact] + public void Icon_WithBadFileExtension_ShowsError() + { + FileInfo fileInfo = CreateTempFile(name: Path.ChangeExtension(Path.GetRandomFileName(), ".wrong")); + var command = new PackCommand(); + + string cli = GetRequiredDefaultOptions() + $"--icon \"{fileInfo.FullName}\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal(1, parseResult.Errors.Count); + Assert.Equal($"--icon does not have an .ico extension", parseResult.Errors[0].Message); + } + + [Fact] + public void Icon_WithoutFile_ShowsError() + { + string file = Path.GetFullPath(Path.ChangeExtension(Path.GetRandomFileName(), ".ico")); + var command = new PackCommand(); + + string cli = GetRequiredDefaultOptions() + $"--icon \"{file}\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal(1, parseResult.Errors.Count); + Assert.Equal(command.Icon, parseResult.Errors[0].SymbolResult?.Symbol.Parents.Single()); + Assert.Contains(file, parseResult.Errors[0].Message); + } + + [Fact] + public void BundleId_WithValue_ParsesValue() + { + var command = new PackCommand(); + + string cli = GetRequiredDefaultOptions() + $"--bundleId \"some id\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal("some id", parseResult.GetValueForOption(command.BundleId)); + } + + [Fact] + public void NoDelta_BareOption_SetsFlag() + { + var command = new PackCommand(); + + string cli = GetRequiredDefaultOptions() + "--noDelta"; + ParseResult parseResult = command.Parse(cli); + + Assert.True(parseResult.GetValueForOption(command.NoDelta)); + } + + [Fact] + public void NoPackage_BareOption_SetsFlag() + { + var command = new PackCommand(); + + string cli = GetRequiredDefaultOptions() + "--noPkg"; + ParseResult parseResult = command.Parse(cli); + + Assert.True(parseResult.GetValueForOption(command.NoPackage)); + } + + [Fact] + public void PackageContent_CanSpecifyMultipleValues() + { + DirectoryInfo packDir = CreateTempDirectory(); + FileInfo testFile1 = CreateTempFile(packDir); + FileInfo testFile2 = CreateTempFile(packDir); + PackCommand command = new PackCommand(); + string cli = $"-u clowd.squirrel -v 1.0.0 -p \"{packDir.FullName}\""; + cli += $" --pkgContent welcome={testFile1.FullName}"; + cli += $" --pkgContent license={testFile2.FullName}"; + ParseResult parseResult = command.Parse(cli); + + Assert.Empty(parseResult.Errors); + var packageContent = parseResult.GetValueForOption(command.PackageContent); + Assert.Equal(2, packageContent?.Length); + + Assert.Equal("welcome", packageContent![0].Key); + Assert.Equal(testFile1.FullName, packageContent![0].Value.FullName); + + Assert.Equal("license", packageContent![1].Key); + Assert.Equal(testFile2.FullName, packageContent![1].Value.FullName); + } + + [Fact] + public void PackageContent_WihtInvalidKey_DisplaysError() + { + DirectoryInfo packDir = CreateTempDirectory(); + FileInfo testFile1 = CreateTempFile(packDir); + PackCommand command = new PackCommand(); + string cli = $"-u clowd.squirrel -v 1.0.0 -p \"{packDir.FullName}\""; + cli += $" --pkgContent unknown={testFile1.FullName}"; + ParseResult parseResult = command.Parse(cli); + + ParseError error = parseResult.Errors.Single(); + Assert.Equal("Invalid pkgContent key: unknown. Must be one of: welcome, readme, license, conclusion", error.Message); + } + + [Fact] + public void SigningAppIdentity_WithSubject_ParsesValue() + { + var command = new PackCommand(); + + string cli = GetRequiredDefaultOptions() + $"--signAppIdentity \"Mac Developer\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal("Mac Developer", parseResult.GetValueForOption(command.SigningAppIdentity)); + } + + [Fact] + public void SigningInstallIdentity_WithSubject_ParsesValue() + { + var command = new PackCommand(); + + string cli = GetRequiredDefaultOptions() + $"--signInstallIdentity \"Mac Developer\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal("Mac Developer", parseResult.GetValueForOption(command.SigningInstallIdentity)); + } + + [Fact] + public void SigningEntitlements_WithValidFile_ParsesValue() + { + FileInfo fileInfo = CreateTempFile(name: Path.ChangeExtension(Path.GetRandomFileName(), ".entitlements")); + var command = new PackCommand(); + + string cli = GetRequiredDefaultOptions() + $"--signEntitlements \"{fileInfo.FullName}\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal(fileInfo.FullName, parseResult.GetValueForOption(command.SigningEntitlements)?.FullName); + } + + [Fact] + public void SigningEntitlements_WithBadFileExtension_ShowsError() + { + FileInfo fileInfo = CreateTempFile(name: Path.ChangeExtension(Path.GetRandomFileName(), ".wrong")); + var command = new PackCommand(); + + string cli = GetRequiredDefaultOptions() + $"--signEntitlements \"{fileInfo.FullName}\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal(1, parseResult.Errors.Count); + Assert.Equal($"--signEntitlements does not have an .entitlements extension", parseResult.Errors[0].Message); + } + + [Fact] + public void SigningEntitlements_WithoutFile_ShowsError() + { + string file = Path.GetFullPath(Path.ChangeExtension(Path.GetRandomFileName(), ".entitlements")); + var command = new PackCommand(); + + string cli = GetRequiredDefaultOptions() + $"--signEntitlements \"{file}\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal(1, parseResult.Errors.Count); + Assert.Equal(command.SigningEntitlements, parseResult.Errors[0].SymbolResult?.Symbol.Parents.Single()); + Assert.Contains(file, parseResult.Errors[0].Message); + } + + [Fact] + public void NotaryProfile_WithName_ParsesValue() + { + var command = new PackCommand(); + + string cli = GetRequiredDefaultOptions() + $"--notaryProfile \"profile name\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal("profile name", parseResult.GetValueForOption(command.NotaryProfile)); + } + + protected override string GetRequiredDefaultOptions() + { + DirectoryInfo packDir = CreateTempDirectory(); + CreateTempFile(packDir); + + return $"-u Clowd.Squirrel -v 1.0.0 -p \"{packDir.FullName}\" "; + } + } +} diff --git a/test/Squirrel.CommandLine.Tests/Squirrel.CommandLine.Tests.csproj b/test/Squirrel.CommandLine.Tests/Squirrel.CommandLine.Tests.csproj new file mode 100644 index 000000000..ffcc89df9 --- /dev/null +++ b/test/Squirrel.CommandLine.Tests/Squirrel.CommandLine.Tests.csproj @@ -0,0 +1,28 @@ + + + + net6.0 + enable + enable + + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/test/Squirrel.CommandLine.Tests/TempFileTestBase.cs b/test/Squirrel.CommandLine.Tests/TempFileTestBase.cs new file mode 100644 index 000000000..bbb84f17c --- /dev/null +++ b/test/Squirrel.CommandLine.Tests/TempFileTestBase.cs @@ -0,0 +1,100 @@ +using Xunit.Sdk; + +namespace Squirrel.CommandLine.Tests +{ + + public abstract class TempFileTestBase : IDisposable + { + private readonly Lazy _WorkingDirectory = new(() => { + DirectoryInfo working = new( + Path.Combine(Path.GetTempPath(), + typeof(TempFileTestBase).Assembly.GetName().Name!, + Path.GetRandomFileName())); + + if (working.Exists) { + working.Delete(recursive: true); + } + + working.Create(); + return working; + }, LazyThreadSafetyMode.ExecutionAndPublication); + + private readonly List _TempFiles = new(); + private readonly List _TempDirectories = new(); + protected DirectoryInfo TempDirectory => _WorkingDirectory.Value; + private bool _Disposed; + + public FileInfo CreateTempFile(DirectoryInfo? directory = null, string? name = null) + { + var tempFile = new FileInfo(GetPath(directory, name)); + tempFile.Create().Close(); + _TempFiles.Add(tempFile); + return tempFile; + } + + public DirectoryInfo CreateTempDirectory(DirectoryInfo? parent = null, string? name = null) + { + var tempDir = new DirectoryInfo(GetPath(parent, name)); + tempDir.Create(); + _TempDirectories.Add(tempDir); + return tempDir; + } + + private string GetPath(DirectoryInfo? parentDirectory, string? name) + { + var directory = parentDirectory ?? _WorkingDirectory.Value; + var fileName = name ?? Path.GetRandomFileName(); + return Path.Combine(directory.FullName, fileName); + } + + protected virtual void Dispose(bool disposing) + { + if (_Disposed || !disposing) { + return; + } + + _Disposed = true; + + ExceptionAggregator aggregator = new(); + + var items = _TempFiles + .Cast() + .Concat(_TempDirectories) + .Concat(_WorkingDirectory.IsValueCreated ? new[] { _WorkingDirectory.Value } : Enumerable.Empty()); + + foreach (var fsi in items) { + fsi.Refresh(); + if (!fsi.Exists) return; + + Action? action = fsi switch { + FileInfo file => () => file.Delete(), + DirectoryInfo dir => () => dir.Delete(recursive: true), + _ => null, + }; + + if (action is null) return; + + aggregator.Run(() => { + for (int i = 0; i < 100; i++) { + try { + action(); + break; + } catch { + Thread.Sleep(TimeSpan.FromMilliseconds(10)); + } + } + }); + } + + if (aggregator.HasExceptions) { + throw aggregator.ToException(); + } + } + + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + } +} \ No newline at end of file diff --git a/test/Squirrel.CommandLine.Tests/Windows/PackCommandTests.cs b/test/Squirrel.CommandLine.Tests/Windows/PackCommandTests.cs new file mode 100644 index 000000000..7e5ceaae8 --- /dev/null +++ b/test/Squirrel.CommandLine.Tests/Windows/PackCommandTests.cs @@ -0,0 +1,270 @@ +using Xunit; +using Squirrel.CommandLine.Windows; +using System.CommandLine; +using System.CommandLine.Parsing; + +namespace Squirrel.CommandLine.Tests.Windows +{ + public class PackCommandTests : ReleaseCommandTests + { + [Fact] + public void Command_WithValidRequiredArguments_Parses() + { + DirectoryInfo packDir = CreateTempDirectory(); + CreateTempFile(packDir); + var command = new PackCommand(); + + ParseResult parseResult = command.Parse($"-u Clowd.Squirrel -v 1.2.3 -p \"{packDir.FullName}\""); + + Assert.Empty(parseResult.Errors); + Assert.Equal("Clowd.Squirrel", parseResult.GetValueForOption(command.PackId)); + Assert.Equal("1.2.3", parseResult.GetValueForOption(command.PackVersion)); + Assert.Equal(packDir.FullName, parseResult.GetValueForOption(command.PackDirectory)?.FullName); + } + + [Fact] + public void PackId_WithInvalidNuGetId_ShowsError() + { + DirectoryInfo packDir = CreateTempDirectory(); + CreateTempFile(packDir); + var command = new PackCommand(); + + ParseResult parseResult = command.Parse($"--packId $42@ -v 1.0.0 -p \"{packDir.FullName}\""); + + Assert.Equal(1, parseResult.Errors.Count); + Assert.StartsWith("--packId is an invalid NuGet package id.", parseResult.Errors[0].Message); + Assert.Contains("$42@", parseResult.Errors[0].Message); + } + + [Fact] + public void PackName_WithValue_ParsesValue() + { + DirectoryInfo packDir = CreateTempDirectory(); + CreateTempFile(packDir); + var command = new PackCommand(); + + ParseResult parseResult = command.Parse($"--packName Clowd.Squirrel -v 1.0.0 -p \"{packDir.FullName}\""); + + Assert.Equal("Clowd.Squirrel", parseResult.GetValueForOption(command.PackName)); + } + + [Fact] + public void ObsoletePackDirectory_WithNonEmptyFolder_ParsesValue() + { + DirectoryInfo packDir = CreateTempDirectory(); + CreateTempFile(packDir); + var command = new PackCommand(); + + ParseResult parseResult = command.Parse($"-u Clowd.Squirrel -v 1.0.0 --packDirectory \"{packDir.FullName}\""); + + Assert.Equal(packDir.FullName, parseResult.GetValueForOption(command.PackDirectoryObsolete)?.FullName); + } + + [Fact] + public void ObsoletePackDirectory_WithEmptyFolder_ShowsError() + { + DirectoryInfo packDir = CreateTempDirectory(); + var command = new PackCommand(); + + ParseResult parseResult = command.Parse($"-u Clowd.Squirrel -v 1.0.0 --packDirectory \"{packDir.FullName}\""); + + Assert.Equal(1, parseResult.Errors.Count); + Assert.StartsWith("--packDirectory must a non-empty directory", parseResult.Errors[0].Message); + Assert.Contains(packDir.FullName, parseResult.Errors[0].Message); + } + + [Fact] + public void PackDirectory_WithEmptyFolder_ShowsError() + { + DirectoryInfo packDir = CreateTempDirectory(); + var command = new PackCommand(); + + ParseResult parseResult = command.Parse($"-u Clowd.Squirrel -v 1.0.0 --packDir \"{packDir.FullName}\""); + + Assert.Equal(1, parseResult.Errors.Count); + Assert.StartsWith("--packDir must a non-empty directory", parseResult.Errors[0].Message); + Assert.Contains(packDir.FullName, parseResult.Errors[0].Message); + } + + [Fact] + public void PackVersion_WithInvalidVersion_ShowsError() + { + DirectoryInfo packDir = CreateTempDirectory(); + CreateTempFile(packDir); + var command = new PackCommand(); + + ParseResult parseResult = command.Parse($"-u Clowd.Squirrel --packVersion 1.a.c -p \"{packDir.FullName}\""); + + Assert.Equal(1, parseResult.Errors.Count); + Assert.StartsWith("--packVersion contains an invalid package version", parseResult.Errors[0].Message); + Assert.Contains("1.a.c", parseResult.Errors[0].Message); + } + + [Fact] + public void PackTitle_WithTitle_ParsesValue() + { + var command = new PackCommand(); + + string cli = GetRequiredDefaultOptions() + "--packTitle \"My Awesome Title\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal("My Awesome Title", parseResult.GetValueForOption(command.PackTitle)); + } + + [Fact] + public void PackAuthors_WithMultipleAuthors_ParsesValue() + { + var command = new PackCommand(); + + string cli = GetRequiredDefaultOptions() + "--packAuthors Me,mysel,I"; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal("Me,mysel,I", parseResult.GetValueForOption(command.PackAuthors)); + } + + [Fact] + public void IncludePdb_BareOption_SetsFlag() + { + var command = new PackCommand(); + + string cli = GetRequiredDefaultOptions() + "--includePdb"; + ParseResult parseResult = command.Parse(cli); + + Assert.True(parseResult.GetValueForOption(command.IncludePdb)); + } + + [Fact] + public void ReleaseNotes_WithExistingFile_ParsesValue() + { + FileInfo releaseNotes = CreateTempFile(); + var command = new PackCommand(); + + string cli = GetRequiredDefaultOptions() + $"--releaseNotes \"{releaseNotes.FullName}\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal(releaseNotes.FullName, parseResult.GetValueForOption(command.ReleaseNotes)?.FullName); + } + + [Fact] + public void ReleaseNotes_WithoutFile_ShowsError() + { + string releaseNotes = Path.GetFullPath(Path.GetRandomFileName()); + var command = new PackCommand(); + + string cli = GetRequiredDefaultOptions() + $"--releaseNotes \"{releaseNotes}\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal(1, parseResult.Errors.Count); + Assert.Equal(command.ReleaseNotes, parseResult.Errors[0].SymbolResult?.Symbol.Parents.Single()); + Assert.Contains(releaseNotes, parseResult.Errors[0].Message); + } + + + [Fact] + public void SignTemplate_WithTemplate_ParsesValue() + { + var command = new PackCommand(); + + string cli = GetRequiredDefaultOptions() + "--signTemplate \"signtool {{file}}\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal("signtool {{file}}", parseResult.GetValueForOption(command.SignTemplate)); + } + + [Fact] + public void SignTemplate_WithoutFileParameter_ShowsError() + { + var command = new PackCommand(); + + string cli = GetRequiredDefaultOptions() + "--signTemplate \"signtool file\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal(1, parseResult.Errors.Count); + Assert.Equal(command.SignTemplate, parseResult.Errors[0].SymbolResult?.Symbol); + Assert.StartsWith("--signTemplate must contain '{{file}}'", parseResult.Errors[0].Message); + } + + [WindowsOnlyFact] + public void SignParameters_WithParameters_ParsesValue() + { + var command = new PackCommand(); + + string cli = GetRequiredDefaultOptions() + "--signParams \"param1 param2\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal("param1 param2", parseResult.GetValueForOption(command.SignParameters)); + } + + [WindowsOnlyFact] + public void SignParameters_WithSignTemplate_ShowsError() + { + var command = new PackCommand(); + + string cli = GetRequiredDefaultOptions() + "--signTemplate \"signtool {{file}}\" --signParams \"param1 param2\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal(1, parseResult.Errors.Count); + Assert.Equal(command, parseResult.Errors[0].SymbolResult?.Symbol); + Assert.StartsWith("Cannot use '--signParams' and '--signTemplate' options together", parseResult.Errors[0].Message); + } + + [WindowsOnlyFact] + public void SignSkipDll_BareOption_SetsFlag() + { + var command = new PackCommand(); + + string cli = GetRequiredDefaultOptions() + "--signSkipDll"; + ParseResult parseResult = command.Parse(cli); + + Assert.True(parseResult.GetValueForOption(command.SignSkipDll)); + } + + [WindowsOnlyFact] + public void SignParallel_WithValue_SetsFlag() + { + var command = new PackCommand(); + + string cli = GetRequiredDefaultOptions() + "--signParallel 42"; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal(42, parseResult.GetValueForOption(command.SignParallel)); + } + + [WindowsOnlyTheory] + [InlineData(-1)] + [InlineData(0)] + [InlineData(1001)] + public void SignParallel_WithBadNumericValue_ShowsError(int value) + { + var command = new PackCommand(); + + string cli = GetRequiredDefaultOptions() + $"--signParallel {value}"; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal(1, parseResult.Errors.Count); + Assert.Equal(command.SignParallel, parseResult.Errors[0].SymbolResult?.Symbol); + Assert.Equal($"The value for --signParallel must be greater than 1 and less than 1000", parseResult.Errors[0].Message); + } + + [WindowsOnlyFact] + public void SignParallel_WithNonNumericValue_ShowsError() + { + var command = new PackCommand(); + + string cli = GetRequiredDefaultOptions() + $"--signParallel abc"; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal(1, parseResult.Errors.Count); + Assert.Equal(command.SignParallel, parseResult.Errors[0].SymbolResult?.Symbol); + Assert.Equal($"abc is not a valid integer for --signParallel", parseResult.Errors[0].Message); + } + + protected override string GetRequiredDefaultOptions() + { + DirectoryInfo packDir = CreateTempDirectory(); + CreateTempFile(packDir); + + return $"-u Clowd.Squirrel -v 1.0.0 -p \"{packDir.FullName}\" "; + } + } +} diff --git a/test/Squirrel.CommandLine.Tests/Windows/ReleaseCommandTests.cs b/test/Squirrel.CommandLine.Tests/Windows/ReleaseCommandTests.cs new file mode 100644 index 000000000..bddf5e60d --- /dev/null +++ b/test/Squirrel.CommandLine.Tests/Windows/ReleaseCommandTests.cs @@ -0,0 +1,226 @@ +using System.CommandLine; +using System.CommandLine.Parsing; +using Squirrel.CommandLine.Windows; +using Xunit; + +namespace Squirrel.CommandLine.Tests.Windows +{ + public abstract class ReleaseCommandTests : BaseCommandTests + where T : ReleaseCommand, new() + { + [Fact] + public void BaseUrl_WithUrl_ParsesValue() + { + var command = new T(); + + string cli = GetRequiredDefaultOptions() + $"--baseUrl \"https://clowd.squirell.com\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal("https://clowd.squirell.com/", parseResult.GetValueForOption(command.BaseUrl)?.AbsoluteUri); + } + + [Fact] + public void BaseUrl_WithNonHttpValue_ShowsError() + { + var command = new T(); + + string cli = GetRequiredDefaultOptions() + $"--baseUrl \"file://clowd.squirrel.com\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal(1, parseResult.Errors.Count); + Assert.Equal(command.BaseUrl, parseResult.Errors[0].SymbolResult?.Symbol); + Assert.StartsWith("--baseUrl must contain a Uri with one of the following schems: http, https.", parseResult.Errors[0].Message); + } + + [Fact] + public void BaseUrl_WithRelativeUrl_ShowsError() + { + var command = new T(); + string cli = GetRequiredDefaultOptions() + $"--baseUrl \"clowd.squirrel.com\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal(1, parseResult.Errors.Count); + Assert.Equal(command.BaseUrl, parseResult.Errors[0].SymbolResult?.Symbol); + Assert.StartsWith("--baseUrl must contain an absolute Uri.", parseResult.Errors[0].Message); + } + + [Fact] + public void AddSearchPath_WithMultipleValues_ParsesValue() + { + string searchPath1 = CreateTempDirectory().FullName; + string searchPath2 = CreateTempDirectory().FullName; + var command = new T(); + string cli = GetRequiredDefaultOptions() + $"--addSearchPath \"{searchPath1}\" --addSearchPath \"{searchPath2}\""; + ParseResult parseResult = command.Parse(cli); + + string[]? searchPaths = parseResult.GetValueForOption(command.AddSearchPath); + Assert.Equal(2, searchPaths?.Length); + Assert.Contains(searchPath1, searchPaths); + Assert.Contains(searchPath2, searchPaths); + } + + [Fact] + public void DebugSetupExe_WithFilePath_ParsesValue() + { + string debugExe = CreateTempFile().FullName; + var command = new T(); + string cli = GetRequiredDefaultOptions() + $"--debugSetupExe \"{debugExe}\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal(debugExe, parseResult.GetValueForOption(command.DebugSetupExe)?.FullName); + } + + + [Fact] + public void NoDelta_BareOption_SetsFlag() + { + var command = new T(); + + string cli = GetRequiredDefaultOptions() + "--noDelta"; + ParseResult parseResult = command.Parse(cli); + + Assert.True(parseResult.GetValueForOption(command.NoDelta)); + } + + [Fact] + public void Runtime_WithValue_ParsesValue() + { + var command = new T(); + string cli = GetRequiredDefaultOptions() + $"--framework \"net6,vcredist143\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal("net6,vcredist143", parseResult.GetValueForOption(command.Runtimes)); + } + + [Fact] + public void SplashImage_WithValidFile_ParsesValue() + { + FileInfo fileInfo = CreateTempFile(name: Path.ChangeExtension(Path.GetRandomFileName(), ".ico")); + var command = new T(); + + string cli = GetRequiredDefaultOptions() + $"--splashImage \"{fileInfo.FullName}\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal(fileInfo.FullName, parseResult.GetValueForOption(command.SplashImage)?.FullName); + } + + [Fact] + public void SplashImage_WithoutFile_ShowsError() + { + string file = Path.GetFullPath(Path.ChangeExtension(Path.GetRandomFileName(), ".ico")); + var command = new T(); + + string cli = GetRequiredDefaultOptions() + $"--splashImage \"{file}\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal(1, parseResult.Errors.Count); + Assert.Equal(command.SplashImage, parseResult.Errors[0].SymbolResult?.Symbol.Parents.Single()); + Assert.Contains(file, parseResult.Errors[0].Message); + } + + [Fact] + public void Icon_WithValidFile_ParsesValue() + { + FileInfo fileInfo = CreateTempFile(name: Path.ChangeExtension(Path.GetRandomFileName(), ".ico")); + var command = new T(); + + string cli = GetRequiredDefaultOptions() + $"--icon \"{fileInfo.FullName}\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal(fileInfo.FullName, parseResult.GetValueForOption(command.Icon)?.FullName); + } + + [Fact] + public void Icon_WithBadFileExtension_ShowsError() + { + FileInfo fileInfo = CreateTempFile(name: Path.ChangeExtension(Path.GetRandomFileName(), ".wrong")); + var command = new T(); + + string cli = GetRequiredDefaultOptions() + $"--icon \"{fileInfo.FullName}\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal(1, parseResult.Errors.Count); + Assert.Equal($"--icon does not have an .ico extension", parseResult.Errors[0].Message); + } + + [Fact] + public void Icon_WithoutFile_ShowsError() + { + string file = Path.GetFullPath(Path.ChangeExtension(Path.GetRandomFileName(), ".ico")); + var command = new T(); + + string cli = GetRequiredDefaultOptions() + $"--icon \"{file}\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal(1, parseResult.Errors.Count); + Assert.Equal(command.Icon, parseResult.Errors[0].SymbolResult?.Symbol.Parents.Single()); + Assert.Contains(file, parseResult.Errors[0].Message); + } + + [Fact] + public void SquirrelAwareExecutable_WithMultipleValues_ParsesValue() + { + var command = new T(); + + string cli = GetRequiredDefaultOptions() + $"--mainExe \"MyApp1.exe\" --mainExe \"MyApp2.exe\""; + ParseResult parseResult = command.Parse(cli); + + string[]? searchPaths = parseResult.GetValueForOption(command.SquirrelAwareExecutable); + Assert.Equal(2, searchPaths?.Length); + Assert.Contains("MyApp1.exe", searchPaths); + Assert.Contains("MyApp2.exe", searchPaths); + } + + [Fact] + public void AppIcon_WithValidFile_ParsesValue() + { + FileInfo fileInfo = CreateTempFile(name: Path.ChangeExtension(Path.GetRandomFileName(), ".ico")); + var command = new T(); + + string cli = GetRequiredDefaultOptions() + $"--appIcon \"{fileInfo.FullName}\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal(fileInfo.FullName, parseResult.GetValueForOption(command.AppIcon)?.FullName); + } + + [Fact] + public void AppIcon_WithBadFileExtension_ShowsError() + { + FileInfo fileInfo = CreateTempFile(name: Path.ChangeExtension(Path.GetRandomFileName(), ".wrong")); + var command = new T(); + + string cli = GetRequiredDefaultOptions() + $"--appIcon \"{fileInfo.FullName}\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal(1, parseResult.Errors.Count); + Assert.Equal($"--appIcon does not have an .ico extension", parseResult.Errors[0].Message); + } + + [Fact] + public void AppIcon_WithoutFile_ShowsError() + { + string file = Path.GetFullPath(Path.ChangeExtension(Path.GetRandomFileName(), ".ico")); + var command = new T(); + + string cli = GetRequiredDefaultOptions() + $"--appIcon \"{file}\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal(1, parseResult.Errors.Count); + Assert.Equal(command.AppIcon, parseResult.Errors[0].SymbolResult?.Symbol.Parents.Single()); + Assert.Contains(file, parseResult.Errors[0].Message); + } + + [WindowsOnlyTheory] + [InlineData(Bitness.x86)] + [InlineData(Bitness.x64)] + public void BuildMsi_WithBitness_ParsesValue(Bitness bitness) + { + var command = new T(); + + string cli = GetRequiredDefaultOptions() + $"--msi \"{bitness}\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal(bitness, parseResult.GetValueForOption(command.BuildMsi)); + } + } +} diff --git a/test/Squirrel.CommandLine.Tests/Windows/ReleasifyCommandTests.cs b/test/Squirrel.CommandLine.Tests/Windows/ReleasifyCommandTests.cs new file mode 100644 index 000000000..8e6cee07f --- /dev/null +++ b/test/Squirrel.CommandLine.Tests/Windows/ReleasifyCommandTests.cs @@ -0,0 +1,154 @@ +using Xunit; +using Squirrel.CommandLine.Windows; +using System.CommandLine; +using System.CommandLine.Parsing; + +namespace Squirrel.CommandLine.Tests.Windows +{ + public class ReleasifyCommandTests : ReleaseCommandTests + { + [Fact] + public void Command_WithValidRequiredArguments_Parses() + { + FileInfo package = CreateTempFile(name:Path.ChangeExtension(Path.GetRandomFileName(), ".nupkg")); + var command = new ReleasifyCommand(); + + ParseResult parseResult = command.Parse($"--package \"{package.FullName}\""); + + Assert.Empty(parseResult.Errors); + Assert.Equal(package.FullName, parseResult.GetValueForOption(command.Package)?.FullName); + } + + [Fact] + public void Package_WithoutNupkgExtension_ShowsError() + { + FileInfo package = CreateTempFile(name: Path.ChangeExtension(Path.GetRandomFileName(), ".notpkg")); + var command = new ReleasifyCommand(); + + ParseResult parseResult = command.Parse($"--package \"{package.FullName}\""); + + Assert.Equal(1, parseResult.Errors.Count); + Assert.Equal(command.Package, parseResult.Errors[0].SymbolResult?.Symbol); + Assert.StartsWith("--package does not have an .nupkg extension", parseResult.Errors[0].Message); + } + + [Fact] + public void Package_WithoutExistingFile_ShowsError() + { + string package = Path.ChangeExtension(Path.GetRandomFileName(), ".nupkg"); + var command = new ReleasifyCommand(); + + ParseResult parseResult = command.Parse($"--package \"{package}\""); + + Assert.Equal(1, parseResult.Errors.Count); + Assert.Equal(command.Package, parseResult.Errors[0].SymbolResult?.Symbol.Parents.Single()); + Assert.StartsWith($"File does not exist: '{package}'", parseResult.Errors[0].Message); + } + + [Fact] + public void SignTemplate_WithTemplate_ParsesValue() + { + var command = new ReleasifyCommand(); + + string cli = GetRequiredDefaultOptions() + "--signTemplate \"signtool {{file}}\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal("signtool {{file}}", parseResult.GetValueForOption(command.SignTemplate)); + } + + [Fact] + public void SignTemplate_WithoutFileParameter_ShowsError() + { + var command = new ReleasifyCommand(); + + string cli = GetRequiredDefaultOptions() + "--signTemplate \"signtool file\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal(1, parseResult.Errors.Count); + Assert.Equal(command.SignTemplate, parseResult.Errors[0].SymbolResult?.Symbol); + Assert.StartsWith("--signTemplate must contain '{{file}}'", parseResult.Errors[0].Message); + } + + [WindowsOnlyFact] + public void SignParameters_WithParameters_ParsesValue() + { + var command = new ReleasifyCommand(); + + string cli = GetRequiredDefaultOptions() + "--signParams \"param1 param2\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal("param1 param2", parseResult.GetValueForOption(command.SignParameters)); + } + + [WindowsOnlyFact] + public void SignParameters_WithSignTemplate_ShowsError() + { + var command = new ReleasifyCommand(); + + string cli = GetRequiredDefaultOptions() + "--signTemplate \"signtool {{file}}\" --signParams \"param1 param2\""; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal(1, parseResult.Errors.Count); + Assert.Equal(command, parseResult.Errors[0].SymbolResult?.Symbol); + Assert.StartsWith("Cannot use '--signParams' and '--signTemplate' options together", parseResult.Errors[0].Message); + } + + [WindowsOnlyFact] + public void SignSkipDll_BareOption_SetsFlag() + { + var command = new ReleasifyCommand(); + + string cli = GetRequiredDefaultOptions() + "--signSkipDll"; + ParseResult parseResult = command.Parse(cli); + + Assert.True(parseResult.GetValueForOption(command.SignSkipDll)); + } + + [WindowsOnlyFact] + public void SignParallel_WithValue_SetsFlag() + { + var command = new ReleasifyCommand(); + + string cli = GetRequiredDefaultOptions() + "--signParallel 42"; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal(42, parseResult.GetValueForOption(command.SignParallel)); + } + + [WindowsOnlyTheory] + [InlineData(-1)] + [InlineData(0)] + [InlineData(1001)] + public void SignParallel_WithBadNumericValue_ShowsError(int value) + { + var command = new ReleasifyCommand(); + + string cli = GetRequiredDefaultOptions() + $"--signParallel {value}"; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal(1, parseResult.Errors.Count); + Assert.Equal(command.SignParallel, parseResult.Errors[0].SymbolResult?.Symbol); + Assert.Equal($"The value for --signParallel must be greater than 1 and less than 1000", parseResult.Errors[0].Message); + } + + [WindowsOnlyFact] + public void SignParallel_WithNonNumericValue_ShowsError() + { + var command = new ReleasifyCommand(); + + string cli = GetRequiredDefaultOptions() + $"--signParallel abc"; + ParseResult parseResult = command.Parse(cli); + + Assert.Equal(1, parseResult.Errors.Count); + Assert.Equal(command.SignParallel, parseResult.Errors[0].SymbolResult?.Symbol); + Assert.Equal($"abc is not a valid integer for --signParallel", parseResult.Errors[0].Message); + } + + protected override string GetRequiredDefaultOptions() + { + FileInfo package = CreateTempFile(name: Path.ChangeExtension(Path.GetRandomFileName(), ".nupkg")); + + return $"-p \"{package.FullName}\" "; + } + } +} diff --git a/test/Squirrel.CommandLine.Tests/WindowsOnlyFact.cs b/test/Squirrel.CommandLine.Tests/WindowsOnlyFact.cs new file mode 100644 index 000000000..41f16df0a --- /dev/null +++ b/test/Squirrel.CommandLine.Tests/WindowsOnlyFact.cs @@ -0,0 +1,15 @@ +using System.Runtime.InteropServices; +using Xunit; + +namespace Squirrel.CommandLine.Tests +{ + public class WindowsOnlyFactAttribute : FactAttribute + { + public WindowsOnlyFactAttribute() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { + Skip = "Only run on Windows"; + } + } + } +} diff --git a/test/Squirrel.CommandLine.Tests/WindowsOnlyTheoryAttribute.cs b/test/Squirrel.CommandLine.Tests/WindowsOnlyTheoryAttribute.cs new file mode 100644 index 000000000..cfc23fd3e --- /dev/null +++ b/test/Squirrel.CommandLine.Tests/WindowsOnlyTheoryAttribute.cs @@ -0,0 +1,15 @@ +using System.Runtime.InteropServices; +using Xunit; + +namespace Squirrel.CommandLine.Tests +{ + public class WindowsOnlyTheoryAttribute : TheoryAttribute + { + public WindowsOnlyTheoryAttribute() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { + Skip = "Only run on Windows"; + } + } + } +} diff --git a/test/Squirrel.Tests/Init.cs b/test/Squirrel.Tests/Init.cs index a61ff2206..25e4e58ab 100644 --- a/test/Squirrel.Tests/Init.cs +++ b/test/Squirrel.Tests/Init.cs @@ -19,11 +19,5 @@ public TestsInit(IMessageSink messageSink) HelperFile.AddSearchPath(Path.Combine(baseDir, "..", "..", "..", "..", "..", "vendor", "7zip")); HelperFile.AddSearchPath(Path.Combine(baseDir, "..", "..", "..", "..", "..", "vendor", "wix")); } - - public new void Dispose() - { - // Place tear down code here - base.Dispose(); - } } } diff --git a/test/Squirrel.Tests/TestHelpers/IntegrationTestHelper.cs b/test/Squirrel.Tests/TestHelpers/IntegrationTestHelper.cs index ce51e040d..d177721b2 100644 --- a/test/Squirrel.Tests/TestHelpers/IntegrationTestHelper.cs +++ b/test/Squirrel.Tests/TestHelpers/IntegrationTestHelper.cs @@ -61,7 +61,6 @@ public static void RunBlockAsSTA(Action block) } } - static object gate = 42; public static IDisposable WithFakeInstallDirectory(string packageFileName, out string path) { var ret = Utility.GetTempDirectory(out path); @@ -75,8 +74,8 @@ public static IDisposable WithFakeInstallDirectory(string packageFileName, out s public static string CreateFakeInstalledApp(string version, string outputDir, string nuspecFile = null) { - var targetDir = default(string); - + string targetDir; + nuspecFile = nuspecFile ?? "SquirrelInstalledApp.nuspec"; using (var clearTemp = Utility.GetTempDirectory(out targetDir)) {