Sorry, there's nothing at this address.
8 |Blazor WASM loading..
20 |Returns from the current scope.
19 | 20 | 21 | '@ 22 | 23 | Get-SynopsisFromHtml -Html $html -Cmd 'return' | Should -Be 'Returns from the current scope.' 24 | } 25 | 26 | It "Extracts synopsis from meta description" { 27 | $html = @' 28 | 29 | 30 | 31 | 32 | 33 |Throws an exception.
48 | 49 | 50 | '@ 51 | 52 | Get-SynopsisFromHtml -Html $html -Cmd 'throw' | Should -Be 'Throws an exception.' 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /explainpowershell.analysisservice.tests/helpers/Helpers.Logger.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using Microsoft.Extensions.Logging; 5 | 6 | namespace ExplainPowershell.SyntaxAnalyzer.Tests 7 | { 8 | public class LoggerDouble(requestBody, new JsonSerializerOptions
44 | {
45 | PropertyNameCaseInsensitive = true
46 | });
47 | }
48 | catch (Exception e)
49 | {
50 | logger.LogError(e, "Failed to deserialize SyntaxAnalyzer request");
51 | return CreateResponse(req, HttpStatusCode.BadRequest, "Invalid request format. Pass powershell code in the request body for an AST analysis.");
52 | }
53 |
54 | var code = request?.PowershellCode ?? string.Empty;
55 |
56 | logger.LogInformation("PowerShell code sent: {Code}", code);
57 |
58 | ScriptBlockAst ast = Parser.ParseInput(code, out Token[] tokens, out ParseError[] parseErrors);
59 |
60 | if (string.IsNullOrEmpty(ast.Extent.Text))
61 | {
62 | return CreateResponse(req, HttpStatusCode.BadRequest, "Empty request. Pass powershell code in the request body for an AST analysis.");
63 | }
64 |
65 | AnalysisResult analysisResult;
66 | try
67 | {
68 | var visitor = new AstVisitorExplainer(ast.Extent.Text, helpRepository: helpRepository, logger, tokens);
69 | ast.Visit(visitor);
70 | analysisResult = visitor.GetAnalysisResult();
71 | }
72 | catch (Exception e)
73 | {
74 | logger.LogError(e, "An error occurred while analyzing the AST");
75 | return CreateResponse(req, HttpStatusCode.InternalServerError, "Oops, someting went wrong internally. Please file an issue with the PowerShell code you submitted when this occurred.");
76 | }
77 |
78 | analysisResult.ParseErrorMessage = string.IsNullOrEmpty(analysisResult.ParseErrorMessage)
79 | ? parseErrors?.FirstOrDefault()?.Message ?? string.Empty
80 | : (analysisResult.ParseErrorMessage + "\n" + parseErrors?.FirstOrDefault()?.Message) ?? string.Empty;
81 |
82 | analysisResult.AiExplanation = string.Empty;
83 |
84 | var json = System.Text.Json.JsonSerializer.Serialize(analysisResult);
85 | return CreateResponse(req, HttpStatusCode.OK, json, "application/json");
86 | }
87 |
88 | private static HttpResponseData CreateResponse(HttpRequestData req, HttpStatusCode status, string message, string mediaType = "text/plain")
89 | {
90 | var response = req.CreateResponse(status);
91 | response.Headers.Add("Content-Type", mediaType);
92 | response.WriteString(message, Encoding.UTF8);
93 | return response;
94 | }
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/explainpowershell.analysisservice/AiExplanationFunction.cs:
--------------------------------------------------------------------------------
1 | using System.Net;
2 | using System.Text;
3 | using System.Text.Json;
4 | using explainpowershell.analysisservice.Services;
5 | using explainpowershell.models;
6 | using Microsoft.Azure.Functions.Worker;
7 | using Microsoft.Azure.Functions.Worker.Http;
8 | using Microsoft.Extensions.Logging;
9 |
10 | namespace ExplainPowershell.SyntaxAnalyzer
11 | {
12 | public sealed class AiExplanationFunction
13 | {
14 | private readonly ILogger logger;
15 | private readonly IAiExplanationService aiExplanationService;
16 |
17 | public AiExplanationFunction(ILogger logger, IAiExplanationService aiExplanationService)
18 | {
19 | this.logger = logger;
20 | this.aiExplanationService = aiExplanationService;
21 | }
22 |
23 | [Function("AiExplanation")]
24 | public async Task Run(
25 | [HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequestData req)
26 | {
27 | string requestBody;
28 | using (var reader = new StreamReader(req.Body))
29 | {
30 | requestBody = await reader.ReadToEndAsync().ConfigureAwait(false);
31 | }
32 |
33 | if (string.IsNullOrEmpty(requestBody))
34 | {
35 | return CreateResponse(req, HttpStatusCode.BadRequest, "Empty request. Pass an AnalysisResult with PowerShell code in the request body.");
36 | }
37 |
38 | AiExplanationRequest? request;
39 | try
40 | {
41 | var options = new JsonSerializerOptions
42 | {
43 | PropertyNameCaseInsensitive = true
44 | };
45 | request = JsonSerializer.Deserialize(requestBody, options);
46 | }
47 | catch (Exception e)
48 | {
49 | logger.LogError(e, "Failed to deserialize AI explanation request");
50 | return CreateResponse(req, HttpStatusCode.BadRequest, "Invalid request format.");
51 | }
52 |
53 | if (request == null || string.IsNullOrEmpty(request.PowershellCode) || request.AnalysisResult == null)
54 | {
55 | return CreateResponse(req, HttpStatusCode.BadRequest, "Request must contain PowershellCode and AnalysisResult.");
56 | }
57 |
58 | logger.LogInformation("AI explanation requested for code: {Code}", request.PowershellCode);
59 |
60 | (string? aiExplanation, string? modelName) result;
61 | try
62 | {
63 | result = await aiExplanationService.GenerateAsync(request.PowershellCode, request.AnalysisResult).ConfigureAwait(false);
64 | }
65 | catch (Exception e)
66 | {
67 | logger.LogError(e, "An error occurred while generating AI explanation");
68 | return CreateResponse(req, HttpStatusCode.InternalServerError, "Failed to generate AI explanation.");
69 | }
70 |
71 | var response = new AiExplanationResponse
72 | {
73 | AiExplanation = result.aiExplanation ?? string.Empty,
74 | ModelName = result.modelName
75 | };
76 |
77 | var json = JsonSerializer.Serialize(response);
78 | return CreateResponse(req, HttpStatusCode.OK, json, "application/json");
79 | }
80 |
81 | private static HttpResponseData CreateResponse(HttpRequestData req, HttpStatusCode status, string message, string mediaType = "text/plain")
82 | {
83 | var response = req.CreateResponse(status);
84 | response.Headers.Add("Content-Type", mediaType);
85 | response.WriteString(message, Encoding.UTF8);
86 | return response;
87 | }
88 | }
89 |
90 | public class AiExplanationRequest
91 | {
92 | public string PowershellCode { get; set; } = string.Empty;
93 | public AnalysisResult AnalysisResult { get; set; } = new();
94 | }
95 |
96 | public class AiExplanationResponse
97 | {
98 | public string AiExplanation { get; set; } = string.Empty;
99 | public string? ModelName { get; set; }
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/explainpowershell.analysisservice.tests/Invoke-AiExplanation.ps1:
--------------------------------------------------------------------------------
1 | # Ensure we use the repo-local (optimized) helper implementation.
2 | # This avoids accidentally calling a stale `Invoke-SyntaxAnalyzer` already loaded in the caller's session.
3 | $invokeSyntaxAnalyzerPath = Join-Path -Path $PSScriptRoot -ChildPath 'Invoke-SyntaxAnalyzer.ps1'
4 | if (Test-Path -LiteralPath $invokeSyntaxAnalyzerPath) {
5 | . $invokeSyntaxAnalyzerPath
6 | }
7 |
8 | function Invoke-AiExplanation {
9 | <#
10 | .SYNOPSIS
11 | Invokes the Analysis Service "AiExplanation" endpoint for a given PowerShell snippet.
12 |
13 | .DESCRIPTION
14 | Sends PowerShell code (and optionally an existing analysis result) to the local Analysis Service.
15 | If -AnalysisResult is not provided, this function first obtains one by calling Invoke-SyntaxAnalyzer
16 | when available, otherwise it calls the SyntaxAnalyzer HTTP endpoint directly.
17 |
18 | By default, the function returns the raw web response. Use -AsObject to return the deserialized JSON
19 | payload, or -AiExplanation to return only the AiExplanation string.
20 |
21 | .PARAMETER PowershellCode
22 | The PowerShell code to analyze/explain.
23 |
24 | .PARAMETER AnalysisResult
25 | An existing analysis result object (typically the deserialized output of the SyntaxAnalyzer endpoint).
26 | When provided, the syntax analysis step is skipped.
27 |
28 | .PARAMETER BaseUri
29 | The base URI of the Analysis Service Functions host.
30 |
31 | .PARAMETER TimeoutSec
32 | The request timeout in seconds.
33 |
34 | .PARAMETER AsObject
35 | Return the response content as a PowerShell object (ConvertFrom-Json).
36 |
37 | .PARAMETER AiExplanation
38 | Return only the AiExplanation property from the response.
39 |
40 | .OUTPUTS
41 | When neither -AsObject nor -AiExplanation is specified, returns the Invoke-WebRequest response object.
42 | When -AsObject is specified, returns the deserialized JSON object.
43 | When -AiExplanation is specified, returns a string.
44 |
45 | .EXAMPLE
46 | Invoke-AiExplanation -PowershellCode 'Get-Process | Select-Object -First 1'
47 |
48 | .EXAMPLE
49 | Invoke-AiExplanation -PowershellCode 'Get-Date' -AiExplanation
50 |
51 | .EXAMPLE
52 | $analysis = (Invoke-SyntaxAnalyzer -PowershellCode 'Get-ChildItem').Content | ConvertFrom-Json
53 | Invoke-AiExplanation -PowershellCode 'Get-ChildItem' -AnalysisResult $analysis -AsObject
54 | #>
55 | param(
56 | [Parameter(Mandatory)]
57 | [string]$PowershellCode,
58 |
59 | [Parameter()]
60 | [object]$AnalysisResult,
61 |
62 | [Parameter()]
63 | [string]$BaseUri = 'http://127.0.0.1:7071/api',
64 |
65 | [Parameter()]
66 | [int]$TimeoutSec = 30,
67 |
68 | [Parameter()]
69 | [switch]$AsObject,
70 |
71 | [Parameter()]
72 | [switch]$AiExplanation
73 | )
74 |
75 | $ErrorActionPreference = 'stop'
76 |
77 | if (-not $AnalysisResult) {
78 | if (Get-Command -Name Invoke-SyntaxAnalyzer -ErrorAction SilentlyContinue) {
79 | $analysisResponse = Invoke-SyntaxAnalyzer -PowershellCode $PowershellCode -BaseUri $BaseUri -TimeoutSec $TimeoutSec
80 | $AnalysisResult = $analysisResponse.Content | ConvertFrom-Json
81 | }
82 | else {
83 | $analysisBody = @{ PowershellCode = $PowershellCode } | ConvertTo-Json
84 |
85 | $analysisResponse = Invoke-WebRequest -Uri "$BaseUri/SyntaxAnalyzer" -Method Post -Body $analysisBody -ContentType 'application/json' -TimeoutSec $TimeoutSec
86 | $AnalysisResult = $analysisResponse.Content | ConvertFrom-Json
87 | }
88 | }
89 |
90 | $body = @{
91 | PowershellCode = $PowershellCode
92 | AnalysisResult = $AnalysisResult
93 | } | ConvertTo-Json -Depth 20
94 |
95 | # Note: the function route is `AiExplanation`, but the Functions host is case-insensitive.
96 |
97 | $response = Invoke-WebRequest -Uri "$BaseUri/aiexplanation" -Method Post -Body $body -ContentType 'application/json' -TimeoutSec $TimeoutSec
98 |
99 | if ($AsObject -or $AiExplanation) {
100 | $result = $response.Content | ConvertFrom-Json
101 |
102 | if ($AiExplanation) {
103 | return $result.AiExplanation
104 | }
105 |
106 | return $result
107 | }
108 |
109 | return $response
110 | }
111 |
--------------------------------------------------------------------------------
/explainpowershell.analysisservice.tests/Get-HelpDatabaseData.ps1:
--------------------------------------------------------------------------------
1 | using namespace Microsoft.Azure.Cosmos.Table
2 |
3 | <#
4 | .SYNOPSIS
5 | Gets help data from the HelpData Azure Table (local Azurite or production).
6 |
7 | .DESCRIPTION
8 | Retrieves entities from the HelpData table in the CommandHelp partition.
9 |
10 | By default, the function uses a local Azurite Table endpoint via a connection string.
11 | When -IsProduction is specified, it creates a Storage context using a SAS token and queries
12 | the production Storage Account.
13 |
14 | If -ReturnTable is specified, the underlying CloudTable object is returned instead of data.
15 |
16 | .PARAMETER RowKey
17 | Optional RowKey to filter for a single entity. When omitted, all entities in the CommandHelp
18 | partition are returned.
19 |
20 | .PARAMETER ReturnTable
21 | Returns the underlying CloudTable object for the HelpData table.
22 |
23 | .PARAMETER IsProduction
24 | Uses the production Storage Account instead of the local Azurite Table endpoint.
25 |
26 | .PARAMETER StorageAccountName
27 | Production-only. The Azure Storage Account name.
28 |
29 | .PARAMETER ResourceGroupName
30 | Production-only. The Azure Resource Group name containing the Storage Account.
31 |
32 | .EXAMPLE
33 | Get-HelpDatabaseData
34 |
35 | Returns all entities in the CommandHelp partition from the local Azurite table.
36 |
37 | .EXAMPLE
38 | Get-HelpDatabaseData -RowKey 'Get-Process'
39 |
40 | Returns the entity with RowKey 'Get-Process' from the local Azurite table.
41 |
42 | .EXAMPLE
43 | Get-HelpDatabaseData -IsProduction -RowKey 'Get-Process' -ResourceGroupName 'powershellexplainer' -StorageAccountName 'explainpowershell'
44 |
45 | Returns the entity with RowKey 'Get-Process' from the production table.
46 |
47 | .EXAMPLE
48 | $table = Get-HelpDatabaseData -ReturnTable
49 |
50 | Returns the CloudTable object.
51 |
52 | .OUTPUTS
53 | Microsoft.Azure.Cosmos.Table.DynamicTableEntity[]
54 | Microsoft.Azure.Cosmos.Table.CloudTable
55 | #>
56 | function Get-HelpDatabaseData {
57 | [CmdletBinding(DefaultParameterSetName = 'local')]
58 | param(
59 | [parameter(ParameterSetName = 'local', Position = '0')]
60 | [parameter(ParameterSetName = 'production', Position = '0')]
61 | [string]$RowKey,
62 |
63 | [parameter(ParameterSetName = 'local')]
64 | [parameter(ParameterSetName = 'production')]
65 | [switch]$ReturnTable,
66 |
67 | [parameter(ParameterSetName = 'production')]
68 | [switch]$IsProduction,
69 |
70 | [parameter(ParameterSetName = 'production')]
71 | [String]$StorageAccountName = 'explainpowershell',
72 |
73 | [parameter(ParameterSetName = 'production')]
74 | [String]$ResourceGroupName = 'powershellexplainer'
75 | )
76 |
77 | $tableName = 'HelpData'
78 | $partitionKey = 'CommandHelp'
79 |
80 | if ($IsProduction) {
81 | . /workspace/explainpowershell.helpcollector/New-SasToken.ps1
82 | $sasToken = New-SasToken -ResourceGroupName $ResourceGroupName -StorageAccountName $storageAccountName
83 | $storageCtx = New-AzStorageContext -StorageAccountName $storageAccountName -SasToken $sasToken
84 | }
85 | else {
86 | $azuriteLocalConnectionString = 'AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;DefaultEndpointsProtocol=http;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;QueueEndpoint=http://127.0.0.1:10001/devstoreaccount1;TableEndpoint=http://127.0.0.1:10002/devstoreaccount1;'
87 | $storageCtx = New-AzStorageContext -ConnectionString $azuriteLocalConnectionString
88 | }
89 |
90 | $table = (Get-AzStorageTable -Context $storageCtx -Name $tableName).CloudTable
91 | if ($ReturnTable) {
92 | return $table
93 | }
94 | elseif (-not $rowKey) {
95 | $query = [TableQuery]@{
96 | FilterString = "PartitionKey eq '$partitionKey'"
97 | }
98 |
99 | return $table.ExecuteQuery($query)
100 | }
101 | else {
102 | $query = [TableQuery]@{
103 | FilterString = [TableQuery]::CombineFilters(
104 | [TableQuery]::GenerateFilterCondition(
105 | 'PartitionKey',
106 | [QueryComparisons]::Equal,
107 | $partitionKey
108 | ),
109 | 'and',
110 | [TableQuery]::GenerateFilterCondition(
111 | 'RowKey',
112 | [QueryComparisons]::Equal,
113 | $rowKey
114 | )
115 | )
116 | }
117 |
118 | return $table.ExecuteQuery($query)
119 | }
120 | }
121 |
--------------------------------------------------------------------------------
/explainpowershell.azureinfra/template.bicep:
--------------------------------------------------------------------------------
1 | param functionAppName string
2 | param appServicePlanName string
3 | param storageAccountName string
4 | param location string = resourceGroup().location
5 |
6 | resource storageAccount 'Microsoft.Storage/storageAccounts@2023-01-01' = {
7 | name: storageAccountName
8 | location: location
9 | sku: {
10 | name: 'Standard_LRS'
11 | }
12 | kind: 'StorageV2'
13 | properties: {
14 | minimumTlsVersion: 'TLS1_2'
15 | allowBlobPublicAccess: true
16 | allowSharedKeyAccess: true
17 | supportsHttpsTrafficOnly: true
18 | encryption: {
19 | services: {
20 | file: {
21 | keyType: 'Account'
22 | enabled: true
23 | }
24 | blob: {
25 | keyType: 'Account'
26 | enabled: true
27 | }
28 | }
29 | keySource: 'Microsoft.Storage'
30 | }
31 | accessTier: 'Hot'
32 | }
33 | }
34 |
35 | resource storageBlobService 'Microsoft.Storage/storageAccounts/blobServices@2023-01-01' = {
36 | name: 'default'
37 | parent: storageAccount
38 | properties: {
39 | cors: {
40 | corsRules: []
41 | }
42 | staticWebsite: {
43 | enabled: true
44 | indexDocument: 'index.html'
45 | errorDocument404Path: 'index.html'
46 | }
47 | }
48 | }
49 |
50 | resource storageQueueService 'Microsoft.Storage/storageAccounts/queueServices@2023-01-01' = {
51 | name: 'default'
52 | parent: storageAccount
53 | properties: {
54 | cors: {
55 | corsRules: []
56 | }
57 | }
58 | }
59 |
60 | resource storageTableService 'Microsoft.Storage/storageAccounts/tableServices@2023-01-01' = {
61 | name: 'default'
62 | parent: storageAccount
63 | properties: {
64 | cors: {
65 | corsRules: []
66 | }
67 | }
68 | }
69 |
70 | resource webContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-01-01' = {
71 | name: '$web'
72 | parent: storageBlobService
73 | properties: {
74 | publicAccess: 'Blob'
75 | }
76 | }
77 |
78 | resource webjobsHostsContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-01-01' = {
79 | name: 'azure-webjobs-hosts'
80 | parent: storageBlobService
81 | properties: {
82 | publicAccess: 'None'
83 | }
84 | }
85 |
86 | resource webjobsSecretsContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-01-01' = {
87 | name: 'azure-webjobs-secrets'
88 | parent: storageBlobService
89 | properties: {
90 | publicAccess: 'None'
91 | }
92 | }
93 |
94 | resource helpDataTable 'Microsoft.Storage/storageAccounts/tableServices/tables@2023-01-01' = {
95 | name: 'HelpData'
96 | parent: storageTableService
97 | }
98 |
99 | resource appServicePlan 'Microsoft.Web/serverfarms@2023-01-01' = {
100 | name: appServicePlanName
101 | location: location
102 | sku: {
103 | name: 'Y1'
104 | tier: 'Dynamic'
105 | }
106 | kind: 'functionapp'
107 | properties: {
108 | perSiteScaling: false
109 | maximumElasticWorkerCount: 1
110 | }
111 | }
112 |
113 | resource functionApp 'Microsoft.Web/sites@2023-01-01' = {
114 | name: functionAppName
115 | location: location
116 | kind: 'functionapp'
117 | properties: {
118 | httpsOnly: true
119 | serverFarmId: appServicePlan.id
120 | siteConfig: {
121 | appSettings: [
122 | {
123 | name: 'AzureWebJobsStorage'
124 | value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccount.name};AccountKey=${listKeys(storageAccount.id, storageAccount.apiVersion).keys[0].value};EndpointSuffix=${environment().suffixes.storage}'
125 | }
126 | {
127 | name: 'FUNCTIONS_EXTENSION_VERSION'
128 | value: '~4'
129 | }
130 | {
131 | name: 'FUNCTIONS_WORKER_RUNTIME'
132 | value: 'dotnet-isolated'
133 | }
134 | ]
135 | cors: {
136 | allowedOrigins: [
137 | 'https://functions.azure.com'
138 | 'https://functions-staging.azure.com'
139 | 'https://functions-next.azure.com'
140 | substring(storageAccount.properties.primaryEndpoints.web, 0, length(storageAccount.properties.primaryEndpoints.web) - 1)
141 | ]
142 | supportCredentials: false
143 | }
144 | http20Enabled: true
145 | minTlsVersion: '1.2'
146 | ftpsState: 'Disabled'
147 | }
148 | }
149 | dependsOn: [
150 | storageAccount
151 | ]
152 | }
153 |
154 | resource functionHostname 'Microsoft.Web/sites/hostNameBindings@2023-01-01' = {
155 | name: '${functionApp.name}/${functionApp.name}.azurewebsites.net'
156 | location: location
157 | properties: {
158 | siteName: functionApp.name
159 | hostNameType: 'Verified'
160 | }
161 | }
162 |
--------------------------------------------------------------------------------
/.github/workflows/add_module_help.yml:
--------------------------------------------------------------------------------
1 | name: Check if we need to automatically add help for a module
2 |
3 | on:
4 | issue_comment:
5 | types: [created, edited]
6 |
7 | jobs:
8 | moduleHelpAdd:
9 | name: Add help for module '${{ github.event.comment.body }}'
10 | if: github.event.issue.number == 43 && ! startsWith(github.event.comment.body, '#')
11 | runs-on: ubuntu-latest
12 | env:
13 | MODULE_NAME: ${{ github.event.comment.body }}
14 | steps:
15 |
16 | - name: Comment back saying the process has started
17 | uses: jungwinter/comment@v1
18 | id: create
19 | with:
20 | type: create
21 | issue_number: ${{ github.event.issue.number }}
22 | body: "#. Hi @${{ github.event.sender.login }}, I've started working on your module '${{ github.event.comment.body }}'. You can follow the details [under Actions](https://github.com/Jawz84/explainpowershell/actions/runs/${{ github.run_id }}), but I will report back here when I'm done too."
23 | token: ${{ secrets.GITHUB_TOKEN }}
24 |
25 | - name: install module if necessary
26 | shell: pwsh
27 | run: |
28 | $env:MODULE_NAME = $env:MODULE_NAME.Trim()
29 | if (-not (Get-Module -ListAvailable -Name $env:MODULE_NAME)) {
30 | Install-Module -Force $env:MODULE_NAME.replace('*','')
31 | Write-Output "Module '$env:MODULE_NAME' installed"
32 | }
33 | else {
34 | Write-Output "Module '$env:MODULE_NAME' present"
35 | }
36 |
37 | - uses: actions/checkout@v1
38 |
39 | - name: get help info
40 | shell: pwsh
41 | run: |
42 | $helpCollectorSplat = @{
43 | ModulesToProcess = @(Get-Module -ListAvailable $env:MODULE_NAME)
44 | verbose = $true
45 | }
46 |
47 | ./explainpowershell.helpcollector/helpcollector.ps1 @helpCollectorSplat
48 | | Tee-Object -variable out
49 | | ConvertTo-Json
50 | | Out-File -path "./explainpowershell.helpcollector/help.$env:MODULE_NAME.cache.json"
51 |
52 | $fileSize = (Get-Item "./explainpowershell.helpcollector/help.$env:MODULE_NAME.cache.json").Length / 1Kb
53 | Write-Host "Output file size: $fileSize Kb, containing $($out.count) help items" -ForegroundColor Cyan
54 | if ($fileSize -eq 0) {
55 | throw "The script was unable to acquire help automatically. Feel free to create an issue for this."
56 | }
57 |
58 | - name: Authenticate with Azure
59 | uses: azure/login@v2
60 | with:
61 | creds: ${{ secrets.AZURE_SERVICE_PRINCIPAL }}
62 | enable-AzPSSession: true
63 |
64 | - name: Verify Azure Authentication
65 | uses: azure/powershell@v2
66 | with:
67 | inlineScript: |
68 | Write-Host "Testing Azure authentication..."
69 | $context = Get-AzContext
70 | Write-Host "Authenticated as: $($context.Account.Id)"
71 | Write-Host "Subscription: $($context.Subscription.Name)"
72 | azPSVersion: 'latest'
73 |
74 | - name: Write help info
75 | uses: azure/powershell@v2
76 | with:
77 | inlineScript: |
78 | ./explainpowershell.helpcollector/helpwriter.ps1 -HelpDataCacheFilename "./explainpowershell.helpcollector/help.$env:MODULE_NAME.cache.json" -IsProduction
79 | azPSVersion: 'latest'
80 |
81 | - name: Comment back saying the module was added successfully
82 | if: success()
83 | uses: jungwinter/comment@v1
84 | with:
85 | type: edit
86 | issue_number: ${{ github.event.issue.number }}
87 | comment_id: ${{ steps.create.outputs.id }}
88 | body: "#. Great news @${{ github.event.sender.login }}, your module '${{ github.event.comment.body }}' was added successfully! :+1:"
89 | token: ${{ secrets.GITHUB_TOKEN }}
90 |
91 | - name: Comment back saying the module was not added successfully
92 | if: failure()
93 | uses: jungwinter/comment@v1
94 | with:
95 | type: edit
96 | issue_number: ${{ github.event.issue.number }}
97 | comment_id: ${{ steps.create.outputs.id }}
98 | body: "#. Sorry @${{ github.event.sender.login }} cc @jawz84, I was not able to process module '${{ github.event.comment.body }}' on my own.. :-( [See details here](https://github.com/Jawz84/explainpowershell/actions/runs/${{ github.run_id }})"
99 | token: ${{ secrets.GITHUB_TOKEN }}
100 |
101 | - name: Calculate new HelpMetaData
102 | uses: azure/powershell@v2
103 | with:
104 | inlineScript: |
105 | Get-AzFunctionApp
106 | | Where-Object { $_.Name -match 'powershellexplainer' -and $_.Status -eq 'running' }
107 | | ForEach-Object { Invoke-RestMethod -Uri "https://$($_.DefaultHostName)/api/MetaData?refresh=true" }
108 | azPSVersion: 'latest'
--------------------------------------------------------------------------------
/tools/bump_versions.ps1:
--------------------------------------------------------------------------------
1 | [CmdletBinding()]
2 | param(
3 | [string]$TargetDotnetVersion
4 | )
5 |
6 | $ErrorActionPreference = 'stop'
7 |
8 | $currentGitBranch = git branch --show-current
9 | if ($currentGitBranch -eq 'main') {
10 | throw "You are on 'main' branch, create a new branch first"
11 | }
12 |
13 | function Ensure-Module {
14 | param([string]$Name)
15 | if (-not (Get-Module -ListAvailable $Name)) {
16 | Install-Module -Name $Name -Force
17 | }
18 | }
19 |
20 | Ensure-Module -Name 'powershell-yaml'
21 |
22 | function Get-TargetDotnetVersion {
23 | param([string]$Override)
24 | if ($Override) { return [version]$Override }
25 | $sdks = dotnet --list-sdks | ForEach-Object { [version]($_.Split(' ')[0]) }
26 | if (-not $sdks) {
27 | throw 'No .NET SDKs found. Install a recent SDK or provide -TargetDotnetVersion.'
28 | }
29 | return ($sdks | Sort-Object | Select-Object -Last 1)
30 | }
31 |
32 | $dotNetVersion = Get-TargetDotnetVersion -Override $TargetDotnetVersion
33 | $dotNetShortVersion = '{0}.{1}' -f $dotNetVersion.Major, $dotNetVersion.Minor
34 | $functionsToolsVersion = try { [version](func --version) } catch { [version]'4.0.0' }
35 |
36 | Write-Host "Updating project files to net$dotNetShortVersion"
37 |
38 | Get-ChildItem -Path "$PSScriptRoot/.." -Filter *.csproj -Recurse -Depth 2 | ForEach-Object {
39 | $xml = [xml](Get-Content $_.FullName)
40 | foreach ($group in @($xml.Project.PropertyGroup)) {
41 | if ($group.TargetFramework) {
42 | $group.TargetFramework = "net$dotNetShortVersion"
43 | }
44 | }
45 | foreach ($itemGroup in @($xml.Project.ItemGroup)) {
46 | foreach ($reference in @($itemGroup.Reference)) {
47 | if ($reference.HintPath) {
48 | $reference.HintPath = $reference.HintPath -replace '\\net\d+\.\d+\\', "\net$dotNetShortVersion\"
49 | }
50 | }
51 | }
52 | $xml.Save($_.FullName)
53 | }
54 |
55 | Write-Host "Updating Azure Functions settings to runtime ~$($functionsToolsVersion.Major)"
56 | $vsCodeSettingsFile = "$PSScriptRoot/../.vscode/settings.json"
57 | if (Test-Path $vsCodeSettingsFile) {
58 | $settings = Get-Content -Path $vsCodeSettingsFile | ConvertFrom-Json
59 | $settings.'azureFunctions.deploySubpath' = "bin/Release/net$dotNetShortVersion/publish"
60 | $settings.'azureFunctions.projectRuntime' = "~$($functionsToolsVersion.Major)"
61 | $settings | ConvertTo-Json -Depth 5 | Out-File -Path $vsCodeSettingsFile -Force
62 | }
63 |
64 | Write-Host "Updating GitHub Actions workflows"
65 | $deployAppWorkflow = "$PSScriptRoot/../.github/workflows/deploy_app.yml"
66 | if (Test-Path $deployAppWorkflow) {
67 | $ghFlow = Get-Content -Path $deployAppWorkflow
68 | $ghFlow | ForEach-Object { $_ -replace 'dotnet-version: "\d+.0"', "dotnet-version: `"$($dotNetVersion.Major).0`"" }
69 | | Set-Content -Path $deployAppWorkflow -Force
70 | }
71 | else {
72 | Write-Host "No deploy_app.yml workflow found, skipping update."
73 | }
74 |
75 | Write-Host "Updating NuGet package versions to latest stable"
76 | ## Update packages (mirrors Pester checks: dotnet list package --outdated)
77 | $outdated = dotnet list "$PSScriptRoot/../explainpowershell.sln" package --outdated
78 | $outdated += dotnet list "$PSScriptRoot/../explainpowershell.analysisservice.tests/explainpowershell.analysisservice.tests.csproj" package --outdated
79 |
80 | $targetVersions = $outdated | Select-String '^ >' | ForEach-Object {
81 | $parts = ($_ -split '\s{2,}') | Where-Object { $_ }
82 | [pscustomobject]@{
83 | PackageName = $parts[0].Trim('>',' ')
84 | LatestVersion = [version]$parts[-1]
85 | }
86 | } | Sort-Object PackageName -Unique
87 |
88 | Write-Host "Found $($targetVersions.Count) packages to update: $($targetVersions.PackageName -join ', ')"
89 |
90 | Get-ChildItem -Path "$PSScriptRoot/.." -Filter *.csproj -Recurse -Depth 2 | ForEach-Object {
91 | $xml = [xml](Get-Content $_.FullName)
92 | foreach ($itemGroup in @($xml.Project.ItemGroup)) {
93 | foreach ($package in @($itemGroup.PackageReference)) {
94 | if ($package.Include -in $targetVersions.PackageName) {
95 | $latest = ($targetVersions | Where-Object PackageName -EQ $package.Include).LatestVersion
96 | Write-Debug "Updating '$($package.Include)' to version '$latest' in '$($_.FullName)'"
97 | if ($latest) {
98 | $package.Version = $latest.ToString()
99 | }
100 | }
101 | }
102 | }
103 | $xml.Save($_.FullName)
104 | }
105 |
106 | Write-Host "Restoring and cleaning solution"
107 | Push-Location $PSScriptRoot/..
108 | dotnet restore
109 | dotnet clean --verbosity minimal
110 | Pop-Location
111 |
112 | Write-Host -ForegroundColor Magenta 'Version bump complete.'
--------------------------------------------------------------------------------
/.tours/tour-of-the-help-collector.tour:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://aka.ms/codetour-schema",
3 | "title": "Tour of the help collector",
4 | "steps": [
5 | {
6 | "file": ".github/workflows/add_module_help.yml",
7 | "description": "Welcome to the help collector tour.\n\nThe CmdLet explanations generated by Explain PowerShell come from cached help information from modules. To cache help information, the module must be installed and all help files for this module must be cached. We call that help scraping or collecting. \n\nWe are going to have a look at what the help collection process looks like by following the process from request to cached help.\n\nThis Yaml pipeline is a GitHub Action that is automatically run when someone comments on issue [#43](https://github.com/Jawz84/explainpowershell/issues/43).\n",
8 | "line": 2
9 | },
10 | {
11 | "file": ".github/workflows/add_module_help.yml",
12 | "description": "If the module is not installed on the machine running this pipeline, try to install it.",
13 | "line": 25
14 | },
15 | {
16 | "file": ".github/workflows/add_module_help.yml",
17 | "description": "After the module is installed, the `helpcollector.ps1` script is run. This script will get all the relevant help information from the module. \n\nThe output is saved to json, and a check is done to see if there actually is any data in the file. ",
18 | "line": 39
19 | },
20 | {
21 | "file": ".github/workflows/add_module_help.yml",
22 | "description": "Here the `helpwriter.ps1` script is started, and is given the path to the cached json output from the previous step. This basically writes the information to an Azure Storage Table.\n\nThe steps below are just to notify the requester of the module if everything succeeded or not. We will have a look over at the scripts now, to see what they do.",
23 | "line": 74
24 | },
25 | {
26 | "file": "explainpowershell.helpcollector/helpcollector.ps1",
27 | "description": "This is the `helpcollector.ps1` script that reads help information for each command in a module. \n\nIt: \n- Tries to update help for the module\n- Gets names of all the commands (and skips aliases)\n- Gathers help info for each command and outputs this as an object to the pipeline.",
28 | "line": 86
29 | },
30 | {
31 | "file": "explainpowershell.helpcollector/helpwriter.ps1",
32 | "description": "We store the help data in an Azure Storage Table. These are tables with two very important columns: `PartitionKey` and `RowKey`. Data from a Table like this, is retrieved based on these two columns. If we want a certain row, we ask for `(PartitionKey='x' RowKey='y')`, if our data is at x, y so to speak. So if we store data, the string that is in the PartitionKey and the RowKey, needs to be unique and easily searchable. That's why we convert the name of the command to lower case. When we search for a command later, we can convert that commandname to lower too, and be sure we always find the right command, even when the user MIsTyped-ThecOmmand. ",
33 | "line": 117
34 | },
35 | {
36 | "file": "explainpowershell.helpcollector/BulkHelpCollector.ps1",
37 | "description": "This script is used to scrape a lot of modules at once, after they are installed manually. Some modules require special prerequisites (like the Windows RSAT module, or the Exchange module). This script can be usefull in those cases. It produces a json file per scraped module.\n\nIt can be used in conjunction with the `BulkHelpCacheUploader.ps1` script, to later upload all cached json help.",
38 | "line": 5
39 | },
40 | {
41 | "file": "explainpowershell.helpcollector/aboutcollector.ps1",
42 | "description": "All PowerShell built in about_-articles are also scraped and available. This is a treasure trove for additional details for many Explanations. \n\nAnother cool thing is that you can get a link to the about_-article by just asking for an Explanation with only the about_-article. For instance, requesting `about_History` will give you an explanation result with the link to that about_-article.\n\nAbout_-articles are only available after updating help from the internet. That's why there is a check if they are present at the top, and if not, an update help.\n\nWhen we want to be able to provide links to the about_-articles to the end user, we need to find the online url for them. I have not found a more elegant way than trial and error yet. Each possibility is tried in turn until a request succeeds.\n\nAt the last part, the synopsis of the about_article is extracted, so we have some info to show to the user.\n\nAll found info is piped to the pipeline as objects, so we can further process them elsewhere.\n\nThis code is used in the `bootstrap.ps1` script to ensure a working dev environment.\n\n\n> To view the help data in Azurite or in your own Azure Table in Azure, you can use the `Microsoft Azure Storage Explorer` application, found here: https://azure.microsoft.com/en-us/features/storage-explorer/",
43 | "line": 2
44 | }
45 | ]
46 | }
--------------------------------------------------------------------------------
/explainpowershell.helpcollector/helpcollector.ps1:
--------------------------------------------------------------------------------
1 | [cmdletbinding()]
2 | param(
3 | [pscustomobject[]]
4 | $ModulesToProcess
5 | )
6 |
7 | . $PSScriptRoot/HelpCollector.Functions.ps1
8 |
9 | $ModulesToProcess = $ModulesToProcess | Sort-Object -Unique -Property Name
10 |
11 | $script:badUrls = @()
12 |
13 | foreach ($mod in $ModulesToProcess) {
14 | if (-not $PSCmdlet.MyInvocation.BoundParameters['Verbose'].IsPresent) {
15 | Write-Progress -Id 1 -Activity "Processing '$($ModulesToProcess.Count)' modules." -CurrentOperation "Processing module '$($mod.Name)'" -PercentComplete ((@($ModulesToProcess).IndexOf($mod) + 1) / $ModulesToProcess.Count * 100)
16 | }
17 | else {
18 | Write-Verbose "Processing module '$($mod.Name)'"
19 | }
20 |
21 | try {
22 | Update-Help -Module $mod.Name -Force -ErrorAction stop
23 | }
24 | catch {
25 | Write-Warning "$_"
26 | }
27 |
28 | [string]$moduleProjectUri = $mod.ProjectUri
29 |
30 | $commandsToProcess = (Get-Command -Module $mod.name).where{ $_.CommandType -ne 'Alias' }
31 |
32 | foreach ($cmd in $commandsToProcess) {
33 | Write-Debug $cmd.Name
34 |
35 | if (-not $PSCmdlet.MyInvocation.BoundParameters['Verbose'].IsPresent) {
36 | Write-Progress -ParentId 1 -Activity "Processing '$($commandsToProcess.Count)' commands" -CurrentOperation "Processing command '$($cmd.Name)'" -PercentComplete ((@($commandsToProcess).IndexOf($cmd) + 1) / $commandsToProcess.Count * 100)
37 | }
38 |
39 | try {
40 | $help = Get-Help $cmd.Name -ErrorAction stop
41 | }
42 | catch {
43 | Write-Warning "!!$($cmd.name) - Unexpected error in Get-Help: $_"
44 | $help = $null
45 | }
46 | $null = $help ?? $(Write-Warning "!!$($cmd.name) - No local help present, limited results")
47 |
48 | $relatedLinks = $help.relatedLinks.navigationLink.where{ $_.uri -match '^http' }.uri ?? $cmd.HelpUri ?? $moduleProjectUri
49 | $documentationLink = $relatedLinks | Select-Object -First 1
50 |
51 | $synopsis, $documentationLink = Get-Synopsis -Help $help -Cmd $cmd -DocumentationLink $DocumentationLink
52 |
53 | try {
54 | $syntax = ($help.syntax | Out-String).Trim()
55 | if ($syntax -like '*syntaxItem*') {
56 | $syntax = Get-Command $cmd.Name -Syntax
57 | }
58 | }
59 | catch {
60 | Write-Warning "!!$($cmd.name) - Something went wrong getting syntax info: $_"
61 | }
62 |
63 | $parameterData = $help.parameters.parameter.foreach{
64 | try {
65 | $cmdParam = $cmd.Parameters[$_.name]
66 | }
67 | catch {
68 | Write-Verbose "$($cmd.name) - No command parameter data"
69 | }
70 |
71 | [pscustomobject]@{
72 | Aliases = ($_.aliases -join ', ') ?? ($cmdParam.Aliases -join ', ')
73 | DefaultValue = $_.defaultValue
74 | Description = $_.Description.Text -join ''
75 | Globbing = $_.globbing
76 | IsDynamic = $cmdParam.IsDynamic
77 | Name = $_.name
78 | ParameterSets = $cmdParam.ParameterSets
79 | PipelineInput = $_.pipelineInput
80 | Position = $_.position
81 | Required = $_.required
82 | SwitchParameter = $cmdParam.SwitchParameter
83 | TypeName = $_.parameterValue ?? $cmdParam.ParameterType.FullName
84 | }
85 | }
86 |
87 | [pscustomobject]@{
88 | Aliases = @($help.Aliases) -join ', '
89 | CommandName = $cmd.Name
90 | DefaultParameterSet = $cmd.DefaultParameterSet
91 | Description = $help.Description.Text -join ''
92 | DocumentationLink = $documentationLink
93 | InputTypes = $help.InputTypes.inputType.type.name -join ', '
94 | ModuleName = $cmd.ModuleName
95 | ModuleVersion = "$($cmd.Module.Version ?? $cmd.Version ?? '')"
96 | ModuleProjectUri = $moduleProjectUri
97 | Parameters = $parameterData | ConvertTo-Json -Compress -Depth 3
98 | ParameterSetNames = @($parameterData.ParameterSets.Keys | Where-Object { $_ -ne '__AllParameterSets' } | Sort-Object -Unique) -join ', '
99 | RelatedLinks = $relatedLinks -join ', '
100 | ReturnValues = $help.ReturnValues.returnValue.type.name -join ', '
101 | Synopsis = $synopsis
102 | Syntax = $syntax
103 | }
104 | }
105 |
106 | if (-not $PSCmdlet.MyInvocation.BoundParameters['Verbose'].IsPresent) {
107 | Write-Progress -ParentId 1 -Activity "Processing '$($commandsToProcess.Count)' commands" -CurrentOperation 'Completed' -Completed
108 | Write-Progress -Id 1 -Activity "Processing '$($ModulesToProcess.Count)' modules." -CurrentOperation 'Completed' -Completed
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/explainpowershell.analysisservice/MetaData.cs:
--------------------------------------------------------------------------------
1 | using Azure;
2 | using Azure.Data.Tables;
3 | using explainpowershell.models;
4 | using Microsoft.Azure.Functions.Worker;
5 | using Microsoft.Azure.Functions.Worker.Http;
6 | using Microsoft.Extensions.Logging;
7 | using System;
8 | using System.Collections.Generic;
9 | using System.Linq;
10 | using System.Net;
11 | using System.Reflection;
12 | using System.Text.Json;
13 | using System.Threading.Tasks;
14 |
15 | namespace explainpowershell.analysisservice
16 | {
17 | public sealed class MetaDataFunction
18 | {
19 | private const string HelpTableName = "HelpData";
20 | private const string MetaDataPartitionKey = "HelpMetaData";
21 | private const string MetaDataRowKey = "HelpMetaData";
22 | private const string CommandHelpPartitionKey = "CommandHelp";
23 | private readonly ILogger logger;
24 |
25 | public MetaDataFunction(ILogger logger)
26 | {
27 | this.logger = logger;
28 | }
29 |
30 | [Function("MetaData")]
31 | public async Task Run(
32 | [HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequestData req)
33 | {
34 | var client = TableClientFactory.Create(HelpTableName);
35 | HelpMetaData helpMetaData;
36 | if (ShouldRefresh(req))
37 | {
38 | helpMetaData = CalculateMetaData(client, logger);
39 | }
40 | else
41 | {
42 | logger.LogInformation("Trying to get HelpMetaData from cache");
43 | try
44 | {
45 | helpMetaData = client.GetEntity(MetaDataPartitionKey, MetaDataRowKey);
46 | }
47 | catch (RequestFailedException)
48 | {
49 | helpMetaData = CalculateMetaData(client, logger);
50 | }
51 | }
52 |
53 | var json = JsonSerializer.Serialize(helpMetaData);
54 | var response = req.CreateResponse(HttpStatusCode.OK);
55 | response.Headers.Add("Content-Type", "application/json");
56 | await response.WriteStringAsync(json).ConfigureAwait(false);
57 | return response;
58 | }
59 |
60 | public static HelpMetaData CalculateMetaData(TableClient client, ILogger log)
61 | {
62 | log.LogInformation("Calculating meta data on HelpTable");
63 |
64 | string filter = TableServiceClient.CreateQueryFilter($"PartitionKey eq {CommandHelpPartitionKey}");
65 | var select = new[] { "CommandName", "ModuleName" };
66 | var entities = client.Query(filter: filter, select: select).ToList();
67 |
68 | var numAbout = entities
69 | .Count(r => r.CommandName?.StartsWith("about_", StringComparison.OrdinalIgnoreCase) ?? false);
70 |
71 | var moduleNames = entities
72 | .Select(r => r.ModuleName)
73 | .Where(moduleName => !string.IsNullOrEmpty(moduleName))
74 | .Distinct();
75 |
76 | var helpMetaData = new HelpMetaData()
77 | {
78 | PartitionKey = MetaDataPartitionKey,
79 | RowKey = MetaDataRowKey,
80 | NumberOfAboutArticles = numAbout,
81 | NumberOfCommands = entities.Count() - numAbout,
82 | NumberOfModules = moduleNames.Count(),
83 | ModuleNames = string.Join(',', moduleNames),
84 | LastPublished = Helpers.GetBuildDate(Assembly.GetExecutingAssembly()).ToLongDateString()
85 | };
86 |
87 | try
88 | {
89 | _ = client.GetEntity(MetaDataPartitionKey, MetaDataRowKey);
90 | _ = client.UpsertEntity(helpMetaData);
91 | }
92 | catch (RequestFailedException)
93 | {
94 | _ = client.AddEntity(helpMetaData);
95 | }
96 |
97 | return helpMetaData;
98 | }
99 |
100 | private static bool ShouldRefresh(HttpRequestData req)
101 | {
102 | var query = req.Url.Query;
103 | if (string.IsNullOrEmpty(query))
104 | {
105 | return false;
106 | }
107 |
108 | var pairs = query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries);
109 | foreach (var pair in pairs)
110 | {
111 | var kvp = pair.Split('=', 2);
112 | if (kvp.Length == 0)
113 | {
114 | continue;
115 | }
116 |
117 | if (!kvp[0].Equals("refresh", StringComparison.OrdinalIgnoreCase))
118 | {
119 | continue;
120 | }
121 |
122 | var value = kvp.Length > 1 ? Uri.UnescapeDataString(kvp[1]) : string.Empty;
123 | return value.Equals("true", StringComparison.OrdinalIgnoreCase);
124 | }
125 |
126 | return false;
127 | }
128 | }
129 | }
130 |
--------------------------------------------------------------------------------
/explainpowershell.analysisservice.tests/testfiles/oneliners.ps1:
--------------------------------------------------------------------------------
1 | help command -full
2 | Clear-Host
3 | get-history | more
4 | invoke-history 12
5 | invoke-history | export-csv c:\temp\output.txt
6 | start-transcript c:\temp\output.txt -IncludeInvocationHeader
7 | stop-transcript
8 | gip
9 | get-netadapter
10 | get-netadapter "MyConnection01" | get-netroute -addressfamily ipv4
11 | Get-NetTCPConnection | ? State -eq Established | sort Localport | FT -Autosize
12 | Get-DnsClientCache
13 | Get-SmbMapping
14 | test-connection DC01
15 | test-connection DC01 -count 999999999
16 | tnc DC01 -tr
17 | tnc DC01 -p 3389
18 | tnc DC01 -p 3389 -inf detailed
19 | resolve-dnsname DC01
20 | New-SmbMapping -LocalPath S: -RemotePath \\server\demo
21 | . 'C:\Program Files\Microsoft\Exchange Server\V14\bin\RemoteExchange.ps1'
22 | Get-Mailbox -ResultSize Unlimited | Get-ADPermission | Where-Object {($_.ExtendedRights -like "*send-as*") -and -not ($_.User -like "nt authority\self")} | Format-Table Identity, User -auto
23 | Get-Mailbox -ResultSize Unlimited | Get-MailboxPermission | Where-Object {($_.AccessRights -match "FullAccess") -and -not ($_.User -like "NT AUTHORITY\SELF")} | Format-Table Identity, User
24 | Get-Mailbox identity@dummy.com | Format-List *Quota
25 | Get-ActiveSyncDevice -filter {deviceaccessstate -eq 'quarantined'} | select identity, deviceid | fl
26 | Set-Mailbox identity@dummy.com -IssueWarningQuota 950MB -ProhibitSendQuota 970MB -ProhibitSendReceiveQuota 1GB -UseDatabaseQuotaDefaults $false
27 | Set-Mailbox -Identity "Full Name" -DeliverToMailboxAndForward $true -ForwardingAddress "Full Name"
28 | Set-Mailbox -Identity "Full Name" -ForwardingAddress $null -ForwardingSmtpAddress $null
29 | Get-ActiveSyncDevice -filter {deviceaccessstate -eq 'quarantined'} | select identity, deviceid | fl Set-CASMailbox –Identity identity@dummy.com –ActiveSyncAllowedDeviceIDs $DeviceId
30 | $env:computername
31 | [Management.ManagementDateTimeConverter]::ToDateTime((Get-WmiObject Win32_OperatingSystem -Property LastBootUpTime -ComputerName DC01).LastBootUpTime)
32 | Get-WmiObject win32_operatingsystem -ComputerName DC01 | select @{Name="Last Boot Time"; Expression={$_.ConvertToDateTime($_.LastBootUpTime)}}, PSComputerName
33 | rename-computer -name WS001 -newname WS-001
34 | restart-computer
35 | restart-computer -computername DC01
36 | stop-computer
37 | Start-Process -FilePath cmd.exe -Wait -WindowStyle Maximized
38 | disable-windowsoptionalfeature -FeatureName SMB1Protocol
39 | Get-ChildItem -Path hkcu:\
40 | Set-Location hkcu: ; cd SOFTWARE; dir
41 | New-Item -Path hkcu:\new_key
42 | Remove-Item -Path hkcu:\new_key
43 | Set-ItemProperty -Path hkcu:\key -Name myProp -Value "testinggg"
44 | Get-Service s* | sort Status
45 | Get-Service someservice | where Status -eq running | sort DisplayName
46 | get-service -computername hyd-rdsh-10 | where DisplayName -match "myRegex_now_you_have_two_problems"
47 | start-service spooler
48 | stop-service fax
49 | restart-service spooler
50 | get-printer -computername hyd-rdsh-10 | where Type -eq Local | select ComputerName, Name, ShareName, Location, Comment | Out-GridView
51 | Get-Printer -ComputerName DC01 | where PrinterStatus -eq Error | fl Name,JobCount
52 | Get-PrintJob -ComputerName DC01 -PrinterName $myPrinterQueueName
53 | Get-PrintJob -ComputerName DC01 -PrinterName DC01 | where id -eq [job number] | fl JobStatus,UserName
54 | add-printer -connect \\computer\queue_name
55 | Remove-Printer -Name "[Printer Path and Name]"
56 | Get-ChildItem -Path c:\temp -Recurse -Include *.JPG
57 | Copy-Item -Path C:\Users\myUser\desktop\test.txt -Destination ~\Documents\ -Verbose
58 | Move-Item $myPath -Destination $myDestination -Verbose
59 | Rename-Item ~\Desktop\MyTestFile -NewName MyTestFile.ps1
60 | search-adaccount -accountinactive -datetime (Get-date).adddays(-30) -usersonly
61 | set-aduser $userName -title "Senior DevOps Engineer"
62 | disable-adaccount -identity $userName
63 | Get-WmiObject -Class ccm_application -Namespace root\ccm\clientsdk -ComputerName (get-content .\computers.txt) | Where-Object { ($_.InstallState -ne "Installed") -and ($_.ApplicabilityState -eq "Applicable") -and ($_.IsMachineTarget -eq $True) -and ($_.EvaluationState -ne 1)} | select FullName,__SERVER
64 | Get-ADDomainController –Filter * | Select Hostname, IsGlobalCatalog | Export-CSV C:\Temp\AllDomainControllerStatus.CSV -NoTypeInfo
65 | Backup-GPO –All –Path C:\Temp\AllGPO
66 | Get-HotFix –ID KB2877616
67 | Get-HotFix –ID KB2877616 –Computername WindowsServer1.TechGenix.com
68 | Get-EventLog –Log System –Newest 100 | Where-Object {$_.EventID –eq ‘1074’} | FT MachineName, UserName, TimeGenerated –AutoSize | Export-CSV C:\Temp\AllEvents.CSV -NoTypeInfo
69 | $cred = Get-Credential; $Session = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri https://ps.outlook.com/powershell -Credential $cred -Authentication Basic -AllowRedirection ; Import-PSSession $Session
70 | dir -r | % { $_.FullName.substring($pwd.Path.length+1) + $(if ($_.PsIsContainer) {'\'}) }
71 | dir | % { New-Object PSObject -Property @{ Name = $_.Name; Size = if($_.PSIsContainer) { (gci $_.FullName -Recurse | Measure Length -Sum).Sum } else {$_.Length}; Type = if($_.PSIsContainer) {'Directory'} else {'File'} } }
72 | Get-CimInstance -ClassName Win32_OperatingSystem | Select-Object -Property Build*,OSType,ServicePack*
73 | $env:path -split ';' | sort -Unique
--------------------------------------------------------------------------------
/.github/workflows/deploy_app.yml:
--------------------------------------------------------------------------------
1 | on:
2 | workflow_dispatch:
3 | push:
4 | branches:
5 | - main
6 | name: Deploy app to Azure
7 | jobs:
8 | deployBackend:
9 | runs-on: windows-latest
10 | steps:
11 | - uses: actions/download-artifact@v4
12 | name: Download artifacts
13 | with:
14 | name: api
15 | path: backend_artifact
16 | - uses: azure/login@v2
17 | name: Authenticate with Azure
18 | with:
19 | creds: ${{ secrets.AZURE_SERVICE_PRINCIPAL }}
20 | - name: Deploy Function App package
21 | shell: pwsh
22 | run: |
23 | az functionapp deployment source config-zip `
24 | --resource-group "${{ secrets.RESOURCE_GROUP_NAME }}".trim() `
25 | --name "${{ secrets.FUNCTION_APP_NAME }}".trim() `
26 | --src (Join-Path $PWD 'backend_artifact/functionapp.zip')
27 | - run: "az functionapp list |\n ConvertFrom-Json |\n where {$_.defaulthostname -match 'powershellexplainer' -and $_.state -eq 'running'} | \n ForEach-Object { Invoke-RestMethod -Uri \"https://$($_.DefaultHostName)/api/MetaData?refresh=true\" }\n"
28 | name: Calculate new HelpMetaData
29 | shell: pwsh
30 | needs: buildBackend
31 | name: Deploy Backend api
32 | buildBackend:
33 | runs-on: windows-latest
34 | steps:
35 | - uses: actions/setup-dotnet@v4
36 | with:
37 | dotnet-version: "10.0"
38 | - uses: actions/checkout@v4
39 | - run: Get-ChildItem -Path ./explainpowershell.analysisservice/ -Recurse -Filter *_code_generator.ps1 | ForEach-Object { & $_.FullName }
40 | name: Run code generators
41 | shell: pwsh
42 | - run: |
43 | Set-Location ./explainpowershell.analysisservice
44 | dotnet publish --configuration Release
45 | name: Publish with dotnet
46 | shell: pwsh
47 | - name: Zip publish output
48 | shell: pwsh
49 | run: |
50 | $publishDir = Join-Path $PWD 'explainpowershell.analysisservice/bin/Release/net10.0/publish'
51 | $zipPath = Join-Path $PWD 'explainpowershell.analysisservice/bin/Release/net10.0/functionapp.zip'
52 | if (Test-Path $zipPath) {
53 | Remove-Item $zipPath -Force
54 | }
55 | $entries = Get-ChildItem -LiteralPath $publishDir -Force
56 | if (-not $entries) {
57 | throw "No publish output found at $publishDir"
58 | }
59 | Compress-Archive -Path $entries.FullName -DestinationPath $zipPath -Force
60 | - uses: actions/upload-artifact@v4
61 | name: Publish artifacts
62 | with:
63 | name: api
64 | path: ./explainpowershell.analysisservice/bin/Release/net10.0/functionapp.zip
65 | name: Build Backend api
66 | buildFrontend:
67 | runs-on: windows-latest
68 | steps:
69 | - uses: actions/setup-dotnet@v4
70 | with:
71 | dotnet-version: "10.0"
72 | - uses: actions/checkout@v4
73 | - run: |
74 | $uri = "https://$("${{ secrets.FUNCTION_APP_NAME }}".trim()).azurewebsites.net/api/"
75 | Set-Content -Path ./explainpowershell.frontend/wwwroot/appsettings.json -Value (@{BaseAddress = $uri} | ConvertTo-Json)
76 | Get-Content -path ./explainpowershell.frontend/wwwroot/appsettings.json
77 | name: Set api url to correct url manually
78 | shell: pwsh
79 | - run: Get-ChildItem -Path ./explainpowershell.frontend/ -Recurse -Filter *_code_generator.ps1 | ForEach-Object { & $_.FullName }
80 | name: Run code generators
81 | shell: pwsh
82 | - run: dotnet build --configuration Release explainpowershell.frontend
83 | name: Build with dotnet
84 | - run: dotnet publish --configuration Release explainpowershell.frontend
85 | name: Publish with dotnet
86 | - uses: actions/upload-artifact@v4
87 | name: Publish artifacts
88 | with:
89 | name: webapp
90 | path: ./explainpowershell.frontend/bin/Release/net10.0/publish/wwwroot
91 | name: Build Frontend
92 | deployFrontend:
93 | runs-on: windows-latest
94 | steps:
95 | - uses: actions/download-artifact@v4
96 | name: Download artifacts
97 | with:
98 | name: webapp
99 | - uses: azure/login@v2
100 | name: Authenticate with Azure
101 | with:
102 | creds: ${{ secrets.AZURE_SERVICE_PRINCIPAL }}
103 | - run: "az storage blob delete-batch --account-name \"${{ secrets.STORAGE_ACCOUNT_NAME }}\".trim() --source `$web \n"
104 | name: Clean up previous deployment
105 | shell: pwsh
106 | - run: |
107 | az storage blob upload-batch --account-name "${{ secrets.STORAGE_ACCOUNT_NAME }}".trim() --source ./ --destination `$web
108 | name: Deploy to storage account
109 | shell: pwsh
110 | - run: |
111 | if ( -not [String]::IsNullOrEmpty("${{ secrets.AZURE_CDN_ENDPOINT }}".trim()) ) {
112 | az cdn endpoint purge -n "${{ secrets.AZURE_CDN_ENDPOINT }}".trim() --profile-name "${{ secrets.AZURE_CDN_ENDPOINT }}".trim() --content-paths "/*" --resource-group "${{ secrets.RESOURCE_GROUP_NAME }}".trim()
113 | "Az CDN endpoint purged"
114 | }
115 | name: Purge Azure CDN
116 | shell: pwsh
117 | needs: buildFrontend
118 | name: Deploy Frontend
119 |
120 |
--------------------------------------------------------------------------------
/explainpowershell.analysisservice.tests/tests/InMemoryHelpRepository.tests.cs:
--------------------------------------------------------------------------------
1 | using System.Linq;
2 | using explainpowershell.models;
3 | using NUnit.Framework;
4 |
5 | namespace ExplainPowershell.SyntaxAnalyzer.Tests
6 | {
7 | [TestFixture]
8 | public class InMemoryHelpRepositoryTests
9 | {
10 | private InMemoryHelpRepository repository;
11 |
12 | [SetUp]
13 | public void Setup()
14 | {
15 | repository = new InMemoryHelpRepository();
16 | }
17 |
18 | [Test]
19 | public void GetHelpForCommand_WithValidCommand_ReturnsEntity()
20 | {
21 | // Arrange
22 | var entity = new HelpEntity
23 | {
24 | CommandName = "Get-Process",
25 | Synopsis = "Gets the processes running on the local computer.",
26 | ModuleName = "Microsoft.PowerShell.Management"
27 | };
28 | repository.AddHelpEntity(entity);
29 |
30 | // Act
31 | var result = repository.GetHelpForCommand("get-process");
32 |
33 | // Assert
34 | Assert.IsNotNull(result);
35 | Assert.AreEqual("Get-Process", result.CommandName);
36 | }
37 |
38 | [Test]
39 | public void GetHelpForCommand_WithInvalidCommand_ReturnsNull()
40 | {
41 | // Act
42 | var result = repository.GetHelpForCommand("NonExistentCommand");
43 |
44 | // Assert
45 | Assert.IsNull(result);
46 | }
47 |
48 | [Test]
49 | public void GetHelpForCommand_WithModule_ReturnsCorrectEntity()
50 | {
51 | // Arrange
52 | var entity1 = new HelpEntity
53 | {
54 | CommandName = "Test-Command",
55 | ModuleName = "Module1",
56 | Synopsis = "Test command from Module1"
57 | };
58 | var entity2 = new HelpEntity
59 | {
60 | CommandName = "Test-Command",
61 | ModuleName = "Module2",
62 | Synopsis = "Test command from Module2"
63 | };
64 | repository.AddHelpEntity(entity1);
65 | repository.AddHelpEntity(entity2);
66 |
67 | // Act
68 | var result1 = repository.GetHelpForCommand("Test-Command", "Module1");
69 | var result2 = repository.GetHelpForCommand("Test-Command", "Module2");
70 |
71 | // Assert
72 | Assert.IsNotNull(result1);
73 | Assert.AreEqual("Module1", result1.ModuleName);
74 | Assert.IsNotNull(result2);
75 | Assert.AreEqual("Module2", result2.ModuleName);
76 | }
77 |
78 | [Test]
79 | public void GetHelpForCommandRange_WithMatchingPrefix_ReturnsMultipleEntities()
80 | {
81 | // Arrange
82 | repository.AddHelpEntity(new HelpEntity { CommandName = "Get-Process" });
83 | repository.AddHelpEntity(new HelpEntity { CommandName = "Get-Service" });
84 | repository.AddHelpEntity(new HelpEntity { CommandName = "Set-Service" });
85 |
86 | // Act
87 | var result = repository.GetHelpForCommandRange("Get-");
88 |
89 | // Assert
90 | Assert.AreEqual(2, result.Count);
91 | Assert.IsTrue(result.Any(e => e.CommandName == "Get-Process"));
92 | Assert.IsTrue(result.Any(e => e.CommandName == "Get-Service"));
93 | }
94 |
95 | [Test]
96 | public void GetHelpForCommandRange_WithNoMatches_ReturnsEmptyList()
97 | {
98 | // Arrange
99 | repository.AddHelpEntity(new HelpEntity { CommandName = "Get-Process" });
100 |
101 | // Act
102 | var result = repository.GetHelpForCommandRange("Set-");
103 |
104 | // Assert
105 | Assert.AreEqual(0, result.Count);
106 | }
107 |
108 | [Test]
109 | public void Clear_RemovesAllEntities()
110 | {
111 | // Arrange
112 | repository.AddHelpEntity(new HelpEntity { CommandName = "Get-Process" });
113 | repository.AddHelpEntity(new HelpEntity { CommandName = "Get-Service" });
114 |
115 | // Act
116 | repository.Clear();
117 | var result = repository.GetHelpForCommand("Get-Process");
118 |
119 | // Assert
120 | Assert.IsNull(result);
121 | }
122 |
123 | [Test]
124 | public void GetHelpForCommand_IsCaseInsensitive()
125 | {
126 | // Arrange
127 | var entity = new HelpEntity { CommandName = "Get-Process" };
128 | repository.AddHelpEntity(entity);
129 |
130 | // Act
131 | var result1 = repository.GetHelpForCommand("GET-PROCESS");
132 | var result2 = repository.GetHelpForCommand("get-process");
133 | var result3 = repository.GetHelpForCommand("Get-Process");
134 |
135 | // Assert
136 | Assert.IsNotNull(result1);
137 | Assert.IsNotNull(result2);
138 | Assert.IsNotNull(result3);
139 | Assert.AreEqual(result1.CommandName, result2.CommandName);
140 | Assert.AreEqual(result2.CommandName, result3.CommandName);
141 | }
142 | }
143 | }
144 |
--------------------------------------------------------------------------------
/explainpowershell.frontend/Tree.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Linq;
4 | using MudBlazor;
5 |
6 | namespace explainpowershell.frontend
7 | {
8 | internal static class GenericTree
9 | {
10 | private const int MaxDepth = 256;
11 |
12 | ///
13 | /// Generates a MudBlazor-compatible tree from a flat collection.
14 | ///
15 | public static List> GenerateTree(
16 | this IEnumerable collection,
17 | Func idSelector,
18 | Func parentIdSelector,
19 | K rootId = default)
20 | {
21 | static K CanonicalizeKey(K key)
22 | {
23 | if (typeof(K) == typeof(string))
24 | {
25 | var s = key as string;
26 | return (K)(object)(s ?? string.Empty);
27 | }
28 |
29 | return key;
30 | }
31 |
32 | var comparer = EqualityComparer.Default;
33 | var items = collection as IList ?? collection.ToList();
34 |
35 | // Group children by parent id in one pass (O(n)).
36 | var childrenByParent = new Dictionary>(comparer);
37 | foreach (var item in items)
38 | {
39 | var parentKey = CanonicalizeKey(parentIdSelector(item));
40 | if (!childrenByParent.TryGetValue(parentKey, out var list))
41 | {
42 | list = new List();
43 | childrenByParent[parentKey] = list;
44 | }
45 |
46 | list.Add(item);
47 | }
48 |
49 | var rootKey = CanonicalizeKey(rootId);
50 | if (!childrenByParent.TryGetValue(rootKey, out var rootItems) || rootItems.Count == 0)
51 | {
52 | // Special-case: when rootId is null/empty string, treat both null and empty as root.
53 | if (typeof(K) == typeof(string) && string.IsNullOrEmpty(rootKey as string))
54 | {
55 | if (childrenByParent.TryGetValue((K)(object)string.Empty, out var emptyRootItems))
56 | {
57 | rootItems = emptyRootItems;
58 | }
59 | }
60 |
61 | if (rootItems is null || rootItems.Count == 0)
62 | {
63 | return new List>();
64 | }
65 | }
66 |
67 | var nodes = new List>(rootItems.Count);
68 |
69 | var stack = new Stack<(TreeItemData Node, K Id, int Depth, HashSet Path)>();
70 |
71 | foreach (var item in rootItems)
72 | {
73 | var nodeId = CanonicalizeKey(idSelector(item));
74 | var node = new TreeItemData
75 | {
76 | Value = item,
77 | Expanded = true
78 | };
79 |
80 | nodes.Add(node);
81 | stack.Push((node, nodeId, 1, new HashSet(comparer) { nodeId }));
82 | }
83 |
84 | while (stack.Count > 0)
85 | {
86 | var (node, id, depth, path) = stack.Pop();
87 |
88 | if (depth >= MaxDepth)
89 | {
90 | node.Children = null;
91 | node.Expandable = false;
92 | continue;
93 | }
94 |
95 | if (!childrenByParent.TryGetValue(id, out var children) || children.Count == 0)
96 | {
97 | node.Children = null;
98 | node.Expandable = false;
99 | continue;
100 | }
101 |
102 | var childNodes = new List>(children.Count);
103 |
104 | foreach (var child in children)
105 | {
106 | var childId = CanonicalizeKey(idSelector(child));
107 | if (path.Contains(childId))
108 | {
109 | continue;
110 | }
111 |
112 | childNodes.Add(new TreeItemData
113 | {
114 | Value = child,
115 | Expanded = true
116 | });
117 | }
118 |
119 | if (childNodes.Count == 0)
120 | {
121 | node.Children = null;
122 | node.Expandable = false;
123 | continue;
124 | }
125 |
126 | node.Children = childNodes;
127 | node.Expandable = true;
128 |
129 | // Push in reverse so the first child is processed first.
130 | for (var i = childNodes.Count - 1; i >= 0; i--)
131 | {
132 | var childNode = childNodes[i];
133 | var childId = CanonicalizeKey(idSelector(childNode.Value));
134 | var childPath = new HashSet(path, comparer) { childId };
135 | stack.Push((childNode, childId, depth + 1, childPath));
136 | }
137 | }
138 |
139 | return nodes;
140 | }
141 | }
142 | }
143 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | ## Ignore Visual Studio temporary files, build results, and
2 | ## files generated by popular Visual Studio add-ons.
3 |
4 | # Azure Functions localsettings file
5 | **/local.settings.json
6 |
7 | # Test artifacts
8 | testResults.xml
9 | coverage.xml
10 |
11 | # User-specific files
12 | *.suo
13 | *.user
14 | *.userosscache
15 | *.sln.docstates
16 |
17 | # User-specific files (MonoDevelop/Xamarin Studio)
18 | *.userprefs
19 |
20 | # Build results
21 | [Dd]ebug/
22 | [Dd]ebugPublic/
23 | [Rr]elease/
24 | [Rr]eleases/
25 | x64/
26 | x86/
27 | bld/
28 | [Bb]in/
29 | [Oo]bj/
30 | [Ll]og/
31 |
32 | # Visual Studio 2015 cache/options directory
33 | .vs/
34 | # Uncomment if you have tasks that create the project's static files in wwwroot
35 | #wwwroot/
36 |
37 | # MSTest test Results
38 | [Tt]est[Rr]esult*/
39 | [Bb]uild[Ll]og.*
40 |
41 | # NUNIT
42 | *.VisualState.xml
43 | TestResult.xml
44 |
45 | # Build Results of an ATL Project
46 | [Dd]ebugPS/
47 | [Rr]eleasePS/
48 | dlldata.c
49 |
50 | # DNX
51 | project.lock.json
52 | project.fragment.lock.json
53 | artifacts/
54 |
55 | *_i.c
56 | *_p.c
57 | *_i.h
58 | *.ilk
59 | *.meta
60 | *.obj
61 | *.pch
62 | *.pdb
63 | *.pgc
64 | *.pgd
65 | *.rsp
66 | *.sbr
67 | *.tlb
68 | *.tli
69 | *.tlh
70 | *.tmp
71 | *.tmp_proj
72 | *.log
73 | *.vspscc
74 | *.vssscc
75 | .builds
76 | *.pidb
77 | *.svclog
78 | *.scc
79 |
80 | # Chutzpah Test files
81 | _Chutzpah*
82 |
83 | # Visual C++ cache files
84 | ipch/
85 | *.aps
86 | *.ncb
87 | *.opendb
88 | *.opensdf
89 | *.sdf
90 | *.cachefile
91 | *.VC.db
92 | *.VC.VC.opendb
93 |
94 | # Visual Studio profiler
95 | *.psess
96 | *.vsp
97 | *.vspx
98 | *.sap
99 |
100 | # TFS 2012 Local Workspace
101 | $tf/
102 |
103 | # Guidance Automation Toolkit
104 | *.gpState
105 |
106 | # ReSharper is a .NET coding add-in
107 | _ReSharper*/
108 | *.[Rr]e[Ss]harper
109 | *.DotSettings.user
110 |
111 | # JustCode is a .NET coding add-in
112 | .JustCode
113 |
114 | # TeamCity is a build add-in
115 | _TeamCity*
116 |
117 | # DotCover is a Code Coverage Tool
118 | *.dotCover
119 |
120 | # NCrunch
121 | _NCrunch_*
122 | .*crunch*.local.xml
123 | nCrunchTemp_*
124 |
125 | # MightyMoose
126 | *.mm.*
127 | AutoTest.Net/
128 |
129 | # Web workbench (sass)
130 | .sass-cache/
131 |
132 | # Installshield output folder
133 | [Ee]xpress/
134 |
135 | # DocProject is a documentation generator add-in
136 | DocProject/buildhelp/
137 | DocProject/Help/*.HxT
138 | DocProject/Help/*.HxC
139 | DocProject/Help/*.hhc
140 | DocProject/Help/*.hhk
141 | DocProject/Help/*.hhp
142 | DocProject/Help/Html2
143 | DocProject/Help/html
144 |
145 | # Click-Once directory
146 | publish/
147 |
148 | # Publish Web Output
149 | *.[Pp]ublish.xml
150 | *.azurePubxml
151 | # TODO: Comment the next line if you want to checkin your web deploy settings
152 | # but database connection strings (with potential passwords) will be unencrypted
153 | #*.pubxml
154 | *.publishproj
155 |
156 | # Microsoft Azure Web App publish settings. Comment the next line if you want to
157 | # checkin your Azure Web App publish settings, but sensitive information contained
158 | # in these scripts will be unencrypted
159 | PublishScripts/
160 |
161 | # NuGet Packages
162 | *.nupkg
163 | # The packages folder can be ignored because of Package Restore
164 | **/packages/*
165 | # except build/, which is used as an MSBuild target.
166 | !**/packages/build/
167 | # Uncomment if necessary however generally it will be regenerated when needed
168 | #!**/packages/repositories.config
169 | # NuGet v3's project.json files produces more ignoreable files
170 | *.nuget.props
171 | *.nuget.targets
172 |
173 | # Microsoft Azure Build Output
174 | csx/
175 | *.build.csdef
176 |
177 | # Microsoft Azure Emulator
178 | ecf/
179 | rcf/
180 |
181 | # Windows Store app package directories and files
182 | AppPackages/
183 | BundleArtifacts/
184 | Package.StoreAssociation.xml
185 | _pkginfo.txt
186 |
187 | # Visual Studio cache files
188 | # files ending in .cache can be ignored
189 | *.[Cc]ache
190 | # but keep track of directories ending in .cache
191 | !*.[Cc]ache/
192 |
193 | # Others
194 | ClientBin/
195 | ~$*
196 | *~
197 | *.dbmdl
198 | *.dbproj.schemaview
199 | *.jfm
200 | *.pfx
201 | *.publishsettings
202 | node_modules/
203 | orleans.codegen.cs
204 |
205 | # Since there are multiple workflows, uncomment next line to ignore bower_components
206 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
207 | #bower_components/
208 |
209 | # RIA/Silverlight projects
210 | Generated_Code/
211 |
212 | # Backup & report files from converting an old project file
213 | # to a newer Visual Studio version. Backup files are not needed,
214 | # because we have git ;-)
215 | _UpgradeReport_Files/
216 | Backup*/
217 | UpgradeLog*.XML
218 | UpgradeLog*.htm
219 |
220 | # SQL Server files
221 | *.mdf
222 | *.ldf
223 |
224 | # Business Intelligence projects
225 | *.rdl.data
226 | *.bim.layout
227 | *.bim_*.settings
228 |
229 | # Microsoft Fakes
230 | FakesAssemblies/
231 |
232 | # GhostDoc plugin setting file
233 | *.GhostDoc.xml
234 |
235 | # Node.js Tools for Visual Studio
236 | .ntvs_analysis.dat
237 |
238 | # Visual Studio 6 build log
239 | *.plg
240 |
241 | # Visual Studio 6 workspace options file
242 | *.opt
243 |
244 | # Visual Studio LightSwitch build output
245 | **/*.HTMLClient/GeneratedArtifacts
246 | **/*.DesktopClient/GeneratedArtifacts
247 | **/*.DesktopClient/ModelManifest.xml
248 | **/*.Server/GeneratedArtifacts
249 | **/*.Server/ModelManifest.xml
250 | _Pvt_Extensions
251 |
252 | # Paket dependency manager
253 | .paket/paket.exe
254 | paket-files/
255 |
256 | # FAKE - F# Make
257 | .fake/
258 |
259 | # JetBrains Rider
260 | .idea/
261 | *.sln.iml
262 |
263 | # CodeRush
264 | .cr/
265 |
266 | # Python Tools for Visual Studio (PTVS)
267 | __pycache__/
268 | *.pyc
269 |
270 | # Ignore azureite local db
271 | .azurite/
272 |
273 | # Ingore generated code files
274 | **/*.generated.cs
--------------------------------------------------------------------------------
/explainpowershell.helpcollector/HelpCollector.Functions.ps1:
--------------------------------------------------------------------------------
1 | function ConvertTo-LearnDocumentationUri {
2 | [CmdletBinding()]
3 | param(
4 | [Parameter(Mandatory)]
5 | [string]$Uri
6 | )
7 |
8 | $normalized = $Uri.Trim()
9 | if ([string]::IsNullOrWhiteSpace($normalized)) {
10 | return $Uri
11 | }
12 |
13 | # Prefer HTTPS
14 | $normalized = $normalized -replace '^http://', 'https://'
15 |
16 | # docs.microsoft.com redirects to learn.microsoft.com; normalize for consistency.
17 | $normalized = $normalized -replace '^https://docs\.microsoft\.com', 'https://learn.microsoft.com'
18 |
19 | return $normalized
20 | }
21 |
22 | function Get-TitleFromHtml {
23 | [CmdletBinding()]
24 | param(
25 | [Parameter(Mandatory)]
26 | [string]$Html
27 | )
28 |
29 | $m = [regex]::Match($Html, ']*>(.*?)
', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase -bor [System.Text.RegularExpressions.RegexOptions]::Singleline)
30 | if ($m.Success) {
31 | return ([System.Net.WebUtility]::HtmlDecode($m.Groups[1].Value) -replace '<[^>]+>', '').Trim()
32 | }
33 |
34 | return $null
35 | }
36 |
37 | function Get-SynopsisFromHtml {
38 | [CmdletBinding()]
39 | param(
40 | [Parameter(Mandatory)]
41 | [string]$Html,
42 |
43 | [Parameter()]
44 | [string]$Cmd
45 | )
46 |
47 | if ([string]::IsNullOrWhiteSpace($Html)) {
48 | return $null
49 | }
50 |
51 | $regexOptions = [System.Text.RegularExpressions.RegexOptions]::IgnoreCase -bor [System.Text.RegularExpressions.RegexOptions]::Singleline
52 |
53 | # 1) Learn pages usually expose a meta description.
54 | $meta = [regex]::Match($Html, '', $regexOptions)
55 | if ($meta.Success) {
56 | return ([System.Net.WebUtility]::HtmlDecode($meta.Groups[1].Value)).Trim()
57 | }
58 |
59 | # 2) Learn pages often have a summary paragraph.
60 | $summary = [regex]::Match($Html, ']*class=["\'']summary["\''][^>]*>(.*?)
', $regexOptions)
61 | if ($summary.Success) {
62 | $text = $summary.Groups[1].Value
63 | $text = [System.Net.WebUtility]::HtmlDecode($text)
64 | $text = ($text -replace '<[^>]+>', '').Trim()
65 | if (-not [string]::IsNullOrWhiteSpace($text)) {
66 | return $text
67 | }
68 | }
69 |
70 | # 3) About_language_keywords sometimes has a per-keyword section.
71 | if (-not [string]::IsNullOrWhiteSpace($Cmd)) {
72 | $escapedCmd = [regex]::Escape($Cmd)
73 | $pattern = ']*id=[''"]' + $escapedCmd + '[''"][^>]*>.*?
\s*]*>(.*?)
'
74 | $section = [regex]::Match($Html, $pattern, $regexOptions)
75 | if ($section.Success) {
76 | $text = $section.Groups[1].Value
77 | $text = [System.Net.WebUtility]::HtmlDecode($text)
78 | $text = ($text -replace '<[^>]+>', '').Trim()
79 | if (-not [string]::IsNullOrWhiteSpace($text)) {
80 | return $text
81 | }
82 | }
83 | }
84 |
85 | return $null
86 | }
87 |
88 | function Get-SynopsisFromUri {
89 | [CmdletBinding()]
90 | param(
91 | [Parameter(Mandatory)]
92 | [string]$Uri,
93 |
94 | [Parameter()]
95 | [string]$Cmd
96 | )
97 |
98 | $normalizedUri = ConvertTo-LearnDocumentationUri -Uri $Uri
99 |
100 | try {
101 | # Use Invoke-WebRequest to reliably get HTML content; IRM sometimes tries to parse.
102 | $response = Invoke-WebRequest -Uri $normalizedUri -ErrorAction Stop
103 | $html = $response.Content
104 |
105 | $synopsis = Get-SynopsisFromHtml -Html $html -Cmd $Cmd
106 | if (-not [string]::IsNullOrWhiteSpace($synopsis)) {
107 | return @($true, $synopsis)
108 | }
109 |
110 | $title = Get-TitleFromHtml -Html $html
111 | return @($false, $title)
112 | }
113 | catch {
114 | return @($false, $null)
115 | }
116 | }
117 |
118 | function Get-Synopsis {
119 | param(
120 | $Help,
121 | $Cmd,
122 | $DocumentationLink,
123 | $description
124 | )
125 |
126 | if ($null -eq $help) {
127 | return @($null, $null)
128 | }
129 |
130 | $synopsis = $help.Synopsis.Trim()
131 |
132 | if ($synopsis -like '') {
133 | Write-Verbose "$($cmd.name) - Empty synopsis, trying to get synopsis from description."
134 | $description = $help.description.Text
135 | if ([string]::IsNullOrEmpty($description)) {
136 | Write-Verbose "$($cmd.name) - Empty description."
137 | }
138 | else {
139 | $synopsis = $description.Trim().Split('.')[0].Trim()
140 | }
141 | }
142 |
143 | if ($synopsis -match "^$($cmd.Name) .*[-\[\]<>]" -or $synopsis -like '') {
144 | # If synopsis starts with the name of the verb, it's not a synopsis.
145 | $synopsis = $null
146 |
147 | if ([string]::IsNullOrEmpty($DocumentationLink) -or $DocumentationLink -in $script:badUrls) {
148 | }
149 | else {
150 | Write-Verbose "$($cmd.name) - Trying to get missing synopsis from Uri"
151 | $success, $synopsis = Get-SynopsisFromUri -Uri $DocumentationLink -Cmd $cmd.Name -verbose:$false
152 |
153 | if ($null -eq $synopsis -or -not $success) {
154 | if ($synopsis -notmatch "^$($cmd.Name) .*[-\[\]<>]") {
155 | Write-Warning "!!$($cmd.name) - Bad online help uri, '$DocumentationLink' is about '$synopsis'"
156 | $script:badUrls += $DocumentationLink
157 | $DocumentationLink = $null
158 | $synopsis = $null
159 | }
160 | }
161 | }
162 | }
163 |
164 | if ($null -ne $synopsis -and $synopsis -match "^$($cmd.Name) .*[-\[\]<>]") {
165 | $synopsis = $null
166 | }
167 |
168 | return @($synopsis, $DocumentationLink)
169 | }
170 |
--------------------------------------------------------------------------------
/.tours/tour-of-the-ai-explanation-feature.tour:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://aka.ms/codetour-schema",
3 | "title": "Tour of the AI explanation feature",
4 | "steps": [
5 | {
6 | "file": "explainpowershell.analysisservice/SyntaxAnalyzer.cs",
7 | "description": "The AI explanation is intentionally *not* generated during the main AST analysis call.\n\nThis endpoint parses the PowerShell input into an AST and walks it with `AstVisitorExplainer` to produce the structured explanation nodes (the data that becomes the tree you see in the UI).\n\nThat AST-based result is returned quickly so the UI can render immediately.",
8 | "line": 25
9 | },
10 | {
11 | "file": "explainpowershell.analysisservice/SyntaxAnalyzer.cs",
12 | "description": "Key design choice: the AST endpoint always sets `AiExplanation` to an empty string.\n\nThe AI summary is fetched via a separate endpoint *after* the AST explanation tree is available. This keeps the main analysis deterministic and avoids coupling UI responsiveness to an external AI call.",
13 | "line": 82
14 | },
15 | {
16 | "file": "explainpowershell.frontend/Pages/Index.razor.cs",
17 | "description": "Frontend starts by calling the AST analysis endpoint (`SyntaxAnalyzer`).\n\nThis request returns the expanded code plus a flat list of explanation nodes (each has an `Id` and optional `ParentId`).",
18 | "line": 120
19 | },
20 | {
21 | "file": "explainpowershell.frontend/Pages/Index.razor.cs",
22 | "description": "Once the AST analysis result comes back, the frontend builds the explanation tree (based on `Id` / `ParentId`) for display in the `MudTreeView`.\n\nAt this point, the user already has a useful explanation from the AST visitor.",
23 | "line": 152
24 | },
25 | {
26 | "file": "explainpowershell.frontend/Pages/Index.razor.cs",
27 | "description": "Immediately after the tree is available, the AI request is kicked off *in the background*.\n\nNotice the fire-and-forget pattern (`_ = ...`) so the AST UI is not blocked while the AI endpoint runs.",
28 | "line": 157
29 | },
30 | {
31 | "file": "explainpowershell.frontend/Pages/Index.razor.cs",
32 | "description": "`LoadAiExplanationAsync` constructs a payload containing:\n- the original PowerShell code\n- the full AST analysis result\n\nThen it POSTs to the backend `AiExplanation` endpoint.\n\nIf the call fails, the UI silently continues without the AI summary (AI is treated as optional).",
33 | "line": 160
34 | },
35 | {
36 | "file": "explainpowershell.frontend/Clients/SyntaxAnalyzerClient.cs",
37 | "description": "This is the actual HTTP call that requests the AI explanation.\n\nIt sends both the code and the AST result so the backend can build a prompt that is grounded in the already-produced explanation nodes and help metadata.",
38 | "line": 69
39 | },
40 | {
41 | "file": "explainpowershell.frontend/Pages/Index.razor",
42 | "description": "The UI has a dedicated 'AI explanation' card.\n\nIt's only shown when either:\n- `AiExplanationLoading` is true (spinner), or\n- a non-empty `AiExplanation` has arrived.\n\nThis makes the feature feel additive: the AST explanation tree appears first, then the AI summary appears when ready.",
43 | "line": 34
44 | },
45 | {
46 | "file": "explainpowershell.analysisservice/AiExplanationFunction.cs",
47 | "description": "Backend entrypoint for the AI feature: an Azure Function named `AiExplanation`.\n\nIt accepts an `AiExplanationRequest` that includes both the PowerShell code and the `AnalysisResult` produced earlier by the AST endpoint.",
48 | "line": 23
49 | },
50 | {
51 | "file": "explainpowershell.analysisservice/AiExplanationFunction.cs",
52 | "description": "After validating/deserializing the request, this function delegates the real work to `IAiExplanationService.GenerateAsync(...)`.\n\nThis separation keeps the HTTP handler thin and makes the behavior easier to test.",
53 | "line": 63
54 | },
55 | {
56 | "file": "explainpowershell.analysisservice/Program.cs",
57 | "description": "The AI feature is wired up through DI.\n\nA `ChatClient` is registered only when configuration is present (`Endpoint`, `ApiKey`, `DeploymentName`) and `Enabled` is true. If not configured, the factory returns `null` and AI remains disabled.",
58 | "line": 36
59 | },
60 | {
61 | "file": "explainpowershell.analysisservice/Services/AiExplanationOptions.cs",
62 | "description": "AI behavior is controlled via `AiExplanationOptions`:\n- the system prompt (how the AI should behave)\n- an example prompt/response (few-shot guidance)\n- payload size guardrails (`MaxPayloadCharacters`)\n- request timeout (`RequestTimeoutSeconds`)\n\nThese settings are loaded from the `AiExplanation` config section.",
63 | "line": 6
64 | },
65 | {
66 | "file": "explainpowershell.analysisservice/Services/AiExplanationService.cs",
67 | "description": "First guardrail: if DI did not create a `ChatClient`, the service returns `(null, null)` and logs that AI is unavailable.\n\nThis is what makes the feature optional without breaking the main AST explanation flow.",
68 | "line": 30
69 | },
70 | {
71 | "file": "explainpowershell.analysisservice/Services/AiExplanationService.cs",
72 | "description": "Prompt grounding & size safety: the service builds a ‘slim’ version of the analysis result and serializes it to JSON for the prompt.\n\nIf the JSON is too large, it progressively reduces details (e.g., trims help fields, limits explanation count) so the request stays under `MaxPayloadCharacters`.",
73 | "line": 57
74 | },
75 | {
76 | "file": "explainpowershell.analysisservice/Services/AiExplanationService.cs",
77 | "description": "Finally, the service builds a chat message list and calls `CompleteChatAsync`.\n\nThe response is reduced to a best-effort single sentence (per the prompt), and the model name is returned too so the UI can display what model produced the summary.",
78 | "line": 92
79 | },
80 | {
81 | "file": "explainpowershell.analysisservice.tests/Invoke-AiExplanation.Tests.ps1",
82 | "description": "The AI endpoint has Pester integration tests.\n\nNotably, there’s coverage for:\n- accepting a valid `AnalysisResult` payload\n- handling very large payloads (50KB+) gracefully (exercise the payload reduction path)\n- an end-to-end workflow: call SyntaxAnalyzer first, then call AiExplanation with that output.",
83 | "line": 3
84 | }
85 | ]
86 | }
87 |
--------------------------------------------------------------------------------
/explainpowershell.analysisservice.tests/tests/SyntaxAnalyzerFunction.tests.cs:
--------------------------------------------------------------------------------
1 | #nullable enable
2 |
3 | using System;
4 | using System.Collections.Generic;
5 | using System.IO;
6 | using System.Net;
7 | using System.Security.Claims;
8 | using System.Text;
9 | using System.Text.Json;
10 | using System.Threading.Tasks;
11 | using explainpowershell.models;
12 | using ExplainPowershell.SyntaxAnalyzer;
13 | using Microsoft.Azure.Functions.Worker;
14 | using Microsoft.Azure.Functions.Worker.Http;
15 | using Microsoft.Extensions.Logging;
16 | using NUnit.Framework;
17 |
18 | namespace ExplainPowershell.SyntaxAnalyzer.Tests
19 | {
20 | [TestFixture]
21 | public class SyntaxAnalyzerFunctionTests
22 | {
23 | [Test]
24 | public async Task Run_DoesNotRequireAzureWebJobsStorage_WhenHelpRepositoryInjected()
25 | {
26 | Environment.SetEnvironmentVariable("AzureWebJobsStorage", null);
27 |
28 | var loggerFactory = LoggerFactory.Create(_ => { });
29 | var logger = loggerFactory.CreateLogger();
30 |
31 | var helpRepository = new InMemoryHelpRepository();
32 | var function = new SyntaxAnalyzerFunction(logger, helpRepository);
33 |
34 | var request = new Code { PowershellCode = "Get-Process" };
35 | var bodyJson = JsonSerializer.Serialize(request);
36 |
37 | var req = new TestHttpRequestData(bodyJson);
38 | var resp = await function.Run(req);
39 |
40 | Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK));
41 |
42 | resp.Body.Position = 0;
43 | using var reader = new StreamReader(resp.Body, Encoding.UTF8);
44 | var responseJson = await reader.ReadToEndAsync();
45 |
46 | var analysisResult = JsonSerializer.Deserialize(responseJson, new JsonSerializerOptions
47 | {
48 | PropertyNameCaseInsensitive = true
49 | });
50 |
51 | Assert.That(analysisResult, Is.Not.Null);
52 | Assert.That(analysisResult!.ExpandedCode, Is.Not.Empty);
53 | }
54 |
55 | private sealed class TestHttpRequestData : HttpRequestData
56 | {
57 | private readonly MemoryStream body;
58 | private readonly HttpHeadersCollection headers = new();
59 | private readonly Uri url;
60 |
61 | public TestHttpRequestData(string bodyJson)
62 | : base(new TestFunctionContext())
63 | {
64 | body = new MemoryStream(Encoding.UTF8.GetBytes(bodyJson));
65 | url = new Uri("http://127.0.0.1/api/SyntaxAnalyzer");
66 | }
67 |
68 | public override Stream Body => body;
69 |
70 | public override HttpHeadersCollection Headers => headers;
71 |
72 | public override IReadOnlyCollection Cookies => Array.Empty();
73 |
74 | public override Uri Url => url;
75 |
76 | public override IEnumerable Identities => Array.Empty();
77 |
78 | public override string Method => "POST";
79 |
80 | public override HttpResponseData CreateResponse()
81 | {
82 | return new TestHttpResponseData(FunctionContext);
83 | }
84 | }
85 |
86 | private sealed class TestHttpResponseData : HttpResponseData
87 | {
88 | public TestHttpResponseData(FunctionContext functionContext)
89 | : base(functionContext)
90 | {
91 | Headers = new HttpHeadersCollection();
92 | Body = new MemoryStream();
93 | Cookies = new TestHttpCookies();
94 | }
95 |
96 | public override HttpStatusCode StatusCode { get; set; }
97 |
98 | public override HttpHeadersCollection Headers { get; set; }
99 |
100 | public override Stream Body { get; set; }
101 |
102 | public override HttpCookies Cookies { get; }
103 | }
104 |
105 | private sealed class TestHttpCookies : HttpCookies
106 | {
107 | private readonly Dictionary cookies = new(StringComparer.OrdinalIgnoreCase);
108 |
109 | public override void Append(IHttpCookie cookie)
110 | {
111 | cookies[cookie.Name] = cookie;
112 | }
113 |
114 | public override void Append(string name, string value)
115 | {
116 | cookies[name] = new TestHttpCookie { Name = name, Value = value };
117 | }
118 |
119 | public override IHttpCookie CreateNew()
120 | {
121 | return new TestHttpCookie();
122 | }
123 |
124 | private sealed class TestHttpCookie : IHttpCookie
125 | {
126 | public string Name { get; set; } = string.Empty;
127 | public string Value { get; set; } = string.Empty;
128 | public string? Domain { get; set; }
129 | public string? Path { get; set; }
130 | public DateTimeOffset? Expires { get; set; }
131 | public bool? Secure { get; set; }
132 | public bool? HttpOnly { get; set; }
133 | public SameSite SameSite { get; set; }
134 | public double? MaxAge { get; set; }
135 | }
136 | }
137 |
138 | private sealed class TestFunctionContext : FunctionContext
139 | {
140 | private IDictionary