#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") }