Architecture - phax/peppol-cli GitHub Wiki
Status: proposed, except where an ADR is referenced.
| Aspect | Choice | Notes |
|---|---|---|
| Language | Java | The libraries this tool is built on are Java. |
| Baseline | Java 25 |
ADR-005. The shared ph-parent-pom currently defaults java.version to 17, so this project overrides that property. |
| Build | Maven | Consistent with the rest of the ecosystem; parent ph-parent-pom. |
| CLI framework | picocli |
ADR-006. Already used by norma-cli, kaltblut-cli, ph-redact-cli, the en16931-*-cli tools and phoss-peppol-mcp (4.7.7). |
| Logging | SLF4J, with a runtime binding in the distribution only | Log output goes to stderr. Default level is quiet; --verbose/--debug raise it. |
| Licence | Apache License 2.0 | ADR-008. Consistent with the rest of the ecosystem, and the licence every planned dependency already uses. |
Build note, verified: several peppol-commons modules generate sources with JAXB/wsimport into
target/generated-sources/xjc and need mvn generate-sources before IDE use. Worth stating in CONTRIBUTING so
a first checkout does not look broken.
The tool is deliberately a thin command line layer over existing libraries. Verified against the local sources:
| Need | Library / module |
|---|---|
| Identifiers, code list enums, Seat ID / SPID handling |
peppol-commons β peppol-id (with edelivery-id, peppol-id-datatypes; also brings ph-security from 13.1.0 for the certificate-based Seat ID extraction in SPIDHelper) |
| Identifier value checks, country derivation, GLN/GTIN check digits |
peppol-id-checks, which already depends on com.helger.masterdata:ph-masterdata β the latter provides GTINValidator (GLN, GTIN-8/12/13/14, SSCC) and IBAN handling |
| SML descriptors, networks, trust stores, CA checkers |
peppol-commons β peppol-commons (ESML, EPeppolNetwork, PeppolTrustStores, PeppolTrustedCA) |
| SMP read/write, DNS discovery, SMP JSON |
peppol-commons β peppol-smp-client (brings ph-dns + dnsjava). DNSSEC validation of the U-NAPTR lookups needs peppol-commons 13.1.2 (setDnsSecValidation) over ph-web/ph-dns 11.4.7 (com.helger.dns.dnssec) |
| SMP core, signature verification |
peppol-commons β smp-client-base. It also carries the OASIS BDXR SMP 1.0/2.0 clients, which this project does not use β see ADR-007
|
| SML management (SOAP 1.1, mTLS) |
peppol-commons β peppol-sml-client. Note SML 1.3.0 Β§4.2.2/Β§4.3.2: every SML call needs a client certificate issued under the Peppol root CA, so this whole area is unusable without configured key material β which is why it sits in M3 and why peppol config validate should check the keystore loads before a write is attempted. |
| Business cards |
peppol-commons β peppol-directory-businesscard (data model v1/v2/v3) |
| Directory indexing |
com.helger.peppol.directory:peppol-directory-client 1.0.0 (PDClient) β mTLS, indexer interface |
| Directory search |
com.helger.peppol.directory:peppol-directory-searchclient β PDSearchClient incl. searchAllPages, PDSearchClient, PDSearchQuery, PDSearchRateLimitException. No ph-config or key store dependency, since the search API is public |
| Directory search model |
com.helger.peppol.directory:peppol-directory-searchapi 1.0.0 β PDResultListMarshaller, EPDSearchAPIField, CPDSearchAPI, and the JAXB types ResultListType / MatchType / EntityType in com.helger.pd.searchapi.v1; depends only on ph-xml/ph-jaxb
|
| MLS / SBDH |
peppol-commons β peppol-mls, peppol-sbdh. Not peppol-mlr β MLR is end of life, see Rejected features
|
| Phorm validation |
com.helger:phorm-client (PhormClient, PhormValidationResult) |
| VESID parsing |
ph-diver (DVRCoordinate) |
| Local document type detection |
ddd-model (DocumentDetailsDeterminator, DocumentDetails) β a compile dependency of phorm-client, so available either way |
| Validation report model |
phive-result β resolved: this is the artifactId, confirmed as a compile dependency of phorm-client
|
Only add a dependency outside this set when nothing in it does the job. In particular: no second HTTP client, no second JSON library, no second collections library.
> TODO(verify): the full transitive dependency set and its licences once the module list is fixed β the SOAP/JAX-WS stack behind peppol-sml-client and the dnsjava dependency are the ones most likely to surprise on size or on Java 25.
Four modules, mirroring the -core / -cli split already used by kaltblut and norma:
| Module | Contains | Depends on |
|---|---|---|
peppol-cli-core |
All the actual work: identifier operations, discovery, SMP/SML/Directory/Phorm access, certificate checks, code list access. Returns typed result objects. No picocli, no System.out, no exit codes. | The libraries above |
peppol-cli-config |
Config resolution, file discovery, profiles, secret handling, redaction. | ph-config |
peppol-cli-output |
Renderers: the text renderer and the JSON envelope renderer. Turns core result objects into output. | ph-json |
peppol-cli-commands |
The picocli command tree. Parses arguments, calls core, hands results to output, maps failures to exit codes. Thin by design. | all of the above, picocli |
The point of the split is that the interesting logic is testable without a command line, and that the JSON contract lives in one module rather than being spread across command classes.
> TODO(decide): is four modules over-engineered for a tool this size? A single module with clean internal packages would build faster and is easier to navigate. The counter-argument is that peppol-cli-core is potentially reusable, and that the boundary is what stops output formatting leaking into logic.
Main class following the ecosystem's convention (com.helger.kaltblut.cli.KaltblutMain): com.helger.peppol.cli.PeppolCliMain.
> TODO(verify): confirm the intended base package and groupId (see [Naming](Naming)).
- Command results are objects, not print statements. Every core operation returns a typed result; rendering happens once, in the output module. This is what makes the text and JSON outputs consistent by construction.
- One exception-to-exit-code mapping, in the commands module. Core throws typed exceptions; nothing else decides an exit code.
-
--configbeforeConfigFactory. As noted in Configuration,ConfigFactoryreadsconfig.fileduring static initialisation, so the CLI builds its ownMultiConfigurationValueProviderrather than relying on that timing. -
Network profile values come from the libraries, via
EPeppolNetworkandESMLβ not from constants in this project. -
Startup cost is a feature requirement. Avoid eager classloading of the JAXB and SOAP stacks for commands
that do not need them; a
peppol pid validateshould not pay forpeppol-sml-client.
| Artefact | Purpose | Notes |
|---|---|---|
| Fat JAR | The single-file fallback that works everywhere |
maven-shade-plugin with a mainClass, as in kaltblut-cli and norma-cli. Watch for SPI/META-INF/services merging β peppol-commons uses SPIs for identifier validators and transport profiles, so the shade plugin needs ServicesResourceTransformer, or those registrations are silently lost. > TODO(verify): confirm that transformer is configured; this is a classic and quiet shading bug.
|
| Distribution archive |
.tar.gz and .zip with bin/peppol, bin/peppol.cmd, lib/, completion scripts, LICENSE and README |
maven-assembly-plugin. Launcher resolves JAVA_HOME, checks the Java version and prints a clear message if it is below 25. |
jlink image |
Self-contained archive with a bundled runtime | Removes the Java 25 prerequisite at the cost of size and per-platform builds. > TODO(decide): worth it, or is "install a JRE" acceptable?
|
| Container image | For CI use and for anyone without a Java runtime | In scope β see the Docker section below. Multi-stage build, JRE-only runtime stage, multi-arch. |
| Native image | Deliberately deferred β Open Questions #1. |
Ordered by effort. The pattern in every case is: one signed, versioned release archive plus its SHA-256, and each channel points at it.
| Channel | Effort | Notes |
|---|---|---|
| GitHub Releases | low | The source of truth every other channel consumes. Attach archives plus a SHA256SUMS file. |
| Maven Central | low | The fat JAR, consistent with the other projects. |
| Docker | low | Docker Hub under phelger/, matching phelger/phoss-ap. Arguably the single easiest way for a CI pipeline to use the tool, since it needs no Java runtime on the agent. See below. |
| Homebrew (own tap) | lowβmedium | The realistic first step. See below. |
| SDKMAN! | medium | Purpose-built for JVM CLI tools and a natural fit for this audience. > TODO(verify): SDKMAN's current vendor onboarding requirements.
|
| Scoop / WinGet | medium | Depends on the Windows support decision, Open Questions #8. |
| Homebrew core | high | Only worth attempting once the tool is established. See below. |
| Linux distro packages | high | Best left to distro maintainers; provide a clean tarball and they can. |
A container is a first-class channel here, not an afterthought: a CI agent can use the tool with no Java runtime installed, which removes the Java 25 prerequisite entirely for the audience most likely to script against it.
Image: phelger/peppol-cli on Docker Hub, following the existing phelger/phoss-ap convention.
> TODO(decide): also publish to GHCR (ghcr.io/phax/peppol-cli)? It costs one more push step and some users prefer it.
Build, matching the ecosystem's existing Dockerfiles (phorm, phase4-peppol-standalone): multi-stage, with
the Apache 2.0 licence header comment at the top. The runtime stage needs only a JRE β phorm already builds on a
JRE 25 base image, so current bases are available. A JRE-only or jlink-trimmed runtime stage keeps the image
small; > TODO(decide): eclipse-temurin:25-alpine, a jlink-produced runtime, or a distroless base?
Container-specific design points, which differ from the server images in this ecosystem because this is a short-lived CLI:
-
ENTRYPOINTis the executable,CMDis--help. That makesdocker run --rm phelger/peppol-cli smp lookup β¦read as the command it is, and a baredocker runself-documents. - Run as a non-root user. The tool writes nothing except its cache, and that only when asked.
-
Configuration by environment variable is the natural container idiom, and it is free:
ph-configalready mapspeppol.cli.networktoPEPPOL_CLI_NETWORK, sodocker run -e PEPPOL_CLI_NETWORK=prod β¦works with no extra code. Mounting a config directory read-only (-v ~/.config/peppol-cli:/config:ro) is the alternative for people with many settings. - Never bake key material into the image. Keystores are mounted read-only at run time. This must be stated explicitly in the documentation, because it is exactly the mistake people make.
-
Do not allocate a TTY when piping.
docker run -tcorrupts a piped stream, so--output json | jqrequiresdocker run --rmwithout-t. Colour and progress already switch off automatically when stdout is not a TTY, so the default behaviour is correct β but the footgun is common enough to document with a worked example. -
Exit codes pass through
docker rununchanged, which is what makes the container usable in CI. Our range (0β7, see CLI Conventions) stays clear of Docker's own reserved 125β127, so a pipeline can always tell a tool verdict from a container failure. -
Multi-arch
linux/amd64andlinux/arm64via buildx. A pure-JVM image is architecture-agnostic in content, so this is only about the base image. -
Tags:
X.Y.Z,X.Yandlatest. Documentation should recommend pinning an exact version in CI and treatlatestas a convenience for interactive use only. -
Document a shell alias, so people are not typing a long
docker runline repeatedly:alias peppol='docker run --rm -e PEPPOL_CLI_NETWORK -v "$HOME/.config/peppol-cli:/config:ro" phelger/peppol-cli'
- Image size is where a native image would pay off most β a native binary in a minimal base would be a fraction of a JVM image. That is a genuine argument for Open Questions #1, and a reason to keep the Dockerfile's runtime stage easy to swap.
A packaging test should run the built image and assert a known command's output and exit code, exactly as for the fat JAR β see Testing Strategy.
Two distinct paths, and they should not be confused.
Step 1 β own tap (recommended starting point). A repository named homebrew-tap under the same GitHub
account makes the tool installable as:
brew tap phax/tap
brew install peppol-cli
# or in one step
brew install phax/tap/peppol-cli
A tap has no notability requirements, no review queue and no gatekeeper, and it can be published the same day as the first release. What it takes:
- A public repository
homebrew-tap(thehomebrew-prefix is what makesbrew tap phax/tapwork). - A formula
Formula/peppol-cli.rbwith:desc,homepage,urlpointing at the release archive, itssha256,license "Apache-2.0", adepends_on "openjdk", aninstallblock that puts the JAR underlibexecand creates a wrapper inbin, atest doblock, and alivecheckblock sobrew livecheckcan spot new versions. - A release step that computes the SHA-256, updates
urlandsha256in the formula and commits it β trivially automatable from the release workflow.
Java-specific formula points to get right:
- The JAR must not be put directly in
bin. The convention is to install it intolibexecand generate a small wrapper script that invokesjava -jar, soopenjdkis found via Homebrew rather than the user'sPATH. Homebrew provides a helper for exactly this β> TODO(verify): the current helper name and signature (bin.write_jar_script, as used by existing Java formulae) against the Homebrew Formula Cookbook, rather than relying on memory. -
The Java 25 requirement is the main open risk.
depends_on "openjdk"tracks Homebrew's current default JDK, which is a moving target, and versioned formulae (openjdk@21,openjdk@17) only exist for some versions.> TODO(verify): whether Homebrew's openjdk formula is currently at 25 or later, and whether an openjdk@25 exists. If neither, the choice is to relax the baseline for the Homebrew build, ship a jlink image as a cask, or wait. - Completion scripts install via
bash_completion.install/zsh_completion.install/fish_completion.install, which is whypeppol completion <shell>should be able to write them at build time. - Both
arm64andx86_64macOS must work. A pure-Java formula is architecture-independent, which is one real advantage of not going native first.
Step 2 β homebrew-core (later, optional). Getting into homebrew-core means brew install peppol-cli with no
tap, but the bar is much higher:
-
Notability. homebrew-core declines formulae for projects without a track record; the documented guideline
is a threshold of GitHub stars, forks and watchers.
> TODO(verify): the current thresholds in Homebrew's "Acceptable Formulae" document β they have changed over time and should not be quoted from memory. - A stable, versioned release (not a snapshot, not a git HEAD build), with a stable archive URL.
- An open source licence Homebrew accepts β Apache 2.0 qualifies.
- No duplication of an existing formula, and it must not be trivially replaceable by a one-line script.
- It must pass
brew audit --strict --online --newand the CI build (brew test-bot), which includes a meaningfultest doblock. A good test here: runpeppol pid validateon a known-good identifier and assert the output, since that needs no network. -
Maintenance obligation. Once in core, every release should be followed by a
brew bump-formula-pr. That is an ongoing commitment, which is the real reason to start with a tap.
Practical sequence: ship releases β publish the tap β let usage accumulate β only then consider core.
The full plan β six test tiers from hermetic unit tests to manually-approved live write tests, fixture discipline, and the golden-file tests that make the JSON contract enforceable β is in Testing Strategy. It is planned as part of development from M1, not added afterwards.
Two architectural consequences worth stating here, because they constrain the module split above:
-
peppol-cli-coremust be usable without a command line and without the network, or T1 and T2 are impossible. That is the main justification for keeping it separate frompeppol-cli-commands. - The stubs (SMP, DNS resolver, Phorm) belong in a shared test-support module, so every tier can reuse them.
Several features in the Feature Catalogue need capabilities the libraries do not currently have β a GLN check-digit validator, a Directory search client, a reusable Phorm client, runtime code list access for diffing, and per-user config discovery. They are catalogued with their verified evidence and their proper home in Library Gaps. Most belong upstream rather than in this project.