docs(04): create phase plan for script generation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 13:27:14 +02:00
co-authored by Claude Opus 4.6
parent a57ecb94d4
commit c7361bef18
3 changed files with 442 additions and 5 deletions
+4 -5
View File
@@ -76,12 +76,11 @@ Plans:
3. Generated install script relaunches in 64-bit PowerShell when Intune's 32-bit process triggers it (WOW64 guard) 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 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 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: Plans:
- [ ] 04-01: Jinja2 PS install script template (pnputil two-step, SYSTEM vs user detection, WOW64 guard, idempotency) - [ ] 04-01-PLAN.md — Install script generator with TDD (Jinja2 template, WOW64 guard, UAC elevation, pnputil two-step, duplex mapping, idempotency)
- [ ] 04-02: Uninstall and detection script templates - [ ] 04-02-PLAN.md — Uninstall + detection templates, script download API endpoints, router registration
- [ ] 04-03: Script generation API endpoint (accepts printer config, returns script content)
### Phase 5: Package Export ### Phase 5: Package Export
**Goal**: Technicians can download a complete, ready-to-deploy package for either Intune or NinjaRMM in one click **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 | | 1. Foundation | 3/3 | Complete | 2026-04-10 |
| 2. Driver Management | 1/2 | In Progress| | | 2. Driver Management | 1/2 | In Progress| |
| 3. Printer Configuration | 2/2 | Complete | 2026-04-10 | | 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 | - | | 5. Package Export | 0/3 | Not started | - |
@@ -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"
---
<objective>
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.
</objective>
<execution_context>
@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md
@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/04-script-generation/04-RESEARCH.md
<interfaces>
<!-- Key types and contracts from existing codebase -->
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
```
</interfaces>
</context>
<tasks>
<feature>
<name>Install script generator with TDD</name>
<files>imptune/generators/script_generator.py, imptune/templates/scripts/install.ps1.j2, tests/test_script_generator.py</files>
<behavior>
- 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)
</behavior>
<implementation>
**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
</implementation>
</feature>
</tasks>
<verification>
```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.
</verification>
<success_criteria>
- 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
</success_criteria>
<output>
After completion, create `.planning/phases/04-script-generation/04-01-SUMMARY.md`
</output>
@@ -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"
---
<objective>
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.
</objective>
<execution_context>
@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md
@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
<interfaces>
<!-- Contracts from Plan 01 that this plan builds on -->
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): ...
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Uninstall and detection templates + render functions</name>
<files>imptune/generators/script_generator.py, imptune/templates/scripts/uninstall.ps1.j2, imptune/templates/scripts/detect.ps1.j2, tests/test_script_generator.py</files>
<behavior>
- 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
</behavior>
<action>
**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.
</action>
<verify>
<automated>python -m pytest tests/test_script_generator.py::test_render_uninstall tests/test_script_generator.py::test_render_detect -x -q</automated>
</verify>
<done>render_uninstall and render_detect produce correct PowerShell scripts; removal order is correct; detection uses Write-Output + exit codes per Intune contract.</done>
</task>
<task type="auto">
<name>Task 2: Script download API endpoints and router registration</name>
<files>imptune/api/scripts.py, imptune/main.py, tests/test_script_generator.py</files>
<action>
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)
```
</action>
<verify>
<automated>python -m pytest tests/test_script_generator.py -x -q</automated>
</verify>
<done>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.</done>
</task>
</tasks>
<verification>
```bash
python -m pytest tests/ -x -q
```
Full test suite passes (existing + new script tests). No regressions.
</verification>
<success_criteria>
- 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
</success_criteria>
<output>
After completion, create `.planning/phases/04-script-generation/04-02-SUMMARY.md`
</output>