Skip to content
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 13 additions & 21 deletions src/dotnet-bootstrapper/BootstrapperCommandParser.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) .NET Foundation and contributors. All rights reserved.
// Copyright (c) .NET Foundation and contributors. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System;
Expand All @@ -7,6 +7,9 @@
using System.CommandLine.Invocation;
using System.CommandLine.Parsing;
using System.Reflection;
using Microsoft.DotNet.Tools.Uninstall.Shared.Configs;
using Microsoft.DotNet.Tools.Bootstrapper.Commands.Search;
using Microsoft.DotNet.Tools.Bootstrapper.Commands.Install;

namespace Microsoft.DotNet.Tools.Bootstrapper
{
Expand All @@ -15,30 +18,19 @@ internal static class BootstrapperCommandParser
public static Parser BootstrapParser;

public static RootCommand BootstrapperRootCommand = new RootCommand("dotnet bootstrapper");

public static readonly Command VersionCommand = new Command("--version");

private static readonly Lazy<string> _assemblyVersion =
new Lazy<string>(() =>
{
var assembly = Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly();
var assemblyVersionAttribute = assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>();
if (assemblyVersionAttribute == null)
{
return assembly.GetName().Version.ToString();
}
else
{
return assemblyVersionAttribute.InformationalVersion;
}
});
public static readonly Command HelpCommand = new("--help");

static BootstrapperCommandParser()
{
BootstrapperRootCommand.AddCommand(VersionCommand);
VersionCommand.Handler = CommandHandler.Create(() =>
BootstrapperRootCommand.AddCommand(SearchCommandParser.GetCommand());
BootstrapperRootCommand.AddCommand(InstallCommandParser.GetCommand());
BootstrapperRootCommand.AddCommand(CommandLineConfigs.RemoveCommand);
BootstrapperRootCommand.AddCommand(CommandLineConfigs.VersionSubcommand);
BootstrapperRootCommand.AddCommand(HelpCommand);

HelpCommand.Handler = CommandHandler.Create(() =>
{
Console.WriteLine(_assemblyVersion.Value);
Console.WriteLine(LocalizableStrings.BootstrapperHelp);
});

BootstrapParser = new CommandLineBuilder(BootstrapperRootCommand)
Expand Down
72 changes: 72 additions & 0 deletions src/dotnet-bootstrapper/BootstrapperUtilities.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;
using System.Text.Json;
using Microsoft.Deployment.DotNet.Releases;

namespace Microsoft.DotNet.Tools.Bootstrapper
{
internal static class BootstrapperUtilities
{
public static string GetRID()
{
string operatingSystem = RuntimeInformation.OSDescription switch
{
string os when os.Contains("Windows") => "win",
string os when os.Contains("Linux") => "linux",
string os when os.Contains("Darwin") => "osx",
_ => null
};
string architecture = RuntimeInformation.OSArchitecture switch
{
Architecture.X64 => "x64",
Architecture.X86 => "x86",
Architecture.Arm => "arm",
Architecture.Arm64 => "arm64",
_ => null
};

if (operatingSystem == null || architecture == null)
{
throw new PlatformNotSupportedException("Unsupported OS or architecture.");
}

return $"{operatingSystem}-{architecture}";
}

public static string GetMajorVersionToInstallInDirectory(string basePath)
{
// Get the nearest global.json file.
JsonElement globalJson = GlobalJsonUtilities.GetNearestGlobalJson(basePath);
string sdkVersion = globalJson
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://learn.microsoft.com/en-us/dotnet/api/system.text.json.jsonelement.getproperty?view=net-9.0 It looks like GetProperty will throw if the property keys are not present. I believe a global json that does not specify an SDK should be valid, same with one that has no 'tools'. I would write a test for both of these scenarios.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With that being said, the global.json partition can be an entirely separate PR. There is already an issue for that. https://github.com/dotnet/cli-lab/issues/354

.GetProperty("tools")
.GetProperty("dotnet")
.ToString();

ReleaseVersion version = ReleaseVersion.Parse(sdkVersion);
Console.WriteLine($"Found version {version.Major}.0 in global.json");
return $"{version.Major}.0";
}

public static string GetInstallationDirectoryPath()
{
string globalJsonPath = GlobalJsonUtilities.GetNearestGlobalJsonPath(Environment.CurrentDirectory);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the current behavior of this, is that it will fail if it has no global.json, since this is called first when trying to find the install directory path. I don't think this is the behavior that we want, @baronfel?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can defer this to https://github.com/dotnet/cli-lab/issues/354.

if (globalJsonPath == null)
{
throw new FileNotFoundException("No global.json file found in the directory tree.");
}
string directoryPath = Path.GetDirectoryName(globalJsonPath);
if (directoryPath == null)
{
throw new DirectoryNotFoundException("Directory path is null.");
}

// TODO: Replace with the actual installation directory.
return Path.Combine(directoryPath, ".dotnet.local");
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where should we install to? Should we install to the .dotnet directory?

Copy link
Member

@nagilson nagilson Apr 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Priority #1: I believe we should have a command argument where you can specify the directory

I would personally say that we should not block this PR on this decision, and this logic can be reserved for another PR/issue. I would refer to https://github.com/dotnet/cli-lab/issues/354 for the global.json and a new issue for the file location default: https://github.com/dotnet/cli-lab/issues/392

Priority #2: Global.Json: Based on https://github.com/dotnet/cli-lab/issues/354, in which we decided to go with the spec here, https://github.com/dotnet/designs/pull/303/files#diff-6327dc0859c24dcc1de13ab88ec74a7b00f7b0d877a12b12d1dd802b2409e2e6R11, it seems this should look at the paths key inside of sdk of global.json. I'm not sure how we prioritize which path to put it in out of the paths in paths. Perhaps the first -- cc @baronfel @agocke?

Priority 3: Default location. Perhaps somewhere in temp/home dir.

}
}
}
21 changes: 21 additions & 0 deletions src/dotnet-bootstrapper/CommandBase.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using System;
using System.Collections.Generic;
using System.CommandLine.Parsing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace Microsoft.DotNet.Tools.Bootstrapper
{
public abstract class CommandBase
{
protected ParseResult _parseResult;

protected CommandBase(ParseResult parseResult)
{
_parseResult = parseResult;
}

public abstract int Execute();
}
}
16 changes: 16 additions & 0 deletions src/dotnet-bootstrapper/Commands/Common.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
using System.CommandLine;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace Microsoft.DotNet.Tools.Bootstrapper.Commands
{
internal static class Common
{
internal static Option<bool> AllowPreviewsOptions = new Option<bool>(
"--allow-previews",
description: "Include pre-release sdk versions");
}
}
105 changes: 105 additions & 0 deletions src/dotnet-bootstrapper/Commands/Install/InstallCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
using System;
using System.Collections.Generic;
using System.CommandLine.Parsing;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Deployment.DotNet.Releases;

namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Install;

internal class InstallCommand(
ParseResult parseResult) : CommandBase(parseResult)
{
private string _version = parseResult.ValueForArgument(InstallCommandParser.VersionArgument);
private string _rid = BootstrapperUtilities.GetRID();
private bool _allowPreviews = parseResult.ValueForOption(InstallCommandParser.AllowPreviewsOption);


public override int Execute()
{
// If no channel is specified, use the default channel.
if (string.IsNullOrEmpty(_version))
{
_version = BootstrapperUtilities.GetMajorVersionToInstallInDirectory(
Environment.CurrentDirectory);
}

ProductCollection productCollection = ProductCollection.GetAsync().Result;
Product product = productCollection
.FirstOrDefault(p => string.IsNullOrEmpty(_version) || p.ProductVersion.Equals(_version, StringComparison.OrdinalIgnoreCase));

if (product == null)
{
Console.WriteLine($"No product found for channel: {_version}");
return 1;
}

ProductRelease latestRelease = product.GetReleasesAsync().Result
.Where(release => !release.IsPreview || _allowPreviews)
.OrderByDescending(release => release.ReleaseDate)
.FirstOrDefault();

if (latestRelease == null)
{
Console.WriteLine($"No releases found for product: {product.ProductName}");
return 1;
}

Console.WriteLine($"Installing {product.ProductName} {latestRelease.Version}...");

string installationDirectoryPath = BootstrapperUtilities.GetInstallationDirectoryPath();

foreach (ReleaseComponent component in latestRelease.Components)
{
Console.WriteLine($"Installing {component.Name}...");
DownloadAndExtractReleaseComponentFiles(component, installationDirectoryPath);
}

return 0;
}

private static void DownloadAndExtractReleaseComponentFiles(ReleaseComponent component, string basePath)
{
if (component is WindowsDesktopReleaseComponent && !OperatingSystem.IsWindows())
{
return;
}

ReleaseFile releaseFile = component.Files.FirstOrDefault(file =>
file.Rid.Equals(BootstrapperUtilities.GetRID(), StringComparison.OrdinalIgnoreCase) && (file.Name.EndsWith(".zip") || file.Name.EndsWith(".tar.gz")));

if (string.IsNullOrEmpty(releaseFile?.FileName))
{
Console.WriteLine($"\tNo suitable file found for {component.Name}");
return;
}

string zipPath = Path.Combine(basePath, releaseFile.FileName);

if (File.Exists(zipPath))
{
Console.WriteLine($"\t{component.Name} already exists at {zipPath}");
return;
}

try
{
releaseFile.DownloadAsync(zipPath)?.Wait();

// Extract the downloaded file
ZipFile.ExtractToDirectory(zipPath, Path.ChangeExtension(zipPath, ""));

Console.WriteLine($"\tExtracted {component.Name} to {Path.ChangeExtension(zipPath, "")}");

// Delete the downloaded file
File.Delete(zipPath);
}
catch (IOException)
{
return;
}
}
}
41 changes: 41 additions & 0 deletions src/dotnet-bootstrapper/Commands/Install/InstallCommandParser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.CommandLine;
using System.CommandLine.Invocation;
using System.CommandLine.Parsing;

namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Install;

internal class InstallCommandParser
{
internal static Argument<string> VersionArgument = new Argument<string>(
name: "version",
description: "SDK version to install. If not specified, It will take the latest.")
{
Arity = ArgumentArity.ZeroOrOne,
};

internal static Option<bool> AllowPreviewsOption = Common.AllowPreviewsOptions;

private static readonly Command Command = ConstructCommand();

public static Command GetCommand() => Command;

private static Command ConstructCommand()
{
Command command = new("install", "Install SDKs available for installation.");

command.AddArgument(VersionArgument);

command.AddOption(AllowPreviewsOption);

command.Handler = CommandHandler.Create((ParseResult parseResult) =>
{
return new InstallCommand(parseResult).Execute();
});
return command;
}
}
65 changes: 65 additions & 0 deletions src/dotnet-bootstrapper/Commands/Search/SearchCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
using System.CommandLine.Parsing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Deployment.DotNet.Releases;
using Spectre.Console;

namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Search;

internal class SearchCommand(
ParseResult parseResult) : CommandBase(parseResult)
{
private string _channel = parseResult.ValueForArgument(SearchCommandParser.ChannelArgument);
private bool _allowPreviews = parseResult.ValueForOption(SearchCommandParser.AllowPreviewsOption);
public override int Execute()
{
List<Product> productCollection = [.. ProductCollection.GetAsync().Result];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The releases.json only changes about once a month. You should consider caching the file on disk and only update it if there's a new version available.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should be using the same etag-based cache invalidation system that @nagilson has for the VSCode extension. ETags are the way to handle cache invalidation of HTTP-delivered resources, and we shouldn't be reinventing the wheel.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does https://github.com/dotnet/deployment-tools cache it? I would hope it does so, but at a glance it looks like it does not. I tis what is interfacing with the web request caller API. I would also hope that it handles proxies well. As well as timeouts. If it does not, I would almost question why that should not be implemented over there instead of here. Definitely wouldn't block on this for this PR but would create another issue from it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From what I've seen, I don't think that it does. I do agree that it would make a lot of sense to implement it there

productCollection = [..
productCollection.Where(product => !product.IsOutOfSupport() && (product.SupportPhase != SupportPhase.Preview || _allowPreviews))];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@baronfel Did we decide to only support in support installs? I understand why we'd want to do that, but it seems to limit the product use cases. I would rather emit a warning than block the behavior, but maybe that's ill-advised.


if (!string.IsNullOrEmpty(_channel))
{
productCollection = [.. productCollection.Where(product => product.ProductVersion.Equals(_channel, StringComparison.OrdinalIgnoreCase))];
}

foreach (Product product in productCollection)
{
string productHeader = $"{product.ProductName} {product.ProductVersion}";
Console.WriteLine(productHeader);

Table productMetadataTable = new Table()
.AddColumn("Version")
.AddColumn("Release Date")
.AddColumn("Latest SDK")
.AddColumn("Runtime")
.AddColumn("ASP.NET Runtime")
.AddColumn("Windows Desktop Runtime");

List<ProductRelease> releases = product.GetReleasesAsync().Result.ToList()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For future reference in the above issue again: Does this cache, and does it handle no internet/timeouts well? The responsibility of ownership here is interesting. Ideally this command could work offline if it has cached information.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.Where(relase => !relase.IsPreview || _allowPreviews).ToList();

foreach (ProductRelease release in releases)
{
// Get release.Sdks latest version
var latestSdk = release.Sdks
.OrderByDescending(sdk => sdk.Version)
.FirstOrDefault();

productMetadataTable.AddRow(
release.Version.ToString(),
release.ReleaseDate.ToString("yyyy-MM-dd"),
latestSdk?.DisplayVersion ?? "N/A",
release.Runtime?.DisplayVersion ?? "N/A",
release.AspNetCoreRuntime?.DisplayVersion ?? "N/A",
release.WindowsDesktopRuntime?.DisplayVersion ?? "N/A");
}
AnsiConsole.Write(productMetadataTable);
Console.WriteLine();
}

return 0;
}
}
Loading