// Build src/data.json — the payload the Worker serves — from published winget manifests. // // Source of truth is the Gitea RELEASES, not local files: windows-host.yml attaches the manifest // trio to each stable tag, so walking releases yields every version the source should offer. That // matters because winget resolves `--version` and `upgrade` against the version LIST; a source that // only knows the newest release can neither pin an older one nor show an upgrade path from it. // // Deriving from releases also means this script holds no state. Re-running it reproduces the same // data.json, so a lost deployment is one command away and there is no accumulated file to drift. // // node build-data.mjs # from Gitea releases (what CI runs) // node build-data.mjs --local ../ # from a local manifest dir (dev, single version) // node build-data.mjs --out src/data.json // // The `yaml` dep is a BUILD-time dependency only — the Worker ships data.json, never a parser. import { readFileSync, writeFileSync, readdirSync, mkdirSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { parse as parseYaml } from "yaml"; const HERE = dirname(fileURLToPath(import.meta.url)); const API = process.env.PF_GITEA_API ?? "https://git.unom.io/api/v1"; const REPO = process.env.PF_GITEA_REPO ?? "unom/punktfunk"; const PACKAGE_ID = "unom.PunktfunkHost"; const SOURCE_IDENTIFIER = process.env.PF_SOURCE_ID ?? "unom.punktfunk"; // The API contract this source implements. Advertised via /information so a future client can // negotiate; bump only alongside a real change to the response shapes. const SERVER_SUPPORTED_VERSIONS = ["1.1.0"]; const args = process.argv.slice(2); const argVal = (flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : undefined; }; const outPath = resolve(HERE, argVal("--out") ?? "src/data.json"); const localDir = argVal("--local"); /** One version's three manifests -> the shape the Worker serves. */ function toVersionEntry(installer, locale, productCodeFallback) { // The REST manifest nests DefaultLocale + Installers under each version, whereas the YAML splits // them across files keyed by PackageVersion. Strip the per-file bookkeeping that does not belong // in the merged object. const { PackageIdentifier: _i, PackageVersion, ManifestType: _t, ManifestVersion: _v, Installers, ...installerRest } = installer; const { PackageIdentifier: _i2, PackageVersion: _pv, ManifestType: _t2, ManifestVersion: _v2, PackageLocale, ...localeRest } = locale; // Installer-level fields are inherited by each entry in Installers unless overridden there. The // REST shape has no inheritance, so fold the parent down into every installer. const installers = (Installers ?? []).map((inst) => ({ ...installerRest, ...inst })); return { PackageVersion, DefaultLocale: { PackageLocale, ...localeRest }, Installers: installers, ProductCodes: [installer.ProductCode ?? productCodeFallback].filter(Boolean), }; } async function fetchJson(url) { const res = await fetch(url, { headers: { accept: "application/json" } }); if (!res.ok) throw new Error(`GET ${url} -> ${res.status} ${res.statusText}`); return res.json(); } async function fetchText(url) { const res = await fetch(url); if (!res.ok) throw new Error(`GET ${url} -> ${res.status} ${res.statusText}`); return res.text(); } async function versionsFromReleases() { const releases = await fetchJson(`${API}/repos/${REPO}/releases?limit=100`); const out = []; for (const rel of releases) { if (rel.draft) continue; const assets = rel.assets ?? []; const find = (suffix) => assets.find((a) => a.name === `${PACKAGE_ID}${suffix}`)?.browser_download_url; const installerUrl = find(".installer.yaml"); const localeUrl = find(".locale.en-US.yaml"); // Releases from before winget support shipped have no manifests — skip, don't fail. if (!installerUrl || !localeUrl) continue; const [installer, locale] = await Promise.all([ fetchText(installerUrl).then(parseYaml), fetchText(localeUrl).then(parseYaml), ]); out.push({ entry: toVersionEntry(installer, locale), locale, prerelease: rel.prerelease }); console.log(` + ${rel.tag_name} (${installer.PackageVersion})`); } return out; } function versionsFromLocal(dir) { const d = resolve(HERE, dir); const names = readdirSync(d); const read = (suffix) => { const n = names.find((x) => x === `${PACKAGE_ID}${suffix}`); if (!n) throw new Error(`missing ${PACKAGE_ID}${suffix} in ${d}`); return parseYaml(readFileSync(join(d, n), "utf8")); }; const installer = read(".installer.yaml"); const locale = read(".locale.en-US.yaml"); console.log(` + local (${installer.PackageVersion})`); return [{ entry: toVersionEntry(installer, locale), locale, prerelease: false }]; } /** Newest first, so winget's "latest" is the head of the list. */ function compareVersionsDesc(a, b) { const parts = (v) => v.PackageVersion.split(/[.\-+]/).map((p) => (/^\d+$/.test(p) ? Number(p) : p)); const [pa, pb] = [parts(a), parts(b)]; for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const x = pa[i] ?? 0; const y = pb[i] ?? 0; if (x === y) continue; // A numeric segment outranks a string one (0.20.0 > 0.20.0-rc1). if (typeof x === typeof y) return x > y ? -1 : 1; return typeof x === "number" ? -1 : 1; } return 0; } console.log(localDir ? `building from local dir ${localDir}` : `building from ${API}/repos/${REPO} releases`); const collected = localDir ? versionsFromLocal(localDir) : await versionsFromReleases(); if (collected.length === 0) { throw new Error("no winget manifests found - refusing to write an empty source"); } const versions = collected.map((c) => c.entry).sort(compareVersionsDesc); // Package-level search metadata comes from the NEWEST release's locale manifest, so a rename or a // retagged description takes effect without rewriting history. const newestLocale = collected.sort((a, b) => compareVersionsDesc(a.entry, b.entry))[0].locale; const data = { sourceIdentifier: SOURCE_IDENTIFIER, serverSupportedVersions: SERVER_SUPPORTED_VERSIONS, packages: [ { PackageIdentifier: PACKAGE_ID, PackageName: newestLocale.PackageName, Publisher: newestLocale.Publisher, Moniker: newestLocale.Moniker ?? null, Tags: newestLocale.Tags ?? [], // Union across versions: an installed 0.19.2 must still correlate after 0.20.0 ships. ProductCodes: [...new Set(versions.flatMap((v) => v.ProductCodes))], Versions: versions.map((v) => ({ PackageVersion: v.PackageVersion, ProductCodes: v.ProductCodes, })), Manifest: { PackageIdentifier: PACKAGE_ID, Versions: versions }, }, ], }; mkdirSync(dirname(outPath), { recursive: true }); writeFileSync(outPath, `${JSON.stringify(data, null, 2)}\n`); console.log(`wrote ${outPath} (${versions.length} version(s): ${versions.map((v) => v.PackageVersion).join(", ")})`);