26 KiB
Phase 4: Script Generation - Research
Researched: 2026-04-10 Domain: Jinja2 template-based PowerShell script generation; Intune/RMM printer deployment patterns Confidence: HIGH
Summary
Phase 4 produces three PowerShell scripts — install, uninstall, and detection — rendered from
Jinja2 templates stored in imptune/templates/scripts/. The project already uses Jinja2 3.1.*
(it is in requirements.txt and drives all HTML pages), so no new dependency is required.
Script generation fits naturally as a new module imptune/generators/script_generator.py plus a
FastAPI router imptune/api/scripts.py, following the exact same patterns as intunewin_builder.py
and the existing API routers.
The most important correctness risk is a duplex mode name mismatch: the Printer model stores
OneSided | LongEdge | ShortEdge, but Set-PrintConfiguration -DuplexingMode accepts
OneSided | TwoSidedLongEdge | TwoSidedShortEdge. The templates must translate these values.
The second highest risk is the WOW64 / 32-bit Intune execution context: Intune's Win32 app
installer runs in a 32-bit PowerShell process. pnputil.exe does not exist under SysWOW64, so the
install script must detect the 32-bit environment and relaunch itself under 64-bit PowerShell before
any driver operations occur.
Primary recommendation: Render scripts from Jinja2 .ps1.j2 templates with
trim_blocks=True, lstrip_blocks=True. Place templates at
imptune/templates/scripts/{install,uninstall,detect}.ps1.j2. Return rendered content as
PlainTextResponse with Content-Disposition: attachment from a GET endpoint.
<phase_requirements>
Phase Requirements
| ID | Description | Research Support |
|---|---|---|
| SCRPT-01 | Generate PowerShell install script (pnputil staging + Add-PrinterPort + Add-PrinterDriver + Add-Printer + Set-PrintConfiguration) | Verified PowerShell cmdlets; pnputil two-step pattern documented below |
| SCRPT-02 | Generate PowerShell uninstall script (Remove-Printer + Remove-PrinterDriver + Remove-PrinterPort) | Standard cmdlets; idempotency via -ErrorAction SilentlyContinue |
| SCRPT-03 | Generate Intune detection script (printer-name registry check → exit 0 / exit 1) | Intune detection contract verified: Write-Output + exit 0 for present, exit 1 for absent |
| SCRPT-04 | Install script detects SYSTEM vs user context and self-elevates via UAC when run by user | Pattern verified: [Environment]::UserName check + Start-Process -Verb Runas |
| SCRPT-05 | Install script includes 64-bit WOW64 relaunch guard for Intune's 32-bit execution context | Pattern verified: $env:PROCESSOR_ARCHITECTURE + SysNative path |
| </phase_requirements> |
Standard Stack
Core (already installed — no new packages needed)
| Library | Version | Purpose | Why Standard |
|---|---|---|---|
| Jinja2 | 3.1.* | Template rendering engine | Already in requirements.txt; used for all HTML pages |
| FastAPI | 0.115.* | HTTP routing + response types | Already in requirements.txt; existing router pattern |
| Peewee | 3.17.* | ORM for reading Printer + Driver records | Already in requirements.txt |
No New Dependencies
Script generation requires zero new packages. Jinja2 is the template engine; FastAPI returns
PlainTextResponse with an attachment header; the Printer/Driver ORM models provide all data.
Installation
# Nothing to install — all dependencies already present in requirements.txt
Architecture Patterns
Recommended File Layout
imptune/
├── generators/
│ ├── __init__.py
│ ├── intunewin_builder.py # existing
│ └── script_generator.py # NEW — renders templates to strings
├── templates/
│ ├── scripts/ # NEW directory
│ │ ├── install.ps1.j2
│ │ ├── uninstall.ps1.j2
│ │ └── detect.ps1.j2
│ └── ... (existing HTML templates)
├── api/
│ ├── scripts.py # NEW — GET /printers/{id}/scripts/{type}
│ └── ... (existing routers)
tests/
└── test_script_generator.py # NEW
Pattern 1: Jinja2 Environment for Script Templates
Use a separate Environment with trim_blocks=True and lstrip_blocks=True to prevent Jinja
control-block lines from producing blank lines in rendered scripts.
# imptune/generators/script_generator.py
from pathlib import Path
from jinja2 import Environment, FileSystemLoader
_SCRIPTS_DIR = Path(__file__).parent.parent / "templates" / "scripts"
_env = Environment(
loader=FileSystemLoader(str(_SCRIPTS_DIR)),
trim_blocks=True,
lstrip_blocks=True,
keep_trailing_newline=True,
)
def render_install(printer, inf_filename: str, driver_name: str) -> str:
"""Render install.ps1.j2 with the given printer config."""
tpl = _env.get_template("install.ps1.j2")
return tpl.render(
printer_name=printer.name,
ip_address=printer.ip_address,
port_name=printer.port_name,
driver_name=driver_name,
inf_filename=inf_filename,
duplex_mode=_duplex_map[printer.duplex_mode], # translate enum
color=str(printer.color_mode).lower(), # "$true" / "$false"
paper_size=printer.paper_size,
collate=str(printer.collate).lower(),
)
_duplex_map = {
"OneSided": "OneSided",
"LongEdge": "TwoSidedLongEdge",
"ShortEdge": "TwoSidedShortEdge",
}
Pattern 2: FastAPI Script Download Endpoint
# imptune/api/scripts.py
from fastapi import APIRouter
from fastapi.responses import PlainTextResponse
from imptune.db.models import Printer, Driver
from imptune.generators.script_generator import render_install, render_uninstall, render_detect
import json
router = APIRouter(prefix="/printers")
@router.get("/{printer_id}/scripts/install", response_class=PlainTextResponse)
def download_install_script(printer_id: int) -> PlainTextResponse:
printer = Printer.get_or_none(Printer.id == printer_id)
if printer is None:
return PlainTextResponse("Not found", status_code=404)
driver = printer.driver
driver_names = json.loads(driver.driver_desc) if driver and driver.driver_desc else []
driver_name = driver_names[0] if driver_names else ""
inf_filename = driver.inf_filename if driver else ""
content = render_install(printer, inf_filename, driver_name)
return PlainTextResponse(
content=content,
headers={"Content-Disposition": 'attachment; filename="install.ps1"'},
)
Register in main.py alongside existing routers:
from imptune.api import scripts
app.include_router(scripts.router)
Pattern 3: Install Script Structure (Jinja2 Template Logic)
The .ps1.j2 template must implement the following blocks in order:
1. WOW64 guard (detect 32-bit, relaunch self in 64-bit, exit 32-bit process)
2. SYSTEM vs user context check (self-elevate via UAC if running as user)
3. pnputil step 1: /add-driver <inf> /install (uses $PSScriptRoot)
4. Idempotency check: Add-PrinterPort only if port does not exist
5. Add-PrinterDriver -Name <driver_name>
6. Idempotency check: Add-Printer only if printer does not exist
7. Set-PrintConfiguration for duplex, color, paper size, collate
Pattern 4: Idempotency Guards
All install operations must be idempotent to avoid Intune re-run failures:
# Port idempotency
if (-not (Get-PrinterPort -Name "{{ port_name }}" -ErrorAction SilentlyContinue)) {
Add-PrinterPort -Name "{{ port_name }}" -PrinterHostAddress "{{ ip_address }}"
}
# Printer idempotency
if (-not (Get-Printer -Name "{{ printer_name }}" -ErrorAction SilentlyContinue)) {
Add-Printer -Name "{{ printer_name }}" -PortName "{{ port_name }}" -DriverName "{{ driver_name }}"
}
Anti-Patterns to Avoid
- Hardcoding System32 paths:
C:\Windows\System32\pnputil.exefails from 32-bit context. Use"$env:WINDIR\SysNative\pnputil.exe"OR the WOW64 guard ensures the script already runs 64-bit by the time pnputil is called (thenpnputil.exeresolves correctly from PATH). - UAC elevation when already SYSTEM: SYSTEM account does not need UAC and
Start-Process -Verb Runasfails silently. The install script must detect the current identity and skip elevation when running as SYSTEM. - Skipping idempotency checks: Running
Add-Printertwice throws a non-terminating error that Intune logs as a warning. Wrap all add operations with existence checks. - Using bare jinja2.Environment without trim_blocks: Produces extra blank lines from
{% if %}blocks that make scripts harder to read and diff. - Returning scripts as
application/octet-stream: Usetext/plainso browsers open them without a save dialog, or useContent-Disposition: attachmentexplicitly.
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| Script whitespace | Manual string concatenation | Jinja2 with trim_blocks | Edge cases: trailing newlines, empty blocks, nested conditionals |
| Template file loading | Inline heredocs in Python | Jinja2 FileSystemLoader | Testable in isolation; editor syntax highlighting; version-controlled separately |
| Printer existence check | Registry query in Python | Get-Printer / Get-PrinterPort in the script itself |
The check must run on the endpoint, not on the server |
| Detection logic | Custom WMI query | Get-Printer + registry path pattern | Intune's detection contract is well-defined: exit 0 + STDOUT for present |
Key insight: Script logic (WOW64 guard, UAC self-elevation, idempotency) lives in the .ps1.j2
template file, not in Python. Python only provides data variables. This keeps scripts readable and
testable as real PowerShell without a Python interpreter on the endpoint.
Common Pitfalls
Pitfall 1: Duplex Mode Name Mismatch
What goes wrong: Set-PrintConfiguration -DuplexingMode LongEdge throws an error — the
accepted values are OneSided | TwoSidedLongEdge | TwoSidedShortEdge.
Why it happens: The Printer model was designed with short names (LongEdge, ShortEdge) for
UI simplicity. The PowerShell cmdlet uses the full names.
How to avoid: Use the _duplex_map dict in script_generator.py to translate before rendering.
Warning signs: Set-PrintConfiguration throws "Cannot bind parameter 'DuplexingMode'".
Pitfall 2: WOW64 — pnputil Not Found
What goes wrong: pnputil.exe /add-driver fails with "The system cannot find the file specified"
when Intune's 32-bit PowerShell runs the script.
Why it happens: Under WOW64, C:\Windows\System32 is redirected to SysWOW64. pnputil.exe
does not exist in SysWOW64.
How to avoid: Place the WOW64 relaunch guard as the VERY FIRST executable block in the install script (before any function definitions or logic):
# WOW64 Guard — must be first
if ($env:PROCESSOR_ARCHITECTURE -eq "x86" -and $env:PROCESSOR_ARCHITEW6432) {
$64bit = "$env:WINDIR\SysNative\WindowsPowerShell\v1.0\powershell.exe"
& $64bit -NoProfile -ExecutionPolicy Bypass -File $PSCommandPath @args
exit $LASTEXITCODE
}
Warning signs: Error in Intune management console about pnputil.exe not found; install fails
only on 64-bit machines through Intune but succeeds when run manually.
Pitfall 3: UAC Elevation When Running as SYSTEM
What goes wrong: Calling Start-Process powershell -Verb Runas when already running as the
SYSTEM account causes the elevation attempt to fail or prompt unexpectedly.
Why it happens: SYSTEM is already the highest privilege. -Verb Runas triggers UAC which does
not make sense for a service account.
How to avoid: Check the current user identity before attempting elevation:
$currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent()
$isSystem = $currentUser.IsSystem
$isAdmin = ([System.Security.Principal.WindowsPrincipal]$currentUser).IsInRole(
[System.Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $isAdmin -and -not $isSystem) {
# Re-launch with elevation
Start-Process powershell.exe -Verb Runas `
-ArgumentList "-NoProfile -ExecutionPolicy Bypass -File `"$PSCommandPath`"" `
-Wait
exit $LASTEXITCODE
}
# If SYSTEM or already admin, continue directly
Warning signs: UAC dialog appears when Intune runs the script; script hangs waiting for user input.
Pitfall 4: Detection Script STDOUT Requirement
What goes wrong: Detection script exits 0 but Intune still marks app as "Not installed".
Why it happens: Intune's detection contract requires both exit 0 AND a non-empty STDOUT string. Exit 0 alone is insufficient.
How to avoid:
$printer = Get-Printer -Name "{{ printer_name }}" -ErrorAction SilentlyContinue
if ($printer) {
Write-Output "Installed"
exit 0
} else {
exit 1
}
Warning signs: Script returns 0 in testing but Intune keeps re-installing.
Pitfall 5: $PSScriptRoot Empty in Intune Context
What goes wrong: $PSScriptRoot is empty when PowerShell executes a script via
-Command flag rather than -File flag.
Why it happens: $PSScriptRoot is only populated when the script is launched with -File.
How to avoid: Always configure Intune install command as:
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "install.ps1"
Not as -Command ".\install.ps1". Document the required Intune install command string in the
generated script header comment.
Pitfall 6: Set-PrintConfiguration Requires Printer Already Exist
What goes wrong: Set-PrintConfiguration throws if called before Add-Printer completes.
Why it happens: The Print Spooler service may need a moment to register the printer.
How to avoid: Call Set-PrintConfiguration immediately after Add-Printer in the same script
block. No sleep is required if the same PowerShell session registers the printer synchronously.
Code Examples
Verified patterns from official sources:
WOW64 Relaunch Guard (SCRPT-05)
# Source: community-verified pattern, consistent with call4cloud.nl + patchmypc.com research
# Must appear BEFORE any other logic in the script
if ($env:PROCESSOR_ARCHITECTURE -eq "x86" -and $env:PROCESSOR_ARCHITEW6432) {
$ps64 = "$env:WINDIR\SysNative\WindowsPowerShell\v1.0\powershell.exe"
& $ps64 -NoProfile -ExecutionPolicy Bypass -File "$PSCommandPath" @args
exit $LASTEXITCODE
}
SYSTEM vs User Context Detection + UAC Self-Elevation (SCRPT-04)
# Source: [System.Security.Principal.WindowsIdentity] — .NET BCL, available in all PS versions
$id = [System.Security.Principal.WindowsIdentity]::GetCurrent()
$isSystem = $id.IsSystem
$isAdmin = ([System.Security.Principal.WindowsPrincipal]$id).IsInRole(
[System.Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $isSystem -and -not $isAdmin) {
Start-Process powershell.exe `
-Verb Runas `
-ArgumentList "-NoProfile -ExecutionPolicy Bypass -File `"$PSCommandPath`"" `
-Wait
exit $LASTEXITCODE
}
pnputil Two-Step Driver Staging (SCRPT-01)
# Source: msendpointmgr.com + call4cloud.nl verified
# Step 1: Stage INF into Windows Driver Store
pnputil.exe /add-driver "$PSScriptRoot\drivers\{{ inf_filename }}" /install
# Step 2: Install named driver from Driver Store
Add-PrinterDriver -Name "{{ driver_name }}"
Note: By the time this runs, the WOW64 guard has already relaunched in 64-bit PowerShell, so
pnputil.exeresolves toSystem32\pnputil.exewithout needing an explicit path.
Port + Printer Creation with Idempotency
# Source: call4cloud.nl pattern, verified against Microsoft PrintManagement module docs
if (-not (Get-PrinterPort -Name "{{ port_name }}" -ErrorAction SilentlyContinue)) {
Add-PrinterPort -Name "{{ port_name }}" -PrinterHostAddress "{{ ip_address }}"
}
if (-not (Get-Printer -Name "{{ printer_name }}" -ErrorAction SilentlyContinue)) {
Add-Printer -Name "{{ printer_name }}" `
-PortName "{{ port_name }}" `
-DriverName "{{ driver_name }}"
}
Set-PrintConfiguration (SCRPT-01)
# Source: Microsoft Learn — Set-PrintConfiguration (windowsserver2025-ps)
# DuplexingMode accepted values: OneSided, TwoSidedLongEdge, TwoSidedShortEdge
# PaperSize accepted values include: A4, Letter, Legal (and many others)
# Color: Boolean ($true / $false)
# Collate: Boolean ($true / $false)
Set-PrintConfiguration -PrinterName "{{ printer_name }}" `
-DuplexingMode {{ duplex_mode }} `
-Color ${{ color }} `
-PaperSize {{ paper_size }} `
-Collate ${{ collate }}
Uninstall Script (SCRPT-02)
# Source: call4cloud.nl verified; -ErrorAction SilentlyContinue for idempotency
Remove-Printer -Name "{{ printer_name }}" -ErrorAction SilentlyContinue
Remove-PrinterDriver -Name "{{ driver_name }}" -ErrorAction SilentlyContinue
Remove-PrinterPort -Name "{{ port_name }}" -ErrorAction SilentlyContinue
Note on driver removal order: Remove-Printer BEFORE Remove-PrinterDriver. Removing the driver while a printer still references it produces an error.
Detection Script (SCRPT-03)
# Source: Intune detection script contract (powershellisfun.com + andrewstaylor.com verified)
# Intune requires: exit 0 + non-empty STDOUT = installed; any other exit = not installed
$printer = Get-Printer -Name "{{ printer_name }}" -ErrorAction SilentlyContinue
if ($printer) {
Write-Output "Installed: {{ printer_name }}"
exit 0
} else {
exit 1
}
Jinja2 Environment Setup for Script Templates
# Source: Jinja2 3.1.x official docs — trim_blocks + lstrip_blocks for non-HTML rendering
from jinja2 import Environment, FileSystemLoader
from pathlib import Path
_env = Environment(
loader=FileSystemLoader(str(Path(__file__).parent.parent / "templates" / "scripts")),
trim_blocks=True, # removes newline after block tags ({% %})
lstrip_blocks=True, # strips leading spaces/tabs before block tags
keep_trailing_newline=True, # preserves final newline (important for scripts)
)
State of the Art
| Old Approach | Current Approach | Notes |
|---|---|---|
| IntuneWinAppUtil.exe for packaging | Python-native (Phase 1 decision) | Locked decision |
| Hardcoded scripts per printer | Jinja2 template rendering | Enables regeneration (PRNT-10) |
| printui.exe for settings export | Set-PrintConfiguration cmdlet | Cmdlet is the current standard |
| HKLM...\Print\Printers registry check | Get-Printer cmdlet check | Both work; Get-Printer is more reliable |
Deprecated/outdated:
wmic printerqueries: deprecated in Windows 11, useGet-PrinterSet-WmiInstance Win32_PrinterConfiguration: superseded bySet-PrintConfiguration
Open Questions
-
Multiple driver names per INF
- What we know: The model stores
driver_descas a JSON list of names (e.g.,["HP Universal", "HP Universal PCL6"]) - What's unclear: Which name should be used in the script when multiple are present?
- Recommendation: Use the first name from the list (index 0). The driver selection UI (Phase 2, DRV-03) enforced a single selection — that selected name should be stored separately, or the template receives
driver_nameas the first list element. The planner should decide whether to store the selected driver name on the Printer record or derive it at render time.
- What we know: The model stores
-
driver_name field on Printer model
- What we know: The
Printermodel has adriverFK toDriver, but nodriver_namefield storing the specific selected name. - What's unclear: Phase 2 let users pick a driver name from a dropdown, but this selection is not persisted on the Printer record.
- Recommendation: Either (a) add a
selected_driver_nameCharField to the Printer model in this phase, or (b) derive it asjson.loads(printer.driver.driver_desc)[0]at render time. Option (b) avoids a schema change and is simpler for v1.
- What we know: The
-
inf_filename on Driver record
- What we know:
Driver.inf_filenameis nullable. If null, pnputil staging cannot proceed. - Recommendation: The generate endpoint should return a 400/422 with a clear message if
inf_filenameis null or driver is unassigned.
- What we know:
Validation Architecture
Test Framework
| Property | Value |
|---|---|
| Framework | pytest >= 8.0 |
| Config file | none — discovered automatically |
| Quick run command | python -m pytest tests/test_script_generator.py -x -q |
| Full suite command | python -m pytest tests/ -x -q |
Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|---|---|---|---|---|
| SCRPT-01 | render_install() produces script containing pnputil, Add-PrinterPort, Add-PrinterDriver, Add-Printer, Set-PrintConfiguration | unit | python -m pytest tests/test_script_generator.py::test_render_install_contains_pnputil -x |
❌ Wave 0 |
| SCRPT-01 | Set-PrintConfiguration receives correct duplex/color/paper/collate values | unit | python -m pytest tests/test_script_generator.py::test_render_install_print_config -x |
❌ Wave 0 |
| SCRPT-02 | render_uninstall() produces script with Remove-Printer, Remove-PrinterDriver, Remove-PrinterPort | unit | python -m pytest tests/test_script_generator.py::test_render_uninstall -x |
❌ Wave 0 |
| SCRPT-03 | render_detect() exits 0 with Write-Output when printer present; exits 1 when absent | unit | python -m pytest tests/test_script_generator.py::test_render_detect -x |
❌ Wave 0 |
| SCRPT-04 | Install script contains IsSystem + IsInRole check + Start-Process Runas | unit | python -m pytest tests/test_script_generator.py::test_render_install_uac_guard -x |
❌ Wave 0 |
| SCRPT-05 | Install script contains PROCESSOR_ARCHITECTURE check + SysNative relaunch | unit | python -m pytest tests/test_script_generator.py::test_render_install_wow64_guard -x |
❌ Wave 0 |
| SCRPT-01 | GET /printers/{id}/scripts/install returns 200 PlainTextResponse with .ps1 content | integration | python -m pytest tests/test_script_generator.py::test_install_endpoint -x |
❌ Wave 0 |
| SCRPT-02 | GET /printers/{id}/scripts/uninstall returns 200 | integration | python -m pytest tests/test_script_generator.py::test_uninstall_endpoint -x |
❌ Wave 0 |
| SCRPT-03 | GET /printers/{id}/scripts/detect returns 200 | integration | python -m pytest tests/test_script_generator.py::test_detect_endpoint -x |
❌ Wave 0 |
Sampling Rate
- Per task commit:
python -m pytest tests/test_script_generator.py -x -q - Per wave merge:
python -m pytest tests/ -x -q - Phase gate: Full suite green before
/gsd:verify-work
Wave 0 Gaps
tests/test_script_generator.py— all SCRPT-01 through SCRPT-05 unit + integration testsimptune/templates/scripts/install.ps1.j2— template fileimptune/templates/scripts/uninstall.ps1.j2— template fileimptune/templates/scripts/detect.ps1.j2— template fileimptune/generators/script_generator.py— render functionsimptune/api/scripts.py— FastAPI router
Sources
Primary (HIGH confidence)
- Microsoft Learn —
Set-PrintConfiguration(windowsserver2025-ps, updated 2025-05-14): https://learn.microsoft.com/en-us/powershell/module/printmanagement/set-printconfiguration?view=windowsserver2025-ps — confirmed parameter names and accepted enum values for DuplexingMode, PaperSize, Color, Collate - Jinja2 3.1.x official docs — Environment trim_blocks/lstrip_blocks: https://jinja.palletsprojects.com/en/stable/templates/
- FastAPI docs — PlainTextResponse / Custom Response: https://fastapi.tiangolo.com/advanced/custom-response/
- Project codebase —
imptune/db/models.py,imptune/generators/intunewin_builder.py,imptune/api/printers.py,requirements.txt— all read directly
Secondary (MEDIUM confidence)
- msendpointmgr.com — pnputil two-step staging + Add-PrinterPort/Add-PrinterDriver/Add-Printer sequence: https://msendpointmgr.com/2022/01/03/install-network-printers-intune-win32apps-powershell/
- call4cloud.nl — pnputil SysNative path, idempotency patterns, detection registry path: https://call4cloud.nl/deploy-printer-drivers-intune-win32app/
- powershellisfun.com — Intune detection script contract (exit 0 + STDOUT): https://powershellisfun.com/2023/11/30/microsoft-intune-powershell-detection-scripts/
- andrewstaylor.com — detection script demystified: https://andrewstaylor.com/2022/04/19/demystifying-intune-custom-app-detection-scripts/
Tertiary (LOW confidence)
- WOW64 relaunch guard gist (community pattern, not official MS docs): https://gist.github.com/talatham/ad406d5428ccec641f075a7019cd29a8 — Cross-verified with patchmypc.com and call4cloud.nl articles describing the same pattern.
Metadata
Confidence breakdown:
- Standard stack: HIGH — zero new dependencies; all libraries already in requirements.txt
- Architecture patterns: HIGH — follows existing project conventions (generators/ + api/ + templates/)
- PowerShell cmdlet parameters: HIGH — verified against Microsoft Learn official docs
- WOW64 guard pattern: MEDIUM — community-verified, consistent across multiple sources, not in official MS docs
- UAC self-elevation pattern: MEDIUM — community-verified, stable pattern since PS 3.0
- Pitfalls: HIGH — duplex mismatch verified against official docs; others verified against multiple community sources
Research date: 2026-04-10 Valid until: 2026-07-10 (stable domain; PowerShell PrintManagement module rarely changes)