diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..944d978 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +bin/ +obj/ +.ionide/ +project.lock.json +*-tests.xml +/debug/ +/staging/ +/Packages/ +*.nuget.props + +# VSCode directories that are not at the repository root +/**/.vscode/ + +# Ignore binaries and symbols +*.pdb +*.dll + diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..fb39dbf --- /dev/null +++ b/build.ps1 @@ -0,0 +1,22 @@ +[CmdletBinding(DefaultParameterSetName = 'Build')] +param( + [Parameter(ParameterSetName = 'Build')] + [ValidateSet('Debug', 'Release')] + [string] $Configuration = 'Debug', + + [Parameter(ParameterSetName = 'Bootstrap')] + [switch] $Bootstrap +) + +Import-Module "$PSScriptRoot/tools/helper.psm1" + +if ($Bootstrap) { + Write-Log "Validate and install missing prerequisits for building ..." + Install-Dotnet + return +} + +$srcDir = Join-Path $PSScriptRoot 'src' +dotnet publish -c $Configuration $srcDir + +Write-Host "`nThe module 'command-not-found' is published to 'bin\command-not-found'`n" -ForegroundColor Green diff --git a/src/FeedbackProvider.cs b/src/FeedbackProvider.cs new file mode 100644 index 0000000..24127a5 --- /dev/null +++ b/src/FeedbackProvider.cs @@ -0,0 +1,189 @@ +using System.Diagnostics; +using System.Management.Automation; +using System.Management.Automation.Subsystem; +using System.Management.Automation.Subsystem.Feedback; +using System.Management.Automation.Subsystem.Prediction; + +namespace Microsoft.PowerShell.FeedbackProvider; + +public sealed class UnixCommandNotFound : IFeedbackProvider, ICommandPredictor +{ + private readonly Guid _guid; + private List? _candidates; + + internal UnixCommandNotFound(string guid) + { + _guid = new Guid(guid); + } + + Dictionary? ISubsystem.FunctionsToDefine => null; + + public Guid Id => _guid; + + public string Name => "cmd-not-found"; + + public string Description => "The built-in feedback/prediction source for the Unix command utility."; + + #region IFeedbackProvider + + private static string? GetUtilityPath() + { + string cmd_not_found = "/usr/lib/command-not-found"; + bool exist = IsFileExecutable(cmd_not_found); + + if (!exist) + { + cmd_not_found = "/usr/share/command-not-found/command-not-found"; + exist = IsFileExecutable(cmd_not_found); + } + + return exist ? cmd_not_found : null; + + static bool IsFileExecutable(string path) + { + var file = new FileInfo(path); + return file.Exists && file.UnixFileMode.HasFlag(UnixFileMode.OtherExecute); + } + } + + public FeedbackItem? GetFeedback(string commandLine, ErrorRecord lastError, CancellationToken token) + { + if (Platform.IsWindows || lastError.FullyQualifiedErrorId != "CommandNotFoundException") + { + return null; + } + + var target = (string)lastError.TargetObject; + if (target is null) + { + return null; + } + + if (target.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + string? cmd_not_found = GetUtilityPath(); + if (cmd_not_found is not null) + { + var startInfo = new ProcessStartInfo(cmd_not_found); + startInfo.ArgumentList.Add(target); + startInfo.RedirectStandardError = true; + startInfo.RedirectStandardOutput = true; + + using var process = Process.Start(startInfo); + if (process is not null) + { + string? header = null; + List? actions = null; + + while (true) + { + string? line = process.StandardError.ReadLine(); + if (line is null) + { + break; + } + + if (line == string.Empty) + { + continue; + } + + if (line.StartsWith("sudo ", StringComparison.Ordinal)) + { + actions ??= new List(); + actions.Add(line.TrimEnd()); + } + else if (actions is null) + { + header = line; + } + } + + if (actions is not null && header is not null) + { + _candidates = actions; + + var footer = process.StandardOutput.ReadToEnd().Trim(); + return string.IsNullOrEmpty(footer) + ? new FeedbackItem(header, actions) + : new FeedbackItem(header, actions, footer, FeedbackDisplayLayout.Portrait); + } + } + } + + return null; + } + + #endregion + + #region ICommandPredictor + + public bool CanAcceptFeedback(PredictionClient client, PredictorFeedbackKind feedback) + { + return feedback switch + { + PredictorFeedbackKind.CommandLineAccepted => true, + _ => false, + }; + } + + public SuggestionPackage GetSuggestion(PredictionClient client, PredictionContext context, CancellationToken cancellationToken) + { + if (_candidates is not null) + { + string input = context.InputAst.Extent.Text; + List? result = null; + + foreach (string c in _candidates) + { + if (c.StartsWith(input, StringComparison.OrdinalIgnoreCase)) + { + result ??= new List(_candidates.Count); + result.Add(new PredictiveSuggestion(c)); + } + } + + if (result is not null) + { + return new SuggestionPackage(result); + } + } + + return default; + } + + public void OnCommandLineAccepted(PredictionClient client, IReadOnlyList history) + { + // Reset the candidate state. + _candidates = null; + } + + public void OnSuggestionDisplayed(PredictionClient client, uint session, int countOrIndex) { } + + public void OnSuggestionAccepted(PredictionClient client, uint session, string acceptedSuggestion) { } + + public void OnCommandLineExecuted(PredictionClient client, string commandLine, bool success) { } + + #endregion; +} + +public class Init : IModuleAssemblyInitializer, IModuleAssemblyCleanup +{ + private const string Id = "47013747-CB9D-4EBC-9F02-F32B8AB19D48"; + + public void OnImport() + { + var feedback = new UnixCommandNotFound(Id); + SubsystemManager.RegisterSubsystem(SubsystemKind.FeedbackProvider, feedback); + SubsystemManager.RegisterSubsystem(SubsystemKind.CommandPredictor, feedback); + } + + public void OnRemove(PSModuleInfo psModuleInfo) + { + SubsystemManager.UnregisterSubsystem(new Guid(Id)); + SubsystemManager.UnregisterSubsystem(new Guid(Id)); + } +} diff --git a/src/PowerShell.CommandNotFound.Feedback.csproj b/src/PowerShell.CommandNotFound.Feedback.csproj new file mode 100644 index 0000000..c0d8e9f --- /dev/null +++ b/src/PowerShell.CommandNotFound.Feedback.csproj @@ -0,0 +1,33 @@ + + + + net8.0 + enable + enable + true + + + false + + + ..\bin\command-not-found + + + + + false + None + + + + + contentFiles + All + + + PreserveNewest + PreserveNewest + + + + diff --git a/src/ValidateOS.psm1 b/src/ValidateOS.psm1 new file mode 100644 index 0000000..b11e6a3 --- /dev/null +++ b/src/ValidateOS.psm1 @@ -0,0 +1,14 @@ +function Test-Utility([string] $path) { + $target = [System.IO.FileInfo]::new($path) + $mode = [System.IO.UnixFileMode]@('OtherExecute', 'GroupExecute', 'UserExecute') + $target.Exists -and $target.UnixFileMode.HasFlag($mode) +} + +if (!$IsLinux -or ( + !(Test-Utility "/usr/lib/command-not-found") -and + !(Test-Utility "/usr/share/command-not-found/command-not-found"))) { + $exception = [System.PlatformNotSupportedException]::new( + "This module only works on Linux and depends on the utility 'command-not-found' to be available under the folder '/usr/lib' or '/usr/share/command-not-found'.") + $err = [System.Management.Automation.ErrorRecord]::new($exception, "PlatformNotSupported", "InvalidOperation", $null) + throw $err +} diff --git a/src/command-not-found.psd1 b/src/command-not-found.psd1 new file mode 100644 index 0000000..718a292 --- /dev/null +++ b/src/command-not-found.psd1 @@ -0,0 +1,19 @@ +# +# Module manifest for module 'command-not-found' +# + +@{ + ModuleVersion = '0.1.0' + GUID = '47013747-CB9D-4EBC-9F02-F32B8AB19D48' + Author = 'PowerShell' + CompanyName = "Microsoft Corporation" + Copyright = "Copyright (c) Microsoft Corporation." + Description = "Provide feedback on the 'CommandNotFound' error stemmed from running an executable on Linux platform." + PowerShellVersion = '7.4' + + NestedModules = @('ValidateOS.psm1', 'PowerShell.CommandNotFound.Feedback.dll') + FunctionsToExport = @() + CmdletsToExport = @() + VariablesToExport = '*' + AliasesToExport = @() +} diff --git a/tools/helper.psm1 b/tools/helper.psm1 new file mode 100644 index 0000000..c2cca5e --- /dev/null +++ b/tools/helper.psm1 @@ -0,0 +1,105 @@ +$MinimalSDKVersion = '8.0.100' +$IsWindowsEnv = [System.Environment]::OSVersion.Platform -eq "Win32NT" +$LocalDotnetDirPath = if ($IsWindowsEnv) { "$env:LocalAppData\Microsoft\dotnet" } else { "$env:HOME/.dotnet" } + +<# +.SYNOPSIS + Find the dotnet SDK that meets the minimal version requirement. +#> +function Find-Dotnet +{ + $dotnetFile = if ($IsWindowsEnv) { "dotnet.exe" } else { "dotnet" } + $dotnetExePath = Join-Path -Path $LocalDotnetDirPath -ChildPath $dotnetFile + + # If dotnet is already in the PATH, check to see if that version of dotnet can find the required SDK. + # This is "typically" the globally installed dotnet. + $foundDotnetWithRightVersion = $false + $dotnetInPath = Get-Command 'dotnet' -ErrorAction Ignore + if ($dotnetInPath) { + $foundDotnetWithRightVersion = Test-DotnetSDK $dotnetInPath.Source + } + + if (-not $foundDotnetWithRightVersion) { + if (Test-DotnetSDK $dotnetExePath) { + Write-Warning "Can't find the dotnet SDK version $MinimalSDKVersion or higher, prepending '$LocalDotnetDirPath' to PATH." + $env:PATH = $LocalDotnetDirPath + [IO.Path]::PathSeparator + $env:PATH + } + else { + throw "Cannot find the dotnet SDK with the version $MinimalSDKVersion or higher. Please specify '-Bootstrap' to install build dependencies." + } + } +} + +<# +.SYNOPSIS + Check if the dotnet SDK meets the minimal version requirement. +#> +function Test-DotnetSDK +{ + param($dotnetExePath) + + if (Test-Path $dotnetExePath) { + $installedVersion = & $dotnetExePath --version + return $installedVersion -ge $MinimalSDKVersion + } + return $false +} + +<# +.SYNOPSIS + Install the dotnet SDK if we cannot find an existing one. +#> +function Install-Dotnet +{ + [CmdletBinding()] + param( + [string]$Channel = 'release', + [string]$Version = $MinimalSDKVersion + ) + + try { + Find-Dotnet + return # Simply return if we find dotnet SDk with the correct version + } catch { } + + $logMsg = if (Get-Command 'dotnet' -ErrorAction Ignore) { + "dotnet SDK out of date. Require '$MinimalSDKVersion' but found '$dotnetSDKVersion'. Updating dotnet." + } else { + "dotent SDK is not present. Installing dotnet SDK." + } + Write-Log $logMsg -Warning + + $obtainUrl = "https://raw.githubusercontent.com/dotnet/cli/master/scripts/obtain" + + try { + Remove-Item $LocalDotnetDirPath -Recurse -Force -ErrorAction Ignore + $installScript = if ($IsWindowsEnv) { "dotnet-install.ps1" } else { "dotnet-install.sh" } + Invoke-WebRequest -Uri $obtainUrl/$installScript -OutFile $installScript + + if ($IsWindowsEnv) { + & .\$installScript -Channel $Channel -Version $Version + } else { + bash ./$installScript -c $Channel -v $Version + } + } + finally { + Remove-Item $installScript -Force -ErrorAction Ignore + } +} + +<# +.SYNOPSIS + Write log message for the build. +#> +function Write-Log +{ + param( + [string] $Message, + [switch] $Warning, + [switch] $Indent + ) + + $foregroundColor = if ($Warning) { "Yellow" } else { "Green" } + $indentPrefix = if ($Indent) { " " } else { "" } + Write-Host -ForegroundColor $foregroundColor "${indentPrefix}${Message}" +}