# CLI Render Contract The desktop app (Story: "Launch a text-only render from the desktop app") integrates against this contract, not against `EnvelopeRenderer.Cli`'s internals. Treat it as the stable interface between the two; changing it is a cross-cutting decision, not a local one. Implementation: [`src/EnvelopeRenderer.Cli`](src/EnvelopeRenderer.Cli). ## Arguments All three are required. There is no short form and no `=` form in this MVP — keep the surface intentionally small per the story's conversation notes. | Argument | Value | Notes | |---|---|---| | `--template ` | Local or UNC path to the XML template | Must exist and be readable. | | `--csv ` | Local or UNC path to the CSV data file | Must exist and be readable. | | `--output ` | Local or UNC path to write the rendered PDF to | Parent directory must already exist; the CLI does not create directories. | | `-h`, `--help` | — | Prints usage to stdout and exits `0`. Overrides everything else on the command line. | Unknown arguments, a flag given without a value, a flag given twice, or an empty value are all usage errors (exit `2`). ## stdout / stderr rules - **stdout** is reserved for machine-readable output: `--help` text (human-readable by exception, since it's explicitly for a human at a terminal), and — once argument/path validation has succeeded and rendering begins — one line-oriented `PROGRESS` event per line (see "Progress events" below). The desktop app should be able to treat stdout as parseable and never need to filter noise out of it. - **stderr** carries every error, one per line, each prefixed `ERROR: `. Multiple problems (e.g. two missing arguments) are each reported as their own line rather than stopping at the first. A render-stage failure still writes its `ERROR: ` line(s) here exactly as before — the stdout `failure` progress event described below supplements this, it never replaces it, and it never changes the exit code. ## Progress events Once argument parsing, input-path validation, and output-path validation have all succeeded (i.e. the CLI is past every exit-`2`/`3`/`4` case and has started the actual render), it emits one line per event to stdout in this fixed, space-separated, `key=value` format: ``` PROGRESS elapsedMs= completed= [reason=] ``` - `` is one of `startup`, `render`, `complete`, `failure`. - `elapsedMs` — milliseconds since the render phase started, as an integer. Always present. - `completed` — number of CSV records successfully rendered to a page so far. Always present (`0` for `startup` and for a failure that happened before any page was rendered). - `reason` — present only on `failure`; a short, human-readable description of what went wrong. It is always the **last** field on the line and takes everything remaining on the line verbatim (embedded newlines are stripped to a single space first), so it never needs quoting or escaping and a consumer can safely `Split(' ', 5)`-style parse the fixed fields first. Why this shape instead of one JSON object per line: it stays trivially parseable with a plain string split (no JSON library dependency needed in the desktop shell), it stays readable at a terminal or in a redirected log file — matching the plain-text style already used for stderr's `ERROR: ` lines — and every numeric field is a culture-invariant integer, so there's no decimal-separator ambiguity across locales. Implementation: [`Progress/ProgressEventFormatter.cs`](src/EnvelopeRenderer.Cli/Progress/ProgressEventFormatter.cs). Event kinds, each emitted at most as documented: | Kind | When | Count per run | |---|---|---| | `startup` | Immediately when the render phase begins, before template parsing, CSV header validation, or Debenu setup. | Exactly 1 | | `render` | After a record is successfully rendered to a page. **Throttled** to at least once per second — see below — not once per row. | 0 or more | | `complete` | The run finished successfully; `completed` is the final record count. | Exactly 1, only on success | | `failure` | The run failed at any point during the render phase (template parse, CSV read, Debenu setup, or the per-record render/save loop); `completed` is however many records were successfully rendered before the failure, and `reason` summarizes the same problem reported to stderr. | Exactly 1, only on failure | A run therefore always starts with exactly one `startup` line and ends with exactly one `complete` or `failure` line, with zero or more `render` lines in between. Argument parsing and path-validation failures (exit `2`/`3`/`4`) produce **no** progress events at all — that failure mode is entirely a pre-render validation error, unaffected by this contract. ### Throttling `render` events are throttled to at least once per second of wall-clock time, not once per CSV row, so a fast render of thousands of rows doesn't flood stdout with one line per row. The first `render` event after `startup` is always written immediately (so a slow run shows *something* right away); after that, an event is only written once at least 1000ms have passed since the last one that was actually written. `startup`, `complete`, and `failure` are never throttled — each happens exactly once, so there's nothing to throttle. Implementation: [`Progress/ConsoleProgressReporter.cs`](src/EnvelopeRenderer.Cli/Progress/ConsoleProgressReporter.cs). ## Exit codes | Code | Meaning | |---|---| | `0` | Success — PDF written to `--output`. | | `1` | Template, CSV, or render error: malformed/invalid template XML, a template `column` not present in the CSV header row, an empty CSV, a missing/unresolvable font, or a Debenu render/save failure (including a missing or invalid license key — see below). Read stderr for which. | | `2` | Invalid usage — missing, unknown, duplicate, or empty-valued argument. | | `3` | Input not found — `--template` or `--csv` path does not exist. | | `4` | Output path invalid — output directory does not exist, or the output path is itself an existing directory. | Validation runs in this order and stops at the first failing stage: usage → input paths → output path → render. A caller can rely on, e.g., never seeing an output-path error (`4`) while an input path is still missing. ## Rendering The text-only template format (`--template`) is documented separately in [`TEMPLATE_FORMAT.md`](TEMPLATE_FORMAT.md). ## Debenu license key Rendering (not argument validation) requires a Debenu Quick PDF Library 10.13 license key at runtime. Without it, every render fails at the save step with exit `1` — confirmed directly against the vendor DLL: page creation, font embedding, and text drawing all succeed, but `SaveToFile`/`SaveToString` both return error code 999 regardless of content. The key is resolved in this order (`DebenuLicenseKeyResolver.cs`): 1. The `DEBENU_LICENSE_KEY` environment variable, if set to a non-blank value. 2. Otherwise, a `key.txt` file, walked up from the CLI executable's own directory (its own directory first, then each parent, up to 10 levels) — so a `key.txt` dropped next to `EnvelopeRenderer.Cli.exe` is picked up automatically, and a `key.txt` at the project root still works for local `dotnet run` development without needing to set anything. This matters for the desktop app in particular: `EnvelopeRenderer.Desktop` launches `EnvelopeRenderer.Cli.exe` as a child process, which by default only inherits environment variables that were already present in whatever process launched the desktop app itself (a double-clicked `.exe` or Start Menu shortcut typically has none). Requiring an operator to set an environment variable before every launch is not viable, so the key.txt fallback is the intended path for that workflow — never commit a real key.txt or hardcode a key into source; it's untracked (`.gitignore`) for exactly this reason. ### Production configuration delivery decision (Sprint 5, `dev-team`, 2026-10-05) **Decision: keep the `key.txt`-adjacent-to-executable mechanism (env var first, then `key.txt` walked up from the executable's directory) as this product's accepted production approach for the Debenu license key, unchanged from how it already works today.** No code change was made or is required for this story — `DebenuLicenseKeyResolver` and its tests are unchanged. Reasoning, made as the most reasonable Development Team call in the absence of a live product owner to consult mid-sprint (to be reviewed and confirmed or overridden by `product-owner` at Sprint Review, per this story's own acceptance criteria): - **The mechanism already works and is already verified against the real built `.exe`**, not just a dev-shell invocation (see the 2026-09-04 fix in `logs/technical_debt_log.md`) — replacing a working, verified mechanism without a concrete driving requirement would be change for its own sake, not risk reduction. - **It fits this product's actual deployment model.** Per `project_config.md`'s hard constraints, this is a single-workstation Windows desktop application with Windows-login-only, no app-level authentication, targeting **non-technical** print operators. A plaintext `key.txt` dropped next to the executable requires zero extra operator steps (no environment variable to set, no credential-manager entry to create) — anything requiring manual configuration would cut directly against the "non-technical operator" constraint for a vendor SDK license key that the operator never needs to see or touch. - **The key's exposure risk is a vendor-licensing concern, not a security/privacy one.** It is a Debenu Quick PDF Library license key, not user credentials, customer PII, or CSV data — the product's actual sensitive data (operator-supplied CSV records) never passes through this mechanism at all. Given the single-workstation, Windows-login-only deployment model, the realistic exposure scenario (another local account, or someone copying the file off the machine) is the same threat model already accepted for every other file on disk in this product, not a new or heightened risk this mechanism introduces. - **No packaging/installer story has been done yet**, so there is no concrete evidence today of a distribution model (MSI installer, xcopy deployment, ClickOnce, etc.) that would benefit from a stronger mechanism (e.g., an installer-provisioned per-machine `%ProgramData%` file with tighter ACLs, or Windows Credential Manager/DPAPI-protected storage). Inventing and building that now would be speculative work against a requirement that doesn't exist yet. **Explicit revisit trigger (satisfies this story's 4th acceptance criterion):** the *next* release/packaging/installer story must reference this decision rather than reopening the question from scratch, but **should** revisit it if the chosen distribution mechanism makes a stronger option cheap/natural to add (e.g., an installer that can already write a per-machine `%ProgramData%` location as part of setup) — at that point, checking a `%ProgramData%`-rooted path in addition to the existing walked-up-from-executable search would be a small, additive change to `DebenuLicenseKeyResolver`, not a redesign. **Scope-generalization decision:** the *pattern* (environment variable first, then a file-based fallback discovered near the executable) is the team's established convention for any future CLI runtime configuration value, not a one-off special case — but building a generic, reusable "settings resolver" abstraction now, with exactly one configuration value in existence today, would be speculative (YAGNI). Revisit genericizing this if/when a second CLI runtime setting is actually introduced. ## UNC paths Local and UNC paths are both accepted and validated identically — `System.IO.File.Exists` / `Directory.Exists` handle UNC paths natively, so there's no special-case code. What is **not** yet defined is timeout/retry behavior against a slow or unreachable UNC share; that's an open impediment (`logs/impediment_log.md`, logged 2026-09-04) carried as an explicit risk into the Debenu render story, not solved here. ## Smoke examples Run from `code/`: ``` $ dotnet run --project src/EnvelopeRenderer.Cli -- --help (usage text) exit=0 $ dotnet run --project src/EnvelopeRenderer.Cli -- ERROR: Missing required argument '--template'. ERROR: Missing required argument '--csv'. ERROR: Missing required argument '--output'. (usage text on stderr) exit=2 $ dotnet run --project src/EnvelopeRenderer.Cli -- --template nope.xml --csv sample-data/wilson.csv --output out.pdf ERROR: Template not found: 'nope.xml'. exit=3 $ dotnet run --project src/EnvelopeRenderer.Cli -- --template envelope.xml --csv sample-data/wilson.csv --output nosuchdir/out.pdf ERROR: Output directory does not exist: 'nosuchdir'. exit=4 $ dotnet run --project src/EnvelopeRenderer.Cli -- --template sample-data/sample-envelope-template.xml --csv "sample-data/87700 - 999999 - Wilson Township.csv" --output out.pdf # stdout: PROGRESS startup elapsedMs=0 completed=0 PROGRESS render elapsedMs=802 completed=1 PROGRESS render elapsedMs=1804 completed=392 PROGRESS failure elapsedMs=1805 completed=392 reason=Failed to save PDF to 'out.pdf' (error code 999). # stderr: ERROR: Failed to save PDF to 'out.pdf' (error code 999). exit=1 # (DEBENU_LICENSE_KEY not set — see "Debenu license key" above. Note the throttling in action: # 392 records were each individually reported to RenderEngine, but only 2 `render` lines were # actually written to stdout because the whole render took under 2 seconds.) $ DEBENU_LICENSE_KEY="$(cat ../key.txt)" dotnet run --project src/EnvelopeRenderer.Cli -- --template sample-data/sample-envelope-template.xml --csv "sample-data/87700 - 999999 - Wilson Township.csv" --output out.pdf exit=0 # stdout (illustrative — not re-captured for this story since no license key was available in # this dev environment; the render loop that produces `startup`/`render` is identical to the # failure example above, a valid key only changes the final Save() outcome, so the only actual # difference is the last line): PROGRESS startup elapsedMs=0 completed=0 PROGRESS render elapsedMs=802 completed=1 PROGRESS render elapsedMs=1804 completed=392 PROGRESS complete elapsedMs= completed=392 ```