#Requires -Version 5.1 <# .SYNOPSIS Exportiert Power Automate Flows als ZIP-Pakete - OHNE PnP.PowerShell, nur Azure CLI + REST. .DESCRIPTION Nutzt die Azure CLI (az) fuer den interaktiven Login (Browser bzw. WAM, kein Device Code) und holt ein Azure-Resource-Manager-Token. Genau dieses Token verwenden auch die PnP-Cmdlets Get-PnPFlow/Export-PnPFlow - fuer Listen UND ZIP-Export wird nichts anderes benoetigt (kein PowerApps-Service-Token, keine eigene App-Registrierung). Ablauf: 1. az login (normales az-Profil; optional -AzConfigDir fuer ein getrenntes Profil, damit ein vorhandener az-Login unangetastet bleibt). Der Login wird dort persistiert -> beim naechsten Lauf i. d. R. kein Prompt. 2. Umgebungen listen (api.flow.microsoft.com .../environments) 3. Flows listen (api.flow.microsoft.com .../environments//flows) 4. Je Flow: listPackageResources -> exportPackage -> (Polling) -> ZIP-Download (api.bap.microsoft.com, identisch zu Export-PnPFlow -AsZipPackage) Voraussetzung: Azure CLI installiert (winget install Microsoft.AzureCLI). Laeuft unter Windows PowerShell 5.1 und PowerShell 7. .EXAMPLE ./Export-PowerAutomateFlows-AzCli.ps1 # Login (falls noetig), Umgebung + Flows per Auswahlliste, Export nach Downloads\PowerAutomateFlowExport .EXAMPLE ./Export-PowerAutomateFlows-AzCli.ps1 -EnvironmentName Default- -All # Alle eigenen Flows der Default-Umgebung ohne Rueckfrage exportieren .EXAMPLE ./Export-PowerAutomateFlows-AzCli.ps1 -ForceLogin # Neu anmelden (z. B. anderer Benutzer) #> [CmdletBinding()] param( # Optional. Leer = Home-Tenant des angemeldeten Kontos (wird nach dem Login aus az gelesen). # Nur noetig, wenn du als Gast in einem fremden Tenant exportieren willst. [string]$TenantId, [string]$ExportFolder = "$env:USERPROFILE\Downloads\PowerAutomateFlowExport", # Umgebungs-Name (GUID bzw. "Default-"). Leer = Auswahlliste. [string]$EnvironmentName, # Alle gefundenen Flows exportieren, keine Auswahlliste. [switch]$All, # Nur eigene Flows (search('personal')) statt eigene + geteilte (search('team AND personal')). [switch]$PersonalOnly, # Login erzwingen, auch wenn im az-Profil schon ein Konto liegt. [switch]$ForceLogin, # az-Login ueber den System-Browser statt WAM (falls das WAM-Fenster nicht erscheint). [switch]$NoWam, # Optional: eigenes az-Profilverzeichnis, um den Login vom normalen az-Profil zu trennen # (z. B. -AzConfigDir "$env:LOCALAPPDATA\FlowExport-azcli"). Leer = normales az-Profil. [string]$AzConfigDir ) $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' # Windows PowerShell 5.1: TLS 1.2 sicherstellen (PS 7 macht das von selbst). [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 $FlowApi = 'https://api.flow.microsoft.com' $BapApi = 'https://api.bap.microsoft.com' $ApiVer = '2016-11-01' # ── Hilfsfunktionen ─────────────────────────────────────────────────────────── function Invoke-Az { # Fuehrt az aus, wirft bei Exit-Code != 0 mit der stderr-Ausgabe. param([Parameter(Mandatory)][string[]]$Arguments) # 5.1-Gotcha: bei $ErrorActionPreference='Stop' macht 2>&1 aus der ersten stderr-Zeile (az-WARNING) # einen terminierenden Fehler. Deshalb hier lokal auf 'Continue'. $ErrorActionPreference = 'Continue' $out = & az @Arguments 2>&1 $exit = $LASTEXITCODE $stdout = ($out | Where-Object { $_ -isnot [System.Management.Automation.ErrorRecord] }) -join "`n" $stderr = ($out | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] } | ForEach-Object { $_.ToString() }) -join "`n" if ($exit -ne 0) { throw "az $($Arguments[0..1] -join ' ') fehlgeschlagen (Exit $exit): $stderr" } return $stdout } function Get-ArmToken { $json = Invoke-Az @('account', 'get-access-token', '--resource', 'https://management.azure.com/', '--tenant', $script:TenantId, '-o', 'json') $t = $json | ConvertFrom-Json if (-not $t.accessToken) { throw "Kein Access-Token von az erhalten." } return $t.accessToken } function Invoke-Api { # REST-Call mit ARM-Token; gibt das Response-Objekt (Invoke-WebRequest) zurueck, wirft bei HTTP >= 400. param( [Parameter(Mandatory)][ValidateSet('GET', 'POST')][string]$Method, [Parameter(Mandatory)][string]$Uri, [object]$Body ) $headers = @{ Authorization = "Bearer $script:Token"; Accept = 'application/json' } $splat = @{ Method = $Method; Uri = $Uri; Headers = $headers; TimeoutSec = 120; UseBasicParsing = $true } if ($null -ne $Body) { $splat.ContentType = 'application/json' $splat.Body = ($Body | ConvertTo-Json -Depth 20 -Compress) } $attempt = 0 while ($true) { $attempt++ # PS 5.1 wirft bei HTTP >= 400 eine Exception, PS 7 mit -SkipHttpErrorCheck nicht. # Beides wird hier auf ein einheitliches Objekt (StatusCode/Headers/Content) abgebildet. $status = 0; $hdrs = @{}; $content = '' try { $r = Invoke-WebRequest @splat $status = [int]$r.StatusCode; $hdrs = $r.Headers; $content = [string]$r.Content } catch { # Generischer catch: der PS7-Typ HttpResponseException existiert unter 5.1 nicht. $resp = $_.Exception.PSObject.Properties['Response'].Value if (-not $resp) { throw } if ($resp -is [System.Net.HttpWebResponse]) { # Windows PowerShell 5.1 $status = [int]$resp.StatusCode foreach ($k in $resp.Headers.AllKeys) { $hdrs[$k] = $resp.Headers[$k] } try { $stream = $resp.GetResponseStream() if ($stream.CanSeek) { $stream.Position = 0 } $sr = New-Object System.IO.StreamReader($stream) $content = $sr.ReadToEnd(); $sr.Dispose() } catch { $content = '' } # Invoke-WebRequest hat den Stream i. d. R. schon gelesen -> Body steht dann in ErrorDetails. if (-not $content) { $content = [string]$_.ErrorDetails.Message } } else { # PowerShell 7 (HttpResponseMessage): der Content-Stream ist hier schon disposed, # der Body steckt aber in ErrorDetails. $status = [int]$resp.StatusCode foreach ($h in $resp.Headers) { $hdrs[$h.Key] = ($h.Value -join ',') } $content = [string]$_.ErrorDetails.Message } } if ($status -eq 429 -and $attempt -le 5) { $wait = 5 $ra = [string]($hdrs['Retry-After'] | Select-Object -First 1) if ($ra -match '^\d+$') { $wait = [int]$ra } Write-Host " Throttled (429) - warte $wait s ..." -ForegroundColor DarkYellow Start-Sleep -Seconds ([Math]::Max($wait, 1)) continue } if ($status -ge 400) { throw "HTTP $status bei $Method $Uri`n$content" } return [PSCustomObject]@{ StatusCode = $status; Headers = $hdrs; Content = $content } } } function Get-ApiCollection { # GET mit nextLink-Paging; liefert alle 'value'-Elemente. param([Parameter(Mandatory)][string]$Uri) $items = @() $next = $Uri while ($next) { $json = (Invoke-Api -Method GET -Uri $next).Content | ConvertFrom-Json if ($json.value) { $items += $json.value } $next = $json.nextLink } return $items } function Get-SafeFileName { param([Parameter(Mandatory)][string]$Name) foreach ($c in [System.IO.Path]::GetInvalidFileNameChars()) { $Name = $Name.Replace($c, '_') } if ($Name.Length -gt 120) { $Name = $Name.Substring(0, 120) } return $Name } function Select-Items { # Mehrfachauswahl: Out-GridView, sonst Out-ConsoleGridView, sonst nummerierte Konsolenauswahl. param( [Parameter(Mandatory)][object[]]$Items, [Parameter(Mandatory)][string]$Title ) if (Get-Command Out-GridView -ErrorAction SilentlyContinue) { return $Items | Out-GridView -PassThru -Title $Title } if (Get-Command Out-ConsoleGridView -ErrorAction SilentlyContinue) { Write-Host "(Out-GridView fehlt -> Out-ConsoleGridView; Leertaste = markieren, Enter = OK)" -ForegroundColor DarkGray return $Items | Out-ConsoleGridView -Title $Title } Write-Host "" Write-Host $Title -ForegroundColor Cyan for ($i = 0; $i -lt $Items.Count; $i++) { Write-Host (" [{0}] {1}" -f $i, $Items[$i].DisplayName) } Write-Host "Nummern kommagetrennt (z. B. 0,2,3) oder 'a' fuer alle:" -ForegroundColor Cyan $answer = Read-Host "Auswahl" if ([string]::IsNullOrWhiteSpace($answer)) { return @() } if ($answer.Trim().ToLower() -eq 'a') { return $Items } $picked = foreach ($tok in $answer -split '[,\s]+') { if ($tok -match '^\d+$' -and [int]$tok -lt $Items.Count) { $Items[[int]$tok] } } return $picked } function Export-FlowPackage { # Entspricht Export-PnPFlow -AsZipPackage: listPackageResources -> exportPackage -> Download. param( [Parameter(Mandatory)][string]$EnvName, [Parameter(Mandatory)][string]$FlowName, [Parameter(Mandatory)][string]$DisplayName, [Parameter(Mandatory)][string]$OutFile ) $flowResId = "/providers/Microsoft.Flow/flows/$FlowName" $base = "$BapApi/providers/Microsoft.BusinessAppPlatform/environments/$EnvName" # 1) Abhaengigkeiten ermitteln $wrapper = (Invoke-Api -Method POST -Uri "$base/listPackageResources?api-version=$ApiVer" ` -Body @{ baseResourceIds = @($flowResId) }).Content | ConvertFrom-Json if ($wrapper.status -ne 'Succeeded') { $errs = ($wrapper.errors | ForEach-Object { "$($_.code): $($_.message)" }) -join '; ' throw "listPackageResources: $($wrapper.status) - $errs" } # 2) suggestedCreationType setzen (wie Export-PnPFlow: Flow = Update, Rest = Existing). # 'resources' ist ein Objekt mit dynamischen Keys -> ueber PSObject.Properties iterieren (5.1-kompatibel). foreach ($prop in $wrapper.resources.PSObject.Properties) { $res = $prop.Value $type = if ($res.type -eq 'Microsoft.Flow/flows') { 'Update' } else { 'Existing' } $res | Add-Member -NotePropertyName suggestedCreationType -NotePropertyValue $type -Force } # 3) Paket erzeugen (kann synchron 200 oder asynchron 202 + Location liefern) # Die API lehnt Paketnamen ab 128 Zeichen ab (InvalidPackageDisplayNameLength) -> kuerzen. $packageName = if ($DisplayName.Length -gt 120) { $DisplayName.Substring(0, 117) + '...' } else { $DisplayName } $exportBody = @{ includedResourceIds = @($flowResId) details = @{ displayName = $packageName description = "Export $(Get-Date -Format 'yyyy-MM-dd HH:mm')" creator = $script:AccountUpn sourceEnvironment = $EnvName } resources = $wrapper.resources } $r = Invoke-Api -Method POST -Uri "$base/exportPackage?api-version=$ApiVer" -Body $exportBody $result = if ($r.Content) { $r.Content | ConvertFrom-Json } else { $null } if ($r.StatusCode -eq 202 -or ($result -and $result.status -notin @('Succeeded', 'Failed'))) { $loc = $r.Headers['Location'] | Select-Object -First 1 if (-not $loc) { throw "exportPackage lieferte HTTP $($r.StatusCode) ohne Location-Header." } $deadline = (Get-Date).AddMinutes(5) do { Start-Sleep -Seconds 2 $p = Invoke-Api -Method GET -Uri $loc $result = if ($p.Content) { $p.Content | ConvertFrom-Json } else { $null } } while ((-not $result -or $result.status -notin @('Succeeded', 'Failed')) -and (Get-Date) -lt $deadline) } if (-not $result -or $result.status -ne 'Succeeded') { $errs = ($result.errors | ForEach-Object { "$($_.code): $($_.message)" }) -join '; ' throw "exportPackage: $($result.status) - $errs" } $link = $result.packageLink.value if (-not $link) { throw "exportPackage: kein packageLink in der Antwort." } # 4) ZIP laden (SAS-Link, kein Token noetig) Invoke-WebRequest -Uri $link -OutFile $OutFile -TimeoutSec 300 -UseBasicParsing } # ── Vorbedingungen ──────────────────────────────────────────────────────────── if (-not (Get-Command az -ErrorAction SilentlyContinue)) { throw "Azure CLI (az) nicht gefunden. Installieren: winget install Microsoft.AzureCLI" } New-Item -Path $ExportFolder -ItemType Directory -Force | Out-Null if ($AzConfigDir) { New-Item -Path $AzConfigDir -ItemType Directory -Force | Out-Null $env:AZURE_CONFIG_DIR = $AzConfigDir Write-Host "az-Profil: $AzConfigDir" -ForegroundColor DarkGray } # ── Login ───────────────────────────────────────────────────────────────────── # Vorhandenen az-Login wiederverwenden, sofern er (bei angegebener -TenantId) zum Tenant passt. $account = $null if (-not $ForceLogin) { try { $account = (Invoke-Az @('account', 'show', '-o', 'json')) | ConvertFrom-Json if ($TenantId -and $account.tenantId -ne $TenantId) { $account = $null } } catch { $account = $null } } if (-not $account) { Write-Host "Anmeldung (Browser/WAM)$(if ($TenantId) { " fuer Tenant $TenantId" }) ..." -ForegroundColor Yellow # Subscription-Auswahldialog der neuen CLI abschalten - wir brauchen keine Subscription. Invoke-Az @('config', 'set', 'core.login_experience_v2=off', '--only-show-errors') | Out-Null Invoke-Az @('config', 'set', "core.enable_broker_on_windows=$(if ($NoWam) { 'false' } else { 'true' })", '--only-show-errors') | Out-Null $loginArgs = @('login', '--allow-no-subscriptions', '--only-show-errors', '-o', 'none') if ($TenantId) { $loginArgs += @('--tenant', $TenantId) } Invoke-Az $loginArgs | Out-Null $account = (Invoke-Az @('account', 'show', '-o', 'json')) | ConvertFrom-Json } $script:AccountUpn = $account.user.name $script:TenantId = if ($TenantId) { $TenantId } else { $account.tenantId } Write-Host "Angemeldet als: $script:AccountUpn (Tenant $script:TenantId)" -ForegroundColor Green Write-Host "Anderes Konto? -> -ForceLogin" -ForegroundColor DarkGray Write-Host "Hole ARM-Token ..." -ForegroundColor Cyan $script:Token = Get-ArmToken # ── Umgebung(en) ────────────────────────────────────────────────────────────── Write-Host "Lade Power-Platform-Umgebungen ..." -ForegroundColor Cyan $environments = Get-ApiCollection -Uri "$FlowApi/providers/Microsoft.ProcessSimple/environments?api-version=$ApiVer" if (-not $environments) { Write-Host "Keine Umgebungen gefunden."; exit 0 } $envRows = foreach ($e in $environments) { [PSCustomObject]@{ DisplayName = $e.properties.displayName Name = $e.name IsDefault = [bool]$e.properties.isDefault Region = $e.location } } if ($EnvironmentName) { $selectedEnvs = @($envRows | Where-Object Name -eq $EnvironmentName) if (-not $selectedEnvs) { throw "Umgebung '$EnvironmentName' nicht gefunden. Vorhanden: $(($envRows.Name) -join ', ')" } } else { $selectedEnvs = @(Select-Items -Items ($envRows | Sort-Object DisplayName) -Title "Power-Platform-Umgebung(en) auswaehlen") if (-not $selectedEnvs) { Write-Host "Keine Umgebung ausgewaehlt. Abbruch." -ForegroundColor Yellow; exit 0 } } # ── Flows je Umgebung ───────────────────────────────────────────────────────── $filter = if ($PersonalOnly) { "search('personal')" } else { "search('team AND personal')" } $ok = 0; $failed = 0 foreach ($envRow in $selectedEnvs) { Write-Host "" Write-Host "Umgebung: $($envRow.DisplayName) [$($envRow.Name)]" -ForegroundColor Cyan $flows = Get-ApiCollection -Uri "$FlowApi/providers/Microsoft.ProcessSimple/environments/$($envRow.Name)/flows?api-version=$ApiVer&`$filter=$filter" if (-not $flows) { Write-Host " Keine Flows gefunden." -ForegroundColor Yellow; continue } $flowRows = foreach ($f in $flows) { [PSCustomObject]@{ DisplayName = $f.properties.displayName State = $f.properties.state Modified = $f.properties.lastModifiedTime Name = $f.name } } Write-Host " $($flowRows.Count) Flow(s) gefunden." -ForegroundColor DarkGray $selectedFlows = if ($All) { $flowRows } else { @(Select-Items -Items ($flowRows | Sort-Object DisplayName) -Title "Flows aus '$($envRow.DisplayName)' auswaehlen") } if (-not $selectedFlows) { Write-Host " Keine Flows ausgewaehlt." -ForegroundColor Yellow; continue } $envFolder = Join-Path $ExportFolder (Get-SafeFileName $envRow.DisplayName) New-Item -Path $envFolder -ItemType Directory -Force | Out-Null foreach ($flow in $selectedFlows) { $target = Join-Path $envFolder "$(Get-SafeFileName $flow.DisplayName)_$($flow.Name).zip" Write-Host "Exportiere: $($flow.DisplayName)" -ForegroundColor Green try { Export-FlowPackage -EnvName $envRow.Name -FlowName $flow.Name -DisplayName $flow.DisplayName -OutFile $target Write-Host " OK: $target ($([Math]::Round((Get-Item $target).Length / 1KB)) KB)" -ForegroundColor Green $ok++ } catch { Write-Host " FEHLER: $($_.Exception.Message)" -ForegroundColor Red $failed++ } } } Write-Host "" Write-Host "Fertig: $ok exportiert, $failed fehlgeschlagen. Ordner: $ExportFolder" -ForegroundColor $(if ($failed) { 'Yellow' } else { 'Green' })