Files
macOS_IntuneManagement/Start-IntuneToolkit.ps1
tomas.kracmar d3e0769799 release: v4.1.0 — restructure entry points, add CIS baselines, reporting tools and fzf hints
- Restructure launchers: Start-IntuneToolkit.ps1 moves to repo root;
  Start-HeadlessIntune.ps1 moves to Scripts/; TUI helper moves to Scripts/Private/
- Add AGENTS.md with project architecture, entry points, and security notes
- Add CIS M365 baseline assets (CISM365-v7, M365-CIS-Rapid) and reporting scripts
- Add Python reporting utilities (Export-SettingsReport, Export-AssignmentReport,
  Export-ObjectInventoryReport) and CA wizard helpers
- Update Deploy-IntuneBaseline.ps1 with Merge conflict resolution, ReportPath,
  and optimized group loading
- Update Initialize-IntuneAuth.ps1 with -RotateSecret and configurable secret expiry
- Update Extensions for Settings Catalog definition auto-export
- Update README with v4.1.0, new entry points and script catalog
- Bump VERSION to 4.1.0
- Harden .gitignore against .DS_Store, __pycache__, .venv-pdf/, local exports,
  Settings.json and IntuneManagement.log
2026-06-14 15:24:42 +02:00

563 lines
19 KiB
PowerShell

#requires -Version 5.1
<#
.SYNOPSIS
Unified launcher for the macOS Intune Toolkit.
.DESCRIPTION
Presents a single terminal UI to choose from all available
headless Intune management tools. Passes through common auth parameters.
Press Esc to go back to the menu from any selection.
.EXAMPLE
./Start-IntuneToolkit.ps1 -TenantId "contoso.onmicrosoft.com"
#>
[CmdletBinding()]
param(
[string]$TenantId,
[string]$AppId,
[string]$Secret,
[string]$Certificate,
[ValidateSet("AppOnly","Browser","DeviceCode")]
[string]$AuthMode = "AppOnly",
[string]$RedirectUri,
[string]$SettingsFile
)
$ErrorActionPreference = "Stop"
#region Helper functions
function Test-FzfAvailable
{
return [bool](Get-Command fzf -ErrorAction SilentlyContinue)
}
function Show-FzfHint
{
if(Test-FzfAvailable) { return }
Write-Host "`n[fzf not found]" -ForegroundColor Yellow -NoNewline
Write-Host " Install fzf for the best interactive menu experience.`n" -ForegroundColor DarkGray
if($IsMacOS)
{
Write-Host " macOS: brew install fzf" -ForegroundColor DarkGray
}
elseif($IsLinux)
{
Write-Host " Debian/Ubuntu: sudo apt install fzf" -ForegroundColor DarkGray
Write-Host " Fedora: sudo dnf install fzf" -ForegroundColor DarkGray
Write-Host " Arch: sudo pacman -S fzf" -ForegroundColor DarkGray
}
else
{
Write-Host " Windows: winget install junegunn.fzf" -ForegroundColor DarkGray
Write-Host " choco install fzf" -ForegroundColor DarkGray
}
Write-Host " (Falling back to numbered menus for now.)`n" -ForegroundColor DarkGray
}
function Show-FzfMenu
{
param(
[Parameter(Mandatory)]
[string[]]$Items,
[string]$Header = "Select one"
)
$selected = $Items | fzf --header=$Header
if(-not $selected) { return $null }
return $selected
}
function Show-NumberedMenu
{
param(
[Parameter(Mandatory)]
[string[]]$Items,
[string]$Header = "Select one"
)
Write-Host "`n$Header" -ForegroundColor Cyan
for($i=0; $i -lt $Items.Count; $i++)
{
Write-Host " $($i+1). $($Items[$i])"
}
$choice = Read-Host "Enter a number (0 to exit)"
if($choice -eq "0") { return "EXIT" }
$index = [int]$choice - 1
if($index -ge 0 -and $index -lt $Items.Count)
{
return $Items[$index]
}
return $null
}
function Select-MenuItem
{
param(
[Parameter(Mandatory)]
[string[]]$Items,
[string]$Header = "Select one"
)
if(Test-FzfAvailable)
{
return Show-FzfMenu -Items $Items -Header $Header
}
return Show-NumberedMenu -Items $Items -Header $Header
}
#endregion
$projectRoot = $PSScriptRoot
#region Tenant selection
function Get-DefaultSettingsPath
{
if($IsWindows -or $env:OS -eq "Windows_NT")
{
if($env:LOCALAPPDATA) { return (Join-Path $env:LOCALAPPDATA "macOS_IntuneManagement\Settings.json") }
return (Join-Path $env:USERPROFILE "AppData\Local\macOS_IntuneManagement\Settings.json")
}
if($IsMacOS) { return (Join-Path $HOME "Library/Application Support/macOS_IntuneManagement/Settings.json") }
return (Join-Path $HOME ".local/share/macOS_IntuneManagement/Settings.json")
}
function Get-SavedTenants
{
param([string]$SettingsPath)
if(-not (Test-Path $SettingsPath)) { return @() }
try
{
$raw = Get-Content $SettingsPath -Raw -ErrorAction Stop | ConvertFrom-Json -AsHashtable -ErrorAction Stop
$tenants = @()
foreach($key in $raw.Keys)
{
if($key -match '^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')
{
$name = $null
if($raw[$key] -is [hashtable] -and $raw[$key].ContainsKey('TenantName'))
{
$name = $raw[$key]['TenantName']
}
elseif($raw[$key] -is [psobject] -and $raw[$key].PSObject.Properties['TenantName'])
{
$name = $raw[$key].TenantName
}
$display = if($name) { "$name ($key)" } else { $key }
$tenants += [PSCustomObject]@{ TenantId = $key; TenantName = $name; Display = $display }
}
}
return $tenants | Sort-Object Display
}
catch
{
return @()
}
}
function Update-TenantNameCache
{
param([string]$SettingsPath, [string]$TenantId, [string]$TenantName)
if(-not (Test-Path $SettingsPath)) { return }
try
{
$raw = Get-Content $SettingsPath -Raw -ErrorAction Stop | ConvertFrom-Json -AsHashtable -ErrorAction Stop
if($raw[$TenantId] -is [hashtable])
{
$raw[$TenantId]['TenantName'] = $TenantName
}
else
{
$raw[$TenantId] = @{ TenantName = $TenantName }
}
$raw | ConvertTo-Json -Depth 10 | Set-Content -Path $SettingsPath -Force
}
catch { }
}
function Resolve-TenantName
{
param([string]$TenantId, [string]$SettingsPath)
$settingsObj = $null
try
{
$settingsObj = Get-Content $SettingsPath -Raw -ErrorAction Stop | ConvertFrom-Json -AsHashtable -ErrorAction Stop
}
catch { return $null }
$tenantNode = $settingsObj[$TenantId]
if(-not $tenantNode) { return $null }
$appId = $tenantNode['GraphAzureAppId']
if(-not $appId) { return $null }
$secret = $tenantNode['GraphAzureAppSecret']
$cert = $tenantNode['GraphAzureAppCert']
if(-not $secret -and $IsMacOS)
{
try
{
$keychainSecret = security find-generic-password -a "IntuneManagement" -s "IntuneMgmt-$AppId" -w 2>$null
if($keychainSecret) { $secret = $keychainSecret }
}
catch { }
}
$runtimeModule = Join-Path $projectRoot "Runtime/IntuneManagement.Runtime.psd1"
if(-not (Test-Path $runtimeModule)) { return $null }
$invokeParams = @{
Silent = $true
JSonSettings = $true
JSonFile = $SettingsPath
TenantId = $TenantId
AppId = $appId
AuthMode = "AppOnly"
}
if($secret) { $invokeParams.Secret = $secret }
elseif($cert) { $invokeParams.Certificate = $cert }
try
{
Import-Module $runtimeModule -Force | Out-Null
Initialize-IntuneManagementRuntime -View "IntuneGraphAPI" @invokeParams | Out-Null
if(Get-Command Invoke-GraphRequest -ErrorAction SilentlyContinue)
{
$org = Invoke-GraphRequest "/organization" -ErrorAction Stop
if($org.value -and $org.value[0].displayName)
{
return $org.value[0].displayName
}
}
}
catch { }
return $null
}
$settingsPath = $SettingsFile
if(-not $settingsPath) { $settingsPath = Get-DefaultSettingsPath }
if(-not $TenantId)
{
$tenants = Get-SavedTenants -SettingsPath $settingsPath
$tenantOptions = @()
foreach($t in $tenants)
{
$tenantOptions += $t.Display
}
$tenantOptions += "[+ Onboard new tenant]"
$tenantOptions += "[Exit]"
$selectedTenantDisplay = Select-MenuItem -Items $tenantOptions -Header "Select a tenant"
if(-not $selectedTenantDisplay -or $selectedTenantDisplay -eq "[Exit]")
{
exit 0
}
elseif($selectedTenantDisplay -eq "[+ Onboard new tenant]")
{
$TenantId = Read-Host "Enter the new Tenant ID (GUID)"
if(-not $TenantId)
{
Write-Host "No tenant ID provided. Exiting." -ForegroundColor Yellow
exit 0
}
$initPath = Join-Path $projectRoot "Scripts/Initialize-IntuneAuth.ps1"
& $initPath -TenantId $TenantId
Write-Host "`nOnboarding complete. Restarting launcher..." -ForegroundColor Green
Start-Sleep -Seconds 1
$restartParams = @{}
if($SettingsFile) { $restartParams.SettingsFile = $SettingsFile }
& $PSCommandPath @restartParams
exit 0
}
else
{
$TenantId = $selectedTenantDisplay -replace '.*\(([0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12})\)$', '$1'
if(-not $TenantId)
{
$TenantId = $selectedTenantDisplay
}
}
}
$currentTenant = (Get-SavedTenants -SettingsPath $settingsPath) | Where-Object { $_.TenantId -eq $TenantId } | Select-Object -First 1
if(-not $currentTenant -or -not $currentTenant.TenantName)
{
Write-Host "`nResolving tenant name..." -ForegroundColor Cyan
$resolvedName = Resolve-TenantName -TenantId $TenantId -SettingsPath $settingsPath
if($resolvedName)
{
Update-TenantNameCache -SettingsPath $settingsPath -TenantId $TenantId -TenantName $resolvedName
Write-Host "Cached tenant name: $resolvedName" -ForegroundColor Green
$currentTenant = [PSCustomObject]@{ TenantId = $TenantId; TenantName = $resolvedName; Display = "$resolvedName ($TenantId)" }
}
}
#endregion
Show-FzfHint
# Build common parameter hashtable
$commonParams = @{
TenantId = $TenantId
AppId = $AppId
Secret = $Secret
Certificate = $Certificate
AuthMode = $AuthMode
RedirectUri = $RedirectUri
SettingsFile = $SettingsFile
}
$menuItems = @(
"18. Rotate app secret"
"17. Deploy CIS M365 baseline"
"16. Generate reports"
"15. Delete tenant auth and app registration"
"14. Delete local tenant auth only"
"13. Refresh tenant names"
"12. Initialize auth (one-time setup)"
"11. Deploy baseline (dry-run / WhatIf)"
"10. Deploy baseline"
"9. Bulk device operations"
"8. Bulk rename policies"
"7. Export assignments to CSV/Markdown"
"6. Restore assignments"
"5. Backup assignments"
"4. Bulk assignment manager (policies)"
"3. Bulk app assignment"
"2. Import policies"
"1. Export policies"
"0. Exit"
)
while($true)
{
Clear-Host
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " macOS Intune Toolkit" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
if($currentTenant -and $currentTenant.TenantName)
{
Write-Host " Tenant: $($currentTenant.TenantName) ($TenantId)" -ForegroundColor Green
}
else
{
Write-Host " Tenant: $TenantId" -ForegroundColor Green
}
Write-Host " Press Esc to go back, Space to select" -ForegroundColor DarkGray
$selection = Select-MenuItem -Items $menuItems -Header "Select a tool to launch"
if(-not $selection)
{
continue
}
if($selection -eq "EXIT" -or $selection -like "*0. Exit*")
{
Write-Host "`nExiting. Goodbye!" -ForegroundColor Yellow
exit 0
}
$choiceNumber = [int]($selection -replace "^(\d+)\..*$", '$1')
$script = $null
switch($choiceNumber)
{
1 { $script = "Scripts/Start-HeadlessIntune.ps1" }
2 { $script = "Scripts/Start-HeadlessIntune.ps1" }
3 { $script = "Scripts/Bulk-AppAssignment.ps1" }
4 { $script = "Scripts/Bulk-AssignmentManager.ps1" }
5 { $script = "Scripts/Backup-Restore-Assignments.ps1" }
6 { $script = "Scripts/Backup-Restore-Assignments.ps1" }
7 { $script = "Scripts/Export-AssignmentsToCsv.ps1" }
8 { $script = "Scripts/Bulk-RenamePolicies.ps1" }
9 { $script = "Scripts/Bulk-DeviceOperations.ps1" }
10 { $script = "Scripts/Deploy-IntuneBaseline.ps1" }
11 { $script = "Scripts/Deploy-IntuneBaseline.ps1" }
12 { $script = "Scripts/Initialize-IntuneAuth.ps1" }
14 { $script = "Scripts/Initialize-IntuneAuth.ps1" }
15 { $script = "Scripts/Initialize-IntuneAuth.ps1" }
18 { $script = "Scripts/Initialize-IntuneAuth.ps1" }
default { }
}
# Clear any mode-specific params from previous loop iteration
$commonParams.Remove("Interactive")
$commonParams.Remove("Mode")
$commonParams.Remove("WhatIf")
switch($choiceNumber)
{
1 { $commonParams.Interactive = $true }
2 { $commonParams.Interactive = $true }
5 { $commonParams.Mode = "Backup" }
6 { $commonParams.Mode = "Restore" }
11 { $commonParams.WhatIf = $true }
}
if($choiceNumber -eq 13)
{
Write-Host "`nRefreshing tenant names..." -ForegroundColor Cyan
$tenantsToRefresh = Get-SavedTenants -SettingsPath $settingsPath
$refreshed = 0
$failed = 0
foreach($t in $tenantsToRefresh)
{
Write-Host " Resolving $($t.TenantId) ..." -ForegroundColor DarkGray -NoNewline
$name = Resolve-TenantName -TenantId $t.TenantId -SettingsPath $settingsPath
if($name)
{
Update-TenantNameCache -SettingsPath $settingsPath -TenantId $t.TenantId -TenantName $name
Write-Host " -> $name" -ForegroundColor Green
$refreshed++
if($t.TenantId -eq $TenantId)
{
$currentTenant = [PSCustomObject]@{ TenantId = $TenantId; TenantName = $name; Display = "$name ($TenantId)" }
}
}
else
{
Write-Host " -> FAILED" -ForegroundColor Red
$failed++
}
}
Write-Host "`nRefresh complete. Success: $refreshed, Failed: $failed" -ForegroundColor Cyan
Write-Host "`nPress any key to return to the menu..." -ForegroundColor DarkGray
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
continue
}
if($choiceNumber -eq 16)
{
$reportTypes = @("Settings","Assignments","ObjectInventory","All")
$reportType = Select-MenuItem -Items $reportTypes -Header "Select report type"
if(-not $reportType) { continue }
$dataSource = Select-MenuItem -Items @("Use existing backup","Pull fresh data from tenant") -Header "Data source"
if(-not $dataSource) { continue }
$backupRoot = $null
$exportPath = $null
if($dataSource -like "*fresh*")
{
$exportPath = Read-Host "Export path (where to save fresh data)"
if([string]::IsNullOrWhiteSpace($exportPath)) { Write-Host "Required." -ForegroundColor Red; continue }
$backupRoot = $exportPath
}
else
{
$backupRoot = Read-Host "Backup root path"
if([string]::IsNullOrWhiteSpace($backupRoot)) { Write-Host "Required." -ForegroundColor Red; continue }
if(-not (Test-Path $backupRoot)) { Write-Host "Path not found: $backupRoot" -ForegroundColor Red; continue }
}
$outputDir = Read-Host "Output directory for reports"
if([string]::IsNullOrWhiteSpace($outputDir)) { Write-Host "Required." -ForegroundColor Red; continue }
$includeAssignments = $false
if($reportType -in @("Settings","All"))
{
$ans = Read-Host "Include assignment columns in settings report? [y/N]"
$includeAssignments = $ans -like 'y*'
}
$headlessScript = Join-Path $projectRoot "Scripts/Start-HeadlessIntune.ps1"
if($dataSource -like "*fresh*")
{
Write-Host "`nExporting policies from tenant $TenantId ..." -ForegroundColor Cyan
$exportParams = @{ Action = "Export"; TenantId = $TenantId; ExportPath = $exportPath; IncludeAssignments = $true; AuthMode = $AuthMode }
if($AppId) { $exportParams.AppId = $AppId }
if($Secret) { $exportParams.Secret = $Secret }
elseif($Certificate) { $exportParams.Certificate = $Certificate }
if($SettingsFile) { $exportParams.SettingsFile = $SettingsFile }
& $headlessScript @exportParams
}
$genParams = @{ Action = "GenerateReports"; ReportType = $reportType; BackupRoot = $backupRoot; OutputDir = $outputDir }
if($includeAssignments) { $genParams.IncludeAssignmentsInSettings = $true }
& $headlessScript @genParams
Write-Host "`nPress any key to return to the menu..." -ForegroundColor DarkGray
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
continue
}
if($choiceNumber -eq 17)
{
$defaultBaseline = Join-Path $projectRoot "Baselines/CISM365-v7-Generated.yaml"
$baselinePath = Read-Host "Baseline YAML path (default: $defaultBaseline)"
if([string]::IsNullOrWhiteSpace($baselinePath)) { $baselinePath = $defaultBaseline }
if(-not (Test-Path $baselinePath)) { Write-Host "Not found: $baselinePath" -ForegroundColor Red; continue }
$cisMode = Select-MenuItem -Items @("Assess","Deploy") -Header "Select mode"
if(-not $cisMode) { continue }
$apply = $false
if($cisMode -eq "Deploy")
{
$ans = Read-Host "Apply changes? [y/N]"
$apply = $ans -like 'y*'
}
$allWorkloads = @("EntraID","ConditionalAccess","Exchange","SharePoint","Teams","PowerBI","Defender","Purview")
$workloadStr = Read-Host "Workloads (comma-separated, or Enter for all)"
$workloads = if([string]::IsNullOrWhiteSpace($workloadStr)) { $allWorkloads } else { $workloadStr -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ } }
$cisScript = Join-Path $projectRoot "Scripts/Deploy-CISM365Baseline.ps1"
$cisParams = @{ BaselinePath = $baselinePath; TenantId = $TenantId; Mode = $cisMode; AuthMode = $AuthMode; Workloads = $workloads }
if($apply) { $cisParams.Apply = $true }
if($AppId) { $cisParams.AppId = $AppId }
if($Secret) { $cisParams.Secret = $Secret }
elseif($Certificate) { $cisParams.Certificate = $Certificate }
& $cisScript @cisParams
Write-Host "`nPress any key to return to the menu..." -ForegroundColor DarkGray
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
continue
}
if(-not $script)
{
continue
}
$scriptPath = Join-Path $projectRoot $script
if(-not (Test-Path $scriptPath))
{
throw "Script not found: $scriptPath"
}
Write-Host "`nLaunching $script ...`n" -ForegroundColor Green
# Clone params and sanitize for scripts that don't accept the full auth set
$launchParams = $commonParams.Clone()
if($script -eq "Scripts/Initialize-IntuneAuth.ps1")
{
@("AppId","Secret","Certificate","AuthMode","RedirectUri","Interactive","Mode","WhatIf") | ForEach-Object { $launchParams.Remove($_) }
}
if($choiceNumber -eq 14)
{
$launchParams.Delete = $true
}
if($choiceNumber -eq 15)
{
$launchParams.DeleteApp = $true
}
if($choiceNumber -eq 18)
{
$launchParams.RotateSecret = $true
}
# Execute in same process so TUI flows naturally
& $scriptPath @launchParams
if($choiceNumber -eq 14 -or $choiceNumber -eq 15)
{
Write-Host "`nTenant auth deleted. Exiting." -ForegroundColor Yellow
exit 0
}
Write-Host "`nPress any key to return to the menu..." -ForegroundColor DarkGray
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
}