diff --git a/Source/v2/Meadow.CLI/Commands/Current/App/AppTools.cs b/Source/v2/Meadow.CLI/Commands/Current/App/AppTools.cs index 8b50d7ba..d5385b27 100644 --- a/Source/v2/Meadow.CLI/Commands/Current/App/AppTools.cs +++ b/Source/v2/Meadow.CLI/Commands/Current/App/AppTools.cs @@ -1,4 +1,5 @@ -using CliFx.Infrastructure; +using System.Diagnostics; +using CliFx.Infrastructure; using Meadow.Hcom; using Meadow.Package; using Microsoft.Extensions.Logging; @@ -143,4 +144,42 @@ internal static string SanitizeMeadowFilename(string fileName) return meadowFileName!.Replace(Path.DirectorySeparatorChar, '/'); } + + internal static async Task RunProcessCommand(string command, string args, Action? handleOutput = null, Action? handleError = null, CancellationToken cancellationToken = default) + { + var processStartInfo = new ProcessStartInfo + { + FileName = command, + Arguments = args, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using (var process = new Process { StartInfo = processStartInfo }) + { + process.Start(); + + var outputCompletion = ReadLinesAsync(process.StandardOutput, handleOutput, cancellationToken); + var errorCompletion = ReadLinesAsync(process.StandardError, handleError, cancellationToken); + + await Task.WhenAll(outputCompletion, errorCompletion, process.WaitForExitAsync()); + + return process.ExitCode; + } + } + + private static async Task ReadLinesAsync(StreamReader reader, Action? handleLine, CancellationToken cancellationToken) + { + while (!reader.EndOfStream) + { + var line = await reader.ReadLineAsync(cancellationToken); + if (!string.IsNullOrWhiteSpace(line) + && handleLine != null) + { + handleLine(line); + } + } + } } \ No newline at end of file diff --git a/Source/v2/Meadow.CLI/Commands/Current/Project/MeadowTemplate.cs b/Source/v2/Meadow.CLI/Commands/Current/Project/MeadowTemplate.cs new file mode 100644 index 00000000..bf9e9baf --- /dev/null +++ b/Source/v2/Meadow.CLI/Commands/Current/Project/MeadowTemplate.cs @@ -0,0 +1,14 @@ +namespace Meadow.CLI.Commands.Current.Project +{ + internal class MeadowTemplate + { + public string Name; + public string ShortName; + + public MeadowTemplate(string longName, string shortName) + { + this.Name = longName; + this.ShortName = shortName; + } + } +} \ No newline at end of file diff --git a/Source/v2/Meadow.CLI/Commands/Current/Project/ProjectInstallCommand.cs b/Source/v2/Meadow.CLI/Commands/Current/Project/ProjectInstallCommand.cs new file mode 100644 index 00000000..c701771d --- /dev/null +++ b/Source/v2/Meadow.CLI/Commands/Current/Project/ProjectInstallCommand.cs @@ -0,0 +1,63 @@ +using System; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; +using CliFx.Attributes; +using Meadow.CLI.Commands.DeviceManagement; +using Microsoft.Extensions.Logging; +using Spectre.Console; + +namespace Meadow.CLI.Commands.Current.Project +{ + [Command("project install", Description = Strings.ProjectTemplates.InstallCommandDescription)] + public class ProjectInstallCommand : BaseCommand + { + public ProjectInstallCommand(ILoggerFactory loggerFactory) + : base(loggerFactory) + { + } + + protected override async ValueTask ExecuteCommand() + { + AnsiConsole.MarkupLine(Strings.ProjectTemplates.InstallTitle); + + var templateList = await ProjectNewCommand.GetInstalledTemplates(LoggerFactory, Console, CancellationToken); + + if (templateList != null) + { + DisplayInstalledTemplates(templateList); + } + else + { + Logger?.LogError(Strings.ProjectTemplates.ErrorInstallingTemplates); + } + } + + private void DisplayInstalledTemplates(List templateList) + { + // Use regex to split each line into segments using two or more spaces as the separator + var regex = new Regex(@"\s{2,}"); + + var table = new Table(); + // Add some columns + table.AddColumn(Strings.ProjectTemplates.ColumnTemplateName); + table.AddColumn(Strings.ProjectTemplates.ColumnLanguages); + foreach (var templatesLine in templateList) + { + // Isolate the long and shortnames, as well as languages + var segments = regex.Split(templatesLine.Trim()); + if (segments.Length >= 2) + { + // Add Key Value of Long Name and Short Name + var longName = segments[0].Trim(); + var languages = segments[2].Replace("[", string.Empty).Replace("]", string.Empty).Trim(); + table.AddRow(longName, languages); + } + } + AnsiConsole.WriteLine(Strings.ProjectTemplates.Installed); + + // Render the table to the console + AnsiConsole.Write(table); + } + } +} \ No newline at end of file diff --git a/Source/v2/Meadow.CLI/Commands/Current/Project/ProjectNewCommand.cs b/Source/v2/Meadow.CLI/Commands/Current/Project/ProjectNewCommand.cs new file mode 100644 index 00000000..164c9238 --- /dev/null +++ b/Source/v2/Meadow.CLI/Commands/Current/Project/ProjectNewCommand.cs @@ -0,0 +1,240 @@ +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; +using CliFx.Attributes; +using Meadow.CLI.Commands.DeviceManagement; +using Microsoft.Extensions.Logging; +using Spectre.Console; + +namespace Meadow.CLI.Commands.Current.Project +{ + [Command("project new", Description = Strings.ProjectTemplates.NewCommandDescription)] + public class ProjectNewCommand : BaseCommand + { + [CommandOption('o', Description = Strings.ProjectTemplates.CommandOptionOutputPathDescription, IsRequired = false)] + public string? OutputPath { get; private set; } = null; + + [CommandOption('l', Description = Strings.ProjectTemplates.CommandOptionSupportedLanguagesDescription, IsRequired = false)] + public string Language { get; private set; } = "C#"; + + public ProjectNewCommand(ILoggerFactory loggerFactory) + : base(loggerFactory) + { + } + + protected override async ValueTask ExecuteCommand() + { + AnsiConsole.MarkupLine(Strings.ProjectTemplates.WizardTitle); + + var templateList = await GetInstalledTemplates(LoggerFactory, Console, CancellationToken); + + if (templateList != null) + { + // Ask some pertinent questions + var projectName = AnsiConsole.Ask(Strings.ProjectTemplates.ProjectName); + + if (string.IsNullOrWhiteSpace(OutputPath)) + { + OutputPath = projectName; + } + + var outputPathArgument = $"--output {OutputPath}"; + + List selectedTemplates = GatherTemplateInformationFromUsers(templateList); + + if (selectedTemplates.Count > 0) + { + await GenerateProjectsAndSolutionsFromSelectedTemplates(projectName, outputPathArgument, selectedTemplates); + } + else + { + AnsiConsole.MarkupLine($"[yellow]{Strings.ProjectTemplates.NoTemplateSelected}[/]"); + } + } + else + { + Logger?.LogError(Strings.ProjectTemplates.ErrorInstallingTemplates); + } + } + + private List GatherTemplateInformationFromUsers(List templateList) + { + var templateNames = new List(); + MeadowTemplate? startKitGroup = null; + List startKitTemplates = new List(); + + startKitGroup = PopulateTemplateNameList(templateList, templateNames, startKitGroup, startKitTemplates); + + var multiSelectionPrompt = new MultiSelectionPrompt() + .Title(Strings.ProjectTemplates.InstalledTemplates) + .PageSize(15) + .NotRequired() // Can be Blank to exit + .MoreChoicesText(string.Format($"[grey]{Strings.ProjectTemplates.MoreChoicesInstructions}[/]")) + .InstructionsText(string.Format($"[grey]{Strings.ProjectTemplates.Instructions}[/]", $"[blue]<{Strings.Space}>[/]", $"[green]<{Strings.Enter}>[/]")) + .UseConverter(x => x.Name); + + // I wanted StartKit to appear 1st, if it exists + if (startKitGroup != null) + { + multiSelectionPrompt.AddChoiceGroup(startKitGroup, startKitTemplates); + } + + multiSelectionPrompt.AddChoices(templateNames); + + var selectedTemplates = AnsiConsole.Prompt(multiSelectionPrompt); + return selectedTemplates; + } + + private async Task GenerateProjectsAndSolutionsFromSelectedTemplates(string projectName, string outputPathArgument, List selectedTemplates) + { + string generatedProjectName = projectName; + + var generateSln = AnsiConsole.Confirm(Strings.ProjectTemplates.GenerateSln); + + // Create the selected templates + foreach (var selectedTemplate in selectedTemplates) + { + AnsiConsole.MarkupLine($"[green]{Strings.ProjectTemplates.CreatingProject}[/]", selectedTemplate.Name); + + var outputPath = string.Empty; + outputPath = Path.Combine(OutputPath!, $"{OutputPath}.{selectedTemplate.ShortName}"); + outputPathArgument = "--output " + outputPath; + generatedProjectName = $"{projectName}.{selectedTemplate.ShortName}"; + + _ = await AppTools.RunProcessCommand("dotnet", $"new {selectedTemplate.ShortName} --name {generatedProjectName} {outputPathArgument} --language {Language} --force", cancellationToken: CancellationToken); + } + + if (generateSln) + { + await GenerateSolution(projectName); + } + + AnsiConsole.MarkupLine(Strings.ProjectTemplates.GenerationComplete, $"[green]{projectName}[/]"); + } + + private async Task GenerateSolution(string projectName) + { + AnsiConsole.MarkupLine($"[green]{Strings.ProjectTemplates.CreatingSln}[/]"); + + // Create the sln + _ = await AppTools.RunProcessCommand("dotnet", $"new sln -n {projectName} -o {OutputPath} --force", cancellationToken: CancellationToken); + + //Now add to the new sln + var slnFilePath = Path.Combine(OutputPath!, projectName + ".sln"); + + string? searchWildCard; + switch (Language) + { + case "C#": + searchWildCard = "*.csproj"; + break; + case "F#": + searchWildCard = "*.fsproj"; + break; + case "VB": + searchWildCard = "*.vbproj"; + break; + default: + searchWildCard = "*.csproj"; + break; + } + + // get all the project files and add them to the sln + var projectFiles = Directory.EnumerateFiles(OutputPath!, searchWildCard, SearchOption.AllDirectories); + foreach (var projectFile in projectFiles) + { + _ = await AppTools.RunProcessCommand("dotnet", $"sln {slnFilePath} add {projectFile}", cancellationToken: CancellationToken); + } + + await OpenSolution(slnFilePath); + } + + private MeadowTemplate? PopulateTemplateNameList(List templateList, List templateNameList, MeadowTemplate? startKitGroup, List startKitTemplates) + { + // Use regex to split each line into segments using two or more spaces as the separator + var regexTemplateLines = new Regex(@"\s{2,}"); + + foreach (var templatesLine in templateList) + { + // Isolate the long and short names + var segments = regexTemplateLines.Split(templatesLine.Trim()); + if (segments.Length >= 2) + { + // Add Key Value of Long Name and Short Name + var longName = segments[0].Trim(); + var shortName = segments[1].Trim(); + var languages = segments[2].Replace("[", string.Empty).Replace("]", string.Empty).Trim(); + + templateNameList.Add(new MeadowTemplate($"{longName} ({languages})", shortName)); + } + } + + return startKitGroup; + } + + internal static async Task?> GetInstalledTemplates(ILoggerFactory loggerFactory, CliFx.Infrastructure.IConsole console, CancellationToken cancellationToken) + { + var templateTable = new List(); + + // Get the list of Meadow project templates + var exitCode = await AppTools.RunProcessCommand("dotnet", "new list Meadow", handleOutput: outputLogLine => + { + // Ignore empty output + if (!string.IsNullOrWhiteSpace(outputLogLine)) + { + templateTable.Add(outputLogLine); + } + }, cancellationToken: cancellationToken); + + + if (exitCode == 0) + { + if (templateTable.Count == 0) + { + AnsiConsole.MarkupLine($"[yellow]{Strings.ProjectTemplates.NoTemplatesFound}[/]"); + + // Let's install the templates then + var projectInstallCommand = new ProjectInstallCommand(loggerFactory); + await projectInstallCommand.ExecuteAsync(console); + + // Try to populate the templateTable again, after installing the templates + exitCode = await AppTools.RunProcessCommand("dotnet", "new list Meadow", handleOutput: outputLogLine => + { + // Ignore empty output + if (!string.IsNullOrWhiteSpace(outputLogLine)) + { + templateTable.Add(outputLogLine); + } + }, cancellationToken: cancellationToken); + } + + // Extract template names from the output + var templateNameList = templateTable + .Skip(4) // Skip the header information + .Where(line => !string.IsNullOrWhiteSpace(line)) // Avoid empty lines + .Select(line => line.Trim()) // Clean whitespace + .ToList(); + + return templateNameList; + } + + return null; + } + + private async Task OpenSolution(string solutionPath) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + var exitCode = await AppTools.RunProcessCommand("cmd", $"/c start {solutionPath}"); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) + || RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + var exitCode = await AppTools.RunProcessCommand("code", Path.GetDirectoryName(solutionPath)); + } + else + { + Logger?.LogError(Strings.UnsupportedOperatingSystem); + } + } + } +} \ No newline at end of file diff --git a/Source/v2/Meadow.Cli/Commands/Current/BaseCommand.cs b/Source/v2/Meadow.Cli/Commands/Current/BaseCommand.cs index 0ac35fde..9553d974 100644 --- a/Source/v2/Meadow.Cli/Commands/Current/BaseCommand.cs +++ b/Source/v2/Meadow.Cli/Commands/Current/BaseCommand.cs @@ -36,7 +36,7 @@ public async ValueTask ExecuteAsync(IConsole console) { if (MeadowTelemetry.Current.ShouldAskForConsent) { - AnsiConsole.MarkupLine(Strings.Telemetry.ConsentMessage); + AnsiConsole.MarkupLine(Strings.Telemetry.ConsentMessage, "[bold]meadow telemetry [[enable|disable]][/]", $"[bold]{MeadowTelemetry.TelemetryEnvironmentVariable}[/]"); var result = AnsiConsole.Confirm(Strings.Telemetry.AskToParticipate, defaultValue: true); MeadowTelemetry.Current.SetTelemetryEnabled(result); diff --git a/Source/v2/Meadow.Cli/Strings.cs b/Source/v2/Meadow.Cli/Strings.cs index 4a0fb946..43dbfc18 100644 --- a/Source/v2/Meadow.Cli/Strings.cs +++ b/Source/v2/Meadow.Cli/Strings.cs @@ -1,4 +1,5 @@ using Meadow.Telemetry; +using Spectre.Console; namespace Meadow.CLI; @@ -70,21 +71,49 @@ public static class Strings public const string AppDeployFailed = "Application deploy failed"; public const string AppDeployedSuccessfully = "Application deployed successfully"; public const string AppTrimFailed = "Application trimming failed"; - public const string WithConfiguration = "with configuration"; - public const string At = "at"; + public const string WithConfiguration = "with configuration"; + public const string At = "at"; - public static class Telemetry + public static class Telemetry { - public const string ConsentMessage = @$" + public const string ConsentMessage = @" Let's improve the Meadow experience together -------------------------------------------- To help improve the Meadow experience, we'd like to collect anonymous usage data. This data helps us understand how our tools are used, so we can make them better for everyone. This usage data is not tied to individuals and no personally identifiable information is collected. Our privacy policy is available at https://www.wildernesslabs.co/privacy-policy. -You can change your mind at any time by running the ""[bold]meadow telemetry [[enable|disable]][/]"" command or by setting the [bold]{MeadowTelemetry.TelemetryEnvironmentVariable}[/] environment variable to '1' or '0' ('true' or 'false', respectively). +You can change your mind at any time by running the ""{0}"" command or by setting the {1} environment variable to '1' or '0' ('true' or 'false', respectively). "; - public const string AskToParticipate = "Would you like to participate?"; } + + public static class ProjectTemplates + { + public const string InstallTitle = "Installing and updating the Meadow Project Templates"; + public const string Installed = "The following Meadow Project Templates are now installed."; + + public const string WizardTitle = "Meadow Project Wizard..."; + public const string ProjectName = "What is your project's name?"; + public const string GenerationComplete = "All of {0}'s projects have now been created!"; + public const string GenerateSln = "Create an sln that contains all the new projects?"; + public const string NoTemplatesFound = "No Meadow project templates found."; + public const string InstalledTemplates = "--- Installed Templates ---"; + public const string MoreChoicesInstructions = "(Move up and down to reveal more templates)"; + public const string Instructions = "Press {0} to toggle a template or {1} to accept and generate the selected templates"; + public const string CreatingProject = "Creating {0} project"; + public const string CreatingSln = "Creating sln for selected projects...."; + public const string NoTemplateSelected = "No templates selected."; + public const string NewCommandDescription = "Shows a list of project templates to choose from."; + public const string CommandOptionOutputPathDescription = "The path where you would like the projects created."; + public const string CommandOptionSupportedLanguagesDescription = "Generate the projects using the specified language. Valid values are C# or F# or VB.NET"; + public const string InstallCommandDescription = "Install or updates to the latest project templates"; + public const string ErrorInstallingTemplates = "An error occured install the templates. Check that your internet connection is working."; + public const string ColumnTemplateName = "Template Name"; + public const string ColumnLanguages = "Languages"; + } + + public const string UnsupportedOperatingSystem = "Unsupported Operating System"; + public const string Enter = "Enter"; + public const string Space = "Space"; } \ No newline at end of file