Second field report on 0.20.0's winget path, and independent of the conflict
abort in 37781a61: an INTERACTIVE install through UniGetUI dies before the
wizard with Inno's "Error creating log file: The filename, directory name, or
volume label syntax is incorrect."
`<LOGPATH>` is winget's own token and the only spelling it replaces — the
schema this manifest declares says so verbatim ("<LOGPATH> token can be included
in the switch value so that winget will replace the token with user provided
path"). We shipped `|LOGPATH|`, which is not a token, so winget passed the
literal string to Inno; `|` is not legal in a Windows filename, and Inno refuses
before doing anything else.
Worse than the conflict abort in two ways: it is not confined to silent installs
— it fires in EVERY mode for any caller that requests a log — and UniGetUI
requests one by default, so a GUI user cannot install at all. Confirmed against
the live source, which is serving the broken switch to everyone right now:
curl -s https://winget.punktfunk.unom.io/packageManifests/unom.PunktfunkHost
→ "Log": "/LOG=\"|LOGPATH|\""
Guarded by the suite that exists for exactly this class of defect (a wrong
response shape does not fail loudly — winget just says "no package found"). The
new check accepts an absent Log switch, requires <LOGPATH> when present, and
rejects any |TOKEN| spelling. Mutation-verified: restoring |LOGPATH| fails the
check naming the offending value, and the corrected manifest passes 29/29.
⚠ Fixing the template does NOT fix the live catalogue: it is derived from the
manifest trio attached to each release, so the corrected switch only reaches
users when a release carries it.
Punktfunk winget REST source
A self-hosted winget REST source serving the
Punktfunk Windows host, so users can install and upgrade with winget instead of downloading a
setup.exe by hand.
# one-time, from an ELEVATED prompt (winget requires admin to add a source)
winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest
winget install unom.PunktfunkHost # silent, wizard defaults
winget install unom.PunktfunkHost --interactive # the full wizard
winget upgrade unom.PunktfunkHost
Why self-hosted rather than the community repo
microsoft/winget-pkgs gates submissions on its Binary-Validation-Error /
Validation-Defender-Error checks, and the host installer is currently signed with a self-signed
cert (CN=unom). That is a pre-existing condition — winget does not sign anything, so SmartScreen
behaves identically whether the installer arrives by browser or by winget — but it does block that
route until a publicly trusted cert is in place. A self-hosted source has no such gate, can carry
Agreements (verified-developers-only upstream), and can serve channels the community repo would
never accept.
What it implements
The winget client consumes exactly three endpoints. The reference implementation's other twenty
(/packages/**) are its admin API for mutating a CosmosDB; a catalogue generated at release
time has nothing to mutate, so they are not implemented.
| Endpoint | Notes |
|---|---|
GET /information |
Source identity, ServerSupportedVersions, declared capabilities. |
POST /manifestSearch |
Query / Inclusions (OR) / Filters (AND) / FetchAllManifests. 204 on no match — not an empty 200. |
GET /packageManifests/{id} |
Full manifest; honours ?Version=. Identifier match is case-insensitive. |
GET /healthz |
Not part of the spec — liveness for compose. |
NormalizedPackageNameAndPublisher is deliberately declared unsupported. winget derives it
client-side from ARP entries with its own normalization (case folding plus stripping legal suffixes,
punctuation and version-like tails); claiming support would mean reimplementing that exactly, and a
near-miss silently mis-correlates an installed host. Correlation rides on ProductCode instead,
which Inno gives us exactly (<AppId>_is1).
Layout
| File | Role |
|---|---|
handler.mjs |
The three endpoints. Pure (Request, catalogue) -> Response, no I/O, no deps. |
server.mjs |
node:http front. Loads the catalogue, reloads on mtime change. |
build-data.mjs |
Generates data/data.json from the published release manifests. Build-time only. |
test.mjs |
28 checks driving handler.mjs directly. |
compose.production.yml |
The unom-1 service. |
How it runs
Gitea releases (manifest trio per stable tag)
│ build-data.mjs ← walks ALL releases, no local state [CI, Linux job]
▼
data/data.json (~8 KB) ← rsynced to unom-1 on each stable tag
▼
bun + server.mjs on unom-1:3240 ← edge Caddy TLS ← winget.punktfunk.unom.io
Stock oven/bun image with the two .mjs files bind-mounted — the same shape as the flatpak server
(stock caddy:2-alpine + a bind-mounted Caddyfile). There is no image to build, publish or pull, so
a code change deploys by scp plus docker compose up -d.
The server has zero dependencies: it reads a generated JSON file. Parsing YAML and talking to
Gitea is build-data.mjs's job, and that runs in CI. Only that build step needs node_modules.
Content and config deploy separately, matching the flatpak repo:
deploy-services.yml(wingetjob) shipscompose.production.yml+ the two.mjsfiles.windows-host.yml(winget-sourcejob) builds and shipsdata/data.jsonon each stable tag.
server.mjs reloads the catalogue when its mtime changes, so publishing a release needs no restart.
A half-written file mid-rsync keeps the previous catalogue rather than taking the source down.
Why the catalogue is derived from releases
build-data.mjs walks the Gitea releases rather than reading local files. That is what makes
winget install --version and winget upgrade work: winget resolves both against the version
list, so a source that only knew the newest release could neither pin an older version nor show an
upgrade path from one.
It also means the source holds no state — re-running the build reproduces the same data.json, so a
lost deployment is one command away and nothing can drift.
Local development
npm install
npm run build:local # data/data.json from ../*.yaml (single version — fine for dev)
npm test # 28 checks, no network, no server
npm start # http://localhost:3240
npm test is the gate that matters. A malformed response does not fail loudly — winget reports "no
package found", so a shape regression looks like a missing package rather than a broken source. CI
runs it before anything reaches the box.
Note a real winget source add will not accept a local instance: it requires HTTPS with a publicly
trusted certificate, which is what the edge vhost provides in production.
First-time setup
-
DNS —
winget.punktfunk.unom.io→ the unom-1 hcloud box, in theunom.ioCloudflare zone (DNS-only, same asdocs). ✅ done -
Caddy vhost on unom-1, next to the existing
docs.punktfunk.unom.ioblock:winget.punktfunk.unom.io { reverse_proxy 127.0.0.1:3240 }Until this exists the hostname resolves but the TLS handshake fails — Caddy has no certificate for a name it does not serve. That is the expected state, not a broken deploy.
-
Run
deploy-services.ymlto place the compose file and start the container. It serves 503 until a catalogue exists — expected, and visible on/healthz. -
Publish a stable tag, or build and ship the catalogue by hand:
cd packaging/winget/server && npm install && npm run build && npm test
scp data/data.json <deploy-user>@<unom-1>:~/unom-winget/data/data.json
Then verify from anywhere:
curl -s https://winget.punktfunk.unom.io/information | jq .
curl -s -X POST https://winget.punktfunk.unom.io/manifestSearch \
-H 'content-type: application/json' -d '{"FetchAllManifests":true}' | jq '.Data[].PackageIdentifier'
Releases published before winget support shipped have no manifests attached, so
npm run buildskips them. Until the first stable tag lands with them, build with--local ..to serve the checked-in manifests, or backfill the manifest trio onto an existing release.