From 8fe71c0078cfa3655d8b9943bd2d938aa7d37951 Mon Sep 17 00:00:00 2001 From: Tomas Kracmar Date: Wed, 8 Apr 2026 15:18:32 +0200 Subject: [PATCH] Add headless macOS CLI workflow --- CloudAPIPowerShellManagement.psm1 | 38 ++- Core.psm1 | 134 ++++++++-- Extensions/EndpointManager.psm1 | 11 +- Extensions/MSALAuthentication.psm1 | 30 +-- Extensions/MSGraph.psm1 | 101 ++++++-- Headless/IntuneManagement.Headless.psd1 | 17 ++ Headless/IntuneManagement.Headless.psm1 | 311 ++++++++++++++++++++++++ Headless/README.md | 40 +++ README.md | 136 ++++++++++- Scripts/Export-Policies.ps1 | 43 ++++ Scripts/Import-Policies.ps1 | 48 ++++ Start-HeadlessIntune.ps1 | 74 ++++++ 12 files changed, 917 insertions(+), 66 deletions(-) create mode 100644 Headless/IntuneManagement.Headless.psd1 create mode 100644 Headless/IntuneManagement.Headless.psm1 create mode 100644 Headless/README.md create mode 100644 Scripts/Export-Policies.ps1 create mode 100644 Scripts/Import-Policies.ps1 create mode 100644 Start-HeadlessIntune.ps1 diff --git a/CloudAPIPowerShellManagement.psm1 b/CloudAPIPowerShellManagement.psm1 index d8859e1..2c2fa6a 100644 --- a/CloudAPIPowerShellManagement.psm1 +++ b/CloudAPIPowerShellManagement.psm1 @@ -1,6 +1,21 @@ #region Console functions +function Test-IsWindowsPlatform +{ + return ([Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) +} + +function Invoke-AppDoEvents +{ + if("System.Windows.Forms.Application" -as [type]) + { + [System.Windows.Forms.Application]::DoEvents() + } +} + # https://stackoverflow.com/questions/40617800/opening-powershell-script-and-hide-command-prompt-but-not-the-gui +if(Test-IsWindowsPlatform) +{ Add-Type -Name Window -Namespace Console -MemberDefinition ' [DllImport("Kernel32.dll")] public static extern IntPtr GetConsoleWindow(); @@ -17,9 +32,11 @@ public static extern bool SetConsoleIcon(IntPtr hIcon); [DllImport("user32.dll")] public static extern int SendMessage(int hWnd, uint wMsg, uint wParam, IntPtr lParam); ' +} function Show-Console { + if(-not (Test-IsWindowsPlatform)) { return } $consolePtr = [Console.Window]::GetConsoleWindow() # Hide = 0, @@ -41,6 +58,7 @@ function Show-Console function Hide-Console { + if(-not (Test-IsWindowsPlatform)) { return } $consolePtr = [Console.Window]::GetConsoleWindow() #0 hide [Console.Window]::ShowWindow($consolePtr, 0) | Out-Null @@ -94,17 +112,19 @@ function Initialize-CloudAPIManagement ) $PSModuleAutoloadingPreference = "none" + $global:hideUI = ($Silent -eq $true) + $global:SilentBatchFile = $SilentBatchFile $global:wpfNS = "xmlns='http://schemas.microsoft.com/winfx/2006/xaml/presentation' xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'" - [void] [System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms") - Add-Type -AssemblyName PresentationFramework + if($global:hideUI -ne $true) + { + [void] [System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms") + Add-Type -AssemblyName PresentationFramework + } [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - $global:hideUI = ($Silent -eq $true) - $global:SilentBatchFile = $SilentBatchFile - if($tenantId) { Write-Host "Using Tenant Id: $tenantId" @@ -163,7 +183,7 @@ function Initialize-CloudAPIManagement $global:txtSplashTitle.Text = ("Initializing Cloud API PowerShell Management") $global:SplashScreen.Show() | Out-Null - [System.Windows.Forms.Application]::DoEvents() + Invoke-AppDoEvents } catch { @@ -204,15 +224,15 @@ function Initialize-CloudAPIManagement { $global:txtSplashText.Text = "Unblock files" } - [System.Windows.Forms.Application]::DoEvents() + Invoke-AppDoEvents Unblock-AllFiles $PSScriptRoot if($global:hideUI -ne $true) { $global:txtSplashText.Text = "Load core module" } - [System.Windows.Forms.Application]::DoEvents() - Import-Module ($PSScriptRoot + "\Core.psm1") -Force -Global + Invoke-AppDoEvents + Import-Module (Join-Path $PSScriptRoot "Core.psm1") -Force -Global Start-CoreApp $View } diff --git a/Core.psm1 b/Core.psm1 index 96cd1c5..b98506d 100644 --- a/Core.psm1 +++ b/Core.psm1 @@ -14,6 +14,14 @@ function Get-ModuleVersion '3.9.6' } +function Invoke-AppDoEvents +{ + if("System.Windows.Forms.Application" -as [type]) + { + [System.Windows.Forms.Application]::DoEvents() + } +} + function Initialize-Window { param($xamlFile) @@ -67,7 +75,27 @@ function Start-CoreApp $global:AppRootFolder = $PSScriptRoot # Load all modules in the Modules folder - $global:modulesPath = [IO.Path]::GetDirectoryName($PSCommandPath) + "\Extensions" + $global:modulesPath = Join-Path ([IO.Path]::GetDirectoryName($PSCommandPath)) "Extensions" + + if($global:hideUI -eq $true) + { + $global:skipModules = @( + "Compare.psm1", + "Copy.psm1", + "Documentation.psm1", + "DocumentationCustom.psm1", + "DocumentationHTML.psm1", + "DocumentationMD.psm1", + "DocumentationWord.psm1", + "IntuneAssignments.psm1", + "IntuneFilterUsage.psm1", + "IntuneTools.psm1" + ) + } + else + { + $global:skipModules = @() + } Add-DefaultSettings @@ -122,7 +150,7 @@ function Start-CoreApp $global:MainAppStarted = $false Set-SplashWindowText "Initialize views" - [System.Windows.Forms.Application]::DoEvents() + Invoke-AppDoEvents Invoke-ModuleFunction "Invoke-InitializeModule" @@ -176,8 +204,6 @@ function Start-CoreApp Write-Log "SilentBatchFile $($global:SilentBatchFile) not found" 3 return } - Invoke-ModuleFunction "Invoke-ShowMainWindow" - Invoke-ModuleFunction "Invoke-InitSilentBatchJob" Start-RunSilentBatchJob @@ -199,13 +225,13 @@ function Start-RunSilentBatchJob function Import-AllModules { - foreach($file in (Get-Item -path "$($global:modulesPath)\*.psm1")) + foreach($file in (Get-Item -path (Join-Path $global:modulesPath "*.psm1"))) { $fileName = [IO.Path]::GetFileName($file) if($skipModules -contains $fileName) { Write-Warning "Module $fileName excluded"; continue; } Set-SplashWindowText "Import module $fileName" - [System.Windows.Forms.Application]::DoEvents() + Invoke-AppDoEvents $module = Import-Module $file -PassThru -Force -Global -ErrorAction SilentlyContinue if($module) @@ -422,7 +448,14 @@ function Set-XamlProperty { if($obj) { - $obj."$propertyName" = $value + if(-not ($obj.PSObject.Properties.Name -contains $propertyName)) + { + $obj | Add-Member -MemberType NoteProperty -Name $propertyName -Value $value -Force + } + else + { + $obj."$propertyName" = $value + } } else { @@ -571,15 +604,15 @@ function Set-BatchProperties try { - if($obj -is [System.Windows.Controls.CheckBox]) + if($obj.PSObject.Properties.Name -contains "IsChecked") { $obj.IsChecked = $prop.Value -eq $true } - elseif($obj -is [System.Windows.Controls.TextBox]) + elseif($obj.PSObject.Properties.Name -contains "Text") { $obj.Text = $prop.Value } - elseif($obj -is [System.Windows.Controls.ComboBox]) + elseif($obj.PSObject.Properties.Name -contains "SelectedValue") { $obj.SelectedValue = $prop.Value } @@ -601,6 +634,60 @@ function Set-BatchProperties } #endregion +function New-HeadlessControl +{ + param( + [string]$Name, + [ValidateSet("TextBox","CheckBox","ComboBox","Label","DataGrid")] + [string]$Type = "TextBox" + ) + + $control = [PSCustomObject]@{ + Name = $Name + Focusable = $true + Visibility = "Visible" + IsEnabled = $true + Text = "" + IsChecked = $false + SelectedValue = $null + ItemsSource = @() + Content = "" + } + + if($Type -eq "DataGrid") + { + $control.Focusable = $false + } + + $control +} + +function New-HeadlessForm +{ + param($Controls) + + $form = [PSCustomObject]@{ + Controls = @{} + } + + foreach($control in $Controls) + { + $form.Controls[$control.Name] = $control + } + + $form | Add-Member -MemberType ScriptMethod -Name FindName -Value { + param($name) + $this.Controls[$name] + } + + $form | Add-Member -MemberType ScriptMethod -Name RegisterName -Value { + param($name, $control) + $this.Controls[$name] = $control + } + + $form +} + #region Dialogs function Show-AboutDialog @@ -1204,6 +1291,21 @@ function Expand-FileName #endregion +function Get-CloudApiDataFolder +{ + if($env:LOCALAPPDATA) + { + return (Join-Path $env:LOCALAPPDATA "CloudAPIPowerShellManagement") + } + + if(Test-IsWindowsPlatform) + { + return (Join-Path $env:USERPROFILE "AppData/Local/CloudAPIPowerShellManagement") + } + + return (Join-Path $HOME "Library/Application Support/CloudAPIPowerShellManagement") +} + #region Save/Read Settings functions ######################################################################## # @@ -1231,7 +1333,7 @@ function Initialize-JsonSettings { if(-not $global:JSonSettingFile) { - $global:JSonSettingFile = "$($env:LOCALAPPDATA)\CloudAPIPowerShellManagement\Settings.json" + $global:JSonSettingFile = Join-Path (Get-CloudApiDataFolder) "Settings.json" $fi = [IO.FileInfo]$global:JSonSettingFile if($fi.Exists -eq $false) { @@ -1548,7 +1650,7 @@ function Add-RegKeyToSettings try { $keyObj = Get-Item -Path $regKey -ErrorAction SilentlyContinue - foreach($keyValue in ($keyObj.GetValueNames() | Sort)) + foreach($keyValue in ($keyObj.GetValueNames() | Sort-Object)) { try { @@ -1560,7 +1662,7 @@ function Add-RegKeyToSettings } } - foreach($subKey in ($keyObj.GetSubKeyNames() | Sort)) + foreach($subKey in ($keyObj.GetSubKeyNames() | Sort-Object)) { $settingObjSub = [ordered]@{} @@ -1967,7 +2069,7 @@ function Add-DefaultSettings Value = "" } - foreach($color in ([System.Drawing.Color].GetProperties() | Where { $_.PropertyType -eq [System.Drawing.Color] } | Sort -Property Name | Select Name).Name) + foreach($color in ([System.Drawing.Color].GetProperties() | Where { $_.PropertyType -eq [System.Drawing.Color] } | Sort-Object -Property Name | Select Name).Name) { $script:lstColors += [PSCustomObject]@{ Name = $color @@ -2286,7 +2388,7 @@ function Add-ViewItem if($viewObject.ViewInfo.Permissions -is [Object[]] -and $viewObject.ViewInfo.Permissions -notcontains $scope) { $viewObject.ViewInfo.Permissions += $scope } } - if($viewItem.Icon -or [IO.File]::Exists(($global:AppRootFolder + "\Xaml\Icons\$($viewItem.Id).xaml"))) + if($global:hideUI -ne $true -and ($viewItem.Icon -or [IO.File]::Exists(($global:AppRootFolder + "\Xaml\Icons\$($viewItem.Id).xaml")))) { $ctrl = Get-XamlObject ($global:AppRootFolder + "\Xaml\Icons\$((?? $viewItem.Icon $viewItem.Id)).xaml") $viewItem | Add-Member -NotePropertyName "IconImage" -NotePropertyValue $ctrl @@ -2926,4 +3028,4 @@ function Get-GUIDs New-Alias -Name ?? -value Invoke-Coalesce New-Alias -Name ?: -value Invoke-IfTrue -Export-ModuleMember -alias * -function * \ No newline at end of file +Export-ModuleMember -alias * -function * diff --git a/Extensions/EndpointManager.psm1 b/Extensions/EndpointManager.psm1 index 12ecf86..c4e5fe8 100644 --- a/Extensions/EndpointManager.psm1 +++ b/Extensions/EndpointManager.psm1 @@ -105,9 +105,12 @@ function Invoke-InitializeModule Write-Log "Microsoft Intune PowerShell is being decomissioned. Please change to a supported app eg Microsoft Graph or a custom app!" 2 } - $viewPanel = Get-XamlObject ($global:AppRootFolder + "\Xaml\EndpointManagerPanel.xaml") -AddVariables - - Set-EMViewPanel $viewPanel + $viewPanel = $null + if($global:hideUI -ne $true) + { + $viewPanel = Get-XamlObject (Join-Path $global:AppRootFolder "Xaml/EndpointManagerPanel.xaml") -AddVariables + Set-EMViewPanel $viewPanel + } #Add menu group and items $global:EMViewObject = (New-Object PSObject -Property @{ @@ -4417,4 +4420,4 @@ function Start-PreImportCommandAuthenticationContext #endregion -Export-ModuleMember -alias * -function * \ No newline at end of file +Export-ModuleMember -alias * -function * diff --git a/Extensions/MSALAuthentication.psm1 b/Extensions/MSALAuthentication.psm1 index bff4791..6495549 100644 --- a/Extensions/MSALAuthentication.psm1 +++ b/Extensions/MSALAuthentication.psm1 @@ -138,8 +138,8 @@ function Invoke-InitializeModule }) "MSAL" $script:MSALUseWAM = Get-SettingValue "UseWAM" - if($script:MSALUseWAM -and $PSVersionTable.PSVersion.Major -lt 7) { - Write-Log "WAM is only supported in PowerShell 7 and later. Disabling WAM" 2 + if($script:MSALUseWAM -and ($PSVersionTable.PSVersion.Major -lt 7 -or -not (Test-IsWindowsPlatform))) { + Write-Log "WAM is only supported on Windows with PowerShell 7 and later. Disabling WAM" 2 $script:MSALUseWAM = $false } @@ -268,7 +268,7 @@ function Get-MSALUserInfo ### Only get user info from home tenant $global:Me = $tmpMe Write-Log "Get profile picture" - $global:profilePhoto = "$($env:LOCALAPPDATA)\CloudAPIPowerShellManagement\$($global:Me.Id).jpeg" + $global:profilePhoto = Join-Path (Get-CloudApiDataFolder) "$($global:Me.Id).jpeg" MSGraph\Invoke-GraphRequest "me/photos/48x48/`$value" -OutFile $global:profilePhoto -SkipAuthentication -NoError | Out-Null } } @@ -517,17 +517,17 @@ function Add-MSALPrereq $DLLFiles = @() if($PSVersionTable.PSVersion.Major -ge 7) { - $DLLFiles += [IO.FileInfo]($ScriptRoot + "\Bin\Microsoft.IdentityModel.Abstractions.dll") + $DLLFiles += [IO.FileInfo](Join-Path $ScriptRoot "Bin/Microsoft.IdentityModel.Abstractions.dll") } else { - $DLLFiles += [IO.FileInfo]($ScriptRoot + "\Bin\6.35.0\Microsoft.IdentityModel.Abstractions.dll") + $DLLFiles += [IO.FileInfo](Join-Path $ScriptRoot "Bin/6.35.0/Microsoft.IdentityModel.Abstractions.dll") } - $DLLFiles += [IO.FileInfo]($ScriptRoot + "\Bin\Microsoft.Identity.Client.dll") + $DLLFiles += [IO.FileInfo](Join-Path $ScriptRoot "Bin/Microsoft.Identity.Client.dll") if($script:MSALUseWAM) { - $DLLFiles += [IO.FileInfo]($ScriptRoot + "\BIN\$MSALDLLPath\Microsoft.Identity.Client.Extensions.Msal.dll") - $DLLFiles += [IO.FileInfo]($ScriptRoot + "\BIN\$MSALDLLPath\Microsoft.Identity.Client.Broker.dll") - $DLLFiles += [IO.FileInfo]($ScriptRoot + "\BIN\$MSALDLLPath\Microsoft.Identity.Client.Desktop.dll") - $DLLFiles += [IO.FileInfo]($ScriptRoot + "\BIN\$MSALDLLPath\Microsoft.Identity.Client.NativeInterop.dll") + $DLLFiles += [IO.FileInfo](Join-Path $ScriptRoot "Bin/$MSALDLLPath/Microsoft.Identity.Client.Extensions.Msal.dll") + $DLLFiles += [IO.FileInfo](Join-Path $ScriptRoot "Bin/$MSALDLLPath/Microsoft.Identity.Client.Broker.dll") + $DLLFiles += [IO.FileInfo](Join-Path $ScriptRoot "Bin/$MSALDLLPath/Microsoft.Identity.Client.Desktop.dll") + $DLLFiles += [IO.FileInfo](Join-Path $ScriptRoot "Bin/$MSALDLLPath/Microsoft.Identity.Client.NativeInterop.dll") } $DLLFiles | ForEach-Object { @@ -570,7 +570,7 @@ function Add-MSALPrereq try { - Add-Type -Path ($ScriptRoot + "\CS\TokenCacheHelperEx.cs") -ReferencedAssemblies $RequiredAssemblies -IgnoreWarnings + Add-Type -Path (Join-Path $ScriptRoot "CS/TokenCacheHelperEx.cs") -ReferencedAssemblies $RequiredAssemblies -IgnoreWarnings } catch { @@ -809,7 +809,7 @@ function Get-MsalAuthenticationToken { # Login hung on rare occations # Workaround: Added DoEvents - [System.Windows.Forms.Application]::DoEvents() + Invoke-AppDoEvents Start-Sleep -Seconds 1 } } @@ -954,7 +954,7 @@ function Get-MSALApp if($global:SkipTokenCacheHelperEx -ne $true -and (Get-SettingValue "CacheMSALToken")) { - [TokenCacheHelperEx]::EnableSerialization($msalApp.UserTokenCache, "%LOCALAPPDATA%\CloudAPIPowerShellManagement\msalcahce.bin3") + [TokenCacheHelperEx]::EnableSerialization($msalApp.UserTokenCache, (Join-Path (Get-CloudApiDataFolder) "msalcahce.bin3")) } $script:MSALAllApps += $msalApp } @@ -1350,7 +1350,7 @@ function Connect-MSALUser if((Get-SettingValue "CacheMSALToken")) { - [TokenCacheHelperEx]::EnableSerialization($app.UserTokenCache, "%LOCALAPPDATA%\CloudAPIPowerShellManagement\msalcahce.bin3") + [TokenCacheHelperEx]::EnableSerialization($app.UserTokenCache, (Join-Path (Get-CloudApiDataFolder) "msalcahce.bin3")) } ### Silent login @@ -2377,4 +2377,4 @@ function Show-MSALDecodedToken { Show-ModalForm $title $dg } -Export-ModuleMember -alias * -function * \ No newline at end of file +Export-ModuleMember -alias * -function * diff --git a/Extensions/MSGraph.psm1 b/Extensions/MSGraph.psm1 index 96ace73..bb29fa7 100644 --- a/Extensions/MSGraph.psm1 +++ b/Extensions/MSGraph.psm1 @@ -782,7 +782,7 @@ function Add-GraphObjectProperties if($objects.Count -gt 0 -and $SortProperty -and ($objects[0] | GM -MemberType NoteProperty -Name $SortProperty)) { - $objects = $objects | sort -Property $SortProperty + $objects = $objects | Sort-Object -Property $SortProperty } $objects @@ -1400,12 +1400,77 @@ function Invoke-SilentBatchJob } } +function New-GraphSilentBatchForm +{ + param($Controls) + + $form = New-HeadlessForm $Controls + + foreach($control in $Controls) + { + New-Variable -Name $control.Name -Value $control -Force -Scope Global + } + + $form +} + +function New-GraphSilentExportForm +{ + $controls = @( + (New-HeadlessControl -Name "txtExportPath" -Type "TextBox"), + (New-HeadlessControl -Name "txtExportNameFilter" -Type "TextBox"), + (New-HeadlessControl -Name "chkAddObjectType" -Type "CheckBox"), + (New-HeadlessControl -Name "chkExportAssignments" -Type "CheckBox"), + (New-HeadlessControl -Name "chkAddCompanyName" -Type "CheckBox"), + (New-HeadlessControl -Name "dgObjectsToExport" -Type "DataGrid") + ) + + $form = New-GraphSilentBatchForm $controls + Set-XamlProperty $form "txtExportPath" "Text" (?? (Get-Setting "" "LastUsedRoot") (Get-SettingValue "RootFolder")) + Set-XamlProperty $form "chkAddObjectType" "IsChecked" $true + Set-XamlProperty $form "chkExportAssignments" "IsChecked" (Get-SettingValue "ExportAssignments") + Set-XamlProperty $form "chkAddCompanyName" "IsChecked" (Get-SettingValue "AddCompanyName") + + $form +} + +function New-GraphSilentImportForm +{ + $path = Get-Setting "" "LastUsedFullPath" + if($path) + { + $path = [IO.Directory]::GetParent($path).FullName + } + + $controls = @( + (New-HeadlessControl -Name "txtImportPath" -Type "TextBox"), + (New-HeadlessControl -Name "txtImportNameFilter" -Type "TextBox"), + (New-HeadlessControl -Name "lblMigrationTableInfo" -Type "Label"), + (New-HeadlessControl -Name "chkAddObjectType" -Type "CheckBox"), + (New-HeadlessControl -Name "chkImportScopes" -Type "CheckBox"), + (New-HeadlessControl -Name "chkImportAssignments" -Type "CheckBox"), + (New-HeadlessControl -Name "chkReplaceDependencyIDs" -Type "CheckBox"), + (New-HeadlessControl -Name "cbImportType" -Type "ComboBox"), + (New-HeadlessControl -Name "dgObjectsToImport" -Type "DataGrid") + ) + + $form = New-GraphSilentBatchForm $controls + Set-XamlProperty $form "txtImportPath" "Text" (?? $path (Get-SettingValue "RootFolder")) + Set-XamlProperty $form "chkAddObjectType" "IsChecked" $true + Set-XamlProperty $form "chkImportAssignments" "IsChecked" (Get-SettingValue "ImportAssignments") + Set-XamlProperty $form "chkImportScopes" "IsChecked" (Get-SettingValue "ImportScopeTags") + Set-XamlProperty $form "chkReplaceDependencyIDs" "IsChecked" $true + Set-XamlProperty $form "cbImportType" "ItemsSource" $script:lstImportTypes + Set-XamlProperty $form "cbImportType" "SelectedValue" (Get-SettingValue "ImportType" "alwaysImport") + + $form +} + function Start-GraphSilentBulkExport { param($settingsObj) - $script:exportForm = Get-XamlObject ($global:AppRootFolder + "\Xaml\BulkExportForm.xaml") -AddVariables - if(-not $script:exportForm) { return } + $script:exportForm = New-GraphSilentExportForm $script:exportObjects = Get-GraphBatchObjectTypes $settingsObj.BulkExport @@ -1414,8 +1479,6 @@ function Start-GraphSilentBulkExport if(-not $viewObj.Title) { continue } if($viewObj.ObjectType.ShowButtons -is [Object[]] -and $viewObj.ObjectType.ShowButtons -notcontains "Export") { continue } - - Add-GraphExportExtensions $script:exportForm 0 $viewObj.ObjectType } Set-BatchProperties $settingsObj.BulkExport $script:exportForm @@ -1449,8 +1512,7 @@ function Start-GraphSilentBulkImport { param($settingsObj) - $script:importForm = Get-XamlObject ($global:AppRootFolder + "\Xaml\BulkImportForm.xaml") -AddVariables - if(-not $script:importForm) { return } + $script:importForm = New-GraphSilentImportForm # Get all objects but not selected # This will allow dependencies @@ -1479,11 +1541,10 @@ function Start-GraphSilentBulkImport if(-not $viewObj.Title) { continue } if($viewObj.ObjectType.ShowButtons -is [Object[]] -and $viewObj.ObjectType.ShowButtons -notcontains "Import") { continue } - - Add-GraphImportExtensions $script:importForm 0 $viewObj.ObjectType } Set-BatchProperties $settingsObj.BulkImport $script:importForm + Set-XamlProperty $script:importForm "lblMigrationTableInfo" "Content" (Get-MigrationTableInfo) $global:dgObjectsToImport.ItemsSource = @($script:importObjects) @@ -2358,7 +2419,7 @@ function Get-GraphFileObjects } $fileArr = @() - foreach($file in (Get-Item -path "$path\*.json" @params)) + foreach($file in (Get-Item -path (Join-Path $path "*.json") @params)) { if($ObjectType.LoadObject) { @@ -2864,7 +2925,7 @@ function Add-GroupMigrationObject # Export group info to json file for possible import $grouspPath = Join-Path $path "Groups" if(-not (Test-Path $grouspPath)) { mkdir -Path $grouspPath -Force -ErrorAction SilentlyContinue | Out-Null } - $fileName = "$grouspPath\$((Remove-InvalidFileNameChars $groupObj.displayName)).json" + $fileName = Join-Path $grouspPath "$((Remove-InvalidFileNameChars $groupObj.displayName)).json" Save-GraphObjectToFile $groupObj $fileName } } @@ -2907,7 +2968,7 @@ function Add-GraphMigrationObject # Export group info to json file for possible import $grouspPath = Join-Path $path "Groups" if(-not (Test-Path $grouspPath)) { mkdir -Path $grouspPath -Force -ErrorAction SilentlyContinue | Out-Null } - $fileName = "$grouspPath\$((Remove-InvalidFileNameChars $graphObj.displayName)).json" + $fileName = Join-Path $grouspPath "$((Remove-InvalidFileNameChars $graphObj.displayName)).json" Save-GraphObjectToFile $graphObj $fileName } } @@ -3050,7 +3111,7 @@ function Get-GraphMigrationObjectsFromFile if($global:GraphMigrationTable) { $fi = [IO.FileInfo]$global:GraphMigrationTable - $groupFi = [IO.FileInfo]($fi.DirectoryName + "\Groups\$((Remove-InvalidFileNameChars $migTableGroupName)).json") + $groupFi = [IO.FileInfo](Join-Path (Join-Path $fi.DirectoryName "Groups") "$((Remove-InvalidFileNameChars $migTableGroupName)).json") } if($groupFi.Exists -eq $true) @@ -3197,13 +3258,13 @@ function Add-GraphDependencyObjects continue } - if([IO.Directory]::Exists(($importPath + "\" + $dep))) + if([IO.Directory]::Exists((Join-Path $importPath $dep))) { - $path = ($importPath + "\" + $dep) + $path = (Join-Path $importPath $dep) } - elseif([IO.Directory]::Exists(($parentPath + "\" + $dep))) + elseif([IO.Directory]::Exists((Join-Path $parentPath $dep))) { - $path = ($parentPath + "\" + $dep) + $path = (Join-Path $parentPath $dep) } else { @@ -3674,7 +3735,7 @@ function Invoke-GraphBatchRequest $retryAfter = 0 $tmpResults = Invoke-GraphRequest -Url "`$batch" -Body $json -Method "POST" - foreach($batchResult in ($tmpResults.responses | Sort -Property Id)) + foreach($batchResult in ($tmpResults.responses | Sort-Object -Property Id)) { if($batchResult.Status -ge 300 -or -not $batchResult.body) { @@ -4151,7 +4212,7 @@ function Show-GraphObjectInfo if($prop.Name.Contains('@') -or $prop.Name.Contains('#')) { continue } $objProps += ([PSCustomObject]@{ Name=$prop.Name;Value=$prop }) } - $objProps = $objProps | sort -Property Name + $objProps = $objProps | Sort-Object -Property Name Set-XamlProperty $script:detailsForm "lstObjectProperties" "ItemsSource" $objProps Add-XamlEvent $script:detailsForm "btnObjectColumnsReset" "Add_Click" -scriptBlock ([scriptblock]{ @@ -4633,4 +4694,4 @@ function Confirm-GraphMatchFilter } } return $true -} \ No newline at end of file +} diff --git a/Headless/IntuneManagement.Headless.psd1 b/Headless/IntuneManagement.Headless.psd1 new file mode 100644 index 0000000..69736cd --- /dev/null +++ b/Headless/IntuneManagement.Headless.psd1 @@ -0,0 +1,17 @@ +@{ + RootModule = 'IntuneManagement.Headless.psm1' + ModuleVersion = '0.1.0' + GUID = 'b5b4183d-8d6b-4b31-bbde-f2f0f0a0739d' + Author = 'OpenAI Codex' + Copyright = '(c) OpenAI. Adapter module for headless Intune policy migration.' + Description = 'Headless export/import wrapper for IntuneManagement.' + FunctionsToExport = @( + 'Get-DefaultIntunePolicyObjectTypes', + 'Export-IntunePolicies', + 'Import-IntunePolicies', + 'Invoke-IntunePolicyAction' + ) + AliasesToExport = @() + VariablesToExport = @() + CmdletsToExport = @() +} diff --git a/Headless/IntuneManagement.Headless.psm1 b/Headless/IntuneManagement.Headless.psm1 new file mode 100644 index 0000000..f1442c6 --- /dev/null +++ b/Headless/IntuneManagement.Headless.psm1 @@ -0,0 +1,311 @@ +function Get-DefaultIntunePolicyObjectTypes +{ + @( + "DeviceConfiguration", + "SettingsCatalog", + "AdministrativeTemplates", + "CompliancePolicies", + "EndpointSecurity", + "PolicySets" + ) +} + +function Get-IntuneManagementProjectRoot +{ + Split-Path -Parent $PSScriptRoot +} + +function Resolve-HeadlessSettingsPath +{ + param([string]$SettingsFile) + + if($SettingsFile) + { + return $SettingsFile + } + + Join-Path ([IO.Path]::GetTempPath()) "IntuneManagement.Settings.json" +} + +function New-TemporaryBatchFile +{ + param([string]$Prefix) + + Join-Path ([IO.Path]::GetTempPath()) ("IntuneManagement.{0}.{1}.json" -f $Prefix, [guid]::NewGuid().ToString()) +} + +function Test-AuthParameters +{ + param( + [string]$Secret, + [string]$Certificate + ) + + if((-not $Secret) -and (-not $Certificate)) + { + throw "Specify -Secret or -Certificate." + } +} + +function Invoke-IntuneHeadlessBatch +{ + param( + [Parameter(Mandatory = $true)] + [string]$TenantId, + + [Parameter(Mandatory = $true)] + [string]$AppId, + + [string]$Secret, + + [string]$Certificate, + + [Parameter(Mandatory = $true)] + [psobject]$BatchConfig, + + [string]$SettingsFile, + + [string]$BatchFile + ) + + Test-AuthParameters -Secret $Secret -Certificate $Certificate + + $projectRoot = Get-IntuneManagementProjectRoot + $startScript = Join-Path $projectRoot "Start-IntuneManagement.ps1" + + if(-not (Test-Path $startScript)) + { + throw "Could not find Start-IntuneManagement.ps1 in $projectRoot" + } + + $settingsPath = Resolve-HeadlessSettingsPath $SettingsFile + + $deleteBatchFile = $false + if(-not $BatchFile) + { + $BatchFile = New-TemporaryBatchFile "Batch" + $deleteBatchFile = $true + } + + try + { + $BatchConfig | ConvertTo-Json -Depth 20 | Out-File -LiteralPath $BatchFile -Encoding utf8 -Force + + $invokeParams = @{ + Silent = $true + JSonSettings = $true + JSonFile = $settingsPath + TenantId = $TenantId + AppId = $AppId + SilentBatchFile = $BatchFile + } + + if($Secret) + { + $invokeParams.Secret = $Secret + } + else + { + $invokeParams.Certificate = $Certificate + } + + & $startScript @invokeParams + } + finally + { + if($deleteBatchFile -and (Test-Path $BatchFile)) + { + Remove-Item -LiteralPath $BatchFile -Force -ErrorAction SilentlyContinue + } + } +} + +function Export-IntunePolicies +{ + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantId, + + [Parameter(Mandatory = $true)] + [string]$AppId, + + [string]$Secret, + + [string]$Certificate, + + [Parameter(Mandatory = $true)] + [string]$ExportPath, + + [string]$SettingsFile, + + [string]$BatchFile, + + [string]$NameFilter = "", + + [string[]]$ObjectTypes = (Get-DefaultIntunePolicyObjectTypes), + + [switch]$IncludeAssignments, + + [switch]$AddCompanyName + ) + + $batchConfig = [PSCustomObject]@{ + BulkExport = @( + [PSCustomObject]@{ Name = "txtExportPath"; Value = $ExportPath }, + [PSCustomObject]@{ Name = "txtExportNameFilter"; Value = $NameFilter }, + [PSCustomObject]@{ Name = "chkAddObjectType"; Value = $true }, + [PSCustomObject]@{ Name = "chkExportAssignments"; Value = $IncludeAssignments.IsPresent }, + [PSCustomObject]@{ Name = "chkAddCompanyName"; Value = $AddCompanyName.IsPresent }, + [PSCustomObject]@{ Name = "ObjectTypes"; Type = "Custom"; ObjectTypes = @($ObjectTypes) } + ) + } + + Invoke-IntuneHeadlessBatch ` + -TenantId $TenantId ` + -AppId $AppId ` + -Secret $Secret ` + -Certificate $Certificate ` + -BatchConfig $batchConfig ` + -SettingsFile $SettingsFile ` + -BatchFile $BatchFile +} + +function Import-IntunePolicies +{ + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantId, + + [Parameter(Mandatory = $true)] + [string]$AppId, + + [string]$Secret, + + [string]$Certificate, + + [Parameter(Mandatory = $true)] + [string]$ImportPath, + + [string]$SettingsFile, + + [string]$BatchFile, + + [string]$NameFilter = "", + + [ValidateSet("alwaysImport","skipIfExist","replace","replace_with_assignments","update")] + [string]$ImportType = "alwaysImport", + + [string[]]$ObjectTypes = (Get-DefaultIntunePolicyObjectTypes), + + [switch]$IncludeAssignments, + + [switch]$IncludeScopeTags, + + [switch]$ReplaceDependencyIds + ) + + $batchConfig = [PSCustomObject]@{ + BulkImport = @( + [PSCustomObject]@{ Name = "txtImportPath"; Value = $ImportPath }, + [PSCustomObject]@{ Name = "txtImportNameFilter"; Value = $NameFilter }, + [PSCustomObject]@{ Name = "chkAddObjectType"; Value = $true }, + [PSCustomObject]@{ Name = "chkImportScopes"; Value = $IncludeScopeTags.IsPresent }, + [PSCustomObject]@{ Name = "chkImportAssignments"; Value = $IncludeAssignments.IsPresent }, + [PSCustomObject]@{ Name = "chkReplaceDependencyIDs"; Value = $ReplaceDependencyIds.IsPresent }, + [PSCustomObject]@{ Name = "cbImportType"; Value = $ImportType }, + [PSCustomObject]@{ Name = "ObjectTypes"; Type = "Custom"; ObjectTypes = @($ObjectTypes) } + ) + } + + Invoke-IntuneHeadlessBatch ` + -TenantId $TenantId ` + -AppId $AppId ` + -Secret $Secret ` + -Certificate $Certificate ` + -BatchConfig $batchConfig ` + -SettingsFile $SettingsFile ` + -BatchFile $BatchFile +} + +function Invoke-IntunePolicyAction +{ + [CmdletBinding(DefaultParameterSetName = 'Export')] + param( + [Parameter(Mandatory = $true)] + [ValidateSet("Export","Import")] + [string]$Action, + + [Parameter(Mandatory = $true)] + [string]$TenantId, + + [Parameter(Mandatory = $true)] + [string]$AppId, + + [string]$Secret, + + [string]$Certificate, + + [string]$SettingsFile, + + [string]$BatchFile, + + [string]$NameFilter = "", + + [string[]]$ObjectTypes = (Get-DefaultIntunePolicyObjectTypes), + + [string]$ExportPath, + + [string]$ImportPath, + + [ValidateSet("alwaysImport","skipIfExist","replace","replace_with_assignments","update")] + [string]$ImportType = "alwaysImport", + + [switch]$IncludeAssignments, + + [switch]$AddCompanyName, + + [switch]$IncludeScopeTags, + + [switch]$ReplaceDependencyIds + ) + + switch($Action) + { + "Export" + { + if(-not $ExportPath) { throw "Export requires -ExportPath." } + Export-IntunePolicies ` + -TenantId $TenantId ` + -AppId $AppId ` + -Secret $Secret ` + -Certificate $Certificate ` + -ExportPath $ExportPath ` + -SettingsFile $SettingsFile ` + -BatchFile $BatchFile ` + -NameFilter $NameFilter ` + -ObjectTypes $ObjectTypes ` + -IncludeAssignments:$IncludeAssignments ` + -AddCompanyName:$AddCompanyName + } + "Import" + { + if(-not $ImportPath) { throw "Import requires -ImportPath." } + Import-IntunePolicies ` + -TenantId $TenantId ` + -AppId $AppId ` + -Secret $Secret ` + -Certificate $Certificate ` + -ImportPath $ImportPath ` + -SettingsFile $SettingsFile ` + -BatchFile $BatchFile ` + -NameFilter $NameFilter ` + -ImportType $ImportType ` + -ObjectTypes $ObjectTypes ` + -IncludeAssignments:$IncludeAssignments ` + -IncludeScopeTags:$IncludeScopeTags ` + -ReplaceDependencyIds:$ReplaceDependencyIds + } + } +} diff --git a/Headless/README.md b/Headless/README.md new file mode 100644 index 0000000..a0074cd --- /dev/null +++ b/Headless/README.md @@ -0,0 +1,40 @@ +# IntuneManagement Headless + +This is the CLI-first surface for a cross-platform fork of the original IntuneManagement project. + +The original project is still a Windows WPF application. This layer treats that codebase as an implementation backend and exposes a smaller product surface focused on: + +* app-only authentication +* headless export/import +* macOS/Linux/Windows execution with `pwsh` +* automation and CI usage + +## Entry points + +* [Start-HeadlessIntune.ps1](/Users/avedelphina/Local/IntuneManagement/Start-HeadlessIntune.ps1) +* [Scripts/Export-Policies.ps1](/Users/avedelphina/Local/IntuneManagement/Scripts/Export-Policies.ps1) +* [Scripts/Import-Policies.ps1](/Users/avedelphina/Local/IntuneManagement/Scripts/Import-Policies.ps1) +* [Headless/IntuneManagement.Headless.psd1](/Users/avedelphina/Local/IntuneManagement/Headless/IntuneManagement.Headless.psd1) + +## Default policy scope + +The default object types are: + +* `DeviceConfiguration` +* `SettingsCatalog` +* `AdministrativeTemplates` +* `CompliancePolicies` +* `EndpointSecurity` +* `PolicySets` + +## Example + +```powershell +pwsh ./Start-HeadlessIntune.ps1 ` + -Action Export ` + -TenantId "" ` + -AppId "" ` + -Secret "" ` + -ExportPath "/tmp/intune-export" ` + -IncludeAssignments +``` diff --git a/README.md b/README.md index 8124130..c8bc894 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,14 @@ -# IntuneManagement with PowerShell and WPF UI +# IntuneManagement + +This repository now contains two usable surfaces: + +* the original Windows WPF application +* a newer headless CLI-first surface for cross-platform export/import + +The CLI-first fork shape starts here: + +* [Start-HeadlessIntune.ps1](/Users/avedelphina/Local/IntuneManagement/Start-HeadlessIntune.ps1) +* [Headless/README.md](/Users/avedelphina/Local/IntuneManagement/Headless/README.md)

@@ -64,6 +74,128 @@ The tool will by default generate the files; `BulkExport.json` and `BulkImport.j The app authentication can either be passed on the command line or stored in the settings. Tennant Settings is required for multiple environments. +### Headless use on macOS/Linux + +The WPF UI is still Windows-only, but the **silent batch** mode can be used headlessly with `pwsh` on macOS/Linux. + +For non-Windows use: + +* Run `Start-IntuneManagement.ps1` with `-Silent` +* Use app-only authentication with `-TenantId`, `-AppId` and `-Secret` or `-Certificate` +* Use `-JSonSettings` and preferably specify `-JSonFile` +* Pass a batch configuration JSON with `-SilentBatchFile` + +Common policy object type IDs: + +* `DeviceConfiguration` +* `SettingsCatalog` +* `AdministrativeTemplates` +* `CompliancePolicies` +* `EndpointSecurity` +* `PolicySets` +* `ConditionalAccess` + +**Example export batch file** + +```json +{ + "BulkExport": [ + { "Name": "txtExportPath", "Value": "/tmp/intune-export" }, + { "Name": "txtExportNameFilter", "Value": "" }, + { "Name": "chkAddObjectType", "Value": true }, + { "Name": "chkExportAssignments", "Value": true }, + { "Name": "chkAddCompanyName", "Value": false }, + { + "Name": "ObjectTypes", + "Type": "Custom", + "ObjectTypes": [ + "DeviceConfiguration", + "SettingsCatalog", + "AdministrativeTemplates", + "CompliancePolicies", + "EndpointSecurity", + "PolicySets" + ] + } + ] +} +``` + +**Example export command** + +```powershell +pwsh ./Start-IntuneManagement.ps1 ` + -Silent ` + -JSonSettings -JSonFile "/tmp/intune-settings.json" ` + -TenantId "" ` + -AppId "" ` + -Secret "" ` + -SilentBatchFile "/tmp/BulkExport.json" +``` + +**Example import batch file** + +```json +{ + "BulkImport": [ + { "Name": "txtImportPath", "Value": "/tmp/intune-export/SourceTenantName" }, + { "Name": "txtImportNameFilter", "Value": "" }, + { "Name": "chkAddObjectType", "Value": true }, + { "Name": "chkImportScopes", "Value": true }, + { "Name": "chkImportAssignments", "Value": true }, + { "Name": "chkReplaceDependencyIDs", "Value": true }, + { "Name": "cbImportType", "Value": "alwaysImport" }, + { + "Name": "ObjectTypes", + "Type": "Custom", + "ObjectTypes": [ + "DeviceConfiguration", + "SettingsCatalog", + "AdministrativeTemplates", + "CompliancePolicies", + "EndpointSecurity", + "PolicySets" + ] + } + ] +} +``` + +**Example import command** + +```powershell +pwsh ./Start-IntuneManagement.ps1 ` + -Silent ` + -JSonSettings -JSonFile "/tmp/intune-settings.json" ` + -TenantId "" ` + -AppId "" ` + -Secret "" ` + -SilentBatchFile "/tmp/BulkImport.json" +``` + +Wrapper scripts are also included: + +```powershell +pwsh ./Scripts/Export-Policies.ps1 ` + -TenantId "" ` + -AppId "" ` + -Secret "" ` + -ExportPath "/tmp/intune-export" ` + -IncludeAssignments +``` + +```powershell +pwsh ./Scripts/Import-Policies.ps1 ` + -TenantId "" ` + -AppId "" ` + -Secret "" ` + -ImportPath "/tmp/intune-export/SourceTenantName" ` + -ImportType alwaysImport ` + -IncludeAssignments ` + -IncludeScopeTags ` + -ReplaceDependencyIds +``` + **Command line example:** Start-IntuneManagement.ps1 -Silent -TenantId "<*TenantID*>" -SilentBatchFile <*PathToFile*> [-AppId <*AppId*>] [-Secret <*Secret*> | -Certificate <*CertThumb*>] @@ -341,4 +473,4 @@ Check the log file for errors. The UI might not show errors why login failed etc ## License -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. \ No newline at end of file +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/Scripts/Export-Policies.ps1 b/Scripts/Export-Policies.ps1 new file mode 100644 index 0000000..f7de805 --- /dev/null +++ b/Scripts/Export-Policies.ps1 @@ -0,0 +1,43 @@ +<# +.SYNOPSIS +Headless Intune policy export wrapper for macOS/Linux/Windows. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$TenantId, + + [Parameter(Mandatory = $true)] + [string]$AppId, + + [string]$Secret, + + [string]$Certificate, + + [Parameter(Mandatory = $true)] + [string]$ExportPath, + + [string]$SettingsFile, + + [string]$BatchFile, + + [string]$NameFilter = "", + + [string[]]$ObjectTypes = @( + "DeviceConfiguration", + "SettingsCatalog", + "AdministrativeTemplates", + "CompliancePolicies", + "EndpointSecurity", + "PolicySets" + ), + +[switch]$IncludeAssignments, + + [switch]$AddCompanyName +) + +$modulePath = Join-Path (Split-Path -Parent $PSScriptRoot) "Headless/IntuneManagement.Headless.psd1" +Import-Module $modulePath -Force + +Export-IntunePolicies @PSBoundParameters diff --git a/Scripts/Import-Policies.ps1 b/Scripts/Import-Policies.ps1 new file mode 100644 index 0000000..dcefe28 --- /dev/null +++ b/Scripts/Import-Policies.ps1 @@ -0,0 +1,48 @@ +<# +.SYNOPSIS +Headless Intune policy import wrapper for macOS/Linux/Windows. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$TenantId, + + [Parameter(Mandatory = $true)] + [string]$AppId, + + [string]$Secret, + + [string]$Certificate, + + [Parameter(Mandatory = $true)] + [string]$ImportPath, + + [string]$SettingsFile, + + [string]$BatchFile, + + [string]$NameFilter = "", + + [ValidateSet("alwaysImport","skipIfExist","replace","replace_with_assignments","update")] + [string]$ImportType = "alwaysImport", + + [string[]]$ObjectTypes = @( + "DeviceConfiguration", + "SettingsCatalog", + "AdministrativeTemplates", + "CompliancePolicies", + "EndpointSecurity", + "PolicySets" + ), + + [switch]$IncludeAssignments, + + [switch]$IncludeScopeTags, + + [switch]$ReplaceDependencyIds +) + +$modulePath = Join-Path (Split-Path -Parent $PSScriptRoot) "Headless/IntuneManagement.Headless.psd1" +Import-Module $modulePath -Force + +Import-IntunePolicies @PSBoundParameters diff --git a/Start-HeadlessIntune.ps1 b/Start-HeadlessIntune.ps1 new file mode 100644 index 0000000..327e2a6 --- /dev/null +++ b/Start-HeadlessIntune.ps1 @@ -0,0 +1,74 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidateSet("Export","Import")] + [string]$Action, + + [Parameter(Mandatory = $true)] + [string]$TenantId, + + [Parameter(Mandatory = $true)] + [string]$AppId, + + [string]$Secret, + + [string]$Certificate, + + [string]$SettingsFile, + + [string]$BatchFile, + + [string]$NameFilter = "", + + [string[]]$ObjectTypes, + + [string]$ExportPath, + + [string]$ImportPath, + + [ValidateSet("alwaysImport","skipIfExist","replace","replace_with_assignments","update")] + [string]$ImportType = "alwaysImport", + + [switch]$IncludeAssignments, + + [switch]$AddCompanyName, + + [switch]$IncludeScopeTags, + + [switch]$ReplaceDependencyIds +) + +$modulePath = Join-Path $PSScriptRoot "Headless/IntuneManagement.Headless.psd1" +Import-Module $modulePath -Force + +$invokeParams = @{ + Action = $Action + TenantId = $TenantId + AppId = $AppId + SettingsFile = $SettingsFile + BatchFile = $BatchFile + NameFilter = $NameFilter + ExportPath = $ExportPath + ImportPath = $ImportPath + ImportType = $ImportType + IncludeAssignments = $IncludeAssignments + AddCompanyName = $AddCompanyName + IncludeScopeTags = $IncludeScopeTags + ReplaceDependencyIds = $ReplaceDependencyIds +} + +if($PSBoundParameters.ContainsKey("ObjectTypes")) +{ + $invokeParams.ObjectTypes = $ObjectTypes +} + +if($Secret) +{ + $invokeParams.Secret = $Secret +} +elseif($Certificate) +{ + $invokeParams.Certificate = $Certificate +} + +Invoke-IntunePolicyAction @invokeParams