From c7361bef1842eb8a6286aaf5d1721562593c57c2 Mon Sep 17 00:00:00 2001 From: Kawa Date: Fri, 10 Apr 2026 13:27:14 +0200 Subject: [PATCH] docs(04): create phase plan for script generation Co-Authored-By: Claude Opus 4.6 (1M context) --- .planning/ROADMAP.md | 9 +- .../phases/04-script-generation/04-01-PLAN.md | 191 ++++++++++++++ .../phases/04-script-generation/04-02-PLAN.md | 247 ++++++++++++++++++ 3 files changed, 442 insertions(+), 5 deletions(-) create mode 100644 .planning/phases/04-script-generation/04-01-PLAN.md create mode 100644 .planning/phases/04-script-generation/04-02-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 4fb152e..2f70949 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -76,12 +76,11 @@ Plans: 3. Generated install script relaunches in 64-bit PowerShell when Intune's 32-bit process triggers it (WOW64 guard) 4. Generated uninstall script removes printer, driver, and port cleanly 5. Generated detection script returns exit 0 when the printer is installed and exit 1 when it is not -**Plans**: TBD +**Plans**: 2 plans Plans: -- [ ] 04-01: Jinja2 PS install script template (pnputil two-step, SYSTEM vs user detection, WOW64 guard, idempotency) -- [ ] 04-02: Uninstall and detection script templates -- [ ] 04-03: Script generation API endpoint (accepts printer config, returns script content) +- [ ] 04-01-PLAN.md — Install script generator with TDD (Jinja2 template, WOW64 guard, UAC elevation, pnputil two-step, duplex mapping, idempotency) +- [ ] 04-02-PLAN.md — Uninstall + detection templates, script download API endpoints, router registration ### Phase 5: Package Export **Goal**: Technicians can download a complete, ready-to-deploy package for either Intune or NinjaRMM in one click @@ -109,5 +108,5 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 | 1. Foundation | 3/3 | Complete | 2026-04-10 | | 2. Driver Management | 1/2 | In Progress| | | 3. Printer Configuration | 2/2 | Complete | 2026-04-10 | -| 4. Script Generation | 0/3 | Not started | - | +| 4. Script Generation | 0/2 | Not started | - | | 5. Package Export | 0/3 | Not started | - | diff --git a/.planning/phases/04-script-generation/04-01-PLAN.md b/.planning/phases/04-script-generation/04-01-PLAN.md new file mode 100644 index 0000000..9145562 --- /dev/null +++ b/.planning/phases/04-script-generation/04-01-PLAN.md @@ -0,0 +1,191 @@ +--- +phase: 04-script-generation +plan: 01 +type: tdd +wave: 1 +depends_on: [] +files_modified: + - imptune/generators/script_generator.py + - imptune/templates/scripts/install.ps1.j2 + - tests/test_script_generator.py +autonomous: true +requirements: + - SCRPT-01 + - SCRPT-04 + - SCRPT-05 + +must_haves: + truths: + - "render_install() produces a complete PowerShell script containing pnputil /add-driver, Add-PrinterPort, Add-PrinterDriver, Add-Printer, Set-PrintConfiguration" + - "Generated install script contains WOW64 relaunch guard as the first executable block" + - "Generated install script contains SYSTEM vs user detection with UAC self-elevation" + - "Set-PrintConfiguration receives translated duplex values (TwoSidedLongEdge, TwoSidedShortEdge)" + - "All add operations are wrapped in idempotency checks (Get-PrinterPort, Get-Printer)" + artifacts: + - path: "imptune/generators/script_generator.py" + provides: "Jinja2 Environment + render_install function with duplex_map" + exports: ["render_install"] + - path: "imptune/templates/scripts/install.ps1.j2" + provides: "PowerShell install template with WOW64, UAC, pnputil, idempotency" + min_lines: 30 + - path: "tests/test_script_generator.py" + provides: "Unit tests for SCRPT-01, SCRPT-04, SCRPT-05" + min_lines: 40 + key_links: + - from: "imptune/generators/script_generator.py" + to: "imptune/templates/scripts/install.ps1.j2" + via: "Jinja2 FileSystemLoader" + pattern: "_env\\.get_template.*install" + - from: "imptune/generators/script_generator.py" + to: "imptune/db/models.py" + via: "Printer model fields used as template vars" + pattern: "printer\\.name|printer\\.ip_address|printer\\.port_name" +--- + + +Create the script generator module and install.ps1 Jinja2 template with full correctness guards. + +Purpose: The install script is the most complex of the three scripts (WOW64, UAC, pnputil two-step, idempotency, duplex mapping). Building it first with TDD ensures all edge cases are covered before the simpler templates. + +Output: `script_generator.py` with `render_install()`, `install.ps1.j2` template, and passing unit tests. + + + +@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md +@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/04-script-generation/04-RESEARCH.md + + + + +From imptune/db/models.py: +```python +class Printer(BaseModel): + name = CharField() + ip_address = CharField() + port_name = CharField() + client = ForeignKeyField(Client, null=True, backref="printers") + driver = ForeignKeyField(Driver, null=True, backref="printers") + duplex_mode = CharField(default="OneSided") # "OneSided" | "LongEdge" | "ShortEdge" + color_mode = BooleanField(default=True) + paper_size = CharField(default="A4") # "A4" | "Letter" | "Legal" + collate = BooleanField(default=True) + +class Driver(BaseModel): + sha256 = CharField(unique=True, index=True) + original_filename = CharField() + driver_desc = CharField(null=True) # JSON list: '["HP Universal Printing PCL 6"]' + inf_filename = CharField(null=True) # e.g. "hpcu270u.inf" +``` + +From imptune/generators/intunewin_builder.py (pattern reference): +```python +# Existing generator module pattern — script_generator.py follows same structure +# Module-level setup, public render functions +``` + + + + + + + Install script generator with TDD + imptune/generators/script_generator.py, imptune/templates/scripts/install.ps1.j2, tests/test_script_generator.py + + - render_install(printer_name, ip_address, port_name, driver_name, inf_filename, duplex_mode, color_mode, paper_size, collate) returns a string containing valid PowerShell + - Output contains WOW64 guard: `$env:PROCESSOR_ARCHITECTURE -eq "x86"` and `SysNative` relaunch as the FIRST executable block + - Output contains SYSTEM/admin detection: `WindowsIdentity::GetCurrent()`, `IsSystem`, `IsInRole(Administrator)`, `Start-Process -Verb Runas` + - Output contains pnputil two-step: `pnputil.exe /add-driver "$PSScriptRoot\drivers\{inf_filename}" /install` then `Add-PrinterDriver -Name "{driver_name}"` + - Output contains idempotent port creation: `Get-PrinterPort` check before `Add-PrinterPort` + - Output contains idempotent printer creation: `Get-Printer` check before `Add-Printer` + - Output contains `Set-PrintConfiguration` with translated duplex: "LongEdge" -> "TwoSidedLongEdge", "ShortEdge" -> "TwoSidedShortEdge", "OneSided" -> "OneSided" + - Output contains correct boolean rendering: color_mode=True -> `$true`, color_mode=False -> `$false` + - Output contains paper size and collate values + - Script uses proper quoting for printer name, port name, driver name (double-quoted in PS) + + + **RED phase — write tests first in tests/test_script_generator.py:** + + 1. Create `imptune/templates/scripts/` directory (empty, needed for Jinja2 loader) + 2. Write tests that import `render_install` from `imptune.generators.script_generator` and assert on rendered output: + - `test_render_install_contains_pnputil`: assert `pnputil.exe /add-driver` and `Add-PrinterDriver` in output + - `test_render_install_print_config`: assert `Set-PrintConfiguration` with `-DuplexingMode TwoSidedLongEdge` when duplex_mode="LongEdge" + - `test_render_install_wow64_guard`: assert `PROCESSOR_ARCHITECTURE` and `SysNative` in output + - `test_render_install_uac_guard`: assert `IsSystem` and `Start-Process` and `-Verb Runas` in output + - `test_render_install_idempotency`: assert `Get-PrinterPort` and `Get-Printer` checks before add operations + - `test_render_install_booleans`: assert `$true` / `$false` for color and collate + 3. Run tests — all MUST fail (RED) + + **GREEN phase — implement:** + + 4. Create `imptune/generators/script_generator.py`: + - Module-level Jinja2 Environment with `FileSystemLoader` pointing to `imptune/templates/scripts/` + - `trim_blocks=True`, `lstrip_blocks=True`, `keep_trailing_newline=True` + - `_duplex_map` dict: `{"OneSided": "OneSided", "LongEdge": "TwoSidedLongEdge", "ShortEdge": "TwoSidedShortEdge"}` + - `render_install(printer_name, ip_address, port_name, driver_name, inf_filename, duplex_mode, color_mode, paper_size, collate) -> str` + - Translates duplex_mode via `_duplex_map` + - Converts color_mode/collate bools to `"true"` / `"false"` (lowercase, template adds `$` prefix) + - Calls `_env.get_template("install.ps1.j2").render(...)` with all variables + - Use plain string parameters (not ORM objects) so the function is testable without DB + + 5. Create `imptune/templates/scripts/install.ps1.j2`: + Template structure (in this exact order): + ``` + # Header comment: Generated by ImpTune, printer name, install command hint + # WOW64 Guard (FIRST executable block) + if ($env:PROCESSOR_ARCHITECTURE -eq "x86" -and $env:PROCESSOR_ARCHITEW6432) { ... relaunch 64-bit ... exit } + # SYSTEM/Admin check + UAC elevation + $id = [System.Security.Principal.WindowsIdentity]::GetCurrent() + $isSystem = $id.IsSystem + $isAdmin = ... IsInRole(Administrator) + if (-not $isSystem -and -not $isAdmin) { Start-Process -Verb Runas ... exit } + # pnputil driver staging + pnputil.exe /add-driver "$PSScriptRoot\drivers\{{ inf_filename }}" /install + # Add-PrinterDriver + Add-PrinterDriver -Name "{{ driver_name }}" + # Idempotent port creation + if (-not (Get-PrinterPort -Name "{{ port_name }}" -ErrorAction SilentlyContinue)) { Add-PrinterPort ... } + # Idempotent printer creation + if (-not (Get-Printer -Name "{{ printer_name }}" -ErrorAction SilentlyContinue)) { Add-Printer ... } + # Set-PrintConfiguration + Set-PrintConfiguration -PrinterName "{{ printer_name }}" -DuplexingMode {{ duplex_mode }} -Color ${{ color }} -PaperSize {{ paper_size }} -Collate ${{ collate }} + ``` + + 6. Run tests — all MUST pass (GREEN) + + **Important notes:** + - Use plain string args for render_install, NOT Printer ORM object — keeps tests DB-free + - The `_duplex_map` must translate BEFORE passing to template (template receives already-mapped value) + - Boolean values: pass as lowercase string `"true"` / `"false"` so template renders `$true` / `$false` with `${{ color }}` + - Template must use `{{ }}` for all variable interpolation — no `{% set %}` for simple values + - All PowerShell string parameters (printer_name, port_name, driver_name) must be double-quoted in the template + + + + + + +```bash +python -m pytest tests/test_script_generator.py -x -q +``` +All 6+ tests pass. Rendered install script contains all required blocks in correct order. + + + +- render_install() produces complete PowerShell install script +- WOW64 guard appears before any other logic +- UAC self-elevation skips when SYSTEM +- Duplex mode values correctly translated (LongEdge -> TwoSidedLongEdge) +- All add operations wrapped in idempotency checks +- All unit tests pass + + + +After completion, create `.planning/phases/04-script-generation/04-01-SUMMARY.md` + diff --git a/.planning/phases/04-script-generation/04-02-PLAN.md b/.planning/phases/04-script-generation/04-02-PLAN.md new file mode 100644 index 0000000..5356237 --- /dev/null +++ b/.planning/phases/04-script-generation/04-02-PLAN.md @@ -0,0 +1,247 @@ +--- +phase: 04-script-generation +plan: 02 +type: execute +wave: 2 +depends_on: ["04-01"] +files_modified: + - imptune/generators/script_generator.py + - imptune/templates/scripts/uninstall.ps1.j2 + - imptune/templates/scripts/detect.ps1.j2 + - imptune/api/scripts.py + - imptune/main.py + - tests/test_script_generator.py +autonomous: true +requirements: + - SCRPT-02 + - SCRPT-03 + +must_haves: + truths: + - "render_uninstall() produces script with Remove-Printer, Remove-PrinterDriver, Remove-PrinterPort in correct order" + - "render_detect() produces script that exits 0 with Write-Output when printer found, exits 1 when absent" + - "GET /printers/{id}/scripts/install returns 200 with PowerShell content and attachment header" + - "GET /printers/{id}/scripts/uninstall returns 200 with PowerShell content" + - "GET /printers/{id}/scripts/detect returns 200 with PowerShell content" + - "GET /printers/{id}/scripts/{type} returns 404 for nonexistent printer" + - "GET /printers/{id}/scripts/{type} returns 422 when driver or inf_filename is missing" + artifacts: + - path: "imptune/templates/scripts/uninstall.ps1.j2" + provides: "PowerShell uninstall template" + contains: "Remove-Printer" + - path: "imptune/templates/scripts/detect.ps1.j2" + provides: "PowerShell detection template" + contains: "Write-Output" + - path: "imptune/api/scripts.py" + provides: "Script download endpoints" + exports: ["router"] + - path: "imptune/generators/script_generator.py" + provides: "render_uninstall and render_detect functions added" + exports: ["render_install", "render_uninstall", "render_detect"] + key_links: + - from: "imptune/api/scripts.py" + to: "imptune/generators/script_generator.py" + via: "import render_install, render_uninstall, render_detect" + pattern: "from imptune\\.generators\\.script_generator import" + - from: "imptune/api/scripts.py" + to: "imptune/db/models.py" + via: "Printer.get_or_none query with Driver join" + pattern: "Printer\\.get_or_none" + - from: "imptune/main.py" + to: "imptune/api/scripts.py" + via: "app.include_router(scripts.router)" + pattern: "include_router.*scripts" +--- + + +Add uninstall and detection templates, then wire all three scripts to downloadable API endpoints. + +Purpose: Completes the script generation phase by adding the two simpler templates and exposing all scripts via GET endpoints that the printer detail page (Phase 3) can link to. + +Output: `uninstall.ps1.j2`, `detect.ps1.j2`, `scripts.py` router, updated `main.py`, passing integration tests. + + + +@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md +@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/04-script-generation/04-RESEARCH.md +@.planning/phases/04-script-generation/04-01-SUMMARY.md + + + + +From imptune/generators/script_generator.py (created in 04-01): +```python +# Jinja2 Environment already configured with FileSystemLoader for templates/scripts/ +# _duplex_map already defined +def render_install(printer_name, ip_address, port_name, driver_name, inf_filename, + duplex_mode, color_mode, paper_size, collate) -> str: ... +# Plan 02 adds: render_uninstall(), render_detect() +``` + +From imptune/db/models.py: +```python +class Printer(BaseModel): + name = CharField() + ip_address = CharField() + port_name = CharField() + driver = ForeignKeyField(Driver, null=True, backref="printers") + duplex_mode = CharField(default="OneSided") + color_mode = BooleanField(default=True) + paper_size = CharField(default="A4") + collate = BooleanField(default=True) + +class Driver(BaseModel): + driver_desc = CharField(null=True) # JSON list + inf_filename = CharField(null=True) +``` + +From imptune/main.py (router registration pattern): +```python +from imptune.api import clients, drivers, health, pages, printers +app.include_router(health.router) +app.include_router(pages.router) +app.include_router(drivers.router) +app.include_router(printers.router) +app.include_router(clients.router) +``` + +From tests/conftest.py: +```python +@pytest.fixture +def client(tmp_data_dir): + from imptune.main import app + with TestClient(app) as c: + yield c + +@pytest.fixture +def tmp_data_dir(tmp_path, monkeypatch): ... +``` + + + + + + + Task 1: Uninstall and detection templates + render functions + imptune/generators/script_generator.py, imptune/templates/scripts/uninstall.ps1.j2, imptune/templates/scripts/detect.ps1.j2, tests/test_script_generator.py + + - render_uninstall(printer_name, driver_name, port_name) returns PS script with Remove-Printer BEFORE Remove-PrinterDriver BEFORE Remove-PrinterPort, all with -ErrorAction SilentlyContinue + - render_detect(printer_name) returns PS script with Get-Printer check, Write-Output + exit 0 when found, exit 1 when absent + + + **Tests first (add to existing test_script_generator.py):** + + - `test_render_uninstall`: call render_uninstall("Test Printer", "HP Driver", "IP_10.0.0.1"), assert output contains `Remove-Printer -Name "Test Printer"`, `Remove-PrinterDriver -Name "HP Driver"`, `Remove-PrinterPort -Name "IP_10.0.0.1"`, and `-ErrorAction SilentlyContinue` on all three. Assert Remove-Printer appears BEFORE Remove-PrinterDriver (order matters — driver removal fails if printer still references it). + - `test_render_detect`: call render_detect("Test Printer"), assert output contains `Get-Printer -Name "Test Printer"`, `Write-Output`, `exit 0`, `exit 1`. + + Run tests — both MUST fail. + + **Implement:** + + Add `render_uninstall(printer_name, driver_name, port_name) -> str` to `script_generator.py`: + - Gets `uninstall.ps1.j2` template, renders with the three names. + + Add `render_detect(printer_name) -> str` to `script_generator.py`: + - Gets `detect.ps1.j2` template, renders with printer_name. + + Create `imptune/templates/scripts/uninstall.ps1.j2`: + ``` + # Header: Generated by ImpTune — Uninstall script for {{ printer_name }} + Remove-Printer -Name "{{ printer_name }}" -ErrorAction SilentlyContinue + Remove-PrinterDriver -Name "{{ driver_name }}" -ErrorAction SilentlyContinue + Remove-PrinterPort -Name "{{ port_name }}" -ErrorAction SilentlyContinue + ``` + + Create `imptune/templates/scripts/detect.ps1.j2`: + ``` + # Header: Generated by ImpTune — Detection script for {{ printer_name }} + $printer = Get-Printer -Name "{{ printer_name }}" -ErrorAction SilentlyContinue + if ($printer) { + Write-Output "Installed: {{ printer_name }}" + exit 0 + } else { + exit 1 + } + ``` + + Run tests — both MUST pass. + + + python -m pytest tests/test_script_generator.py::test_render_uninstall tests/test_script_generator.py::test_render_detect -x -q + + render_uninstall and render_detect produce correct PowerShell scripts; removal order is correct; detection uses Write-Output + exit codes per Intune contract. + + + + Task 2: Script download API endpoints and router registration + imptune/api/scripts.py, imptune/main.py, tests/test_script_generator.py + + Create `imptune/api/scripts.py`: + - `router = APIRouter(prefix="/printers")` + - Three GET endpoints: `/{printer_id}/scripts/install`, `/{printer_id}/scripts/uninstall`, `/{printer_id}/scripts/detect` + - Each endpoint: + 1. `Printer.get_or_none(Printer.id == printer_id)` — return `PlainTextResponse("Printer not found", status_code=404)` if None + 2. Access `printer.driver` — return `PlainTextResponse("No driver assigned", status_code=422)` if driver is None + 3. Check `driver.inf_filename` — return `PlainTextResponse("Driver has no INF file", status_code=422)` if None/empty + 4. Parse `driver_name = json.loads(driver.driver_desc)[0]` — return 422 if driver_desc is empty/null + 5. Call the appropriate render function with plain values extracted from ORM objects + 6. Return `PlainTextResponse(content=rendered, headers={"Content-Disposition": 'attachment; filename="{type}.ps1"'})` + - For install endpoint: extract all printer fields + driver fields, call `render_install(printer.name, printer.ip_address, printer.port_name, driver_name, driver.inf_filename, printer.duplex_mode, printer.color_mode, printer.paper_size, printer.collate)` + - For uninstall: call `render_uninstall(printer.name, driver_name, printer.port_name)` + - For detect: call `render_detect(printer.name)` + + Update `imptune/main.py`: + - Add `scripts` to import: `from imptune.api import clients, drivers, health, pages, printers, scripts` + - Add `app.include_router(scripts.router)` after existing router registrations + + Add integration tests to `tests/test_script_generator.py`: + - `test_install_endpoint`: create Driver + Printer via ORM in test, GET `/printers/{id}/scripts/install`, assert 200 + content contains `pnputil` + - `test_uninstall_endpoint`: same setup, GET `/printers/{id}/scripts/uninstall`, assert 200 + `Remove-Printer` + - `test_detect_endpoint`: same setup, GET `/printers/{id}/scripts/detect`, assert 200 + `Write-Output` + - `test_script_endpoint_missing_printer`: GET `/printers/9999/scripts/install`, assert 404 + - `test_script_endpoint_no_driver`: create Printer without driver FK, GET install, assert 422 + + For integration tests, use the `client` fixture from conftest.py. Create test data via ORM: + ```python + from imptune.db.models import Driver, Printer + driver = Driver.create(sha256="abc123", original_filename="test.zip", size_bytes=100, + driver_desc='["Test Driver"]', inf_filename="test.inf") + printer = Printer.create(name="Test Printer", ip_address="10.0.0.1", port_name="IP_10.0.0.1", + driver=driver, duplex_mode="LongEdge", color_mode=True, + paper_size="A4", collate=True) + ``` + + + python -m pytest tests/test_script_generator.py -x -q + + All script endpoints return 200 with correct PS content; 404 for missing printer; 422 for missing driver/INF; router registered in main.py; full test suite passes. + + + + + +```bash +python -m pytest tests/ -x -q +``` +Full test suite passes (existing + new script tests). No regressions. + + + +- render_uninstall produces script with correct removal order +- render_detect follows Intune detection contract (Write-Output + exit 0/1) +- All three script types downloadable via GET /printers/{id}/scripts/{type} +- Error handling: 404 for missing printer, 422 for missing driver/INF +- Scripts router registered in main.py +- Full test suite green + + + +After completion, create `.planning/phases/04-script-generation/04-02-SUMMARY.md` +