--bind=space:toggle prevented typing spaces in search queries. fzf --multi already supports Tab for selection by default.
426 lines
16 KiB
PowerShell
426 lines
16 KiB
PowerShell
#requires -Version 5.1
|
|
<#
|
|
.SYNOPSIS
|
|
Bulk rename Intune policy/app displayNames and descriptions.
|
|
.DESCRIPTION
|
|
Search and replace names or descriptions across multiple Intune object types
|
|
in a single operation. Supports regex search/replace and prefix add/strip.
|
|
Integrates with the IntuneManagement headless auth stack.
|
|
.EXAMPLE
|
|
./Scripts/Bulk-RenamePolicies.ps1 -TenantId "contoso.onmicrosoft.com"
|
|
#>
|
|
[CmdletBinding()]
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$TenantId,
|
|
|
|
[string]$AppId,
|
|
|
|
[string]$Secret,
|
|
|
|
[string]$Certificate,
|
|
|
|
[ValidateSet("AppOnly","Browser","DeviceCode")]
|
|
[string]$AuthMode = "AppOnly",
|
|
|
|
[string]$RedirectUri,
|
|
|
|
[string]$SettingsFile,
|
|
|
|
[switch]$WhatIf
|
|
)
|
|
|
|
$ErrorActionPreference = "Stop"
|
|
|
|
#region Helper functions
|
|
function Test-FzfAvailable
|
|
{
|
|
return [bool](Get-Command fzf -ErrorAction SilentlyContinue)
|
|
}
|
|
|
|
function Show-FzfMenu
|
|
{
|
|
param(
|
|
[Parameter(Mandatory)]
|
|
[string[]]$Items,
|
|
[string]$Header = "Select one",
|
|
[switch]$Multi
|
|
)
|
|
$argsList = @("--header=$Header")
|
|
if($Multi) { $argsList += "--multi" }
|
|
$selected = $Items | fzf @argsList
|
|
if(-not $selected) { return $null }
|
|
if($Multi) { return @($selected -split "`r?`n" | Where-Object { $_ }) }
|
|
return $selected
|
|
}
|
|
|
|
function Show-NumberedMenu
|
|
{
|
|
param(
|
|
[Parameter(Mandatory)]
|
|
[string[]]$Items,
|
|
[string]$Header = "Select one or more",
|
|
[switch]$Multi
|
|
)
|
|
Write-Host "`n$Header" -ForegroundColor Cyan
|
|
for($i=0; $i -lt $Items.Count; $i++)
|
|
{
|
|
Write-Host " $($i+1). $($Items[$i])"
|
|
}
|
|
if($Multi)
|
|
{
|
|
$prompt = "Enter numbers separated by commas (e.g. 1,3,5) or 'all'"
|
|
}
|
|
else
|
|
{
|
|
$prompt = "Enter a number"
|
|
}
|
|
$choice = Read-Host $prompt
|
|
if($choice -eq "all" -and $Multi) { return $Items }
|
|
$indices = $choice -split "," | ForEach-Object { $_.Trim() } | Where-Object { $_ -match "^\d+$" } | ForEach-Object { [int]$_ - 1 } | Where-Object { $_ -ge 0 -and $_ -lt $Items.Count }
|
|
if($Multi)
|
|
{
|
|
return $Items[$indices] | Select-Object -Unique
|
|
}
|
|
else
|
|
{
|
|
if($indices.Count -eq 0) { return $null }
|
|
return $Items[$indices[0]]
|
|
}
|
|
}
|
|
|
|
function Select-MenuItem
|
|
{
|
|
param(
|
|
[Parameter(Mandatory)]
|
|
[string[]]$Items,
|
|
[string]$Header = "Select one",
|
|
[switch]$Multi
|
|
)
|
|
if(Test-FzfAvailable)
|
|
{
|
|
return Show-FzfMenu -Items $Items -Header $Header -Multi:$Multi
|
|
}
|
|
return Show-NumberedMenu -Items $Items -Header $Header -Multi:$Multi
|
|
}
|
|
|
|
function Read-YesNo
|
|
{
|
|
param(
|
|
[string]$Prompt,
|
|
[bool]$Default = $false
|
|
)
|
|
$defaultChar = if($Default) { "Y" } else { "N" }
|
|
$response = Read-Host "$Prompt [Y/n] (default: $defaultChar)"
|
|
if([string]::IsNullOrWhiteSpace($response)) { return $Default }
|
|
return $response -match "^\s*y"
|
|
}
|
|
|
|
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")
|
|
}
|
|
#endregion
|
|
|
|
#region Initialize Runtime
|
|
$projectRoot = Split-Path -Parent $PSScriptRoot
|
|
$runtimeModule = Join-Path $projectRoot "Runtime/IntuneManagement.Runtime.psd1"
|
|
if(-not (Test-Path $runtimeModule))
|
|
{
|
|
throw "Could not find IntuneManagement.Runtime.psd1 in $projectRoot"
|
|
}
|
|
|
|
$settingsPath = $SettingsFile
|
|
if(-not $settingsPath)
|
|
{
|
|
$settingsPath = Get-DefaultSettingsPath
|
|
}
|
|
|
|
# Pre-load auth from settings
|
|
if($AuthMode -eq "AppOnly" -and (Test-Path $settingsPath) -and (-not $AppId -or (-not $Secret -and -not $Certificate)))
|
|
{
|
|
try
|
|
{
|
|
$raw = Get-Content -Path $settingsPath -Raw -ErrorAction Stop
|
|
$settingsObj = ConvertFrom-Json $raw -AsHashtable -ErrorAction Stop
|
|
if($settingsObj -and $settingsObj.ContainsKey($TenantId))
|
|
{
|
|
$tenantNode = $settingsObj[$TenantId]
|
|
if(-not $AppId -and $tenantNode.ContainsKey("GraphAzureAppId"))
|
|
{
|
|
$AppId = $tenantNode["GraphAzureAppId"]
|
|
}
|
|
if(-not $Secret -and $tenantNode.ContainsKey("GraphAzureAppSecret"))
|
|
{
|
|
$Secret = $tenantNode["GraphAzureAppSecret"]
|
|
}
|
|
if(-not $Certificate -and $tenantNode.ContainsKey("GraphAzureAppCert"))
|
|
{
|
|
$Certificate = $tenantNode["GraphAzureAppCert"]
|
|
}
|
|
}
|
|
|
|
if(-not $Secret -and $IsMacOS -and $AppId)
|
|
{
|
|
try
|
|
{
|
|
$keychainSecret = security find-generic-password -a "IntuneManagement" -s "IntuneMgmt-$AppId" -w 2>$null
|
|
if($keychainSecret) { $Secret = $keychainSecret }
|
|
}
|
|
catch { }
|
|
}
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
$invokeParams = @{
|
|
Silent = $true
|
|
JSonSettings = $true
|
|
JSonFile = $settingsPath
|
|
TenantId = $TenantId
|
|
AppId = $AppId
|
|
AuthMode = $AuthMode
|
|
}
|
|
if($RedirectUri) { $invokeParams.RedirectUri = $RedirectUri }
|
|
if($AuthMode -eq "AppOnly" -and $Secret) { $invokeParams.Secret = $Secret }
|
|
elseif($AuthMode -eq "AppOnly") { $invokeParams.Certificate = $Certificate }
|
|
|
|
Import-Module $runtimeModule -Force
|
|
Initialize-IntuneManagementRuntime -View "IntuneGraphAPI" @invokeParams
|
|
#endregion
|
|
|
|
#region Ensure Graph connectivity
|
|
if(-not (Get-Command Invoke-GraphRequest -ErrorAction SilentlyContinue))
|
|
{
|
|
throw "Graph runtime did not load Invoke-GraphRequest. Aborting."
|
|
}
|
|
|
|
Write-Host "`nConnecting to Microsoft Graph..." -ForegroundColor Cyan
|
|
try
|
|
{
|
|
$org = Invoke-GraphRequest "/organization"
|
|
Write-Host "Connected to tenant: $($org.value[0].displayName) ($($org.value[0].id))" -ForegroundColor Green
|
|
}
|
|
catch
|
|
{
|
|
throw "Failed to connect to Graph. Ensure auth parameters are correct. Error: $_"
|
|
}
|
|
#endregion
|
|
|
|
#region Object type registry (editable types)
|
|
$editableTypes = @(
|
|
[PSCustomObject]@{ Title = "Applications"; API = "/deviceAppManagement/mobileApps"; NameProp = "displayName"; DescProp = "description" },
|
|
[PSCustomObject]@{ Title = "Device Configuration"; API = "/deviceManagement/deviceConfigurations"; NameProp = "displayName"; DescProp = "description" },
|
|
[PSCustomObject]@{ Title = "Settings Catalog"; API = "/deviceManagement/configurationPolicies"; NameProp = "name"; DescProp = "description" },
|
|
[PSCustomObject]@{ Title = "Compliance Policies"; API = "/deviceManagement/deviceCompliancePolicies"; NameProp = "displayName"; DescProp = "description" },
|
|
[PSCustomObject]@{ Title = "Administrative Templates"; API = "/deviceManagement/groupPolicyConfigurations"; NameProp = "displayName"; DescProp = "description" },
|
|
[PSCustomObject]@{ Title = "Endpoint Security"; API = "/deviceManagement/intents"; NameProp = "displayName"; DescProp = "description" },
|
|
[PSCustomObject]@{ Title = "App Protection"; API = "/deviceAppManagement/managedAppPolicies"; NameProp = "displayName"; DescProp = "description" },
|
|
[PSCustomObject]@{ Title = "App Configuration (Device)"; API = "/deviceAppManagement/mobileAppConfigurations"; NameProp = "displayName"; DescProp = "description" },
|
|
[PSCustomObject]@{ Title = "Platform Scripts"; API = "/deviceManagement/deviceManagementScripts"; NameProp = "displayName"; DescProp = "description" },
|
|
[PSCustomObject]@{ Title = "macOS Scripts"; API = "/deviceManagement/deviceShellScripts"; NameProp = "displayName"; DescProp = "description" },
|
|
[PSCustomObject]@{ Title = "Device Health Scripts"; API = "/deviceManagement/deviceHealthScripts"; NameProp = "displayName"; DescProp = "description" },
|
|
[PSCustomObject]@{ Title = "macOS Custom Attributes"; API = "/deviceManagement/deviceCustomAttributeShellScripts"; NameProp = "displayName"; DescProp = "description" },
|
|
[PSCustomObject]@{ Title = "Enrollment Restrictions"; API = "/deviceManagement/deviceEnrollmentConfigurations"; NameProp = "displayName"; DescProp = "description" },
|
|
[PSCustomObject]@{ Title = "Enrollment Status Page"; API = "/deviceManagement/deviceEnrollmentConfigurations"; NameProp = "displayName"; DescProp = "description" },
|
|
[PSCustomObject]@{ Title = "Autopilot"; API = "/deviceManagement/windowsAutopilotDeploymentProfiles"; NameProp = "displayName"; DescProp = "description" },
|
|
[PSCustomObject]@{ Title = "Terms and Conditions"; API = "/deviceManagement/termsAndConditions"; NameProp = "displayName"; DescProp = "description" },
|
|
[PSCustomObject]@{ Title = "Policy Sets"; API = "/deviceAppManagement/policySets"; NameProp = "displayName"; DescProp = "description" },
|
|
[PSCustomObject]@{ Title = "Update Policies"; API = "/deviceManagement/windowsUpdateForBusinessConfigurations"; NameProp = "displayName"; DescProp = "description" },
|
|
[PSCustomObject]@{ Title = "Feature Updates"; API = "/deviceManagement/windowsFeatureUpdateProfiles"; NameProp = "displayName"; DescProp = "description" },
|
|
[PSCustomObject]@{ Title = "Quality Updates"; API = "/deviceManagement/windowsQualityUpdateProfiles"; NameProp = "displayName"; DescProp = "description" },
|
|
[PSCustomObject]@{ Title = "Device Management Intents"; API = "/deviceManagement/intents"; NameProp = "displayName"; DescProp = "description" }
|
|
)
|
|
#endregion
|
|
|
|
Clear-Host
|
|
Write-Host "========================================" -ForegroundColor Cyan
|
|
Write-Host " Intune Bulk Rename Tool" -ForegroundColor Cyan
|
|
Write-Host "========================================" -ForegroundColor Cyan
|
|
|
|
#region Select object type
|
|
$typeTitles = $editableTypes | ForEach-Object { $_.Title }
|
|
$selectedTypeTitle = Select-MenuItem -Items $typeTitles -Header "Select object type"
|
|
if(-not $selectedTypeTitle) { Write-Host "Cancelled." -ForegroundColor Yellow; exit 0 }
|
|
$objectType = $editableTypes | Where-Object { $_.Title -eq $selectedTypeTitle } | Select-Object -First 1
|
|
#endregion
|
|
|
|
#region Load objects
|
|
Write-Host "`nLoading $($objectType.Title) objects..." -ForegroundColor Cyan
|
|
$api = "$($objectType.API)?`$select=id,$($objectType.NameProp),$(if($objectType.DescProp){$objectType.DescProp})&`$orderby=$($objectType.NameProp)"
|
|
$objectsResponse = Invoke-GraphRequest $api -AllPages
|
|
$objects = $objectsResponse.value | Where-Object { $_ } | Sort-Object $objectType.NameProp
|
|
Write-Host "Found $($objects.Count) objects." -ForegroundColor Green
|
|
|
|
$filter = Read-Host "`nFilter by current name (optional, press Enter to skip)"
|
|
if(-not [string]::IsNullOrWhiteSpace($filter))
|
|
{
|
|
$objects = $objects | Where-Object { $_."$($objectType.NameProp)" -like "*$filter*" }
|
|
Write-Host "Filtered to $($objects.Count) objects." -ForegroundColor Green
|
|
}
|
|
|
|
if($objects.Count -eq 0)
|
|
{
|
|
Write-Host "No objects found. Exiting." -ForegroundColor Yellow
|
|
exit 0
|
|
}
|
|
|
|
$objectDisplays = $objects | ForEach-Object { "$($_."$($objectType.NameProp)") [$($_.id)]" }
|
|
$selectedDisplays = Select-MenuItem -Items $objectDisplays -Header "Select objects to rename (multi-select)" -Multi
|
|
if(-not $selectedDisplays)
|
|
{
|
|
Write-Host "No objects selected. Exiting." -ForegroundColor Yellow
|
|
exit 0
|
|
}
|
|
|
|
$selectedObjects = @()
|
|
foreach($disp in $selectedDisplays)
|
|
{
|
|
$id = $disp -replace '.*\[(.*?)\]$', '$1'
|
|
$obj = $objects | Where-Object { $_.id -eq $id } | Select-Object -First 1
|
|
if($obj) { $selectedObjects += $obj }
|
|
}
|
|
Write-Host "Selected $($selectedObjects.Count) objects." -ForegroundColor Green
|
|
#endregion
|
|
|
|
#region Mutation options
|
|
$fieldToEdit = Select-MenuItem -Items @("displayName","description","both") -Header "Which field to edit?"
|
|
if(-not $fieldToEdit) { $fieldToEdit = "displayName" }
|
|
|
|
$mode = Select-MenuItem -Items @("Search and replace","Add prefix","Strip prefix") -Header "Select rename mode"
|
|
if(-not $mode) { Write-Host "Cancelled." -ForegroundColor Yellow; exit 0 }
|
|
|
|
$searchPattern = ""
|
|
$replacePattern = ""
|
|
$prefix = ""
|
|
|
|
switch($mode)
|
|
{
|
|
"Search and replace"
|
|
{
|
|
$searchPattern = Read-Host "Enter search regex"
|
|
$replacePattern = Read-Host "Enter replacement string"
|
|
}
|
|
"Add prefix"
|
|
{
|
|
$prefix = Read-Host "Enter prefix to add"
|
|
}
|
|
"Strip prefix"
|
|
{
|
|
$prefix = Read-Host "Enter prefix to strip (will be removed from start)"
|
|
}
|
|
}
|
|
|
|
# Preview changes
|
|
Write-Host "`nPreview of changes:" -ForegroundColor Cyan
|
|
$changes = @()
|
|
foreach($obj in $selectedObjects)
|
|
{
|
|
$oldName = $obj."$($objectType.NameProp)"
|
|
$oldDesc = if($objectType.DescProp -and $obj.PSObject.Properties[$objectType.DescProp]) { $obj."$($objectType.DescProp)" } else { "" }
|
|
$newName = $oldName
|
|
$newDesc = $oldDesc
|
|
|
|
if($fieldToEdit -in @("displayName","both"))
|
|
{
|
|
switch($mode)
|
|
{
|
|
"Search and replace" { if($oldName -match $searchPattern) { $newName = $oldName -replace $searchPattern, $replacePattern } }
|
|
"Add prefix" { $newName = "$prefix$oldName" }
|
|
"Strip prefix" { if($oldName.StartsWith($prefix)) { $newName = $oldName.Substring($prefix.Length) } }
|
|
}
|
|
}
|
|
if($fieldToEdit -in @("description","both") -and $objectType.DescProp)
|
|
{
|
|
switch($mode)
|
|
{
|
|
"Search and replace" { if($oldDesc -match $searchPattern) { $newDesc = $oldDesc -replace $searchPattern, $replacePattern } }
|
|
"Add prefix" { $newDesc = "$prefix$oldDesc" }
|
|
"Strip prefix" { if($oldDesc.StartsWith($prefix)) { $newDesc = $oldDesc.Substring($prefix.Length) } }
|
|
}
|
|
}
|
|
|
|
if($newName -ne $oldName -or $newDesc -ne $oldDesc)
|
|
{
|
|
$changes += [PSCustomObject]@{
|
|
Object = $obj
|
|
OldName = $oldName
|
|
NewName = $newName
|
|
OldDesc = $oldDesc
|
|
NewDesc = $newDesc
|
|
}
|
|
Write-Host " $($oldName)" -ForegroundColor DarkGray
|
|
if($newName -ne $oldName) { Write-Host " -> Name: $newName" -ForegroundColor Green }
|
|
if($newDesc -ne $oldDesc) { Write-Host " -> Desc: $newDesc" -ForegroundColor Green }
|
|
}
|
|
}
|
|
|
|
if($changes.Count -eq 0)
|
|
{
|
|
Write-Host "No objects would be changed. Exiting." -ForegroundColor Yellow
|
|
exit 0
|
|
}
|
|
|
|
$confirm = Read-Host "`nProceed with renaming $($changes.Count) objects? [Y/n]"
|
|
if(-not ([string]::IsNullOrWhiteSpace($confirm) -or $confirm -match "^\s*y"))
|
|
{
|
|
Write-Host "Cancelled." -ForegroundColor Yellow
|
|
exit 0
|
|
}
|
|
#endregion
|
|
|
|
#region Execute
|
|
$success = 0
|
|
$failed = 0
|
|
|
|
foreach($change in $changes)
|
|
{
|
|
$obj = $change.Object
|
|
$payload = @{}
|
|
|
|
if($fieldToEdit -in @("displayName","both") -and $change.NewName -ne $change.OldName)
|
|
{
|
|
$payload[$objectType.NameProp] = $change.NewName
|
|
}
|
|
if($fieldToEdit -in @("description","both") -and $objectType.DescProp -and $change.NewDesc -ne $change.OldDesc)
|
|
{
|
|
$payload[$objectType.DescProp] = $change.NewDesc
|
|
}
|
|
|
|
if($payload.Count -eq 0) { continue }
|
|
|
|
try
|
|
{
|
|
if($WhatIf)
|
|
{
|
|
Write-Host " WHATIF: Would update $($change.OldName)" -ForegroundColor Magenta
|
|
$success++
|
|
}
|
|
else
|
|
{
|
|
$body = $payload | ConvertTo-Json -Depth 10 -Compress
|
|
$null = Invoke-GraphRequest "$($objectType.API)/$($obj.id)" -HttpMethod PATCH -Content $body
|
|
Write-Host " OK: Renamed '$($change.OldName)' -> '$($change.NewName)'" -ForegroundColor Green
|
|
$success++
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
Write-Host " ERROR: Failed to rename '$($change.OldName)'. $($_.Exception.Message)" -ForegroundColor Red
|
|
$failed++
|
|
}
|
|
}
|
|
|
|
Write-Host "`n========================================" -ForegroundColor Cyan
|
|
Write-Host " Bulk Rename Complete" -ForegroundColor Cyan
|
|
Write-Host "========================================" -ForegroundColor Cyan
|
|
Write-Host " Success : $success"
|
|
Write-Host " Failed : $failed"
|
|
#endregion
|