#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]" -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 = @( # Export / Import "1. Export policies" "2. Import policies" "7. Export assignments to CSV/Markdown" "5. Backup assignments" "6. Restore assignments" # Bulk operations "3. Bulk app assignment" "4. Bulk assignment manager (policies)" "8. Bulk rename policies" "21. Bulk delete policies" "9. Bulk device operations" # Baselines & compliance "10. Deploy baseline" "11. Deploy baseline (dry-run / WhatIf)" "17. Deploy CIS M365 baseline" "19. Document Conditional Access policies" # Reporting "16. Generate reports" "20. Export Entra role membership" # Tenant & auth admin "12. Initialize auth (one-time setup)" "13. Refresh tenant names" "18. Rotate app secret" "14. Delete local tenant auth only" "15. Delete tenant auth and app registration" "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" } 21 { $script = "Scripts/Bulk-DeletePolicies.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("Action") $commonParams.Remove("Mode") $commonParams.Remove("WhatIf") switch($choiceNumber) { 1 { $commonParams.Interactive = $true; $commonParams.Action = "Export" } 2 { $commonParams.Interactive = $true; $commonParams.Action = "Import" } 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($choiceNumber -eq 20) { $roleScript = Join-Path $projectRoot "Scripts/Export-EntraRoleMembership.ps1" $roleParams = @{ TenantId = $TenantId; AuthMode = $AuthMode } if($AppId) { $roleParams.AppId = $AppId } if($Secret) { $roleParams.Secret = $Secret } elseif($Certificate) { $roleParams.Certificate = $Certificate } if($SettingsFile) { $roleParams.SettingsFile = $SettingsFile } $csvOut = Read-Host "CSV output path (Enter for default: -Entra-AllRoleMemberships.csv)" if(-not [string]::IsNullOrWhiteSpace($csvOut)) { $roleParams.CsvPath = $csvOut } & $roleScript @roleParams Write-Host "`nPress any key to return to the menu..." -ForegroundColor DarkGray $null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") continue } if($choiceNumber -eq 19) { $caScript = Join-Path $projectRoot "Scripts/Invoke-ConditionalAccessDocumentation.ps1" $ans = Read-Host "Export to Excel as well? [y/N]" $doExcel = $ans -like 'y*' $caParams = @{} if($doExcel) { $caParams.ExportExcel = $true $xlPath = Read-Host "Excel output path (Enter for default: Scripts/ConditionalAccessDocumentation.xlsx)" if(-not [string]::IsNullOrWhiteSpace($xlPath)) { $caParams.ExcelPath = $xlPath } } & $caScript @caParams 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") }