diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..8b22210 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "WebSearch", + "Bash(xargs wc:*)" + ] + } +} diff --git a/.planning/MILESTONES.md b/.planning/MILESTONES.md new file mode 100644 index 0000000..c5422a2 --- /dev/null +++ b/.planning/MILESTONES.md @@ -0,0 +1,53 @@ +# ImpTune Milestones + +Historical record of shipped versions. + +--- + +## v1.0 — ImpTune MVP + +**Shipped:** 2026-04-13 +**Timeline:** 2026-04-10 → 2026-04-13 (4 days) +**Phases:** 7 (1–7, including gap-closure phases 6 & 7) +**Plans:** 13 +**Requirements:** 27/27 satisfied +**Git tag:** `v1.0` + +### Delivered + +A self-hosted single-container webapp that takes a driver ZIP + printer configuration and produces ready-to-deploy packages for Microsoft Intune (.intunewin, Python-native) or NinjaRMM (ZIP) — covering driver INF parsing, full printer configuration with client/tenant grouping, PowerShell install/uninstall/detect generation with UAC + WOW64 guards, icon embedding, and command-preview UI. + +### Key Accomplishments + +1. **Python-native `.intunewin` format** — reimplemented AES-256-CBC + HMAC-SHA256 encrypted ZIP-in-ZIP with detection.xml, validated byte-level (Phase 1 / 01-03) +2. **Driver upload with INF parsing** — RawConfigParser-based parser handles BOM/UTF-16, `%TOKEN%` resolution, multi-model drivers, and unused-file detection; backed by SHA256 content-addressed storage (Phase 2) +3. **Full printer CRUD** — all 10 PRNT requirements (name, IP, port, duplex, color, paper, collate, client assignment, persistence, regenerate), HTMX forms, Alpine.js IP→port auto-derivation (Phase 3) +4. **Production-ready PowerShell generators** — install/uninstall/detect with SYSTEM-vs-user UAC elevation, WOW64 64-bit relaunch guard, pnputil two-step staging, duplex mapping (Phase 4) +5. **One-click package export** — `/packages/intunewin` and `/packages/ninja` endpoints assemble complete ready-to-deploy artifacts from a saved printer config (Phase 5) +6. **Icon upload + embedding** — Pillow-validated 256×256 PNG ≤750KB, SHA256-addressed storage, wired into .intunewin output (Phases 5 + 6) +7. **Working dashboard + nav** — `/packages` listing page, live `recent_printers` / `recent_packages` DB queries on dashboard (Phase 7 gap closure) + +### Architecture / Stack + +Python 3.12 · FastAPI · Jinja2 · HTMX · Alpine.js · Pico CSS · SQLite (Peewee WAL) · pycryptodome · Pillow — single Docker container, no Node.js, no external DB, no auth. + +### Known Gaps / Tech Debt carried into v1.1 + +- Printer form driver dropdown requires manual page reload after new driver upload (Phase 2) +- PRNT-03 Alpine.js port auto-derivation — code verified, live browser runtime verification pending (Phase 3) +- No UI links to individual script downloads — only via package export or direct URL (Phase 5) +- `.intunewin` byte-level format validation against a real Intune tenant +- `pnputil` + `$PSScriptRoot` path resolution under SYSTEM context on a real Intune-managed device +- Nyquist validation: all 7 phases have draft VALIDATION.md but none are Nyquist-compliant — separate track for v1.1 + +### Notable Fixes Late in Milestone (2026-04-13) + +- **BLOCKER:** DriverStore saved files at `{sha256}` but `packages.py` looked up `{sha256}.zip` — upload→export flow was broken in production, masked by pre-staged test fixtures. Fixed by centralizing path in `DriverStore.get_path()`; added `tests/test_upload_export_roundtrip.py` regression test. +- Peewee `datetime.utcnow()` deprecation eliminated (`_utcnow()` helper in `db/models.py`) +- Stale `requirements-completed` frontmatter back-filled on 5 SUMMARY.md files + +### Archives + +- Roadmap: [`milestones/v1.0-ROADMAP.md`](milestones/v1.0-ROADMAP.md) +- Requirements: [`milestones/v1.0-REQUIREMENTS.md`](milestones/v1.0-REQUIREMENTS.md) +- Audit report: [`milestones/v1.0-MILESTONE-AUDIT.md`](milestones/v1.0-MILESTONE-AUDIT.md) diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md new file mode 100644 index 0000000..b75c8a1 --- /dev/null +++ b/.planning/PROJECT.md @@ -0,0 +1,98 @@ +# ImpTune + +## What This Is + +A self-hosted webapp (single Docker container) that lets IT technicians configure printer deployments and export ready-to-deploy packages for Microsoft Intune or NinjaRMM. It handles driver ZIP upload with INF parsing, full printer configuration with client/tenant grouping, PowerShell install/uninstall/detect generation (UAC + WOW64 guards), Python-native .intunewin assembly with embedded icon, and NinjaRMM ZIP export — all from a no-auth HTMX/Alpine.js browser UI. + +**Current state:** v1.0 shipped 2026-04-13 — 27 requirements, 7 phases, 13 plans. + +## Core Value + +Generate a complete, working printer deployment package (script + drivers + icon) in minutes instead of manually scripting each printer setup. + +Validated in v1.0: the tool produces both .intunewin and NinjaRMM artifacts from a saved printer config without re-uploading drivers. + +## Requirements + +### Validated (shipped in v1.0) + +- ✓ Upload driver ZIP with INF parsing and DriverDesc dropdown — v1.0 (DRV-01..05) +- ✓ Configure all printer parameters (name, IP/port, duplex, color, paper, collate) — v1.0 (PRNT-01..07) +- ✓ Client/tenant grouping with SQLite persistence and regenerate-from-saved-config — v1.0 (PRNT-08..10) +- ✓ PowerShell install script with UAC self-elevation and WOW64 64-bit relaunch guard — v1.0 (SCRPT-01, SCRPT-04, SCRPT-05) +- ✓ Uninstall and Intune detection scripts — v1.0 (SCRPT-02, SCRPT-03) +- ✓ Python-native .intunewin export (no IntuneWinAppUtil.exe dependency) — v1.0 (PKG-01, PKG-02) +- ✓ NinjaRMM ZIP export — v1.0 (PKG-03) +- ✓ Custom PNG icon upload, validated and embedded in .intunewin — v1.0 (PKG-04, Phase 6 gap closure) +- ✓ Install/uninstall command preview with copy buttons — v1.0 (PKG-05) +- ✓ Single Docker container, minimal runtime dependencies — v1.0 (INFRA-01, INFRA-02) + +### Active (v1.1 candidates) + +- [ ] Runtime validation on a real Intune tenant (.intunewin byte-level, pnputil under SYSTEM) +- [ ] Live browser verification of PRNT-03 Alpine.js port auto-derivation +- [ ] Fix printer form driver dropdown refresh after new driver upload (no manual page reload) +- [ ] Add UI links to individual script downloads on printer detail page +- [ ] Nyquist-compliant VALIDATION.md for all 7 phases (separate validation track) +- [ ] First real-world deployment + user feedback capture + +### Out of Scope + +- User authentication / separate logins — internal tool on private network +- Direct Intune / NinjaRMM API push — export packages only, keeps scope contained +- Real-time printer status / monitoring — requires SNMP + per-site network access, different product +- Universal Print integration — different deployment model, requires Azure subscription +- Mobile / tablet UI — target users are at workstations; no validated demand +- Multi-language / localization — English only, no demand signal +- Full deployment history / audit log — MSPs already have Intune/RMM logs + +## Context + +Shipped v1.0 with ~3,924 LOC Python (incl. tests) + templates/static assets. 113 files, ~14,840 lines added from first commit to v1.0. + +**Stack:** Python 3.12 · FastAPI · Jinja2 · HTMX · Alpine.js · Pico CSS · SQLite (Peewee WAL) · pycryptodome · Pillow — single Docker container, no Node.js, no external DB. + +**Target users:** MSP technicians managing printers across multiple client sites in multi-brand environments (HP, Canon, Ricoh, Brother, etc.). + +**Known runtime validations pending:** No real-world Intune tenant test yet — format compliance is byte-level validated against the C# reference but not end-to-end against a live tenant. + +## Constraints + +- **Deployment**: Single Docker container — no external database, message queue, or sidecar services +- **Dependencies**: Minimal — no Node.js, no external DB, no non-Python build tools +- **Platform**: Generated scripts target Windows endpoints (PowerShell 5.1+) +- **Persistence**: SQLite for config, Docker volume for driver packages and icons (both SHA256 content-addressed) + +## Key Decisions + +| Decision | Rationale | Outcome | +|----------|-----------|---------| +| No authentication | Internal tool on private network, simplicity over security | ✓ Good — v1.0 shipped without auth, no incidents | +| Python-native .intunewin | IntuneWinAppUtil.exe is a Windows PE binary, cannot run in Linux container | ✓ Good — byte-level validated, 14 format tests | +| Single Docker container | Minimal ops burden, easy to deploy | ✓ Good — shipped in v1.0 | +| Peewee + SQLite WAL | Minimal dependency, sync ORM compatible with sync FastAPI routes in thread pool | ✓ Good | +| Full 4-table schema upfront (Phase 1) | Later phases add routes only, no schema migrations | ✓ Good — zero schema churn across phases 2–7 | +| SHA256 content-addressed storage for drivers + icons | Free deduplication, consistent pattern | ✓ Good (but caused one bug: path suffix mismatch, fixed) | +| Plain-string args for script generators (not ORM objects) | Keeps unit tests DB-free | ✓ Good | +| Silent-skip on missing icon | Export always succeeds, optional feature | ✓ Good | +| Gap-closure phases 6 & 7 (post-audit) | Cleaner than shipping with known defects | ✓ Good — all 27 requirements passed re-audit | +| HTMX + Alpine.js (no SPA) | No Node.js in container, server-rendered templates | ✓ Good | + +## Current Milestone: v1.1 Hardening & Validation + +**Goal:** Close every open concern from v1.0 — real-world runtime validation, UX tech debt, and Nyquist-compliant validation track — to ship a confidence release. + +**Target features:** +- Real-world Intune tenant runtime validation (.intunewin byte-level + `pnputil` under SYSTEM context) +- Live browser verification of PRNT-03 Alpine.js IP→port auto-derivation +- Driver dropdown refresh after new driver upload (no manual page reload) +- UI links to individual script downloads on printer detail page +- Nyquist-compliant VALIDATION.md retro-fitted across all 7 v1.0 phases +- First real-world deployment + structured user feedback capture + +## Current Focus + +v1.1 — Hardening & Validation. No new features; pure quality, validation, and rollout milestone. + +--- +*Last updated: 2026-04-13 after v1.1 kickoff* diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md new file mode 100644 index 0000000..77bfb00 --- /dev/null +++ b/.planning/REQUIREMENTS.md @@ -0,0 +1,78 @@ +# Requirements: ImpTune v1.1 — Hardening & Validation + +**Defined:** 2026-04-13 +**Core Value:** Generate a complete, working printer deployment package (script + drivers + icon) in minutes instead of manually scripting each printer setup. +**Milestone goal:** Close every open concern from v1.0 — real-world runtime validation, UX tech debt, and Nyquist-compliant validation track — to ship a confidence release. + +> No new product features. Pure quality, validation, and rollout milestone. REQ-IDs continue numbering from v1.0 categories. + +## v1.1 Requirements + +### Real-World Runtime Validation (RTVAL) + +- [x] **RTVAL-01**: A generated `.intunewin` package is uploaded to a real Microsoft Intune tenant and accepted (no format errors), with byte-level conformance confirmed against tenant ingestion +- [x] **RTVAL-02**: A generated install script runs successfully under SYSTEM context on a real Intune-managed Windows endpoint, with `pnputil` driver staging and `$PSScriptRoot` path resolution verified +- [x] **RTVAL-03**: Generated detect script returns the expected exit code on a real endpoint after install (Intune detection rule succeeds) +- [x] **RTVAL-04**: Uninstall script removes the printer cleanly under SYSTEM context on a real endpoint +- [x] **RTVAL-05**: A signed-off RUNTIME-VALIDATION.md report records tenant, device, OS build, driver vendor(s) tested, screenshots/logs, and any issues found + +### UX Tech Debt (UX) + +- [x] **UX-01**: After a new driver is uploaded on the printer form, the DriverDesc dropdown refreshes automatically (no manual page reload) — verified live in browser +- [x] **UX-02**: PRNT-03 Alpine.js IP→port auto-derivation is verified live in a real browser session, with the verification recorded in VALIDATION.md +- [x] **UX-03**: The printer detail page exposes direct download links for each generated script (install / uninstall / detect) in addition to the package export buttons + +### Nyquist Validation Track (NYQ) + +- [x] **NYQ-01**: All 7 v1.0 phases have a Nyquist-compliant `VALIDATION.md` (one observable check per success criterion, evidence cited, no hand-wavy "code looks right" entries) +- [x] **NYQ-02**: A `.planning/milestones/v1.0-VALIDATION-INDEX.md` aggregates per-phase validation status with pass/fail and links to evidence +- [x] **NYQ-03**: Any validation gaps surfaced during the Nyquist pass that block real usage are tracked as defects and either fixed in v1.1 or explicitly deferred with rationale + +### Real-World Rollout (RWR) + +- [x] **RWR-01**: ImpTune is deployed in its single Docker container to at least one real MSP environment serving real printers +- [x] **RWR-02**: At least one real printer deployment package generated by the deployed instance is pushed to endpoints (via Intune or NinjaRMM) end-to-end +- [x] **RWR-03**: Structured user feedback is captured from the deploying technician (what worked, what blocked, what's missing) in a `.planning/feedback/v1.1-rollout.md` document +- [x] **RWR-04**: Feedback items are triaged into: fix-in-v1.1, defer-to-v1.2, won't-do (with reasoning) — recorded in the same feedback document + +## Future Requirements + +Carried forward from v1.0 Out of Scope — no change. + +## Out of Scope + +| Feature | Reason | +|---------|--------| +| New product features (auth, monitoring, API push, mobile UI, i18n, audit log) | v1.1 is hardening-only; new capability work waits for v1.2+ | +| Refactoring storage / DB schema | v1.0 schema stable, no migration churn warranted | +| Performance optimization | No reported bottleneck; premature | +| Rewriting v1.0 phases that already pass real-world validation | Only fix what real-world validation breaks | + +## Traceability + +| Requirement | Phase | Status | +|-------------|-------|--------| +| RTVAL-01 | Phase 10 | Complete | +| RTVAL-02 | Phase 10 | Complete | +| RTVAL-03 | Phase 10 | Complete | +| RTVAL-04 | Phase 10 | Complete | +| RTVAL-05 | Phase 10 | Complete | +| UX-01 | Phase 9 | Complete | +| UX-02 | Phase 9 | Complete | +| UX-03 | Phase 9 | Complete | +| NYQ-01 | Phase 8 | Complete | +| NYQ-02 | Phase 8 | Complete | +| NYQ-03 | Phase 8 | Complete | +| RWR-01 | Phase 11 | Complete | +| RWR-02 | Phase 11 | Complete | +| RWR-03 | Phase 11 | Complete | +| RWR-04 | Phase 11 | Complete | + +**Coverage:** +- v1.1 requirements: 15 total +- Mapped to phases: 15 ✓ +- Unmapped: 0 + +--- +*Requirements defined: 2026-04-13* +*Last updated: 2026-04-13 after v1.1 roadmap creation (Phases 8–11)* diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md new file mode 100644 index 0000000..f5ed0a9 --- /dev/null +++ b/.planning/ROADMAP.md @@ -0,0 +1,122 @@ +# Roadmap: ImpTune + +## Milestones + +- ✅ **v1.0 MVP** — Phases 1–7, 13 plans, 27/27 requirements (shipped 2026-04-13) — see [`milestones/v1.0-ROADMAP.md`](milestones/v1.0-ROADMAP.md) +- 🚧 **v1.1 Hardening & Validation** — Phases 8–11, 15 requirements + UI enhancements (in progress, started 2026-04-13) + +## Phases + +
+✅ v1.0 MVP (Phases 1–7) — SHIPPED 2026-04-13 + +- [x] Phase 1: Foundation (3/3 plans) — 2026-04-10 +- [x] Phase 2: Driver Management (2/2 plans) — 2026-04-10 +- [x] Phase 3: Printer Configuration (2/2 plans) — 2026-04-10 +- [x] Phase 4: Script Generation (2/2 plans) — 2026-04-10 +- [x] Phase 5: Package Export (2/2 plans) — 2026-04-10 +- [x] Phase 6: Wire Icon into .intunewin (1/1 plan, gap closure) — 2026-04-10 +- [x] Phase 7: Dashboard & Navigation Polish (1/1 plan, gap closure) — 2026-04-13 + +Full details: [`milestones/v1.0-ROADMAP.md`](milestones/v1.0-ROADMAP.md) + +
+ +### 🚧 v1.1 Hardening & Validation (Phases 8–11) + +- [x] **Phase 8: Nyquist Validation Track** — Retro-fit Nyquist-compliant VALIDATION.md across all 7 v1.0 phases with evidence-backed checks (completed 2026-04-13) +- [x] **Phase 9: UX Tech Debt Closure** — Fix the three carried-over UX gaps so the deployed build is the polished one technicians actually use + (completed 2026-04-13) +- [x] **Phase 10: Real-World Runtime Validation** — Validate generated artifacts end-to-end against a live Intune tenant and a real managed endpoint (completed 2026-04-13) +- [x] **Phase 11: UI Enhancements** — Add printer edit, separate form from list, clickable client names, dark/light mode toggle, and French/English language switch (completed 2026-04-15) + +## Phase Details + +### Phase 8: Nyquist Validation Track +**Goal**: Every v1.0 phase has a signed-off Nyquist-compliant validation record with cited evidence, and any blocking gaps are tracked. +**Depends on**: Nothing (parallelizable — pure audit of shipped code, no runtime dependency) +**Requirements**: NYQ-01, NYQ-02, NYQ-03 +**Success Criteria** (what must be TRUE): + 1. An operator can open any of the 7 v1.0 phase folders and read a `VALIDATION.md` where every success criterion maps to exactly one observable check with cited evidence (commit, test name, file path, or screenshot) + 2. An operator can open `.planning/milestones/v1.0-VALIDATION-INDEX.md` and see a single pass/fail roll-up across all 7 phases with links to each phase's validation file + 3. Any validation gap surfaced during the Nyquist pass appears in the index as either a v1.1 defect ticket (linked to the fixing phase) or an explicitly deferred item with written rationale +**Plans**: 8 plans + - [ ] 08-01-PLAN.md — Audit Phase 1 (Foundation) into Nyquist-compliant 01-VALIDATION.md (NYQ-01) + - [ ] 08-02-PLAN.md — Audit Phase 2 (Driver Management) + record POST /drivers/upload 500 gap (NYQ-01) + - [ ] 08-03-PLAN.md — Audit Phase 3 (Printer Configuration) into Nyquist Record (NYQ-01) + - [ ] 08-04-PLAN.md — Audit Phase 4 (Script Generation) with SYSTEM-context attestation notes (NYQ-01) + - [ ] 08-05-PLAN.md — Audit Phase 5 (Package Export) with RTVAL-01 byte-level evidence (NYQ-01) + - [ ] 08-06-PLAN.md — Audit Phase 6 (Wire Icon into .intunewin) into Nyquist Record (NYQ-01) + - [ ] 08-07-PLAN.md — Audit Phase 7 (Dashboard & Nav Polish) into Nyquist Record (NYQ-01) + - [ ] 08-08-PLAN.md — Compile v1.0-VALIDATION-INDEX.md, triage gaps, human sign-off (NYQ-02, NYQ-03) + +### Phase 9: UX Tech Debt Closure +**Goal**: The three carried-over UX defects are fixed and live-verified in a real browser so the rolled-out build is the polished one. +**Depends on**: Nothing (independent of validation and rollout — but must complete before Phase 11) +**Requirements**: UX-01, UX-02, UX-03 +**Success Criteria** (what must be TRUE): + 1. A technician uploading a new driver on the printer form sees the new DriverDesc appear in the dropdown without manually reloading the page + 2. A technician typing an IP address into the printer form sees the port field auto-populate via the PRNT-03 Alpine.js handler, observed live in a real browser and recorded in VALIDATION.md + 3. A technician on the printer detail page can click direct download links for the install, uninstall, and detect scripts individually, in addition to the existing package export buttons +**Plans**: 3 plans + - [ ] 09-01-driver-upload-fix-and-inline-oob-PLAN.md — Fix POST /drivers/upload 500 + add inline upload to printer form with HTMX OOB refresh (UX-01) + - [ ] 09-02-playwright-port-autofill-PLAN.md — Add Playwright dev dep + headless test for PRNT-03 IP->port auto-fill (UX-02) + - [ ] 09-03-script-download-links-PLAN.md — Add .ps1 route aliases + printer_detail.html script download links (UX-03) + +### Phase 10: Real-World Runtime Validation +**Goal**: Generated .intunewin, install, detect, and uninstall artifacts are proven to work end-to-end on a real Intune tenant against a real Windows endpoint, with evidence recorded. +**Depends on**: Phase 9 (rollout uses the validated-AND-polished build; validation itself only strictly needs v1.0, but running it on the polished build avoids re-doing the pass) +**Requirements**: RTVAL-01, RTVAL-02, RTVAL-03, RTVAL-04, RTVAL-05 +**Success Criteria** (what must be TRUE): + 1. A generated `.intunewin` package is uploaded to a real Microsoft Intune tenant and accepted without format errors, with the tenant ingestion confirmation captured as evidence + 2. A technician assigning the package to a real Intune-managed Windows endpoint observes the install script succeed under SYSTEM context, with `pnputil` driver staging and `$PSScriptRoot` path resolution verified in the device log + 3. After install, the Intune detection rule driven by the generated detect script reports "installed" for the endpoint + 4. A technician triggering uninstall from Intune sees the printer cleanly removed from the endpoint under SYSTEM context + 5. A reviewer can open `RUNTIME-VALIDATION.md` and read a signed-off report listing tenant, device, OS build, driver vendor(s), screenshots/logs, and any issues found +**Plans**: 3 plans + - [ ] 10-01-preflight-package-and-scaffold-PLAN.md — Generate real .intunewin from current commit and scaffold RUNTIME-VALIDATION.md with tenant/device/vendor metadata (RTVAL-05 scaffold) + - [ ] 10-02-live-intune-runtime-validation-PLAN.md — Drive RTVAL-01..04 manual checkpoints against a live Intune tenant + real Windows endpoint, capturing screenshots and device logs as evidence + - [ ] 10-03-report-signoff-PLAN.md — Finalize RUNTIME-VALIDATION.md, human sign-off, tick RTVAL-01..05 and mark Phase 10 complete + +### Phase 11: UI Enhancements +**Goal**: Improve the daily usability of ImpTune with printer editing, better form/list layout, client-scoped navigation, dark/light theme, and bilingual (FR/EN) support. +**Depends on**: Phase 9 (polished base build), Phase 10 (runtime validation passed) +**Requirements**: UIE-01, UIE-02, UIE-03, UIE-04, UIE-05 +**Success Criteria** (what must be TRUE): + 1. Every printer in the list has an Edit button that opens a pre-filled form and saves changes in-place without losing other printer data + 2. The new-printer form is visually separated from the printer list (distinct section, card, or page) so adding a printer doesn't feel buried in the list + 3. Every client name in the interface is a clickable link that navigates to a filtered page showing only that client's printers + 4. A toggle lets the user switch between Dark mode, Light mode, and Follow system — the chosen preference persists across page reloads + 5. A toggle lets the user switch the UI language between French and English — all labels, buttons, and messages update immediately and the choice persists +**Plans**: 4 plans + - [ ] 11-01-PLAN.md — Wave 0 test scaffolds + UIE-02: dedicated /printers/new page + POST redirect (UIE-02) + - [ ] 11-02-PLAN.md — Printer edit modal: PATCH /printers/{id} + native dialog + Edit button per row (UIE-01) + - [ ] 11-03-PLAN.md — Theme toggle + FR/EN language toggle in base.html via Alpine.js stores (UIE-04, UIE-05) + - [ ] 11-04-PLAN.md — Client detail page /clients/{id} + clickable client names everywhere (UIE-03) + +## Progress + +| Phase | Milestone | Plans Complete | Status | Completed | +|-------|-----------|----------------|----------|------------| +| 1. Foundation | v1.0 | 3/3 | Complete | 2026-04-10 | +| 2. Driver Management | v1.0 | 2/2 | Complete | 2026-04-10 | +| 3. Printer Configuration | v1.0 | 2/2 | Complete | 2026-04-10 | +| 4. Script Generation | v1.0 | 2/2 | Complete | 2026-04-10 | +| 5. Package Export | v1.0 | 2/2 | Complete | 2026-04-10 | +| 6. Wire Icon into .intunewin | v1.0 | 1/1 | Complete | 2026-04-10 | +| 7. Dashboard & Nav Polish | v1.0 | 1/1 | Complete | 2026-04-13 | +| 8. Nyquist Validation Track | v1.1 | 8/8 | Complete | 2026-04-13 | +| 9. UX Tech Debt Closure | 3/3 | Complete | 2026-04-13 | 2026-04-13 | +| 10. Real-World Runtime Validation | v1.1 | 3/3 | Complete | 2026-04-13 | +| 11. UI Enhancements | 4/4 | Complete | 2026-04-15 | | + +### Phase 12: i18n bugfixes — full translation coverage and browser language auto-detection + +**Goal:** All hardcoded UI strings in every template respond to the FR/EN language toggle; browser language auto-detected from navigator.language on first visit; E2E suite fully green. +**Requirements**: TBD +**Depends on:** Phase 11 +**Plans:** 2/2 plans complete + +Plans: +- [ ] 12-01-PLAN.md — Browser language auto-detection (navigator.language fallback) + fix test_port_autofill E2E +- [ ] 12-02-PLAN.md — Full template i18n coverage: wire all hardcoded strings across 13 templates to Alpine i18n store diff --git a/.planning/STATE.md b/.planning/STATE.md new file mode 100644 index 0000000..54835e7 --- /dev/null +++ b/.planning/STATE.md @@ -0,0 +1,150 @@ +--- +gsd_state_version: 1.0 +milestone: v1.1 +milestone_name: Hardening & Validation +current_plan: 3 +status: verifying +stopped_at: Completed 12-i18n-bugfixes/12-02-PLAN.md +last_updated: "2026-04-15T14:16:32.471Z" +last_activity: 2026-04-15 +progress: + total_phases: 5 + completed_phases: 5 + total_plans: 20 + completed_plans: 20 +--- + +--- +gsd_state_version: 1.0 +milestone: v1.1 +milestone_name: Hardening & Validation +current_plan: 3 +status: Phase complete — ready for verification +stopped_at: Completed 11-ui-enhancements/11-04-PLAN.md +last_updated: "2026-04-15T13:08:42.940Z" +last_activity: 2026-04-15 +progress: + total_phases: 4 + completed_phases: 4 + total_plans: 18 + completed_plans: 18 +--- + +--- +gsd_state_version: 1.0 +milestone: v1.1 +milestone_name: Hardening & Validation +current_plan: 3 +status: Phase complete — ready for verification +stopped_at: Completed 11-ui-enhancements/11-01-PLAN.md +last_updated: "2026-04-15T09:04:38.757Z" +last_activity: 2026-04-15 +progress: + total_phases: 4 + completed_phases: 3 + total_plans: 18 + completed_plans: 15 +--- + +# Project State + +## Project Reference + +See: .planning/PROJECT.md (updated 2026-04-13 after v1.0 milestone) + +**Core value:** Generate a complete, working printer deployment package (script + drivers + icon) in minutes instead of manually scripting each printer setup. +**Current focus:** v1.1 Hardening & Validation — Phase 9 (UX Tech Debt Closure) + +## Current Position + +Milestone: v1.1 Hardening & Validation +Phase: 09 — UX Tech Debt Closure — ACTIVE (1/3 plans complete) +Current Plan: 3 +Total Plans in Phase: 3 +Status: Phase 09 active — 09-01 complete (UX-01: driver upload 500 fixed + HTMX OOB refresh wired) +Decision: 09-01 delivered caller-aware upload handler, driver_upload_with_oob.html template, inline upload form in printer_form.html, and 5 new integration tests (500 regression x2, OOB contract x3). +Last activity: 2026-04-15 + +## Milestone History + +- **v1.0** — ImpTune MVP (shipped 2026-04-13) — see [MILESTONES.md](MILESTONES.md) + +## Accumulated Context + +### v1.1 Phase Structure + +- Phase 8: Nyquist Validation Track (NYQ-01..03) — parallelizable audit track +- Phase 9: UX Tech Debt Closure (UX-01..03) — must land before rollout +- Phase 10: Real-World Runtime Validation (RTVAL-01..05) — must pass before rollout +- Phase 11: UI Enhancements (UIE-01..05) — printer edit, separated form/list, client nav, theme toggle, i18n FR/EN +- Phase 12: i18n bugfixes — full translation coverage + browser language auto-detection + +### Roadmap Evolution + +- Phase 12 added: i18n bugfixes — untranslated buttons/labels found post-Phase 11; browser language setting not honoured + +### Open Concerns (now owned by v1.1 phases) + +- Real-world Intune tenant .intunewin acceptance → Phase 10 (RTVAL-01) +- `pnputil` + `$PSScriptRoot` under SYSTEM → Phase 10 (RTVAL-02..04) +- Driver dropdown refresh after upload → Phase 9 (UX-01) +- PRNT-03 Alpine.js port auto-derivation live verification → Phase 9 (UX-02) +- Individual script download links on printer detail page → Phase 9 (UX-03) +- Nyquist-compliant VALIDATION.md across v1.0 phases → Phase 8 (NYQ-01..03) + +### Decisions + +- **Phase ordering:** RTVAL before RWR (cannot deploy unvalidated runtime); UX before RWR (deployed build must be polished); NYQ parallel to all (pure audit, no code dependency) — placed first so v1.0 validation evidence is fresh before runtime work begins. +- **RTVAL grouping:** RTVAL-01..05 combined into single Phase 10 because they share setup (same tenant, same test endpoint, same RUNTIME-VALIDATION.md report). +- **NYQ as dedicated phase:** Kept standalone (not absorbed) because it audits all 7 v1.0 phases and its evidence feeds defect triage into Phases 9/10. + +Full decision log in PROJECT.md Key Decisions table. Milestone v1.0 decisions archived in `milestones/v1.0-ROADMAP.md`. +- [Phase 09-ux-tech-debt-closure]: 09-03: .ps1 routes added as aliases (not renames) to preserve backward compatibility +- [Phase 09-ux-tech-debt-closure]: 09-03: Shared _*_response() helper pattern used for route aliases +- [Phase 09]: Sentinel field (caller=printer_form) for OOB branching: chosen over HX-Target header for clarity and testability +- [Phase 09]: HTMX OOB template includes primary fragment + OOB select sibling in driver_upload_with_oob.html +- [Phase 09-ux-tech-debt-closure]: 09-02: /printers route used for e2e test (full-page with Alpine.js) — no new /printers/new route needed +- [Phase 09-ux-tech-debt-closure]: 09-02: conftest.py adapted — imptune.config uses string paths, init_db() takes no args +- [Phase 10-real-world-runtime-validation]: 10-01: Package under test is Ricoh PCL6 Universal Print (Copieur_2eme.intunewin), ImpTune commit 1c3f458, committed to evidence/ for traceability +- [Phase 10-real-world-runtime-validation]: 10-01: Commit SHA locked before runtime testing — all RTVAL results reference this exact build +- [Phase 10-real-world-runtime-validation]: 10-02: RTVAL-01 FAIL — Stop plan 10-02; surface .intunewin structure defect as gap; use /gsd:debug on generator or /gsd:plan-phase 10 --gaps before retesting +- [Phase 10-real-world-runtime-validation]: 10-02: RTVAL-01 PASS on re-test (2026-04-13) — ISSUE-01 resolved by commits 74535ea (HMAC over IV+ciphertext) and 7716246 (Detection.xml alignment with IntuneWinAppUtil.exe reference format); plan resumed at Task 2 +- [Phase 10-real-world-runtime-validation]: 10-02: RTVAL-02 accepted as attestation-only PASS (2026-04-13) — technician verbally confirmed install succeeded on ARES-5CG5220YTM but did NOT provide IntuneManagementExtension.log excerpt or portal screenshot; user explicitly approved "Pass without evidence"; audit trail weakened for this check and flagged in RUNTIME-VALIDATION.md Notes +- [Phase 10-real-world-runtime-validation]: 10-02: RTVAL-03 accepted as attestation-only PASS (2026-04-13) — second consecutive attestation-only check; no rtval-03-detection.png and no rtval-03-detect-manual.txt captured; user was explicitly warned that a second consecutive attestation-only check further weakens the audit trail and still chose to proceed; flagged in RUNTIME-VALIDATION.md Notes as soft PASS requiring re-run with full artifact capture before phase sign-off +- [Phase 10-real-world-runtime-validation]: 10-02: RTVAL-04 accepted as attestation-only PASS (2026-04-13) — **third consecutive attestation-only check**; no rtval-04-uninstall-log.txt and no rtval-04-uninstall-status.png captured; user was warned a SECOND time about cumulative audit trail damage and still chose to proceed. Together, RTVAL-02/03/04 constitute an attestation-only runtime half for Phase 10: only RTVAL-01 (tenant ingestion) is artifact-backed. Plan 10-03 sign-off must explicitly address whether to re-run RTVAL-02/03/04 with full evidence before closing the phase. +- [Phase 10-real-world-runtime-validation]: 10-02: Plan 10-02 COMPLETE (2026-04-13) — SUMMARY.md created with prominent "Attestation-Only Audit Trail Damage" section for the wave-3 verifier and phase verifier +- [Phase 10-real-world-runtime-validation]: 10-03: Plan 10-03 COMPLETE (2026-04-13) — RUNTIME-VALIDATION.md signed off by Sébastien QUEROL with explicit attestation-gap acknowledgement; REQUIREMENTS.md RTVAL-01..05 ticked (idempotent, already landed in 10-02 commit 206648c); ROADMAP.md Phase 10 flipped to 3/3 Complete 2026-04-13. Phase 10 officially closed. +- [Phase 08-nyquist-validation-track]: 08-01: Phase 1 Nyquist Record complete with 14/14 pass rows; row 14 (upload-to-real-Intune spike) resolved PASS citing Phase 10 RTVAL-01 sign-off rather than fail-fix-v1.1 +- [Phase 08-nyquist-validation-track]: 08-02: Phase 2 Nyquist Record complete with 6/6 pass rows; POST /drivers/upload 500 historical gap (row 6) closed as pass citing Phase 9 UX-01 fixing commits d1de839 + 10ee09a + 72c6a98 +- [Phase 08-nyquist-validation-track]: 08-03: Phase 3 Nyquist Record complete with 10/10 pass rows; PRNT-03 Alpine.js IP->port historical gap (row 3) closed as pass citing Phase 9 UX-02 Playwright fixing commits 322fc20 + 37a06da +- [Phase 08-nyquist-validation-track]: 08-04: Phase 4 Nyquist Record complete with 5/5 pass rows; SYSTEM-context rows (SCRPT-01/02/03/04/05) cite Phase 10 RTVAL-02/03/04 with explicit attestation-only caveat per STATE.md 2026-04-13 faithfully recorded in Notes +- [Phase 08-nyquist-validation-track]: 08-05: Phase 5 Nyquist Record complete with 5/5 pass rows; PKG-02 row is the ONLY artifact-backed live-tenant runtime row in the 7-phase audit track (cites RTVAL-01 PASS on rubis.fr + fix commits 74535ea/7716246); PKG-04 icon-embedding historical gap closed in place via Phase 6 TestIntunewinIconInclusion +- [Phase 08-nyquist-validation-track]: 08-06: Phase 6 Nyquist Record complete with 1/1 pass row (PKG-04 icon embedding); shortest audit in track reflecting single-criterion gap-closure structure; bidirectional citation loop with 05-VALIDATION row 4; RTVAL-01 cited as supporting transitive runtime evidence +- [Phase 08-nyquist-validation-track]: 08-07: Phase 7 Nyquist Record complete with 4/4 pass (3 in-scope rows anchored to 07-VERIFICATION.md truths since Phase 7 has zero REQUIREMENTS.md IDs, plus 1 UX-03 carry-over row closed via Phase 9 / 09-03); per-phase NYQ-01 coverage complete across all 7 v1.0 phases (45 audit rows total, 0 roll-forward) +- [Phase 08-nyquist-validation-track]: 08-08: v1.0-VALIDATION-INDEX.md signed off 2026-04-13 by Sébastien QUEROL; 45/45 pass across 7 phases, 0 fail-fix-v1.1; NYQ-01/02/03 Complete; Phase 4 attestation-only runtime gap recorded as residual risk owned by Phase 11 rollout (not reopened) +- [Phase 09-ux-tech-debt-closure]: 09-02: /printers route used for e2e test (full-page with Alpine.js) — no new /printers/new route needed +- [Phase 09-ux-tech-debt-closure]: 09-02: conftest.py adapted — imptune.config uses string paths, init_db() takes no args +- [Phase 11-ui-enhancements]: 11-01: Plain HTML form in printers_new.html (Option A) — no hx-post, uses action=/printers method=post so browser follows 303 redirect naturally +- [Phase 11-ui-enhancements]: 11-01: driver_data context kept in GET /printers handler for future Plan 02 edit modal +- [Phase 11-ui-enhancements]: 11-04: client_id extracted from printers[0].client_id in Jinja2 — no grouped structure change needed +- [Phase 11-ui-enhancements]: 11-04: Unassigned group header plain text — group_client_id is None when client_id absent; no dead anchor +- [Phase 12-i18n-bugfixes]: IIFE pattern chosen for Alpine store lang init — evaluates at store creation time (inside alpine:init), before any hydration +- [Phase 12-i18n-bugfixes]: playwright browser.new_context(locale=...) used for navigator.language tests — isolates locale per test without global fixture contamination +- [Phase 12-i18n-bugfixes]: Span-wrapper pattern for label text: since x-text replaces all child nodes +- [Phase 12-i18n-bugfixes]: Span-wrapper pattern for label text: since x-text replaces all child nodes + +### Active Blockers + +None. BLOCKER-01 resolved 2026-04-13 via commits 74535ea (HMAC over IV+ciphertext) and 7716246 (Detection.xml aligned with IntuneWinAppUtil.exe reference format); RTVAL-01 re-tested PASS on fixed build. + +### Pending Todos + +- Run `/gsd:plan-phase 8` to draft plans for Nyquist Validation Track +- Schedule real Intune tenant + test endpoint access for Phase 10 +- Run `/gsd:plan-phase 11` to draft plans for UI Enhancements (printer edit, form/list separation, client nav, theme, i18n) + +## Session Continuity + +Last session: 2026-04-15T14:13:39.425Z +Stopped at: Completed 12-i18n-bugfixes/12-02-PLAN.md +Resume file: None diff --git a/.planning/config.json b/.planning/config.json new file mode 100644 index 0000000..cf96442 --- /dev/null +++ b/.planning/config.json @@ -0,0 +1,15 @@ +{ + "mode": "yolo", + "granularity": "standard", + "parallelization": true, + "commit_docs": true, + "model_profile": "balanced", + "workflow": { + "research": false, + "plan_check": true, + "verifier": true, + "nyquist_validation": true, + "_auto_chain_active": false + }, + "nyquist_validation_enabled": false +} \ No newline at end of file diff --git a/.planning/debug/resolved/phase-10-rtval-01-intunewin-parse-fail.md b/.planning/debug/resolved/phase-10-rtval-01-intunewin-parse-fail.md new file mode 100644 index 0000000..4535e4b --- /dev/null +++ b/.planning/debug/resolved/phase-10-rtval-01-intunewin-parse-fail.md @@ -0,0 +1,80 @@ +--- +status: resolved +trigger: "phase-10-rtval-01-intunewin-parse-fail" +created: 2026-04-13T00:00:00Z +updated: 2026-04-13T00:00:00Z +--- + +## Current Focus + +hypothesis: HMAC is computed over `ciphertext` only, but the reference (svrooij/ContentPrep, confirmed by multiple sources) computes it over `IV + ciphertext`. This causes Intune's HMAC verification to fail silently, producing the exact symptom: empty fields, OK button greyed, no error banner. +test: Inspect svrooij C# DecryptFileAsync: after reading first 32 bytes (HMAC), it hashes "remaining bytes" = IV+ciphertext. ImpTune computes HMAC over ciphertext only (line 81: `hmac.new(mac_key, ciphertext, ...)`). +expecting: If confirmed, fixing HMAC to cover `iv + ciphertext` will fix the package. +next_action: Fix HMAC computation in intunewin_builder.py and update tests. + +## Symptoms + +expected: Uploading the .intunewin to Intune parses metadata, populates Name/Platform/Size/MAM-enabled fields, enables OK button. +actual: Intune accepts upload but never populates the metadata form. All fields stay empty. OK button stays greyed out. No error banner. +errors: Silent metadata-parse failure inside the wizard. +reproduction: Build with ImpTune, upload to Intune Apps > Windows > Add > Windows app (Win32). +started: First time the generator has been tested against a real Intune tenant. Never worked in production. + +## Eliminated + +- hypothesis: archive layout is wrong (different folder structure) + evidence: python -m zipfile -l confirms correct IntuneWinPackage/Contents/ and IntuneWinPackage/Metadata/ layout + timestamp: 2026-04-13T00:00:00Z + +- hypothesis: encryption algorithm (AES mode, IV size, padding) is wrong + evidence: code uses AES-256-CBC with PKCS7 padding, 16-byte IV — matches reference. Algorithm itself correct. + timestamp: 2026-04-13T00:00:00Z + +- hypothesis: Detection.xml structural defects alone caused the failure (prior hypothesis) + evidence: Detection.xml was fixed in commit 7716246 (no xmlns, no XML decl, added ToolVersion attr, removed MacAlgorithm). Human verification came back with IDENTICAL symptom. Fix was real but not sufficient. Bug is deeper. + timestamp: 2026-04-13T10:30:00Z + +## Evidence + +- timestamp: 2026-04-13T00:00:00Z + checked: Copieur_2eme.intunewin archive layout + found: Correct paths — IntuneWinPackage/Contents/IntunePackage.intunewin + IntuneWinPackage/Metadata/Detection.xml + implication: Archive layout is not the issue + +- timestamp: 2026-04-13T00:00:00Z + checked: Detection.xml from Copieur_2eme.intunewin + found: Has xmlns="http://schemas.microsoft.com/IntuneWin", has declaration, missing ToolVersion attribute, has MacAlgorithm child element + implication: Multiple structural deviations from reference + +- timestamp: 2026-04-13T00:00:00Z + checked: svrooij/ContentPrep reference implementation (Packager.cs + ApplicationInfo.cs) + found: (1) ToolVersion="1.8.6.0" is an XML ATTRIBUTE on ApplicationInfo, (2) NO xmlns namespace ([XmlRoot("ApplicationInfo")] with no Namespace param + empty XmlSerializerNamespaces), (3) OmitXmlDeclaration=true so no header, (4) FileEncryptionInfo model has NO MacAlgorithm field + implication: ImpTune's Detection.xml deviates in 4 ways from the reference. The missing ToolVersion and wrong namespace are the most likely causes of Intune wizard silence. + +- timestamp: 2026-04-13T10:30:00Z + checked: Human verification result after Detection.xml fix (commit 7716246) + found: Same exact symptom — empty fields, OK greyed, no error banner. Bit-for-bit identical failure. Post-fix package was NOT checked into evidence/. + implication: Either (a) stale build tested, or (b) additional structural bug beyond Detection.xml. Must assume (b) since symptom is bit-for-bit identical. + +- timestamp: 2026-04-13T10:30:00Z + checked: svrooij decryption article — DecryptFileAsync algorithm + found: After reading first 32 bytes (HMAC), method computes hash of "remaining bytes" (= IV + ciphertext). Multiple web sources confirm: "HMAC is computed over IV + ciphertext combined". + implication: ImpTune computes HMAC over ciphertext only (intunewin_builder.py line 81: hmac.new(mac_key, ciphertext, ...)). Reference computes over iv+ciphertext. This is a cryptographic mismatch that Intune would detect silently. + +- timestamp: 2026-04-13T10:30:00Z + checked: packages.py get_intunewin_package endpoint + found: output_path = os.path.join(tmpdir, "out.intunewin") — output file is inside source_dir passed to build_intunewin(). build_intunewin walks source_dir FIRST (step 1), output_path does not exist yet, so it is NOT included in inner ZIP. + implication: No self-inclusion bug. Endpoint code is structurally correct. + +## Resolution + +root_cause: TWO bugs, both in intunewin_builder.py: + (1) Detection.xml structural errors — 4 deviations from IntuneWinAppUtil.exe reference: missing ToolVersion attribute, spurious xmlns namespace, header, extra MacAlgorithm element. Fixed in commit 7716246. + (2) HMAC scope bug — HMAC was computed over ciphertext only, but the reference (svrooij/ContentPrep DecryptFileAsync) hashes the "remaining bytes" after the stored HMAC = IV+ciphertext. Intune's HMAC verification uses HMAC(mac_key, iv+ciphertext) but the stored value was HMAC(mac_key, ciphertext). This is a silent authentication mismatch that would cause Intune to reject the encrypted payload, manifesting identically to the XML bug: empty form fields, greyed OK button, no error banner. Fixed in commit [new commit]. +fix: | + Bug 1 (commit 7716246): Rewrote Detection.xml generation — removed xmlns namespace, removed XML declaration, added ToolVersion="1.8.6.0" attribute on ApplicationInfo, removed MacAlgorithm child element. + Bug 2 (commit 74535ea): Changed HMAC computation from hmac.new(mac_key, ciphertext, ...) to hmac.new(mac_key, iv + ciphertext, ...). Updated test_hmac_matches to verify HMAC over iv_and_ciphertext = blob[32:] (matches reference decryption: hash all bytes after the stored MAC). +verification: Verified against live arescom.fr Intune tenant — rebuilt package parses correctly. Name/Platform/Size/MAM-enabled fields all populate; OK button becomes active. Human confirmation: "confirmed fixed". +files_changed: + - imptune/generators/intunewin_builder.py (Detection.xml structural fixes + HMAC scope fix) + - tests/test_intunewin.py (test updated for corrected HMAC scope) diff --git a/.planning/feedback/v1.1-rollout.md b/.planning/feedback/v1.1-rollout.md new file mode 100644 index 0000000..01a4356 --- /dev/null +++ b/.planning/feedback/v1.1-rollout.md @@ -0,0 +1,45 @@ +# ImpTune v1.1 Rollout — Technician Feedback + +**Requirements:** RWR-03, RWR-04 +**Captured:** 2026-04-13 +**Technician:** Kawa +**Deployed instance:** [../phases/11-real-world-rollout-feedback/deploy/DEPLOYMENT.md](../phases/11-real-world-rollout-feedback/deploy/DEPLOYMENT.md) +**Rollout run:** [../phases/11-real-world-rollout-feedback/deploy/ROLLOUT-RUN.md](../phases/11-real-world-rollout-feedback/deploy/ROLLOUT-RUN.md) + +--- + +## What Worked + +- ImpTune generated printer deployment packages that installed cleanly on real endpoints. +- Packages delivered end-to-end via **both** Microsoft Intune and NinjaRMM without channel-specific issues. +- Tested across various devices — all installs succeeded, printers were usable after deployment. + +## What Blocked + +- Nothing blocked the rollout. + +## What's Missing + +- Nothing surfaced during this rollout. + +--- + +## Triage + +| # | Item | Tag | Rationale | +|---|------|-----|-----------| +| — | *(no feedback items)* | — | Rollout succeeded on all tested devices via both delivery channels; nothing to fix, defer, or decline. | + +**Every feedback item is triaged:** N/A — no items raised. + +--- + +## Sign-off + +> "I tested all the packages on various devices, it works." — Kawa, 2026-04-13 + +Phase 11 success criteria satisfied: +1. ✓ ImpTune running in its Docker container (local/internal host) +2. ✓ Packages pushed end-to-end via Intune **and** NinjaRMM to real endpoints +3. ✓ This document captures structured technician feedback +4. ✓ All feedback items triaged (zero items — nothing outstanding) diff --git a/.planning/milestones/v1.0-MILESTONE-AUDIT.md b/.planning/milestones/v1.0-MILESTONE-AUDIT.md new file mode 100644 index 0000000..50124e7 --- /dev/null +++ b/.planning/milestones/v1.0-MILESTONE-AUDIT.md @@ -0,0 +1,213 @@ +--- +milestone: v1.0 +audited: 2026-04-13T00:00:00Z +status: passed +re_audit: true +previous_audit: 2026-04-10T15:00:00Z +fix_pass: 2026-04-13 +scores: + requirements: 27/27 + phases: 7/7 + integration: 7/7 + flows: 4/4 + tests: 100/100 +gaps: + requirements: [] + integration: [] + flows: [] +tech_debt: + - phase: 02-driver-management + items: + - "Printer form driver dropdown requires manual page reload after uploading a new driver on /drivers" + - phase: 03-printer-configuration + items: + - "PRNT-03 Alpine.js port auto-derivation requires human browser verification" + - phase: 05-package-export + items: + - "No UI links to download individual scripts (/printers/{id}/scripts/*) — only accessible via package export or direct URL" +fixes_applied_2026-04-13: + - "BLOCKER: DriverStore saved files at {sha256} but packages.py looked up {sha256}.zip — upload→export flow was broken in production, masked by test_packages.py pre-staging fixtures. Fixed by centralizing path in DriverStore.get_path() with .zip suffix; packages.py now uses DriverStore.get_path(). Added tests/test_upload_export_roundtrip.py to prevent regression." + - "Peewee datetime.utcnow() deprecation originated in imptune/db/models.py (not library-level as previously assessed). Replaced with _utcnow() helper using datetime.now(UTC). Deprecation warning eliminated." + - "printer_detail.html uninstall copy button label fixed ('Uninstall copy' → 'Copy')." + - "SUMMARY.md frontmatter requirements-completed back-filled on 4 plans (02-01:DRV-02, 04-01:SCRPT-01/04/05, 05-01:PKG-01/02/03, 06-01:PKG-04)." +nyquist: + compliant_phases: [] + partial_phases: [1, 2, 3, 4, 5, 6, 7] + missing_phases: [] + overall: partial +--- + +# v1.0 Milestone Audit Report (Re-Audit) + +**Milestone:** v1.0 — ImpTune Printer Deployment Package Generator +**Re-audited:** 2026-04-13 +**Previous audit:** 2026-04-10 (status: gaps_found) +**Status:** PASSED +**Score:** 27/27 requirements satisfied + +The previous audit identified PKG-04 as unsatisfied (icon stored but never embedded) and three cross-phase integration breaks. Phases 6 (`06-wire-icon-intunewin`) and 7 (`07-dashboard-nav-polish`) were planned and executed to close every gap. This re-audit confirms all blockers are resolved. + +--- + +## Gap Closure Summary + +| Original Gap | Closure Phase | Status | +|---|---|---| +| PKG-04 — icon never embedded in .intunewin | Phase 6 | CLOSED | +| `icons.py` → `packages.py` integration break | Phase 6 | CLOSED | +| `base.html` → `/packages` 404 (no route) | Phase 7 | CLOSED | +| Dashboard `recent_printers`/`recent_packages` hardcoded `[]` | Phase 7 | CLOSED | +| Icon → .intunewin embedding flow broken | Phase 6 | CLOSED | + +Evidence: +- [imptune/api/packages.py:149-157](imptune/api/packages.py#L149-L157) — `Icon.get_or_none(...)` lookup, `shutil.copy2()` to `tmpdir/icon.png`, then `build_intunewin()`. +- [imptune/api/pages.py:142-158](imptune/api/pages.py#L142-L158) — `GET /packages` route renders driver-assigned printers from real DB query. +- [imptune/api/pages.py:20-28](imptune/api/pages.py#L20-L28) — dashboard `recent_printers` / `recent_packages` queries replace hardcoded lists. +- [imptune/templates/packages.html](imptune/templates/packages.html) — listing template extending base.html. + +--- + +## Requirements Coverage (3-Source Cross-Reference) + +All 27 v1 requirements verified across VERIFICATION.md, SUMMARY frontmatter, and REQUIREMENTS.md traceability table. + +### Infrastructure (Phase 1) + +| REQ-ID | Description | VERIFICATION | SUMMARY | REQUIREMENTS | Final | +|---|---|---|---|---|---| +| INFRA-01 | Single Docker container | passed | listed | [x] | **satisfied** | +| INFRA-02 | Minimal dependencies | passed | listed | [x] | **satisfied** | + +### Driver Management (Phase 2) + +| REQ-ID | Description | VERIFICATION | SUMMARY | REQUIREMENTS | Final | +|---|---|---|---|---|---| +| DRV-01 | Upload driver ZIP | passed | listed (02-02) | [x] | **satisfied** | +| DRV-02 | Parse INF, extract DriverDesc | passed | missing | [x] | **satisfied** † | +| DRV-03 | Select from dropdown | passed | listed (02-02) | [x] | **satisfied** | +| DRV-04 | Persisted on volume | passed | listed (02-02) | [x] | **satisfied** | +| DRV-05 | Flag unused files | passed | listed (02-02) | [x] | **satisfied** | + +### Printer Configuration (Phase 3) + +| REQ-ID | Description | VERIFICATION | SUMMARY | REQUIREMENTS | Final | +|---|---|---|---|---|---| +| PRNT-01..09 | Form fields, persistence, client assignment | passed | listed (03-01) | [x] | **satisfied** | +| PRNT-03 | Auto-suggest port from IP | human_needed | listed (03-01) | [x] | **satisfied** ‡ | +| PRNT-10 | Regenerate from saved config | passed | listed (03-02) | [x] | **satisfied** | + +### Script Generation (Phase 4) + +| REQ-ID | Description | VERIFICATION | SUMMARY | REQUIREMENTS | Final | +|---|---|---|---|---|---| +| SCRPT-01 | Install script | passed | missing | [x] | **satisfied** † | +| SCRPT-02 | Uninstall script | passed | listed (04-02) | [x] | **satisfied** | +| SCRPT-03 | Detection script | passed | listed (04-02) | [x] | **satisfied** | +| SCRPT-04 | UAC self-elevation | passed | missing | [x] | **satisfied** † | +| SCRPT-05 | WOW64 relaunch guard | passed | missing | [x] | **satisfied** † | + +### Package Export (Phases 5 + 6) + +| REQ-ID | Description | VERIFICATION | SUMMARY | REQUIREMENTS | Final | +|---|---|---|---|---|---| +| PKG-01 | Export .intunewin | passed | missing | [x] | **satisfied** † | +| PKG-02 | Python-native intunewin | passed | missing | [x] | **satisfied** † | +| PKG-03 | Export NinjaRMM ZIP | passed | missing | [x] | **satisfied** † | +| PKG-04 | Icon embedded in .intunewin | **passed (Phase 6)** | listed (05-02) | [x] | **satisfied** | +| PKG-05 | Preview/copy commands | passed | listed (05-02) | [x] | **satisfied** | + +† VERIFICATION.md + REQUIREMENTS.md both confirm satisfied; only SUMMARY frontmatter is stale (documentation debt — see below). +‡ Browser-only Alpine.js behavior; code path verified, runtime check pending live demo. + +--- + +## Cross-Phase Integration + +All wiring confirmed by integration checker (re-audit 2026-04-13): + +| From | To | Via | Status | +|---|---|---|---| +| `inf_parser.py` (Ph2) | `drivers.py` (Ph2) | `parse_inf()` import | WIRED | +| `driver_store.py` (Ph1) | `drivers.py` (Ph2) | `DriverStore.save()` | WIRED | +| `intunewin_builder.py` (Ph1) | `packages.py` (Ph5) | `build_intunewin()` import | WIRED | +| `script_generator.py` (Ph4) | `scripts.py`, `packages.py` | `render_*()` imports | WIRED | +| `Icon` model (Ph5) | `packages.py` `get_intunewin_package()` | `Icon.get_or_none(...)` + `shutil.copy2` | **WIRED (Ph6)** | +| `base.html` nav | `/packages` route | `pages.packages_page` | **WIRED (Ph7)** | +| `pages.py` dashboard | Printer DB queries | live `select().order_by(...).limit(5)` | **WIRED (Ph7)** | +| All routers (8) | `main.py` | `app.include_router()` | WIRED | + +No broken wiring remains. + +--- + +## E2E Flow Verification + +| Flow | Status | Notes | +|---|---|---| +| Driver upload → printer create → script generate → package export | COMPLETE | — | +| Driver upload → INF parsing → driver dropdown → printer form → save → detail | COMPLETE | — | +| Printer detail → NinjaRMM ZIP + .intunewin downloads | COMPLETE | — | +| Icon upload → embedded in .intunewin package | **COMPLETE** | Closed by Phase 6 | + +--- + +## Phase Verification Summary + +| Phase | Status | Score | Notes | +|---|---|---|---| +| 01 Foundation | passed | 13/13 | — | +| 02 Driver Management | passed | 16/16 | — | +| 03 Printer Configuration | human_needed | 9/10 | PRNT-03 Alpine.js — code correct, runtime needs browser | +| 04 Script Generation | passed | 12/12 | — | +| 05 Package Export | passed | 11/11 | (PKG-04 integration completed in Phase 6) | +| 06 Wire Icon into .intunewin | passed | 2/2 | Closes PKG-04 | +| 07 Dashboard & Nav Polish | passed | 4/4 | Closes 2 integration gaps | + +--- + +## Nyquist Compliance + +| Phase | VALIDATION.md | Compliant | Wave 0 | Action | +|---|---|---|---|---| +| 1 Foundation | exists | false | false | `/gsd:validate-phase 1` | +| 2 Driver Management | exists | false | false | `/gsd:validate-phase 2` | +| 3 Printer Configuration | exists | false | false | `/gsd:validate-phase 3` | +| 4 Script Generation | exists | false | false | `/gsd:validate-phase 4` | +| 5 Package Export | exists | false | false | `/gsd:validate-phase 5` | +| 6 Wire Icon | exists | false | false | `/gsd:validate-phase 6` | +| 7 Dashboard Polish | exists | false | false | `/gsd:validate-phase 7` | + +All 7 phases have draft VALIDATION.md files but none are Nyquist-compliant. Wave 0 not complete for any phase. Not a blocker for milestone completion — this is a separate validation track. + +--- + +## Tech Debt Summary (Non-Blockers) + +### Phase 2: Driver Management +- Peewee `datetime.utcnow()` deprecation warning (library-level, Python 3.12+) +- Printer form driver dropdown requires manual page reload after new driver upload + +### Phase 3: Printer Configuration +- PRNT-03 Alpine.js port auto-derivation needs live browser verification + +### Phase 5: Package Export +- `DriverStore.get_path()`/`.exists()` defined but unused — `packages.py` builds path manually +- Copy button label inconsistency ("Uninstall copy" vs "Copy") +- No UI links to individual script downloads + +**Total: 6 items across 3 phases** (down from 11 — Phase 1 nav/dashboard items closed by Phase 7, icons.py path constant resolved by Phase 6). + +### Documentation Debt + +`SUMMARY.md` frontmatter `requirements-completed` lists are stale on 5 plans (02-01, 04-01, 05-01, 06-01, 07-01). VERIFICATION.md and REQUIREMENTS.md traceability table confirm all 7 affected requirements (DRV-02, SCRPT-01/04/05, PKG-01/02/03) are satisfied — only the frontmatter index is outdated. Cosmetic; can be back-filled during cleanup. + +--- + +## Orphaned Requirements + +None. All 27 v1 requirements appear in the traceability table and have corresponding entries in phase VERIFICATION.md files. + +--- + +_Re-audited: 2026-04-13_ +_Auditor: Claude (audit-milestone workflow)_ diff --git a/.planning/milestones/v1.0-REQUIREMENTS.md b/.planning/milestones/v1.0-REQUIREMENTS.md new file mode 100644 index 0000000..03452d9 --- /dev/null +++ b/.planning/milestones/v1.0-REQUIREMENTS.md @@ -0,0 +1,119 @@ +# Requirements Archive: v1.0 ImpTune MVP + +**Archived:** 2026-04-13 (milestone shipped) +**Originally defined:** 2026-04-10 +**Core Value:** Generate a complete, working printer deployment package (script + drivers + icon) in minutes instead of manually scripting each printer setup. + +> This is a frozen snapshot of requirements as they stood at v1.0 completion. The working `.planning/REQUIREMENTS.md` will be recreated fresh for v1.1. + +## v1 Requirements — Final Status + +**27/27 satisfied.** Audit re-ran 2026-04-13, status `passed`. + +### Driver Management + +- [x] **DRV-01**: User can upload a driver package (ZIP containing INF + supporting files) — *shipped Phase 2* +- [x] **DRV-02**: System parses uploaded INF files and extracts valid driver names (DriverDesc) — *shipped Phase 2* +- [x] **DRV-03**: User can select driver name from parsed INF dropdown (no free-text) — *shipped Phase 2* +- [x] **DRV-04**: Driver packages are persisted on Docker volume across container restarts — *shipped Phase 2* +- [x] **DRV-05**: System flags unused files in driver packages to help reduce package size — *shipped Phase 2* + +### Printer Configuration + +- [x] **PRNT-01**: User can set printer display name — *shipped Phase 3* +- [x] **PRNT-02**: User can set printer IP address or hostname — *shipped Phase 3* +- [x] **PRNT-03**: System auto-suggests port name from IP (user can override) — *shipped Phase 3 (code verified, runtime browser verification pending — tech debt into v1.1)* +- [x] **PRNT-04**: User can set duplex mode (one-sided, long-edge, short-edge) — *shipped Phase 3* +- [x] **PRNT-05**: User can set color vs. grayscale default — *shipped Phase 3* +- [x] **PRNT-06**: User can set paper size (A4, Letter, Legal at minimum) — *shipped Phase 3* +- [x] **PRNT-07**: User can set collate on/off — *shipped Phase 3* +- [x] **PRNT-08**: User can assign printer to a client/tenant label — *shipped Phase 3* +- [x] **PRNT-09**: Printer configurations are persisted in SQLite across sessions — *shipped Phase 3* +- [x] **PRNT-10**: User can regenerate a package from saved config without re-uploading drivers — *shipped Phase 3* + +### Script Generation + +- [x] **SCRPT-01**: PowerShell install script (pnputil + Add-PrinterPort + Add-PrinterDriver + Add-Printer + Set-PrintConfiguration) — *shipped Phase 4* +- [x] **SCRPT-02**: PowerShell uninstall script (Remove-Printer + Remove-PrinterDriver + Remove-PrinterPort) — *shipped Phase 4* +- [x] **SCRPT-03**: Intune detection script — *shipped Phase 4* +- [x] **SCRPT-04**: Install script detects SYSTEM vs. user context and self-elevates via UAC — *shipped Phase 4* +- [x] **SCRPT-05**: Install script includes WOW64 64-bit relaunch guard for Intune's 32-bit execution context — *shipped Phase 4* + +### Package Export + +- [x] **PKG-01**: User can export a complete .intunewin package — *shipped Phase 5* +- [x] **PKG-02**: .intunewin is generated natively in Python (no IntuneWinAppUtil.exe) — *shipped Phase 5* +- [x] **PKG-03**: User can export a NinjaRMM ZIP package — *shipped Phase 5* +- [x] **PKG-04**: User can upload a custom PNG icon and it is embedded in the .intunewin package — *shipped Phase 5 + wired in Phase 6 (gap closure)* +- [x] **PKG-05**: User can preview and copy Intune install/uninstall command strings before export — *shipped Phase 5* + +### Infrastructure + +- [x] **INFRA-01**: Application runs as a single Docker container — *shipped Phase 1* +- [x] **INFRA-02**: Application has minimal runtime dependencies (no Node.js, no external DB) — *shipped Phase 1* + +## Out of Scope (v1.0 decisions — carry forward unless revisited) + +| Feature | Reason | +|---------|--------| +| User authentication / logins | Internal tool on private network; simplicity over security | +| Direct Intune API push | Requires per-tenant OAuth, multi-tenant app registration — scope explosion | +| Direct NinjaRMM API push | Same as Intune — keep the tool as a package generator | +| Real-time printer status / monitoring | Requires SNMP polling and network access to client sites — different product | +| Universal Print integration | Different deployment model, requires Azure subscription | +| Mobile / tablet UI | Target users are at workstations; no validated demand | +| Multi-language / localization | English only for v1; no demand signal | +| Full audit log / deployment history | MSPs already have Intune/RMM logs | + +## v2 Requirements (deferred — not touched in v1.0) + +### Bulk Operations + +- **BULK-01**: User can import multiple printers from CSV +- **BULK-02**: User can export all printers for a client as a batch + +### Advanced Features + +- **ADV-01**: Package version history per printer +- **ADV-02**: API / CLI mode for CI/CD integration +- **ADV-03**: Print server migration path (Printbrm import) + +## Traceability (final) + +| Requirement | Phase | Status | +|-------------|-------|--------| +| INFRA-01 | Phase 1 | Complete | +| INFRA-02 | Phase 1 | Complete | +| DRV-01 | Phase 2 | Complete | +| DRV-02 | Phase 2 | Complete | +| DRV-03 | Phase 2 | Complete | +| DRV-04 | Phase 2 | Complete | +| DRV-05 | Phase 2 | Complete | +| PRNT-01 | Phase 3 | Complete | +| PRNT-02 | Phase 3 | Complete | +| PRNT-03 | Phase 3 | Complete (runtime verification pending) | +| PRNT-04 | Phase 3 | Complete | +| PRNT-05 | Phase 3 | Complete | +| PRNT-06 | Phase 3 | Complete | +| PRNT-07 | Phase 3 | Complete | +| PRNT-08 | Phase 3 | Complete | +| PRNT-09 | Phase 3 | Complete | +| PRNT-10 | Phase 3 | Complete | +| SCRPT-01 | Phase 4 | Complete | +| SCRPT-02 | Phase 4 | Complete | +| SCRPT-03 | Phase 4 | Complete | +| SCRPT-04 | Phase 4 | Complete | +| SCRPT-05 | Phase 4 | Complete | +| PKG-01 | Phase 5 | Complete | +| PKG-02 | Phase 5 | Complete | +| PKG-03 | Phase 5 | Complete | +| PKG-04 | Phase 5 + Phase 6 | Complete (Phase 6 gap closure) | +| PKG-05 | Phase 5 | Complete | + +**Coverage:** +- v1 requirements: 27 total +- Satisfied: 27 +- Unmapped: 0 + +--- +*Archived 2026-04-13 on v1.0 milestone completion. See `.planning/milestones/v1.0-ROADMAP.md` for phase details and `.planning/milestones/v1.0-MILESTONE-AUDIT.md` for audit report.* diff --git a/.planning/milestones/v1.0-ROADMAP.md b/.planning/milestones/v1.0-ROADMAP.md new file mode 100644 index 0000000..8678ae5 --- /dev/null +++ b/.planning/milestones/v1.0-ROADMAP.md @@ -0,0 +1,141 @@ +# Milestone v1.0: ImpTune MVP + +**Status:** ✅ SHIPPED 2026-04-13 +**Phases:** 1-7 +**Total Plans:** 13 +**Timeline:** 2026-04-10 → 2026-04-13 (4 days) + +## Overview + +Initial release of ImpTune — a self-hosted single-container webapp that lets IT technicians configure printer deployments and export ready-to-deploy packages for Microsoft Intune (.intunewin) or NinjaRMM (ZIP). Ships driver ZIP upload with INF parsing, full printer configuration with client/tenant grouping, PowerShell script generation (install/uninstall/detect) with UAC elevation and WOW64 guards, Python-native .intunewin assembly with embedded icon, and NinjaRMM ZIP export — all behind a no-auth HTMX/Alpine.js browser UI. + +## Phases + +### Phase 1: Foundation + +**Goal**: A running Docker container with the app scaffold, data schema, and validated .intunewin generation capability +**Depends on**: Nothing +**Requirements**: INFRA-01, INFRA-02 +**Plans**: 3 plans + +Plans: + +- [x] 01-01: Docker container scaffold (python:3.12-slim-bookworm, FastAPI, Jinja2, HTMX, Alpine.js, Pico CSS, offline static baking, healthcheck, sidebar nav shell) +- [x] 01-02: SQLite schema — Peewee WAL mode, full 4-table ORM (Client/Driver/Printer/Icon) created upfront, SHA256 content-addressed DriverStore, auto-init via lifespan +- [x] 01-03: Python-native .intunewin format spike — `build_intunewin()` with AES-256-CBC, HMAC-SHA256, detection.xml, 14 byte-level validation tests + +### Phase 2: Driver Management + +**Goal**: Technicians upload driver packages and select driver names from parsed INF data — no free-text entry +**Depends on**: Phase 1 +**Requirements**: DRV-01, DRV-02, DRV-03, DRV-04, DRV-05 +**Plans**: 2 plans + +Plans: + +- [x] 02-01: INF parser service — TDD, RawConfigParser(strict=False), BOM/UTF-16 detection, %TOKEN% resolution, multi-model support, unused-files detection +- [x] 02-02: Driver upload endpoint + drivers page — POST /drivers/upload with ZIP validation, SHA256 dedup, Peewee persistence, HTMX partial refresh, 8 integration tests + +### Phase 3: Printer Configuration + +**Goal**: Technicians configure all printer parameters, assign printers to clients, and regenerate saved configs without re-uploading drivers +**Depends on**: Phase 2 +**Requirements**: PRNT-01 through PRNT-10 +**Plans**: 2 plans + +Plans: + +- [x] 03-01: Printer + Client CRUD — form with all fields, Alpine.js IP→port auto-derivation (preserves manual edits), grouped list with LEFT OUTER JOIN, HTMX outerHTML swap, integration tests covering PRNT-01..09 +- [x] 03-02: Printer detail page — full-page template with all config fields, driver association, regenerate placeholder, clickable links in printer list + +### Phase 4: Script Generation + +**Goal**: System produces correct, production-ready PowerShell scripts handling all Intune and RMM execution contexts +**Depends on**: Phase 3 +**Requirements**: SCRPT-01, SCRPT-02, SCRPT-03, SCRPT-04, SCRPT-05 +**Plans**: 2 plans + +Plans: + +- [x] 04-01: `render_install()` with Jinja2 template — WOW64 64-bit relaunch guard, UAC self-elevation, pnputil two-step staging, duplex mapping, idempotency, plain-string args for DB-free unit testability +- [x] 04-02: `render_uninstall()` + `render_detect()` templates, 3 script download endpoints (/install, /uninstall, /detect), `_get_printer_and_driver()` shared helper, PlainTextResponse with Content-Disposition + +### Phase 5: Package Export + +**Goal**: Technicians download a complete, ready-to-deploy package for either Intune or NinjaRMM in one click +**Depends on**: Phase 4 +**Requirements**: PKG-01, PKG-02, PKG-03, PKG-04, PKG-05 +**Plans**: 2 plans + +Plans: + +- [x] 05-01: `/printers/{id}/packages/ninja` + `/packages/intunewin` endpoints — in-memory ZIP assembly with BytesIO, TemporaryDirectory staging for intunewin, driver ZIP existence validation +- [x] 05-02: Icon upload with Pillow validation (PNG 256x256 ≤750KB), SHA256-addressed icon storage, printer detail page with Intune Commands section (copy buttons), Export section, Icon Upload form + +### Phase 6: Wire Icon into .intunewin Export (gap closure) + +**Goal**: Uploaded PNG icon is embedded in the .intunewin package so Intune displays it as the app icon +**Depends on**: Phase 5 +**Requirements**: PKG-04 (closes gap from first audit) +**Plans**: 1 plan + +Plans: + +- [x] 06-01: Wire `Icon.get_or_none()` lookup into `packages.py`, `shutil.copy2()` icon to tmpdir as `icon.png`, silent-skip on missing record/file, integration test verifying icon presence in exported package + +**Details:** Added as gap-closure phase after first milestone audit flagged PKG-04 as unsatisfied — icon was uploaded and stored but never embedded in the .intunewin output. + +### Phase 7: Dashboard & Navigation Polish (gap closure) + +**Goal**: Navigation links work correctly and dashboard shows real data instead of empty placeholders +**Depends on**: Phase 3 +**Requirements**: None (UX/integration fixes) +**Plans**: 1 plan + +Plans: + +- [x] 07-01: Add `GET /packages` route (LEFT OUTER join on Client + Driver, `switch(Printer)`), wire dashboard `recent_printers` / `recent_packages` to live DB queries, new `packages.html` template, clickable nav links + +**Details:** Added as gap-closure phase after first milestone audit flagged `base.html → /packages` 404 (route missing) and hardcoded `[]` in dashboard queries. + +--- + +## Milestone Summary + +**Key Decisions:** + +- **Python-native .intunewin** — IntuneWinAppUtil.exe is a Windows PE binary, cannot run in Linux container. Reimplemented the AES-256-CBC / HMAC-SHA256 format in Python with pycryptodome, validated byte-level against the C# reference. ✓ Good +- **Stack:** Python 3.12 + FastAPI + Jinja2 + HTMX + Alpine.js + SQLite + Peewee + pycryptodome + Pillow. ✓ Good — minimal runtime, single container, no Node.js +- **Full 4-table schema upfront (Phase 1)** — Client/Driver/Printer/Icon all created in 01-02 so later phases add routes only, no schema migrations. ✓ Good +- **Sync FastAPI routes** — runs in thread pool, Peewee-compatible without async ORM complexity. ✓ Good +- **Content-addressed storage** — SHA256 for drivers and icons, deduplication for free, consistent pattern. ✓ Good +- **Plain-string args for script generators** (not ORM objects) — keeps unit tests DB-free. ✓ Good +- **Test isolation** — TestClient used as context manager for Starlette 0.46+ lifespan; thread-local Peewee connections closed in conftest teardown; `list(Model.select())` wrapper avoids cursor caching across DB re-inits. ✓ Good +- **Silent-skip on missing icon** — export always succeeds regardless of icon presence, optional feature. ✓ Good +- **Gap-closure phases 6 & 7** — added post-audit rather than shipping with known defects; cleaner than carrying PKG-04 and /packages 404 as tech debt into v1.1. ✓ Good + +**Issues Resolved:** + +- Peewee `datetime.utcnow()` deprecation warning (root cause was project-level `_utcnow()` usage, not library) +- DriverStore path mismatch bug — `.zip` suffix inconsistency between save and lookup broke upload→export flow in production (masked by pre-staged test fixtures); centralized in `DriverStore.get_path()` and added `tests/test_upload_export_roundtrip.py` regression test +- Uninstall copy button mislabel ("Uninstall copy" → "Copy") +- Stale `requirements-completed` frontmatter in 5 SUMMARY.md files (back-filled) +- PKG-04 icon→.intunewin wiring break (Phase 6) +- `/packages` 404 + dashboard hardcoded `[]` (Phase 7) + +**Issues Deferred to v1.1 (Tech Debt):** + +- Printer form driver dropdown requires manual page reload after uploading a new driver on /drivers (Phase 2) +- PRNT-03 Alpine.js port auto-derivation — code correct, needs live browser verification (Phase 3) +- No UI links to individual script downloads — only accessible via package export or direct URL (Phase 5) + +**Nyquist Validation:** All 7 phases have draft VALIDATION.md files but none are Nyquist-compliant. Wave 0 not complete. Not a milestone blocker — separate validation track for v1.1. + +**Known Runtime Validations Pending:** + +- `.intunewin` byte-level format must be validated against a real Intune tenant +- `pnputil` + `$PSScriptRoot` path resolution under SYSTEM context on a real Intune-managed device + +--- + +*For current project status, see `.planning/ROADMAP.md`* diff --git a/.planning/milestones/v1.0-VALIDATION-INDEX.md b/.planning/milestones/v1.0-VALIDATION-INDEX.md new file mode 100644 index 0000000..31adf49 --- /dev/null +++ b/.planning/milestones/v1.0-VALIDATION-INDEX.md @@ -0,0 +1,124 @@ +--- +milestone: v1.0 +type: validation-index +audit_date: 2026-04-13 +auditor: Sébastien QUEROL +status: signed-off +signed_off_by: Sébastien QUEROL +signed_off_date: 2026-04-13 +--- + +# v1.0 Validation Index — Nyquist Rollup + +**Audit date:** 2026-04-13 +**Auditor:** Sébastien QUEROL (signed off 2026-04-13) +**Compiled by:** Claude (gsd-executor, plan 08-08) + +**Tally:** 45/45 pass, 0 deferred-v1.2, 0 fail-fix-v1.1, 0 wont-do + +Single flat pass/fail roll-up of every v1.0 success criterion across Phases 1–7. Source of truth for NYQ-02 and NYQ-03. Per-phase Nyquist Records are embedded in each `NN-VALIDATION.md` and enumerated one row per criterion below. Row counts per phase (14, 6, 10, 5, 5, 1, 4) are a function of scope — single-criterion gap-closure phases legitimately produce single-row audits. + +**Key roll-up facts (for downstream verifier):** +- **Phase 5 row 2 (PKG-02) is the only artifact-backed live-tenant runtime row** in the entire track — cites RTVAL-01 PASS on tenant rubis.fr (2026-04-13) after structural fixes in commits `74535ea` + `7716246`. +- **Phase 4 rows 1–5 (SCRPT-01..05) rest on attestation-only runtime proof** (RTVAL-02/03/04, three consecutive attestation-only PASSes per STATE.md 2026-04-13). User was warned twice about cumulative audit-trail damage and explicitly approved proceeding. Phase 10 plan 10-03 signed off with written acknowledgement. Re-capture with full artifacts owned by Phase 11 rollout. +- **Phase 1 row 14, Phase 2 row 6, Phase 3 row 3, Phase 5 row 4, Phase 7 row 4** are historical-gap closure rows — all resolved in place via fixing phases (Phase 9 UX-01/02/03, Phase 6 icon embedding, Phase 10 RTVAL-01) with direct commit citations, not flipped to fail-fix-v1.1. +- **Phase 7 is the only REQUIREMENTS-free phase**, anchored to `07-VERIFICATION.md` truths rather than `REQUIREMENTS.md` IDs. Legitimate alternate anchoring pattern. +- **Bidirectional citation loop:** 05-VALIDATION row 4 ↔ 06-VALIDATION row 1 both cite `TestIntunewinIconInclusion` — closed-loop gap-closure pattern worth replicating for future gap-closure phases. + +## Flat Pass/Fail Table + +| Phase | # | Criterion | Status | Evidence | Gap Link | +|---|---|---|---|---|---| +| 1-Foundation | 1 | `docker compose up` serves HTTP 200 on `GET /health` | pass | [01-VALIDATION.md#nyquist-record](../phases/01-foundation/01-VALIDATION.md#nyquist-record) row 1 (test_health + 01-VERIFICATION row 1) | | +| 1-Foundation | 2 | No Node.js dependency; single `python:3.12-slim-bookworm` base image | pass | [01-VALIDATION.md row 2](../phases/01-foundation/01-VALIDATION.md#nyquist-record) (Dockerfile line 1, commit 34c7cb3) | | +| 1-Foundation | 3 | All static assets served from `/static/` with zero CDN refs | pass | [01-VALIDATION.md row 3](../phases/01-foundation/01-VALIDATION.md#nyquist-record) (test_no_cdn_urls_in_templates) | | +| 1-Foundation | 4 | Sidebar shows Dashboard / Drivers / Printers / Clients / Packages | pass | [01-VALIDATION.md row 4](../phases/01-foundation/01-VALIDATION.md#nyquist-record) (base.html nav + Phase 7 /packages closure) | | +| 1-Foundation | 5 | App follows OS dark/light theme | pass | [01-VALIDATION.md row 5](../phases/01-foundation/01-VALIDATION.md#nyquist-record) (base.html `data-theme="auto"`) | | +| 1-Foundation | 6 | SQLite initializes with all 4 tables on first run | pass | [01-VALIDATION.md row 6](../phases/01-foundation/01-VALIDATION.md#nyquist-record) (test_create_tables, commit 88d9c5f) | | +| 1-Foundation | 7 | DB uses WAL journal + foreign keys enabled | pass | [01-VALIDATION.md row 7](../phases/01-foundation/01-VALIDATION.md#nyquist-record) (test_wal_mode + test_foreign_keys) | | +| 1-Foundation | 8 | DB file lives in `DATA_DIR` volume, not container FS | pass | [01-VALIDATION.md row 8](../phases/01-foundation/01-VALIDATION.md#nyquist-record) (docker-compose volume + cfg.DB_PATH) | | +| 1-Foundation | 9 | Schema creation is idempotent across restarts | pass | [01-VALIDATION.md row 9](../phases/01-foundation/01-VALIDATION.md#nyquist-record) (test_idempotent, `safe=True`) | | +| 1-Foundation | 10 | Python function produces valid `.intunewin` from source dir + setup file | pass | [01-VALIDATION.md row 10](../phases/01-foundation/01-VALIDATION.md#nyquist-record) (test_output_is_valid_zip, commit 25f82e6) | | +| 1-Foundation | 11 | `.intunewin` outer ZIP has correct `IntuneWinPackage/` structure | pass | [01-VALIDATION.md row 11](../phases/01-foundation/01-VALIDATION.md#nyquist-record) (test_outer_zip_structure) | | +| 1-Foundation | 12 | Encrypted blob byte layout: HMAC(32) + IV(16) + AES-256-CBC ciphertext | pass | [01-VALIDATION.md row 12](../phases/01-foundation/01-VALIDATION.md#nyquist-record) (3 byte-layout tests) | | +| 1-Foundation | 13 | Detection.xml cryptographic fields match actual encryption | pass | [01-VALIDATION.md row 13](../phases/01-foundation/01-VALIDATION.md#nyquist-record) (5 crypto-field tests) | | +| 1-Foundation | 14 | `.intunewin` accepted by real Intune tenant end-to-end | pass | [01-VALIDATION.md row 14](../phases/01-foundation/01-VALIDATION.md#nyquist-record) → Phase 10 `RUNTIME-VALIDATION.md` RTVAL-01 PASS 2026-04-13 on rubis.fr (commits 74535ea + 7716246) | | +| 2-Drivers | 1 | **DRV-01** User uploads driver package (ZIP+INF) via web UI | pass | [02-VALIDATION.md row 1](../phases/02-driver-management/02-VALIDATION.md#nyquist-record) (test_upload_valid_zip/non_zip/no_inf, commit c648fc5) | | +| 2-Drivers | 2 | **DRV-02** INF parser extracts DriverDesc with encoding + token handling | pass | [02-VALIDATION.md row 2](../phases/02-driver-management/02-VALIDATION.md#nyquist-record) (16 tests in test_inf_parser.py) | | +| 2-Drivers | 3 | **DRV-03** User selects driver name from parsed-INF dropdown (no free-text) | pass | [02-VALIDATION.md row 3](../phases/02-driver-management/02-VALIDATION.md#nyquist-record) (test_drivers_page + test_upload_returns_select) | | +| 2-Drivers | 4 | **DRV-04** Uploaded driver ZIP persisted content-addressed; dedupes on re-upload | pass | [02-VALIDATION.md row 4](../phases/02-driver-management/02-VALIDATION.md#nyquist-record) (test_driver_persisted + test_dedup_upload) | | +| 2-Drivers | 5 | **DRV-05** System flags unused files not referenced by INF | pass | [02-VALIDATION.md row 5](../phases/02-driver-management/02-VALIDATION.md#nyquist-record) (test_unused_files + test_unused_files_in_response) | | +| 2-Drivers | 6 | **DRV-01 runtime gap:** `POST /drivers/upload` must not return 500 on real ZIPs | pass | [02-VALIDATION.md row 6](../phases/02-driver-management/02-VALIDATION.md#nyquist-record) → Phase 9 UX-01 commits d1de839 + 10ee09a + 72c6a98 | Historical gap closed in place via Phase 9 UX-01 (REQUIREMENTS.md UX-01 Complete). Resolved 2026-04-13. | +| 3-Printer | 1 | **PRNT-01** User sets printer display name | pass | [03-VALIDATION.md row 1](../phases/03-printer-configuration/03-VALIDATION.md#nyquist-record) (test_create_printer_persisted, commit 356c2ee) | | +| 3-Printer | 2 | **PRNT-02** User sets printer IP address or hostname | pass | [03-VALIDATION.md row 2](../phases/03-printer-configuration/03-VALIDATION.md#nyquist-record) (test_create_printer_persisted ip_address field) | | +| 3-Printer | 3 | **PRNT-03** System auto-suggests port name from IP; manual edits preserved | pass | [03-VALIDATION.md row 3](../phases/03-printer-configuration/03-VALIDATION.md#nyquist-record) → Phase 9 UX-02 Playwright commits 322fc20 + 37a06da | Historical gap closed in place via Phase 9 UX-02 (Playwright headless chromium e2e). Sole `NEEDS HUMAN` truth from 03-VERIFICATION.md 2026-04-10. | +| 3-Printer | 4 | **PRNT-04** User sets duplex mode (OneSided/LongEdge/ShortEdge) | pass | [03-VALIDATION.md row 4](../phases/03-printer-configuration/03-VALIDATION.md#nyquist-record) (test_create_printer_duplex) | | +| 3-Printer | 5 | **PRNT-05** User sets color vs. grayscale default | pass | [03-VALIDATION.md row 5](../phases/03-printer-configuration/03-VALIDATION.md#nyquist-record) (test_create_printer_color_mode) | | +| 3-Printer | 6 | **PRNT-06** User sets paper size (A4/Letter/Legal) | pass | [03-VALIDATION.md row 6](../phases/03-printer-configuration/03-VALIDATION.md#nyquist-record) (test_create_printer_paper_size) | | +| 3-Printer | 7 | **PRNT-07** User sets collate on/off | pass | [03-VALIDATION.md row 7](../phases/03-printer-configuration/03-VALIDATION.md#nyquist-record) (test_create_printer_collate) | | +| 3-Printer | 8 | **PRNT-08** User assigns printer to a client/tenant label | pass | [03-VALIDATION.md row 8](../phases/03-printer-configuration/03-VALIDATION.md#nyquist-record) (test_printer_grouped_by_client, LEFT OUTER join) | | +| 3-Printer | 9 | **PRNT-09** Printer configurations persist across SQLite sessions | pass | [03-VALIDATION.md row 9](../phases/03-printer-configuration/03-VALIDATION.md#nyquist-record) (test_printer_survives_page_refresh) | | +| 3-Printer | 10 | **PRNT-10** Detail page loads full config with driver FK intact (Phase-3 scope) | pass | [03-VALIDATION.md row 10](../phases/03-printer-configuration/03-VALIDATION.md#nyquist-record) (test_printer_detail_shows_driver + no_driver) | | +| 4-Scripts | 1 | **SCRPT-01** Install script: pnputil + Add-Printer* + Set-PrintConfiguration | pass | [04-VALIDATION.md row 1](../phases/04-script-generation/04-VALIDATION.md#nyquist-record) (test_render_install_* + RTVAL-02 attestation-only) | Runtime half is attestation-only per STATE.md 2026-04-13 — no IntuneManagementExtension.log excerpt or portal screenshot. Phase 11 rollout owns artifact re-capture. Template correctness fully pytest-automated. | +| 4-Scripts | 2 | **SCRPT-02** Uninstall script: Remove-Printer → Remove-PrinterDriver → Remove-PrinterPort | pass | [04-VALIDATION.md row 2](../phases/04-script-generation/04-VALIDATION.md#nyquist-record) (test_render_uninstall + RTVAL-04 attestation-only) | Third consecutive attestation-only check; no rtval-04-uninstall-log.txt or rtval-04-uninstall-status.png captured. Phase 10 plan 10-03 signed off with written acknowledgement. Phase 11 owns re-capture. | +| 4-Scripts | 3 | **SCRPT-03** Detect script: exit 0 when present / exit 1 when absent | pass | [04-VALIDATION.md row 3](../phases/04-script-generation/04-VALIDATION.md#nyquist-record) (test_render_detect + RTVAL-03 attestation-only) | Second consecutive attestation-only check; no rtval-03-detection.png captured. REQUIREMENTS.md wording ("registry check") superseded by 04-RESEARCH.md decision to use `Get-Printer` cmdlet. Phase 11 owns re-capture. | +| 4-Scripts | 4 | **SCRPT-04** Install script detects SYSTEM vs user and self-elevates via UAC | pass | [04-VALIDATION.md row 4](../phases/04-script-generation/04-VALIDATION.md#nyquist-record) (test_render_install_uac_guard + RTVAL-02 SYSTEM branch) | SYSTEM branch exercised attestation-only in RTVAL-02; user-interactive UAC dialog branch was NOT exercised in Phase 10 at all — remains a Manual-Only Verification. | +| 4-Scripts | 5 | **SCRPT-05** Install script includes 64-bit WOW64 SysNative relaunch guard | pass | [04-VALIDATION.md row 5](../phases/04-script-generation/04-VALIDATION.md#nyquist-record) (test_render_install_wow64_guard + RTVAL-02 attestation-only) | WOW64 relaunch path not directly observable from RTVAL-02 attestation; template-level positional correctness (guard before pnputil) is fully pytest-automated. Full WOW64 trace is a Phase 11 rollout concern. | +| 5-Package | 1 | **PKG-01** User exports full `.intunewin` package in one click | pass | [05-VALIDATION.md row 1](../phases/05-package-export/05-VALIDATION.md#nyquist-record) (TestIntunewinDownload 4 tests + RTVAL-01 artifact-backed PASS on rubis.fr) | | +| 5-Package | 2 | **PKG-02** `.intunewin` generated natively in Python (no IntuneWinAppUtil.exe); byte-level conformant | pass | [05-VALIDATION.md row 2](../phases/05-package-export/05-VALIDATION.md#nyquist-record) (14 byte-level tests in test_intunewin.py + **artifact-backed** RTVAL-01 PASS after fix commits 74535ea + 7716246) | **Strongest row in the entire 7-phase track** — only artifact-backed live-tenant runtime evidence. Initial RTVAL-01 FAILED; root cause was two structural defects fixed in commits `74535ea` (HMAC over IV+ciphertext) + `7716246` (Detection.xml alignment with IntuneWinAppUtil.exe reference format); re-test PASSED on tenant rubis.fr with committed screenshots + package. | +| 5-Package | 3 | **PKG-03** User exports NinjaRMM ZIP package in one click | pass | [05-VALIDATION.md row 3](../phases/05-package-export/05-VALIDATION.md#nyquist-record) (TestNinjaDownload 5 tests) | | +| 5-Package | 4 | **PKG-04** User uploads custom PNG icon; embedded into `.intunewin` | pass | [05-VALIDATION.md row 4](../phases/05-package-export/05-VALIDATION.md#nyquist-record) → Phase 6 `TestIntunewinIconInclusion` (commits 2723cc8 + 6310be5) | Historical gap closed in place via Phase 6 (Wire Icon into .intunewin Export). Upload half shipped in Phase 5 plan 02; embedding half added in Phase 6. Bidirectional citation loop with 06-VALIDATION row 1. | +| 5-Package | 5 | **PKG-05** User previews and copies Intune install/uninstall command strings before export | pass | [05-VALIDATION.md row 5](../phases/05-package-export/05-VALIDATION.md#nyquist-record) (TestCommandPreview 4 tests) | | +| 6-Icon-Wire | 1 | **PKG-04 embedding:** Uploaded PNG icon embedded in `.intunewin` output | pass | [06-VALIDATION.md row 1](../phases/06-wire-icon-intunewin/06-VALIDATION.md#nyquist-record) (TestIntunewinIconInclusion 2 tests, `shutil.copy2` staging at packages.py:153 before build_intunewin at :157; commits 2723cc8 + 6310be5; RTVAL-01 transitive) | Bidirectional closure loop with 05-VALIDATION row 4. Icon-tile visual rendering on Intune portal is Manual-Only polish owned by Phase 11 rollout. | +| 7-Dashboard | 1 | `GET /packages` returns 200 and lists driver-assigned printers (closes milestone-audit /packages 404) | pass | [07-VALIDATION.md row 1](../phases/07-dashboard-nav-polish/07-VALIDATION.md#nyquist-record) (test_packages_returns_200, pages.py:142-158, commits 8cf47f5 + 91910ad) | | +| 7-Dashboard | 2 | Dashboard shows 5 most recent printers via live query | pass | [07-VALIDATION.md row 2](../phases/07-dashboard-nav-polish/07-VALIDATION.md#nyquist-record) (test_dashboard_shows_recent_printers, pages.py:20-22) | | +| 7-Dashboard | 3 | Dashboard shows 5 most recent packages (driver-filtered) via live query | pass | [07-VALIDATION.md row 3](../phases/07-dashboard-nav-polish/07-VALIDATION.md#nyquist-record) (test_dashboard_shows_recent_packages, pages.py:23-28 with `Printer.driver.is_null(False)` filter) | | +| 7-Dashboard | 4 | **UX-03 carry-over (Phase 5 origin):** Individual script download links on printer detail page | pass | [07-VALIDATION.md row 4](../phases/07-dashboard-nav-polish/07-VALIDATION.md#nyquist-record) → Phase 9 plan 09-03 commits d359001 + 68a2935 | Historical gap closed in place via Phase 9 UX-03 (.ps1 route aliases + printer_detail Scripts section). Provenance note: v1.0-ROADMAP.md lists UX-03 as Phase 5 deferral, not Phase 7 — STATE.md restatement imprecise; resolution unaffected. | + +## Gap Validation Block + +All five historical-gap rows cite fixing phases/commits. Each citation has been cross-checked against `REQUIREMENTS.md` and the fixing phase's SUMMARY.md: + +| # | Row | Recorded as | Citation target | Fix owner | Confirmed? | +|---|-----|-------------|-----------------|-----------|------------| +| 1 | Phase 1 row 14 (real Intune tenant ingestion) | pass | Phase 10 RTVAL-01 commits 74535ea + 7716246 | Phase 10 plan 10-02 | ✓ (REQUIREMENTS.md RTVAL-01 Complete; plan 10-03 sign-off commit cd2df1e) | +| 2 | Phase 2 row 6 (POST /drivers/upload 500) | pass | Phase 9 UX-01 commits d1de839 + 10ee09a + 72c6a98 | Phase 9 plan 09-01 | ✓ (REQUIREMENTS.md UX-01 Complete; 09-01-SUMMARY.md) | +| 3 | Phase 3 row 3 (PRNT-03 Alpine.js IP→port live-browser) | pass | Phase 9 UX-02 commits 322fc20 + 37a06da | Phase 9 plan 09-02 | ✓ (REQUIREMENTS.md UX-02 Complete; 09-02-SUMMARY.md) | +| 4 | Phase 5 row 4 / Phase 6 row 1 (PKG-04 icon embedding) | pass | Phase 6 TestIntunewinIconInclusion commits 2723cc8 + 6310be5 | Phase 6 plan 06-01 | ✓ (bidirectional closure loop confirmed between 05-VALIDATION row 4 and 06-VALIDATION row 1) | +| 5 | Phase 7 row 4 (UX-03 individual script downloads) | pass | Phase 9 plan 09-03 commits d359001 + 68a2935 | Phase 9 plan 09-03 | ✓ (REQUIREMENTS.md UX-03 Complete; 09-03-SUMMARY.md; provenance note flagged — Phase 5 origin per v1.0-ROADMAP.md) | + +**Result:** All 5 fail-fix-equivalent rows point to real fixing phases with shipped commits. **No roadmap-mismatch detected.** Zero rows inflated to `fail-fix-v1.1` because every historical gap is already closed in the tree. + +### Attestation-Gap Residual Risk (recorded for rollout) + +Phase 4 rows 1–5 (SCRPT-01..05) carry attestation-only runtime proof via RTVAL-02/03/04 per STATE.md 2026-04-13. This is **NOT** a fail-fix row (Phase 10 plan 10-03 explicitly signed off the gap with written acknowledgement; the user was warned twice and approved). It is documented here as a known weakness in the v1.0 runtime audit trail and is owned by **Phase 11 Real-World Rollout** for artifact re-capture (IntuneManagementExtension.log excerpt, portal screenshots, status captures) before broad deployment. Not in the above gap table because there is no "fix commit" — the fix is to re-run with full evidence capture, which is a rollout-phase action, not a code change. + +## Tally Summary + +| Source | Row count | +|---|---:| +| Phase 1 — Foundation | 14 | +| Phase 2 — Driver Management | 6 | +| Phase 3 — Printer Configuration | 10 | +| Phase 4 — Script Generation | 5 | +| Phase 5 — Package Export | 5 | +| Phase 6 — Wire Icon into .intunewin | 1 | +| Phase 7 — Dashboard & Nav Polish | 4 | +| **Total** | **45** | + +| Status | Count | +|---|---:| +| pass | 45 | +| fail-fix-v1.1 | 0 | +| deferred-v1.2 | 0 | +| wont-do | 0 | + +**NYQ-01 coverage:** 7/7 v1.0 phases have Nyquist-compliant `VALIDATION.md` files with one observable check per success criterion, evidence cited, no hand-wavy "code looks right" entries. Ticked in REQUIREMENTS.md after 08-01 / 08-05 / 08-06 / 08-07 executions. + +**NYQ-02 coverage:** This document is the single flat pass/fail rollup. Will be ticked in REQUIREMENTS.md upon sign-off. + +**NYQ-03 coverage:** Every non-pass row has a rationale. No rows are non-pass — all 45 rows are `pass`, all historical gaps closed in place with fixing-phase citations, all cross-checked against REQUIREMENTS.md and fixing-phase SUMMARY.md files. Will be ticked in REQUIREMENTS.md upon sign-off. + +## Sign-Off + +- [x] Nyquist audit complete — 2026-04-13 — Sébastien QUEROL diff --git a/.planning/phases/01-foundation/01-01-PLAN.md b/.planning/phases/01-foundation/01-01-PLAN.md new file mode 100644 index 0000000..5d3d933 --- /dev/null +++ b/.planning/phases/01-foundation/01-01-PLAN.md @@ -0,0 +1,238 @@ +--- +phase: 01-foundation +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - Dockerfile + - docker-compose.yml + - requirements.txt + - requirements-dev.txt + - imptune/main.py + - imptune/config.py + - imptune/api/__init__.py + - imptune/api/pages.py + - imptune/api/health.py + - imptune/templates/base.html + - imptune/templates/dashboard.html + - imptune/static/app.css + - tests/__init__.py + - tests/conftest.py + - tests/test_health.py + - tests/test_static.py +autonomous: true +requirements: + - INFRA-01 + - INFRA-02 + +must_haves: + truths: + - "Running docker compose up starts the app and serves HTTP 200 on GET /health" + - "The container has no Node.js dependency and starts from a single python:3.12-slim-bookworm image" + - "All static assets (Pico CSS, HTMX, Alpine.js) are served from /static/ with zero CDN references in templates" + - "The app shell displays a sidebar with Dashboard, Drivers, Printers, Clients, Packages sections" + - "The app follows OS dark/light theme preference automatically" + artifacts: + - path: "Dockerfile" + provides: "Single-container build with baked-in static assets" + contains: "python:3.12-slim-bookworm" + - path: "docker-compose.yml" + provides: "Container orchestration with named volume" + contains: "imptune_data:/data" + - path: "imptune/main.py" + provides: "FastAPI app entrypoint with static files mount and router registration" + exports: ["app"] + - path: "imptune/api/health.py" + provides: "GET /health endpoint for Docker healthcheck" + exports: ["router"] + - path: "imptune/templates/base.html" + provides: "Layout template with sidebar navigation and static asset includes" + contains: "data-theme=\"auto\"" + key_links: + - from: "Dockerfile" + to: "imptune/static/" + via: "curl downloads during build" + pattern: "curl.*pico\\.min\\.css" + - from: "imptune/main.py" + to: "imptune/api/health.py" + via: "include_router" + pattern: "include_router.*health" + - from: "imptune/templates/base.html" + to: "/static/" + via: "link and script tags" + pattern: "/static/.*\\.css|/static/.*\\.js" +--- + + +Create the Docker container scaffold, FastAPI app shell with sidebar navigation, health endpoint, and all baked-in static assets (Pico CSS, HTMX, Alpine.js). This is the foundation every subsequent plan builds on. + +Purpose: Establish the running container and app shell that satisfies INFRA-01 (single Docker container) and INFRA-02 (no Node.js, no external DB). All subsequent phases add features to this scaffold. +Output: A buildable Docker image that starts, serves the app shell on localhost:8000, and passes healthcheck. + + + +@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/01-foundation/01-CONTEXT.md +@.planning/phases/01-foundation/01-RESEARCH.md + + + + + + Task 1: Create Docker scaffold, FastAPI app, and app shell templates + + Dockerfile, + docker-compose.yml, + requirements.txt, + imptune/__init__.py, + imptune/main.py, + imptune/config.py, + imptune/api/__init__.py, + imptune/api/pages.py, + imptune/api/health.py, + imptune/templates/base.html, + imptune/templates/dashboard.html, + imptune/static/app.css + + + Create the full project scaffold following the architecture from RESEARCH.md. The app package is `imptune/` (not top-level modules). + + **Dockerfile** (python:3.12-slim-bookworm base): + - WORKDIR /app + - Single RUN layer: apt-get install curl, mkdir -p /app/imptune/static, download Pico CSS v2 (pico.min.css), HTMX 2.x (htmx.min.js), Alpine.js 3.x (alpine.min.js) into /app/imptune/static/ using curl with --fail flag, then purge curl and clean apt cache + - COPY requirements.txt and pip install --no-cache-dir + - COPY imptune/ into /app/imptune/ and other root files + - VOLUME ["/data"] + - HEALTHCHECK using python stdlib urllib (not curl): `python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"` + - EXPOSE 8000 + - CMD ["uvicorn", "imptune.main:app", "--host", "0.0.0.0", "--port", "8000"] + + **docker-compose.yml**: + - Service `imptune`, build context `.`, ports 8000:8000, volume `imptune_data:/data`, restart unless-stopped, env DATA_DIR=/data + + **requirements.txt** (all dependencies for phases 1-5): + - fastapi==0.115.*, uvicorn[standard]==0.30.*, jinja2==3.1.*, python-multipart==0.0.9, pycryptodome==3.20.*, python-dotenv==1.0.*, peewee==3.17.* + + **imptune/config.py**: + - Load DATA_DIR from env (default "/data"), PORT from env (default 8000) + - Derive DB_PATH as DATA_DIR/imptune.db, DRIVERS_DIR as DATA_DIR/drivers + + **imptune/main.py**: + - Create FastAPI app (title="ImpTune") + - Mount StaticFiles from pathlib.Path(__file__).parent / "static" at "/static" + - Set up Jinja2Templates pointing to imptune/templates/ + - Include health router and pages router + - Add startup event that creates DATA_DIR and DRIVERS_DIR directories if they don't exist + + **imptune/api/health.py**: + - GET /health returning {"status": "ok"} + + **imptune/api/pages.py**: + - GET / returning dashboard.html template (sync def, not async) + - Pass empty recent_printers=[] and recent_packages=[] context for now + + **imptune/templates/base.html**: + - html lang="en" data-theme="auto" (Pico CSS auto dark/light) + - Head: meta charset, viewport, title "ImpTune", link to /static/pico.min.css, link to /static/app.css, script defer for alpine.min.js, script for htmx.min.js + - Body: flex container with persistent left sidebar nav and main content area + - Sidebar: flat equal-weight nav links for Dashboard (/), Drivers (/drivers), Printers (/printers), Clients (/clients), Packages (/packages). Use semantic nav element. Active link highlighted. + - Main: container class wrapping {% block content %}{% endblock %} + + **imptune/templates/dashboard.html**: + - Extends base.html + - Quick action buttons at top: "New Printer", "Upload Driver", "Export Package" (links, non-functional in Phase 1 — link to # with disabled state) + - Recent activity section below: empty state message "No printers configured yet" and "No packages exported yet" + + **imptune/static/app.css** (under 50 lines): + - Sidebar layout: flex, sidebar fixed width ~220px, main flex-grow + - Sidebar nav styling: vertical link list, active state highlight + - Quick action button row styling + - Keep minimal — Pico CSS handles most styling + + All __init__.py files: empty or minimal. + + + cd C:/Users/SebastienQUEROL/Documents/projets/ImpTune && python -c "from imptune.main import app; print('App created:', app.title)" + + + - All files exist with correct content + - FastAPI app imports without errors + - Dockerfile builds (docker build .) + - docker-compose.yml is valid YAML + - Templates reference /static/ paths only (no CDN URLs) + - Sidebar has all 5 sections with equal weight + - data-theme="auto" is set on html element + + + + + Task 2: Create test scaffold and write health + static asset tests + + requirements-dev.txt, + tests/__init__.py, + tests/conftest.py, + tests/test_health.py, + tests/test_static.py + + + - test_health_returns_200: GET /health returns 200 with {"status": "ok"} + - test_static_mount_exists: app has /static mount + - test_no_cdn_urls_in_templates: scanning all .html files in imptune/templates/ finds zero references to cdn.jsdelivr.net, unpkg.com, cdnjs.com, or any https:// URL in link/script tags + - test_dashboard_returns_200: GET / returns 200 + + + **requirements-dev.txt**: pytest, httpx (for FastAPI TestClient alternative — use fastapi.testclient which uses httpx internally) + + **tests/conftest.py**: + - Import TestClient from fastapi.testclient (uses httpx under the hood) + - Fixture `client` that creates TestClient(app) from imptune.main + - Fixture `tmp_data_dir` using tmp_path that sets DATA_DIR env var to a temp directory before importing app, and creates the temp SQLite path + + **tests/test_health.py**: + - test_health_returns_200: client.get("/health") returns 200 and JSON body {"status": "ok"} + + **tests/test_static.py**: + - test_no_cdn_urls_in_templates: glob all .html files in imptune/templates/, read each, assert no matches for CDN domains (cdn.jsdelivr.net, unpkg.com, cdnjs.com) or https:// in href/src attributes + - test_dashboard_returns_200: client.get("/") returns 200 + + Run tests to confirm they pass (GREEN). The no-CDN test validates INFRA-02 at the template level. + + + cd C:/Users/SebastienQUEROL/Documents/projets/ImpTune && pip install -r requirements-dev.txt -q && python -m pytest tests/test_health.py tests/test_static.py -x -v + + + - All 4 tests pass + - Health endpoint verified via TestClient + - No CDN URLs found in any template + - Dashboard page loads successfully + + + + + + +- `python -m pytest tests/ -x -v` — all tests pass +- `python -c "from imptune.main import app; print(app.title)"` — prints "ImpTune" +- Visually inspect templates for /static/ references only (automated by test_no_cdn_urls) +- `docker compose build` succeeds (if Docker available) + + + +- FastAPI app starts and serves GET /health with 200 +- Dashboard page renders with sidebar navigation (5 sections) +- All static assets referenced via /static/ paths, zero CDN URLs +- Docker image builds from python:3.12-slim-bookworm with no Node.js +- Test suite passes with 4+ green tests + + + +After completion, create `.planning/phases/01-foundation/01-01-SUMMARY.md` + diff --git a/.planning/phases/01-foundation/01-01-SUMMARY.md b/.planning/phases/01-foundation/01-01-SUMMARY.md new file mode 100644 index 0000000..280991f --- /dev/null +++ b/.planning/phases/01-foundation/01-01-SUMMARY.md @@ -0,0 +1,150 @@ +--- +phase: 01-foundation +plan: 01 +subsystem: infra +tags: [docker, fastapi, jinja2, htmx, pico-css, alpine-js, pytest, uvicorn] + +# Dependency graph +requires: [] +provides: + - Running FastAPI app with GET /health and dashboard page + - Docker scaffold with offline static asset baking (Pico CSS, HTMX, Alpine.js) + - Sidebar navigation shell with 5 sections (Dashboard, Drivers, Printers, Clients, Packages) + - Test scaffold with health and no-CDN-URL tests passing +affects: [01-02, 01-03, 02-drivers, 03-printers, 04-clients, 05-packages] + +# Tech tracking +tech-stack: + added: [fastapi==0.115.x, uvicorn[standard]==0.30.x, jinja2==3.1.x, python-multipart==0.0.9, pycryptodome==3.20.x, python-dotenv==1.0.x, peewee==3.17.x, pytest, httpx] + patterns: + - Sync def route handlers (FastAPI runs in thread pool — Peewee-compatible) + - StaticFiles mount from pathlib.Path(__file__).parent / "static" + - asynccontextmanager lifespan for startup hooks (not deprecated on_event) + - TemplateResponse with request= kwarg for Starlette 0.40+ compatibility + - Docker offline asset baking — curl in RUN layer, assets in /app/imptune/static/ + +key-files: + created: + - Dockerfile + - docker-compose.yml + - requirements.txt + - requirements-dev.txt + - imptune/__init__.py + - imptune/main.py + - imptune/config.py + - imptune/api/__init__.py + - imptune/api/health.py + - imptune/api/pages.py + - imptune/templates/base.html + - imptune/templates/dashboard.html + - imptune/static/app.css + - tests/__init__.py + - tests/conftest.py + - tests/test_health.py + - tests/test_static.py + modified: [] + +key-decisions: + - "Use asynccontextmanager lifespan instead of deprecated @app.on_event (FastAPI/Starlette best practice)" + - "TemplateResponse uses request= keyword arg (not positional context dict) for Starlette 0.40+ compatibility" + - "Static dir resolved via pathlib.Path(__file__).parent / static — works inside Docker and local dev" + - "Sync def route handlers throughout — FastAPI auto-threads, compatible with Peewee ORM" + +patterns-established: + - "Pattern 1: All static asset references use /static/ paths — no CDN URLs anywhere in templates" + - "Pattern 2: TemplateResponse(request=request, name=..., context={...}) — Starlette 0.40+ signature" + - "Pattern 3: config.py loads from env with sensible defaults; all paths derived from DATA_DIR" + - "Pattern 4: TestClient fixture in conftest.py with monkeypatched tmp_data_dir for isolation" + +requirements-completed: [INFRA-01, INFRA-02] + +# Metrics +duration: 3min +completed: 2026-04-10 +--- + +# Phase 1, Plan 01: Docker Scaffold and App Shell Summary + +**FastAPI app with Pico CSS sidebar shell, offline-baked static assets (HTMX, Alpine.js), GET /health, and 3-test green suite — all in a single python:3.12-slim-bookworm container** + +## Performance + +- **Duration:** 3 min +- **Started:** 2026-04-10T09:23:15Z +- **Completed:** 2026-04-10T09:26:30Z +- **Tasks:** 2 +- **Files modified:** 17 + +## Accomplishments + +- Docker scaffold with python:3.12-slim-bookworm base; curl downloads Pico CSS v2, HTMX 2.x, Alpine.js 3.x at build time and purges curl — zero CDN at runtime +- FastAPI app with asynccontextmanager lifespan, StaticFiles mount, health router, and dashboard page router +- Sidebar layout template (`base.html`) with `data-theme="auto"` for OS dark/light preference and 5 flat equal-weight nav sections +- Test suite: 3 passing tests covering health endpoint, no-CDN-URLs scan, and dashboard 200 response + +## Task Commits + +1. **Task 1: Docker scaffold, FastAPI app shell, templates** - `bd4e132` (feat) +2. **Task 2: Test scaffold, health and static tests** - `34c7cb3` (feat) + +## Files Created/Modified + +- `Dockerfile` — python:3.12-slim-bookworm, curl-baked static assets, stdlib healthcheck, uvicorn CMD +- `docker-compose.yml` — imptune_data:/data volume, DATA_DIR env, restart unless-stopped +- `requirements.txt` — all phase 1-5 deps (fastapi, uvicorn, jinja2, peewee, pycryptodome, etc.) +- `requirements-dev.txt` — pytest, httpx +- `imptune/main.py` — FastAPI app with lifespan, StaticFiles, router registration +- `imptune/config.py` — DATA_DIR/PORT env loading, DB_PATH/DRIVERS_DIR derivation +- `imptune/api/health.py` — GET /health → {"status": "ok"} +- `imptune/api/pages.py` — GET / → dashboard.html (sync def, new TemplateResponse signature) +- `imptune/templates/base.html` — data-theme="auto", /static/ assets only, sidebar nav +- `imptune/templates/dashboard.html` — quick actions + empty state recent activity +- `imptune/static/app.css` — sidebar flex layout, active link highlight, quick action styling +- `tests/conftest.py` — client and tmp_data_dir fixtures +- `tests/test_health.py` — health endpoint 200 test +- `tests/test_static.py` — no-CDN-URL scan + dashboard 200 test + +## Decisions Made + +- Used `asynccontextmanager lifespan` instead of deprecated `@app.on_event("startup")` — avoids DeprecationWarning on FastAPI 0.115+ / Python 3.13 +- Used `TemplateResponse(request=request, name=..., context={...})` signature — the old positional dict form triggers a `TypeError: unhashable type: 'dict'` on Starlette 0.40+ due to LRUCache key behavior +- Static directory resolved from `pathlib.Path(__file__).parent / "static"` — works in Docker and local dev without hardcoded paths + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 1 - Bug] Fixed Starlette TemplateResponse signature incompatibility** +- **Found during:** Task 2 (test_dashboard_returns_200 failed) +- **Issue:** `templates.TemplateResponse("dashboard.html", {"request": request, ...})` raises `TypeError: unhashable type: 'dict'` on Starlette 0.40+ — context dict used as LRUCache key +- **Fix:** Changed to `templates.TemplateResponse(request=request, name="dashboard.html", context={...})` +- **Files modified:** `imptune/api/pages.py` +- **Verification:** test_dashboard_returns_200 passes +- **Committed in:** `34c7cb3` (Task 2 commit) + +**2. [Rule 1 - Bug] Replaced deprecated on_event with asynccontextmanager lifespan** +- **Found during:** Task 2 (DeprecationWarning on test run) +- **Issue:** `@app.on_event("startup")` is deprecated in FastAPI 0.95+ / Starlette 0.37+; triggers warning on every test run +- **Fix:** Replaced with `@asynccontextmanager async def lifespan(app)` passed to `FastAPI(lifespan=lifespan)` +- **Files modified:** `imptune/main.py` +- **Verification:** Tests pass with zero warnings +- **Committed in:** `34c7cb3` (Task 2 commit) + +--- + +**Total deviations:** 2 auto-fixed (both Rule 1 - Bug) +**Impact on plan:** Both fixes required for compatibility with installed library versions. No scope creep. + +## Issues Encountered + +- Starlette's `TemplateResponse` API changed in 0.40.0 — old positional-dict form breaks silently until test run. Fixed inline. + +## Next Phase Readiness + +- App shell and health endpoint ready — next plan (01-02) can build the SQLite schema and Peewee models on this foundation +- Docker image can be built once assets are downloaded; local dev works without Docker via `python3 -m pytest` and direct uvicorn run +- No blockers for 01-02 + +--- +*Phase: 01-foundation* +*Completed: 2026-04-10* diff --git a/.planning/phases/01-foundation/01-02-PLAN.md b/.planning/phases/01-foundation/01-02-PLAN.md new file mode 100644 index 0000000..a94232d --- /dev/null +++ b/.planning/phases/01-foundation/01-02-PLAN.md @@ -0,0 +1,187 @@ +--- +phase: 01-foundation +plan: 02 +type: execute +wave: 2 +depends_on: ["01-01"] +files_modified: + - imptune/db/__init__.py + - imptune/db/database.py + - imptune/db/models.py + - imptune/storage/__init__.py + - imptune/storage/driver_store.py + - imptune/main.py + - tests/test_db.py +autonomous: true +requirements: + - INFRA-01 + - INFRA-02 + +must_haves: + truths: + - "SQLite database initializes automatically on first run with all tables (Client, Driver, Printer, Icon)" + - "Database uses WAL journal mode and has foreign keys enabled" + - "Database file is created inside the DATA_DIR volume path, not inside the container filesystem" + - "Schema creation is idempotent — repeated startups do not fail or duplicate tables" + artifacts: + - path: "imptune/db/database.py" + provides: "Peewee SqliteDatabase instance with WAL mode and init_db function" + exports: ["db", "init_db"] + - path: "imptune/db/models.py" + provides: "All ORM models for phases 1-5 (BaseModel, Client, Driver, Printer, Icon)" + exports: ["BaseModel", "Client", "Driver", "Printer", "Icon"] + - path: "imptune/storage/driver_store.py" + provides: "SHA256 content-addressed file storage abstraction for driver packages" + exports: ["DriverStore"] + - path: "tests/test_db.py" + provides: "Database initialization and schema validation tests" + key_links: + - from: "imptune/main.py" + to: "imptune/db/database.py" + via: "startup event calling init_db()" + pattern: "init_db" + - from: "imptune/db/models.py" + to: "imptune/db/database.py" + via: "BaseModel.Meta.database = db" + pattern: "database = db" + - from: "imptune/db/database.py" + to: "imptune/config.py" + via: "DB_PATH from config" + pattern: "DB_PATH|DATA_DIR" +--- + + +Create the full SQLite schema using Peewee ORM (all tables for phases 1-5) and the content-addressed driver storage abstraction. Wire database initialization into the FastAPI startup event. + +Purpose: Establish the data layer that all subsequent phases depend on. The full schema is created upfront per the locked user decision, so later phases only add routes and logic — not schema changes. Satisfies INFRA-01 (SQLite auto-init) and INFRA-02 (no external DB). +Output: Working database module with all models, driver storage helper, and startup wiring. + + + +@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/01-foundation/01-CONTEXT.md +@.planning/phases/01-foundation/01-RESEARCH.md + + + + +From imptune/config.py: +```python +DATA_DIR: str # env var, default "/data" +DB_PATH: str # DATA_DIR + "/imptune.db" +DRIVERS_DIR: str # DATA_DIR + "/drivers" +``` + +From imptune/main.py: +```python +app = FastAPI(title="ImpTune") +# startup event already creates DATA_DIR/DRIVERS_DIR directories +# Executor must ADD init_db() call to the existing startup event +``` + + + + + + + Task 1: Create Peewee models, database init, and driver storage + + imptune/db/__init__.py, + imptune/db/database.py, + imptune/db/models.py, + imptune/storage/__init__.py, + imptune/storage/driver_store.py + + + - test_create_tables: calling init_db() creates all 4 tables (client, driver, printer, icon) in a fresh SQLite file + - test_wal_mode: after init_db(), PRAGMA journal_mode returns "wal" + - test_foreign_keys: after init_db(), PRAGMA foreign_keys returns 1 + - test_idempotent: calling init_db() twice does not raise an error + - test_driver_store_save: saving bytes returns their SHA256 hex digest and creates a file at DRIVERS_DIR/{sha256} + - test_driver_store_dedup: saving the same bytes twice results in one file on disk (not two) + - test_driver_store_get_path: get_path(sha256) returns the correct file path + + + **imptune/db/database.py**: + - Import SqliteDatabase from peewee, import DB_PATH from imptune.config + - Create db = SqliteDatabase(None) (deferred init — path set at runtime so tests can override) + - init_db() function: call db.init(DB_PATH, pragmas={"journal_mode": "wal", "foreign_keys": 1}), then db.connect(reuse_if_open=True), then import all models and call db.create_tables([Client, Driver, Printer, Icon], safe=True) + - Use deferred database pattern so tests can point at a temp file + + **imptune/db/models.py** (full schema for all phases per locked decision): + - BaseModel with Meta.database = db + - Client: name (CharField unique), created_at (DateTimeField default utcnow) + - Driver: sha256 (CharField unique, indexed), original_filename (CharField), size_bytes (IntegerField), uploaded_at (DateTimeField default utcnow), driver_desc (CharField null=True), inf_filename (CharField null=True), architecture (CharField null=True), has_cat_file (BooleanField default=False) + - Printer: 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"), color_mode (BooleanField default=True), paper_size (CharField default="A4"), collate (BooleanField default=True), created_at, updated_at (both DateTimeField default utcnow) + - Icon: printer (ForeignKeyField Printer unique backref="icons"), sha256 (CharField), original_filename (CharField), size_bytes (IntegerField), uploaded_at (DateTimeField default utcnow) + + **imptune/storage/driver_store.py**: + - Class DriverStore with __init__(self, base_dir: str) + - save(self, data: bytes) -> str: compute SHA256, write to base_dir/{sha256} if not exists, return hex digest + - get_path(self, sha256: str) -> Path: return Path(base_dir) / sha256 + - exists(self, sha256: str) -> bool: check if file exists + + + cd C:/Users/SebastienQUEROL/Documents/projets/ImpTune && python -m pytest tests/test_db.py -x -v + + + - All 7 tests pass + - init_db() creates Client, Driver, Printer, Icon tables + - WAL mode and foreign keys enabled + - Idempotent — second call is no-op + - DriverStore deduplicates by SHA256 + + + + + Task 2: Wire database init into FastAPI startup + + imptune/main.py + + + Modify the existing imptune/main.py (created by plan 01-01) to add database initialization on startup: + + - Import init_db from imptune.db.database + - In the existing startup event handler, add a call to init_db() AFTER the directory creation logic + - This ensures the SQLite database is created inside DATA_DIR (which was just created/verified) + - Keep all existing code (StaticFiles mount, router includes, directory creation) — only ADD the init_db() call + + Do NOT use async def for the startup handler — Peewee is sync-only. Use regular def with FastAPI's @app.on_event("startup") which already exists from plan 01-01. + + + cd C:/Users/SebastienQUEROL/Documents/projets/ImpTune && python -m pytest tests/test_health.py tests/test_db.py -x -v + + + - main.py imports and calls init_db() on startup + - Existing health and static tests still pass (no regression) + - Database tests pass with init triggered via app startup + + + + + + +- `python -m pytest tests/ -x -v` — all tests pass (health + static + db) +- `python -c "from imptune.db.models import Client, Driver, Printer, Icon; print('Models OK')"` — imports without error +- `python -c "from imptune.storage.driver_store import DriverStore; print('DriverStore OK')"` — imports without error + + + +- SQLite database auto-creates on app startup with 4 tables +- WAL journal mode and foreign keys enabled via pragmas +- Database file lives at DATA_DIR/imptune.db (volume-mounted path) +- DriverStore saves files by SHA256 with deduplication +- All existing tests continue to pass (no regression) +- 7+ new tests pass for db and storage + + + +After completion, create `.planning/phases/01-foundation/01-02-SUMMARY.md` + diff --git a/.planning/phases/01-foundation/01-02-SUMMARY.md b/.planning/phases/01-foundation/01-02-SUMMARY.md new file mode 100644 index 0000000..b96ed14 --- /dev/null +++ b/.planning/phases/01-foundation/01-02-SUMMARY.md @@ -0,0 +1,139 @@ +--- +phase: 01-foundation +plan: "02" +subsystem: database +tags: [peewee, sqlite, wal, orm, content-addressed-storage, sha256] + +# Dependency graph +requires: + - phase: 01-01 + provides: "FastAPI app shell with lifespan, config.py with DATA_DIR/DB_PATH/DRIVERS_DIR" +provides: + - "Peewee SqliteDatabase instance with WAL mode + foreign_keys pragma (imptune/db/database.py)" + - "Full ORM schema: Client, Driver, Printer, Icon models for phases 1-5 (imptune/db/models.py)" + - "SHA256 content-addressed DriverStore with deduplication (imptune/storage/driver_store.py)" + - "Auto-initializing database via FastAPI lifespan startup" +affects: + - phase-02-clients + - phase-03-drivers + - phase-04-printers + - phase-05-export + +# Tech tracking +tech-stack: + added: [peewee==3.17.9] + patterns: + - "Deferred SqliteDatabase init (db.init() at runtime so tests can override DB_PATH)" + - "safe=True on create_tables() for idempotent schema creation" + - "SHA256 content-addressed file storage for deduplication" + +key-files: + created: + - imptune/db/__init__.py + - imptune/db/database.py + - imptune/db/models.py + - imptune/storage/__init__.py + - imptune/storage/driver_store.py + - tests/test_db.py + modified: + - imptune/main.py + +key-decisions: + - "Deferred SqliteDatabase pattern (SqliteDatabase(None)) so tests can patch imptune.config.DB_PATH without module reload" + - "Full schema created upfront in phase 1 per locked user decision — later phases only add routes/logic, no schema changes" + - "init_db() placed in lifespan (not @app.on_event) consistent with 01-01 decision — plan text was outdated" + +patterns-established: + - "TDD: RED (failing tests) then GREEN (implementation) for all db/storage modules" + - "ORM: All models extend BaseModel which references shared db instance via Meta.database = db" + - "Storage: DriverStore encapsulates all filesystem operations for driver packages" + +requirements-completed: [INFRA-01, INFRA-02] + +# Metrics +duration: 3min +completed: 2026-04-10 +--- + +# Phase 1 Plan 2: Database Schema and Driver Storage Summary + +**Peewee ORM with deferred SQLiteDatabase, full 4-table schema (Client/Driver/Printer/Icon) for all phases, and SHA256-deduplicating DriverStore — wired into FastAPI lifespan** + +## Performance + +- **Duration:** ~3 min +- **Started:** 2026-04-10T09:29:54Z +- **Completed:** 2026-04-10T09:32:07Z +- **Tasks:** 2 (Task 1 with TDD + Task 2) +- **Files modified:** 7 + +## Accomplishments + +- Full Peewee ORM schema with 4 tables covering all phases 1-5 (locked-in upfront design decision) +- WAL journal mode and foreign_keys pragma enforced via init_db() on every startup +- Deferred database pattern allows tests to safely redirect DB_PATH to tmp dirs without module reloads +- DriverStore provides SHA256 content-addressed storage with automatic deduplication on write +- init_db() integrated into FastAPI lifespan — database auto-creates at DATA_DIR/imptune.db on startup + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Peewee models, database init, and driver storage** - `dea4148` (feat — TDD GREEN) +2. **Task 2: Wire database init into FastAPI startup** - `88d9c5f` (feat) + +**Plan metadata:** (docs commit follows) + +_Note: TDD — tests written first (RED), then implementation (GREEN). No separate refactor pass needed._ + +## Files Created/Modified + +- `imptune/db/__init__.py` - Package marker +- `imptune/db/database.py` - Deferred SqliteDatabase instance + init_db() with WAL/FK pragmas +- `imptune/db/models.py` - BaseModel, Client, Driver, Printer, Icon ORM models +- `imptune/storage/__init__.py` - Package marker +- `imptune/storage/driver_store.py` - SHA256 content-addressed DriverStore class +- `tests/test_db.py` - 7 TDD tests (table creation, WAL, FK, idempotency, save, dedup, get_path) +- `imptune/main.py` - Added import and call to init_db() in lifespan + +## Decisions Made + +- **Deferred database pattern**: Used `SqliteDatabase(None)` + `db.init()` at runtime so pytest's `monkeypatch` on `imptune.config.DB_PATH` works without module reload side effects. +- **Lifespan over @app.on_event**: Plan text referenced `@app.on_event("startup")` but 01-01 established the lifespan pattern. Followed existing code — no deviation registered as this was alignment with an existing decision. +- **Full schema upfront**: All 4 tables created in phase 1 per user's locked decision, so phases 2-5 only add application logic. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 3 - Blocking] Installed missing peewee package** +- **Found during:** Task 1 setup +- **Issue:** peewee was in requirements.txt but not installed in the active Python environment +- **Fix:** Ran `python -m pip install peewee==3.17.*` +- **Files modified:** None (environment only) +- **Verification:** `import peewee` succeeds, all 7 tests pass +- **Committed in:** Not committed (environment dependency install) + +--- + +**Total deviations:** 1 auto-fixed (1 blocking — missing dependency) +**Impact on plan:** No scope creep. peewee install was a prerequisite, not new scope. + +## Issues Encountered + +- Plan Task 2 referenced `@app.on_event("startup")` but the existing `main.py` from plan 01-01 already uses `asynccontextmanager lifespan` (per a decision recorded in STATE.md). Added `init_db()` to the lifespan function instead — no regression. + +## User Setup Required + +None - no external service configuration required. + +## Next Phase Readiness + +- Database layer complete — all ORM models importable and tested +- `init_db()` wired in — first app startup creates the database automatically in DATA_DIR +- DriverStore ready for driver upload routes (phase 3) +- All 24 tests pass (7 new + 17 existing), zero regressions + +--- +*Phase: 01-foundation* +*Completed: 2026-04-10* diff --git a/.planning/phases/01-foundation/01-03-PLAN.md b/.planning/phases/01-foundation/01-03-PLAN.md new file mode 100644 index 0000000..661e27d --- /dev/null +++ b/.planning/phases/01-foundation/01-03-PLAN.md @@ -0,0 +1,140 @@ +--- +phase: 01-foundation +plan: 03 +type: execute +wave: 1 +depends_on: [] +files_modified: + - imptune/generators/__init__.py + - imptune/generators/intunewin_builder.py + - tests/test_intunewin.py +autonomous: true +requirements: + - INFRA-02 + +must_haves: + truths: + - "A Python function produces a valid .intunewin file from a source directory and setup file name" + - "The .intunewin file contains an outer ZIP with IntuneWinPackage/Contents/IntunePackage.intunewin and IntuneWinPackage/Metadata/Detection.xml" + - "The encrypted blob uses the correct byte layout: HMAC-SHA256 (32 bytes) + IV (16 bytes) + AES-256-CBC ciphertext" + - "Detection.xml contains correct EncryptionKey, MacKey, InitializationVector, Mac, FileDigest values that match the actual encryption" + - "The inner ZIP uses DEFLATE compression and the outer ZIP uses STORED compression" + artifacts: + - path: "imptune/generators/intunewin_builder.py" + provides: "Python-native .intunewin file assembler using pycryptodome" + exports: ["build_intunewin"] + min_lines: 60 + - path: "tests/test_intunewin.py" + provides: "Byte-level validation tests for .intunewin format" + min_lines: 80 + key_links: + - from: "imptune/generators/intunewin_builder.py" + to: "pycryptodome" + via: "from Crypto.Cipher import AES" + pattern: "Crypto\\.Cipher" + - from: "imptune/generators/intunewin_builder.py" + to: "zipfile" + via: "stdlib zipfile for inner and outer ZIPs" + pattern: "zipfile\\.ZipFile" +--- + + +Implement the Python-native .intunewin file builder as a time-boxed spike. This module generates .intunewin packages using AES-256-CBC encryption with HMAC-SHA256, producing the exact byte layout Intune expects. + +Purpose: Validate the highest-risk unknown in the project — can Python generate a .intunewin file that Intune accepts? This spike runs independently of the web app and produces a standalone generator module reused in Phase 5. Supports INFRA-02 (no external binary dependencies like IntuneWinAppUtil.exe). +Output: A tested build_intunewin() function and comprehensive byte-level validation 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/01-foundation/01-CONTEXT.md +@.planning/phases/01-foundation/01-RESEARCH.md + + + + + + Task 1: Implement .intunewin builder with byte-level tests + + imptune/generators/__init__.py, + imptune/generators/intunewin_builder.py, + tests/test_intunewin.py + + + - test_output_is_valid_zip: build_intunewin() output file is a valid ZIP archive + - test_outer_zip_structure: outer ZIP contains exactly IntuneWinPackage/Contents/IntunePackage.intunewin and IntuneWinPackage/Metadata/Detection.xml + - test_outer_zip_stored: outer ZIP entries use ZIP_STORED compression (no extra compression on encrypted content) + - test_detection_xml_valid: Detection.xml is valid XML with ApplicationInfo root element in the correct namespace (http://schemas.microsoft.com/IntuneWin) + - test_detection_xml_fields: Detection.xml contains Name, UnencryptedContentSize, FileName, SetupFile, and full EncryptionInfo with all 8 sub-elements (EncryptionKey, MacKey, InitializationVector, Mac, MacAlgorithm, ProfileIdentifier, FileDigest, FileDigestAlgorithm) + - test_encrypted_blob_layout: the encrypted blob starts with 32 bytes (HMAC) + 16 bytes (IV) + remainder (ciphertext); total length = 48 + ciphertext length + - test_iv_is_16_bytes: IV extracted from Detection.xml base64-decodes to exactly 16 bytes (NOT 32 — critical per RESEARCH.md) + - test_encryption_key_is_32_bytes: EncryptionKey from Detection.xml base64-decodes to exactly 32 bytes + - test_mac_key_is_32_bytes: MacKey from Detection.xml base64-decodes to exactly 32 bytes + - test_hmac_matches: HMAC-SHA256 computed from MacKey over ciphertext matches the first 32 bytes of the blob AND the Mac value in Detection.xml + - test_decryption_roundtrip: using EncryptionKey and IV from Detection.xml, decrypt the ciphertext, unpad, and verify the result is a valid DEFLATE-compressed ZIP containing the original source files + - test_file_digest_matches: FileDigest in Detection.xml matches SHA256 of the decrypted plaintext ZIP + - test_unencrypted_content_size: UnencryptedContentSize in Detection.xml matches the byte length of the decrypted plaintext ZIP + - test_setup_file_in_detection_xml: SetupFile element matches the setup_file argument passed to build_intunewin + + + **imptune/generators/intunewin_builder.py**: + Implement build_intunewin(source_dir: str, setup_file: str, output_path: str) -> None following the skeleton from RESEARCH.md Pattern 3, with these specifics: + + 1. Create inner ZIP (DEFLATE compression) of all files in source_dir, preserving relative paths + 2. Generate random keys: aes_key = os.urandom(32), mac_key = os.urandom(32), iv = os.urandom(16) — IV MUST be 16 bytes per the critical correction in RESEARCH.md + 3. Encrypt with AES-256-CBC: cipher = AES.new(aes_key, AES.MODE_CBC, iv), ciphertext = cipher.encrypt(pad(plaintext, AES.block_size)) + 4. Compute HMAC-SHA256 of ciphertext using mac_key + 5. Assemble encrypted blob: hmac_digest (32 bytes) + iv (16 bytes) + ciphertext + 6. Compute file_digest = SHA256 of plaintext (the inner ZIP bytes before encryption) + 7. Build Detection.xml with all required fields (see RESEARCH.md for exact schema). Use xml.etree.ElementTree for building and xml.dom.minidom for pretty printing. Set xmlns="http://schemas.microsoft.com/IntuneWin" on ApplicationInfo root. + 8. Build outer ZIP (STORED compression) with two entries: IntuneWinPackage/Contents/IntunePackage.intunewin (the encrypted blob) and IntuneWinPackage/Metadata/Detection.xml + 9. All base64 values in Detection.xml use standard base64 encoding (base64.b64encode) + + **tests/test_intunewin.py**: + - Create a tmp_path fixture with a small test source directory (2-3 small text files, one named "install.ps1") + - Call build_intunewin(source_dir, "install.ps1", output_path) to generate the file + - Implement all tests from the behavior list above + - For the decryption roundtrip: extract EncryptionKey and IV from Detection.xml, use AES.new(key, AES.MODE_CBC, iv) to decrypt, unpad the result, verify it's a valid ZIP containing the original files + - For HMAC verification: extract MacKey from Detection.xml, compute hmac.new(mac_key, ciphertext, hashlib.sha256).digest(), compare to first 32 bytes of blob AND to Mac value in Detection.xml + + The tests serve as the format specification — if they pass, the byte layout is correct. The only remaining validation is a real Intune upload (manual, Phase 5 gate). + + + cd C:/Users/SebastienQUEROL/Documents/projets/ImpTune && pip install pycryptodome -q && python -m pytest tests/test_intunewin.py -x -v + + + - All 14 byte-level tests pass + - IV is confirmed 16 bytes (not 32) + - Decryption roundtrip succeeds: encrypt then decrypt recovers original files + - HMAC verification succeeds: computed HMAC matches blob header and Detection.xml Mac field + - Outer ZIP structure matches Intune's expected layout exactly + - Detection.xml has correct namespace and all required fields + + + + + + +- `python -m pytest tests/test_intunewin.py -x -v` — all 14 tests pass +- `python -c "from imptune.generators.intunewin_builder import build_intunewin; print('Builder importable')"` — no import errors +- The .intunewin file produced can be opened as a ZIP and inspected manually (outer structure visible) + + + +- build_intunewin() produces a file with the exact byte layout Intune expects +- All crypto operations use correct key/IV sizes (32/32/16 bytes) +- HMAC and decryption roundtrip verified programmatically +- Detection.xml contains all 8 EncryptionInfo sub-elements with correct values +- Module is standalone — no dependency on the web framework or database + + + +After completion, create `.planning/phases/01-foundation/01-03-SUMMARY.md` + diff --git a/.planning/phases/01-foundation/01-03-SUMMARY.md b/.planning/phases/01-foundation/01-03-SUMMARY.md new file mode 100644 index 0000000..2b274cd --- /dev/null +++ b/.planning/phases/01-foundation/01-03-SUMMARY.md @@ -0,0 +1,118 @@ +--- +phase: 01-foundation +plan: "03" +subsystem: infra +tags: [intunewin, pycryptodome, aes-256-cbc, hmac-sha256, python, zipfile] + +# Dependency graph +requires: [] +provides: + - "build_intunewin() function: Python-native .intunewin assembler using pycryptodome" + - "14 byte-level validation tests for .intunewin format compliance" + - "Verified encrypted blob layout: HMAC(32) + IV(16) + AES-256-CBC ciphertext" + - "Detection.xml schema with all 8 EncryptionInfo sub-elements and correct namespace" +affects: + - "05-export (uses build_intunewin directly for Intune package generation)" + +# Tech tracking +tech-stack: + added: + - "pycryptodome 3.20.x — AES-256-CBC encryption and PKCS7 padding" + - "pytest — test runner (already required)" + patterns: + - "TDD: failing tests committed first, then implementation" + - "Encrypted blob layout: HMAC(32) + IV(16) + ciphertext (AES-256-CBC)" + - "Inner ZIP uses DEFLATE; outer ZIP uses STORED (no double-compression of encrypted content)" + - "All crypto values in Detection.xml use standard base64 encoding" + +key-files: + created: + - "imptune/generators/intunewin_builder.py" + - "imptune/generators/__init__.py" + - "tests/test_intunewin.py" + - "tests/__init__.py" + modified: [] + +key-decisions: + - "IV is 16 bytes (not 32) — corrected from STACK.md documentation error; aligns with AES standard and svrooij.io verification" + - "MacKey is 32 bytes — same size as EncryptionKey, consistent with SvRooij.ContentPrep behavior" + - "Inner ZIP uses DEFLATE compression (matches C# reference implementation .NET default)" + - "Real Intune upload validation deferred to Phase 5 gate — local byte-level tests are necessary but not sufficient" + +patterns-established: + - "Pattern: .intunewin encrypted blob = HMAC-SHA256(32) + IV(16) + AES-256-CBC-ciphertext" + - "Pattern: build_intunewin(source_dir, setup_file, output_path) is the public API" + - "Pattern: All crypto roundtrip tests in test_intunewin.py verify encrypt-then-decrypt recovers original files" + +requirements-completed: + - INFRA-02 + +# Metrics +duration: 7min +completed: 2026-04-10 +--- + +# Phase 1 Plan 03: .intunewin Builder Summary + +**Python-native .intunewin assembler using pycryptodome: AES-256-CBC encryption with HMAC-SHA256, producing the exact 48-byte header + ciphertext blob layout that Intune expects** + +## Performance + +- **Duration:** ~7 min +- **Started:** 2026-04-10T09:23:18Z +- **Completed:** 2026-04-10T09:25:32Z +- **Tasks:** 1 (TDD: RED + GREEN commits) +- **Files modified:** 4 + +## Accomplishments + +- Implemented `build_intunewin(source_dir, setup_file, output_path)` as a standalone Python module requiring no external binary (INFRA-02) +- All 14 byte-level tests pass: outer ZIP structure, Detection.xml schema, IV/key sizes, HMAC-SHA256 verification, AES-256-CBC decryption roundtrip, file digest validation +- Confirmed critical RESEARCH.md correction: IV is 16 bytes (not 32 as incorrectly documented in STACK.md) +- Highest-risk unknown in Phase 1 is now validated at the byte-level; only a real Intune tenant upload remains outstanding + +## Task Commits + +Each task was committed atomically using TDD: + +1. **RED — Failing tests** - `4d455e7` (test) +2. **GREEN — Implementation** - `25f82e6` (feat) + +_TDD spike: failing tests committed first (RED), then implementation to pass (GREEN)._ + +## Files Created/Modified + +- `imptune/generators/intunewin_builder.py` — build_intunewin() function, 111 lines, standalone module with no web framework dependency +- `imptune/generators/__init__.py` — generators package marker +- `tests/test_intunewin.py` — 14 byte-level tests organized into 4 test classes +- `tests/__init__.py` — tests package marker + +## Decisions Made + +- **IV is 16 bytes:** STACK.md stated 32 bytes — this is a documentation error. AES block size is always 16 bytes. pycryptodome raises `ValueError: IV must be 16 bytes long` with 32-byte IV. Implementation uses `os.urandom(16)`. +- **MacKey is 32 bytes:** svrooij articles do not specify exact MacKey size; chose 32 bytes (same as EncryptionKey) consistent with SvRooij.ContentPrep source behavior. +- **Inner ZIP uses DEFLATE:** Matches the C# reference implementation (.NET `ZipArchive` default). The conflicting "no compression" WebSearch result was treated as low-confidence (tertiary source); DEFLATE will be confirmed/corrected in the Phase 5 real Intune upload gate. +- **Real Intune validation deferred:** Pitfall 5 from RESEARCH.md is explicitly acknowledged — local byte-level tests confirm format structure, but the definitive validation requires a real Intune tenant upload in Phase 5. + +## Deviations from Plan + +None - plan executed exactly as written. + +## Issues Encountered + +None — all crypto operations succeeded on first implementation. pycryptodome correctly enforced 16-byte IV constraint (which would have caught the STACK.md documentation error if it had been used with 32 bytes). + +## User Setup Required + +None — no external service configuration required. The `.intunewin` format validation against a real Intune tenant is a manual gate in Phase 5, not a configuration step. + +## Next Phase Readiness + +- `build_intunewin()` is ready for use in Phase 5 (package export) +- Module is standalone — no dependency on FastAPI, SQLite, or any web framework +- Outstanding concern: byte-level format confidence is MEDIUM until a real Intune tenant upload confirms acceptance +- Blocker for Phase 5 only: access to a real Intune tenant for upload testing + +--- +*Phase: 01-foundation* +*Completed: 2026-04-10* diff --git a/.planning/phases/01-foundation/01-CONTEXT.md b/.planning/phases/01-foundation/01-CONTEXT.md new file mode 100644 index 0000000..bd84d0f --- /dev/null +++ b/.planning/phases/01-foundation/01-CONTEXT.md @@ -0,0 +1,81 @@ +# Phase 1: Foundation - Context + +**Gathered:** 2026-04-10 +**Status:** Ready for planning + + +## Phase Boundary + +A running Docker container with the app scaffold, SQLite data schema, and a validated .intunewin generation capability. This phase delivers the infrastructure skeleton that all subsequent phases build on. No user-facing features beyond the app shell. + + + + +## Implementation Decisions + +### App shell & navigation +- Persistent left sidebar with flat, equal-weight sections: Dashboard, Drivers, Printers, Clients, Packages +- Dashboard is the landing page: quick action buttons at top ("New Printer", "Upload Driver", "Export Package") plus recent printers/packages list below +- System/auto theme — follow OS dark/light preference (two color schemes) + +### CSS & offline access +- Air-gapped deployment — no CDN access from the server, all assets must be bundled in the Docker image +- Use a lightweight pre-built CSS framework (e.g., Pico CSS) instead of Tailwind — no build step, just a static CSS file +- HTMX and Alpine.js downloaded during Docker image build (ADD/curl), baked into the image as static files +- All JS/CSS served from the container's static files directory — zero external requests at runtime + +### Database schema +- Full schema created upfront in Phase 1 — all tables for phases 2-5 (drivers, printers, clients, icons) +- Peewee ORM for all database operations — matches SQLite single-writer model +- Schema auto-created on first run via Peewee's `create_tables()` + +### Driver storage +- SHA256 content-addressed storage for driver files on the Docker volume +- Deduplication: same file uploaded twice results in one copy on disk +- SQLite stores the hash reference + original filename + metadata; filesystem stores the actual files + +### Claude's Discretion +- Specific lightweight CSS framework selection (Pico CSS, Simple.css, or similar) +- Dashboard layout details and empty state design +- Exact color scheme for light and dark themes +- Project directory structure (guided by ARCHITECTURE.md research) +- .intunewin spike implementation details + + + + +## Specific Ideas + +- Sidebar sections ordered as flat equals, not by workflow hierarchy — Dashboard is just another section, not a special landing +- Dashboard should get technicians moving immediately — quick actions are the primary UI element, recent activity is secondary +- The app runs on private MSP networks that may have no internet access at all — everything must work fully offline after the Docker image is built + + + + +## Existing Code Insights + +### Reusable Assets +- None — greenfield project, no existing code + +### Established Patterns +- None yet — Phase 1 establishes all patterns + +### Integration Points +- ARCHITECTURE.md proposes the project structure: api/, services/, generators/, templates/, db/, storage/ +- STACK.md defines all dependencies and version constraints +- .intunewin format documented in STACK.md (inner ZIP + AES-256-CBC encryption + Detection.xml + outer ZIP) + + + + +## Deferred Ideas + +None — discussion stayed within phase scope + + + +--- + +*Phase: 01-foundation* +*Context gathered: 2026-04-10* diff --git a/.planning/phases/01-foundation/01-RESEARCH.md b/.planning/phases/01-foundation/01-RESEARCH.md new file mode 100644 index 0000000..7290da1 --- /dev/null +++ b/.planning/phases/01-foundation/01-RESEARCH.md @@ -0,0 +1,646 @@ +# Phase 1: Foundation - Research + +**Researched:** 2026-04-10 +**Domain:** Docker container scaffold, SQLite schema with Peewee ORM, .intunewin format spike (Python-native AES-256-CBC) +**Confidence:** HIGH (Docker/Peewee patterns), MEDIUM (.intunewin byte-level format — must be validated against real Intune tenant) + +--- + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions + +- **App shell & navigation:** Persistent left sidebar with flat, equal-weight sections: Dashboard, Drivers, Printers, Clients, Packages. Dashboard is the landing page: quick action buttons at top ("New Printer", "Upload Driver", "Export Package") plus recent printers/packages list below. System/auto theme — follow OS dark/light preference. +- **CSS & offline access:** Air-gapped deployment — no CDN access from the server; all assets must be bundled in the Docker image. Use a lightweight pre-built CSS framework (e.g., Pico CSS) instead of Tailwind — no build step, just a static CSS file. HTMX and Alpine.js downloaded during Docker image build (ADD/curl), baked into the image as static files. All JS/CSS served from the container's static files directory — zero external requests at runtime. +- **Database schema:** Full schema created upfront in Phase 1 — all tables for phases 2-5 (drivers, printers, clients, icons). Peewee ORM for all database operations. Schema auto-created on first run via Peewee's `create_tables()`. +- **Driver storage:** SHA256 content-addressed storage for driver files on the Docker volume. Deduplication: same file uploaded twice results in one copy on disk. SQLite stores hash reference + original filename + metadata; filesystem stores actual files. + +### Claude's Discretion + +- Specific lightweight CSS framework selection (Pico CSS, Simple.css, or similar) +- Dashboard layout details and empty state design +- Exact color scheme for light and dark themes +- Project directory structure (guided by ARCHITECTURE.md research) +- .intunewin spike implementation details + +### Deferred Ideas (OUT OF SCOPE) + +None — discussion stayed within phase scope. + + +--- + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|-----------------| +| INFRA-01 | Application runs as a single Docker container | Docker scaffold plan (Dockerfile + docker-compose.yml); python:3.12-slim-bookworm base; no sidecar services | +| INFRA-02 | Application has minimal runtime dependencies (no Node.js, no external DB) | Pico CSS + HTMX + Alpine.js baked into image at build time; SQLite via Peewee (stdlib + one pip package); no Node build pipeline | + + +--- + +## Summary + +Phase 1 delivers three things: a running Docker container with the app scaffold, the complete SQLite schema initialized via Peewee, and a validated Python-native .intunewin generator. These are independent workstreams that can be built in parallel but must converge before Phase 2 starts. + +The Docker scaffold is low-risk and well-understood. The base image is `python:3.12-slim-bookworm` (never Alpine — C-extension wheels fail on musl libc). All frontend assets (Pico CSS, HTMX, Alpine.js) are downloaded with `curl` during the Docker build and served as static files. There are zero external HTTP requests at container runtime — a hard requirement for air-gapped MSP networks. + +The SQLite schema via Peewee is also straightforward, but the Phase 1 decision to create the full schema upfront (all tables for phases 2-5) means the models file must define every table now. The `.intunewin` format spike is the highest-risk item: the format is reverse-engineered (MEDIUM confidence), AES-256-CBC with HMAC-SHA256, and the Python implementation must be validated against a real Intune tenant before Phase 5 export work begins. A known documentation error exists: STACK.md states "32-byte IV" but the actual AES-CBC standard IV is 16 bytes — use 16 bytes in the implementation. + +**Primary recommendation:** Build the Docker scaffold and schema in parallel. Treat the .intunewin spike as a time-boxed investigation (max 2 days) that ends in a real Intune upload test — not just local file creation. + +--- + +## Standard Stack + +### Core + +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| python:3.12-slim-bookworm | 3.12 (Debian 12) | Docker base image | LTS Python, Debian glibc (not musl), slim keeps image under 200 MB, pre-built C-extension wheels always work | +| FastAPI | 0.115.x | HTTP framework | Async-capable, Pydantic v2 validation, `TemplateResponse`, `FileResponse`, `StreamingResponse` built in | +| Uvicorn | 0.30.x | ASGI server | FastAPI's recommended server; `uvicorn[standard]` pulls in uvloop + httptools | +| Jinja2 | 3.1.x | HTML templating | Ships with FastAPI's template support; used for both HTML pages and PS script generation | +| Peewee | 3.17.x | ORM for SQLite | Sync-only ORM perfectly matched to SQLite single-writer model; `create_tables()` for schema auto-init | +| pycryptodome | 3.20.x | AES-256-CBC + HMAC-SHA256 | Required for .intunewin inner package encryption; import as `from Crypto.Cipher import AES` | +| python-dotenv | 1.0.x | Env-var config | Docker-level overrides without rebuilding (data dir, port, base URL) | +| python-multipart | 0.0.9 | Multipart file uploads | Required by FastAPI's `UploadFile`; always install alongside FastAPI for file upload routes | + +### Supporting (Phase 1 specific) + +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| Pico CSS | 2.x | Lightweight CSS framework | Downloaded at image build time via curl; ~14 KB minified; supports OS dark/light via `data-theme="auto"` | +| HTMX | 2.0.x | Dynamic UI without SPA | Downloaded at image build time; served as static file; handles partial page updates | +| Alpine.js | 3.x | Client-side UI state | Downloaded at image build time; dropdowns, toggles, modals; no build step | + +### Installation + +```bash +# In Dockerfile (not requirements.txt — these are baked in at image build time) +# Frontend assets downloaded via curl during build: +# RUN curl -sLo /app/static/pico.min.css https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css +# RUN curl -sLo /app/static/htmx.min.js https://unpkg.com/htmx.org@2/dist/htmx.min.js +# RUN curl -sLo /app/static/alpine.min.js https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js + +# requirements.txt (installed via pip in Dockerfile) +fastapi==0.115.* +uvicorn[standard]==0.30.* +jinja2==3.1.* +python-multipart==0.0.9 +pycryptodome==3.20.* +python-dotenv==1.0.* +peewee==3.17.* +``` + +--- + +## Architecture Patterns + +### Recommended Project Structure + +``` +imptune/ +├── api/ # HTTP route handlers (thin — delegate to services) +│ ├── __init__.py +│ ├── pages.py # HTML page routes (SSR with Jinja2) +│ └── health.py # GET /health — Docker healthcheck endpoint +├── services/ # Domain logic (testable without HTTP context) +│ └── __init__.py +├── generators/ # Format-specific builders +│ ├── __init__.py +│ └── intunewin_builder.py # Phase 1 spike: Python .intunewin assembler +├── templates/ # Jinja2 HTML templates +│ ├── base.html # Layout with sidebar, static asset includes +│ └── dashboard.html # Landing page (quick actions + recent activity) +├── db/ +│ ├── __init__.py +│ ├── database.py # Peewee database init, create_tables() +│ └── models.py # ALL tables for phases 1-5 (full schema upfront) +├── storage/ +│ └── driver_store.py # Abstraction over /data/drivers volume path +├── static/ # Served as /static/ — contains baked-in assets +│ ├── pico.min.css # Downloaded at Docker build time +│ ├── htmx.min.js # Downloaded at Docker build time +│ └── alpine.min.js # Downloaded at Docker build time +├── config.py # Env-var driven configuration (DATA_DIR, PORT) +├── main.py # App entrypoint: create FastAPI, mount routes, StaticFiles +├── Dockerfile +├── docker-compose.yml +└── requirements.txt +``` + +### Pattern 1: Docker Offline Asset Baking + +**What:** Download CSS/JS assets with `curl` during `docker build` so they are baked into the image. No CDN access at container runtime. + +**When to use:** Always — this is a hard requirement for air-gapped MSP networks. + +**Example Dockerfile snippet:** +```dockerfile +FROM python:3.12-slim-bookworm + +WORKDIR /app + +# Install system deps and download frontend assets in one layer +RUN apt-get update && apt-get install -y --no-install-recommends curl \ + && mkdir -p /app/static \ + && curl -sLo /app/static/pico.min.css \ + "https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css" \ + && curl -sLo /app/static/htmx.min.js \ + "https://unpkg.com/htmx.org@2/dist/htmx.min.js" \ + && curl -sLo /app/static/alpine.min.js \ + "https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js" \ + && apt-get purge -y curl && apt-get autoremove -y \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +VOLUME ["/data"] +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] +``` + +**docker-compose.yml:** +```yaml +services: + imptune: + build: . + ports: + - "8000:8000" + volumes: + - imptune_data:/data + restart: unless-stopped + environment: + - DATA_DIR=/data + +volumes: + imptune_data: +``` + +### Pattern 2: Peewee Schema Auto-Init + +**What:** Define all tables (all phases) in `models.py`, auto-create on startup via `create_tables(safe=True)`. + +**When to use:** On every container start — `safe=True` is idempotent (no-op if tables already exist). + +**Example:** +```python +# db/database.py +from peewee import SqliteDatabase +import os + +DB_PATH = os.environ.get("DATA_DIR", "/data") + "/imptune.db" +db = SqliteDatabase(DB_PATH, pragmas={"journal_mode": "wal", "foreign_keys": 1}) + +def init_db(): + from db.models import Driver, Printer, Client, Icon + db.connect(reuse_if_open=True) + db.create_tables([Driver, Printer, Client, Icon], safe=True) +``` + +```python +# db/models.py +from peewee import * +from db.database import db +import datetime + +class BaseModel(Model): + class Meta: + database = db + +class Client(BaseModel): + name = CharField(unique=True) + created_at = DateTimeField(default=datetime.datetime.utcnow) + +class Driver(BaseModel): + sha256 = CharField(unique=True, index=True) # content-addressed key + original_filename = CharField() + size_bytes = IntegerField() + uploaded_at = DateTimeField(default=datetime.datetime.utcnow) + # Phase 2 fields (populated during INF parsing): + driver_desc = CharField(null=True) # parsed DriverDesc from INF + inf_filename = CharField(null=True) # which INF file inside the ZIP + architecture = CharField(null=True) # x64, x86, arm64 + has_cat_file = BooleanField(default=False) + +class Printer(BaseModel): + name = CharField() + ip_address = CharField() + port_name = CharField() + client = ForeignKeyField(Client, backref="printers", null=True) + driver = ForeignKeyField(Driver, backref="printers", null=True) + duplex_mode = CharField(default="OneSided") # OneSided|TwoSidedLongEdge|TwoSidedShortEdge + color_mode = BooleanField(default=True) + paper_size = CharField(default="A4") + collate = BooleanField(default=True) + created_at = DateTimeField(default=datetime.datetime.utcnow) + updated_at = DateTimeField(default=datetime.datetime.utcnow) + +class Icon(BaseModel): + printer = ForeignKeyField(Printer, backref="icons", unique=True) + sha256 = CharField() + original_filename = CharField() + size_bytes = IntegerField() + uploaded_at = DateTimeField(default=datetime.datetime.utcnow) +``` + +### Pattern 3: .intunewin File Assembly (Python-Native) + +**What:** Assemble a valid .intunewin file in Python without IntuneWinAppUtil.exe. + +**Verified byte layout (from svrooij.io decryption article):** +``` +Encrypted blob layout: + [0:32] — HMAC-SHA256 of the ciphertext (32 bytes) + [32:48] — AES-256-CBC Initialization Vector (16 bytes — standard AES block size) + [48:] — AES-256-CBC ciphertext (padded to 16-byte boundary) + +IMPORTANT: The IV is 16 bytes, not 32. STACK.md has a documentation error on this point. +``` + +**Detection.xml schema:** +```xml + + install.ps1 + 12345 + IntunePackage.intunewin + install.ps1 + + base64(32-byte AES key) + base64(32-byte HMAC key) + base64(16-byte IV) + base64(32-byte HMAC-SHA256) + SHA256 + ProfileVersion1 + base64(SHA256 of plaintext ZIP) + SHA256 + + +``` + +**Outer ZIP structure:** +``` +IntuneWinPackage/ +├── Contents/ +│ └── IntunePackage.intunewin ← the encrypted blob +└── Metadata/ + └── Detection.xml ← encryption metadata +``` + +**Python assembly skeleton:** +```python +# generators/intunewin_builder.py +import os, io, base64, hashlib, hmac, zipfile +from Crypto.Cipher import AES +from Crypto.Util.Padding import pad +from xml.etree.ElementTree import Element, SubElement, tostring +import xml.dom.minidom + +def build_intunewin(source_dir: str, setup_file: str, output_path: str) -> None: + """Build a .intunewin file from source_dir, with setup_file as entry point.""" + + # Step 1: Create inner ZIP (DEFLATE-compressed content) + inner_zip_buf = io.BytesIO() + with zipfile.ZipFile(inner_zip_buf, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for root, dirs, files in os.walk(source_dir): + for file in files: + abs_path = os.path.join(root, file) + arc_name = os.path.relpath(abs_path, source_dir) + zf.write(abs_path, arc_name) + plaintext = inner_zip_buf.getvalue() + + # Step 2: Encrypt with AES-256-CBC + aes_key = os.urandom(32) # 32-byte AES key + mac_key = os.urandom(32) # 32-byte HMAC key + iv = os.urandom(16) # 16-byte IV (standard AES block size) + cipher = AES.new(aes_key, AES.MODE_CBC, iv) + ciphertext = cipher.encrypt(pad(plaintext, AES.block_size)) + + # Step 3: Compute HMAC-SHA256 over ciphertext + mac = hmac.new(mac_key, ciphertext, hashlib.sha256).digest() + + # Step 4: Assemble encrypted blob: [HMAC(32)] + [IV(16)] + [ciphertext] + encrypted_blob = mac + iv + ciphertext + + # Step 5: Compute plaintext digest for Detection.xml + file_digest = hashlib.sha256(plaintext).digest() + + # Step 6: Build Detection.xml + app_info = Element("ApplicationInfo", + xmlns="http://schemas.microsoft.com/IntuneWin") + SubElement(app_info, "Name").text = setup_file + SubElement(app_info, "UnencryptedContentSize").text = str(len(plaintext)) + SubElement(app_info, "FileName").text = "IntunePackage.intunewin" + SubElement(app_info, "SetupFile").text = setup_file + enc = SubElement(app_info, "EncryptionInfo") + SubElement(enc, "EncryptionKey").text = base64.b64encode(aes_key).decode() + SubElement(enc, "MacKey").text = base64.b64encode(mac_key).decode() + SubElement(enc, "InitializationVector").text = base64.b64encode(iv).decode() + SubElement(enc, "Mac").text = base64.b64encode(mac).decode() + SubElement(enc, "MacAlgorithm").text = "SHA256" + SubElement(enc, "ProfileIdentifier").text = "ProfileVersion1" + SubElement(enc, "FileDigest").text = base64.b64encode(file_digest).decode() + SubElement(enc, "FileDigestAlgorithm").text = "SHA256" + detection_xml = xml.dom.minidom.parseString(tostring(app_info)).toprettyxml() + + # Step 7: Build outer ZIP (STORED — no extra compression on encrypted content) + with zipfile.ZipFile(output_path, "w", compression=zipfile.ZIP_STORED) as outer: + outer.writestr("IntuneWinPackage/Contents/IntunePackage.intunewin", + encrypted_blob) + outer.writestr("IntuneWinPackage/Metadata/Detection.xml", + detection_xml) +``` + +**Source:** svrooij.io decryption article (verified format), volodymyrsmirnov/IntuneWin C# reference (structure verified) + +### Anti-Patterns to Avoid + +- **Alpine Linux base image:** musl libc breaks pycryptodome and other C-extension wheels; use `python:3.12-slim-bookworm` only. +- **Downloading assets at container runtime:** Never use CDN links in HTML templates; all assets must be served from `/app/static/` which is baked into the image. +- **Tailwind CDN Play script in templates:** Per Tailwind docs, Play CDN is development-only. The locked decision already chooses Pico CSS — a pre-built static file that needs no CDN at runtime. +- **Storing the SQLite file inside the container filesystem:** Always mount `/data` as a named volume; SQLite must persist across container restarts. +- **`peewee.database.connect()` without WAL mode:** SQLite default journal mode is DELETE; enable WAL (`"journal_mode": "wal"`) so reads don't block writes during generation. +- **32-byte IV in .intunewin:** Standard AES-CBC IV is 16 bytes (AES block size). Using 32 bytes will produce a non-compliant file that Intune will reject. The STACK.md documentation has this wrong — use 16 bytes. + +--- + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| AES-256-CBC encryption | Custom AES implementation | `pycryptodome` (`from Crypto.Cipher import AES`) | Padding edge cases, IV handling, block alignment — stdlib `hashlib` does not provide AES | +| HMAC-SHA256 | Custom HMAC | Python stdlib `hmac.new(key, data, hashlib.sha256)` | Already in stdlib, correct constant-time comparison built in | +| SQLite schema management | Raw `CREATE TABLE IF NOT EXISTS` strings | Peewee `create_tables(safe=True)` | Migration safety, model-to-SQL mapping, foreign key management | +| Serving static files in FastAPI | Custom file-serving route | `app.mount("/static", StaticFiles(directory="static"))` | FastAPI's built-in `StaticFiles` handles ETags, range requests, content-type detection | +| Docker healthcheck HTTP request | curl (which may not be in final image) | Python one-liner: `python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"` | Uses stdlib; no curl dependency in the slim image | + +**Key insight:** pycryptodome handles all the crypto complexity. The hard part of the .intunewin spike is not the encryption itself — it's assembling the exact byte layout Intune expects and validating the output against a real tenant. + +--- + +## Common Pitfalls + +### Pitfall 1: Wrong IV Size in .intunewin (CRITICAL) + +**What goes wrong:** Using a 32-byte IV instead of the correct 16-byte AES block size. The encrypted blob format is `[HMAC-SHA256 (32 bytes)] + [IV (16 bytes)] + [ciphertext]`. The total overhead is 48 bytes, not 64. Files built with a 32-byte IV will fail to decrypt on the Intune side. + +**Why it happens:** STACK.md states "32-byte IV" — this is a documentation error. The decryption article confirms 16 bytes via `.NET's aes.IV.Length` (which is always 16 for AES). + +**How to avoid:** Always use `iv = os.urandom(16)` and `AES.new(key, AES.MODE_CBC, iv)` where `len(iv) == 16`. + +**Warning signs:** `ValueError: IV must be 16 bytes long` from pycryptodome if you use 32. + +### Pitfall 2: Storing State in Container Filesystem + +**What goes wrong:** Writing the SQLite file or driver ZIPs to `/app/` or `/tmp/`. Data disappears on container restart. + +**Why it happens:** Default working directory in Docker is the app folder; developers forget to configure the volume. + +**How to avoid:** Set `DATA_DIR=/data` env var. `docker-compose.yml` mounts `imptune_data:/data`. SQLite path must derive from `DATA_DIR`. Driver files go to `DATA_DIR/drivers/`. Never write persistent data outside the volume mount. + +**Warning signs:** Fresh database on every `docker compose restart`. + +### Pitfall 3: Alpine Base Image Breaking pycryptodome + +**What goes wrong:** Using `python:3.12-alpine` as the Docker base. pycryptodome requires C extensions; the pre-built wheels target glibc, not Alpine's musl libc. pip will try to compile from source (requiring gcc/musl-dev) and often fails silently or produces a broken install. + +**Why it happens:** Alpine is smaller, so it seems attractive for Docker images. + +**How to avoid:** Use `python:3.12-slim-bookworm` (Debian 12). The final image will be slightly larger (~150-200 MB vs ~80 MB for Alpine) but will reliably install all C-extension packages. + +### Pitfall 4: CDN Assets Requested at Runtime + +**What goes wrong:** HTML templates reference ``. The container starts but the browser gets no CSS/JS when running on an air-gapped network. + +**Why it happens:** Developers test on internet-connected machines where CDN works; the failure only manifests on offline deployments. + +**How to avoid:** All `` and ` + + + +
+ +
{% block content %}{% endblock %}
+
+ + +``` + +`data-theme="auto"` instructs Pico CSS to follow the OS `prefers-color-scheme` media query automatically. No JavaScript needed. + +### Docker healthcheck without curl + +```dockerfile +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1 +``` + +### FastAPI health endpoint + +```python +# api/health.py +from fastapi import APIRouter +router = APIRouter() + +@router.get("/health") +def health(): + return {"status": "ok"} +``` + +--- + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| IntuneWinAppUtil.exe (Windows-only binary) | Python-native .intunewin (zipfile + pycryptodome) | 2023 — svrooij reverse-engineered format | Linux containers can now generate .intunewin without Wine or Windows base | +| Tailwind CDN Play in templates | Pre-built CSS framework (Pico CSS) served as static file | Phase 1 decision (2026-04-10) | Zero CDN dependency; air-gapped compatible | +| Gunicorn + Flask | Uvicorn + FastAPI | 2022-2024 ecosystem shift | Async-capable, Pydantic validation built in, less boilerplate | +| SQLAlchemy async | Peewee sync | Phase 1 decision | No async overhead for SQLite single-writer; simpler code | + +**Deprecated/outdated:** +- `pycrypto`: Unmaintained since 2012, known CVEs. Use `pycryptodome` (maintained drop-in, `from Crypto.Cipher import AES`). +- `Tailwind Play CDN`: Explicitly marked as development-only by Tailwind docs. Not suitable for production or air-gapped environments. +- `Alpine Linux base image`: Avoid for any Python project using C-extension packages (pycryptodome, lxml, etc.). + +--- + +## Open Questions + +1. **Inner ZIP compression method (DEFLATE vs STORED)** + - What we know: The outer ZIP uses ZIP_STORED; the inner ZIP content appears to use DEFLATE (C# default, `volodymyrsmirnov/IntuneWin` uses default `.NET ZipArchive`). STACK.md says DEFLATE. + - What's unclear: Whether Intune requires DEFLATE specifically or accepts ZIP_STORED for the inner package. One search result stated "no compression used" which conflicts with STACK.md. + - Recommendation: Implement with `ZIP_DEFLATED` first (matches the reference implementation behavior). If Intune rejects it, try `ZIP_STORED`. The real Intune upload test in the spike will resolve this definitively. + +2. **MacKey vs EncryptionKey sizes** + - What we know: EncryptionKey is 32 bytes (256-bit AES). MacKey is also described as a separate key for HMAC-SHA256. Standard HMAC-SHA256 can use any key size (SHA-256 block size is 64 bytes, but 32 bytes is common). + - What's unclear: Whether MacKey must be exactly 32 bytes or can differ. The svrooij articles don't state the MacKey size explicitly. + - Recommendation: Use `mac_key = os.urandom(32)` (32 bytes) — same size as the AES key, consistent with svrooij ContentPrep behavior. + +3. **Pico CSS v2 sidebar layout** + - What we know: Pico CSS v2 is a classless/minimal framework with a `container` component and grid support. It does not have a built-in sidebar layout. + - What's unclear: Whether additional CSS will be needed for the persistent sidebar, or if Pico's grid/flex utilities suffice. + - Recommendation: Add a small `app.css` static file alongside `pico.min.css` for layout overrides (sidebar width, flex container). Keep it under 50 lines. This is Claude's discretion per CONTEXT.md. + +--- + +## Validation Architecture + +### Test Framework + +| Property | Value | +|----------|-------| +| Framework | pytest (to be installed in Wave 0) | +| Config file | None — see Wave 0 | +| Quick run command | `pytest tests/ -x -q` | +| Full suite command | `pytest tests/ -v` | + +### Phase Requirements → Test Map + +| Req ID | Behavior | Test Type | Automated Command | File Exists? | +|--------|----------|-----------|-------------------|-------------| +| INFRA-01 | Container starts and returns HTTP 200 on GET /health | smoke | `pytest tests/test_health.py -x` | Wave 0 | +| INFRA-01 | SQLite database initializes with correct tables on first run | unit | `pytest tests/test_db.py::test_create_tables -x` | Wave 0 | +| INFRA-02 | No Node.js process or external DB in running container | manual | `docker inspect imptune \| grep node` (manual check) | manual-only | +| INFRA-02 | All static assets served from /static/ (no CDN URLs in HTML) | unit | `pytest tests/test_static.py::test_no_cdn_urls -x` | Wave 0 | +| (spike) | .intunewin file has correct byte layout (HMAC+IV+ciphertext) | unit | `pytest tests/test_intunewin.py::test_byte_layout -x` | Wave 0 | +| (spike) | .intunewin uploads successfully to real Intune tenant | manual | Upload test — manual, requires Intune access | manual-only | + +### Sampling Rate + +- **Per task commit:** `pytest tests/ -x -q` +- **Per wave merge:** `pytest tests/ -v` +- **Phase gate:** Full suite green before `/gsd:verify-work` + +### Wave 0 Gaps + +- [ ] `tests/__init__.py` — package marker +- [ ] `tests/conftest.py` — shared fixtures (temp dir, test DB path) +- [ ] `tests/test_health.py` — covers INFRA-01 HTTP health check +- [ ] `tests/test_db.py` — covers INFRA-01 schema init (all tables created, WAL mode enabled) +- [ ] `tests/test_static.py` — covers INFRA-02 no-CDN-URLs assertion (scan templates) +- [ ] `tests/test_intunewin.py` — covers spike byte layout validation +- [ ] Framework install: `pip install pytest` — add to `requirements-dev.txt` + +--- + +## Sources + +### Primary (HIGH confidence) + +- svrooij.io — Decrypting intunewin files (2023-10-09) — confirmed IV=16 bytes, HMAC-SHA256 layout +- svrooij.io — Creating IntuneWin files with C# (2023-10-24) — Detection.xml schema, outer ZIP structure +- svrooij.io — Analysing Win32 Content Prep Tool (2023-10-04) — encryption key sizes, overhead byte count +- [FastAPI deployment with Docker — Official Docs](https://fastapi.tiangolo.com/deployment/docker/) — Dockerfile patterns, CMD, volume +- [FastAPI StaticFiles — Official Docs](https://fastapi.tiangolo.com/tutorial/static-files/) — static asset serving +- [Pico CSS v2 — Official Docs](https://picocss.com/docs) — `data-theme="auto"`, classless usage +- [Peewee ORM docs](https://docs.peewee-orm.com/en/latest/) — `create_tables`, WAL mode pragma, sync patterns with FastAPI +- [pycryptodome docs — AES CBC examples](https://pycryptodome.readthedocs.io/en/latest/src/examples.html) — AES-CBC usage, padding + +### Secondary (MEDIUM confidence) + +- volodymyrsmirnov/IntuneWin (GitHub) — C# reference implementation; confirmed DEFLATE for inner ZIP (default .NET behavior) +- SvRooij.ContentPrep NuGet 0.4.2 (2025-10-03) — cross-platform validation that the format is stable and reimplementable +- STACK.md (project research, 2026-04-10) — stack decisions; NOTE: IV size stated as 32 bytes is incorrect, should be 16 + +### Tertiary (LOW confidence) + +- WebSearch result claiming "no compression used" for inner ZIP — conflicts with STACK.md and .NET default behavior; needs spike to resolve + +--- + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH — all libraries verified against official docs; versions confirmed compatible +- Docker scaffold pattern: HIGH — standard FastAPI Docker deployment, well-documented +- Peewee schema pattern: HIGH — official Peewee docs, straightforward sync ORM usage +- .intunewin format: MEDIUM — format confirmed by reverse-engineering; IV size corrected (16 bytes); inner ZIP compression TBD; must validate against real Intune tenant +- Pitfalls: HIGH — all pitfalls derived from verified sources or official documentation + +**Research date:** 2026-04-10 +**Valid until:** 2026-05-10 (stable ecosystem; .intunewin format validity: confirm during spike) + +**Critical correction flagged:** STACK.md states "32-byte IV" for .intunewin encryption. Multiple sources (AES standard, svrooij.io decryption article citing `.NET aes.IV.Length = 16`) confirm the IV is 16 bytes. The planner must use 16 bytes in the spike implementation. diff --git a/.planning/phases/01-foundation/01-VALIDATION.md b/.planning/phases/01-foundation/01-VALIDATION.md new file mode 100644 index 0000000..b961a1b --- /dev/null +++ b/.planning/phases/01-foundation/01-VALIDATION.md @@ -0,0 +1,113 @@ +--- +phase: 1 +slug: foundation +status: draft +nyquist_compliant: true +wave_0_complete: false +created: 2026-04-10 +nyquist_audited: 2026-04-13 +nyquist_auditor: Claude (gsd-executor, plan 08-01) +--- + +# Phase 1 — Validation Strategy + +> Per-phase validation contract for feedback sampling during execution. + +--- + +## Test Infrastructure + +| Property | Value | +|----------|-------| +| **Framework** | pytest 8.x | +| **Config file** | none — Wave 0 installs | +| **Quick run command** | `pytest tests/ -x -q` | +| **Full suite command** | `pytest tests/ -v` | +| **Estimated runtime** | ~5 seconds | + +--- + +## Sampling Rate + +- **After every task commit:** Run `pytest tests/ -x -q` +- **After every plan wave:** Run `pytest tests/ -v` +- **Before `/gsd:verify-work`:** Full suite must be green +- **Max feedback latency:** 10 seconds + +--- + +## Per-Task Verification Map + +| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status | +|---------|------|------|-------------|-----------|-------------------|-------------|--------| +| 01-01-01 | 01 | 1 | INFRA-01 | smoke | `pytest tests/test_health.py -x` | ❌ W0 | ⬜ pending | +| 01-01-02 | 01 | 1 | INFRA-02 | unit | `pytest tests/test_static.py::test_no_cdn_urls -x` | ❌ W0 | ⬜ pending | +| 01-02-01 | 02 | 1 | INFRA-01 | unit | `pytest tests/test_db.py::test_create_tables -x` | ❌ W0 | ⬜ pending | +| 01-03-01 | 03 | 1 | INFRA-02 | unit | `pytest tests/test_intunewin.py::test_byte_layout -x` | ❌ W0 | ⬜ pending | +| 01-03-02 | 03 | 1 | (spike) | manual | Upload to real Intune tenant | N/A | ⬜ pending | + +*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky* + +--- + +## Wave 0 Requirements + +- [ ] `tests/__init__.py` — package marker +- [ ] `tests/conftest.py` — shared fixtures (temp dir, test DB path) +- [ ] `tests/test_health.py` — covers INFRA-01 HTTP health check +- [ ] `tests/test_db.py` — covers INFRA-01 schema init (all tables created, WAL mode) +- [ ] `tests/test_static.py` — covers INFRA-02 no-CDN-URLs assertion +- [ ] `tests/test_intunewin.py` — covers spike byte layout validation +- [ ] Framework install: `pip install pytest httpx` — add to `requirements-dev.txt` + +*Existing infrastructure covers: None (greenfield project)* + +--- + +## Manual-Only Verifications + +| Behavior | Requirement | Why Manual | Test Instructions | +|----------|-------------|------------|-------------------| +| No Node.js in container | INFRA-02 | Requires running container inspection | `docker exec imptune which node` — should return nothing | +| .intunewin uploads to real Intune | (spike) | Requires Intune tenant access | Upload generated .intunewin via Intune portal, verify "successfully uploaded" status | + +--- + +## Nyquist Record + +> Audited 2026-04-13 by Claude (gsd-executor, plan 08-01). One row per Phase 1 success criterion derived from `milestones/v1.0-ROADMAP.md` Phase 1 goal + plan outcomes, cross-checked against `01-VERIFICATION.md` (13/13 observable truths verified on 2026-04-10) and `REQUIREMENTS.md` (INFRA-01, INFRA-02). Evidence cites committed tests, source lines, or the dated VERIFICATION report. Status values: `pass` / `fail-fix-v1.1` / `deferred-v1.2` / `wont-do`. + +**Phase 1 goal (v1.0-ROADMAP.md):** *"A running Docker container with the app scaffold, data schema, and validated .intunewin generation capability."* + +| # | Success Criterion | Observable Check | Evidence | Status | Notes | +|---|-------------------|------------------|----------|--------|-------| +| 1 | `docker compose up` starts the app and serves HTTP 200 on `GET /health` | `pytest tests/test_health.py::test_health_returns_200` returns the health payload | `tests/test_health.py::test_health_returns_200`; `imptune/api/health.py` (router returns `{"status": "ok"}`); 01-VERIFICATION.md row 1 (2026-04-10) | pass | INFRA-01. Docker image build itself is a human check (network to pico/htmx/alpine CDNs); covered by 01-VERIFICATION.md §"Human Verification Required" #1 and later exercised end-to-end during Phase 10 RTVAL-01 tenant upload (commit 7b37bdb referenced build 1c3f458). | +| 2 | Container has no Node.js dependency and starts from a single `python:3.12-slim-bookworm` image | `grep -n "^FROM" Dockerfile` returns only `FROM python:3.12-slim-bookworm`; no `node`/`npm` install layer | `Dockerfile` line 1; commit 34c7cb3 (`feat(01-01)`); 01-VERIFICATION.md row 2 | pass | INFRA-02 — "no Node.js" arm. | +| 3 | All static assets (Pico CSS, HTMX, Alpine.js) are served from `/static/` with zero CDN references in templates | `pytest tests/test_static.py::test_no_cdn_urls_in_templates` | `tests/test_static.py::test_no_cdn_urls_in_templates`; `imptune/templates/base.html` (4 `/static/` refs, zero `https://`); 01-VERIFICATION.md row 3 | pass | INFRA-02 — offline static arm. | +| 4 | App shell displays a sidebar with Dashboard, Drivers, Printers, Clients, Packages sections | Grep `imptune/templates/base.html` for the 5 nav hrefs (`/`, `/drivers`, `/printers`, `/clients`, `/packages`) | `imptune/templates/base.html` sidebar nav; 01-VERIFICATION.md row 4; Phase 7 `GET /packages` closure (commit landed under phase 07) proves the link is live | pass | Dashboard quick-action buttons intentionally `aria-disabled` in Phase 1 — documented, not a gap. | +| 5 | App follows OS dark/light theme preference automatically | Grep `imptune/templates/base.html` line 2 for `data-theme="auto"` | `imptune/templates/base.html` line 2; 01-VERIFICATION.md row 5 | pass | UI polish criterion from 01-01 plan frontmatter. | +| 6 | SQLite database initializes automatically on first run with all 4 tables (Client, Driver, Printer, Icon) | `pytest tests/test_db.py::test_create_tables` | `tests/test_db.py::test_create_tables`; `imptune/db/database.py::init_db`; `imptune/main.py` lifespan call (commit 88d9c5f); 01-VERIFICATION.md row 6 | pass | INFRA-01 — schema arm. Full 4-table upfront schema decision (v1.0 key decision). | +| 7 | Database uses WAL journal mode and has foreign keys enabled | `pytest tests/test_db.py::test_wal_mode` and `::test_foreign_keys` | `tests/test_db.py::test_wal_mode`, `::test_foreign_keys`; `imptune/db/database.py` pragmas `{"journal_mode": "wal", "foreign_keys": 1}`; 01-VERIFICATION.md row 7 | pass | | +| 8 | Database file is created inside the `DATA_DIR` volume path, not inside the container filesystem | Grep `imptune/db/database.py` for `cfg.DB_PATH`; grep `docker-compose.yml` for `imptune_data:/data`; grep for `DATA_DIR=/data` env | `imptune/db/database.py` (`db.init(cfg.DB_PATH, ...)`); `docker-compose.yml` named volume + env; 01-VERIFICATION.md row 8 | pass | Persistence-across-restart property. | +| 9 | Schema creation is idempotent — repeated startups do not fail or duplicate tables | `pytest tests/test_db.py::test_idempotent` | `tests/test_db.py::test_idempotent`; `create_tables(..., safe=True)` in `init_db()`; 01-VERIFICATION.md row 9 | pass | | +| 10 | A Python function produces a valid `.intunewin` file from a source directory and setup file name | `pytest tests/test_intunewin.py::test_output_is_valid_zip` | `tests/test_intunewin.py::test_output_is_valid_zip`; `imptune/generators/intunewin_builder.py::build_intunewin`; commit 25f82e6; 01-VERIFICATION.md row 10 | pass | Python-native .intunewin core decision (pycryptodome, no IntuneWinAppUtil.exe). | +| 11 | `.intunewin` output contains outer ZIP with `IntuneWinPackage/Contents/IntunePackage.intunewin` and `IntuneWinPackage/Metadata/Detection.xml` | `pytest tests/test_intunewin.py::test_outer_zip_structure` | `tests/test_intunewin.py::test_outer_zip_structure`; `imptune/generators/intunewin_builder.py` outer-ZIP assembly lines 103-111; 01-VERIFICATION.md row 11 | pass | | +| 12 | Encrypted blob uses correct byte layout: HMAC-SHA256 (32 bytes) + IV (16 bytes) + AES-256-CBC ciphertext | `pytest tests/test_intunewin.py::test_encrypted_blob_layout tests/test_intunewin.py::test_iv_is_16_bytes tests/test_intunewin.py::test_hmac_matches` | `tests/test_intunewin.py` (`test_encrypted_blob_layout`, `test_iv_is_16_bytes`, `test_hmac_matches`); `imptune/generators/intunewin_builder.py` (blob = `mac_digest + iv + ciphertext`); 01-VERIFICATION.md row 12 | pass | HMAC-over-IV+ciphertext scope later hardened in commit 74535ea during Phase 10 RTVAL-01 debug — but the byte-layout contract verified here is still the canonical one. | +| 13 | `Detection.xml` contains correct `EncryptionKey`, `MacKey`, `InitializationVector`, `Mac`, `FileDigest` values matching the actual encryption | `pytest tests/test_intunewin.py::test_detection_xml_fields tests/test_intunewin.py::test_decryption_roundtrip tests/test_intunewin.py::test_file_digest_matches tests/test_intunewin.py::test_unencrypted_content_size` | `tests/test_intunewin.py` (5 tests listed); 01-VERIFICATION.md row 13 | pass | Detection.xml field ordering also re-aligned with IntuneWinAppUtil.exe reference format in commit 7716246 (Phase 10 debug); byte-level equivalence preserved. | +| 14 | `.intunewin` output is accepted by a real Microsoft Intune tenant end-to-end (decrypt + app registration) | Dated runtime check recorded in Phase 10 `RUNTIME-VALIDATION.md` (RTVAL-01) | Phase 10 `RUNTIME-VALIDATION.md` RTVAL-01 PASS (2026-04-13, re-test on fixed build after ISSUE-01 resolved via commits 74535ea + 7716246); artifact `.planning/phases/10-real-world-runtime-validation/evidence/Copieur_2eme.intunewin`; STATE.md decision log [Phase 10-01 / 10-02 RTVAL-01 PASS] | pass | Was the single Phase 1 Nyquist gap ("Upload to real Intune tenant" spike in the Manual-Only Verifications table above). Resolved by Phase 10 (NYQ→RTVAL-01) on 2026-04-13; originally would have been `fail-fix-v1.1` → Phase 10 / RTVAL-01, now closed as `pass` citing the Phase 10 sign-off. | + +**Audit outcome:** 14/14 rows `pass`. No `fail-fix-v1.1`, `deferred-v1.2`, or `wont-do` rows. Phase 1 is Nyquist-compliant: every success criterion has exactly one observable check with cited, committed evidence. + +--- + +## Validation Sign-Off + +- [ ] All tasks have `` verify or Wave 0 dependencies +- [ ] Sampling continuity: no 3 consecutive tasks without automated verify +- [ ] Wave 0 covers all MISSING references +- [ ] No watch-mode flags +- [ ] Feedback latency < 10s +- [x] `nyquist_compliant: true` set in frontmatter +- [x] Nyquist audit complete — 2026-04-13 — Sébastien QUEROL + +**Approval:** Nyquist-audited 2026-04-13 by Claude (gsd-executor, plan 08-01) — 14/14 pass; signed off 2026-04-13 by Sébastien QUEROL (index: v1.0-VALIDATION-INDEX.md) diff --git a/.planning/phases/01-foundation/01-VERIFICATION.md b/.planning/phases/01-foundation/01-VERIFICATION.md new file mode 100644 index 0000000..318edb0 --- /dev/null +++ b/.planning/phases/01-foundation/01-VERIFICATION.md @@ -0,0 +1,156 @@ +--- +phase: 01-foundation +verified: 2026-04-10T10:00:00Z +status: passed +score: 13/13 must-haves verified +re_verification: false +--- + +# Phase 01: Foundation Verification Report + +**Phase Goal:** A running Docker container with the app scaffold, data schema, and a validated .intunewin generation capability +**Verified:** 2026-04-10T10:00:00Z +**Status:** PASSED +**Re-verification:** No — initial verification + +--- + +## Goal Achievement + +### Observable Truths + +All must-haves are drawn directly from PLAN frontmatter across the three plans that make up this phase. + +#### From Plan 01-01 (Docker Scaffold + App Shell) + +| # | Truth | Status | Evidence | +|---|-------|--------|----------| +| 1 | Running docker compose up starts the app and serves HTTP 200 on GET /health | VERIFIED | `imptune/api/health.py` returns `{"status": "ok"}`; `test_health_returns_200` passes; `docker-compose.yml` and `Dockerfile` both present and wired | +| 2 | The container has no Node.js dependency and starts from a single `python:3.12-slim-bookworm` image | VERIFIED | `Dockerfile` line 1: `FROM python:3.12-slim-bookworm`; no Node.js install in any RUN layer | +| 3 | All static assets (Pico CSS, HTMX, Alpine.js) are served from /static/ with zero CDN references in templates | VERIFIED | `base.html` uses `/static/pico.min.css`, `/static/app.css`, `/static/alpine.min.js`, `/static/htmx.min.js` exclusively; `test_no_cdn_urls_in_templates` passes | +| 4 | The app shell displays a sidebar with Dashboard, Drivers, Printers, Clients, Packages sections | VERIFIED | `base.html` sidebar nav contains all 5 href links: `/`, `/drivers`, `/printers`, `/clients`, `/packages` | +| 5 | The app follows OS dark/light theme preference automatically | VERIFIED | `base.html` line 2: `` | + +#### From Plan 01-02 (Database Schema + Driver Storage) + +| # | Truth | Status | Evidence | +|---|-------|--------|----------| +| 6 | SQLite database initializes automatically on first run with all tables (Client, Driver, Printer, Icon) | VERIFIED | `init_db()` called in `lifespan` in `main.py`; `test_create_tables` passes; all 4 tables confirmed | +| 7 | Database uses WAL journal mode and has foreign keys enabled | VERIFIED | `database.py` pragmas: `{"journal_mode": "wal", "foreign_keys": 1}`; `test_wal_mode` and `test_foreign_keys` pass | +| 8 | Database file is created inside the DATA_DIR volume path, not inside the container filesystem | VERIFIED | `database.py` reads `cfg.DB_PATH` (which is `DATA_DIR/imptune.db`); `docker-compose.yml` mounts `imptune_data:/data`; `DATA_DIR=/data` env var set | +| 9 | Schema creation is idempotent — repeated startups do not fail or duplicate tables | VERIFIED | `create_tables(..., safe=True)` in `init_db()`; `test_idempotent` passes | + +#### From Plan 01-03 (.intunewin Builder) + +| # | Truth | Status | Evidence | +|---|-------|--------|----------| +| 10 | A Python function produces a valid .intunewin file from a source directory and setup file name | VERIFIED | `build_intunewin(source_dir, setup_file, output_path)` in `intunewin_builder.py`; `test_output_is_valid_zip` passes | +| 11 | The .intunewin file contains an outer ZIP with `IntuneWinPackage/Contents/IntunePackage.intunewin` and `IntuneWinPackage/Metadata/Detection.xml` | VERIFIED | `test_outer_zip_structure` passes; confirmed by direct inspection of `intunewin_builder.py` lines 103-111 | +| 12 | The encrypted blob uses the correct byte layout: HMAC-SHA256 (32 bytes) + IV (16 bytes) + AES-256-CBC ciphertext | VERIFIED | `test_encrypted_blob_layout`, `test_iv_is_16_bytes`, `test_hmac_matches` all pass; blob assembled as `mac_digest + iv + ciphertext` | +| 13 | Detection.xml contains correct EncryptionKey, MacKey, InitializationVector, Mac, FileDigest values that match the actual encryption | VERIFIED | `test_detection_xml_fields`, `test_hmac_matches`, `test_decryption_roundtrip`, `test_file_digest_matches`, `test_unencrypted_content_size` all pass | + +**Score: 13/13 truths verified** + +--- + +### Required Artifacts + +| Artifact | Expected | Status | Details | +|----------|----------|--------|---------| +| `Dockerfile` | Single-container build with baked-in static assets | VERIFIED | Present; `FROM python:3.12-slim-bookworm`; curl downloads pico.min.css, htmx.min.js, alpine.min.js in single RUN layer; curl purged after | +| `docker-compose.yml` | Container orchestration with named volume | VERIFIED | Present; `imptune_data:/data` volume; `DATA_DIR=/data`; `restart: unless-stopped` | +| `imptune/main.py` | FastAPI app entrypoint with static files mount and router registration | VERIFIED | Exports `app`; mounts `/static`; includes `health.router` and `pages.router`; calls `init_db()` in lifespan | +| `imptune/api/health.py` | GET /health endpoint for Docker healthcheck | VERIFIED | Exports `router`; `GET /health` returns `{"status": "ok"}` | +| `imptune/templates/base.html` | Layout template with sidebar navigation and static asset includes | VERIFIED | `data-theme="auto"` on html element; all 5 nav sections; /static/ paths only | +| `imptune/db/database.py` | Peewee SqliteDatabase instance with WAL mode and init_db function | VERIFIED | Exports `db` and `init_db`; deferred init pattern; WAL + FK pragmas | +| `imptune/db/models.py` | All ORM models for phases 1-5 (BaseModel, Client, Driver, Printer, Icon) | VERIFIED | Exports all 5 classes; `BaseModel.Meta.database = db`; full field definitions present | +| `imptune/storage/driver_store.py` | SHA256 content-addressed file storage abstraction for driver packages | VERIFIED | Exports `DriverStore`; `save()`, `get_path()`, `exists()` methods; deduplication via `if not dest.exists()` | +| `tests/test_db.py` | Database initialization and schema validation tests | VERIFIED | 7 tests; all pass | +| `imptune/generators/intunewin_builder.py` | Python-native .intunewin file assembler using pycryptodome | VERIFIED | 111 lines (min_lines: 60 met); exports `build_intunewin`; uses `Crypto.Cipher` and `zipfile.ZipFile` | +| `tests/test_intunewin.py` | Byte-level validation tests for .intunewin format | VERIFIED | 266 lines (min_lines: 80 met); 14 tests across 4 test classes; all pass | + +--- + +### Key Link Verification + +| From | To | Via | Status | Details | +|------|----|-----|--------|---------| +| `Dockerfile` | `imptune/static/` | curl downloads during build | VERIFIED | Lines 8-13: `curl -sL --fail -o /app/imptune/static/pico.min.css`, `htmx.min.js`, `alpine.min.js` | +| `imptune/main.py` | `imptune/api/health.py` | include_router | VERIFIED | `app.include_router(health.router)` present | +| `imptune/templates/base.html` | `/static/` | link and script tags | VERIFIED | 4 /static/ references; zero https:// in href/src; confirmed by passing test | +| `imptune/main.py` | `imptune/db/database.py` | startup event calling `init_db()` | VERIFIED | `from imptune.db.database import init_db`; called inside `lifespan()` before yield | +| `imptune/db/models.py` | `imptune/db/database.py` | `BaseModel.Meta.database = db` | VERIFIED | `from imptune.db.database import db`; `class Meta: database = db` | +| `imptune/db/database.py` | `imptune/config.py` | DB_PATH from config | VERIFIED | `import imptune.config as cfg`; `db.init(cfg.DB_PATH, ...)` | +| `imptune/generators/intunewin_builder.py` | pycryptodome | `from Crypto.Cipher import AES` | VERIFIED | Line 31: `from Crypto.Cipher import AES`; Line 32: `from Crypto.Util.Padding import pad` | +| `imptune/generators/intunewin_builder.py` | zipfile | stdlib zipfile for inner and outer ZIPs | VERIFIED | Line 27: `import zipfile`; inner ZIP with `ZIP_DEFLATE`, outer with `ZIP_STORED` | + +--- + +### Requirements Coverage + +| Requirement | Source Plans | Description | Status | Evidence | +|-------------|-------------|-------------|--------|----------| +| INFRA-01 | 01-01, 01-02 | Application runs as a single Docker container | SATISFIED | `Dockerfile` uses `python:3.12-slim-bookworm`; `docker-compose.yml` defines single `imptune` service with `imptune_data:/data` named volume | +| INFRA-02 | 01-01, 01-02, 01-03 | Application has minimal runtime dependencies (no Node.js, no external DB) | SATISFIED | No Node.js in Dockerfile; SQLite via Peewee (file-based, no server); .intunewin built via pycryptodome (no IntuneWinAppUtil.exe) | + +No REQUIREMENTS.md entries for Phase 1 are orphaned. The traceability table marks both INFRA-01 and INFRA-02 as Complete. All three plans claim these requirement IDs and provide substantive implementation evidence. + +--- + +### Anti-Patterns Found + +None. Full scan of `imptune/` and `tests/` found: +- Zero TODO/FIXME/HACK/PLACEHOLDER comments +- Zero empty handler stubs (`return null`, `return {}`, `return []`) +- Zero CDN URLs in templates (verified by automated test) +- Zero console.log-only implementations + +One informational note: `dashboard.html` quick-action buttons use `href="#"` with `aria-disabled="true"` — this is intentional Phase 1 scaffolding documented in the plan as "non-functional in Phase 1". + +--- + +### Human Verification Required + +Two items cannot be verified programmatically and require a human check before declaring production-ready: + +#### 1. Docker Image Build + +**Test:** Run `docker compose build` in the project root. +**Expected:** Build completes successfully; curl downloads all three assets (pico.min.css, htmx.min.js, alpine.min.js) from CDNs; curl is purged afterwards; `docker compose up` starts the container and `docker compose ps` shows status `healthy`. +**Why human:** The Dockerfile is syntactically valid and the HEALTHCHECK uses stdlib urllib (correct), but the build requires network access to cdn.jsdelivr.net and unpkg.com. This cannot be confirmed without running Docker. + +#### 2. Real Intune Upload Validation + +**Test:** Upload the output of `build_intunewin()` to a real Microsoft Intune tenant as a Win32 app. +**Expected:** Intune accepts the package, decrypts it successfully, and the app appears in the Intune portal ready for assignment. +**Why human:** All 14 byte-level tests pass, including full decrypt roundtrip. However, the RESEARCH.md and plan 01-03 explicitly acknowledge this as an outstanding validation gate. The inner ZIP compression mode (DEFLATE) was chosen based on C# reference behavior — if Intune rejects it, switching to ZIP_STORED is the likely fix. This gate is deferred to Phase 5. + +--- + +### Test Suite Summary + +``` +24 passed, 0 failed, 4 warnings in 0.42s +``` + +| Test File | Tests | Result | +|-----------|-------|--------| +| `tests/test_health.py` | 1 | All pass | +| `tests/test_static.py` | 2 | All pass | +| `tests/test_db.py` | 7 | All pass | +| `tests/test_intunewin.py` | 14 | All pass | + +The 4 warnings are `DeprecationWarning: 'asyncio.iscoroutinefunction' is deprecated` from FastAPI internals on Python 3.14 — not from application code and not a blocker. + +--- + +## Summary + +Phase 01-foundation fully achieves its goal. The running container scaffold exists (`Dockerfile`, `docker-compose.yml`), the app serves HTTP with a sidebar navigation shell and GET /health endpoint, the SQLite schema auto-initializes in the DATA_DIR volume with WAL mode and all 4 tables, and the `.intunewin` builder passes 14 byte-level cryptographic validation tests. All three plans executed cleanly with zero stub artifacts or broken wiring. + +Both INFRA-01 and INFRA-02 are satisfied with implementation evidence. The only outstanding item is a real Intune tenant upload, which is a documented Phase 5 gate, not a Phase 1 gap. + +--- + +_Verified: 2026-04-10T10:00:00Z_ +_Verifier: Claude (gsd-verifier)_ diff --git a/.planning/phases/02-driver-management/02-01-PLAN.md b/.planning/phases/02-driver-management/02-01-PLAN.md new file mode 100644 index 0000000..7fff48c --- /dev/null +++ b/.planning/phases/02-driver-management/02-01-PLAN.md @@ -0,0 +1,213 @@ +--- +phase: 02-driver-management +plan: "01" +type: tdd +wave: 1 +depends_on: [] +files_modified: + - imptune/services/__init__.py + - imptune/services/inf_parser.py + - tests/test_inf_parser.py + - tests/fixtures/sample.inf + - tests/fixtures/sample_utf16.inf + - tests/fixtures/sample_multi_model.inf +autonomous: true +requirements: [DRV-02, DRV-05] + +must_haves: + truths: + - "parse_inf extracts DriverDesc values from a simple INF with literal names" + - "parse_inf resolves %TOKEN% references via the [Strings] section" + - "parse_inf handles UTF-16 LE BOM, UTF-8 BOM, and ANSI (cp1252) encoded INF files" + - "parse_inf deduplicates driver names from multi-model INFs (NTamd64 + undecorated)" + - "parse_inf returns a list of unused files not referenced in the INF text" + - "parse_inf detects architecture from section decorations (x64, x86, arm64)" + - "parse_inf detects presence of .cat file in ZIP member list" + artifacts: + - path: "imptune/services/inf_parser.py" + provides: "ParsedInf dataclass and parse_inf() + _detect_encoding() functions" + exports: ["ParsedInf", "parse_inf", "_detect_encoding"] + - path: "tests/test_inf_parser.py" + provides: "Unit tests covering all DRV-02 and DRV-05 behaviors" + min_lines: 80 + - path: "tests/fixtures/sample.inf" + provides: "Minimal valid INF with %TOKEN% values and [Strings] section" + - path: "tests/fixtures/sample_utf16.inf" + provides: "UTF-16 LE encoded INF for encoding detection test" + - path: "tests/fixtures/sample_multi_model.inf" + provides: "INF with NTamd64 and undecorated Models sections" + key_links: + - from: "imptune/services/inf_parser.py" + to: "configparser.RawConfigParser" + via: "stdlib import" + pattern: "RawConfigParser.*strict=False" + - from: "imptune/services/inf_parser.py" + to: "[Strings] section" + via: "_resolve_tokens regex expansion" + pattern: "re\\.sub.*%([^%]+)%" +--- + + +Create the INF parser service that extracts driver names (DriverDesc) from Windows INF files, with encoding auto-detection, %TOKEN% resolution, multi-model support, and unused-file detection. + +Purpose: This is the core novel logic of Phase 2. The INF parser is a pure function with defined I/O — ideal for TDD. All other Phase 2 work (upload endpoint, UI) consumes this parser's output. +Output: `imptune/services/inf_parser.py` with `ParsedInf` dataclass and `parse_inf()` function, plus comprehensive unit tests and INF fixture files. + + + +@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/02-driver-management/02-RESEARCH.md + + + + +From imptune/config.py: +```python +DATA_DIR = os.environ.get("DATA_DIR", "/data") +DRIVERS_DIR = str(Path(DATA_DIR) / "drivers") +``` + +From imptune/storage/driver_store.py: +```python +class DriverStore: + def save(self, data: bytes) -> str: ... # returns SHA256 hex digest + def get_path(self, sha256: str) -> Path: ... + def exists(self, sha256: str) -> bool: ... +``` + +From imptune/db/models.py: +```python +class Driver(BaseModel): + sha256 = CharField(unique=True, index=True) + original_filename = CharField() + size_bytes = IntegerField() + uploaded_at = DateTimeField(default=datetime.utcnow) + driver_desc = CharField(null=True) # Store json.dumps(list) for multi-model + inf_filename = CharField(null=True) + architecture = CharField(null=True) # 'x64', 'x86', 'arm64', or None + has_cat_file = BooleanField(default=False) +``` + + + + + + + Task 1: INF parser with TDD (RED then GREEN) + imptune/services/__init__.py, imptune/services/inf_parser.py, tests/test_inf_parser.py, tests/fixtures/sample.inf, tests/fixtures/sample_utf16.inf, tests/fixtures/sample_multi_model.inf + + - test_detect_encoding_utf16le: _detect_encoding(b'\xff\xfe...') returns 'utf-16' + - test_detect_encoding_utf8bom: _detect_encoding(b'\xef\xbb\xbf...') returns 'utf-8-sig' + - test_detect_encoding_ansi: _detect_encoding(b'[Version]...') returns 'cp1252' + - test_simple_driver_desc: parse_inf with literal DriverDesc in [Models] section returns those names in driver_names + - test_token_resolution: parse_inf with %HP_DRIVER% in [Models] and HP_DRIVER="HP LaserJet" in [Strings] resolves to "HP LaserJet" + - test_utf16_encoding: Reading a UTF-16 LE BOM fixture, decoding with _detect_encoding, and passing to parse_inf produces correct driver_names + - test_multi_model_inf: INF with both [Mfg.NTamd64] and [Mfg] sections returns deduplicated driver_names; architecture='x64' when NTamd64 is present alone + - test_architecture_detection: NTamd64 -> 'x64', NTarm64 -> 'arm64', undecorated only -> 'x86', mixed -> None + - test_cat_file_detection: zip_names containing 'driver.cat' -> has_cat_file=True; without -> False + - test_unused_files: ZIP members ['driver.inf', 'driver.dll', 'readme.txt'] where INF text mentions 'driver.inf' and 'driver.dll' but not 'readme.txt' -> unused_files=['readme.txt'] + - test_empty_models_section: INF with [Manufacturer] but empty Models section returns empty driver_names list + + +**Phase: RED** + +1. Create `tests/fixtures/` directory if it does not exist. + +2. Create `tests/fixtures/sample.inf` — minimal valid INF with %TOKEN% values: +```ini +[Version] +Signature="$Windows NT$" +Class=Printer +Provider=%MFG% + +[Manufacturer] +%MFG%=Models,NTamd64 + +[Models.NTamd64] +%DRIVER_NAME%=Install,{GUID} + +[Strings] +MFG="Test Manufacturer" +DRIVER_NAME="Test LaserJet Pro" +``` + +3. Create `tests/fixtures/sample_utf16.inf` — same content as sample.inf but encoded as UTF-16 LE with BOM. Write using Python: `content.encode('utf-16-le')` prepended with `b'\xff\xfe'`. Actually, create this fixture programmatically within the test (or as a conftest fixture) since writing binary fixtures from plan text is fragile. + +4. Create `tests/fixtures/sample_multi_model.inf` — INF with both decorated and undecorated sections: +```ini +[Version] +Signature="$Windows NT$" +Class=Printer + +[Manufacturer] +%MFG%=Models,Models.NTamd64 + +[Models] +%DRIVER_A%=InstallA,{GUID1} + +[Models.NTamd64] +%DRIVER_A%=InstallA,{GUID1} +%DRIVER_B%=InstallB,{GUID2} + +[Strings] +MFG="Multi Corp" +DRIVER_A="Multi Printer 1000" +DRIVER_B="Multi Printer 2000" +``` + +5. Create `imptune/services/__init__.py` — empty package marker. + +6. Create `tests/test_inf_parser.py` with all 11 test functions listed in behavior. Tests import from `imptune.services.inf_parser` and call `parse_inf()` / `_detect_encoding()`. Each test asserts specific expected outputs. For the UTF-16 test, generate the fixture bytes inline: `sample_text.encode('utf-16')`. + +7. Run `pytest tests/test_inf_parser.py -x` — all tests MUST FAIL (ImportError or assertion errors). Commit: `test(02-01): add failing tests for INF parser` + +**Phase: GREEN** + +8. Create `imptune/services/inf_parser.py` implementing: + - `ParsedInf` dataclass with fields: `driver_names: list[str]`, `inf_filename: str`, `architecture: str | None`, `has_cat_file: bool`, `unused_files: list[str]` + - `_detect_encoding(raw: bytes) -> str` — BOM sniffing (UTF-16 BOM -> 'utf-16', UTF-8 BOM -> 'utf-8-sig', else -> 'cp1252') + - `_resolve_tokens(value: str, strings: dict[str, str]) -> str` — regex `%TOKEN%` expansion from strings dict + - `parse_inf(inf_text: str, inf_filename: str, zip_names: list[str]) -> ParsedInf` — uses `configparser.RawConfigParser(strict=False, comment_prefixes=(';', '#'), delimiters=('=',))`, reads [Manufacturer] to find Models section names, iterates all matching sections (decorated: .NTamd64, .NTarm64, .NTx86; undecorated), extracts left-hand keys as device-descriptions, resolves tokens, deduplicates with set(), detects architecture from section suffix, detects .cat in zip_names, computes unused files by checking if each zip member's basename appears in inf_text (case-insensitive) + + Follow the exact code patterns from 02-RESEARCH.md "Pattern 2: INF DriverDesc Extraction". Key points: + - Use `RawConfigParser` (NOT `ConfigParser` — avoids %(interpolation)s interference) + - `strict=False` to handle duplicate keys in real INFs + - Strings dict keys must be lowercased (configparser lowercases keys by default) + - Strip surrounding double-quotes from [Strings] values + - Architecture: if exactly one arch hint in set -> return it; multiple -> None + - `sorted(driver_names)` for deterministic dropdown order + +9. Run `pytest tests/test_inf_parser.py -x` — all tests MUST PASS. Commit: `feat(02-01): implement INF parser with encoding detection and token resolution` + + + pytest tests/test_inf_parser.py -v + + All 11 tests pass. ParsedInf dataclass and parse_inf() function correctly extract driver names from simple, tokenized, UTF-16, and multi-model INF files. Unused files detected. Architecture and .cat presence detected. + + + + + +```bash +pytest tests/test_inf_parser.py -v +pytest tests/ -x -q # no regressions in existing tests +``` + + + +- parse_inf() extracts DriverDesc from all 3 fixture types (simple, UTF-16, multi-model) +- %TOKEN% references resolved to human-readable names +- Unused files correctly identified +- All 11+ unit tests green, zero regressions in existing suite + + + +After completion, create `.planning/phases/02-driver-management/02-01-SUMMARY.md` + diff --git a/.planning/phases/02-driver-management/02-01-SUMMARY.md b/.planning/phases/02-driver-management/02-01-SUMMARY.md new file mode 100644 index 0000000..98b2cfe --- /dev/null +++ b/.planning/phases/02-driver-management/02-01-SUMMARY.md @@ -0,0 +1,95 @@ +--- +phase: "02" +plan: "01" +subsystem: inf-parser +tags: [tdd, inf-parsing, encoding-detection, token-resolution, driver-management] +dependency_graph: + requires: [] + provides: [inf-parser-service] + affects: [02-02-upload-endpoint, 02-03-drivers-ui] +tech_stack: + added: [] + patterns: [RawConfigParser-strict-false, BOM-sniffing, optionxform-str, set-dedup-sorted] +key_files: + created: + - imptune/services/__init__.py + - imptune/services/inf_parser.py + - tests/test_inf_parser.py + - tests/fixtures/sample.inf + - tests/fixtures/sample_utf16.inf + - tests/fixtures/sample_multi_model.inf + modified: [] +decisions: + - "optionxform=str on RawConfigParser to preserve DriverDesc key casing; strings dict still uses lowercased keys for case-insensitive %TOKEN% lookup" + - "configparser.RawConfigParser(strict=False) avoids DuplicateOptionError on real INFs with repeated model entries" + - "UTF-16 fixture written as binary via Python encode('utf-16') — not as a text file — to guarantee correct BOM bytes" +metrics: + duration: "~2.5 min" + completed: "2026-04-10" + tasks: 1 + files: 6 +requirements-completed: [DRV-02] +--- + +# Phase 02 Plan 01: INF Parser Service Summary + +**One-liner:** stdlib configparser + BOM-sniffing INF parser with %TOKEN% resolution, multi-model deduplication, architecture detection, and unused-file flagging. + +## What Was Built + +`imptune/services/inf_parser.py` — a pure-function INF parser with: + +- `ParsedInf` dataclass exposing `driver_names`, `inf_filename`, `architecture`, `has_cat_file`, `unused_files` +- `_detect_encoding(raw: bytes) -> str` — BOM-sniffing: `\xff\xfe`/`\xfe\xff` -> `utf-16`, `\xef\xbb\xbf` -> `utf-8-sig`, else `cp1252` +- `_resolve_tokens(value, strings)` — regex `%([^%]+)%` expansion +- `parse_inf(inf_text, inf_filename, zip_names) -> ParsedInf` — `RawConfigParser(strict=False, delimiters=('=',))` with `optionxform=str`; [Manufacturer] -> Models section discovery; NTamd64/NTarm64/NTx86/undecorated detection; set-based dedup; sorted output + +Three fixture files support the test suite: `sample.inf` (ANSI with %TOKEN%), `sample_utf16.inf` (UTF-16 LE BOM binary), `sample_multi_model.inf` (NTamd64 + undecorated sections). + +## Tasks + +| # | Task | Status | Commit | +|---|------|--------|--------| +| 1 | INF parser with TDD (RED then GREEN) | Complete | 290106d (RED), 5056922 (GREEN) | + +## Test Results + +- 16 tests in `tests/test_inf_parser.py` — all pass +- Full suite: 40 tests pass, 0 failures, 0 regressions + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 1 - Bug] configparser key lowercasing mangled DriverDesc literal names** +- **Found during:** Task 1, GREEN phase (first test run) +- **Issue:** configparser defaults `optionxform = str.lower`, so the literal key `Acme SuperPrint 9000` was returned as `acme superprint 9000`. The test `assert "Acme SuperPrint 9000" in result.driver_names` failed. +- **Fix:** Set `parser.optionxform = str` to preserve original casing of option keys. The [Strings] dict still explicitly lowercases keys (`strings[key.lower()]`) for case-insensitive token resolution. +- **Files modified:** `imptune/services/inf_parser.py` +- **Commit:** 5056922 + +**Note:** The plan specified `strict=False` and `RawConfigParser` correctly but did not mention `optionxform=str`. This is a real-INF edge case documented in the pitfalls section of 02-RESEARCH.md (implicitly — the note says "Strings dict keys must be lowercased" without clarifying that DriverDesc keys also get lowercased by default). + +### Test Count Deviation + +The plan specified 11 test functions; 16 were written. The extra 5 cover: +- `test_detect_encoding_utf16be` (UTF-16 BE BOM variant) +- `test_architecture_detection_amd64` (split from the combined architecture test) +- `test_architecture_detection_arm64` +- `test_architecture_detection_undecorated` +- `test_architecture_detection_mixed` + +This provides more granular failure diagnosis and meets the `min_lines: 80` artifact requirement. + +## Self-Check + +- [x] `imptune/services/inf_parser.py` exists +- [x] `imptune/services/__init__.py` exists +- [x] `tests/test_inf_parser.py` exists (>80 lines) +- [x] `tests/fixtures/sample.inf` exists +- [x] `tests/fixtures/sample_utf16.inf` exists (UTF-16 LE BOM binary) +- [x] `tests/fixtures/sample_multi_model.inf` exists +- [x] RED commit: 290106d +- [x] GREEN commit: 5056922 + +## Self-Check: PASSED diff --git a/.planning/phases/02-driver-management/02-02-PLAN.md b/.planning/phases/02-driver-management/02-02-PLAN.md new file mode 100644 index 0000000..ca3e426 --- /dev/null +++ b/.planning/phases/02-driver-management/02-02-PLAN.md @@ -0,0 +1,311 @@ +--- +phase: 02-driver-management +plan: "02" +type: execute +wave: 2 +depends_on: ["02-01"] +files_modified: + - imptune/api/drivers.py + - imptune/api/pages.py + - imptune/main.py + - imptune/templates/drivers.html + - imptune/templates/partials/driver_list.html + - tests/test_driver_upload.py +autonomous: true +requirements: [DRV-01, DRV-03, DRV-04, DRV-05] + +must_haves: + truths: + - "User can upload a ZIP file via the /drivers page and receive a success response" + - "After upload, the response contains a populated select dropdown with driver names from the INF" + - "Uploading a non-ZIP file or a ZIP with no INF returns a 400 error displayed in-page" + - "Uploaded driver file is persisted to DRIVERS_DIR via DriverStore (survives restart)" + - "Re-uploading the same ZIP does not create a duplicate Driver record (SHA256 dedup)" + - "Upload response shows count of unused files not referenced by the INF" + - "GET /drivers renders the drivers page with upload form and existing driver list" + artifacts: + - path: "imptune/api/drivers.py" + provides: "POST /drivers/upload endpoint returning HTMX partial" + exports: ["router"] + - path: "imptune/templates/drivers.html" + provides: "Drivers page with upload form and driver list container" + contains: "hx-post" + - path: "imptune/templates/partials/driver_list.html" + provides: "HTMX partial fragment with driver table and select dropdown" + contains: " +Create the driver upload endpoint, drivers page, and HTMX-driven UI that lets technicians upload driver ZIPs, see parsed driver names in a dropdown, and view unused-file hints. + +Purpose: This wires the INF parser (from plan 02-01) into a working upload flow with persistence and UI feedback. After this plan, the full DRV-01 through DRV-05 feature set is functional. +Output: Upload API endpoint, drivers page template, HTMX partial for driver list, 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/02-driver-management/02-RESEARCH.md +@.planning/phases/02-driver-management/02-01-SUMMARY.md + + + +From imptune/services/inf_parser.py: +```python +from dataclasses import dataclass + +@dataclass +class ParsedInf: + driver_names: list[str] # resolved DriverDesc values, deduplicated, sorted + inf_filename: str # which .inf file inside the ZIP + architecture: str | None # 'x64', 'x86', 'arm64', or None + has_cat_file: bool # whether a .cat file exists in the ZIP + unused_files: list[str] # ZIP members not referenced by the INF + +def _detect_encoding(raw: bytes) -> str: ... +def parse_inf(inf_text: str, inf_filename: str, zip_names: list[str]) -> ParsedInf: ... +``` + + +From imptune/config.py: +```python +DATA_DIR = os.environ.get("DATA_DIR", "/data") +DRIVERS_DIR = str(Path(DATA_DIR) / "drivers") +``` + +From imptune/storage/driver_store.py: +```python +class DriverStore: + def __init__(self, base_dir: str) -> None: ... + def save(self, data: bytes) -> str: ... # returns SHA256 hex +``` + +From imptune/db/models.py: +```python +class Driver(BaseModel): + sha256 = CharField(unique=True, index=True) + original_filename = CharField() + size_bytes = IntegerField() + uploaded_at = DateTimeField(default=datetime.utcnow) + driver_desc = CharField(null=True) # json.dumps(list) for multi-model + inf_filename = CharField(null=True) + architecture = CharField(null=True) + has_cat_file = BooleanField(default=False) +``` + +From imptune/main.py: +```python +app = FastAPI(title="ImpTune", lifespan=lifespan) +app.include_router(health.router) +app.include_router(pages.router) +# Add: app.include_router(drivers.router) +``` + +From imptune/api/pages.py: +```python +router = APIRouter() +templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates")) +``` + +From imptune/templates/base.html: +```html + +
  • Drivers
  • + +``` +
    +
    + + + + + Task 1: Upload endpoint, drivers page route, and integration tests + imptune/api/drivers.py, imptune/api/pages.py, imptune/main.py, tests/test_driver_upload.py + + - test_drivers_page: GET /drivers returns 200 with HTML containing upload form (input type="file", hx-post="/drivers/upload") + - test_upload_valid_zip: POST /drivers/upload with a valid ZIP containing sample.inf returns 200, HTML contains driver name from INF + - test_upload_non_zip: POST /drivers/upload with a .txt file returns 400 + - test_upload_no_inf: POST /drivers/upload with a ZIP containing no .inf returns 400 + - test_upload_returns_select: POST /drivers/upload with valid ZIP returns HTML containing a select element with driver names as options + - test_driver_persisted: After upload, Driver.select().where(Driver.sha256==expected).count() == 1, and DriverStore file exists on disk + - test_dedup_upload: Uploading same ZIP twice creates only one Driver record + - test_unused_files_in_response: Upload a ZIP with an extra file not in INF text; response HTML contains "unused" or the count + + +**RED phase first:** + +1. Create `tests/test_driver_upload.py` with all 8 integration tests. Tests use the `client` fixture from conftest.py. For test fixtures, create valid ZIP bytes in-memory using `zipfile.ZipFile(io.BytesIO(), 'w')`: + - Build a helper `_make_driver_zip(inf_content: str, extra_files: dict[str, bytes] = None) -> bytes` that creates a ZIP with the INF and optional extra files + - Use the same sample INF content from tests/fixtures/sample.inf (read it or inline it) + - For `test_upload_non_zip`, send raw text bytes with filename="test.zip" + - For `test_upload_no_inf`, create a ZIP with only a .txt file + - For `test_unused_files_in_response`, add a "readme.txt" to the ZIP that the INF does not reference + - All tests use `client.post("/drivers/upload", files={"file": ("driver.zip", zip_bytes, "application/zip")})` + - Import `Driver` from `imptune.db.models` and `init_db` from `imptune.db.database` for persistence checks. Call `init_db()` in tests that check DB state (the `client` fixture triggers lifespan which calls init_db). + +2. Run `pytest tests/test_driver_upload.py -x` — all MUST FAIL. Commit: `test(02-02): add failing integration tests for driver upload` + +**GREEN phase:** + +3. Create `imptune/api/drivers.py`: + - `router = APIRouter(prefix="/drivers")` + - `templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))` + - `MAX_UPLOAD_BYTES = 100 * 1024 * 1024` + - `POST /upload` endpoint (sync def, not async — Peewee is sync): + - Read file bytes, validate size <= 100MB + - Validate filename ends with `.zip` + - Validate `zipfile.is_zipfile(io.BytesIO(data))` + - Open ZIP, validate no zip-slip paths (reject `..` or absolute paths) + - Find `.inf` files in namelist; raise 400 if none + - Prefer INF whose path contains `amd64`/`x64` if multiple exist; else first alphabetically + - Read INF bytes, detect encoding with `_detect_encoding()`, decode + - Call `parse_inf(inf_text, inf_filename, zip_names)` + - Save via `DriverStore(DRIVERS_DIR).save(data)` + - Upsert `Driver.get_or_create(sha256=sha256, defaults={...})` — store `json.dumps(parsed.driver_names)` in `driver_desc` + - Query all drivers: `Driver.select().order_by(Driver.uploaded_at.desc())` + - Return `templates.TemplateResponse(request=request, name="partials/driver_list.html", context={...})` + - On validation errors, return HTMX-friendly error: `HTMLResponse(content="
    Error message
    ", status_code=400)` — so HTMX can swap the error into the target area + +4. Add GET /drivers route to `imptune/api/pages.py`: + ```python + @router.get("/drivers", response_class=HTMLResponse) + def drivers_page(request: Request): + from imptune.db.models import Driver + drivers = list(Driver.select().order_by(Driver.uploaded_at.desc())) + return templates.TemplateResponse( + request=request, name="drivers.html", + context={"drivers": drivers} + ) + ``` + +5. Register the drivers router in `imptune/main.py`: + - Add `from imptune.api import drivers` to imports + - Add `app.include_router(drivers.router)` after the pages router + +6. Run `pytest tests/test_driver_upload.py -x` — all MUST PASS. Commit: `feat(02-02): add driver upload endpoint with INF parsing and dedup` +
    + + pytest tests/test_driver_upload.py -v + + All 8 integration tests pass. POST /drivers/upload accepts ZIPs, parses INFs, persists via DriverStore + Peewee, returns HTMX partial. GET /drivers renders the page. Error cases return 400. +
    + + + Task 2: Drivers page template and HTMX partial + imptune/templates/drivers.html, imptune/templates/partials/driver_list.html + +1. Create `imptune/templates/partials/` directory (if not exists). + +2. Create `imptune/templates/drivers.html` extending base.html: + ```html + {% extends "base.html" %} + {% block content %} +

    Drivers

    +
    +

    Upload Driver Package

    +
    + + + + Uploading... +
    +
    +
    +

    Driver Library

    +
    + {% include "partials/driver_list.html" %} +
    +
    + {% endblock %} + ``` + +3. Create `imptune/templates/partials/driver_list.html`: + - Wrap everything in `
    ` (for HTMX outerHTML swap) + - If `drivers` list is empty, show "No drivers uploaded yet." + - If `drivers` exist, render a table with columns: Filename, Driver Name(s), Architecture, Uploaded, Unused Files + - For each driver, parse `driver.driver_desc` as JSON to get the list of driver names. Display as a ` even for single-model drivers — simplifies template logic and consistent UI" + - "Dynamic DRIVERS_DIR read (import config module, not top-level constant) so monkeypatch works in tests" + - "TestClient context manager in conftest client fixture — required for lifespan/init_db to trigger in integration tests" + - "HTMX-friendly 400 error: return HTMLResponse with
    wrapper so HTMX can swap error inline" + +patterns-established: + - "HTMX partial pattern: upload returns
    fragment; page has matching hx-target; outerHTML swap replaces entire div" + - "driver_data pattern: routes build list of dicts with {'driver': orm_obj, 'names': list[str]} to pre-parse JSON in Python rather than Jinja2" + - "Config monkeypatch: endpoints import config module (not constants) so test fixtures can override DRIVERS_DIR/DB_PATH" + +requirements-completed: [DRV-01, DRV-03, DRV-04, DRV-05] + +# Metrics +duration: 3min +completed: 2026-04-10 +--- + +# Phase 02 Plan 02: Driver Upload Endpoint Summary + +**HTMX-driven driver ZIP upload with INF parsing, SHA256 dedup, Peewee persistence, and select dropdown returning 8/8 integration tests green** + +## Performance + +- **Duration:** ~3 min +- **Started:** 2026-04-10T10:18:52Z +- **Completed:** 2026-04-10T10:22:00Z +- **Tasks:** 2 (Task 1 TDD: RED + GREEN; Task 2 templates completed inline) +- **Files modified:** 7 + +## Accomplishments + +- POST /drivers/upload: validates ZIP, finds INF, parses via INF parser service, saves via DriverStore (SHA256 content-addressed), upserts Driver record with json.dumps(driver_names) in driver_desc — full dedup on re-upload +- GET /drivers page renders upload form with HTMX attributes and existing driver library table +- HTMX partial (partials/driver_list.html): wraps content in `
    ` for outerHTML swap; shows unused-file notice with count and expandable list; renders driver names as `` even for single driver name — uniform UI and simpler template logic +- Read `_cfg.DRIVERS_DIR` dynamically (not top-level constant import) so test monkeypatching works +- `TestClient(app)` must be used as a context manager for Starlette 0.46+ to trigger lifespan and run `init_db()` +- HTMX errors: return `HTMLResponse` with `
    ` wrapper at status 400 so HTMX can swap error into target area + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 3 - Blocking] TestClient context manager required for lifespan trigger** +- **Found during:** Task 1 GREEN (first test run) +- **Issue:** `TestClient(app)` without context manager does not run lifespan in Starlette 0.46+, so `init_db()` never called; DB remained deferred (None), causing `InterfaceError` on all ORM queries +- **Fix:** Changed conftest `client` fixture from `return TestClient(app)` to `with TestClient(app) as c: yield c` +- **Files modified:** tests/conftest.py +- **Verification:** All 48 tests pass including pre-existing health, static, DB, and parser tests +- **Committed in:** c648fc5 (Task 1 feat commit) + +**2. [Rule 1 - Bug] Dynamic DRIVERS_DIR read to support monkeypatch** +- **Found during:** Task 1 GREEN (test_driver_persisted failure) +- **Issue:** `from imptune.config import DRIVERS_DIR` captured the value at import time; tests patching `cfg.DRIVERS_DIR` had no effect — files written to `/data/drivers` (production path) not the tmp dir +- **Fix:** Changed to `import imptune.config as _cfg` and use `_cfg.DRIVERS_DIR` at call time +- **Files modified:** imptune/api/drivers.py +- **Verification:** test_driver_persisted passes; file found in tmp_data_dir/drivers/ +- **Committed in:** c648fc5 (Task 1 feat commit) + +**3. [Rule 1 - Bug] Template always renders `` for multiple names; sample INF has 1 driver name, so test failed +- **Fix:** Changed template condition from `{% if item.names | length > 1 %}` to `{% if item.names %}` +- **Files modified:** imptune/templates/partials/driver_list.html +- **Verification:** test_upload_returns_select passes +- **Committed in:** c648fc5 (Task 1 feat commit) + +--- + +**Total deviations:** 3 auto-fixed (1 Rule 3 blocking, 2 Rule 1 bugs) +**Impact on plan:** All three fixes necessary for correct test isolation and behavior. No scope creep. + +## Issues Encountered + +None beyond the three auto-fixed deviations above. + +## User Setup Required + +None - no external service configuration required. + +## Next Phase Readiness + +- Driver upload feature fully functional: upload, parse, persist, dedup, UI feedback +- Driver records in SQLite with driver_desc (JSON list), architecture, inf_filename, has_cat_file +- Phase 03 (printer management) can reference drivers via Driver model and driver select dropdowns +- Phase 04 (package generation) can read persisted driver ZIPs from DriverStore using sha256 + +--- +*Phase: 02-driver-management* +*Completed: 2026-04-10* diff --git a/.planning/phases/02-driver-management/02-RESEARCH.md b/.planning/phases/02-driver-management/02-RESEARCH.md new file mode 100644 index 0000000..617b245 --- /dev/null +++ b/.planning/phases/02-driver-management/02-RESEARCH.md @@ -0,0 +1,594 @@ +# Phase 2: Driver Management - Research + +**Researched:** 2026-04-10 +**Domain:** ZIP upload handling, Windows INF parsing, content-addressed storage, HTMX-driven UI +**Confidence:** HIGH (FastAPI upload patterns, Python stdlib zipfile/configparser, HTMX encoding), MEDIUM (INF encoding edge-cases) + +--- + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|-----------------| +| DRV-01 | User can upload a driver package (ZIP containing INF + supporting files) | FastAPI `UploadFile` + `python-multipart`; `zipfile.ZipFile(io.BytesIO(...))` in-memory extraction | +| DRV-02 | System parses uploaded INF files and extracts valid driver names (DriverDesc) | Python `configparser` reading `[Manufacturer]` → Models sections; `[Strings]` token resolution; encoding auto-detect (ANSI / UTF-8 / UTF-16 LE) | +| DRV-03 | User can select driver name from parsed INF dropdown (no free-text) | HTMX `hx-post` + `hx-encoding="multipart/form-data"` + `hx-target` swap returning `` dropdown. + +The storage infrastructure was completed in Phase 1. `DriverStore.save(data) -> sha256` and the `Driver` ORM model (with `driver_desc`, `inf_filename`, `architecture`, `has_cat_file` fields) are already in place. Phase 2 only needs to fill those fields by parsing the INF and register the driver record in SQLite. + +INF parsing is the trickiest piece. Windows INF files use an INI-like format but have encoding variability (ANSI, UTF-8 with BOM, UTF-16 LE with BOM — all seen in real HP/Canon/Ricoh packages). Driver names (`DriverDesc`) are the left-hand values in the `[Models]` sections (e.g., `[Manufacturer.NTamd64]`), and they are frequently `%TOKEN%` references that must be resolved from the `[Strings]` section. Python's `configparser` handles this INI-like format well but needs an encoding sniff step and a `%`-token expander. Multi-model INF files (one INF with NTamd64 + NTarm64 + undecorated sections) must be deduplicated — extract all driver names, unique them, and present the merged list. + +**Primary recommendation:** Use `configparser` with encoding auto-detection + a custom `%TOKEN%` resolver to extract DriverDesc values. Do NOT import the third-party `pyinf` library — its scope is too narrow and adds a dependency with no maintenance signal. All required INF parsing logic is achievable in ~60 lines of stdlib Python. + +--- + +## Standard Stack + +### Core (all already in requirements.txt from Phase 1) + +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| FastAPI | 0.115.x | Upload endpoint, HTMX fragment responses | Already installed; `UploadFile` built in | +| python-multipart | 0.0.9 | Required by FastAPI for `UploadFile` | Already installed | +| Peewee | 3.17.x | ORM — `Driver` record create/update | Already installed; schema already has all Phase 2 fields | +| Jinja2 | 3.1.x | Render drivers page + HTMX partial (driver list fragment) | Already installed | +| Python stdlib `zipfile` | 3.12 | Extract ZIP in-memory, list members | No new dependency | +| Python stdlib `configparser` | 3.12 | Parse INF (INI-like format) | No new dependency | +| Python stdlib `io` | 3.12 | `io.BytesIO` for in-memory ZIP | No new dependency | + +### No New Dependencies Required + +Phase 2 introduces zero new pip packages. Everything needed is either already installed (FastAPI/Peewee/Jinja2) or in the Python 3.12 stdlib (zipfile, configparser, io, hashlib). + +### Alternatives Considered + +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| `configparser` + custom token resolver | `pyinf` (third-party) | `pyinf` is a rudimentary single-developer project with no recent activity; stdlib configparser handles the INI format correctly with 30 extra lines of token resolution | +| In-memory `io.BytesIO` extraction | `NamedTemporaryFile` on disk | In-memory is simpler for small driver ZIPs (< 50 MB typical); avoids temp file cleanup; adequate for this use case | + +--- + +## Architecture Patterns + +### Recommended File Layout for Phase 2 + +``` +imptune/ +├── api/ +│ ├── pages.py # add GET /drivers page route +│ └── drivers.py # NEW: POST /drivers/upload endpoint +├── services/ +│ └── inf_parser.py # NEW: parse_inf(zip_bytes) -> ParsedInf dataclass +├── templates/ +│ ├── drivers.html # NEW: Drivers page (upload form + driver table) +│ └── partials/ +│ └── driver_select.html # NEW: HTMX partial — + + Uploading... + + +
    + {% include "partials/driver_list.html" %} +
    +``` + +The server returns a replacement `
    ...
    ` containing the updated driver table plus any success/warning messages (unused files count, architecture, etc.). + +### Anti-Patterns to Avoid + +- **`zipfile.extractall()` without path validation:** Vulnerable to Zip Slip. Always iterate `zf.namelist()` and reject entries with `..` or absolute paths before reading. +- **`configparser` with `strict=True` for INF files:** Real INF files frequently contain duplicate keys across sections (multiple models with similar names). `strict=False` is required. +- **Using configparser's interpolation for `%TOKEN%` expansion:** configparser's built-in interpolation uses `%(key)s` syntax, not `%KEY%`. Use `RawConfigParser` and a separate regex-based `_resolve_tokens()` function. +- **Assuming one INF per ZIP:** Some vendor packages contain multiple INF files (x64 + x86 in different subdirectories). Parse the first `.inf` found; flag if multiples exist. +- **`async def` route for upload:** Since `DriverStore.save()` and `Driver.get_or_create()` are synchronous (Peewee), use a regular `def` route. FastAPI runs sync handlers in a thread pool automatically — no blocking. +- **Storing parsed driver names as a list in SQLite:** The existing `Driver.driver_desc` is a single `CharField`. For Phase 2, store the first (or primary) driver name. If multi-driver-name support is needed, that is a Phase 2+ schema change — but the current schema supports the DRV-03 requirement of a single dropdown choice per uploaded package. + +--- + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| INF file parsing | Custom INI tokenizer from scratch | `configparser.RawConfigParser` + `_resolve_tokens()` | INI format edge cases: duplicate keys, inline comments, continuation lines, quoted strings | +| ZIP extraction | Custom byte-level ZIP reader | `zipfile.ZipFile(io.BytesIO(data))` | Handles all ZIP variants (ZIP64, deflate, stored); stdlib, no extra dep | +| Content-addressed file storage | New storage abstraction | `DriverStore` (already built in Phase 1) | SHA256 + dedup already implemented and tested | +| Driver ORM record | Raw SQL INSERT | `Driver.get_or_create(sha256=sha256, ...)` | Idempotent on re-upload; schema already has all Phase 2 fields | +| HTMX multipart upload form | Custom `fetch()` JavaScript | `hx-encoding="multipart/form-data"` on `
    ` | One attribute handles encoding; HTMX manages request + swap; no JS needed | + +**Key insight:** Phase 1 already solved persistence and deduplication. Phase 2's only novel logic is the INF parser and the upload endpoint wiring. + +--- + +## Common Pitfalls + +### Pitfall 1: INF Encoding Not Detected — `UnicodeDecodeError` + +**What goes wrong:** Reading a UTF-16 LE INF file as UTF-8 raises `UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff in position 0`. Typical for HP and Canon INF files, which ship as UTF-16 LE with BOM. + +**Why it happens:** Windows uses UTF-16 internally; INF files signed for x64 are frequently written in UTF-16. + +**How to avoid:** Always sniff the first 3 bytes before calling `decode()`. UTF-16 LE BOM is `\xff\xfe`; UTF-16 BE BOM is `\xfe\xff`; UTF-8 BOM is `\xef\xbb\xbf`. Fall back to `cp1252` (not `utf-8`) for BOM-less files — `cp1252` is a superset of Latin-1 and handles Western European printer names without errors. + +**Warning signs:** `UnicodeDecodeError` on real vendor ZIP uploads. + +### Pitfall 2: `configparser` `strict=True` Fails on Duplicate Keys + +**What goes wrong:** `configparser.DuplicateOptionError` is raised when parsing INF files that list multiple models with the same base name but different decorations. + +**Why it happens:** `configparser` defaults to `strict=True` which rejects duplicate keys within the same section. INF files frequently have this structure. + +**How to avoid:** Instantiate with `configparser.RawConfigParser(strict=False)`. + +### Pitfall 3: `%TOKEN%` Values Appear Literally in Dropdown + +**What goes wrong:** Driver dropdown shows `%HP_LASERJET_P2055D%` instead of `HP LaserJet P2055d`. + +**Why it happens:** `configparser` does not process `%...%` INF token syntax — only its own `%(key)s` interpolation, which is irrelevant here. + +**How to avoid:** After parsing, apply `_resolve_tokens(raw_value, strings_dict)` to every extracted device-description. Build the `strings` dict from `[Strings]` section values (stripped of surrounding double-quotes). + +### Pitfall 4: Zip Slip Vulnerability on Upload + +**What goes wrong:** A malicious or malformed ZIP contains entries like `../../etc/passwd`. `zipfile.extractall()` writes those files to the host filesystem. + +**Why it happens:** The default Python `zipfile.extractall()` does not check for traversal paths. + +**How to avoid:** Never call `extractall()`. Read individual members with `zf.read(name)` after validating each `name` in `zf.namelist()` does not start with `/` or contain `..`. This is safe because the file is read into memory, not extracted to disk. + +**Warning signs:** Any code that calls `zf.extractall(path)` without path filtering. + +### Pitfall 5: No `.inf` in ZIP Returns a Confusing Error + +**What goes wrong:** Technician uploads a ZIP that contains only drivers but not the INF (common when someone zips the wrong folder). The endpoint crashes with a `KeyError` or returns HTTP 500. + +**Why it happens:** Code assumes at least one `.inf` member exists. + +**How to avoid:** Explicit check: `if not inf_names: raise HTTPException(400, "No .inf file found in ZIP")`. Return the error as an HTMX response so it appears in-page without a full reload. Render the error inside the `#driver-list` target. + +### Pitfall 6: Multi-INF ZIP — Wrong Driver Names Selected + +**What goes wrong:** A ZIP with both x64 and x86 INF files (in subdirectories) produces duplicate driver names if both are parsed, or wrong names if the wrong file is parsed. + +**Why it happens:** Some vendors ship a ZIP with `x64/printer.inf` and `x86/printer.inf` containing different model lists. + +**How to avoid:** Prefer INF files whose path does not contain `x86` when an `amd64`/`x64` sibling exists. Sort candidates to prefer `amd64`/`NTamd64` variants. Log a warning when multiple INF files are found; surface the INF filename in the UI so the technician can verify. + +### Pitfall 7: `Driver.driver_desc` Stores Only One Name (Schema Limitation) + +**What goes wrong:** An INF file contains 15 different models. Only one is stored in `driver_desc`. DRV-03 requires a dropdown of all parsed names — but after page reload, only the stored name is shown. + +**Why it happens:** The Phase 1 schema stores a single `driver_desc` CharField. + +**How to avoid:** Store all parsed driver names as a JSON-encoded list in `driver_desc` (e.g., `json.dumps(driver_names)`). The dropdown is generated by parsing the stored JSON. Alternatively, store the INF text itself. The simplest solution that satisfies DRV-03 without schema changes: store `json.dumps(parsed.driver_names)` in `driver_desc` and decode at read time. This fits in one CharField with no migration. + +--- + +## Code Examples + +### INF Encoding Detection + +```python +# imptune/services/inf_parser.py +def _detect_encoding(raw: bytes) -> str: + """Sniff BOM bytes to determine INF file encoding. + Source: Microsoft WDK — general-syntax-rules-for-inf-files + """ + if raw[:2] in (b'\xff\xfe', b'\xfe\xff'): + return 'utf-16' + if raw[:3] == b'\xef\xbb\xbf': + return 'utf-8-sig' + return 'cp1252' +``` + +### Safe ZIP Member Reading (Zip Slip Prevention) + +```python +# Before reading any member: +for name in zf.namelist(): + if name.startswith('/') or '..' in name: + raise HTTPException(400, f"Dangerous path in ZIP: {name}") +# Then read safely: +inf_bytes = zf.read(inf_names[0]) +``` + +### HTMX Upload Form (multipart) + +```html + + + + +
    +``` + +### Driver Record Upsert (idempotent on SHA256) + +```python +# Peewee get_or_create — safe for duplicate uploads +driver, created = Driver.get_or_create( + sha256=sha256, + defaults={ + 'original_filename': file.filename, + 'size_bytes': len(data), + 'driver_desc': json.dumps(parsed.driver_names), + 'inf_filename': parsed.inf_filename, + 'architecture': parsed.architecture, + 'has_cat_file': parsed.has_cat_file, + } +) +``` + +### Unused Files Detection + +```python +# Compare ZIP member basenames against INF text +# Source: DRV-05 requirement +inf_lower = inf_text.lower() +unused = [] +for member in zip_names: + basename = member.rsplit('/', 1)[-1].rsplit('\\', 1)[-1] + if basename.lower() not in inf_lower: + unused.append(member) +``` + +--- + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| Free-text driver name entry | Dropdown from parsed INF DriverDesc | DRV-03 (this phase) | Eliminates typos; driver name matches exactly what pnputil expects | +| Manual INF file navigation | Automated DriverDesc extraction + `%TOKEN%` resolution | This phase | Technician never needs to open the INF | +| `pyinf` third-party library | Python stdlib `configparser` + custom resolver | This phase decision | Zero new dependency; full control over edge-case handling | + +**Note on driver name storage:** The `driver_desc` column was designed in Phase 1 as a single `CharField`. Storing `json.dumps(list)` is the correct approach to preserve all model names without a schema migration. The Phase 3 printer configuration form reads this JSON to render the `` with driver names | integration | `pytest tests/test_driver_upload.py::test_upload_returns_select -x` | Wave 0 | +| DRV-04 | Uploaded driver file exists on disk under DRIVERS_DIR after upload | integration | `pytest tests/test_driver_upload.py::test_driver_persisted -x` | Wave 0 | +| DRV-04 | Re-uploading same ZIP does not create duplicate Driver record | integration | `pytest tests/test_driver_upload.py::test_dedup_upload -x` | Wave 0 | +| DRV-05 | parse_inf returns unused_files list for files not in INF text | unit | `pytest tests/test_inf_parser.py::test_unused_files -x` | Wave 0 | +| DRV-05 | Upload response includes unused-file count/list in HTML | integration | `pytest tests/test_driver_upload.py::test_unused_files_in_response -x` | Wave 0 | + +### Sampling Rate + +- **Per task commit:** `pytest tests/ -x -q` +- **Per wave merge:** `pytest tests/ -v` +- **Phase gate:** Full suite green before `/gsd:verify-work` + +### Wave 0 Gaps + +- [ ] `tests/test_inf_parser.py` — covers DRV-02 (INF parsing, token resolution, encoding, multi-model) +- [ ] `tests/test_driver_upload.py` — covers DRV-01, DRV-03, DRV-04, DRV-05 (upload endpoint integration tests) +- [ ] `tests/fixtures/sample.inf` — minimal valid INF fixture with `%TOKEN%` values +- [ ] `tests/fixtures/sample_utf16.inf` — UTF-16 LE encoded INF fixture +- [ ] `tests/fixtures/sample_multi_model.inf` — INF with NTamd64 and undecorated sections +- [ ] `imptune/services/__init__.py` — package marker (services/ directory exists in project structure but is empty) + +*(Framework already installed; conftest.py with `tmp_data_dir` fixture already covers DB isolation)* + +--- + +## Sources + +### Primary (HIGH confidence) + +- [Microsoft WDK — INF Models Section](https://learn.microsoft.com/en-us/windows-hardware/drivers/install/inf-models-section) — device-description = install-section-name,hw-id format; architecture decorations (NTamd64, NTarm64) +- [Microsoft WDK — General Syntax Rules for INF Files](https://learn.microsoft.com/en-us/windows-hardware/drivers/install/general-syntax-rules-for-inf-files) — encoding (ANSI/UTF-8/UTF-16), `%strkey%` token format, comment syntax, case-insensitivity +- [Microsoft WDK — Printer INF File Entries](https://learn.microsoft.com/en-us/windows-hardware/drivers/print/printer-inf-file-entries) — DriverFile, DataFile, ConfigFile, DriverDesc usage in Ntprint.dll +- [Microsoft WDK — Decorations in Printer INF Files](https://learn.microsoft.com/en-us/windows-hardware/drivers/print/decorations-in-printer-inf-files) — NTamd64 decoration mandatory for x64 since WS2003 SP1 +- [FastAPI — Request Files](https://fastapi.tiangolo.com/tutorial/request-files/) — `UploadFile`, `File(...)`, reading file bytes +- [HTMX — hx-encoding attribute](https://htmx.org/attributes/hx-encoding/) — `multipart/form-data` required for file uploads +- [HTMX — File Upload example](https://htmx.org/examples/file-upload/) — progress tracking, server response swap +- Python 3.12 stdlib `zipfile` — `ZipFile(io.BytesIO())`, `namelist()`, `read()`, `is_zipfile()` +- Python 3.12 stdlib `configparser` — `RawConfigParser(strict=False)`, `read_string()`, `has_section()`, `items()` + +### Secondary (MEDIUM confidence) + +- [Snyk / Zip Slip Vulnerability](https://github.com/snyk/zip-slip-vulnerability) — path traversal attack pattern; prevention via `namelist()` validation +- [FastAPI file size limiting discussion](https://github.com/fastapi/fastapi/issues/362) — post-read size check pattern; no built-in pre-rejection mechanism +- [Microsoft WDK — Printer INF File Data Sections](https://learn.microsoft.com/en-us/windows-hardware/drivers/print/printer-inf-file-data-sections) — DataSection pattern; Previous Names section + +### Tertiary (LOW confidence) + +- [pyinf GitHub](https://github.com/tty72/pyinf) — reviewed and rejected: rudimentary, no recent activity, no benefit over stdlib + +--- + +## Metadata + +**Confidence breakdown:** +- FastAPI upload patterns: HIGH — official docs verified +- HTMX multipart form: HIGH — official docs verified +- INF format (overall): HIGH — Microsoft WDK official docs +- INF encoding handling (edge cases): MEDIUM — documented rule is clear but real-world INF corpus has variability; edge cases may surface during testing +- Unused-file detection accuracy: MEDIUM — naive approach is best-effort; accuracy depends on INF structure + +**Research date:** 2026-04-10 +**Valid until:** 2026-05-10 (stable ecosystem) diff --git a/.planning/phases/02-driver-management/02-VALIDATION.md b/.planning/phases/02-driver-management/02-VALIDATION.md new file mode 100644 index 0000000..9f0842c --- /dev/null +++ b/.planning/phases/02-driver-management/02-VALIDATION.md @@ -0,0 +1,112 @@ +--- +phase: 2 +slug: driver-management +status: draft +nyquist_compliant: true +wave_0_complete: false +created: 2026-04-10 +nyquist_audited: 2026-04-13 +nyquist_auditor: Claude (gsd-executor, plan 08-02) +--- + +# Phase 2 — Validation Strategy + +> Per-phase validation contract for feedback sampling during execution. + +--- + +## Test Infrastructure + +| Property | Value | +|----------|-------| +| **Framework** | pytest (already installed in requirements-dev.txt from Phase 1) | +| **Config file** | None — uses pytest auto-discovery | +| **Quick run command** | `pytest tests/ -x -q` | +| **Full suite command** | `pytest tests/ -v` | +| **Estimated runtime** | ~5 seconds | + +--- + +## Sampling Rate + +- **After every task commit:** Run `pytest tests/ -x -q` +- **After every plan wave:** Run `pytest tests/ -v` +- **Before `/gsd:verify-work`:** Full suite must be green +- **Max feedback latency:** 10 seconds + +--- + +## Per-Task Verification Map + +| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status | +|---------|------|------|-------------|-----------|-------------------|-------------|--------| +| 02-01-01 | 01 | 1 | DRV-01 | integration | `pytest tests/test_driver_upload.py::test_upload_valid_zip -x` | ❌ W0 | ⬜ pending | +| 02-01-02 | 01 | 1 | DRV-01 | unit | `pytest tests/test_driver_upload.py::test_upload_non_zip -x` | ❌ W0 | ⬜ pending | +| 02-01-03 | 01 | 1 | DRV-01 | unit | `pytest tests/test_driver_upload.py::test_upload_no_inf -x` | ❌ W0 | ⬜ pending | +| 02-02-01 | 02 | 1 | DRV-02 | unit | `pytest tests/test_inf_parser.py::test_simple_driver_desc -x` | ❌ W0 | ⬜ pending | +| 02-02-02 | 02 | 1 | DRV-02 | unit | `pytest tests/test_inf_parser.py::test_token_resolution -x` | ❌ W0 | ⬜ pending | +| 02-02-03 | 02 | 1 | DRV-02 | unit | `pytest tests/test_inf_parser.py::test_utf16_encoding -x` | ❌ W0 | ⬜ pending | +| 02-02-04 | 02 | 1 | DRV-02 | unit | `pytest tests/test_inf_parser.py::test_multi_model_inf -x` | ❌ W0 | ⬜ pending | +| 02-03-01 | 03 | 2 | DRV-03 | integration | `pytest tests/test_driver_upload.py::test_drivers_page -x` | ❌ W0 | ⬜ pending | +| 02-03-02 | 03 | 2 | DRV-03 | integration | `pytest tests/test_driver_upload.py::test_upload_returns_select -x` | ❌ W0 | ⬜ pending | +| 02-04-01 | 01 | 1 | DRV-04 | integration | `pytest tests/test_driver_upload.py::test_driver_persisted -x` | ❌ W0 | ⬜ pending | +| 02-04-02 | 01 | 1 | DRV-04 | integration | `pytest tests/test_driver_upload.py::test_dedup_upload -x` | ❌ W0 | ⬜ pending | +| 02-05-01 | 02 | 1 | DRV-05 | unit | `pytest tests/test_inf_parser.py::test_unused_files -x` | ❌ W0 | ⬜ pending | +| 02-05-02 | 03 | 2 | DRV-05 | integration | `pytest tests/test_driver_upload.py::test_unused_files_in_response -x` | ❌ W0 | ⬜ pending | + +*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky* + +--- + +## Wave 0 Requirements + +- [ ] `tests/test_inf_parser.py` — stubs for DRV-02 (INF parsing, token resolution, encoding, multi-model) +- [ ] `tests/test_driver_upload.py` — stubs for DRV-01, DRV-03, DRV-04, DRV-05 (upload endpoint integration tests) +- [ ] `tests/fixtures/sample.inf` — minimal valid INF fixture with `%TOKEN%` values +- [ ] `tests/fixtures/sample_utf16.inf` — UTF-16 LE encoded INF fixture +- [ ] `tests/fixtures/sample_multi_model.inf` — INF with NTamd64 and undecorated sections +- [ ] `imptune/services/__init__.py` — package marker (services/ directory) + +*Framework already installed; conftest.py with `tmp_data_dir` fixture already covers DB isolation* + +--- + +## Manual-Only Verifications + +| Behavior | Requirement | Why Manual | Test Instructions | +|----------|-------------|------------|-------------------| +| Upload form renders correctly in browser | DRV-03 | Visual layout verification | Open /drivers, verify file input and submit button visible | +| Dropdown populated after upload in browser | DRV-03 | HTMX swap visual verification | Upload sample ZIP, verify `` even for single-name drivers (decision in 02-02-SUMMARY). Real-browser HTMX swap covered by row 6. | +| 4 | **DRV-04** — Uploaded driver packages are persisted to the Docker volume (`DRIVERS_DIR`) under SHA256 content-addressed names and survive container restart; re-uploading the same ZIP does not duplicate the Driver record | `pytest tests/test_driver_upload.py::test_driver_persisted` (file lands on disk under `tmp_data_dir/drivers/`) and `::test_dedup_upload` (2 uploads → `Driver.select().where(sha256==...).count() == 1`) | `tests/test_driver_upload.py::test_driver_persisted`, `::test_dedup_upload`; `imptune/storage/driver_store.py::DriverStore.save` (SHA256-named files); `imptune/api/drivers.py` lines 85-99 (`DriverStore(_cfg.DRIVERS_DIR).save(data)` → `Driver.get_or_create(sha256=…)`); 02-VERIFICATION.md rows 11 + 12 | pass | Content-addressed storage gives dedup for free. `_cfg.DRIVERS_DIR` read dynamically at call time so monkeypatch works in tests (02-02-SUMMARY decision). | +| 5 | **DRV-05** — System flags unused files (files in ZIP not referenced by the INF) to help technicians reduce driver package size | `pytest tests/test_inf_parser.py::test_unused_files` (parser returns `unused_files` list) and `pytest tests/test_driver_upload.py::test_unused_files_in_response` (word "unused" present in response HTML) | `tests/test_inf_parser.py::test_unused_files`; `tests/test_driver_upload.py::test_unused_files_in_response`; `imptune/services/inf_parser.py` `ParsedInf.unused_files`; `imptune/templates/partials/driver_list.html` unused-files notice; 02-VERIFICATION.md rows 5 + 13 | pass | | +| 6 | **DRV-01 runtime gap** — `POST /drivers/upload` must not return HTTP 500 on real driver ZIPs uploaded via the browser (reported 2026-04-13 during Phase 8 kickoff; parallel to the v1.1 UX-01 DriverDesc-refresh requirement) | `pytest tests/test_driver_upload.py::test_upload_500_regression` (two parametrized variants: plain UTF-8 and UTF-16 LE BOM) returns 200, never 500; plus OOB refresh covered by `::test_upload_oob_*` contract tests | Phase 9 commit `10ee09a` (fix handler: `caller: str = Form("")` + OOB branch in `imptune/api/drivers.py`); Phase 9 commit `d1de839` (regression + OOB RED tests); Phase 9 commit `72c6a98` (printer_form.html wiring); `.planning/phases/09-ux-tech-debt-closure/09-01-SUMMARY.md` (UX-01 complete 2026-04-13); REQUIREMENTS.md v1.1 UX-01 = Complete | pass | **Historical gap recorded per CONTEXT.md locked decision.** At Phase 8 kickoff this was slated as `fail-fix-v1.1` linked to Phase 9 / UX-01. Resolved 2026-04-13 in Phase 9 Plan 01 (commits d1de839 + 10ee09a + 72c6a98); 112 tests green post-fix. Closed as `pass` citing the fixing commits, consistent with the 08-01 precedent (row 14 Phase 1 spike → Phase 10 RTVAL-01). | + +**Audit outcome:** 6/6 rows `pass`. No `fail-fix-v1.1`, `deferred-v1.2`, or `wont-do` rows. Phase 2 is Nyquist-compliant: every DRV-0x success criterion has exactly one observable check with cited, committed evidence. The Phase 8 kickoff-surfaced `POST /drivers/upload` 500 gap is captured as row 6 and closed via Phase 9 / UX-01 fixing commits — fully honoring the CONTEXT.md locked-decision mandate. + +--- + +## Validation Sign-Off + +- [ ] All tasks have `` verify or Wave 0 dependencies +- [ ] Sampling continuity: no 3 consecutive tasks without automated verify +- [ ] Wave 0 covers all MISSING references +- [ ] No watch-mode flags +- [ ] Feedback latency < 10s +- [x] `nyquist_compliant: true` set in frontmatter +- [x] Nyquist audit complete — 2026-04-13 — Sébastien QUEROL + +**Approval:** Nyquist-audited 2026-04-13 by Claude (gsd-executor, plan 08-02) — 6/6 pass; signed off 2026-04-13 by Sébastien QUEROL (index: v1.0-VALIDATION-INDEX.md) diff --git a/.planning/phases/02-driver-management/02-VERIFICATION.md b/.planning/phases/02-driver-management/02-VERIFICATION.md new file mode 100644 index 0000000..96b7bda --- /dev/null +++ b/.planning/phases/02-driver-management/02-VERIFICATION.md @@ -0,0 +1,150 @@ +--- +phase: 02-driver-management +verified: 2026-04-10T10:45:00Z +status: passed +score: 16/16 must-haves verified +re_verification: false +gaps: [] +human_verification: + - test: "Upload a real-world vendor driver ZIP via browser at /drivers" + expected: "Driver names appear in the select dropdown; page updates inline without reload" + why_human: "HTMX swap behaviour and real-vendor INF edge cases cannot be verified programmatically" + - test: "Upload the same ZIP a second time" + expected: "No duplicate row appears in the driver table; response still returns 200" + why_human: "Dedup correctness is test-verified but visual confirmation in browser confirms UI consistency" +--- + +# Phase 02: Driver Management Verification Report + +**Phase Goal:** Driver upload, INF parsing, and driver management for Windows driver packages +**Verified:** 2026-04-10T10:45:00Z +**Status:** PASSED +**Re-verification:** No — initial verification + +--- + +## Goal Achievement + +### Observable Truths (Plan 02-01) + +| # | Truth | Status | Evidence | +|---|-------|--------|----------| +| 1 | parse_inf extracts DriverDesc values from a simple INF with literal names | VERIFIED | `test_simple_driver_desc` passes; `parse_inf` returns `["Acme SuperPrint 9000"]` | +| 2 | parse_inf resolves %TOKEN% references via the [Strings] section | VERIFIED | `test_token_resolution` passes; `%HP_DRIVER%` resolves to `"HP LaserJet"` | +| 3 | parse_inf handles UTF-16 LE BOM, UTF-8 BOM, and ANSI (cp1252) encoded INF files | VERIFIED | `test_detect_encoding_utf16le`, `test_detect_encoding_utf16be`, `test_detect_encoding_utf8bom`, `test_detect_encoding_ansi`, `test_utf16_encoding` — all pass | +| 4 | parse_inf deduplicates driver names from multi-model INFs (NTamd64 + undecorated) | VERIFIED | `test_multi_model_inf` passes; `Multi Printer 1000` appears exactly once | +| 5 | parse_inf returns a list of unused files not referenced in the INF text | VERIFIED | `test_unused_files` passes; `readme.txt` in unused_files, `driver.dll` not in unused_files | +| 6 | parse_inf detects architecture from section decorations (x64, x86, arm64) | VERIFIED | `test_architecture_detection_amd64/arm64/undecorated/mixed` — all 4 pass | +| 7 | parse_inf detects presence of .cat file in ZIP member list | VERIFIED | `test_cat_file_detection_present` and `test_cat_file_detection_absent` pass | + +### Observable Truths (Plan 02-02) + +| # | Truth | Status | Evidence | +|---|-------|--------|----------| +| 8 | User can upload a ZIP file via the /drivers page and receive a success response | VERIFIED | `test_upload_valid_zip` passes; POST /drivers/upload returns 200 | +| 9 | After upload, the response contains a populated select dropdown with driver names from the INF | VERIFIED | `test_upload_returns_select` passes; `` and unused-files notice block | +| `tests/test_driver_upload.py` | Integration tests for upload endpoint and drivers page, min 80 lines | VERIFIED | 162 lines; 8 tests | + +--- + +## Key Link Verification + +| From | To | Via | Status | Details | +|------|----|-----|--------|---------| +| `imptune/services/inf_parser.py` | `configparser.RawConfigParser` | stdlib import | VERIFIED | `RawConfigParser(... strict=False ...)` at line 85 | +| `imptune/services/inf_parser.py` | [Strings] section token expansion | `re.sub(%([^%]+)%)` regex | VERIFIED | `_resolve_tokens` uses `re.sub(r"%([^%]+)%", replacer, value)` at line 60 | +| `imptune/api/drivers.py` | `imptune/services/inf_parser.py` | `from imptune.services.inf_parser import` | VERIFIED | Line 15: `from imptune.services.inf_parser import _detect_encoding, parse_inf` | +| `imptune/api/drivers.py` | `imptune/storage/driver_store.py` | `DriverStore(...).save(data)` | VERIFIED | Lines 85-86: `store = DriverStore(_cfg.DRIVERS_DIR)` then `sha256 = store.save(data)` | +| `imptune/api/drivers.py` | `imptune/db/models.py` | `Driver.get_or_create(sha256=...)` | VERIFIED | Lines 89-99: full `Driver.get_or_create(sha256=sha256, defaults={...})` | +| `imptune/templates/drivers.html` | `/drivers/upload` | `hx-post` with multipart/form-data | VERIFIED | `hx-post="/drivers/upload"` and `hx-encoding="multipart/form-data"` present | +| `imptune/main.py` | `imptune/api/drivers.py` | `app.include_router(drivers.router)` | VERIFIED | Line 30: `app.include_router(drivers.router)` | + +All 7 key links verified as WIRED. + +--- + +## Requirements Coverage + +| Requirement | Source Plan | Description | Status | Evidence | +|-------------|-------------|-------------|--------|----------| +| DRV-01 | 02-02 | User can upload a driver package (ZIP containing INF + supporting files) | SATISFIED | POST /drivers/upload validated; `test_upload_valid_zip` passes | +| DRV-02 | 02-01 | System parses uploaded INF files and extracts valid driver names (DriverDesc) | SATISFIED | `parse_inf` extracts DriverDesc; 16 unit tests all pass | +| DRV-03 | 02-02 | User can select driver name from parsed INF dropdown (no free-text) | SATISFIED | `` dropdown; unused files count shown if any. +**Why human:** HTMX swap behaviour (outerHTML targeting `#driver-list`) and real-vendor INF edge cases cannot be confirmed by automated HTTP tests. + +### 2. Duplicate upload visual confirmation + +**Test:** Upload the same ZIP twice via the browser. +**Expected:** Driver table shows exactly one row for that driver; no duplicate entry. +**Why human:** The dedup logic is verified by `test_dedup_upload` but the rendered table update on second upload benefits from a visual check. + +--- + +## Gaps Summary + +No gaps. All 14 observable truths verified, all 10 required artifacts present and substantive, all 7 key links wired, all 5 DRV-0x requirements satisfied. + +--- + +_Verified: 2026-04-10T10:45:00Z_ +_Verifier: Claude (gsd-verifier)_ diff --git a/.planning/phases/03-printer-configuration/03-01-PLAN.md b/.planning/phases/03-printer-configuration/03-01-PLAN.md new file mode 100644 index 0000000..a24f4eb --- /dev/null +++ b/.planning/phases/03-printer-configuration/03-01-PLAN.md @@ -0,0 +1,313 @@ +--- +phase: 03-printer-configuration +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - tests/test_printer_crud.py + - imptune/api/printers.py + - imptune/api/clients.py + - imptune/api/pages.py + - imptune/main.py + - imptune/templates/printers.html + - imptune/templates/clients.html + - imptune/templates/partials/printer_list.html + - imptune/templates/partials/printer_form.html +autonomous: false +requirements: + - PRNT-01 + - PRNT-02 + - PRNT-03 + - PRNT-04 + - PRNT-05 + - PRNT-06 + - PRNT-07 + - PRNT-08 + - PRNT-09 + +must_haves: + truths: + - "User can fill in a printer form with name, IP, port, duplex, color, paper size, collate and save it" + - "Port name auto-populates from IP address (user can still edit it)" + - "User can assign a printer to a client/tenant label" + - "Saved printer appears in a grouped list after page refresh" + - "User can create a new client from the clients page" + artifacts: + - path: "imptune/api/printers.py" + provides: "POST /printers endpoint with Form parsing and validation" + exports: ["router"] + - path: "imptune/api/clients.py" + provides: "POST /clients and GET /clients endpoints" + exports: ["router"] + - path: "imptune/templates/printers.html" + provides: "Printer list page grouped by client" + - path: "imptune/templates/partials/printer_form.html" + provides: "Printer create form with all fields, Alpine.js port derivation" + - path: "imptune/templates/partials/printer_list.html" + provides: "Grouped printer list fragment for HTMX swap" + - path: "tests/test_printer_crud.py" + provides: "Integration tests for PRNT-01 through PRNT-09" + key_links: + - from: "imptune/templates/partials/printer_form.html" + to: "/printers" + via: "hx-post form submission" + pattern: "hx-post.*printers" + - from: "imptune/api/printers.py" + to: "imptune/db/models.py" + via: "Printer.create() and Client.select()" + pattern: "Printer\\.create|Client\\.select" + - from: "imptune/main.py" + to: "imptune/api/printers.py" + via: "app.include_router(printers.router)" + pattern: "include_router.*printers" +--- + + +Implement printer and client CRUD with full form, grouped list display, and persistence. + +Purpose: This is the core of Phase 3 — technicians need to configure printer parameters, assign to clients, and see saved configs persist across sessions. All form fields (PRNT-01 through PRNT-07), client assignment (PRNT-08), and persistence (PRNT-09) are covered. + +Output: Working /printers and /clients pages with HTMX-powered form submission, Alpine.js port auto-derivation, and grouped printer list. + + + +@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/03-printer-configuration/03-RESEARCH.md + + + + +From imptune/db/models.py: +```python +class Client(BaseModel): + name = CharField(unique=True) + created_at = DateTimeField(default=datetime.utcnow) + +class Driver(BaseModel): + sha256 = CharField(unique=True, index=True) + original_filename = CharField() + size_bytes = IntegerField() + uploaded_at = DateTimeField(default=datetime.utcnow) + driver_desc = CharField(null=True) # JSON list of driver names + inf_filename = CharField(null=True) + architecture = CharField(null=True) + has_cat_file = BooleanField(default=False) + +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") + color_mode = BooleanField(default=True) + paper_size = CharField(default="A4") + collate = BooleanField(default=True) + created_at = DateTimeField(default=datetime.utcnow) + updated_at = DateTimeField(default=datetime.utcnow) +``` + +From imptune/api/drivers.py (established error pattern): +```python +def _error_response(message: str, status_code: int = 400) -> HTMLResponse: + return HTMLResponse( + content=f"

    {message}

    ", + status_code=status_code, + ) +``` + +From tests/conftest.py (test fixtures): +```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): + data_dir = tmp_path / "data" + data_dir.mkdir() + monkeypatch.setenv("DATA_DIR", str(data_dir)) + import imptune.config as cfg + cfg.DATA_DIR = str(data_dir) + cfg.DB_PATH = str(data_dir / "imptune.db") + cfg.DRIVERS_DIR = str(data_dir / "drivers") + return data_dir +``` + +From imptune/main.py (router registration pattern): +```python +app.include_router(health.router) +app.include_router(pages.router) +app.include_router(drivers.router) +``` +
    +
    + + + + + Task 1: Write failing integration tests for printer and client CRUD + tests/test_printer_crud.py + + - test_create_printer_persisted: POST /printers with name="Test Printer", ip_address="192.168.1.100", port_name="IP_192_168_1_100" returns 200; GET /printers contains "Test Printer" (covers PRNT-01, PRNT-02, PRNT-09) + - test_create_printer_duplex: POST /printers with duplex_mode="LongEdge"; verify Printer record has duplex_mode="LongEdge" (covers PRNT-04) + - test_create_printer_color_mode: POST /printers with color_mode=false; verify Printer record has color_mode=False (covers PRNT-05) + - test_create_printer_paper_size: POST /printers with paper_size="Letter"; verify Printer record has paper_size="Letter" (covers PRNT-06) + - test_create_printer_collate: POST /printers with collate=false; verify Printer record has collate=False (covers PRNT-07) + - test_create_client: POST /clients with name="Contoso" returns 200; GET /clients contains "Contoso" + - test_printer_grouped_by_client: Create client "Contoso", POST /printers with client_id=contoso.id; GET /printers HTML contains "Contoso" as group header (covers PRNT-08) + - test_create_printer_missing_name: POST /printers with empty name returns 400 (validation) + - test_create_printer_invalid_ip: POST /printers with ip_address="" returns 400 (validation) + - test_delete_printer: POST /printers to create, then DELETE /printers/{id} returns 200; printer no longer in GET /printers + + +Create `tests/test_printer_crud.py` with all tests listed above. Use the existing `client` fixture from conftest.py (which provides TestClient with lifespan-triggered init_db). Follow the same pattern as `test_driver_upload.py`: +- Import `pytest` and use `client` fixture +- POST form data via `client.post("/printers", data={...})` (not JSON, form-encoded) +- POST client creation via `client.post("/clients", data={"name": "Contoso"})` +- For verifying DB state, import `Printer` and `Client` from `imptune.db.models` inside each test +- For grouped list test, check that GET /printers response HTML contains the client name in an `

    ` or `
    ` header +- For boolean fields (color_mode, collate): HTML checkboxes send "on" when checked, nothing when unchecked. Use `data={"color_mode": ""}` for false and `data={"color_mode": "on"}` for true. Design tests accordingly. +- All tests should FAIL initially (routes don't exist yet). Run them to confirm RED state. + + + cd C:/Users/SebastienQUEROL/Documents/projets/ImpTune && python -m pytest tests/test_printer_crud.py -x -q 2>&1 | head -30 + + All tests exist and fail with connection/404 errors (RED state). No test passes yet. + + + + Task 2: Implement printer and client CRUD routes, templates, and wire routers + imptune/api/printers.py, imptune/api/clients.py, imptune/api/pages.py, imptune/main.py, imptune/templates/printers.html, imptune/templates/clients.html, imptune/templates/partials/printer_form.html, imptune/templates/partials/printer_list.html + +**1. Create `imptune/api/clients.py`:** +- `router = APIRouter(prefix="/clients")` +- `POST /clients`: Accept `name: str = Form(...)`. Validate non-empty. Create `Client.create(name=name)`. Handle IntegrityError (duplicate name) with 400 error. Return redirect or HTMX partial. +- Follow established pattern: sync `def` handlers, Jinja2Templates from same path as drivers.py. + +**2. Create `imptune/api/printers.py`:** +- `router = APIRouter(prefix="/printers")` +- `POST /printers`: Accept all form fields via `Form(...)`: + - `name: str = Form(...)` (required) + - `ip_address: str = Form(...)` (required) + - `port_name: str = Form(...)` (required) + - `duplex_mode: str = Form("OneSided")` — validate value in ("OneSided", "LongEdge", "ShortEdge") + - `color_mode: str = Form("")` — checkbox: "on" = True, "" = False. Convert: `bool(color_mode)` + - `paper_size: str = Form("A4")` — validate value in ("A4", "Letter", "Legal") + - `collate: str = Form("")` — same checkbox pattern as color_mode + - `client_id: str = Form("")` — empty string = None, otherwise int FK + - `driver_id: str = Form("")` — empty string = None, otherwise int FK +- Validate: name not empty, ip_address not empty. On failure return `_error_response(msg)` with `
    ` wrapper (same HTMX pattern as drivers.py). +- On success: `Printer.create(...)` with all fields. Return the updated printer list partial via `_render_printer_list(request)`. +- `DELETE /printers/{printer_id}`: Delete printer by ID. Return updated printer list partial. +- Helper `_render_printer_list(request)`: Query `Printer.select(Printer, Client).join(Client, JOIN.LEFT_OUTER).order_by(Client.name, Printer.name)`, group into `defaultdict(list)` by client name ("Unassigned" for null client_id), pass `grouped` to `partials/printer_list.html`. +- Helper `_error_response(message, status_code=400)`: Return `HTMLResponse(content=f"

    {message}

    ", status_code=status_code)`. + +**3. Update `imptune/api/pages.py`:** +Add two new page routes (import Client, Printer, Driver, json, JOIN from peewee): +- `GET /printers`: Render `printers.html` with `grouped` printers (same query as `_render_printer_list`), plus `clients` list and `driver_data` list for form dropdowns. +- `GET /clients`: Render `clients.html` with `clients = list(Client.select().order_by(Client.name))`. + +**4. Create `imptune/templates/printers.html`:** +- Extends `base.html`. Contains: + - `

    Printers

    ` + - Section with `

    Add Printer

    ` containing `{% include "partials/printer_form.html" %}` + - Section with `

    Printer Library

    ` containing `{% include "partials/printer_list.html" %}` + +**5. Create `imptune/templates/partials/printer_form.html`:** +- Wrap in `
    ` for Alpine.js reactivity. +- Form with `hx-post="/printers"`, `hx-target="#printer-list"`, `hx-swap="outerHTML"`. +- Fields: + - Printer Name: `` + - IP Address: `` + - Port Name: `` (PRNT-03) + - Driver: `` + - Duplex Mode: `` + - Color Mode: `` (default checked = True) + - Paper Size: `` + - Collate: `` (default checked = True) + - Client: `` + - Submit button: `` + +**6. Create `imptune/templates/partials/printer_list.html`:** +- `
    ` +- If grouped is empty: `

    No printers configured yet.

    ` +- Else: for each `(client_name, printers)` in grouped.items(): `

    {{ client_name }}

    ` then a `` with columns: Name, IP, Driver, Duplex, Paper, Actions. Each row has a Delete button with `hx-delete="/printers/{{ p.id }}" hx-target="#printer-list" hx-swap="outerHTML" hx-confirm="Delete '{{ p.name }}'?"`. + +**7. Create `imptune/templates/clients.html`:** +- Extends `base.html`. `

    Clients

    `. +- Form: `` with name input and submit button. +- `
    `: Table of clients (Name, Created, Printer Count). Printer count via `Client.printers` backref — pass pre-computed count from route. + +**8. Update `imptune/main.py`:** +- Add imports: `from imptune.api import clients, printers` +- Add: `app.include_router(printers.router)` and `app.include_router(clients.router)` + +After all files are created, run the full test suite to confirm GREEN state. + + + cd C:/Users/SebastienQUEROL/Documents/projets/ImpTune && python -m pytest tests/test_printer_crud.py -x -q && python -m pytest tests/ -v + + All test_printer_crud.py tests pass (GREEN). Full test suite passes. GET /printers shows form with all fields. POST /printers creates and persists printer. Printers grouped by client name in list. GET /clients shows client list with creation form. + + + + Task 3: Verify printer form and Alpine.js port auto-derivation in browser + imptune/templates/partials/printer_form.html + +Human verifies the complete printer configuration flow in a browser, especially the Alpine.js port auto-derivation (PRNT-03) which cannot be tested via pytest. + +What was built: Complete printer configuration form with Alpine.js port auto-derivation (PRNT-03), all form fields (PRNT-01 through PRNT-07), client assignment (PRNT-08), and persistence (PRNT-09). Also a /clients page for client management. + +Steps to verify: +1. Start app: `docker compose up` (or `uvicorn imptune.main:app --reload`) +2. Navigate to /clients — create a client "Contoso" +3. Navigate to /printers — verify empty state message +4. Fill in printer form: + - Name: "HP LaserJet 4050" + - IP: "192.168.1.100" — verify port name auto-fills to "IP_192_168_1_100" + - Manually edit port name to "CUSTOM_PORT" — change IP to "10.0.0.1" — verify port stays "CUSTOM_PORT" (not overwritten) + - Select duplex "Long Edge", uncheck Color, paper "Letter", check Collate + - Select client "Contoso" + - Click Save +5. Verify printer appears under "Contoso" group heading +6. Refresh page — verify printer still appears (persistence) +7. Click Delete on the printer — confirm deletion dialog — verify it disappears + + Human confirms all 7 steps pass in browser + Alpine.js port auto-derivation works correctly: auto-fills from IP, preserves manual edits. Full CRUD flow verified visually. + + + + + +- `pytest tests/test_printer_crud.py -x -q` — all printer CRUD tests pass +- `pytest tests/ -v` — full suite green (no regressions) +- GET /printers renders form with all required fields +- POST /printers persists to SQLite and returns updated list +- Printers are grouped by client name in the list display +- Alpine.js port derivation works in browser (manual checkpoint) + + + +- All PRNT-01 through PRNT-09 requirements verified by tests or manual check +- Printer form has: name, IP, port (auto-derived), duplex select, color checkbox, paper select, collate checkbox, client select, driver select +- Printer list groups by client with "Unassigned" fallback +- Client CRUD works on /clients page +- No N+1 queries (LEFT_OUTER JOIN used) +- Full test suite green + + + +After completion, create `.planning/phases/03-printer-configuration/03-01-SUMMARY.md` + diff --git a/.planning/phases/03-printer-configuration/03-01-SUMMARY.md b/.planning/phases/03-printer-configuration/03-01-SUMMARY.md new file mode 100644 index 0000000..7bc99b6 --- /dev/null +++ b/.planning/phases/03-printer-configuration/03-01-SUMMARY.md @@ -0,0 +1,166 @@ +--- +phase: 03-printer-configuration +plan: "01" +subsystem: api +tags: [fastapi, peewee, htmx, alpinejs, jinja2, sqlite, forms] + +requires: + - phase: 02-driver-management + provides: Driver ORM model, HTMX partial rendering pattern, error response pattern, test fixtures with tmp_data_dir + +provides: + - POST /printers endpoint with form parsing, validation, checkbox-to-bool conversion, FK resolution + - DELETE /printers/{id} endpoint + - POST /clients endpoint with duplicate-name handling + - GET /printers page grouped by client with LEFT OUTER JOIN (no N+1) + - GET /clients page with creation form + - Alpine.js port auto-derivation (IP -> port name, preserves manual edits) + - HTMX-powered form submission with outerHTML swap on #printer-list and #client-list + - Integration test suite covering PRNT-01 through PRNT-09 + +affects: + - 03-02 (next plan in printer configuration phase) + - Any phase using Printer or Client ORM models + - Test isolation pattern now fixed in conftest.py (affects all future test suites) + +tech-stack: + added: [] + patterns: + - "Printer/Client CRUD via FastAPI Form() parameters with sync def handlers" + - "Checkbox boolean convention: 'on'=True, absent/empty=False" + - "Grouped list via defaultdict + LEFT_OUTER JOIN — no N+1 queries" + - "HTMX partial swap: success returns partial, failure returns error div with same id" + - "Alpine.js x-data for reactive port derivation with portEdited guard" + - "Peewee test isolation: conftest.py fixture teardown closes test-thread DB connection" + +key-files: + created: + - imptune/api/printers.py + - imptune/api/clients.py + - imptune/templates/printers.html + - imptune/templates/clients.html + - imptune/templates/partials/printer_form.html + - imptune/templates/partials/printer_list.html + - imptune/templates/partials/client_list.html + - tests/test_printer_crud.py + modified: + - imptune/api/pages.py + - imptune/main.py + - imptune/db/database.py + - tests/conftest.py + +key-decisions: + - "Use list(Printer.select().where(...)) in tests instead of Printer.get() — Peewee's get() uses paginate+cursor caching that fails across DB re-inits in the same process" + - "Close test-thread DB connection in conftest.py fixture teardown — thread-local Peewee connections persist across tests and read from stale DB" + - "Close db in lifespan shutdown — enables clean re-init when TestClient is restarted in the same process" + - "Alpine.js portEdited guard prevents port overwrite after manual edit (PRNT-03 requirement)" + +patterns-established: + - "HTMX error fragment:

    {msg}

    with matching id for outerHTML swap" + - "Grouped list query: LEFT_OUTER JOIN with defaultdict grouping, 'Unassigned' fallback for null FK" + - "Form checkbox handling: Form('') default, 'on' == True conversion" + +requirements-completed: + - PRNT-01 + - PRNT-02 + - PRNT-03 + - PRNT-04 + - PRNT-05 + - PRNT-06 + - PRNT-07 + - PRNT-08 + - PRNT-09 + +duration: 7min +completed: "2026-04-10" +--- + +# Phase 03 Plan 01: Printer and Client CRUD Summary + +**FastAPI printer CRUD with Alpine.js IP-to-port derivation, HTMX form submission, LEFT JOIN grouped list by client, and 10-test integration suite covering PRNT-01 through PRNT-09** + +## Performance + +- **Duration:** ~7 min +- **Started:** 2026-04-10T10:49:28Z +- **Completed:** 2026-04-10T10:56:22Z +- **Tasks:** 2 of 3 (Task 3 is checkpoint:human-verify — pending) +- **Files modified:** 12 + +## Accomplishments + +- Printer CRUD: POST /printers (all 9 fields, checkbox bool conversion, optional FK), DELETE /printers/{id} +- Client CRUD: POST /clients (duplicate handling), GET /clients page +- Alpine.js port auto-derivation: fills `IP_x_x_x_x` from IP, preserves manual edits via `portEdited` guard +- Grouped list: LEFT_OUTER JOIN query, defaultdict grouping with "Unassigned" fallback, no N+1 +- 10 integration tests pass (GREEN), full 58-test suite passes + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Failing integration tests (RED)** - `9bc26e3` (test) +2. **Task 2: Full CRUD implementation + GREEN tests** - `356c2ee` (feat) +3. **Task 3: Browser verification** - pending (checkpoint:human-verify) + +## Files Created/Modified + +- `imptune/api/printers.py` - POST /printers, DELETE /printers/{id}, _render_printer_list helper +- `imptune/api/clients.py` - POST /clients, _render_client_list helper +- `imptune/api/pages.py` - Added GET /printers and GET /clients page routes +- `imptune/main.py` - Registered printers + clients routers; db.close() in lifespan shutdown +- `imptune/db/database.py` - Close existing connection before re-init in init_db() +- `imptune/templates/printers.html` - Printer page (form + list sections) +- `imptune/templates/clients.html` - Clients page (add form + list) +- `imptune/templates/partials/printer_form.html` - All 9 fields, Alpine.js x-data reactivity +- `imptune/templates/partials/printer_list.html` - Grouped by client with h3 headers, delete buttons +- `imptune/templates/partials/client_list.html` - Client table partial +- `tests/conftest.py` - Added db.close() teardown in tmp_data_dir fixture +- `tests/test_printer_crud.py` - 10 integration tests for all PRNT requirements + +## Decisions Made + +- Use `list(Model.select().where(...))` in tests instead of `Model.get()` — Peewee's `get()` uses `paginate(1,1)` with cursor caching that hits the wrong database when the deferred db is re-initialized between tests in the same process. +- Close db connection in conftest.py fixture teardown — thread-local Peewee connections persist across tests and read from stale DB path even after `db.init()` updates the path. +- Alpine.js `portEdited` boolean guard preserves manually edited port names when user changes IP (PRNT-03). + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 1 - Bug] Peewee thread-local DB connection leaks across test boundaries** +- **Found during:** Task 2 (GREEN phase verification) +- **Issue:** After TestClient exits and a new test begins with a fresh tmp DB, the test thread's Peewee connection still pointed at the previous test's DB file. `Printer.get()` would query the wrong database (empty or stale data). +- **Fix:** + 1. Added `db.close()` in lifespan shutdown (main.py) so each TestClient teardown closes the ASGI-thread connection. + 2. Added `if not db.is_closed(): db.close()` before `db.init()` in `init_db()` (database.py) so re-init always starts fresh. + 3. Added db connection teardown in `conftest.py` `tmp_data_dir` fixture to close the test-thread's connection after each test. + 4. Updated test DB queries from `Model.get()` to `list(Model.select().where(...))` to avoid Peewee paginate cursor caching issue. +- **Files modified:** imptune/main.py, imptune/db/database.py, tests/conftest.py, tests/test_printer_crud.py +- **Verification:** All 58 tests pass including cross-test ordering +- **Committed in:** `356c2ee` (Task 2 commit) + +--- + +**Total deviations:** 1 auto-fixed (Rule 1 - Bug) +**Impact on plan:** Fix was necessary for test correctness. The underlying isolation pattern now benefits all future test suites in this project. No scope creep. + +## Issues Encountered + +- Peewee `Model.get()` uses `paginate(1,1)` which clears `_cursor_wrapper` cache and re-executes — but after db re-init, the cursor wrapper was returning empty even though `count()` and direct SQL showed the record existed. Root cause: thread-local SQLite connection not updated by `db.init()`. Resolved by proper connection lifecycle management. + +## User Setup Required + +None — no external service configuration required. + +## Next Phase Readiness + +- /printers and /clients pages functional with full CRUD +- Alpine.js port auto-derivation implemented (PRNT-03) — browser verification still pending (Task 3 checkpoint) +- Printer form supports driver dropdown from uploaded drivers +- Grouped printer list ready for 03-02 (script generation) +- Test isolation pattern fixed — future test suites can safely use `list(Model.select().where(...))` for DB assertions + +--- +*Phase: 03-printer-configuration* +*Completed: 2026-04-10* diff --git a/.planning/phases/03-printer-configuration/03-02-PLAN.md b/.planning/phases/03-printer-configuration/03-02-PLAN.md new file mode 100644 index 0000000..4a77e48 --- /dev/null +++ b/.planning/phases/03-printer-configuration/03-02-PLAN.md @@ -0,0 +1,207 @@ +--- +phase: 03-printer-configuration +plan: 02 +type: execute +wave: 2 +depends_on: ["03-01"] +files_modified: + - tests/test_printer_crud.py + - imptune/api/printers.py + - imptune/api/pages.py + - imptune/templates/partials/printer_detail.html +autonomous: true +requirements: + - PRNT-10 + +must_haves: + truths: + - "User can open a saved printer config and see all fields pre-populated" + - "User can see the associated driver info on the detail page" + - "A regenerate button is visible (disabled/placeholder until Phase 4)" + artifacts: + - path: "imptune/templates/partials/printer_detail.html" + provides: "Printer detail view with all fields and driver info" + - path: "imptune/api/printers.py" + provides: "GET /printers/{id} detail endpoint" + key_links: + - from: "imptune/templates/partials/printer_list.html" + to: "/printers/{id}" + via: "printer name link in list row" + pattern: "href.*printers.*id" + - from: "imptune/api/printers.py" + to: "imptune/db/models.py" + via: "Printer.get_by_id with driver FK access" + pattern: "Printer\\.get_by_id|printer\\.driver" +--- + + +Implement the printer detail/edit page so saved configs can be retrieved and prepared for regeneration. + +Purpose: PRNT-10 requires that a user can open a saved printer config and regenerate its package without re-uploading drivers. Phase 3's scope is: the config is fully retrievable, driver FK is intact, and a "Regenerate" button exists (placeholder until Phase 4 delivers script generation). This also adds printer name links in the list for navigation. + +Output: GET /printers/{id} detail page with pre-populated fields, driver info display, and regeneration placeholder button. + + + +@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/03-printer-configuration/03-RESEARCH.md +@.planning/phases/03-printer-configuration/03-01-SUMMARY.md + + + + +From imptune/api/printers.py (created in Plan 01): +```python +router = APIRouter(prefix="/printers") + +def _render_printer_list(request: Request) -> HTMLResponse: + """Returns partials/printer_list.html with grouped printers.""" + +def _error_response(message: str, status_code: int = 400) -> HTMLResponse: + """HTMX-friendly error fragment.""" +``` + +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") + color_mode = BooleanField(default=True) + paper_size = CharField(default="A4") + collate = BooleanField(default=True) + +class Driver(BaseModel): + sha256 = CharField(unique=True) + original_filename = CharField() + driver_desc = CharField(null=True) # JSON list of driver names +``` + +From imptune/storage/driver_store.py: +```python +class DriverStore: + def get_path(self, sha256: str) -> Path: + """Returns path to stored driver ZIP.""" +``` + + + + + + + Task 1: Write failing test for printer detail page + tests/test_printer_crud.py + + - test_printer_detail_shows_driver: Create a Driver record (via direct Peewee insert with sha256, original_filename, driver_desc=json.dumps(["HP Universal"])), create a Printer with driver FK set. GET /printers/{id} returns 200 with HTML containing printer name, IP, and "HP Universal" driver name. + - test_printer_detail_not_found: GET /printers/9999 returns 404. + - test_printer_detail_no_driver: Create a Printer with driver=None. GET /printers/{id} returns 200, HTML does not crash, shows "No driver assigned" or similar. + + +Append three new tests to the existing `tests/test_printer_crud.py` file (created in Plan 01): +- `test_printer_detail_shows_driver`: Use the `client` fixture. Create a Driver record directly via `Driver.create(sha256="abc123", original_filename="test.zip", size_bytes=1000, driver_desc=json.dumps(["HP Universal"]))`. Create a Printer with `driver=driver_obj`. GET `/printers/{printer.id}` and assert 200 status. Assert "HP Universal" appears in response text. Assert printer name appears. +- `test_printer_detail_not_found`: GET `/printers/9999` returns 404. +- `test_printer_detail_no_driver`: Create Printer with driver=None. GET `/printers/{printer.id}` returns 200. Assert "No driver assigned" or similar text in response. + +Run tests to confirm RED state (route does not exist yet). + + + cd C:/Users/SebastienQUEROL/Documents/projets/ImpTune && python -m pytest tests/test_printer_crud.py::test_printer_detail_shows_driver tests/test_printer_crud.py::test_printer_detail_not_found tests/test_printer_crud.py::test_printer_detail_no_driver -x -q 2>&1 | head -20 + + Three new tests exist and fail (RED state). Existing tests still pass. + + + + Task 2: Implement printer detail route, template, and list navigation links + imptune/api/printers.py, imptune/api/pages.py, imptune/templates/partials/printer_detail.html, imptune/templates/partials/printer_list.html + +**1. Add GET /printers/{printer_id} to `imptune/api/pages.py`:** +- Route: `@router.get("/printers/{printer_id}", response_class=HTMLResponse)` +- Handler: `def printer_detail(request: Request, printer_id: int):` +- Query: `Printer.select(Printer, Client, Driver).join(Client, JOIN.LEFT_OUTER).switch(Printer).join(Driver, JOIN.LEFT_OUTER).where(Printer.id == printer_id).first()` +- If not found: return HTMLResponse with 404 status and a simple error page. +- If found: parse `printer.driver.driver_desc` (JSON) into driver_names list if driver exists. Pass `printer`, `driver_names`, and `driver` to template. +- Render `printers.html` but with a detail block, OR create a dedicated detail template that extends base.html. Prefer: render `partials/printer_detail.html` inside the printers page layout. + +Actually, simpler approach: create a standalone detail page. +- Render: `templates.TemplateResponse(request=request, name="printer_detail.html", context={"printer": printer, "driver_names": driver_names})` +- This requires creating `imptune/templates/printer_detail.html` (NOT a partial — a full page). + +**2. Create `imptune/templates/printer_detail.html`:** +Extends `base.html`. Content: +``` +

    {{ printer.name }}

    +
    +

    Configuration

    +
    +
    IP Address
    {{ printer.ip_address }}
    +
    Port Name
    {{ printer.port_name }}
    +
    Duplex Mode
    {{ printer.duplex_mode }}
    +
    Color Mode
    {{ "Color" if printer.color_mode else "Grayscale" }}
    +
    Paper Size
    {{ printer.paper_size }}
    +
    Collate
    {{ "Yes" if printer.collate else "No" }}
    +
    Client
    {{ printer.client.name if printer.client_id else "Unassigned" }}
    +
    + +

    Driver

    + {% if printer.driver_id %} +
    +
    Package
    {{ printer.driver.original_filename }}
    +
    Driver Name(s)
    {{ driver_names | join(", ") }}
    +
    Architecture
    {{ printer.driver.architecture or "Unknown" }}
    +
    + {% else %} +

    No driver assigned

    + {% endif %} + +

    Actions

    + + Back to Printers +
    +``` + +**3. Update `imptune/templates/partials/printer_list.html`:** +Make printer names clickable: change the Name `
    ` | +| `imptune/api/pages.py` | `imptune/db/models.py` | `Printer.get_by_id` with driver FK | VERIFIED | Lines 81-87: LEFT OUTER JOIN chain with `.switch(Printer).join(Driver, JOIN.LEFT_OUTER)` | + +--- + +## Requirements Coverage + +| Requirement | Source Plan | Description | Status | Evidence | +|-------------|------------|-------------|--------|----------| +| PRNT-01 | 03-01 | User can set printer display name | SATISFIED | `name` field in form; `test_create_printer_persisted` verifies persistence | +| PRNT-02 | 03-01 | User can set printer IP address or hostname | SATISFIED | `ip_address` field in form; `test_create_printer_persisted` verifies | +| PRNT-03 | 03-01 | System auto-suggests port name from IP (user can override) | NEEDS HUMAN | Alpine.js logic present and correct in template; browser verification required | +| PRNT-04 | 03-01 | User can set duplex mode | SATISFIED | `duplex_mode` select with 3 options; `test_create_printer_duplex` verifies LongEdge | +| PRNT-05 | 03-01 | User can set color vs. grayscale default | SATISFIED | `color_mode` checkbox; `test_create_printer_color_mode` verifies False when unchecked | +| PRNT-06 | 03-01 | User can set paper size | SATISFIED | `paper_size` select with A4/Letter/Legal; `test_create_printer_paper_size` verifies | +| PRNT-07 | 03-01 | User can set collate on/off | SATISFIED | `collate` checkbox; `test_create_printer_collate` verifies False when unchecked | +| PRNT-08 | 03-01 | User can assign printer to a client/tenant label | SATISFIED | `client_id` FK select; `test_printer_grouped_by_client` verifies grouping | +| PRNT-09 | 03-01 | Printer configurations are persisted in SQLite across sessions | SATISFIED | `test_create_printer_persisted` verifies DB count and GET /printers shows saved record | +| PRNT-10 | 03-02 | User can regenerate a package from saved config without re-uploading drivers | SATISFIED (partial) | Detail page loads full config with driver FK intact (`test_printer_detail_shows_driver`); regenerate button present but disabled — full regeneration is a Phase 4 deliverable per plan scope | + +--- + +## Anti-Patterns Found + +None. Scanned `imptune/api/printers.py`, `imptune/api/clients.py`, `imptune/api/pages.py`, `imptune/templates/printers.html`, `imptune/templates/printer_detail.html` for TODO/FIXME/placeholder comments, empty return values, and console.log-only handlers. No issues found. + +The disabled "Regenerate Package" button is intentional scope deferral (Phase 4), not a stub — documented in plan and REQUIREMENTS.md. + +--- + +## Human Verification Required + +### 1. Alpine.js Port Auto-Derivation (PRNT-03) + +**Test:** Start the app (`uvicorn imptune.main:app --reload`). Navigate to `/printers`. In the printer form: +1. Type `192.168.1.100` into the IP Address field. +2. Verify that the Port Name field auto-fills to `IP_192_168_1_100` as you type. +3. Manually edit the Port Name field to `CUSTOM_PORT`. +4. Change the IP Address to `10.0.0.1`. +5. Verify the Port Name remains `CUSTOM_PORT` (not overwritten by the IP change). + +**Expected:** Auto-fill works during step 2; manual edit lock works during step 5. + +**Why human:** Alpine.js `@input` and `@change` handlers with `portEdited` flag execute in-browser JavaScript. The FastAPI `TestClient` does not run a JavaScript engine, so this behavior cannot be tested via pytest. + +--- + +## Summary + +Phase 03 goal is substantively achieved. All 10 requirement IDs (PRNT-01 through PRNT-10) are implemented with real code — no stubs, no placeholder routes, no empty handlers. The full test suite (61 tests) passes cleanly. + +The only item requiring human confirmation is PRNT-03 (Alpine.js port auto-derivation from IP). The implementation is correct — the `x-data` block, `x-model` bindings, `@input` handler, and `portEdited` guard are all present in `printer_form.html` — but this is JavaScript behavior that only executes in a browser. + +PRNT-10's "regenerate" button is disabled by design. The plan explicitly scopes Phase 3's PRNT-10 deliverable as "config retrievable with driver FK intact, regenerate button present as placeholder." The full regeneration workflow is Phase 4's responsibility. This is not a gap. + +--- + +_Verified: 2026-04-10T12:30:00Z_ +_Verifier: Claude (gsd-verifier)_ 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-01-SUMMARY.md b/.planning/phases/04-script-generation/04-01-SUMMARY.md new file mode 100644 index 0000000..4cf893b --- /dev/null +++ b/.planning/phases/04-script-generation/04-01-SUMMARY.md @@ -0,0 +1,106 @@ +--- +phase: 04-script-generation +plan: "01" +subsystem: script-generator +tags: [jinja2, powershell, tdd, wow64, uac, pnputil, idempotency] +one_liner: "Jinja2-based install.ps1 generator with WOW64 guard, UAC elevation, pnputil two-step, and duplex mapping" +dependency_graph: + requires: [] + provides: [render_install, install.ps1.j2] + affects: [imptune.generators.script_generator, imptune.templates.scripts] +tech_stack: + added: [] + patterns: + - Jinja2 FileSystemLoader with trim_blocks + lstrip_blocks for PowerShell templates + - Plain-string function parameters for DB-free unit testability + - _duplex_map translation dict (model values -> PowerShell cmdlet values) +key_files: + created: + - imptune/generators/script_generator.py + - imptune/templates/scripts/install.ps1.j2 + - tests/test_script_generator.py + modified: [] +decisions: + - "render_install() takes plain string args (not ORM Printer object) — keeps tests DB-free" + - "Boolean color_mode/collate converted to lowercase 'true'/'false' strings; template adds $ prefix" + - "_duplex_map translates before rendering: LongEdge->TwoSidedLongEdge, ShortEdge->TwoSidedShortEdge" + - "WOW64 guard comment avoids 'pnputil.exe' text to preserve ordering assertion in test" +metrics: + duration: "~2 min" + completed_date: "2026-04-10" + tasks_completed: 1 + files_created: 3 + files_modified: 0 + tests_added: 7 + tests_passing: 68 +requirements-completed: [SCRPT-01, SCRPT-04, SCRPT-05] +--- + +# Phase 4 Plan 01: Script Generator (Install) Summary + +**One-liner:** Jinja2-based install.ps1 generator with WOW64 guard, UAC elevation, pnputil two-step, and duplex mapping + +## What Was Built + +A TDD-developed module `imptune/generators/script_generator.py` with a single public function `render_install()` that renders the `install.ps1.j2` Jinja2 template into a complete, production-ready PowerShell printer install script. + +### render_install() function + +- Takes plain string arguments (no ORM dependency) for easy unit testing +- Translates `duplex_mode` via `_duplex_map` before passing to template +- Converts Python booleans to lowercase strings (`"true"`/`"false"`) for PowerShell `$true`/`$false` rendering + +### install.ps1.j2 template structure (in order) + +1. Header comment with printer name and required Intune install command +2. WOW64 guard (`$env:PROCESSOR_ARCHITECTURE` + `SysNative` relaunch) — FIRST executable block +3. SYSTEM vs admin detection (`[WindowsIdentity]::GetCurrent()`, `IsSystem`, `IsInRole(Administrator)`) + UAC self-elevation via `Start-Process -Verb Runas` +4. pnputil two-step: `/add-driver` to stage INF, then `Add-PrinterDriver` to register +5. Idempotent port creation: `Get-PrinterPort` check before `Add-PrinterPort` +6. Idempotent printer creation: `Get-Printer` check before `Add-Printer` +7. `Set-PrintConfiguration` with translated duplex, color, paper size, collate + +## TDD Execution + +### RED Phase (commit b4f2c64) + +7 tests written in `tests/test_script_generator.py` covering SCRPT-01, SCRPT-04, SCRPT-05. All failed with `ModuleNotFoundError` (confirmed RED). + +### GREEN Phase (commit 8193e9d) + +- `imptune/generators/script_generator.py` created +- `imptune/templates/scripts/install.ps1.j2` created + +One auto-fix required during GREEN: template comment contained `"pnputil.exe"` before the WOW64 `PROCESSOR_ARCHITECTURE` check text, causing the ordering assertion in `test_render_install_wow64_guard` to fail. Fixed by removing `.exe` from the comment text. Not a logic error — purely a textual ordering issue in the rendered output. + +All 7 new tests pass. Full suite: 68/68 passing. + +## Commits + +| Hash | Type | Description | +|------|------|-------------| +| b4f2c64 | test | RED phase — 7 failing tests for script_generator | +| 8193e9d | feat | GREEN phase — script_generator.py + install.ps1.j2 | + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 1 - Bug] Template comment contained 'pnputil.exe' before WOW64 guard text** +- **Found during:** GREEN phase test run +- **Issue:** Comment in the WOW64 guard block header said "pnputil.exe is 64-bit only", so the string "pnputil.exe" appeared in the rendered output before "PROCESSOR_ARCHITECTURE", breaking the ordering assertion in `test_render_install_wow64_guard` +- **Fix:** Changed "pnputil.exe is 64-bit only" to "pnputil is 64-bit only" in the template comment +- **Files modified:** `imptune/templates/scripts/install.ps1.j2` +- **Commit:** 8193e9d (included in same GREEN commit) + +## Self-Check: PASSED + +All created files verified on disk. All commits verified in git log. + +| Item | Status | +|------|--------| +| imptune/generators/script_generator.py | FOUND | +| imptune/templates/scripts/install.ps1.j2 | FOUND | +| tests/test_script_generator.py | FOUND | +| Commit b4f2c64 (RED) | FOUND | +| Commit 8193e9d (GREEN) | FOUND | 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` + diff --git a/.planning/phases/04-script-generation/04-02-SUMMARY.md b/.planning/phases/04-script-generation/04-02-SUMMARY.md new file mode 100644 index 0000000..b1b8729 --- /dev/null +++ b/.planning/phases/04-script-generation/04-02-SUMMARY.md @@ -0,0 +1,117 @@ +--- +phase: 04-script-generation +plan: "02" +subsystem: api +tags: [powershell, jinja2, fastapi, intune, tdd] + +requires: + - phase: 04-01 + provides: [render_install, install.ps1.j2, script_generator module with Jinja2 env] +provides: + - render_uninstall function (Remove-Printer/Driver/Port in safe order) + - render_detect function (Intune detection contract) + - uninstall.ps1.j2 template + - detect.ps1.j2 template + - GET /printers/{id}/scripts/install endpoint + - GET /printers/{id}/scripts/uninstall endpoint + - GET /printers/{id}/scripts/detect endpoint + - scripts.py APIRouter registered in main.py +affects: [phase-05-packaging] + +tech-stack: + added: [] + patterns: + - Shared _get_printer_and_driver() helper extracts ORM validation to avoid duplication across 3 endpoints + - All script endpoints return PlainTextResponse with Content-Disposition attachment header + - Integration tests use ORM directly (Driver.create/Printer.create) — no HTTP fixture for setup + +key-files: + created: + - imptune/templates/scripts/uninstall.ps1.j2 + - imptune/templates/scripts/detect.ps1.j2 + - imptune/api/scripts.py + modified: + - imptune/generators/script_generator.py + - imptune/main.py + - tests/test_script_generator.py + +key-decisions: + - "_get_printer_and_driver() private helper centralises 404/422 validation for all 3 script endpoints" + - "PlainTextResponse with Content-Disposition attachment; filename='{type}.ps1' on all script endpoints" + - "Integration tests create ORM records directly (Driver.create/Printer.create) — same pattern as printer CRUD tests" + +patterns-established: + - "Script endpoint pattern: validate printer -> validate driver -> validate inf -> parse driver_desc -> render -> return attachment" + +requirements-completed: [SCRPT-02, SCRPT-03] + +duration: ~2min +completed: 2026-04-10 +--- + +# Phase 4 Plan 02: Script Generator (Uninstall + Detect + API) Summary + +**Jinja2 uninstall/detect templates, render_uninstall/render_detect functions, and three downloadable PS1 script endpoints wired to the scripts router** + +## Performance + +- **Duration:** ~2 min +- **Started:** 2026-04-10T11:33:58Z +- **Completed:** 2026-04-10T11:36:13Z +- **Tasks:** 2 +- **Files modified:** 6 + +## Accomplishments + +- render_uninstall() produces Remove-Printer > Remove-PrinterDriver > Remove-PrinterPort with -ErrorAction SilentlyContinue (safe ordering) +- render_detect() follows Intune detection contract: Get-Printer check, Write-Output + exit 0 when found, exit 1 when absent +- Three GET endpoints /printers/{id}/scripts/{install,uninstall,detect} return PS1 scripts as file downloads +- Full error handling: 404 for missing printer, 422 for missing driver/INF/driver_desc +- Full test suite green: 75 tests (7 new tests added) + +## Task Commits + +Each task was committed atomically: + +1. **Task 1 RED: Failing tests for render_uninstall/detect** - `0f213df` (test) +2. **Task 1 GREEN: render_uninstall + render_detect + templates** - `6bff8f3` (feat) +3. **Task 2: Script API endpoints + router registration** - `b7b0d1b` (feat) + +_Note: TDD task split into RED + GREEN commits per TDD protocol_ + +## Files Created/Modified + +- `imptune/templates/scripts/uninstall.ps1.j2` - PowerShell uninstall template (Remove-Printer/Driver/Port in order) +- `imptune/templates/scripts/detect.ps1.j2` - PowerShell Intune detection template (Get-Printer + exit 0/1) +- `imptune/generators/script_generator.py` - Added render_uninstall() and render_detect() functions +- `imptune/api/scripts.py` - APIRouter with 3 script download endpoints, shared validation helper +- `imptune/main.py` - Registered scripts.router +- `tests/test_script_generator.py` - Added 2 unit tests + 5 integration tests + +## Decisions Made + +- `_get_printer_and_driver()` private helper centralises 404/422 validation logic for all three endpoints — avoids repeating identical ORM+validation code 3 times +- `PlainTextResponse` with `Content-Disposition: attachment; filename="{type}.ps1"` on all endpoints so browsers download the file rather than rendering it +- Integration tests create ORM records directly via `Driver.create()`/`Printer.create()` — same established pattern as printer CRUD tests, no HTTP API calls for setup + +## Deviations from Plan + +None - plan executed exactly as written. + +## Issues Encountered + +None - all tests passed on first run after implementation. + +## User Setup Required + +None - no external service configuration required. + +## Next Phase Readiness + +- All three script types downloadable via API — ready for Phase 5 packaging +- render_install, render_uninstall, render_detect all available in script_generator module +- scripts.py router registered and functional + +--- +*Phase: 04-script-generation* +*Completed: 2026-04-10* diff --git a/.planning/phases/04-script-generation/04-RESEARCH.md b/.planning/phases/04-script-generation/04-RESEARCH.md new file mode 100644 index 0000000..6d4e697 --- /dev/null +++ b/.planning/phases/04-script-generation/04-RESEARCH.md @@ -0,0 +1,584 @@ +# 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 + +| 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 | + + +--- + +## 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 + +```bash +# 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. + +```python +# 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 + +```python +# 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: +```python +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 /install (uses $PSScriptRoot) +4. Idempotency check: Add-PrinterPort only if port does not exist +5. Add-PrinterDriver -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: + +```powershell +# 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): + +```powershell +# 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: + +```powershell +$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:** +```powershell +$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) + +```powershell +# 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) + +```powershell +# 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) + +```powershell +# 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 + +```powershell +# 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) + +```powershell +# 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) + +```powershell +# 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) + +```powershell +# 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 + +```python +# 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) + +- 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) diff --git a/.planning/phases/04-script-generation/04-VALIDATION.md b/.planning/phases/04-script-generation/04-VALIDATION.md new file mode 100644 index 0000000..77d3207 --- /dev/null +++ b/.planning/phases/04-script-generation/04-VALIDATION.md @@ -0,0 +1,108 @@ +--- +phase: 4 +slug: script-generation +status: draft +nyquist_compliant: true +wave_0_complete: false +created: 2026-04-10 +nyquist_audited: 2026-04-13 +nyquist_auditor: Claude (gsd-executor, plan 08-04) +--- + +# Phase 4 — Validation Strategy + +> Per-phase validation contract for feedback sampling during execution. + +--- + +## Test Infrastructure + +| 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` | +| **Estimated runtime** | ~10 seconds | + +--- + +## Sampling Rate + +- **After every task commit:** Run `python -m pytest tests/test_script_generator.py -x -q` +- **After every plan wave:** Run `python -m pytest tests/ -x -q` +- **Before `/gsd:verify-work`:** Full suite must be green +- **Max feedback latency:** 10 seconds + +--- + +## Per-Task Verification Map + +| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status | +|---------|------|------|-------------|-----------|-------------------|-------------|--------| +| 4-01-01 | 01 | 1 | SCRPT-01 | unit | `python -m pytest tests/test_script_generator.py::test_render_install_contains_pnputil -x` | ❌ W0 | ⬜ pending | +| 4-01-02 | 01 | 1 | SCRPT-01 | unit | `python -m pytest tests/test_script_generator.py::test_render_install_print_config -x` | ❌ W0 | ⬜ pending | +| 4-02-01 | 02 | 1 | SCRPT-02 | unit | `python -m pytest tests/test_script_generator.py::test_render_uninstall -x` | ❌ W0 | ⬜ pending | +| 4-02-02 | 02 | 1 | SCRPT-03 | unit | `python -m pytest tests/test_script_generator.py::test_render_detect -x` | ❌ W0 | ⬜ pending | +| 4-01-03 | 01 | 1 | SCRPT-04 | unit | `python -m pytest tests/test_script_generator.py::test_render_install_uac_guard -x` | ❌ W0 | ⬜ pending | +| 4-01-04 | 01 | 1 | SCRPT-05 | unit | `python -m pytest tests/test_script_generator.py::test_render_install_wow64_guard -x` | ❌ W0 | ⬜ pending | +| 4-03-01 | 03 | 2 | SCRPT-01 | integration | `python -m pytest tests/test_script_generator.py::test_install_endpoint -x` | ❌ W0 | ⬜ pending | +| 4-03-02 | 03 | 2 | SCRPT-02 | integration | `python -m pytest tests/test_script_generator.py::test_uninstall_endpoint -x` | ❌ W0 | ⬜ pending | +| 4-03-03 | 03 | 2 | SCRPT-03 | integration | `python -m pytest tests/test_script_generator.py::test_detect_endpoint -x` | ❌ W0 | ⬜ pending | + +*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky* + +--- + +## Wave 0 Requirements + +- [ ] `tests/test_script_generator.py` — stubs for SCRPT-01 through SCRPT-05 (unit + integration) +- [ ] `imptune/templates/scripts/install.ps1.j2` — Jinja2 template file +- [ ] `imptune/templates/scripts/uninstall.ps1.j2` — Jinja2 template file +- [ ] `imptune/templates/scripts/detect.ps1.j2` — Jinja2 template file +- [ ] `imptune/generators/script_generator.py` — render functions +- [ ] `imptune/api/scripts.py` — FastAPI router + +--- + +## Manual-Only Verifications + +| Behavior | Requirement | Why Manual | Test Instructions | +|----------|-------------|------------|-------------------| +| Install script runs on real Windows endpoint via Intune | SCRPT-01 | Requires real Intune + endpoint | Deploy .intunewin package to test device, verify printer appears | +| UAC elevation prompt appears for standard user | SCRPT-04 | Requires interactive desktop session | Run install.ps1 as standard user, verify UAC dialog | +| WOW64 relaunch works in 32-bit PS | SCRPT-05 | Requires 32-bit PowerShell host | Launch powershell.exe (x86), run install.ps1, verify relaunch | + +--- + +## Nyquist Record + +> Audited 2026-04-13 by Claude (gsd-executor, plan 08-04). One row per Phase 4 success criterion derived from `milestones/v1.0-ROADMAP.md` Phase 4 goal + plan outcomes (SCRPT-01..05), cross-checked against `04-VERIFICATION.md` (12/12 observable truths verified 2026-04-10) and `REQUIREMENTS.md` v1.0 SCRPT-0x block. Evidence cites committed tests, source lines, the dated VERIFICATION report, and — for rows whose proof requires real-device SYSTEM-context execution — the Phase 10 `RUNTIME-VALIDATION.md` report with explicit attestation-only caveats per STATE.md 2026-04-13. +> +> **Phase 4 goal (v1.0-ROADMAP.md):** *"System produces correct, production-ready PowerShell scripts handling all Intune and RMM execution contexts."* +> +> **Attestation-only caveat (STATE.md 2026-04-13):** Phase 10 RTVAL-02 (install on real endpoint), RTVAL-03 (detection script on real endpoint), and RTVAL-04 (uninstall on real endpoint) were accepted as **attestation-only PASSes** — the technician verbally confirmed success but did not produce IntuneManagementExtension.log excerpts, portal screenshots, or status captures. The user was warned twice about cumulative audit-trail damage and explicitly approved proceeding. Plan 10-03 closed the phase with this gap acknowledged in writing. Rows below that depend on SYSTEM-context runtime proof therefore record `pass` (Phase 10 signed off) but the Notes column states the weakened audit trail faithfully — this audit does not hide it. + +| # | Success Criterion | Observable Check | Evidence | Status | Notes | +|---|-------------------|------------------|----------|--------|-------| +| 1 | **SCRPT-01** — Generate PowerShell install script (pnputil staging + Add-PrinterPort + Add-PrinterDriver + Add-Printer + Set-PrintConfiguration) | `pytest tests/test_script_generator.py::test_render_install_contains_pnputil` + `::test_render_install_print_config` + `::test_install_endpoint` — unit tests assert all 5 cmdlets appear in rendered template; integration test asserts `GET /printers/{id}/scripts/install` returns 200 PowerShell content with pnputil present | `tests/test_script_generator.py::test_render_install_contains_pnputil`, `::test_render_install_print_config`, `::test_install_endpoint`; `imptune/templates/scripts/install.ps1.j2` lines 40-67 (pnputil `/add-driver` + Add-PrinterPort + Add-PrinterDriver + Add-Printer + Set-PrintConfiguration); `imptune/generators/script_generator.py` `_duplex_map` + `render_install` (commits b4f2c64 RED, 8193e9d GREEN); `imptune/api/scripts.py` lines 38-59; 04-VERIFICATION.md truths 1 + 4 + 5 + 8; Phase 10 `RUNTIME-VALIDATION.md` RTVAL-02 (install succeeded on ARES-5CG5220YTM) | pass | **SYSTEM-context runtime proof is attestation-only per STATE.md 2026-04-13.** pnputil staging + $PSScriptRoot resolution under the real Intune SYSTEM context were confirmed verbally by the technician for RTVAL-02 but no IntuneManagementExtension.log excerpt or portal screenshot was captured. Phase 10 signed off the gap; rollout Phase 11 owns re-capture of full artifacts. Template-level correctness (cmdlet presence, positional ordering, duplex mapping) is fully automated via pytest. | +| 2 | **SCRPT-02** — Generate PowerShell uninstall script (Remove-Printer + Remove-PrinterDriver + Remove-PrinterPort in correct order) | `pytest tests/test_script_generator.py::test_render_uninstall` + `::test_uninstall_endpoint` — asserts all 3 Remove-* cmdlets appear in correct order (Printer → Driver → Port) with `-ErrorAction SilentlyContinue` on each; integration test asserts endpoint returns 200 | `tests/test_script_generator.py::test_render_uninstall`, `::test_uninstall_endpoint`; `imptune/templates/scripts/uninstall.ps1.j2` lines 2-4; `imptune/generators/script_generator.py::render_uninstall` line 70 (commit 6bff8f3); `imptune/api/scripts.py` lines 63-78 (commit b7b0d1b); 04-VERIFICATION.md truth 6 + truth 9; Phase 10 `RUNTIME-VALIDATION.md` RTVAL-04 (uninstall succeeded on real endpoint) | pass | **SYSTEM-context runtime proof is attestation-only per STATE.md 2026-04-13.** RTVAL-04 is the **third consecutive attestation-only** Phase 10 check — no `rtval-04-uninstall-log.txt` and no `rtval-04-uninstall-status.png` were captured. Template-level ordering and `-ErrorAction SilentlyContinue` safety are fully automated via pytest; real-device Remove-Printer behavior under SYSTEM rests on verbal technician confirmation only. | +| 3 | **SCRPT-03** — Generate Intune detection script (exit 0 when printer present, exit 1 when absent, with Write-Output on success) | `pytest tests/test_script_generator.py::test_render_detect` + `::test_detect_endpoint` — asserts `Get-Printer` check + `Write-Output` + `exit 0` on found branch + `exit 1` on absent branch; integration test asserts endpoint returns 200 | `tests/test_script_generator.py::test_render_detect`, `::test_detect_endpoint`; `imptune/templates/scripts/detect.ps1.j2` lines 2-8; `imptune/generators/script_generator.py::render_detect` line 92 (commit 6bff8f3); `imptune/api/scripts.py` lines 82-94; 04-VERIFICATION.md truth 7 + truth 10; `.planning/phases/04-script-generation/04-RESEARCH.md` State-of-the-Art table (Get-Printer cmdlet chosen over HKLM registry path as more reliable); Phase 10 `RUNTIME-VALIDATION.md` RTVAL-03 (Intune detection script evaluated as installed) | pass | **Documented deviation from REQUIREMENTS.md wording.** REQUIREMENTS.md says "registry check" but 04-RESEARCH.md supersedes with `Get-Printer` cmdlet — explicitly documented as more reliable before implementation. The functional Intune contract (Write-Output + exit 0 when present, exit 1 when absent) is correctly satisfied. **SYSTEM-context runtime proof is attestation-only per STATE.md 2026-04-13** — RTVAL-03 is the second consecutive attestation-only Phase 10 check; no `rtval-03-detection.png` or `rtval-03-detect-manual.txt` was captured. Real Intune evaluator behavior confirmed verbally only. | +| 4 | **SCRPT-04** — Install script detects SYSTEM vs user context and self-elevates via UAC when run by user | `pytest tests/test_script_generator.py::test_render_install_uac_guard` — asserts `WindowsIdentity::GetCurrent()`, `IsSystem` check, `IsInRole(Administrator)` check, and `Start-Process -Verb Runas` all present in rendered install template | `tests/test_script_generator.py::test_render_install_uac_guard`; `imptune/templates/scripts/install.ps1.j2` lines 22-33 (SYSTEM identity check + admin role check + self-elevation branch); 04-VERIFICATION.md truth 3; Phase 10 `RUNTIME-VALIDATION.md` RTVAL-02 (install succeeded under Intune SYSTEM context on ARES-5CG5220YTM — UAC guard correctly skipped elevation) | pass | **SYSTEM-context runtime proof is attestation-only per STATE.md 2026-04-13.** The `IsSystem` branch (skip elevation when run by Intune Management Extension as SYSTEM) was exercised in the attestation-only RTVAL-02 run. The user-interactive self-elevation branch (Start-Process -Verb Runas triggering a real UAC dialog for a standard user) is flagged as a `Manual-Only Verification` above and **was not exercised in Phase 10** (RTVAL only covered the Intune SYSTEM path, not standalone standard-user execution). Template-level correctness (both branches present, identity check first) is automated via pytest. | +| 5 | **SCRPT-05** — Install script includes 64-bit WOW64 relaunch guard for Intune's 32-bit execution context | `pytest tests/test_script_generator.py::test_render_install_wow64_guard` — positional assertion: `PROCESSOR_ARCHITECTURE` + `PROCESSOR_ARCHITEW6432` + `SysNative` relaunch block appears **before** the pnputil block in rendered install template (guard must be first executable block) | `tests/test_script_generator.py::test_render_install_wow64_guard`; `imptune/templates/scripts/install.ps1.j2` lines 12-16 (WOW64 guard) preceding lines 40+ (pnputil); 04-VERIFICATION.md truth 2; Phase 10 `RUNTIME-VALIDATION.md` RTVAL-02 (install succeeded end-to-end under Intune on 64-bit Windows) | pass | **SYSTEM-context runtime proof is attestation-only per STATE.md 2026-04-13.** The WOW64 relaunch path (Intune's 32-bit PS host → SysNative 64-bit relaunch → continue execution) is **not directly observable** from RTVAL-02's attestation-only confirmation — the technician only attested the printer installed, not that the WOW64 branch was taken. This check remains a `Manual-Only Verification` pending a real 32-bit PowerShell host trace. Template-level positional correctness (guard before pnputil) is fully automated via pytest. Row recorded as `pass` because Phase 10 signed off end-to-end install; full WOW64 trace is a Phase 11 rollout concern. | + +**Audit outcome:** 5/5 rows `pass`. No `fail-fix-v1.1`, `deferred-v1.2`, or `wont-do` rows. Phase 4 is Nyquist-compliant *at the template level* — every SCRPT-0x success criterion has exactly one observable check with cited, committed evidence. **However**, SYSTEM-context runtime behavior (pnputil staging under SYSTEM, `$PSScriptRoot` resolution under SYSTEM, detect/uninstall under SYSTEM, WOW64 relaunch in real 32-bit Intune host) rests on attestation-only Phase 10 PASSes per STATE.md 2026-04-13. This audit records the weakened runtime audit trail faithfully in the Notes column rather than flipping rows to `fail-fix-v1.1` — Phase 10 signed off with explicit written acknowledgement of the attestation gap, and Phase 11 (Real-World Rollout) owns artifact re-capture before broad rollout. Zero gaps carry forward into 08-08 (rollup) beyond what STATE.md already tracks. + +--- + +## Validation Sign-Off + +- [ ] All tasks have `` verify or Wave 0 dependencies +- [ ] Sampling continuity: no 3 consecutive tasks without automated verify +- [ ] Wave 0 covers all MISSING references +- [ ] No watch-mode flags +- [ ] Feedback latency < 10s +- [x] `nyquist_compliant: true` set in frontmatter +- [x] Nyquist audit complete — 2026-04-13 — Sébastien QUEROL + +**Approval:** Nyquist-audited 2026-04-13 by Claude (gsd-executor, plan 08-04) — 5/5 pass (runtime rows attestation-only per STATE.md 2026-04-13, acknowledged in Phase 10 plan 10-03 sign-off); signed off 2026-04-13 by Sébastien QUEROL (index: v1.0-VALIDATION-INDEX.md) diff --git a/.planning/phases/04-script-generation/04-VERIFICATION.md b/.planning/phases/04-script-generation/04-VERIFICATION.md new file mode 100644 index 0000000..edc9d9c --- /dev/null +++ b/.planning/phases/04-script-generation/04-VERIFICATION.md @@ -0,0 +1,159 @@ +--- +phase: 04-script-generation +verified: 2026-04-10T12:00:00Z +status: passed +score: 12/12 must-haves verified +re_verification: false +--- + +# Phase 4: Script Generation Verification Report + +**Phase Goal:** The system produces correct, production-ready PowerShell scripts that handle all Intune and RMM execution contexts +**Verified:** 2026-04-10 +**Status:** passed +**Re-verification:** No — initial verification + +--- + +## Goal Achievement + +### Observable Truths + +| # | Truth | Status | Evidence | +|---|-------|--------|----------| +| 1 | render_install() produces a complete PowerShell script containing pnputil /add-driver, Add-PrinterPort, Add-PrinterDriver, Add-Printer, Set-PrintConfiguration | VERIFIED | install.ps1.j2 lines 40-67; test_render_install_contains_pnputil + test_render_install_print_config both pass | +| 2 | Generated install script contains WOW64 relaunch guard as the first executable block | VERIFIED | install.ps1.j2 lines 12-16; PROCESSOR_ARCHITECTURE check at line 12 precedes pnputil at line 40; test_render_install_wow64_guard passes with positional assertion | +| 3 | Generated install script contains SYSTEM vs user detection with UAC self-elevation | VERIFIED | install.ps1.j2 lines 22-33; IsSystem, IsInRole(Administrator), Start-Process -Verb Runas present; test_render_install_uac_guard passes | +| 4 | Set-PrintConfiguration receives translated duplex values (TwoSidedLongEdge, TwoSidedShortEdge) | VERIFIED | _duplex_map in script_generator.py lines 22-26; OneSided/LongEdge/ShortEdge all three variants tested; test_render_install_print_config passes | +| 5 | All add operations are wrapped in idempotency checks (Get-PrinterPort, Get-Printer) | VERIFIED | install.ps1.j2 lines 47-57; Get-PrinterPort check before Add-PrinterPort, Get-Printer check before Add-Printer; test_render_install_idempotency passes with positional assertions | +| 6 | render_uninstall() produces script with Remove-Printer, Remove-PrinterDriver, Remove-PrinterPort in correct order | VERIFIED | uninstall.ps1.j2 lines 2-4; Remove-Printer before Remove-PrinterDriver before Remove-PrinterPort, all with -ErrorAction SilentlyContinue; test_render_uninstall passes | +| 7 | render_detect() produces script that exits 0 with Write-Output when printer found, exits 1 when absent | VERIFIED | detect.ps1.j2 lines 2-8; Get-Printer check, Write-Output + exit 0 on found, exit 1 on absent; test_render_detect passes | +| 8 | GET /printers/{id}/scripts/install returns 200 with PowerShell content and attachment header | VERIFIED | scripts.py lines 38-59; PlainTextResponse with Content-Disposition attachment; test_install_endpoint passes | +| 9 | GET /printers/{id}/scripts/uninstall returns 200 with PowerShell content | VERIFIED | scripts.py lines 63-78; test_uninstall_endpoint passes | +| 10 | GET /printers/{id}/scripts/detect returns 200 with PowerShell content | VERIFIED | scripts.py lines 82-94; test_detect_endpoint passes | +| 11 | GET /printers/{id}/scripts/{type} returns 404 for nonexistent printer | VERIFIED | scripts.py _get_printer_and_driver() line 17; test_script_endpoint_missing_printer passes | +| 12 | GET /printers/{id}/scripts/{type} returns 422 when driver or inf_filename is missing | VERIFIED | scripts.py _get_printer_and_driver() lines 21-33; test_script_endpoint_no_driver passes | + +**Score:** 12/12 truths verified + +--- + +## Required Artifacts + +### Plan 04-01 + +| Artifact | Expected | Status | Details | +|----------|----------|--------|---------| +| `imptune/generators/script_generator.py` | Jinja2 Environment + render_install with duplex_map | VERIFIED | 106 lines; _env, _duplex_map, render_install all present; exports render_uninstall and render_detect too | +| `imptune/templates/scripts/install.ps1.j2` | PowerShell install template with WOW64, UAC, pnputil, idempotency | VERIFIED | 68 lines; all required blocks present in correct order | +| `tests/test_script_generator.py` | Unit tests for SCRPT-01, SCRPT-04, SCRPT-05 | VERIFIED | 198 lines; 14 tests (7 unit + 5 integration + 2 unit for uninstall/detect) | + +### Plan 04-02 + +| Artifact | Expected | Status | Details | +|----------|----------|--------|---------| +| `imptune/templates/scripts/uninstall.ps1.j2` | PowerShell uninstall template containing Remove-Printer | VERIFIED | 4 lines; Remove-Printer present | +| `imptune/templates/scripts/detect.ps1.j2` | PowerShell detection template containing Write-Output | VERIFIED | 8 lines; Write-Output present | +| `imptune/api/scripts.py` | Script download endpoints exporting router | VERIFIED | 95 lines; router exported, 3 endpoints + shared validation helper | +| `imptune/generators/script_generator.py` | render_uninstall and render_detect added | VERIFIED | render_uninstall (line 70) and render_detect (line 92) present | + +--- + +## Key Link Verification + +### Plan 04-01 + +| From | To | Via | Status | Details | +|------|----|-----|--------|---------| +| `imptune/generators/script_generator.py` | `imptune/templates/scripts/install.ps1.j2` | Jinja2 FileSystemLoader | WIRED | `_env.get_template("install.ps1.j2")` at line 56; FileSystemLoader points to templates/scripts/ | +| `imptune/generators/script_generator.py` | `imptune/db/models.py` | Printer model fields as template vars | WIRED | render_install takes printer_name, ip_address, port_name as plain string args mirroring model fields; scripts.py passes printer.name, printer.ip_address, printer.port_name | + +### Plan 04-02 + +| From | To | Via | Status | Details | +|------|----|-----|--------|---------| +| `imptune/api/scripts.py` | `imptune/generators/script_generator.py` | import render_install, render_uninstall, render_detect | WIRED | Line 8: `from imptune.generators.script_generator import render_detect, render_install, render_uninstall` | +| `imptune/api/scripts.py` | `imptune/db/models.py` | Printer.get_or_none query with Driver join | WIRED | `Printer.get_or_none(Printer.id == printer_id)` at line 15; `printer.driver` access at line 19 | +| `imptune/main.py` | `imptune/api/scripts.py` | app.include_router(scripts.router) | WIRED | Line 8: scripts in import; line 36: `app.include_router(scripts.router)` | + +--- + +## Requirements Coverage + +| Requirement | Source Plan | Description | Status | Evidence | +|-------------|------------|-------------|--------|----------| +| SCRPT-01 | 04-01 | Generate PowerShell install script (pnputil staging + Add-PrinterPort + Add-PrinterDriver + Add-Printer + Set-PrintConfiguration) | SATISFIED | install.ps1.j2 contains all 5 cmdlets; 4 unit tests cover pnputil, idempotency, duplex, booleans; integration test confirms endpoint returns 200 with pnputil in content | +| SCRPT-02 | 04-02 | Generate PowerShell uninstall script (Remove-Printer + Remove-PrinterDriver + Remove-PrinterPort) | SATISFIED | uninstall.ps1.j2 contains all 3 Remove-* cmdlets in safe order; test_render_uninstall asserts ordering and -ErrorAction SilentlyContinue on all three | +| SCRPT-03 | 04-02 | Generate Intune detection script (registry check for printer name) | SATISFIED — with documented deviation | REQUIREMENTS.md says "registry check" but implementation uses Get-Printer cmdlet. 04-RESEARCH.md State of the Art table explicitly documents this decision: "HKLM registry check → Get-Printer cmdlet check — Both work; Get-Printer is more reliable". Intune detection contract (Write-Output + exit 0/1) is correctly implemented. | +| SCRPT-04 | 04-01 | Install script detects SYSTEM vs user context and self-elevates via UAC when run by user | SATISFIED | install.ps1.j2 lines 22-33; WindowsIdentity::GetCurrent(), IsSystem, IsInRole(Administrator), Start-Process -Verb Runas; UAC guard skips elevation when running as SYSTEM | +| SCRPT-05 | 04-01 | Install script includes 64-bit WOW64 relaunch guard for Intune's 32-bit execution context | SATISFIED | install.ps1.j2 lines 12-16; PROCESSOR_ARCHITECTURE + PROCESSOR_ARCHITEW6432 check + SysNative relaunch; positional test confirms guard appears before pnputil | + +**Note on SCRPT-03:** The requirement description says "registry check" but the research document (04-RESEARCH.md) explicitly supersedes this with Get-Printer cmdlet approach, noting it is more reliable than the HKLM registry path approach. This is a planned deviation documented before implementation. The functional contract (Intune detection: Write-Output + exit 0 when present, exit 1 when absent) is correctly satisfied. + +--- + +## Anti-Patterns Found + +No anti-patterns found in any phase 04 files. + +Scanned: `imptune/generators/script_generator.py`, `imptune/api/scripts.py`, `imptune/templates/scripts/install.ps1.j2`, `imptune/templates/scripts/uninstall.ps1.j2`, `imptune/templates/scripts/detect.ps1.j2` + +No TODO/FIXME/PLACEHOLDER comments, no empty implementations, no stub returns, no console.log equivalents. + +--- + +## Commit Verification + +| Commit | Description | Status | +|--------|-------------|--------| +| b4f2c64 | test(04-01): RED phase — 7 failing tests | FOUND in git log | +| 8193e9d | feat(04-01): script_generator.py + install.ps1.j2 | FOUND in git log | +| 0f213df | test(04-02): failing tests for render_uninstall/detect | FOUND in git log | +| 6bff8f3 | feat(04-02): render_uninstall + render_detect + templates | FOUND in git log | +| b7b0d1b | feat(04-02): script API endpoints + router registration | FOUND in git log | + +--- + +## Test Suite Results + +``` +tests/test_script_generator.py — 14/14 passed +Full suite — 75/75 passed (no regressions) +``` + +--- + +## Human Verification Required + +### 1. WOW64 Relaunch — Live 32-bit Context + +**Test:** Launch `powershell.exe (x86)` on a Windows endpoint and run the generated install.ps1 +**Expected:** Script detects 32-bit process, relaunches under SysNative 64-bit PowerShell, driver staging succeeds +**Why human:** Requires a physical 32-bit PowerShell host; cannot emulate WOW64 in unit tests + +### 2. UAC Elevation Prompt — Standard User + +**Test:** Run install.ps1 as a non-admin standard user on a real Windows desktop +**Expected:** UAC elevation dialog appears; after approval, printer installs successfully +**Why human:** Requires interactive desktop session with a standard user account + +### 3. Intune Detection Contract — Real Intune Enrollment + +**Test:** Deploy a printer as an Intune Win32 app using the detect.ps1 as the detection script +**Expected:** Intune marks the app as "Installed" after seeing Write-Output + exit 0 +**Why human:** Requires Intune tenant, enrolled device, and deployed Win32 app — not automatable + +--- + +## Summary + +Phase 4 goal is fully achieved. All 12 observable truths are verified against actual code, not just SUMMARY claims. The implementation is substantive: templates are real PowerShell (not stubs), render functions use actual Jinja2 template rendering with duplex translation and boolean conversion, and all three API endpoints have complete ORM validation with proper 404/422 error paths. + +The three human verification items are real-world deployment concerns that cannot be automated (WOW64 live context, interactive UAC, Intune tenant). These are flagged in the validation strategy document and are expected at this phase. + +The SCRPT-03 "registry check" wording in REQUIREMENTS.md is a minor description inaccuracy — the implementation correctly uses Get-Printer per the research document's recommendation, which explicitly documents this as the preferred approach over the registry path. The functional Intune contract is satisfied. + +--- + +_Verified: 2026-04-10_ +_Verifier: Claude (gsd-verifier)_ diff --git a/.planning/phases/05-package-export/05-01-PLAN.md b/.planning/phases/05-package-export/05-01-PLAN.md new file mode 100644 index 0000000..d5cabbd --- /dev/null +++ b/.planning/phases/05-package-export/05-01-PLAN.md @@ -0,0 +1,193 @@ +--- +phase: 05-package-export +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - imptune/api/packages.py + - imptune/main.py + - tests/test_packages.py +autonomous: true +requirements: [PKG-01, PKG-02, PKG-03] + +must_haves: + truths: + - "GET /printers/{id}/packages/ninja returns a ZIP containing install.ps1 and drivers/ subfolder" + - "GET /printers/{id}/packages/intunewin returns a valid .intunewin file with correct Content-Disposition" + - "Both endpoints return 404 for missing printer, 422 for missing/invalid driver" + - "NinjaRMM ZIP uses DEFLATE compression and has printer-name-based folder structure" + - ".intunewin is built using Python-native build_intunewin() with no subprocess calls" + artifacts: + - path: "imptune/api/packages.py" + provides: "Package download endpoints for NinjaRMM ZIP and .intunewin" + exports: ["router"] + - path: "tests/test_packages.py" + provides: "Integration tests for both export endpoints" + contains: "TestNinjaDownload" + key_links: + - from: "imptune/api/packages.py" + to: "imptune/generators/script_generator.py" + via: "render_install, render_uninstall, render_detect" + pattern: "from imptune\\.generators\\.script_generator import" + - from: "imptune/api/packages.py" + to: "imptune/generators/intunewin_builder.py" + via: "build_intunewin(source_dir, setup_file, output_path)" + pattern: "from imptune\\.generators\\.intunewin_builder import build_intunewin" + - from: "imptune/main.py" + to: "imptune/api/packages.py" + via: "app.include_router(packages.router)" + pattern: "include_router.*packages" +--- + + +Create the two package export API endpoints: NinjaRMM ZIP download and .intunewin download. Both serve binary file responses for a given printer configuration. + +Purpose: PKG-01/PKG-02/PKG-03 -- Users can download deployment-ready packages in either format with one click. +Output: `imptune/api/packages.py` with two GET endpoints, registered in main.py, with integration tests. + + + +@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md +@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md + + + +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/05-package-export/05-RESEARCH.md + +@imptune/api/scripts.py +@imptune/generators/intunewin_builder.py +@imptune/generators/script_generator.py +@imptune/config.py +@imptune/db/models.py +@imptune/main.py +@tests/conftest.py + + + + +From imptune/api/scripts.py: +```python +def _get_printer_and_driver(printer_id: int): + """Returns (printer, driver, driver_name), None on success + or None, PlainTextResponse on error (404/422).""" +``` + +From imptune/generators/script_generator.py: +```python +def render_install(printer_name, ip_address, port_name, driver_name, + inf_filename, duplex_mode, color_mode, paper_size, collate) -> str: ... +def render_uninstall(printer_name, driver_name, port_name) -> str: ... +def render_detect(printer_name) -> str: ... +``` + +From imptune/generators/intunewin_builder.py: +```python +def build_intunewin(source_dir: str, setup_file: str, output_path: str) -> None: + """Build a .intunewin file from source_dir, with setup_file as entry point.""" +``` + +From imptune/config.py: +```python +DATA_DIR = os.environ.get("DATA_DIR", "/data") +DRIVERS_DIR = str(Path(DATA_DIR) / "drivers") +``` + +From imptune/db/models.py: +```python +class Driver(BaseModel): + sha256 = CharField(unique=True, index=True) + original_filename = CharField() + driver_desc = CharField(null=True) # JSON list of driver names + inf_filename = CharField(null=True) + ... + +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) + ... +``` + + + + + + + Task 1: Package export endpoints with TDD + imptune/api/packages.py, imptune/main.py, tests/test_packages.py + + - TestNinjaDownload::test_returns_zip: GET /printers/{id}/packages/ninja returns 200, media_type application/zip, Content-Disposition with filename + - TestNinjaDownload::test_zip_contains_install_script: Response ZIP contains {safe_name}/install.ps1 + - TestNinjaDownload::test_zip_contains_driver_files: Response ZIP contains {safe_name}/drivers/ with files from driver ZIP + - TestNinjaDownload::test_404_missing_printer: Returns 404 for nonexistent printer_id + - TestNinjaDownload::test_422_no_driver: Returns 422 for printer with no assigned driver + - TestIntunewinDownload::test_returns_intunewin: GET /printers/{id}/packages/intunewin returns 200, media_type application/octet-stream, Content-Disposition with .intunewin extension + - TestIntunewinDownload::test_intunewin_is_valid_zip: Response content is a valid outer ZIP with IntuneWinPackage/ structure + - TestIntunewinDownload::test_404_missing_printer: Returns 404 for nonexistent printer_id + - TestIntunewinDownload::test_422_no_driver: Returns 422 for printer with no assigned driver + + + 1. Create `tests/test_packages.py` with RED tests first. Test fixtures: create a Driver record with a real small ZIP file on disk (use tmp_data_dir from conftest), create a Printer record linked to it. Use the `client` fixture from conftest.py. + + 2. Create `imptune/api/packages.py` with `router = APIRouter(prefix="/printers")`: + + **NinjaRMM endpoint** `GET /{printer_id}/packages/ninja`: + - Reuse `_get_printer_and_driver()` pattern from scripts.py (copy the helper into packages.py or import — prefer copy since it's small and keeps the module self-contained) + - Call `render_install(...)` with all printer/driver params + - Build ZIP in-memory with `io.BytesIO` + `zipfile.ZipFile`: + - `{safe_name}/install.ps1` with rendered script + - `{safe_name}/drivers/{member}` for each file in the driver ZIP on disk + - `safe_name = printer.name.replace(" ", "_")` + - Return `Response(content=buf.getvalue(), media_type="application/zip", headers={"Content-Disposition": f'attachment; filename="{safe_name}_ninja.zip"'})` + + **Intunewin endpoint** `GET /{printer_id}/packages/intunewin`: + - Same printer/driver validation via `_get_printer_and_driver()` + - Use `tempfile.TemporaryDirectory(prefix="imptune_")` as context manager (auto-cleanup, per RESEARCH pitfall 1) + - Write `install.ps1`, `uninstall.ps1`, `detect.ps1` into tmpdir + - Extract driver ZIP contents into `tmpdir/drivers/` + - Call `build_intunewin(tmpdir, "install.ps1", os.path.join(tmpdir, "out.intunewin"))` + - Read output file bytes and return as `Response(content=..., media_type="application/octet-stream", headers={"Content-Disposition": ...})` + - Check driver file exists on disk before proceeding (per RESEARCH pitfall 3), return 422 if missing + + 3. Register router in `imptune/main.py`: + - Add `from imptune.api import packages` to imports + - Add `app.include_router(packages.router)` after scripts router + + 4. Run tests GREEN. + + + pytest tests/test_packages.py -x + + + - NinjaRMM ZIP endpoint returns valid ZIP with install.ps1 and driver files inside a named subfolder + - .intunewin endpoint returns valid .intunewin (outer ZIP with IntuneWinPackage/ structure) + - Both endpoints handle 404/422 for missing printer or driver + - Router registered in main.py + - All tests pass, full suite still green (pytest tests/ -x) + + + + + + +pytest tests/test_packages.py -x && pytest tests/ -x + + + +- GET /printers/{id}/packages/ninja returns downloadable ZIP with install.ps1 + driver files +- GET /printers/{id}/packages/intunewin returns downloadable .intunewin package +- Both endpoints return proper error codes for invalid requests +- Full test suite green + + + +After completion, create `.planning/phases/05-package-export/05-01-SUMMARY.md` + diff --git a/.planning/phases/05-package-export/05-01-SUMMARY.md b/.planning/phases/05-package-export/05-01-SUMMARY.md new file mode 100644 index 0000000..140ff20 --- /dev/null +++ b/.planning/phases/05-package-export/05-01-SUMMARY.md @@ -0,0 +1,96 @@ +--- +phase: 05-package-export +plan: "01" +subsystem: api/packages +tags: [fastapi, zip, intunewin, package-export, tdd] +dependency_graph: + requires: + - imptune/generators/script_generator.py (render_install, render_uninstall, render_detect) + - imptune/generators/intunewin_builder.py (build_intunewin) + - imptune/api/scripts.py (_get_printer_and_driver pattern) + - imptune/db/models.py (Printer, Driver ORM) + - imptune/config.py (DRIVERS_DIR) + provides: + - GET /printers/{id}/packages/ninja (NinjaRMM ZIP download) + - GET /printers/{id}/packages/intunewin (.intunewin download) + affects: + - imptune/main.py (router registration) +tech_stack: + added: [] + patterns: + - In-memory ZIP assembly with io.BytesIO + zipfile.ZipFile + - TemporaryDirectory context manager for auto-cleanup of intunewin build artifacts + - Driver ZIP existence validation before processing +key_files: + created: + - imptune/api/packages.py + - tests/test_packages.py + modified: + - imptune/main.py +decisions: + - _get_printer_and_driver() copied (not imported) from scripts.py for module self-containment + - NinjaRMM ZIP uses DEFLATE compression with {printer_name}/install.ps1 + {printer_name}/drivers/* structure + - intunewin endpoint uses TemporaryDirectory for auto-cleanup of tmp build files (no manual cleanup needed) + - Driver ZIP file existence validated on disk before building package (422 if missing) +metrics: + duration: "~2 min" + completed_date: "2026-04-10" + tasks_completed: 1 + files_modified: 3 +requirements-completed: [PKG-01, PKG-02, PKG-03] +--- + +# Phase 5 Plan 1: Package Export Endpoints Summary + +**One-liner:** NinjaRMM ZIP and .intunewin package export endpoints using in-memory ZIP assembly and Python-native intunewin build. + +## What Was Built + +Two GET endpoints on `imptune/api/packages.py`: + +1. **`GET /printers/{id}/packages/ninja`** — Returns a ZIP file (application/zip) with: + - `{safe_name}/install.ps1` — rendered PowerShell install script + - `{safe_name}/drivers/*` — all driver files extracted from the driver ZIP on disk + - Built entirely in-memory with `io.BytesIO` + `zipfile.ZipFile(ZIP_DEFLATED)` + +2. **`GET /printers/{id}/packages/intunewin`** — Returns a `.intunewin` file (application/octet-stream) with: + - Writes install.ps1, uninstall.ps1, detect.ps1 into a `TemporaryDirectory` + - Extracts driver ZIP into `tmpdir/drivers/` + - Calls `build_intunewin(tmpdir, "install.ps1", output_path)` — no subprocess calls + - Reads bytes and returns as binary Response + +Both endpoints share `_get_printer_and_driver()` helper (404 for missing printer, 422 for no/invalid driver) and validate the driver ZIP file exists on disk (422 if missing). + +Router registered in `imptune/main.py` after `scripts.router`. + +## Tests + +9 new tests in `tests/test_packages.py`: + +- `TestNinjaDownload`: 5 tests (zip response, install.ps1 in zip, driver files in zip, 404, 422) +- `TestIntunewinDownload`: 4 tests (intunewin response, valid outer ZIP structure, 404, 422) + +Full suite result: 84 passed (excluding pre-existing icon upload failures in test_icon_upload.py which existed before this plan). + +## Deviations from Plan + +None — plan executed exactly as written. + +## Pre-existing Issues (Out of Scope) + +`tests/test_icon_upload.py` has 5 failing tests (`/printers/{id}/icon` returns 404). These failures existed before this plan was executed and are unrelated to package export. Logged for future attention. + +## Commits + +| Hash | Type | Description | +| ------- | ------ | ------------------------------------------------------------- | +| a31c71e | test | add failing tests for NinjaRMM ZIP and intunewin endpoints | +| dd6cedf | feat | implement NinjaRMM ZIP and intunewin package export endpoints | + +## Self-Check: PASSED + +- FOUND: imptune/api/packages.py +- FOUND: tests/test_packages.py +- FOUND: imptune/main.py (modified) +- FOUND commit a31c71e (RED tests) +- FOUND commit dd6cedf (GREEN implementation) diff --git a/.planning/phases/05-package-export/05-02-PLAN.md b/.planning/phases/05-package-export/05-02-PLAN.md new file mode 100644 index 0000000..664f401 --- /dev/null +++ b/.planning/phases/05-package-export/05-02-PLAN.md @@ -0,0 +1,292 @@ +--- +phase: 05-package-export +plan: 02 +type: execute +wave: 1 +depends_on: [] +files_modified: + - imptune/api/icons.py + - imptune/config.py + - imptune/main.py + - imptune/api/pages.py + - imptune/templates/printer_detail.html + - requirements.txt + - tests/test_icon_upload.py +autonomous: true +requirements: [PKG-04, PKG-05] + +must_haves: + truths: + - "User can upload a PNG icon for a printer and it is stored on disk" + - "Icon upload rejects non-PNG files, files over 750KB, and wrong dimensions (not 256x256)" + - "Re-uploading an icon for the same printer replaces the previous one" + - "Printer detail page shows Intune install and uninstall command strings" + - "User can copy the command strings (text displayed prominently for copy)" + - "Printer detail page has download links for NinjaRMM ZIP and .intunewin" + artifacts: + - path: "imptune/api/icons.py" + provides: "Icon upload endpoint" + exports: ["router"] + - path: "imptune/templates/printer_detail.html" + provides: "Export buttons, command preview, icon upload form" + contains: "install-cmd" + - path: "tests/test_icon_upload.py" + provides: "Integration tests for icon upload validation" + contains: "test_upload_valid_png" + key_links: + - from: "imptune/api/icons.py" + to: "imptune/db/models.py" + via: "Icon model CRUD" + pattern: "from imptune\\.db\\.models import.*Icon" + - from: "imptune/api/icons.py" + to: "imptune/config.py" + via: "cfg.DATA_DIR for icon storage path" + pattern: "import imptune\\.config as cfg" + - from: "imptune/main.py" + to: "imptune/api/icons.py" + via: "app.include_router(icons.router)" + pattern: "include_router.*icons" + - from: "imptune/templates/printer_detail.html" + to: "/printers/{id}/packages/*" + via: "href download links" + pattern: "packages/ninja|packages/intunewin" +--- + + +Add icon upload for Intune packages and update the printer detail page with export download buttons and Intune command preview strings. + +Purpose: PKG-04 (custom icon upload) and PKG-05 (command preview) -- completing the export UI that makes deployment packages accessible to technicians. +Output: `imptune/api/icons.py` with upload endpoint, updated `printer_detail.html` with export section, command preview, and icon upload form. + + + +@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md +@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md + + + +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/05-package-export/05-RESEARCH.md + +@imptune/api/drivers.py +@imptune/api/pages.py +@imptune/config.py +@imptune/db/models.py +@imptune/main.py +@imptune/templates/printer_detail.html +@imptune/templates/base.html +@tests/conftest.py +@requirements.txt + + + + +From imptune/db/models.py: +```python +class Icon(BaseModel): + printer = ForeignKeyField(Printer, unique=True, backref="icons") + sha256 = CharField() + original_filename = CharField() + size_bytes = IntegerField() + uploaded_at = DateTimeField(default=datetime.utcnow) + + class Meta: + table_name = "icon" +``` + +From imptune/api/drivers.py (UploadFile pattern): +```python +from fastapi import UploadFile +# file: UploadFile parameter, file.file.read() for bytes, file.filename for name +``` + +From imptune/config.py: +```python +DATA_DIR = os.environ.get("DATA_DIR", "/data") +DRIVERS_DIR = str(Path(DATA_DIR) / "drivers") +# Add ICONS_DIR = str(Path(DATA_DIR) / "icons") following same pattern +``` + +From imptune/templates/printer_detail.html (current state): +```html +{% extends "base.html" %} +{% block content %} + + +{% endblock %} +``` + +From imptune/api/pages.py printer_detail(): +```python +@router.get("/printers/{printer_id}", response_class=HTMLResponse) +def printer_detail(request: Request, printer_id: int): + # Returns context: {"printer": printer, "driver_names": driver_names} + # Need to add install_cmd, uninstall_cmd, has_icon to context +``` + + + + + + + Task 1: Icon upload endpoint with validation + imptune/api/icons.py, imptune/config.py, imptune/main.py, requirements.txt, tests/test_icon_upload.py + + - test_upload_valid_png: POST /printers/{id}/icon with valid 256x256 PNG returns 200, Icon record created in DB, file stored on disk + - test_reject_non_png: POST with a JPEG file returns 422 with "PNG format" error message + - test_reject_oversized: POST with PNG > 750KB returns 422 with "750 KB" error message + - test_reject_wrong_dimensions: POST with 128x128 PNG returns 422 with "256x256" error message + - test_replace_existing_icon: Second upload for same printer replaces the Icon record (unique FK constraint) + - test_404_missing_printer: POST to nonexistent printer_id returns 404 + + + 1. Add `Pillow>=10.0` to `requirements.txt` (per RESEARCH recommendation -- needed for dimension validation). + + 2. Add `ICONS_DIR` to `imptune/config.py`: + ```python + ICONS_DIR = str(Path(DATA_DIR) / "icons") + ``` + + 3. Create `tests/test_icon_upload.py` with RED tests. Generate a valid 256x256 PNG in the fixture using Pillow (`Image.new("RGBA", (256, 256), color="red")` saved to BytesIO). Use `client` fixture from conftest.py. Create Printer record in fixture. + + 4. Create `imptune/api/icons.py` with `router = APIRouter(prefix="/printers")`: + + **POST /{printer_id}/icon** (accepts `file: UploadFile`): + - Validate printer exists (Printer.get_or_none), return 404 if not + - Read file bytes: `data = file.file.read(MAX_ICON_BYTES + 1)` where `MAX_ICON_BYTES = 750 * 1024` + - If `len(data) > MAX_ICON_BYTES`, return 422 "Icon exceeds 750 KB limit" + - Validate with Pillow: `img = Image.open(io.BytesIO(data))` + - If `img.format != "PNG"`, return 422 "Icon must be PNG format" + - If `img.size != (256, 256)`, return 422 "Icon must be 256x256 pixels, got {img.size}" + - Store SHA256-addressed: `sha256 = hashlib.sha256(data).hexdigest()`, write to `Path(cfg.DATA_DIR) / "icons" / sha256` (read cfg.DATA_DIR at call time, not import time -- monkeypatch pattern) + - Create icons dir if not exists: `Path(cfg.DATA_DIR, "icons").mkdir(parents=True, exist_ok=True)` + - Delete existing Icon for this printer if any: `Icon.delete().where(Icon.printer == printer_id).execute()` + - Create new Icon record: `Icon.create(printer=printer_id, sha256=sha256, original_filename=file.filename, size_bytes=len(data))` + - Return `HTMLResponse("

    Icon uploaded successfully

    ")` (HTMX-friendly) + + 5. Register router in `imptune/main.py`: + - Add `from imptune.api import icons` to imports + - Add `app.include_router(icons.router)` after packages router + + 6. Also create `ICONS_DIR` in lifespan startup (same as DRIVERS_DIR pattern): + - Add `os.makedirs(cfg.ICONS_DIR, exist_ok=True)` in lifespan -- but use dynamic `cfg.ICONS_DIR` to avoid import-time evaluation. Actually, follow the existing pattern: import ICONS_DIR from config at top of main.py and makedirs in lifespan. But note: the test monkepatches cfg module, so use `import imptune.config as cfg` in lifespan OR just use the string directly. The simplest correct pattern: add `from imptune.config import ICONS_DIR` alongside the existing imports and `os.makedirs(ICONS_DIR, exist_ok=True)` in lifespan. This works because the lifespan runs AFTER monkeypatch has been applied in tests (TestClient context manager triggers lifespan). + + Wait -- looking at the existing code more carefully: main.py imports `DATA_DIR, DRIVERS_DIR` at top level and uses them directly in lifespan. This works for tests because conftest patches `cfg.DRIVERS_DIR` before TestClient enters context. But the top-level import captures the original value. Let me check... Actually the conftest patches the cfg module attributes, but main.py imported the values at module load time. The lifespan still uses the stale import-time values. This is fine because the tests use `tmp_data_dir` which patches cfg, and the test client triggers lifespan which uses the already-imported constants -- wait, this is a potential issue. + + Actually: looking at the conftest, it patches `cfg.DATA_DIR`, `cfg.DB_PATH`, `cfg.DRIVERS_DIR` on the module. The main.py does `from imptune.config import DATA_DIR, DRIVERS_DIR` which binds to the original values. BUT init_db() calls `db.init(cfg.DB_PATH)` dynamically, and the lifespan makedirs uses the imported constant. Since tests have their own tmp_data_dir and the test client is created AFTER monkeypatch, the lifespan runs with stale DATA_DIR/DRIVERS_DIR values. But this seems to work because the test fixtures create those dirs themselves via `data_dir.mkdir()`. + + Simplest approach: Add ICONS_DIR to the import in main.py alongside the others. The conftest already creates `data_dir` and the tests will create `data_dir / "icons"` as needed. In the icons.py endpoint, use `import imptune.config as cfg` and read `cfg.DATA_DIR` at call time (consistent with RESEARCH anti-pattern guidance). + + 7. Run tests GREEN. +
    + + pytest tests/test_icon_upload.py -x + + + - Pillow added to requirements.txt + - ICONS_DIR added to config.py + - Icon upload validates format (PNG), size (<=750KB), dimensions (256x256) + - Icon stored SHA256-addressed on disk, Icon ORM record created + - Re-upload replaces previous icon + - All tests pass + +
    + + + Task 2: Printer detail page with export buttons and command preview + imptune/api/pages.py, imptune/templates/printer_detail.html, tests/test_packages.py + + 1. Update `imptune/api/pages.py` `printer_detail()` to add command strings and icon status to template context: + ```python + install_cmd = "powershell.exe -ExecutionPolicy Bypass -File install.ps1" + uninstall_cmd = "powershell.exe -ExecutionPolicy Bypass -File uninstall.ps1" + has_driver = printer.driver_id is not None and bool(driver_names) + # Check if icon exists + from imptune.db.models import Icon + icon = Icon.get_or_none(Icon.printer == printer_id) + ``` + Pass `install_cmd`, `uninstall_cmd`, `has_driver`, `has_icon=(icon is not None)` to template context. + + 2. Rewrite `imptune/templates/printer_detail.html` to add three new sections after the existing Driver section: + + **Command Preview section** (PKG-05): + ```html +

    Intune Commands

    + ``` + Show install_cmd and uninstall_cmd in `` blocks. Add Alpine.js copy button for each: + ```html +
    + + {{ install_cmd }} + +
    + ``` + Repeat for uninstall command with id="uninstall-cmd". Only show this section if `has_driver` is true. + + **Export Downloads section**: + Show download buttons only when `has_driver` is true: + ```html +

    Export

    + Download NinjaRMM ZIP + Download .intunewin + ``` + + **Icon Upload section** (PKG-04): + ```html +

    Icon

    + {% if has_icon %} +

    Icon uploaded

    + {% endif %} + + + + +
    + ``` + + Replace the old disabled "Regenerate Package" button with the real export buttons. + + 3. Add a test in `tests/test_packages.py` class `TestCommandPreview`: + - test_detail_page_shows_commands: GET /printers/{id} returns HTML containing "install-cmd" and "uninstall-cmd" ids and the command strings + - test_detail_page_shows_export_links: GET /printers/{id} returns HTML containing "/packages/ninja" and "/packages/intunewin" hrefs + + These are simple integration tests using the `client` fixture -- create a Printer+Driver, GET the detail page, assert the command text and download links appear in the response HTML. + + 4. Run all tests green. +
    + + pytest tests/test_packages.py -x && pytest tests/ -x + + + - Printer detail page shows install and uninstall command strings with copy buttons + - Printer detail page has NinjaRMM ZIP and .intunewin download links (visible when driver assigned) + - Printer detail page has icon upload form with HTMX submission + - Disabled placeholder button removed, replaced with real export actions + - All tests pass including full suite + +
    + +
    + + +pytest tests/test_icon_upload.py tests/test_packages.py -x && pytest tests/ -x + + + +- Icon upload validates PNG format, 256x256 dimensions, and 750KB size limit +- Icon stored on disk and tracked in Icon ORM model +- Printer detail page shows Intune command strings with copy-to-clipboard +- Printer detail page has working download links for both package formats +- Full test suite green + + + +After completion, create `.planning/phases/05-package-export/05-02-SUMMARY.md` + diff --git a/.planning/phases/05-package-export/05-02-SUMMARY.md b/.planning/phases/05-package-export/05-02-SUMMARY.md new file mode 100644 index 0000000..c3c8ff0 --- /dev/null +++ b/.planning/phases/05-package-export/05-02-SUMMARY.md @@ -0,0 +1,125 @@ +--- +phase: 05-package-export +plan: 02 +subsystem: ui +tags: [fastapi, pillow, htmx, alpine.js, icon-upload, png-validation, printer-detail] + +# Dependency graph +requires: + - phase: 05-01 + provides: NinjaRMM ZIP and .intunewin package export endpoints + - phase: 04-02 + provides: script generation endpoints (install/uninstall/detect) + - phase: 03-02 + provides: printer detail page foundation in pages.py +provides: + - Icon upload endpoint with PNG format/dimension/size validation + - SHA256-addressed icon storage under DATA_DIR/icons/ + - Icon ORM record tracking (one per printer, replace-on-upload) + - Printer detail page with Intune Commands section (install/uninstall command strings with copy buttons) + - Printer detail page with Export section (NinjaRMM ZIP and .intunewin download links) + - Printer detail page with Icon Upload form (HTMX submission) +affects: [deployment, ui, export] + +# Tech tracking +tech-stack: + added: [Pillow>=10.0 (PNG dimension/format validation)] + patterns: + - "Read cfg.DATA_DIR at call time (not import time) for monkeypatch compatibility" + - "SHA256-addressed icon storage — dedup automatic, filename = sha256 hash" + - "Icon replace pattern: delete existing record then create new (unique FK)" + - "Alpine.js copy-to-clipboard with copied state and 2-second timeout" + - "HTMX icon upload form with #icon-status swap target" + +key-files: + created: + - imptune/api/icons.py + - tests/test_icon_upload.py + modified: + - imptune/config.py + - imptune/main.py + - imptune/api/pages.py + - imptune/templates/printer_detail.html + - tests/conftest.py + - tests/test_packages.py + - requirements.txt + +key-decisions: + - "Pillow used for PNG validation — provides format, dimension, and byte-read in one library" + - "Icons stored SHA256-addressed (not by printer ID) — enables dedup if same PNG used for multiple printers" + - "Read cfg.DATA_DIR dynamically in icons.py endpoint, not at import time — consistent with monkeypatch pattern established in Phase 02" + - "Icon replace via delete-then-create rather than get_or_create — unique FK makes upsert awkward, simpler to delete first" + - "Export and command sections conditionally shown only when has_driver is true — avoids confusing 422 before driver is assigned" + +patterns-established: + - "Icon upload: read MAX+1 bytes, check len > MAX for oversized detection" + - "Printer detail page sections gated on has_driver boolean from view context" + +requirements-completed: [PKG-04, PKG-05] + +# Metrics +duration: 15min +completed: 2026-04-10 +--- + +# Phase 05 Plan 02: Icon Upload and Printer Detail Export UI Summary + +**PNG icon upload endpoint with 750KB/256x256/format validation, SHA256 storage, and printer detail page with Intune command preview and download links** + +## Performance + +- **Duration:** ~15 min +- **Started:** 2026-04-10T12:00:00Z +- **Completed:** 2026-04-10T12:15:00Z +- **Tasks:** 2 +- **Files modified:** 9 + +## Accomplishments +- Icon upload endpoint (POST /printers/{id}/icon) with full validation: PNG format, 256x256 dimensions, 750KB max, 404 on missing printer +- Re-upload replaces previous Icon ORM record (unique FK constraint handled via delete-then-create) +- Printer detail page rewritten with three new sections: Intune Commands (install/uninstall with Alpine.js copy-to-clipboard), Export (NinjaRMM ZIP and .intunewin download links), Icon (HTMX upload form) +- Full test suite green: 94 tests pass + +## Task Commits + +Each task was committed atomically: + +1. **Task 1 RED: Icon upload tests** - `d8ce223` (test) +2. **Task 1 GREEN: Icon upload implementation** - `f9e13ba` (feat) +3. **Task 2: Printer detail page with export UI** - `f96ea6f` (feat) + +## Files Created/Modified +- `imptune/api/icons.py` - Icon upload endpoint with PNG format/dimension/size validation +- `imptune/config.py` - Added ICONS_DIR constant +- `imptune/main.py` - Registered icons.router, added ICONS_DIR makedirs in lifespan +- `imptune/api/pages.py` - Updated printer_detail() with install_cmd, uninstall_cmd, has_driver, has_icon context +- `imptune/templates/printer_detail.html` - Added Intune Commands, Export, Icon Upload sections; removed placeholder button +- `tests/test_icon_upload.py` - 6 TDD integration tests for icon upload validation +- `tests/test_packages.py` - Added TestCommandPreview class (4 tests) +- `tests/conftest.py` - Patched cfg.ICONS_DIR in tmp_data_dir fixture +- `requirements.txt` - Added Pillow>=10.0 + +## Decisions Made +- Used Pillow for PNG validation — single library handles format detection, dimension check, and byte reading in one pass +- Icons stored SHA256-addressed under DATA_DIR/icons/ — consistent with DRIVERS_DIR content-addressing pattern from Phase 02 +- cfg.DATA_DIR read at call time in icons.py endpoint — consistent with monkeypatch pattern established in Phase 02 for DRIVERS_DIR +- Export and command sections conditionally shown only when has_driver is true — prevents confusing broken download links before driver is assigned + +## Deviations from Plan + +None - plan executed exactly as written. + +## Issues Encountered +- Pillow was not yet installed in the environment (requirements.txt addition needed `python -m pip install` before tests could run). Resolved automatically. + +## User Setup Required +None - no external service configuration required. + +## Next Phase Readiness +- Icon upload and command preview complete — export UI is fully functional +- Phase 05 is the final phase; all requirements PKG-01 through PKG-05 are now implemented +- Remaining validation: byte-level .intunewin format compliance against real Intune tenant (noted as MEDIUM confidence concern) + +--- +*Phase: 05-package-export* +*Completed: 2026-04-10* diff --git a/.planning/phases/05-package-export/05-RESEARCH.md b/.planning/phases/05-package-export/05-RESEARCH.md new file mode 100644 index 0000000..873dd9e --- /dev/null +++ b/.planning/phases/05-package-export/05-RESEARCH.md @@ -0,0 +1,478 @@ +# Phase 5: Package Export - Research + +**Researched:** 2026-04-10 +**Domain:** .intunewin file assembly, NinjaRMM ZIP packaging, icon upload, FastAPI StreamingResponse / Response binary downloads, HTMX copy-to-clipboard +**Confidence:** HIGH (NinjaRMM ZIP, FastAPI binary responses, icon validation), MEDIUM (.intunewin Intune acceptance — format is implemented but tenant-level acceptance unverified) + +--- + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|-----------------| +| PKG-01 | User can export a complete .intunewin package (script + drivers + detection + metadata) | `build_intunewin()` in `imptune/generators/intunewin_builder.py` is complete; Phase 5 wires it to a printer config + driver files + rendered scripts | +| PKG-02 | .intunewin is generated natively in Python (no IntuneWinAppUtil.exe dependency) | Already implemented in Phase 1 spike using pycryptodome AES-256-CBC; no new libraries needed | +| PKG-03 | User can export a NinjaRMM ZIP package (install script + driver folder) | Standard `zipfile` + `io.BytesIO` in-memory build, served via FastAPI `Response` with `application/zip` | +| PKG-04 | User can upload a custom PNG icon for Intune app display (256x256, max 750KB) | `UploadFile` pattern from drivers.py; `Icon` ORM model already exists; validation with `imghdr` (stdlib) or `Pillow`; icon stored on DATA_DIR volume | +| PKG-05 | User can preview and copy Intune install/uninstall command strings before export | Alpine.js `navigator.clipboard.writeText()` + `$el.innerText` pattern; rendered server-side in Jinja2 template; no new library needed | + + +--- + +## Summary + +Phase 5 closes out v1 by wiring the already-proven `build_intunewin()` function and the Phase 4 script renderers into two download endpoints (`.intunewin` and NinjaRMM ZIP) plus an icon upload endpoint and a command-preview UI. + +All cryptographic and ZIP assembly code is complete and tested from Phase 1. The new work is: (1) assembling the right files into a temp directory and calling `build_intunewin()`, (2) building a NinjaRMM ZIP in memory via `io.BytesIO`, (3) accepting a PNG upload and storing it to `DATA_DIR/icons/`, and (4) adding an Alpine.js clipboard copy widget to the printer detail page. No new Python packages are required; `zipfile`, `io`, `tempfile`, and `shutil` are all stdlib. + +The one genuine risk remains .intunewin Intune tenant acceptance — the byte-level format has been reverse-engineered from svrooij.io documentation and validated in unit tests, but a real upload has not been attempted. This is called out as a manual gate before Phase 5 is declared done. + +**Primary recommendation:** Build three new API router files (`packages.py` for exports, `icons.py` for upload), add `ICONS_DIR` to config, update the printer detail page with export buttons and command preview, keep all ZIP assembly in-memory (no temp files on disk). + +--- + +## Standard Stack + +### Core +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| `zipfile` | stdlib | Build NinjaRMM ZIP and inner .intunewin ZIP in memory | Already used throughout codebase | +| `io.BytesIO` | stdlib | In-memory byte stream for zip assembly without disk I/O | Already used in `intunewin_builder.py` and `drivers.py` | +| `tempfile` | stdlib | Temporary directory for .intunewin source staging | `build_intunewin()` requires a `source_dir` path | +| `shutil` | stdlib | Copy driver ZIP contents into temp staging dir | Clean recursive copy | +| `pycryptodome` | 3.20.* | AES-256-CBC encryption for .intunewin (already installed) | Phase 1 dependency; no change | +| `FastAPI Response` | 0.115.* | Serve binary file downloads with `application/zip` | `Response(content=bytes, media_type=...)` is simplest for in-memory content | + +### Supporting +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| `FastAPI StreamingResponse` | 0.115.* | Alternative for large file streaming | Prefer `Response` for in-memory builds under ~50 MB; use `StreamingResponse` only if driver packages are so large that holding in RAM is a concern | +| `Pillow` (PIL) | 10.x | PNG validation (dimensions + format) | Only if stdlib `imghdr` is insufficient for size/dimension check; adds a dependency | +| `imghdr` | stdlib (deprecated 3.13) | Basic PNG format detection | Acceptable for Python 3.12; but deprecated — prefer Pillow for dimension validation | + +### Alternatives Considered +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| In-memory `io.BytesIO` ZIP | Write to `tmp_path` on disk, then stream | Disk I/O slower and requires cleanup; in-memory is simpler for packages under ~100 MB | +| `FastAPI Response` | `StreamingResponse` with generator | StreamingResponse is more complex; Response is sufficient for in-memory byte content | +| Pillow for icon validation | `imghdr` + manual struct parse | Pillow gives dimensions easily; `imghdr` only identifies format, not size — Pillow preferred | + +**Installation (if Pillow added):** +```bash +pip install Pillow +``` +> Note: Pillow is not in current `requirements.txt`. Only add it if dimension validation is required by PKG-04. The requirement states "256x256, max 750KB" — dimension check requires Pillow or struct-parsing PNG IHDR chunk manually. + +--- + +## Architecture Patterns + +### Recommended Project Structure additions +``` +imptune/ +├── api/ +│ ├── packages.py # GET /{printer_id}/packages/intunewin, GET /{printer_id}/packages/ninja +│ └── icons.py # POST /{printer_id}/icon, GET /{printer_id}/icon +├── generators/ +│ └── intunewin_builder.py # Already exists — no changes needed +├── storage/ +│ └── icon_store.py # Analogous to driver_store.py (SHA256 content-addressed) +└── templates/ + └── printer_detail.html # Add export buttons, command preview, icon upload form +``` + +### Pattern 1: In-memory NinjaRMM ZIP +**What:** Build the ZIP entirely in `io.BytesIO`, return as `Response` with `Content-Disposition: attachment` +**When to use:** PKG-03 — NinjaRMM export +**Example:** +```python +# Source: stdlib zipfile + FastAPI Response (project pattern from drivers.py) +import io +import json +import zipfile +from fastapi import APIRouter +from fastapi.responses import Response +from imptune.db.models import Printer +from imptune.generators.script_generator import render_install +import imptune.config as cfg + +@router.get("/{printer_id}/packages/ninja") +def download_ninja_package(printer_id: int): + printer = Printer.get_or_none(Printer.id == printer_id) + if printer is None: + return Response("Printer not found", status_code=404, media_type="text/plain") + + driver = printer.driver + driver_names = json.loads(driver.driver_desc) + script = render_install( + printer_name=printer.name, + ip_address=printer.ip_address, + port_name=printer.port_name, + driver_name=driver_names[0], + inf_filename=driver.inf_filename, + duplex_mode=printer.duplex_mode, + color_mode=printer.color_mode, + paper_size=printer.paper_size, + collate=printer.collate, + ) + + buf = io.BytesIO() + driver_zip_path = cfg.DRIVERS_DIR + "/" + driver.sha256 # raw driver ZIP bytes + driver_bytes = open(driver_zip_path, "rb").read() + + with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATE) as zf: + safe_name = printer.name.replace(" ", "_") + zf.writestr(f"{safe_name}/install.ps1", script) + # Expand driver ZIP into drivers/ subfolder + with zipfile.ZipFile(io.BytesIO(driver_bytes)) as driver_zf: + for name in driver_zf.namelist(): + zf.writestr(f"{safe_name}/drivers/{name}", driver_zf.read(name)) + + filename = f"{safe_name}_ninja.zip" + return Response( + content=buf.getvalue(), + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) +``` + +### Pattern 2: .intunewin Package via Temp Directory +**What:** Stage files to `tempfile.mkdtemp()`, call `build_intunewin()`, read output file, clean up +**When to use:** PKG-01/PKG-02 — Intune export +**Example:** +```python +# Source: imptune/generators/intunewin_builder.py (Phase 1 spike) +import io +import json +import os +import shutil +import tempfile +import zipfile +from fastapi.responses import Response +from imptune.generators.intunewin_builder import build_intunewin +from imptune.generators.script_generator import render_install, render_uninstall, render_detect + +@router.get("/{printer_id}/packages/intunewin") +def download_intunewin(printer_id: int): + # ... fetch printer + driver, validate ... + + tmpdir = tempfile.mkdtemp() + try: + # 1. Write rendered scripts + open(os.path.join(tmpdir, "install.ps1"), "w").write(render_install(...)) + open(os.path.join(tmpdir, "uninstall.ps1"), "w").write(render_uninstall(...)) + open(os.path.join(tmpdir, "detect.ps1"), "w").write(render_detect(...)) + + # 2. Expand driver ZIP into drivers/ subfolder + drivers_subdir = os.path.join(tmpdir, "drivers") + os.makedirs(drivers_subdir) + driver_zip_path = os.path.join(cfg.DRIVERS_DIR, driver.sha256) + with zipfile.ZipFile(driver_zip_path) as zf: + zf.extractall(drivers_subdir) + + # 3. Optionally copy icon + icon = getattr(printer, "icons", None) + # ... copy icon if it exists ... + + # 4. Build .intunewin + output_path = os.path.join(tmpdir, "package.intunewin") + build_intunewin(tmpdir, "install.ps1", output_path) + + # 5. Read and return + content = open(output_path, "rb").read() + safe_name = printer.name.replace(" ", "_") + return Response( + content=content, + media_type="application/octet-stream", + headers={"Content-Disposition": f'attachment; filename="{safe_name}.intunewin"'}, + ) + finally: + shutil.rmtree(tmpdir, ignore_errors=True) +``` + +### Pattern 3: PNG Icon Upload and Validation +**What:** Accept PNG via `UploadFile`, validate format + dimensions + size, store SHA256-addressed on disk +**When to use:** PKG-04 — icon upload +**Example:** +```python +# Source: imptune/api/drivers.py upload pattern +from fastapi import UploadFile +from PIL import Image # if Pillow added + +MAX_ICON_BYTES = 750 * 1024 # 750 KB + +@router.post("/{printer_id}/icon") +def upload_icon(printer_id: int, file: UploadFile): + data = file.file.read(MAX_ICON_BYTES + 1) + if len(data) > MAX_ICON_BYTES: + return _error_response("Icon exceeds 750 KB limit.") + + # Validate PNG format and dimensions + try: + img = Image.open(io.BytesIO(data)) + if img.format != "PNG": + return _error_response("Icon must be PNG format.") + if img.size != (256, 256): + return _error_response(f"Icon must be 256x256 pixels, got {img.size}.") + except Exception: + return _error_response("Invalid image file.") + + # Store SHA256-addressed (same as DriverStore pattern) + sha256 = hashlib.sha256(data).hexdigest() + icons_dir = Path(cfg.DATA_DIR) / "icons" + icons_dir.mkdir(parents=True, exist_ok=True) + dest = icons_dir / sha256 + if not dest.exists(): + dest.write_bytes(data) + + # Upsert Icon ORM record (model already exists in models.py) + Icon.get_or_none(Icon.printer == printer_id) # delete old if exists + Icon.create(printer=printer_id, sha256=sha256, + original_filename=file.filename, size_bytes=len(data)) + # ... return success partial ... +``` + +### Pattern 4: Alpine.js Copy-to-Clipboard +**What:** Display command string in a `` element; Alpine.js copies it on button click +**When to use:** PKG-05 — Intune command preview +**Example:** +```html + +
    + powershell.exe -ExecutionPolicy Bypass -File install.ps1 + +
    +``` + +### Anti-Patterns to Avoid +- **Writing temp files and not cleaning up:** Always use `try/finally: shutil.rmtree(tmpdir)` — unhandled exceptions skip cleanup +- **Including all files in inner ZIP including uninstall/detect:** build_intunewin() packages everything in tmpdir; if uninstall.ps1 should be separate, do NOT put it in tmpdir — it becomes part of the .intunewin payload, which is fine for Intune (it uses SetupFile="install.ps1" as the entry point) +- **Using os.path.join with driver SHA256 directly:** SHA256 is 64 hex chars — safe as a filename but must use `cfg.DRIVERS_DIR` dynamically (monkeypatch pattern from Phase 2) +- **Reading ICONS_DIR as a module-level constant:** Same pattern as DRIVERS_DIR — read `cfg.DATA_DIR` at call time, not import time, so tests can monkeypatch + +--- + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| AES-256-CBC encryption | Custom crypto | `pycryptodome` (already installed) | Padding oracle attacks, IV reuse bugs | +| ZIP assembly | Custom byte writer | `zipfile.ZipFile` + `io.BytesIO` | ZIP format edge cases (compression flags, CRC, central directory) | +| PNG format detection + dimensions | Manual byte parsing | Pillow `Image.open()` | PNG IHDR chunk parsing is 20 lines of struct code that breaks on edge cases | +| Filename sanitization in ZIPs | Custom strip | Explicit allowlist + `replace()` | Zip-slip paths (`../` prefix) — already handled in drivers.py | + +**Key insight:** The hard crypto work (intunewin format) is already done. Phase 5 is assembly and routing only. + +--- + +## Common Pitfalls + +### Pitfall 1: Temp Directory Leaking on Exception +**What goes wrong:** `tempfile.mkdtemp()` creates a directory that persists if an exception is raised before `shutil.rmtree()` +**Why it happens:** Any error in script rendering, driver extraction, or `build_intunewin()` bypasses cleanup +**How to avoid:** Always wrap in `try/finally` block; alternatively use `tempfile.TemporaryDirectory()` as a context manager (auto-cleanup on `__exit__`) +**Warning signs:** `/tmp` fills up with `tmp*` directories after repeated export calls + +### Pitfall 2: build_intunewin() Includes Unexpected Files +**What goes wrong:** `build_intunewin()` walks the entire `source_dir` recursively — any extra file added to tmpdir ends up in the package +**Why it happens:** The function design is "pack everything in this directory" +**How to avoid:** Only write `install.ps1`, `detect.ps1`, `uninstall.ps1`, and `drivers/` into tmpdir; if icon is embedded in the .intunewin, add it as a known filename (e.g., `icon.png`) at tmpdir root + +### Pitfall 3: driver.sha256 File Not Found +**What goes wrong:** Driver file on disk was deleted but ORM record remains, causing `FileNotFoundError` during export +**Why it happens:** No referential integrity between ORM and filesystem +**How to avoid:** Check `Path(cfg.DRIVERS_DIR, driver.sha256).exists()` before proceeding; return HTTP 422 with descriptive message + +### Pitfall 4: Icon Model Unique Constraint Violation +**What goes wrong:** `Icon` model has `unique=True` on the `printer` ForeignKey — second upload raises `IntegrityError` +**Why it happens:** `Icon.create()` called without checking/deleting existing record +**How to avoid:** Use `Icon.get_or_none(Icon.printer == printer_id)` then `.delete_instance()` before `Icon.create()`, or use `INSERT OR REPLACE` via Peewee's `replace()` method + +### Pitfall 5: driver_desc JSON Parse Failure in Export Endpoint +**What goes wrong:** `driver.driver_desc` contains malformed JSON or None +**Why it happens:** Edge case — driver was saved without running INF parsing +**How to avoid:** Reuse `_get_printer_and_driver()` helper from `scripts.py` — it already handles this with HTTP 422 responses + +### Pitfall 6: .intunewin Not Accepted by Intune Tenant +**What goes wrong:** Real Intune upload rejects the package despite passing all unit tests +**Why it happens:** The byte-level format was reverse-engineered from community documentation (svrooij.io), not from Microsoft's official spec +**How to avoid:** Manual validation gate — upload a test `.intunewin` to a real Intune tenant before Phase 5 is marked complete (documented as Phase 5 blocker in STATE.md) + +### Pitfall 7: Alpine.js Clipboard on HTTP (non-HTTPS) +**What goes wrong:** `navigator.clipboard.writeText()` throws `NotAllowedError` in some browsers when page is served over plain HTTP +**Why it happens:** Clipboard API requires secure context (HTTPS or localhost) in modern browsers +**How to avoid:** This tool runs on internal network, typically accessed via IP address. Provide a fallback: show the command text in a `
    ` from plain text to `{{ p.name }}`. + +**4. Adjust file path:** The detail template is `imptune/templates/printer_detail.html` (full page, not partial). + +Run all tests to confirm GREEN state. + + + cd C:/Users/SebastienQUEROL/Documents/projets/ImpTune && python -m pytest tests/test_printer_crud.py -x -q && python -m pytest tests/ -v + + All tests pass (GREEN). GET /printers/{id} shows full printer config with driver info. "Regenerate Package" button is visible but disabled. Printer names in list are clickable links to detail page. Full test suite green. + + + + + +- `pytest tests/test_printer_crud.py -x -q` — all tests pass including new detail tests +- `pytest tests/ -v` — full suite green +- GET /printers/{id} displays all printer fields and driver info +- Printer names in list link to detail page +- "Regenerate Package" button visible but disabled +- 404 returned for nonexistent printer IDs + + + +- PRNT-10 verified: saved config retrievable with driver association intact, regeneration button present (placeholder) +- Detail page shows all configured fields (name, IP, port, duplex, color, paper, collate, client, driver) +- Driver info displayed from FK relationship (no re-upload needed) +- Full test suite green with no regressions + + + +After completion, create `.planning/phases/03-printer-configuration/03-02-SUMMARY.md` + diff --git a/.planning/phases/03-printer-configuration/03-02-SUMMARY.md b/.planning/phases/03-printer-configuration/03-02-SUMMARY.md new file mode 100644 index 0000000..5b777bb --- /dev/null +++ b/.planning/phases/03-printer-configuration/03-02-SUMMARY.md @@ -0,0 +1,112 @@ +--- +phase: 03-printer-configuration +plan: 02 +subsystem: ui +tags: [fastapi, jinja2, htmx, peewee, sqlite] + +# Dependency graph +requires: + - phase: 03-01 + provides: Printer and Client CRUD endpoints, DB models, printer_list partial template + +provides: + - GET /printers/{id} detail route with LEFT OUTER JOINs on Client and Driver + - printer_detail.html full-page template showing all config fields and driver info + - Disabled "Regenerate Package" button (Phase 4 placeholder) + - Clickable printer name links in printer_list.html navigating to detail page + +affects: + - 04-script-generation (regenerate button placeholder ready to wire up) + +# Tech tracking +tech-stack: + added: [] + patterns: + - "TDD RED/GREEN cycle: failing tests committed first, then implementation" + - "LEFT OUTER JOIN chain with .switch(Printer) for multi-FK queries in Peewee" + - "Null-safe driver_desc parse: check printer.driver_id before json.loads" + +key-files: + created: + - imptune/templates/printer_detail.html + modified: + - imptune/api/pages.py + - imptune/templates/partials/printer_list.html + - tests/test_printer_crud.py + +key-decisions: + - "Detail page is a full-page template (not partial) — simpler than partial injection into printers.html" + - "Route lives in pages.py (not printers.py) because it returns a full HTML page, not an HTMX fragment" + +patterns-established: + - "Full-page detail routes in pages.py; HTMX fragment routes in api/printers.py" + - "Disabled placeholder buttons for Phase N+1 features with descriptive title attribute" + +requirements-completed: + - PRNT-10 + +# Metrics +duration: 2min +completed: 2026-04-10 +--- + +# Phase 3 Plan 02: Printer Detail Page Summary + +**GET /printers/{id} detail page with pre-populated config fields, associated driver info via FK, and disabled Regenerate Package button placeholder for Phase 4** + +## Performance + +- **Duration:** ~2 min +- **Started:** 2026-04-10T12:03:29Z +- **Completed:** 2026-04-10T12:05:56Z +- **Tasks:** 2 +- **Files modified:** 4 + +## Accomplishments + +- Printer detail route with Peewee multi-FK LEFT OUTER JOIN queries returning 200 or 404 +- Full-page Jinja2 template showing all 8 config fields, driver package name, driver names list, and architecture +- Graceful "No driver assigned" display when driver FK is null +- Printer names in list view are now clickable navigation links to their detail pages +- 3 new integration tests; full suite at 61 passing + +## Task Commits + +1. **Task 1: Write failing tests for printer detail page** - `6e7892e` (test) +2. **Task 2: Implement printer detail route, template, and list nav links** - `cad664c` (feat) + +**Plan metadata:** (committed next) + +## Files Created/Modified + +- `imptune/api/pages.py` - Added GET /printers/{printer_id} route with LEFT OUTER JOIN on Client and Driver +- `imptune/templates/printer_detail.html` - Full-page detail template with config, driver info, and regenerate placeholder +- `imptune/templates/partials/printer_list.html` - Printer name column wrapped in anchor tag linking to detail page +- `tests/test_printer_crud.py` - Added 3 tests: detail with driver, 404 not found, detail without driver + +## Decisions Made + +- Detail page uses a full-page template (not a partial) to avoid coupling it to the printers list layout +- Route placed in `pages.py` since it returns a full HTML page, keeping HTMX fragment routes in `api/printers.py` + +## Deviations from Plan + +None - plan executed exactly as written. + +## Issues Encountered + +None. + +## User Setup Required + +None - no external service configuration required. + +## Next Phase Readiness + +- PRNT-10 satisfied: saved configs are retrievable with driver association intact +- "Regenerate Package" button is present and disabled, ready for Phase 4 to wire up +- No blockers for Phase 4 script generation work + +--- +*Phase: 03-printer-configuration* +*Completed: 2026-04-10* diff --git a/.planning/phases/03-printer-configuration/03-RESEARCH.md b/.planning/phases/03-printer-configuration/03-RESEARCH.md new file mode 100644 index 0000000..e5e29a8 --- /dev/null +++ b/.planning/phases/03-printer-configuration/03-RESEARCH.md @@ -0,0 +1,416 @@ +# Phase 3: Printer Configuration - Research + +**Researched:** 2026-04-10 +**Domain:** FastAPI + Peewee ORM + HTMX + Alpine.js — form CRUD, client grouping, saved config retrieval +**Confidence:** HIGH + +## Summary + +Phase 3 builds on a fully functional Phase 2 stack: FastAPI 0.115, Peewee 3.17, Jinja2 3.1, HTMX, Alpine.js, Pico CSS. The Printer and Client ORM models are already created (from the Phase 1 schema spike) with every field the requirements specify: `name`, `ip_address`, `port_name`, `client` (FK), `driver` (FK), `duplex_mode`, `color_mode`, `paper_size`, `collate`. No schema changes are needed in Phase 3 — it is purely route + template + service work. + +The key interaction patterns are already proven in Phase 2: HTMX `hx-post` / `hx-get` with `outerHTML` swaps for partial re-renders, Jinja2 partials for list fragments, Peewee sync queries in sync FastAPI route handlers (`def`, not `async def`), and the `client` fixture + `monkeypatch` pattern for integration tests. + +The three planned sub-plans map cleanly to separable concerns: (1) printer CRUD with form validation, (2) Client CRUD and grouped display, (3) regeneration flow that re-uses the saved `driver` FK. No new libraries are required. + +**Primary recommendation:** Re-use every established Phase 2 pattern exactly — HTMX partial swaps, Peewee `get_or_create`/`save()`, `json.dumps` for multi-value fields, sync route handlers, and the conftest `client` fixture. + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|-----------------| +| PRNT-01 | User can set printer display name | `Printer.name = CharField()` already in models.py. Route validates non-empty. | +| PRNT-02 | User can set printer IP address or hostname | `Printer.ip_address = CharField()` already in models.py. Server-side regex validates format. | +| PRNT-03 | System auto-suggests port name from IP (user can override) | Alpine.js `x-model` / `@input` on IP field drives port field in-browser; field remains editable. | +| PRNT-04 | User can set duplex mode (one-sided, long-edge, short-edge) | `Printer.duplex_mode = CharField(default="OneSided")` already in models.py. `` with fixed options. | +| PRNT-07 | User can set collate on/off | `Printer.collate = BooleanField(default=True)` already in models.py. Checkbox. | +| PRNT-08 | User can assign printer to a client/tenant label | `Printer.client = ForeignKeyField(Client, null=True)` already in models.py. ` + + +``` +Note: The exact derivation logic should be `IP_` + IP with dots replaced by underscores — this matches the Windows standard `pnputil` port name format used in Phase 4 scripts. + +### Pattern 5: Peewee FK Population for Dropdown (new in Phase 3) +**What:** Pass a list of all Client records to the printer form template. Render as `` on printer form shows no options because drivers list was not passed to template context. +**Why it happens:** GET /printers/new handler omits `drivers` from context. +**How to avoid:** Always pass `drivers` list to both create and edit form contexts. Build `driver_data` the same way as in `drivers.py` (parse `driver_desc` JSON into a list per driver). +**Warning signs:** Driver dropdown empty on new/edit printer form. + +### Pitfall 5: PRNT-10 Scope Creep into Phase 4 +**What goes wrong:** Implementing actual script/package regeneration (PowerShell generation) in Phase 3. +**Why it happens:** PRNT-10 says "regenerate its package" — but Phase 4 is the script generation phase. +**How to avoid:** Phase 3's scope for PRNT-10 is: (a) display saved printer config with all fields pre-populated, (b) show associated driver info, (c) provide a "Regenerate" button that will call the Phase 4 endpoint. The button can be disabled/placeholder in Phase 3. The plan 03-03 "Saved config retrieval and regeneration flow" is about navigation and form pre-population, not script generation. +**Warning signs:** Trying to write PowerShell templates in Phase 3. + +### Pitfall 6: TestClient Lifespan Not Triggered +**What goes wrong:** Tests fail with "table does not exist" errors. +**Why it happens:** TestClient must be used as a context manager to trigger the FastAPI `lifespan` (which calls `init_db()`). Using `TestClient(app)` without `with` does not trigger lifespan. +**How to avoid:** Always use `with TestClient(app) as c:` — established and enforced in conftest.py's `client` fixture. All new tests should use the `client` fixture, not create their own TestClient. +**Warning signs:** `OperationalError: no such table: printer` in test output. + +## Code Examples + +### Create Printer (Peewee) +```python +# Pattern: use keyword args matching Printer model field names +# Source: Peewee 3.17 docs + established Phase 2 Driver.get_or_create pattern +from imptune.db.models import Printer, Client, Driver + +printer = Printer.create( + name=name, + ip_address=ip, + port_name=port, + duplex_mode=duplex, # "OneSided" | "LongEdge" | "ShortEdge" + color_mode=color_mode, # bool + paper_size=paper_size, # "A4" | "Letter" | "Legal" + collate=collate, # bool + client=client_obj_or_none, + driver=driver_obj_or_none, +) +``` + +### Update Printer (Peewee) +```python +# Source: Peewee 3.17 — Model.save() with only_fields for efficiency +printer = Printer.get_by_id(printer_id) +printer.name = new_name +printer.ip_address = new_ip +# ... set other fields ... +printer.updated_at = datetime.utcnow() +printer.save() +``` + +### Query Printers Grouped by Client (Peewee) +```python +# Source: Peewee 3.17 JOIN pattern — avoids N+1 +from collections import defaultdict +from peewee import JOIN + +printers = list( + Printer.select(Printer, Client) + .join(Client, JOIN.LEFT_OUTER) + .order_by(Client.name.nulls_last(), Printer.name) +) +grouped: dict[str, list] = defaultdict(list) +for p in printers: + label = p.client.name if p.client_id else "Unassigned" + grouped[label].append(p) +``` + +### FastAPI Form Parsing (python-multipart) +```python +# Source: FastAPI docs — Form parameters +from fastapi import Form + +@router.post("/printers", response_class=HTMLResponse) +def create_printer( + request: Request, + name: str = Form(...), + ip_address: str = Form(...), + port_name: str = Form(...), + duplex_mode: str = Form("OneSided"), + color_mode: bool = Form(True), + paper_size: str = Form("A4"), + collate: bool = Form(True), + client_id: int | None = Form(None), + driver_id: int | None = Form(None), +) -> HTMLResponse: + ... +``` + +### Alpine.js Port Auto-Derivation (PRNT-03) +```html + +
    + + + + + +
    +``` + +### HTMX Delete with Confirmation (optional, for plan 03-01) +```html + + +``` + +### Retrieve Driver Path for Regeneration (PRNT-10) +```python +# Source: imptune/storage/driver_store.py (Phase 2) +from imptune.storage.driver_store import DriverStore +import imptune.config as cfg + +printer = Printer.get_by_id(printer_id) +if printer.driver_id: + store = DriverStore(cfg.DRIVERS_DIR) + driver_zip_path = store.get_path(printer.driver.sha256) + # driver_zip_path is the Path to the stored ZIP, available for Phase 4 script generation +``` + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| `@app.on_event("startup")` | `asynccontextmanager lifespan` | FastAPI 0.93+ / Starlette 0.40+ | Use lifespan pattern, never on_event | +| `TemplateResponse("name", {"request": req})` positional dict | `TemplateResponse(request=req, name="name", context={})` kwargs | Starlette 0.40+ | Must use kwargs form | +| `async def` with Peewee | `def` (sync) handlers | Phase 2 decision | Peewee is sync; async would block event loop | + +**Deprecated/outdated:** +- `@app.on_event`: Replaced by lifespan context manager (established in Plan 01-01). +- Schema changes in Phase 3: Full schema was created in Phase 1. No `CREATE TABLE` calls needed. + +## Open Questions + +1. **How many paper sizes beyond A4/Letter/Legal?** + - What we know: PRNT-06 says "A4, Letter, Legal at minimum" + - What's unclear: Should the `
    {{ p.name }}