* feat: one semver implementation for the packaging stack
Version handling was duplicated across the packaging repos and the copies
disagreed with each other and with the spec. This makes logos-package the
single home for it.
Precedence is defined by SemVer 2.0.0, so it is delegated to a vendored
library (z4kn4fein/cpp-semver v0.4.0, MIT, single header, C++17):
- numeric pre-release identifiers compare NUMERICALLY, so 1.0.0-rc.2 <
1.0.0-rc.11. Every previous copy got this wrong -- the downloader
compared the whole pre-release tag as one ASCII string, while lgpm and
the package-manager UI dropped the tag entirely (atoi("0-rc1") == 0),
so a pre-release compared EQUAL to its own release.
- build metadata is ignored for precedence (spec 10).
Ranges (^ ~ x * || >= <= > < =) are NOT in the semver spec -- they are an
npm convention the manifests already use -- so that layer is written once,
in include/logos/semver.hpp, on top of the library. It adopts npm's
pre-release rule: a range never matches a pre-release unless the range
itself names one at the same major.minor.patch. Without it `^1.0.0` matches
`2.0.0-alpha`, i.e. an unreleased alpha of the next major satisfies a caret
range on 1.x and can be resolved as a dependency.
Exposed three ways so every consumer reaches the same code:
- include/logos/semver.hpp, header-only and dependency-free (the
package-manager UI is a QML plugin with no native link deps and must be
able to include it without dragging in zlib/ICU/libsodium).
- the lgx_semver_* C ABI.
- `lgx semver compare|sort|satisfies|valid|valid-range`, for the catalog
builder (logos-modules-release-tool's index.py), which is stdlib-only
but already requires lgx -- so it can order versions without growing a
second implementation in Python that could drift.
Manifest range validation now delegates here too; it was a separate regex
that accepted ranges the resolver could not actually evaluate.
Validation is strict but comparison is lenient (parse vs parse_lenient):
real manifests carry partial versions like "1.0", and treating those as
invalid at compare time would sort them below every real version and
silently reorder existing catalogs.
tests/test_semver.cpp covers the spec's own precedence chain verbatim, the
pre-release cases that were previously untested everywhere, and the npm
range rules.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ci: print full nix build logs
Without -L a failing build is truncated to its last 25 lines, which hides
the first compiler error.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix: include <cstdint> before cpp-semver (Linux/libstdc++ build)
cpp-semver 0.4.0 uses uint64_t throughout but never includes <cstdint>
itself. libc++ drags it in transitively via <string>/<regex>, so this built
on macOS -- but libstdc++ stopped doing that in GCC 13, and on Linux the
header failed to parse with "'uint64_t' does not name a type". That
collapsed semver::version, which surfaced as a wall of bogus "has no member
named 'major'" errors pointing at our own header rather than the real cause.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat: add a headers-only package output
For consumers that need the shared semver implementation
(include/logos/semver.hpp) but must NOT link liblgx.
The package-manager UI is a Qt plugin, and the module builder copies every
*.so/*.dylib an external library ships into the plugin's output lib/.
ui-host then scans that directory and tries to load each file as a Qt
plugin: pointing it at the `lib` output put liblgx.dylib there, ui-host
failed with "is not a Qt plugin", and the entire UI never rendered.
Shipping no library at all is what makes `headers` safe -- there is nothing
to copy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix: reject silently-widening ranges; install cpp-semver header via FetchContent
Addresses Copilot review on #30.
valid_range accepted '1.x.3', '1..2' and 'x.1', silently widening them to
'1.x' / '1' / '*' — a range claiming more than it means, which breaks the
syntax-validation contract. parse_partial now requires every component after
the first wildcard/empty one to also be a wildcard/empty; trailing wildcards
('1.2.x', '1.x') stay valid. Tests added.
The FetchContent path forced SEMVER_INSTALL off, so a downstream
'cmake --install' would ship logos/semver.hpp without the <semver/semver.hpp>
it includes. Install the fetched header alongside ours. (The find_package /
Nix path already vendors it.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix: reject a non-SemVer package version at validate/verify time
Manifest::validate() only checked that 'version' was non-empty, never that it
was a valid version. So a package versioned '0.1.2.3' (four sections), 'v1.0.0'
or '1.0' passed 'lgx verify', got published, and only misbehaved later: it's
unparseable to the comparators, so it sorts BELOW every valid version (can
never be 'latest') and orders against other junk by raw byte comparison
(0.1.2.10 < 0.1.2.9).
validate() now requires a full SemVer 2.0.0 version via the shared
logos::semver::valid(), so 'lgx verify' fails loudly at build/publish time
instead. Confirmed end-to-end: a package with version 0.1.2.3 now fails with
"'version' is not a valid SemVer 2.0.0 version".
Every real module in the workspace already uses X.Y.Z, so nothing is broken.
This closes the gap the comparison layer only worked around: comparison stays
lenient (it must tolerate whatever is already in old catalogs), but validation
gates what new packages may ship.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
13 KiB
LGX
Usage
Create a Package
Create a new skeleton package:
lgx create mymodule
# Creates mymodule.lgx
Add Variants
Add a single file to a variant:
lgx add mymodule.lgx --variant linux-amd64 --files ./build/libfoo.so
Add a directory to a variant (usually requires --main):
lgx add mymodule.lgx --variant web --files ./dist --main index.js
For type == "ui_qml" packages, view is the required QML entry point and
main is optional backend metadata, so directory variants can be added without
--main when there is no backend plugin. Use --view to set the QML entry
point in the manifest:
lgx add mymodule.lgx --variant darwin-arm64 --files ./dist --view qml/Main.qml
Note: If a variant already exists, it is completely replaced (no merging). Use -y to skip confirmation:
lgx add mymodule.lgx -v linux-amd64 -f ./new-build/libfoo.so -y
Remove a Variant
lgx remove mymodule.lgx --variant linux-amd64
Verify a Package
Validate a package against the LGX specification:
lgx verify mymodule.lgx
# Use a custom keyring for trust lookup
lgx verify mymodule.lgx --keyring-dir /etc/logos/trusted-keys
Always verifies content hashes (Merkle tree) match the actual package contents. If the package is signed, also checks the Ed25519 signature and reports whether the signer's DID is in the trusted keyring.
Exit code is 0 on success and non-zero on validation failure, so it's safe to
use in scripts and CI pipelines (set -e, gh workflow jobs, etc.).
Inspect a Package's Manifest
Read the manifest of a package without unpacking it:
# Human-readable summary: name, version, type, category, author,
# root hash, variants (keys of manifest.main), dependencies, and the
# signer DID when the package is signed.
lgx manifest mymodule.lgx
# Raw manifest JSON bytes — byte-identical to the manifest.json
# file inside the .lgx. Intended for tooling (e.g. the release CI
# pipeline) that needs to capture the exact manifest verbatim.
lgx manifest mymodule.lgx --json > manifest.json
The --json output is the source of truth consumed by
logos-modules-release-action when it extracts the name / version
to use for the GitHub release tag — feeding it back into other tools is
expected.
Exit code is non-zero if the package or its manifest is missing or malformed; the error message goes to stderr.
Inspect a Package's Signature
For tooling that needs the raw manifest.sig JSON (e.g. an out-of-CI
index builder that reproduces what logos-modules-release-action records
under sidecar.json#signature):
# Raw manifest.sig bytes — byte-identical to the file inside the .lgx.
lgx signature mymodule.lgx > manifest.sig
# Or pipe straight to jq for the signer DID (signed packages only —
# unsigned ones print nothing, and the empty stream makes jq error
# out with a non-zero exit; gate the pipeline accordingly in scripts).
lgx signature mymodule.lgx | jq -r .did
Unsigned packages produce no output and exit 0 — callers tell "no signature" apart from "error" by checking the exit status, not the stream length. Bad/missing packages exit non-zero with the error on stderr.
Generate a Signing Key
lgx keygen --name my-key
# Creates ~/.config/logos/keys/my-key.jwk (secret, JWK format)
# ~/.config/logos/keys/my-key.pub (SSH format)
# ~/.config/logos/keys/my-key.did (DID string)
# Prints the did:jwk:... DID to stdout
# Use a custom output directory
lgx keygen --name ci-key --output-dir /etc/logos/keys
Sign a Package
lgx sign mymodule.lgx --key my-key
lgx sign mymodule.lgx --key my-key --name "My Organization" --url "https://example.com"
# Use keys from a custom directory
lgx sign mymodule.lgx --key ci-key --keys-dir /etc/logos/keys
Signing validates the package, then creates manifest.sig with the signer's DID
(did:jwk:...), an Ed25519 signature over the manifest bytes, and optional signer metadata.
Manage Trusted Keys
# Add a trusted key by DID
lgx keyring add publisher-name did:jwk:eyJjcnYi... --display-name "Publisher" --url "https://..."
# List trusted keys
lgx keyring list
# Remove a trusted key
lgx keyring remove publisher-name
# Use a custom keyring directory
lgx keyring list --dir /etc/logos/trusted-keys
lgx keyring add ci-signer did:jwk:eyJj... --dir /etc/logos/trusted-keys
Trusted keys are stored as .json files in the keyring directory (default: ~/.config/logos/trusted-keys/).
Merge Packages
Merge multiple single-variant .lgx packages into one multi-variant package:
lgx merge linux.lgx darwin.lgx -o mymodule.lgx
All input packages must have identical manifests (except for the variant-specific main field). Fails on duplicate variants unless --skip-duplicates is used:
lgx merge pkg1.lgx pkg2.lgx pkg3.lgx --skip-duplicates -o mymodule.lgx -y
Inspect Package Contents
Since .lgx files are just tar.gz archives:
tar -tzf mymodule.lgx
Command Reference
| Command | Description |
|---|---|
lgx create <name> |
Create a new skeleton package |
lgx add <pkg> --variant <v> --files <path> [--main <relpath>] [--view <relpath>] [-y] |
Add files to a variant |
lgx remove <pkg> --variant <v> [-y] |
Remove a variant |
lgx extract <pkg> [--variant <v>] [--output <dir>] |
Extract variant contents |
lgx merge <pkg1> <pkg2> ... [-o <output>] [--skip-duplicates] [-y] |
Merge packages into one |
lgx verify <pkg> [--keyring-dir <dir>] |
Validate package structure and signature |
lgx manifest <pkg> [--json] |
Print the embedded manifest.json (human-readable or raw bytes) |
lgx signature <pkg> |
Print the raw manifest.sig bytes (unsigned → empty + exit 0) |
lgx sign <pkg> --key <name> [--keys-dir <dir>] [--name "..."] [--url "..."] |
Sign package with Ed25519 key and DID identity |
lgx keygen --name <name> [--output-dir <dir>] |
Generate an Ed25519 signing keypair (outputs DID) |
lgx keyring add|remove|list [--dir <dir>] |
Manage trusted keys (by DID) |
lgx semver compare|sort|satisfies|valid|valid-range |
Compare, sort and range-match versions (see below) |
lgx publish <pkg> |
Publish package (TODO) |
Semver
lgx owns the one semver implementation for the whole packaging stack. lgpm,
lgpd and the package-manager UI all include it directly
(include/logos/semver.hpp); everything else reaches it through lgx semver.
That matters most for the catalog builder
(logos-modules-release-tool's
index.py), which orders each package's versions[] — it shells out here
rather than reimplementing semver in Python, so the catalog can never disagree
with the clients about which version is newest.
Precedence is SemVer 2.0.0 §10–§11 exactly:
a pre-release ranks below its own release, numeric pre-release identifiers compare
numerically (1.0.0-rc.2 < 1.0.0-rc.11), and build metadata is ignored.
lgx semver compare 1.0.0-rc.2 1.0.0-rc.11 # -> -1
lgx semver sort --desc 1.0.0 2.0.0-alpha 1.9.0
lgx semver satisfies 1.5.0 '^1.0.0' # exit 0
lgx semver satisfies 2.0.0-alpha '^1.0.0' # exit 1 — see the pre-release rule below
lgx semver valid 1.0.0 # exit 0
lgx semver valid-range '>=1.0 <2.0' # exit 0
Ranges are npm's dialect, not the spec's — SemVer 2.0.0 defines precedence
but says nothing about ^ ~ x * ||. Supported: ^, ~, =, >, >=, <,
<=, x/X/* wildcards, whitespace conjunction, || alternation. Hyphen
ranges (1.2.3 - 2.3.4) are not supported and are rejected rather than
misread.
Also following npm: a range never matches a pre-release unless the range
itself names one at the same major.minor.patch. So ^1.0.0 does not match
2.0.0-alpha — without that rule an unreleased alpha of the next major
satisfies a caret range on 1.x — while ^1.0.0-rc.1 still matches 1.0.0-rc.2.
Package Structure
mymodule.lgx (tar.gz)
├── manifest.json # Package metadata
├── manifest.sig # Optional - Ed25519 signature with DID identity
├── variants/
│ ├── linux-amd64/
│ │ └── libfoo.so
│ ├── darwin-arm64/
│ │ └── libfoo.dylib
│ └── web/
│ └── index.js
├── docs/ # Optional
└── licenses/ # Optional
Manifest schema
Current manifestVersion is 0.3.0 (introduced support for richer
dependency entries — see below). Tooling reads both 0.2.x and 0.3.x
manifests; new packages produced by lgx create use 0.3.0.
{
"manifestVersion": "0.3.0",
"name": "mymodule",
"version": "1.2.3",
"description": "...",
"author": "...",
"type": "core", // "core" | "ui" | "ui_qml" | "library"
"category": "...",
"icon": "icon.png",
"view": "qml/Main.qml", // required for type == "ui_qml"
"main": { // variant -> entry path
"linux-amd64": "libfoo.so",
"darwin-arm64": "libfoo.dylib"
},
"dependencies": [ // see "Dependency entries" below
"waku_module",
{"name": "core_lib", "version": "^1.2.0"},
{"name": "secure_store", "version": ">=0.5.0", "signer": "did:jwk:..."}
],
"hashes": { // Merkle tree, recomputed on save
"root": "...",
"variants/linux-amd64": "...",
"...": "..."
}
}
The full schema (field constraints, completeness rules, ui_qml
contract) lives in docs/spec.md.
Dependency entries
Each dependencies[] element is one of:
- Plain string (legacy 0.2.x form, still supported):
"waku_module"is equivalent to{"name": "waku_module"}— "any version, any signer". - Object with:
name(string, required) — canonical lowercase package name.version(string, optional) — npm/Cargo-style semver range:^1.2.0,~1.2.3,>=1.2 <2.0,1.2.x,*,||for alternatives. Absent ⇒ any version.signer(string, optional) —did:jwk:...identifying the trusted publisher. Absent ⇒ any signer. When set, only packages whosemanifest.sigwas produced by that DID match — useful for disambiguating same-named packages from different publishers.
lgx verify syntactically validates that version parses as a
semver range and that signer matches the did:jwk: shape; the
semantic resolve (does the constraint actually find a candidate?)
happens client-side in logos-package-downloader. Both use the same
implementation (see Semver) — validation used to be a
separate regex here, which accepted ranges the resolver could not
actually evaluate.
Note the pre-release rule: "version": "^1.2.0" will not resolve to
2.0.0-alpha or 1.5.0-beta.1. To depend on a pre-release, name one:
"^1.2.0-rc.1".
Example Workflow
# Create package
lgx create mylib
# Add variants
lgx add mylib.lgx -v linux-amd64 -f ./out/linux/libmylib.so -y
lgx add mylib.lgx -v darwin-arm64 -f ./out/macos/libmylib.dylib -y
# Verify
lgx verify mylib.lgx
# Inspect the manifest (name, version, variants, dependencies, signer)
lgx manifest mylib.lgx
# Optional: sign the package
lgx keygen --name my-key
lgx sign mylib.lgx --key my-key --name "My Org" --url "https://example.com"
# Inspect the archive contents
tar -tzf mylib.lgx
# Extract for use
lgx extract mylib.lgx --variant linux-amd64 --output ./extracted
Building
Nix (Recommended)
The recommended way to build lgx is using Nix, which automatically handles all dependencies:
Binary
nix build '.#lgx'
The binary will be available at ./result/bin/lgx.
Library
To build the library (.so/.dylib/.dll):
nix build '.#lib'
The library will be available at:
./result/lib/liblgx.dylib(macOS)./result/lib/liblgx.so(Linux)./result/lib/lgx.dll(Windows)
The C API header will be at ./result/include/lgx.h.
Running Tests with Nix
Tests run automatically during nix build. The build will fail if any tests do not pass.
Note: If you haven't enabled flakes, you'll need to add the experimental features flag:
nix --extra-experimental-features "nix-command flakes" build '.#lgx'
Or enable flakes permanently in your Nix configuration by adding to ~/.config/nix/nix.conf:
experimental-features = nix-command flakes
CMake
If you prefer not to use Nix, you can build with CMake directly.
Prerequisites
- CMake 3.16+
- C++17 compiler (GCC 8+, Clang 7+, MSVC 2019+)
- zlib
- ICU
macOS (Homebrew)
brew install cmake icu4c
Ubuntu/Debian
sudo apt install cmake libicu-dev zlib1g-dev
Building
mkdir build
cd build
cmake ..
make -j$(nproc)
The lgx executable will be created in the build/ directory.
Building the Library
To build the library:
mkdir build
cd build
cmake .. -DLGX_BUILD_SHARED=ON
make -j$(nproc)
This will create:
build/liblgx.dylib(macOS) orbuild/liblgx.so(Linux) orbuild/lgx.dll(Windows)- The C API header is at
src/lgx.h
Building with Tests
mkdir build
cd build
cmake .. -DLGX_BUILD_TESTS=ON -DLGX_BUILD_SHARED=ON
make -j$(nproc)
Running Tests
cd build
ctest --output-on-failure