Update-AutopilotDeviceNames.ps1
|
<#PSScriptInfo
.VERSION 0.3.1 .GUID c1300cd2-5402-45c4-88e1-c7ee99f95d9a .AUTHOR Chris Sellar .COMPANYNAME EndpointMgt .COPYRIGHT (c) 2026 Chris Sellar. All rights reserved. .TAGS Autopilot Intune Graph DeviceNaming .LICENSEURI https://opensource.org/licenses/MIT .PROJECTURI https://github.com/endpointmgtgit/AutopilotDeviceNameTool .EXTERNALMODULEDEPENDENCIES Microsoft.Graph.Authentication .REQUIREDSCRIPTS .EXTERNALSCRIPTDEPENDENCIES .RELEASENOTES Added configurable report output location, removed dependency on script root and updated Project URL. #> <# .SYNOPSIS Autopilot Device Name Tool - Default: Updates Autopilot displayName from CSV (SerialNumber -> DeviceName OR DisplayName) - Optional: Export current Autopilot devices (Serial + current displayName) only .DESCRIPTION Allows for the bulk updating of Autopilot device display names based on a provided CSV mapping SerialNumber to DeviceName or DisplayName. Export functionality provides a way to generate a baseline CSV of current Autopilot devices and their display names, which can be edited and reused for updates. Supports -WhatIf to simulate changes without updating Autopilot while still producing a report CSV. .PARAMETER CsvPath Path to CSV containing SerialNumber and DeviceName or DisplayName columns. Used during Update mode. .PARAMETER ForceUpdate Overwrite existing displayName values instead of skipping devices that are already named. .PARAMETER ExportCurrent Exports current Autopilot devices without making changes. Creates a baseline CSV which can be edited and reused for updates. .PARAMETER ReportOutputPath Optional output location for the generated CSV report. - If you pass a folder path, the script will generate a timestamped CSV name in that folder. - If you pass a full *.csv file path, it will write exactly to that file. Defaults to C:\Temp. .EXAMPLE # Export current Autopilot devices (baseline CSV) to default output folder (C:\Temp) .\Update Autopilot Device Names.ps1 -ExportCurrent .EXAMPLE # Export current Autopilot devices to a custom folder (auto filename) .\Update Autopilot Device Names.ps1 -ExportCurrent -ReportOutputPath "C:\Reports\Autopilot" .EXAMPLE # Export current Autopilot devices to an explicit file .\Update Autopilot Device Names.ps1 -ExportCurrent -ReportOutputPath "C:\Temp\Autopilot-Current.csv" .EXAMPLE # Preview changes without making updates (WhatIf) and write report to a folder .\Update Autopilot Device Names.ps1 -CsvPath ".\NewDeviceNames.csv" -WhatIf -ReportOutputPath "C:\Reports\Autopilot" .EXAMPLE # Update Autopilot display names using DeviceName column and write report to Desktop .\Update Autopilot Device Names.ps1 -CsvPath ".\NewDeviceNames.csv" -ReportOutputPath "$env:USERPROFILE\Desktop" .NOTES CSV REQUIRED HEADERS (Update mode) - Option A: SerialNumber,DeviceName - Option B: SerialNumber,DisplayName You can run -ExportCurrent to generate a baseline CSV, edit the DisplayName column, then feed it back into update mode. Uses Microsoft Graph Beta endpoint for Autopilot device properties. #> [CmdletBinding(DefaultParameterSetName = 'Update', SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] param( # --- Update mode --- [Parameter(Mandatory = $true, ParameterSetName = 'Update')] [string]$CsvPath, [Parameter(Mandatory = $false, ParameterSetName = 'Update')] [switch]$ForceUpdate, # --- Export mode --- [Parameter(Mandatory = $true, ParameterSetName = 'Export')] [switch]$ExportCurrent, # --- Shared --- [Parameter(Mandatory = $false)] [Alias("ReportPath")] # Backwards compatible: old -ReportPath still works [string]$ReportOutputPath = "C:\Temp" ) # ------------------------- # Report path handling # ------------------------- function Resolve-ReportCsvPath { [CmdletBinding()] param( [Parameter(Mandatory=$true)] [string]$PathOrFolder, [Parameter(Mandatory=$true)] [ValidateSet("Export","Update")] [string]$Mode ) $timestamp = Get-Date -Format "yyyyMMdd-HHmmss" $defaultFileName = if ($Mode -eq "Export") { "Autopilot-Current-{0}.csv" -f $timestamp } else { "Autopilot-Results-{0}.csv" -f $timestamp } # If a full CSV file path was provided, use it as-is; otherwise treat as a folder. $isCsvFile = $PathOrFolder.TrimEnd('\') -match '\.csv$' if ($isCsvFile) { $csvPath = $PathOrFolder $folder = Split-Path -Path $csvPath -Parent if ([string]::IsNullOrWhiteSpace($folder)) { throw "Invalid ReportOutputPath value: $PathOrFolder" } } else { $folder = $PathOrFolder $csvPath = Join-Path $folder $defaultFileName } if (-not (Test-Path -LiteralPath $folder)) { New-Item -Path $folder -ItemType Directory -Force | Out-Null } return $csvPath } # ---- Graph prerequisites ---- function Initialize-GraphModule { param( [Parameter(Mandatory=$true)] [string]$ModuleName ) $available = Get-Module -ListAvailable -Name $ModuleName -ErrorAction SilentlyContinue if (-not $available) { throw "Required module '$ModuleName' not found. Install it with: Install-Module $ModuleName -Scope CurrentUser" } try { Import-Module $ModuleName -ErrorAction Stop } catch { throw "Failed to import module '$ModuleName'. Error: $($_.Exception.Message)" } } Initialize-GraphModule -ModuleName "Microsoft.Graph.Authentication" # ---- Connect to Graph ---- try { Write-Host "Connecting to Microsoft Graph..." -ForegroundColor Cyan Connect-MgGraph -Scopes 'DeviceManagementServiceConfig.ReadWrite.All' -NoWelcome -ErrorAction Stop $connected = Get-MgContext Write-Host ("Connected as: {0}" -f $connected.Account) -ForegroundColor Green } catch { throw ("Graph connection failed. Ensure you have the required permissions/consent. " + "Error: {0}" -f $_.Exception.Message) } function Get-AutopilotDevice { $graphApiVersion = 'Beta' $resource = 'deviceManagement/windowsAutopilotDeviceIdentities' $uri = "https://graph.microsoft.com/$graphApiVersion/$resource" try { $graphResults = Invoke-MgGraphRequest -Uri $uri -Method Get -OutputType PSObject -ErrorAction Stop $results = @() if ($graphResults.value) { $results += $graphResults.value } $next = $graphResults.'@odata.nextLink' while ($null -ne $next) { $additional = Invoke-MgGraphRequest -Uri $next -Method Get -OutputType PSObject -ErrorAction Stop if ($additional.value) { $results += $additional.value } $next = $additional.'@odata.nextLink' } return $results } catch { throw "Failed to retrieve Autopilot devices. Error: $($_.Exception.Message)" } } function Set-AutopilotDevice { [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] param( [Parameter(Mandatory=$true)][string]$Id, [Parameter(Mandatory=$true)][hashtable]$Body ) $graphApiVersion = 'Beta' $resource = "deviceManagement/windowsAutopilotDeviceIdentities/$Id/microsoft.graph.updateDeviceProperties" $uri = "https://graph.microsoft.com/$graphApiVersion/$resource" $payloadJson = $Body | ConvertTo-Json -Depth 5 $target = "AutopilotDeviceIdentity Id=$Id" $action = "Update device properties (POST updateDeviceProperties)" if ($PSCmdlet.ShouldProcess($target, $action)) { Invoke-MgGraphRequest -Uri $uri -Method Post -Body $payloadJson -ContentType 'application/json' -ErrorAction Stop | Out-Null return $true } return $false } # ---- Pull Autopilot devices (used by both modes) ---- Write-Host "Getting all Windows Autopilot devices..." -ForegroundColor Cyan $apDevices = Get-AutopilotDevice Write-Host ("Found {0} Windows Autopilot devices." -f $apDevices.Count) -ForegroundColor Green # ========================= # EXPORT CURRENT MODE ONLY # ========================= if ($ExportCurrent) { $ReportPath = Resolve-ReportCsvPath -PathOrFolder $ReportOutputPath -Mode "Export" $export = foreach ($ap in $apDevices) { $sn = ([string]$ap.serialNumber).Trim() $dn = ([string]$ap.displayName).Trim() [pscustomobject]@{ SerialNumber = $sn DisplayName = $dn Id = $ap.id Manufacturer = $ap.manufacturer Model = $ap.model GroupTag = $ap.groupTag PurchaseOrder = $ap.purchaseOrderIdentifier EnrollmentState = $ap.enrollmentState } } $export | Export-Csv -Path $ReportPath -NoTypeInformation -Encoding UTF8 -Force -ErrorAction Stop -WhatIf:$false if (-not (Test-Path -LiteralPath $ReportPath)) { throw "Export-Csv completed but file not found at: $ReportPath" } Write-Host "Export complete: $ReportPath" -ForegroundColor Green return } # ========================= # UPDATE MODE # ========================= $ReportPath = Resolve-ReportCsvPath -PathOrFolder $ReportOutputPath -Mode "Update" # Resolve CSV path $CsvPath = (Resolve-Path -Path $CsvPath -ErrorAction Stop).Path # Load CSV $csv = Import-Csv -Path $CsvPath -ErrorAction Stop if (-not $csv -or $csv.Count -eq 0) { throw "CSV is empty: $CsvPath" } # Validate CSV headers $headers = $csv[0].PSObject.Properties.Name $hasSerial = $headers -contains "SerialNumber" $hasDeviceName = $headers -contains "DeviceName" $hasDisplayName = $headers -contains "DisplayName" if (-not $hasSerial -or (-not $hasDeviceName -and -not $hasDisplayName)) { $detectedHeaders = if ($headers -and $headers.Count -gt 0) { ($headers -join ", ") } else { "(No headers detected)" } $message = @" CSV validation failed. Detected headers: $detectedHeaders Expected CSV headers: Option A: SerialNumber,DeviceName Option B: SerialNumber,DisplayName "@ Write-Error $message exit 1 } # Build CSV lookup: Serial -> DesiredName $csvLookup = @{} foreach ($row in $csv) { $sn = ([string]$row.SerialNumber).Trim().ToUpperInvariant() if ([string]::IsNullOrWhiteSpace($sn)) { continue } $desiredRaw = if ($hasDeviceName) { [string]$row.DeviceName } else { [string]$row.DisplayName } $desired = $desiredRaw.Trim() if ([string]::IsNullOrWhiteSpace($desired)) { continue } $csvLookup[$sn] = $desired } Write-Host ("Loaded {0} serials with desired names from CSV." -f $csvLookup.Count) -ForegroundColor Cyan # Check for duplicate desired names (case-insensitive) $desiredNames = $csvLookup.Values | ForEach-Object { $_.Trim() } | Where-Object { $_ } $uniqueNames = $desiredNames | ForEach-Object { $_.ToUpperInvariant() } | Select-Object -Unique $duplicateNameSet = @{} if ($desiredNames.Count -ne $uniqueNames.Count) { # Identify duplicates $dupGroups = $desiredNames | Group-Object { $_.ToUpperInvariant() } | Where-Object { $_.Count -gt 1 } foreach ($g in $dupGroups) { $duplicateNameSet[$g.Name] = $true } Write-Warning ("Duplicate desired device names detected in CSV. " + "Those entries will be skipped and flagged in the report. " + "Duplicates: {0}" -f (($dupGroups | ForEach-Object { $_.Group[0] }) -join ", ")) } # Build Autopilot lookup: Serial -> Record $apLookup = @{} foreach ($ap in $apDevices) { $sn = ([string]$ap.serialNumber).Trim().ToUpperInvariant() if (-not [string]::IsNullOrWhiteSpace($sn) -and -not $apLookup.ContainsKey($sn)) { $apLookup[$sn] = $ap } } # Process CSV serials $results = [System.Collections.Generic.List[object]]::new() foreach ($serial in $csvLookup.Keys) { $desired = $csvLookup[$serial] $desiredKey = $desired.ToUpperInvariant() # Skip duplicates (flag in report) if ($duplicateNameSet.ContainsKey($desiredKey)) { $results.Add([pscustomobject]@{ SerialNumber = $serial DesiredDisplayName = $desired Status = "DuplicateName" Reason = "Desired displayName is duplicated in CSV. Resolve duplicates and re-run." Error = $null }) continue } if (-not $apLookup.ContainsKey($serial)) { $results.Add([pscustomobject]@{ SerialNumber = $serial DesiredDisplayName = $desired Status = "NoDeviceFound" Reason = "Serial not present in Autopilot" Error = $null }) continue } $ap = $apLookup[$serial] $current = ([string]$ap.displayName).Trim() # If not forcing, skip already named devices if (-not $ForceUpdate -and -not [string]::IsNullOrWhiteSpace($current)) { $results.Add([pscustomobject]@{ SerialNumber = $serial DesiredDisplayName = $desired Status = "AlreadyNamed" Reason = "Current displayName is '$current'. Use -ForceUpdate to overwrite." Error = $null }) continue } # If forcing OR current is blank: only update when the value would actually change if ($current -eq $desired) { $results.Add([pscustomobject]@{ SerialNumber = $serial DesiredDisplayName = $desired Status = "NoChange" Reason = "Current displayName already equals desired value." Error = $null }) continue } try { $didUpdate = Set-AutopilotDevice -Id $ap.id -Body @{ displayName = $desired } if ($didUpdate) { $results.Add([pscustomobject]@{ SerialNumber = $serial DesiredDisplayName = $desired Status = "Updated" Reason = "Updated displayName" Error = $null }) } else { $results.Add([pscustomobject]@{ SerialNumber = $serial DesiredDisplayName = $desired Status = "WhatIf" Reason = "Would update displayName (WhatIf/Confirm prevented change)" Error = $null }) } } catch { $results.Add([pscustomobject]@{ SerialNumber = $serial DesiredDisplayName = $desired Status = "Failed" Reason = "Attempted update but failed" Error = $_.Exception.Message }) } } # Export results $results | Export-Csv -Path $ReportPath -NoTypeInformation -Encoding UTF8 -Force -ErrorAction Stop -WhatIf:$false if (-not (Test-Path -LiteralPath $ReportPath)) { throw "Export-Csv completed but file not found at: $ReportPath" } if ($WhatIfPreference) { Write-Host "WhatIf: Report generated showing what WOULD happen: $ReportPath" -ForegroundColor Yellow } else { Write-Host "Report exported OK: $ReportPath" -ForegroundColor Green } Write-Host "Summary:" -ForegroundColor Cyan $results | Group-Object -Property Status | ForEach-Object { Write-Host ("{0}: {1}" -f $_.Name, $_.Count) -ForegroundColor Yellow } Write-Host "Tip: re-run with -ExportCurrent to confirm Autopilot displayName values after propagation." -ForegroundColor DarkCyan |