diff --git a/readme.md b/readme.md index 2e05e12..8a55656 100644 --- a/readme.md +++ b/readme.md @@ -107,6 +107,7 @@ Usage: dnx vs -- install [options] | `sku` | Edition, one of `e\|ent\|enterprise`, `p\|pro\|professional`, `c\|com\|community`, `b\|build\|buildtools` or `t\|test\|testagent` | | `filter` | Expression to filter VS instances. E.g. `x => x.InstanceId = '123'` | | `nick\|nickname` | Optional nickname to use | +| `v\|version` | Install specific (semantic) version, such as 18.7 or 18.7.3 | | `add` | A workload ID | @@ -129,6 +130,12 @@ Examples: # Install VS community with the .NET Core, ASP.NET and Azure workloads, # shows installation progress and waits for it to finish before returning > dnx vs -- install +core +web +azure + +# Install VS 18 Enterprise +> dnx vs -- install -v:18 -sku:ent + +# Install the latest VS 17 (2022) Community +> dnx vs -- install --version 17 ``` @@ -279,7 +286,20 @@ Usage: dnx vs -- update [options] | `filter` | Expression to filter VS instances. E.g. `x => x.InstanceId = '123'` | | `first` | Update first matching instance. | | `all` | Update all instances. | +| `v\|version` | Update specific (semantic) version, such as 18.7 or 18.7.3 | + + +Examples: + +``` +# Update the installed VS 18.7 instance +> dnx vs -- update -v:18.7 + +# Update all matching VS 17 instances +> dnx vs -- update --version 17 --all +``` + ## where diff --git a/src/VisualStudio.Tests/Commands/VersionOptionTests.cs b/src/VisualStudio.Tests/Commands/VersionOptionTests.cs new file mode 100644 index 0000000..98a6b47 --- /dev/null +++ b/src/VisualStudio.Tests/Commands/VersionOptionTests.cs @@ -0,0 +1,58 @@ +using System.CommandLine; +using System.Linq; +using Xunit; + +namespace Devlooped.Tests +{ + public class VersionOptionTests + { + [Theory] + [InlineData(Commands.Run)] + [InlineData(Commands.Install)] + [InlineData(Commands.Update)] + public void when_command_is_defined_then_it_accepts_version_option(string commandName) + { + var root = new VsRootCommand(); + var command = root.Subcommands.Single(c => c.Name == commandName); + + Assert.Contains(command.Options, o => o.Name == "--version" && o.Aliases.Contains("-v")); + } + + [Theory] + [InlineData(Commands.Install, "--version", "18.7")] + [InlineData(Commands.Install, "-v", "18.7.3")] + [InlineData(Commands.Update, "--version", "18.7")] + [InlineData(Commands.Update, "-v", "17.14")] + [InlineData(Commands.Run, "--version", "18.7")] + [InlineData(Commands.Run, "-v", "18.7.3")] + public void when_parsing_version_then_option_is_bound_and_not_unmatched( + string commandName, string option, string value) + { + var root = new VsRootCommand(); + var parse = root.Parse(new[] { commandName, option, value }); + + Assert.Empty(parse.Errors); + Assert.DoesNotContain(option, parse.UnmatchedTokens); + Assert.DoesNotContain(value, parse.UnmatchedTokens); + + var version = parse.CommandResult.Command.Options.OfType>().Single(o => o.Name == "--version"); + Assert.Equal(value, parse.GetValue(version)); + } + + [Theory] + [InlineData(Commands.Install, "-v:18.7", "18.7")] + [InlineData(Commands.Update, "--version=18.7.3", "18.7.3")] + [InlineData(Commands.Run, "-v:18", "18")] + public void when_parsing_legacy_version_syntax_then_option_is_bound( + string commandName, string token, string expected) + { + var rewritten = ArgumentPreprocessor.RewriteForCommand(commandName, new[] { token }); + var root = new VsRootCommand(); + var parse = root.Parse(new[] { commandName }.Concat(rewritten).ToArray()); + + Assert.Empty(parse.Errors); + var version = parse.CommandResult.Command.Options.OfType>().Single(o => o.Name == "--version"); + Assert.Equal(expected, parse.GetValue(version)); + } + } +} diff --git a/src/VisualStudio.Tests/VisualStudioOptionsTests.cs b/src/VisualStudio.Tests/VisualStudioOptionsTests.cs index 78de661..2de2a63 100644 --- a/src/VisualStudio.Tests/VisualStudioOptionsTests.cs +++ b/src/VisualStudio.Tests/VisualStudioOptionsTests.cs @@ -18,12 +18,14 @@ static ParseResult ParseSelection(params string[] args) var all = SharedOptions.AllOption("test"); var nick = SharedOptions.NicknameOption(); var exp = SharedOptions.ExperimentalOption("test"); + var version = SharedOptions.VersionOption("test"); cmd.Options.Add(sku); cmd.Options.Add(filter); cmd.Options.Add(first); cmd.Options.Add(all); cmd.Options.Add(nick); cmd.Options.Add(exp); + cmd.Options.Add(version); cmd.TreatUnmatchedTokensAsErrors = false; var root = new RootCommand(); @@ -49,7 +51,8 @@ static VisualStudioFilter GetFilter(ParseResult parse) cmd.Options.OfType>().First(o => o.Name == "--sku"), cmd.Options.OfType>().First(o => o.Name == "--filter"), cmd.Options.OfType>().First(o => o.Name == "--first"), - cmd.Options.OfType>().First(o => o.Name == "--all")); + cmd.Options.OfType>().First(o => o.Name == "--all"), + cmd.Options.OfType>().First(o => o.Name == "--version")); } [Theory] @@ -187,6 +190,22 @@ public void when_parsing_all_argument_then_all_is_set(string argument, bool expe Assert.Equal(expectedValue, filter.All); } + [Theory] + [InlineData("", null)] + [InlineData("--version=18.7", "18.7")] + [InlineData("-v:18.7.3", "18.7.3")] + [InlineData("--version", "18")] + public void when_parsing_version_argument_then_version_is_set(string argument, string expectedValue) + { + var args = string.IsNullOrEmpty(argument) + ? Array.Empty() + : argument == "--version" + ? new[] { "--version", "18" } + : new[] { argument }; + var filter = GetFilter(ParseSelection(args)); + Assert.Equal(expectedValue, filter.Version); + } + [Theory] [InlineData("", false)] [InlineData("first", true)] diff --git a/src/VisualStudio.Tests/VisualStudioPredicateBuilderTests.cs b/src/VisualStudio.Tests/VisualStudioPredicateBuilderTests.cs index 7387a03..7a3c7f1 100644 --- a/src/VisualStudio.Tests/VisualStudioPredicateBuilderTests.cs +++ b/src/VisualStudio.Tests/VisualStudioPredicateBuilderTests.cs @@ -63,7 +63,25 @@ public async Task when_evaluating_combined_criterias_then_predicate_matches_conf Assert.False(predicate(new vswhere.VisualStudioInstance() { InstanceId = "123" }.WithSku(Sku.Professional).WithChannel(Channel.Stable))); } - static VisualStudioFilter GetFilter(Sku? sku = null, Channel? channel = null, string expression = null) => - new VisualStudioFilter(Channel: channel, Sku: sku, Expression: expression); + [Fact] + public async Task when_evaluating_version_then_predicate_matches_semantic_prefix() + { + var builder = new VisualStudioPredicateBuilder(); + + var predicate = await builder.BuildPredicateAsync(GetFilter(version: "18.7")); + + Assert.True(predicate(new vswhere.VisualStudioInstance + { + Catalog = new vswhere.VisualStudioCatalog { ProductSemanticVersion = "18.7.3" } + })); + Assert.False(predicate(new vswhere.VisualStudioInstance + { + Catalog = new vswhere.VisualStudioCatalog { ProductSemanticVersion = "18.8.0" } + })); + Assert.False(predicate(new vswhere.VisualStudioInstance())); + } + + static VisualStudioFilter GetFilter(Sku? sku = null, Channel? channel = null, string expression = null, string version = null) => + new VisualStudioFilter(Channel: channel, Sku: sku, Expression: expression, Version: version); } } diff --git a/src/VisualStudio.Tests/VisualStudioVersionTests.cs b/src/VisualStudio.Tests/VisualStudioVersionTests.cs new file mode 100644 index 0000000..9f95149 --- /dev/null +++ b/src/VisualStudio.Tests/VisualStudioVersionTests.cs @@ -0,0 +1,29 @@ +using Xunit; + +namespace Devlooped.Tests +{ + public class VisualStudioVersionTests + { + [Theory] + [InlineData(null, null)] + [InlineData("", null)] + [InlineData("18", "18")] + [InlineData("18.7", "18")] + [InlineData("18.7.3", "18")] + [InlineData("17.14.16", "17")] + public void when_getting_major_then_returns_major_component(string version, string expected) => + Assert.Equal(expected, VisualStudioVersion.GetMajor(version)); + + [Theory] + [InlineData("18.7.3", null, true)] + [InlineData("18.7.3", "", true)] + [InlineData("18.7.3", "18", true)] + [InlineData("18.7.3", "18.7", true)] + [InlineData("18.7.3", "18.7.3", true)] + [InlineData("18.8.0", "18.7", false)] + [InlineData("17.14.16", "18", false)] + [InlineData(null, "18.7", false)] + public void when_matching_then_uses_semantic_prefix(string product, string requested, bool expected) => + Assert.Equal(expected, VisualStudioVersion.Matches(product, requested)); + } +} diff --git a/src/VisualStudio/CommandHelpers.cs b/src/VisualStudio/CommandHelpers.cs index b5401a0..8192776 100644 --- a/src/VisualStudio/CommandHelpers.cs +++ b/src/VisualStudio/CommandHelpers.cs @@ -13,14 +13,16 @@ public static VisualStudioFilter GetFilter( Option skuOption, Option filterOption = null, Option firstOption = null, - Option allOption = null) + Option allOption = null, + Option versionOption = null) { return new VisualStudioFilter( Channel: channelOptions.GetChannel(parse), Sku: SharedOptions.ParseSku(parse.GetValue(skuOption)), Expression: filterOption != null ? parse.GetValue(filterOption) : null, First: firstOption != null && parse.GetValue(firstOption), - All: allOption != null && parse.GetValue(allOption)); + All: allOption != null && parse.GetValue(allOption), + Version: versionOption != null ? parse.GetValue(versionOption) : null); } public static string[] GetWorkloadIds(ParseResult parse, Option option) diff --git a/src/VisualStudio/Commands/InstallCommand.cs b/src/VisualStudio/Commands/InstallCommand.cs index d61f305..d07f97a 100644 --- a/src/VisualStudio/Commands/InstallCommand.cs +++ b/src/VisualStudio/Commands/InstallCommand.cs @@ -14,6 +14,7 @@ class InstallCommand : Command readonly Option skuOption; readonly Option filterOption; readonly Option nicknameOption; + readonly Option versionOption; readonly Option addOption = new("--add") { Description = "A workload ID", @@ -28,10 +29,12 @@ public InstallCommand(InstallerService installerService) skuOption = SharedOptions.SkuOption(); filterOption = SharedOptions.FilterOption(); nicknameOption = SharedOptions.NicknameOption(); + versionOption = SharedOptions.VersionOption("Install"); Options.Add(skuOption); Options.Add(filterOption); Options.Add(nicknameOption); + Options.Add(versionOption); Options.Add(addOption); TreatUnmatchedTokensAsErrors = false; @@ -48,6 +51,7 @@ async Task ExecuteAsync(ParseResult parse, TextWriter output) var channel = channelOptions.GetChannel(parse); var sku = SharedOptions.ParseSku(parse.GetValue(skuOption)) ?? Sku.Community; var nickname = parse.GetValue(nicknameOption); + var version = parse.GetValue(versionOption); var workloads = CommandHelpers.GetWorkloadIds(parse, addOption); var extra = parse.UnmatchedTokens; @@ -65,7 +69,7 @@ async Task ExecuteAsync(ParseResult parse, TextWriter output) args.AddRange(extra); - var vs = await installerService.GetLatestMajorAsync(); + var vs = VisualStudioVersion.GetMajor(version) ?? await installerService.GetLatestMajorAsync(); var installBase = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Microsoft Visual Studio", diff --git a/src/VisualStudio/Commands/RunCommand.cs b/src/VisualStudio/Commands/RunCommand.cs index 78d086e..1098e94 100644 --- a/src/VisualStudio/Commands/RunCommand.cs +++ b/src/VisualStudio/Commands/RunCommand.cs @@ -23,10 +23,7 @@ class RunCommand : Command { Description = "If more than one instance matches the criteria, run the first one sorted by descending build version.", }; - readonly Option versionOption = new("--version", "-v") - { - Description = "Run specific (semantic) version, such as 18.7 or 18.7.3", - }; + readonly Option versionOption; readonly Option waitOption = new("--wait", "-w") { Description = "Wait for the started Visual Studio to exit.", @@ -58,6 +55,7 @@ public RunCommand(WhereService whereService) skuOption = SharedOptions.SkuOption(); filterOption = SharedOptions.FilterOption(); experimentalOption = SharedOptions.ExperimentalOption("run"); + versionOption = SharedOptions.VersionOption("Run"); Options.Add(skuOption); Options.Add(filterOption); @@ -90,12 +88,11 @@ async Task ExecuteAsync(ParseResult parse, TextWriter output) : (bool?)null; var id = parse.GetValue(idOption); - var version = parse.GetValue(versionOption); var first = parse.GetValue(firstOption); var wait = parse.GetValue(waitOption); var disableNodeReuse = parse.GetValue(nodeReuseOption); var isExperimental = parse.GetValue(experimentalOption); - var filter = CommandHelpers.GetFilter(parse, channelOptions, skuOption, filterOption, firstOption); + var filter = CommandHelpers.GetFilter(parse, channelOptions, skuOption, filterOption, firstOption, versionOption: versionOption); var devenv = settings.Get("devenv"); if (!string.IsNullOrEmpty(devenv)) @@ -118,13 +115,7 @@ async Task ExecuteAsync(ParseResult parse, TextWriter output) .OrderByDescending(i => i.Catalog.BuildVersion); if (!string.IsNullOrEmpty(id)) - { instances = instances.Where(i => i.InstanceId.Equals(id, StringComparison.OrdinalIgnoreCase)); - } - else if (version != null) - { - instances = instances.Where(i => i.Catalog.ProductSemanticVersion.StartsWith(version)); - } var matches = instances.ToArray(); if (matches.Length == 1 || (matches.Length > 0 && first)) diff --git a/src/VisualStudio/Commands/UpdateCommand.cs b/src/VisualStudio/Commands/UpdateCommand.cs index 67c62c3..cc5f550 100644 --- a/src/VisualStudio/Commands/UpdateCommand.cs +++ b/src/VisualStudio/Commands/UpdateCommand.cs @@ -16,6 +16,7 @@ class UpdateCommand : Command readonly Option filterOption; readonly Option firstOption; readonly Option allOption; + readonly Option versionOption; public UpdateCommand(WhereService whereService, InstallerService installerService) : base(Commands.Update, "Updates an installation of Visual Studio.") @@ -28,11 +29,13 @@ public UpdateCommand(WhereService whereService, InstallerService installerServic filterOption = SharedOptions.FilterOption(); firstOption = SharedOptions.FirstOption("Update"); allOption = SharedOptions.AllOption("Update"); + versionOption = SharedOptions.VersionOption("Update"); Options.Add(skuOption); Options.Add(filterOption); Options.Add(firstOption); Options.Add(allOption); + Options.Add(versionOption); TreatUnmatchedTokensAsErrors = false; @@ -45,7 +48,7 @@ public UpdateCommand(WhereService whereService, InstallerService installerServic async Task ExecuteAsync(ParseResult parse, TextWriter output) { - var filter = CommandHelpers.GetFilter(parse, channelOptions, skuOption, filterOption, firstOption, allOption); + var filter = CommandHelpers.GetFilter(parse, channelOptions, skuOption, filterOption, firstOption, allOption, versionOption); var all = parse.GetValue(allOption); var extraArgs = parse.UnmatchedTokens.ToList(); diff --git a/src/VisualStudio/Docs/install.md b/src/VisualStudio/Docs/install.md index 31c9cb3..78beddf 100644 --- a/src/VisualStudio/Docs/install.md +++ b/src/VisualStudio/Docs/install.md @@ -27,5 +27,11 @@ Examples: # Install VS community with the .NET Core, ASP.NET and Azure workloads, # shows installation progress and waits for it to finish before returning > dnx vs -- install +core +web +azure + +# Install VS 18 Enterprise +> dnx vs -- install -v:18 -sku:ent + +# Install the latest VS 17 (2022) Community +> dnx vs -- install --version 17 ``` diff --git a/src/VisualStudio/Docs/update.md b/src/VisualStudio/Docs/update.md index 935125c..13653c0 100644 --- a/src/VisualStudio/Docs/update.md +++ b/src/VisualStudio/Docs/update.md @@ -7,3 +7,15 @@ ``` {Options} + +Examples: + + +``` +# Update the installed VS 18.7 instance +> dnx vs -- update -v:18.7 + +# Update all matching VS 17 instances +> dnx vs -- update --version 17 --all +``` + diff --git a/src/VisualStudio/InstallerService.cs b/src/VisualStudio/InstallerService.cs index 8f7258b..e58b068 100644 --- a/src/VisualStudio/InstallerService.cs +++ b/src/VisualStudio/InstallerService.cs @@ -67,7 +67,7 @@ Task RunAsync(string command, string vs, Channel? channel, Sku? sku, IEnumerable if (int.TryParse(vs, out var major) && major >= 17) args = args.Select(arg => arg == "Microsoft.VisualStudio.Workload.NetCoreTools" ? "Microsoft.NetCore.Component.DevelopmentTools" : arg); - return RunAsync(command, $"https://aka.ms/vs/{vs}/{MapChannel(channel)}", sku, args, output); + return RunAsync(command, $"https://aka.ms/vs/{vs}/{MapChannel(vs, channel)}", sku, args, output); } async Task RunAsync(string command, string channelUri, Sku? sku, IEnumerable args, TextWriter output) @@ -96,12 +96,16 @@ async Task RunAsync(string command, string channelUri, Sku? sku, IEnumerable channel switch + string MapChannel(string vs, Channel? channel) + => (channel, vs) switch { - Channel.Insiders => "insiders", - Channel.IntPreview => "intpreview", - Channel.Main => "int.main", + (Channel.Insiders, "15" or "16" or "17") => "pre", + (Channel.Insiders, _) => "insiders", + (Channel.IntPreview, _) => "intpreview", + (Channel.Main, _) => "int.main", + // VS 2017-2022 used "release" for the current/stable channel. + // VS 2026+ uses "stable". + (_, "15" or "16" or "17") => "release", // Stable is the default; Channel.Stable and null both map here. // "release" is accepted on the CLI as a hidden alias for Stable. _ => "stable" diff --git a/src/VisualStudio/Options/SharedOptions.cs b/src/VisualStudio/Options/SharedOptions.cs index d307e82..1ec52db 100644 --- a/src/VisualStudio/Options/SharedOptions.cs +++ b/src/VisualStudio/Options/SharedOptions.cs @@ -57,6 +57,12 @@ public static Option FilterOption() => Description = "Expression to filter VS instances. E.g. `x => x.InstanceId = '123'`", }; + public static Option VersionOption(string verb) => + new("--version", "-v") + { + Description = $"{verb} specific (semantic) version, such as 18.7 or 18.7.3", + }; + public static Option FirstOption(string verb) => new("--first") { @@ -142,14 +148,16 @@ public static VisualStudioFilter GetFilter( Option sku, Option filter = null, Option first = null, - Option all = null) + Option all = null, + Option version = null) { return new VisualStudioFilter( Channel: channel.GetChannel(parse), Sku: ParseSku(parse.GetValue(sku)), Expression: filter != null ? parse.GetValue(filter) : null, First: first != null && parse.GetValue(first), - All: all != null && parse.GetValue(all)); + All: all != null && parse.GetValue(all), + Version: version != null ? parse.GetValue(version) : null); } public static Sku? ParseSku(string sku) diff --git a/src/VisualStudio/VisualStudioFilter.cs b/src/VisualStudio/VisualStudioFilter.cs index f2c3a7f..3b70fbc 100644 --- a/src/VisualStudio/VisualStudioFilter.cs +++ b/src/VisualStudio/VisualStudioFilter.cs @@ -8,4 +8,5 @@ record VisualStudioFilter( Sku? Sku = null, string Expression = null, bool First = false, - bool All = false); + bool All = false, + string Version = null); diff --git a/src/VisualStudio/VisualStudioPredicateBuilder.cs b/src/VisualStudio/VisualStudioPredicateBuilder.cs index bde6c90..485e556 100644 --- a/src/VisualStudio/VisualStudioPredicateBuilder.cs +++ b/src/VisualStudio/VisualStudioPredicateBuilder.cs @@ -26,7 +26,11 @@ public async Task> BuildPredicateAsync(VisualSt if (!string.IsNullOrEmpty(filter.Expression)) filterPredicate = await CSharpScript.EvaluateAsync>(filter.Expression, scriptOptions); - return x => skuPredicate(x) && channelPredicate(x) && filterPredicate(x); + Func versionPredicate = _ => true; + if (!string.IsNullOrEmpty(filter.Version)) + versionPredicate = x => VisualStudioVersion.Matches(x.Catalog?.ProductSemanticVersion, filter.Version); + + return x => skuPredicate(x) && channelPredicate(x) && filterPredicate(x) && versionPredicate(x); } } } diff --git a/src/VisualStudio/VisualStudioVersion.cs b/src/VisualStudio/VisualStudioVersion.cs new file mode 100644 index 0000000..e345927 --- /dev/null +++ b/src/VisualStudio/VisualStudioVersion.cs @@ -0,0 +1,27 @@ +using System; + +namespace Devlooped; + +/// +/// Helpers for the shared --version/-v Visual Studio version filter. +/// +static class VisualStudioVersion +{ + public static string GetMajor(string version) + { + if (string.IsNullOrWhiteSpace(version)) + return null; + + var dot = version.IndexOf('.'); + return dot < 0 ? version : version[..dot]; + } + + public static bool Matches(string productSemanticVersion, string requested) + { + if (string.IsNullOrEmpty(requested)) + return true; + + return !string.IsNullOrEmpty(productSemanticVersion) && + productSemanticVersion.StartsWith(requested, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/VisualStudio/readme.md b/src/VisualStudio/readme.md index 346f534..f8c7088 100644 --- a/src/VisualStudio/readme.md +++ b/src/VisualStudio/readme.md @@ -91,6 +91,7 @@ Usage: dnx vs -- install [options] | `sku` | Edition, one of `e\|ent\|enterprise`, `p\|pro\|professional`, `c\|com\|community`, `b\|build\|buildtools` or `t\|test\|testagent` | | `filter` | Expression to filter VS instances. E.g. `x => x.InstanceId = '123'` | | `nick\|nickname` | Optional nickname to use | +| `v\|version` | Install specific (semantic) version, such as 18.7 or 18.7.3 | | `add` | A workload ID | @@ -113,6 +114,12 @@ Examples: # Install VS community with the .NET Core, ASP.NET and Azure workloads, # shows installation progress and waits for it to finish before returning > dnx vs -- install +core +web +azure + +# Install VS 18 Enterprise +> dnx vs -- install -v:18 -sku:ent + +# Install the latest VS 17 (2022) Community +> dnx vs -- install --version 17 ``` @@ -263,7 +270,20 @@ Usage: dnx vs -- update [options] | `filter` | Expression to filter VS instances. E.g. `x => x.InstanceId = '123'` | | `first` | Update first matching instance. | | `all` | Update all instances. | +| `v\|version` | Update specific (semantic) version, such as 18.7 or 18.7.3 | + + +Examples: + +``` +# Update the installed VS 18.7 instance +> dnx vs -- update -v:18.7 + +# Update all matching VS 17 instances +> dnx vs -- update --version 17 --all +``` + ## where