diff --git a/AppLifecycleAnalyzer.ps1 b/AppLifecycleAnalyzer.ps1 index 15c6797..740f7fc 100644 --- a/AppLifecycleAnalyzer.ps1 +++ b/AppLifecycleAnalyzer.ps1 @@ -42,23 +42,21 @@ this same session. Read-only is the default on purpose; this is the deliberate opt-in. .NOTES - Requires PowerShell 7 or later and the modules Microsoft.Graph.Authentication and - Microsoft.Graph.Applications. Install them once with: + Prerequisites: PowerShell 7 or later, and the modules Microsoft.Graph.Authentication and + Microsoft.Graph.Applications. Install them once with Install-Module + Microsoft.Graph.Authentication, Microsoft.Graph.Applications -Scope CurrentUser, or pass + -AutoInstallModules and the script installs what is missing without asking. - Install-Module Microsoft.Graph.Authentication, Microsoft.Graph.Applications -Scope CurrentUser - - Or pass -AutoInstallModules and the script installs what is missing without asking. - - Required permissions: the delegated Graph scopes Application.Read.All, Directory.Read.All and + RequiredPermissions: The delegated Graph scopes Application.Read.All, Directory.Read.All and AuditLog.Read.All, which you consent to at sign-in. Sign-in activity comes from AuditLog and needs Entra ID P1 or P2; without it the report still lists every app and its credentials, and the activity column says so instead of guessing. - Nothing here writes. The script reads Graph and writes one HTML file. Every cleanup command in + Writes: Nothing. The script reads Graph and writes one HTML file. Every cleanup command in the report is text for you to copy - the script never runs one, and without -RequestWriteScopes the session it opens cannot run one either. - MIT licensed. https://github.com/simon-vedder/app-lifecycle-analyzer + License: MIT. https://github.com/simon-vedder/app-lifecycle-analyzer .EXAMPLE # Sign in to your home tenant, read, write the report next to you. diff --git a/docs/commands/AppLifecycleAnalyzer.md b/docs/commands/AppLifecycleAnalyzer.md index aac0c37..72280ae 100644 --- a/docs/commands/AppLifecycleAnalyzer.md +++ b/docs/commands/AppLifecycleAnalyzer.md @@ -27,23 +27,21 @@ text for you to copy; the script never runs one. ## Requirements and notes -Requires PowerShell 7 or later and the modules Microsoft.Graph.Authentication and -Microsoft.Graph.Applications. Install them once with: +Prerequisites: PowerShell 7 or later, and the modules Microsoft.Graph.Authentication and +Microsoft.Graph.Applications. Install them once with Install-Module +Microsoft.Graph.Authentication, Microsoft.Graph.Applications -Scope CurrentUser, or pass +-AutoInstallModules and the script installs what is missing without asking. - Install-Module Microsoft.Graph.Authentication, Microsoft.Graph.Applications -Scope CurrentUser - -Or pass -AutoInstallModules and the script installs what is missing without asking. - -Required permissions: the delegated Graph scopes Application.Read.All, Directory.Read.All and +RequiredPermissions: The delegated Graph scopes Application.Read.All, Directory.Read.All and AuditLog.Read.All, which you consent to at sign-in. Sign-in activity comes from AuditLog and needs Entra ID P1 or P2; without it the report still lists every app and its credentials, and the activity column says so instead of guessing. -Nothing here writes. The script reads Graph and writes one HTML file. Every cleanup command in +Writes: Nothing. The script reads Graph and writes one HTML file. Every cleanup command in the report is text for you to copy - the script never runs one, and without -RequestWriteScopes the session it opens cannot run one either. -MIT licensed. https://github.com/simon-vedder/app-lifecycle-analyzer +License: MIT. https://github.com/simon-vedder/app-lifecycle-analyzer ## Parameters diff --git a/tools/New-CommandReference.ps1 b/tools/New-CommandReference.ps1 index 7a8e88a..a6c9c1a 100644 --- a/tools/New-CommandReference.ps1 +++ b/tools/New-CommandReference.ps1 @@ -64,6 +64,46 @@ function Format-Cell { return (($Text -replace '\s*\n\s*', ' ') -replace '\|', '\|').Trim() } +# The .NOTES block is "Label: value" with continuation lines. Rendered as-is it is a grey wall; +# as a table the permissions line is something a reader can find. +$notesDrop = @('Author', 'Version', 'Created', 'LastModified') +$notesRename = @{ RequiredPermissions = 'Permissions' } +function ConvertTo-RequirementsTable { + param([string[]]$Lines) + + $rows = [System.Collections.Generic.List[object]]::new() + foreach ($line in $Lines) { + $start = [regex]::Match($line, '^([A-Za-z][A-Za-z ]*?):\s+(.*)$') + if ($start.Success) { + $rows.Add([pscustomobject]@{ Label = $start.Groups[1].Value.Trim(); Parts = [System.Collections.Generic.List[string]]@($start.Groups[2].Value.Trim()) }) + } + elseif ($line.Trim()) { + # A line before the first label belongs to no row, and a table has nowhere to put it. A + # .NOTES block written as prose is not a label list, so it stays prose rather than losing + # its opening paragraph on the way to the site. + if (-not $rows.Count) { return $null } + $rows[$rows.Count - 1].Parts.Add($line.Trim()) + } + } + if (-not $rows.Count) { return $null } + + $out = [System.Collections.Generic.List[string]]::new() + $out.Add('| | |') + $out.Add('|---|---|') + foreach ($row in $rows) { + if ($row.Label -in $notesDrop) { continue } + $label = if ($notesRename.ContainsKey($row.Label)) { $notesRename[$row.Label] } else { $row.Label } + $out.Add("| **$label** | $(Format-Cell ($row.Parts -join ' ')) |") + } + if ($out.Count -le 2) { return $null } + return $out +} + +# An undocumented parameter and a missing example both render as a hole on the published page - +# an empty table cell, or a code block with nothing in it. Neither breaks the build, so both stay +# broken until somebody reads the site. Collected here and reported at the end instead. +$gaps = [System.Collections.Generic.List[string]]::new() + $item = Get-Item -Path $scriptPath $name = $item.Name $help = Get-Help -Name $item.FullName -Full @@ -94,6 +134,7 @@ $lines.Add('```') $lines.Add('') $notes = Format-HelpText $help.alertSet.alert +if ($notes -notmatch 'RequiredPermissions\s*:') { $gaps.Add("$name`: no RequiredPermissions in .NOTES") } if ($notes) { $lines.Add('## Requirements and notes') $lines.Add('') @@ -114,6 +155,7 @@ if ($parameters.Count) { $default = Format-Cell ([string]$parameter.defaultValue) if ($default -in '', 'None', 'False') { $default = '' } $text = Format-Cell (Format-HelpText $parameter.description) + if (-not $text) { $gaps.Add("$name -$($parameter.name): no description") } $lines.Add("| ``-$($parameter.name)`` | $type | $required | $pipeline | $default | $text |") } } @@ -122,7 +164,8 @@ else { } $lines.Add('') -$examples = @($help.examples.example) +$examples = @($help.examples.example | Where-Object { $_ -and (Format-HelpText $_.code) }) +if (-not $examples.Count) { $gaps.Add("$name`: no examples") } if ($examples.Count) { $lines.Add('## Examples') $lines.Add('') @@ -154,18 +197,48 @@ $page = ($lines -join "`n").TrimEnd() + "`n" $body = [System.Collections.Generic.List[string]]::new() if ($description) { $body.Add($description); $body.Add('') } $started = $false +$inNotes = $false +$noteLines = [System.Collections.Generic.List[string]]::new() foreach ($line in $lines) { if ($line -eq '## Syntax') { $started = $true } if (-not $started) { continue } if ($line -eq '---') { break } - $body.Add(($line -replace '^## Requirements and notes$', '## Requirements')) + if ($line -eq '## Requirements and notes') { + $body.Add('## Requirements'); $body.Add('') + $inNotes = $true; $noteLines.Clear(); continue + } + if ($inNotes) { + if ($line.StartsWith('## ')) { + $table = ConvertTo-RequirementsTable -Lines $noteLines + if ($table) { $table | ForEach-Object { $body.Add($_) } } else { $noteLines | ForEach-Object { $body.Add($_) } } + $body.Add(''); $inNotes = $false; $body.Add($line); continue + } + # Format-HelpText hands the whole .NOTES block back as one multi-line string, so it arrives + # here as a single element. Split it, or every label lands on one line. + foreach ($noteLine in ($line -split "`r?`n")) { $noteLines.Add($noteLine) } + continue + } + $body.Add($line) +} +if ($inNotes) { + $table = ConvertTo-RequirementsTable -Lines $noteLines + if ($table) { $table | ForEach-Object { $body.Add($_) } } else { $noteLines | ForEach-Object { $body.Add($_) } } + $body.Add('') } $fileName = "$($item.BaseName).md" $target = Join-Path $repoRoot 'docs' 'commands' $targetFile = Join-Path $target $fileName +if ($gaps.Count) { + Write-Warning "The published reference would have $($gaps.Count) hole(s):" + $gaps | ForEach-Object { Write-Warning " $_" } +} + if ($Check) { + if ($gaps.Count) { + throw "The help has $($gaps.Count) undocumented spot(s). Fix the comment-based help, run ./tools/New-CommandReference.ps1 and commit the result." + } if (-not (Test-Path -Path $targetFile)) { throw "Missing docs/commands/$fileName. Run ./tools/New-CommandReference.ps1 and commit the result." }