# CLAUDE.md Guide for Claude Code (claude.ai/code) work in repo. ## Commands **Run tests:** ```bash pytest tests/ # whole suite (no env vars needed) pytest tests/test_inf_parser.py # single test file (no tests/unit/ dir) pytest tests/ -k "test_name" # single test by name ``` Both `tests/conftest.py` (`tmp_data_dir`) and `tests/e2e/conftest.py` force `cfg.COOKIE_SECURE = False`, because `TestClient` talks plain HTTP to `http://testserver` and a `Secure` cookie would be dropped — every request would land on a *new* `Owner` and ~41 tests would 404. Tests asserting the `Secure` branch (`test_secure_mode_*` in `tests/test_session.py`) monkeypatch it back to `True`. **Run dev server:** ```bash export DATA_DIR=/tmp/imptune_data export COOKIE_SECURE=false # plain HTTP — omit if serving behind TLS uvicorn imptune.main:app --reload --port 8000 ``` **Docker:** ```bash docker-compose up docker build -t imptune . ``` **Install deps:** ```bash pip install -r requirements.txt -r requirements-dev.txt ``` ## Architecture ImpTune make printer deploy packages (`.intunewin` for Intune, `.zip` for NinjaRMM) from Windows driver ZIPs + web UI. No external services — single FastAPI + SQLite + Docker volume. **Request flow:** 1. Driver upload → `api/drivers.py` → `services/inf_parser.py` parse INF → `storage/driver_store.py` store by SHA256 → Peewee `Driver` record (shared/global — visible to every Owner) 2. Printer config → `api/printers.py` → `db/models.py` `Printer` record (links Driver FK, scoped to `request.state.owner`) 3. Icon upload → `api/icons.py` → Pillow validate PNG 256×256 → SHA256 storage → `Icon` record 4. Package export → `api/packages.py` → `generators/script_generator.py` render Jinja2 PS1 templates → `generators/intunewin_builder.py` encrypt ZIP (AES-256-CBC + HMAC-SHA256) **Key modules:** - `imptune/config.py` — `DATA_DIR`, `DB_PATH`, `DRIVERS_DIR`, `ICONS_DIR`, `COOKIE_SECURE` from env - `imptune/db/database.py` — SQLite WAL mode + `foreign_keys=1`; all models inherit `BaseModel`; `init_db()` also backfills `owner_id` on pre-per-owner-scoping DBs into a synthetic legacy `Owner` (key written to `{DATA_DIR}/legacy_owner_key.txt`) - `imptune/services/session.py` — `OwnerSessionMiddleware` resolves `request.state.owner` from the `imptune_owner_key` cookie, creating one on first visit (skips `/health`) - `imptune/services/inf_parser.py` — auto-detect encoding (UTF-16/UTF-8/cp1252), resolve `%TOKEN%` from `[Strings]`, handle multi-model INFs - `imptune/generators/intunewin_builder.py` — Python-native `.intunewin` (ZIP-in-ZIP); IV 16 bytes (not 32); match reference tool `1.8.6.0` output - `imptune/templates/scripts/` — Jinja2 templates for `install.ps1`, `uninstall.ps1`, `detect.ps1` **Per-owner storage:** `Printer`/`Client` (groups) are scoped to an `Owner` identified by an opaque bearer key in a cookie — no accounts. `Driver` stays global/shared. Every route taking a `printer_id`/`client_id` must filter/check `.owner == request.state.owner` (404, not 403, on mismatch) — printer IDs are small sequential ints, so a list-only filter isn't enough. Onboarding modal (`templates/base.html`, gated on `request.state.is_new_owner`) offers "download backup key" (`GET /session/key/download`, marks `Owner.is_permanent`) vs. temporary; `/session/restore` re-attaches a browser to a previously downloaded key. In tests, use the `owner` fixture (`tests/conftest.py`) when creating `Printer`/`Client` rows directly via the ORM so the `client` fixture's cookie-scoped requests can see them. **UI stack:** Pico CSS + HTMX 2 + Alpine.js 3 + Jinja2 server-side templates. **Design layer (`static/app.css`):** a token + component layer over Pico. Tokens (`--im-*`) are declared three times — `:root:not([data-theme=dark])`, the `prefers-color-scheme: dark` block, and `[data-theme=dark]` — mirroring Pico's own selectors so equal specificity + later source order wins; a new color must be added to all three. Pico vars are remapped from those tokens, so use `--im-*` in components. Prose is set in the system UI face, machine values (IPs, ports, INF names, PS commands) in `--im-mono`. Components: `.card`, `.rail` (the driver → printer → package pipeline on the dashboard), `.data-table`, `.badge`, `.pill`, `.kv`, `.cmd`, `.empty`, `.form-section`, `.toolbar`. Because the edit dialog renders inside a table cell, `dialog` resets inherited `text-align` / `white-space` — keep that. **Shell:** `base.html` owns the sidebar + topbar; pages fill the `crumb`, `page_title`, `page_actions`, and `content` blocks and must not render their own `

`. Icons come from `{% import "partials/icons.html" as ico %}` → `{{ ico.i('printer') }}` — inline SVG with no text nodes, because E2E tests read `textContent` of nav links to assert the translated label. Nav links are `{{ ico.i(...) }}`: never add count badges or other text inside them. **i18n:** every user-facing string goes through `$store.i18n.t('key')` with the English text as the element's fallback body, and keys must be added to *both* `fr` and `en` in `base.html`. Server-rendered HTMX fragments (icon-upload confirmation, `_error_response`) are English-only. The store's default language follows `navigator.language`, so E2E specs must **never locate a control by its visible label** — `button:has-text('Edit')` matched only on English-locale machines and timed out everywhere else. Target a structural hook instead (`button[onclick*='showModal']`), except in `test_i18n_toggle.py`, which asserts the labels on purpose and pins `locale=` per context. **Client-side filter:** `Alpine.store('filter')` holds the printer search text. Rows and group cards carry `data-search` (lowercased) and `x-show` off that store, so HTMX-swapped rows keep filtering. A group's `data-search` must be a superset of its rows' — otherwise a matching row hides inside a hidden group. `.col-defaults` / `.col-arch` / `.col-used` / `.col-added` mark columns dropped on narrow screens or in the add-printer sidebar (`.form-aside`). **HTMX pattern:** Forms `hx-post`, swap `#driver-list` / `#printer-list` / `#client-list` targets. Errors return inline HTML fragments (HTTP 400/409) via `_error_response()`. Success return partials from `templates/partials/`. **PowerShell install script notes:** - WOW64 64-bit relaunch guard (Intune run 32-bit, `pnputil` need 64-bit) - UAC self-elevation for user context (SYSTEM context skip) - Two-step: `pnputil /add-driver` then `Add-PrinterDriver` + `Add-PrinterPort` + `Add-Printer` - All idempotent (`-ErrorAction SilentlyContinue`) ## Test Setup `conftest.py` monkeypatch `config.DATA_DIR` + `config.DB_PATH` to temp dir per test. `client` fixture yield `TestClient(app)` with isolated SQLite. E2E in `tests/e2e/` use Playwright. ## Environment Variables | Var | Default | Purpose | |-----|---------|---------| | `DATA_DIR` | `/data` | Storage root (DB + drivers + icons) | | `PORT` | `8000` | Server port | | `COOKIE_SECURE` | `true` | Three-way session mode, parsed by `config.parse_cookie_mode()` into `(COOKIE_SECURE, SINGLE_USER)`: `true` = Secure + 10-year cookie; `false` = plain-HTTP serving (browser drops a Secure cookie → new Owner per request), cookie becomes **memory-only** (no `Max-Age`); `single_user` (or `single-user`/`single`) = no cookie at all, one shared Owner — see below. | `COOKIE_SECURE=false` degrades the session instead of weakening the credential: `services/session.cookie_kwargs()` drops `max_age`, so the browser holds the owner key in memory and the session ends when the window closes. Everything still persists server-side; only the browser's link to it is temporary. Both cookie-setting call sites (the middleware and `POST /session/restore`) must go through `cookie_kwargs()`. `request.state.ephemeral_session` mirrors the flag, and `base.html` renders the `#ephemeral-session-warning` banner plus an extra paragraph in the onboarding modal off it. Changing this touches `tests/test_session.py::test_insecure_mode_*` / `test_secure_mode_*`. `COOKIE_SECURE=single_user` (`cfg.SINGLE_USER`) removes sessions for test boxes and single-person local prod: the middleware never reads or sets a cookie and returns `services/session.single_user_owner()` — the **oldest** `Owner` row, created on demand — so a deployment switched over from cookie mode keeps the printers it already had and no second row is ever minted. `request.state.owner` is still what every route filters on, so per-owner query code is unchanged. `request.state.single_user` gates the sidebar "This session" menu and the `#single-user-notice` banner in `base.html`; `is_new_owner`/`ephemeral_session` are forced `False` (no onboarding modal, no memory-only warning). All three `/session/*` routes 404 via `api/session._require_cookie_sessions()` — a key can't re-point a cookie that isn't read, and downloading one would leak the shared owner's bearer credential for a later switch back to cookie mode. Tests: `test_session.py::test_single_user_*` + `test_parse_cookie_mode_*`.