Files
ImpTune/.planning/phases/04-script-generation/04-RESEARCH.md
T
2026-04-10 13:21:03 +02:00

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

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.exe fails 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 (then pnputil.exe resolves correctly from PATH).
  • UAC elevation when already SYSTEM: SYSTEM account does not need UAC and Start-Process -Verb Runas fails silently. The install script must detect the current identity and skip elevation when running as SYSTEM.
  • Skipping idempotency checks: Running Add-Printer twice 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: Use text/plain so browsers open them without a save dialog, or use Content-Disposition: attachment explicitly.

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.exe resolves to System32\pnputil.exe without 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 printer queries: deprecated in Windows 11, use Get-Printer
  • Set-WmiInstance Win32_PrinterConfiguration: superseded by Set-PrintConfiguration

Open Questions

  1. Multiple driver names per INF

    • What we know: The model stores driver_desc as 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_name as 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.
  2. driver_name field on Printer model

    • What we know: The Printer model has a driver FK to Driver, but no driver_name field 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_name CharField to the Printer model in this phase, or (b) derive it as json.loads(printer.driver.driver_desc)[0] at render time. Option (b) avoids a schema change and is simpler for v1.
  3. inf_filename on Driver record

    • What we know: Driver.inf_filename is nullable. If null, pnputil staging cannot proceed.
    • Recommendation: The generate endpoint should return a 400/422 with a clear message if inf_filename is null or driver is unassigned.

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 tests
  • imptune/templates/scripts/install.ps1.j2 — template file
  • imptune/templates/scripts/uninstall.ps1.j2 — template file
  • imptune/templates/scripts/detect.ps1.j2 — template file
  • imptune/generators/script_generator.py — render functions
  • imptune/api/scripts.py — FastAPI router

Sources

Primary (HIGH confidence)

Secondary (MEDIUM confidence)

Tertiary (LOW confidence)


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)