Compare commits

..

13 Commits

Author SHA1 Message Date
AJ ONeal
785dc05324 build: add deploy-webinstall script and document cache-only architecture
- _scripts/deploy-webinstall: rsync-based deploy to beta.webi.sh and
  next.webi.sh that excludes _cache, restarts the webinstall service via
  serviceman (sourcing ~/.config/envman/PATH.env so serviceman is on
  PATH for non-interactive ssh). Uses an end-of-line anchored process
  match so only the node worker is touched, never its supervisor.
- AGENTS.md: document the cache-only Node server (two paths, canonical
  os/arch/libc/ext vocabulary), add a domains table for prod/beta/next,
  remove stale normalize.js references.
- .gitignore: ignore agent session files (LOCAL.md, agents/, etc) and
  local test fixtures (testdata/).
2026-05-16 21:48:37 -06:00
AJ ONeal
73188d50e1 test: add cache-only validation and live-comparison suites
New test programs that exercise the cache-only Node server path and
compare its output against the legacy upstream-fetching path:

- test-cache-api-ready.js: pre-flight check that the cache file matches
  what /api/releases would return for each name in _cache/<yyyy-mm>/.
- test-cache-compat.js: parameterized cache vs. fresh-fetch diff suite,
  walks every package and OS/arch combo.
- test-api-compat.js, test-installer-resolve.js, test-broad-resolve.js:
  installer-side parity checks across the resolver.
- test-live-compare.js, test-live-cache-diff.js,
  test-live-installer-diff.js: TSV-output sweeps comparing two remote
  URLs across the cached package x OS/arch matrix; supports
  --concurrency and --packages filters.
- test-fleet-diff.js: fleet-wide TSV diff with package/OS/arch
  filtering, used to validate beta and next.

Also adds 36 golden snapshots under _webi/testdata/live_*.json covering
6 packages (bat, caddy, go, jq, node, rg) x 4 OS/arch combos plus the
unfiltered baseline per package.
2026-05-16 21:47:53 -06:00
AJ ONeal
bf5cafac18 feat(ffmpeg): add ffmpegdist classifier for eugeneware/ffmpeg-static
Upstream uses non-standard OS/arch names (x64, ia32, win32, arm) and
ships both bare binaries and .gz-compressed copies. classifyFFmpegDist
maps those to canonical names and keeps only bare binaries.

Also adds source-override logic to installerconf so that
github_releases + source = ffmpegdist works: GitHub is used for
fetching while the custom classifier handles classification.
2026-05-16 21:44:45 -06:00
AJ ONeal
1e499ed6c8 fix(webicached): use hardened httpclient for upstream API calls
Replaces the inline &http.Client{Timeout: 30s} with httpclient.New(),
which enforces TLS 1.2+, per-level timeouts, no HTTPS→HTTP redirect
downgrade, connection pooling, and automatic retry with backoff.

The delayTransport (page-delay flag) now wraps httpclient's transport
instead of http.DefaultTransport, preserving all security properties.
2026-05-16 21:44:45 -06:00
AJ ONeal
f638a25529 fix(webicached): use full gittag fetch for first-time supplementary clones
When a package has a git_url but uses a non-gittag source, the
supplementary git clone was always shallow. For packages never cloned
before, a shallow clone may miss older tags that clients need.

Now: check whether the _gittag raw cache is already populated. If it is,
reuse the shallow flag (fast refresh). If it is not, force a full clone
so all tags are available from the first fetch.

The --shallow flag (global) still overrides this so operators can cap
fetch depth when needed.
2026-05-16 21:44:45 -06:00
AJ ONeal
95418b1023 feat(webicached): rescan conf dir each batch, prioritize new packages
Rescans the conf directory at the start of each batch loop so new
{pkg}/releases.conf files dropped on disk are picked up without a restart.

Also runs a rescan after each individual package refresh mid-batch. If a
new conf is discovered, the inner loop breaks immediately so the outer
loop recomputes staleness — new packages have a zero timestamp and sort
to position 1, meaning they are fetched in the very next slot.
2026-05-16 21:44:45 -06:00
AJ ONeal
f66822295b chore: go mod tidy 2026-05-16 21:30:59 -06:00
AJ ONeal
c538942392 chore(scripts): shellcheck + shfmt clean deploy-webicached.sh 2026-05-16 21:22:38 -06:00
AJ ONeal
af28ddb686 docs: add deploy scripts, skills, and pattern guides
Deploy scripts for webicached and webid (build, upload, restart).
AGENTS.md with releases.conf reference and variant tagging docs.
Installer archive pattern guide and version oddities reference.
2026-05-16 21:22:38 -06:00
AJ ONeal
631147901a feat: add Go release cache daemon (webicached)
Rewrites the Node.js release classification pipeline in Go. webicached
fetches upstream releases (GitHub, Gitea, GitLab, HashiCorp, custom
sources), classifies assets by OS/arch/variant, and writes legacy-format
JSON caches compatible with the existing webinstall.dev API.

Git-clone packages emit git_tag and git_commit_hash from real repo
clones — no fabricated refs.
2026-05-16 21:22:38 -06:00
AJ ONeal
b3375d0e24 fix: serve Windows packages to CYGWIN and MINGW user-agents
CYGWIN_NT-* and MINGW64_NT-* UAs (Git Bash / Cygwin on Windows) were
classified as linux, so Windows users got linux binaries or no match.

Three fixes:
- build-classifier v1.0.4: CYGWIN/MINGW → windows in termsToTarget
- ua-detect.js: same fix for the Node server's UA detection path
- builds-cacher.js: default hostTarget.libc to 'libc' when unset —
  termsToTarget omits libc for plain UAs, causing triplets like
  'linux-x86_64-undefined' that never matched cache entries
2026-05-14 17:06:06 -06:00
AJ ONeal
c57757a027 fix(docs): fix typos in sshd-prohibit-password, ssh-harden, and ssh-adduser 2026-05-14 16:00:57 -06:00
AJ ONeal
0bf485dcc4 ref!: add releases.conf (replacing releases.js) 2026-05-14 15:11:56 -06:00
215 changed files with 13460 additions and 222 deletions

22
.gitignore vendored
View File

@@ -3,6 +3,16 @@ install-*.sh
install-*.bat
install-*.ps1
# Go build outputs (from go run/build in repo root)
/classify
/e2etest
/fetchraw
/inspect
/uaparse
/webicached
/zigtest
/distributables.csv
# local config
.env.*
*.env
@@ -13,12 +23,24 @@ install-*.ps1
_cache/
node_modules/
# local test fixtures (regenerated by _webi/test-live-*.js)
testdata/
LIVE_cache/
distributables.csv
# temporary & backup files
.*.sw*
*.bak
*.bak.*
# agent session files
agents/
LOCAL.md
# other
.DS_Store
desktop.ini
.directory
LIVE_cache
/webid
bin/

View File

@@ -1 +1,2 @@
DELETEMEnode_modules/**/*
node_modules/**/*
_webi/test-*.js

249
AGENTS.md
View File

@@ -3,6 +3,26 @@
Webi installs dev tools to `~/.local/` without sudo. Each installer is a small
package of 3-4 files. This guide tells you how to create and modify them.
## Domains
| Environment | Domains |
| ----------- | ------------------------------------------------ |
| Production | webi.sh, webi.ms, webinstall.dev |
| Beta | beta.webi.sh, beta.webi.ms, beta.webinstall.dev |
| Next | next.webi.sh, next.webi.ms, next.webinstall.dev |
- **webi.sh** — POSIX shell installer: `curl https://webi.sh/node | sh`
- **webi.ms** — PowerShell installer: `curl.exe https://webi.ms/node | powershell`
- **webinstall.dev** — canonical domain, serves both shell scripts and the
browser-facing cheat sheet pages
The domain controls the default script format. You can force the same behavior
on any domain via User-Agent:
- `curl -A "MS" ...` — triggers PowerShell output (same as webi.ms)
- `curl ...` (default UA) — triggers POSIX shell output (same as webi.sh)
- `curl -A "$(uname -srm)" ...` — once the API is activated, the full
`uname -srm` string (e.g. `Linux 6.1.0 x86_64`) guides OS/arch selection
## Why Webi Exists
Webi makes tool installation trivially repeatable for people who aren't
@@ -37,9 +57,11 @@ about PATH, permissions, or platform differences. Three things matter:
Key infrastructure directories (do not modify without good reason):
- `_webi/` — bootstrap templates, `normalize.js` (auto-detects OS/arch/ext from
filenames)
- `_common/` — shared JS: `github.js`, `githubish.js`, `gitea.js`, `fetcher.js`
- `_webi/` — bootstrap templates, `transform-releases.js` (API endpoint),
`builds-cacher.js` (reads cache JSON), `serve-installer.js` (installer
scripts)
- `_common/` — shared JS fetcher libraries (being phased out — Go daemon now
fetches upstream)
- `_example/` — canonical template for new packages
- `_examples/` — specialized templates (goreleaser, xz-compressed)
@@ -57,83 +79,50 @@ Ref: <https://github.com/webinstall/webi-installers/issues/412>
| 🔗 | Alias/redirect to another package | `ripgrep``rg` |
| 📝 | Bespoke / custom install | `rustlang` |
## releases.js
## Data Architecture
Fetches release metadata and returns a normalized object. Most packages use
GitHub releases:
There are two data paths. Both read from pre-generated cache — the Node.js
server does NOT fetch upstream APIs.
```js
'use strict';
```
API path (JSON/TAB output):
Request → transform-releases.js → ~/.cache/webi/legacy/{pkg}.json → filter + sort
Vocabulary: macos, amd64, arm64, armv7l (API vocabulary)
normalize.js: REMOVED — cache provides all fields directly
var github = require('../_common/github.js');
var owner = 'OWNER';
var repo = 'REPO';
let Releases = module.exports;
Releases.latest = async function () {
let all = await github(null, owner, repo);
return all;
};
Releases.sample = async function () {
let normalize = require('../_webi/normalize.js');
let all = await Releases.latest();
all = normalize(all);
all.releases = all.releases.slice(0, 5);
return all;
};
if (module === require.main) {
(async function () {
let samples = await Releases.sample();
console.info(JSON.stringify(samples, null, 2));
})();
}
Installer path (bash/ps1 script output):
Request → serve-installer.js → builds.js → builds-cacher.js
Vocabulary: darwin, x86_64, aarch64 (build-classifier vocabulary)
```
### Common release transformations
Cache is generated by the Go daemon (`webicached`) and stored flat in
`~/.cache/webi/legacy/{pkg}.json` (resolved via `Os.homedir()` in the Node
readers — no `_cache` symlink, no month subdirectory). Each file contains:
- Top-level summary arrays: `oses`, `arches`, `libcs`, `formats`
- `releases` array with pre-classified fields: `os`, `arch`, `libc`, `ext`,
`version`, `channel`, `download`
- `download` template string
**Strip version prefix** (monorepo or tool-prefixed tags):
### Canonical vocabulary (cache and API)
```js
// e.g. "tools/monorel/v0.6.5" → "v0.6.5"
rel.version = rel.version.replace(/^tools\/monorel\//, '');
**OS**: `macos` (not darwin), `linux`, `windows`, `freebsd`, etc.
**Arch**: `amd64` (not x86_64), `arm64` (not aarch64), `armv7l` (not armv7)
**Libc**: `none` (never empty), `gnu`, `musl`, `msvc`
**Ext**: `tar.gz`, `zip`, `exe` (no leading dot; `exe` for bare binaries)
// e.g. "cli-v1.2.3" → "v1.2.3"
rel.version = rel.version.replace(/^cli-/, '');
```
## releases.js (legacy — being phased out)
**Filter releases** (monorepo with multiple tools, or unwanted assets):
The `{pkg}/releases.js` files previously fetched upstream release metadata.
These are being replaced by the Go cache daemon. Existing files are kept as
documentation of release sources but are no longer called by the server.
```js
all.releases = all.releases.filter(function (rel) {
// Keep only releases for this tool
return rel.version.startsWith('tools/monorel/');
});
```
Apply transformations inside `Releases.latest`, before returning `all`.
**Available sources** beyond `github.js`:
- `_common/gitea.js` — Gitea servers
- `_common/git-tag.js` — Git tag listing
- Custom fetch from any JSON API (see `go/releases.js`, `terraform/releases.js`)
### Testing releases.js
### Testing the API (current)
```sh
node -e "
let Releases = require('./<name>/releases.js');
Releases.sample().then(function (all) {
console.log(JSON.stringify(all, null, 2));
});
"
curl -sS 'https://beta.webi.sh/api/releases/<name>.json?os=macos&arch=arm64&limit=5' | jq .
```
Verify: versions are clean semver (`0.6.5` not `tools/monorel/v0.6.5`), OS/arch
detected correctly, download URLs resolve.
Verify: versions present, correct OS/arch vocabulary, download URLs resolve.
## install.sh
@@ -365,8 +354,130 @@ Commit messages: `feat(<pkg>): add installer`, `fix(<pkg>): update install.sh`,
must filter in `releases.js` and strip the tag prefix from the version.
- **No `--version` flag**: Some tools lack version introspection. Comment out
`pkg_get_current_version` — webi still works, it just can't skip reinstalls.
- **normalize.js auto-detection**: OS/arch/ext are guessed from download
filenames. If the tool uses non-standard naming, you may need to set `os`,
`arch`, or `ext` explicitly in `releases.js`.
- **Cache directory**: `builds-cacher.js` and `transform-releases.js` read
exclusively from `~/.cache/webi/legacy/{pkg}.json` (resolved via
`Os.homedir()`). No date bucketing, no `_cache` symlink, no month rollover
hazard. If a package returns `0.0.0`, check that `~/.cache/webi/legacy/{pkg}.json`
exists and that `webicached` is running.
- **Goreleaser archives**: Typically contain a bare binary at the archive root
(not nested in a directory). Use `mv ./cmd "$pkg_src_cmd"`.
---
## Go Cache Daemon
The Go pipeline (`cmd/webicached`) replaces the Node.js release-fetching code.
It reads `releases.conf` files, fetches upstream release metadata, classifies
build assets, and writes to `~/.cache/webi/legacy/` in the format the Node.js server expects.
### Canonical Vocabulary
The classifier MUST use exactly these strings. They match the production API.
**OS**: `macos` (NOT `darwin`), `linux`, `windows`, `freebsd`, `openbsd`,
`netbsd`, `dragonfly`, `aix`, `illumos`, `plan9`, `solaris`, `posix_2017`
**Arch** — exact equivalences:
- `amd64` (NOT `x86_64`), `x86` (NOT `i386`/`i686`/`386`)
- `arm64` (NOT `aarch64`)
- `armv7l` (NOT `armv7`), `armv6l` (NOT `armv6`)
- `mipsle` (NOT `mipsel`), `mips64le` (NOT `mips64el`)
**Arch** — compatibility downcasts:
- `armhf` → `armv7l`, `armv7a` → `armv7l`, `armel` → `arm`
**Arch** — other: `arm`, `ppc64le`, `ppc64`, `loong64`, `riscv64`, `s390x`,
`mips`, `mips64`
**Libc**: `none` (never empty), `gnu`, `musl`, `msvc`
**Ext**: `tar.gz`, `tar.xz`, `zip`, `exe`, `7z`, `pkg`, `msi`
(no leading dot; `exe` for bare binaries)
### releases.conf
Each package directory contains a `releases.conf` that tells the daemon where
to fetch releases. Format is `key = value`, one per line. `#` comments and
blank lines are ignored.
#### Source types (mutually exclusive — pick one)
```ini
# GitHub binary releases (most common)
github_releases = sharkdp/bat
# GitHub source tarballs (with optional git fallback)
github_sources = bnnanet/serviceman
git_url = https://github.com/bnnanet/serviceman.git
# Git tag enumeration (vim plugins, shell scripts — git_url alone)
git_url = https://github.com/tpope/vim-commentary.git
# Gitea (full URL required, or short form + base_url)
gitea_releases = https://git.rootprojects.org/root/pathman
# GitLab (defaults to gitlab.com)
gitlab_releases = owner/repo
# HashiCorp releases API
hashicorp_product = terraform
# Custom source (servicemandist, nodedist, zigdist, etc.)
source = nodedist
url = https://nodejs.org/download/release
```
#### Filtering, versioning, and platform
```ini
tag_prefix = bun- # monorepo: strip prefix from version
version_prefixes = jq- # strip from version string (space-separated)
asset_filter = MinGit # filename must contain this substring
exclude = busybox -src- -docs- # skip assets containing these (space-separated)
os = posix_2017 # restrict ALL versions to this OS (blanket)
alias_of = rg # mirrors another package's releases
```
#### Design rules
- `os` is a blanket tag on ALL versions. Only use for packages that are always
POSIX-only. For version-dependent OS tagging, use a custom `TagVariants` in
`internal/releases/{pkg}/variants.go`.
- `git_url` can be primary (gittag source when it's the only key) or secondary
fallback alongside `github_sources`/`gitea_sources`.
- Full URL forms accepted for github/gitea/gitlab (e.g.
`github_releases = https://github.com/sharkdp/bat`).
### Testing
Test tools: `cmd/e2etest` (pipeline comparison), `cmd/comparecache` (cache diff),
`cmd/inspect` (single-package debug). Run each with `--help` for usage.
### Classifier vs Per-Package Tagger
The general classifier (`internal/classify/`) handles patterns common across
many projects. It MUST NOT contain one-off logic for a single package.
Per-package taggers (`internal/releases/{pkg}/variants.go`) handle
project-specific knowledge. Read the existing taggers for conventions.
MUST: Derive arch/OS from concrete evidence — not blanket defaults.
MUST: New general classifier patterns must apply to 2-3+ packages.
### Deploying
```sh
./scripts/deploy-webicached.sh beta.webi.sh
./scripts/deploy-webicached.sh next.webi.sh
```
First-time setup on a new host uses `serviceman`:
```sh
serviceman add --name webicached \
--workdir ~/srv/webid/installers/ -- \
~/bin/webicached \
--envfile ~/srv/webid/.env.secret \
--conf ~/srv/webid/installers/ \
--raw ~/.cache/webi/raw
```

3
_example/releases.conf Normal file
View File

@@ -0,0 +1,3 @@
# Example releases.conf — uses ripgrep as a sample project.
# Copy this file into your package directory and adjust.
github_releases = BurntSushi/ripgrep

100
_scripts/deploy-webinstall Executable file
View File

@@ -0,0 +1,100 @@
#!/bin/sh
set -e
set -u
# Deploy the Node.js webinstall (installer server) to a target host.
# Usage: _scripts/deploy-webinstall <host>
# e.g. _scripts/deploy-webinstall beta.webi.sh
# _scripts/deploy-webinstall next.webi.sh
fn_main() {
g_host="${1:-}"
if test -z "${g_host}"; then
printf 'Usage: %s <host>\n' "$0" >&2
printf ' e.g. %s beta.webi.sh\n' "$0" >&2
exit 1
fi
# beta.webi.sh → ~/srv/beta.webinstall.dev/installers/
# next.webi.sh → ~/srv/next.webinstall.dev/installers/
b_subdomain="${g_host%%.*}"
g_dest="~/srv/${b_subdomain}.webinstall.dev/installers/"
# Verify the build-classifier submodule is populated. rsync will
# happily push an empty submodule directory, after which Node
# crashes at startup with `Cannot find module './build-classifier/
# host-targets.js'`. New worktrees from `git worktree add` don't
# init submodules by default.
if ! test -f ./_webi/build-classifier/host-targets.js; then
printf 'fatal: _webi/build-classifier submodule not initialized\n' >&2
printf ' run: git submodule update --init _webi/build-classifier\n' >&2
exit 1
fi
printf '%s\n' "Deploying to ${g_host}:${g_dest} ..."
rsync -avz --delete \
--exclude='.git' \
--exclude='node_modules' \
--exclude='_cache' \
--exclude='LIVE_cache' \
--exclude='webicached' \
--exclude='classify' \
--exclude='fetchraw' \
--exclude='inspect' \
--exclude='e2etest' \
--exclude='zigtest' \
--exclude='distributables.csv' \
--exclude='agents/' \
./ "${g_host}:${g_dest}"
printf '%s\n' "Restarting webinstall ..."
# shellcheck disable=SC2029
ssh "${g_host}" "
. ~/.config/envman/PATH.env 2>/dev/null
rm -rf ${g_dest}_cache 2>/dev/null
serviceman restart webinstall
"
printf '%s\n' "Waiting for restart ..."
sleep 3
fn_smoke_test
}
fn_smoke_test() {
printf '%s\n' "Smoke testing https://${g_host}/ ..."
b_headers="$(curl -sI --max-time 5 "https://${g_host}/api/releases/go.json?limit=1")"
if printf '%s' "${b_headers}" | grep -qi 'X-Robots-Tag: noindex'; then
printf '%s\n' " [ok] X-Robots-Tag: noindex"
else
printf '%s\n' " [FAIL] Missing X-Robots-Tag header"
fi
if printf '%s' "${b_headers}" | grep -qi 'rel="canonical"'; then
printf '%s\n' " [ok] Link: canonical"
else
printf '%s\n' " [FAIL] Missing canonical Link header"
fi
b_version="$(curl -sS --max-time 5 "https://${g_host}/api/releases/go.json?limit=1" | jq -r '.[0].version')"
if test "${b_version}" != "0.0.0" && test -n "${b_version}"; then
printf '%s\n' " [ok] API returns go ${b_version}"
else
printf '%s\n' " [FAIL] API returned error or empty for go"
fi
b_shebang="$(curl -sS --max-time 5 "https://${g_host}/node" | head -1)"
if test "${b_shebang}" = "#!/bin/sh"; then
printf '%s\n' " [ok] Installer path serves shell script"
else
printf '%s\n' " [FAIL] Installer path unexpected: ${b_shebang}"
fi
printf '%s\n' "Done."
}
fn_main "$@"

View File

@@ -621,13 +621,15 @@ BuildsCacher.create = function ({ ALL_TERMS, installers }) {
let arches = waterfall[hostTarget.arch] ||
HostTargets.WATERFALL.ANYOS[hostTarget.arch] || [hostTarget.arch];
arches = arches.concat(['ANYARCH']);
let libcs = waterfall[hostTarget.libc] ||
HostTargets.WATERFALL.ANYOS[hostTarget.libc] || [hostTarget.libc];
// termsToTarget omits libc for plain UAs; 'libc' → waterfall ['none','libc',...]
let libc = hostTarget.libc || 'libc';
let libcs = waterfall[libc] ||
HostTargets.WATERFALL.ANYOS[libc] || [libc];
// Extend the glibc-host waterfall: the table only lists [none, libc]
// but Rust projects (bat, rg) and node ship libc='gnu' builds, and
// static musl builds also run on glibc hosts.
if (hostTarget.libc === 'libc' && !libcs.includes('gnu')) {
if (libc === 'libc' && !libcs.includes('gnu')) {
libcs = ['none', 'gnu', 'musl', 'libc'];
}

208
_webi/test-api-compat.js Normal file
View File

@@ -0,0 +1,208 @@
'use strict';
let Fs = require('node:fs/promises');
let Path = require('node:path');
let Releases = require('./transform-releases.js');
let TESTDATA_DIR = Path.join(__dirname, 'testdata');
// These mirror what the live API returns for /api/releases/{pkg}@stable.json?...
let FILTERED_CASES = [
{ pkg: 'bat', os: 'macos', arch: 'amd64' },
{ pkg: 'bat', os: 'macos', arch: 'arm64' },
{ pkg: 'bat', os: 'linux', arch: 'amd64' },
{ pkg: 'bat', os: 'windows', arch: 'amd64' },
{ pkg: 'go', os: 'macos', arch: 'amd64' },
{ pkg: 'go', os: 'macos', arch: 'arm64' },
{ pkg: 'go', os: 'linux', arch: 'amd64' },
{ pkg: 'go', os: 'windows', arch: 'amd64' },
{ pkg: 'rg', os: 'macos', arch: 'amd64' },
{ pkg: 'rg', os: 'macos', arch: 'arm64' },
{ pkg: 'rg', os: 'linux', arch: 'amd64' },
{ pkg: 'rg', os: 'windows', arch: 'amd64' },
{ pkg: 'caddy', os: 'macos', arch: 'amd64' },
{ pkg: 'caddy', os: 'macos', arch: 'arm64' },
{ pkg: 'caddy', os: 'linux', arch: 'amd64' },
{ pkg: 'caddy', os: 'windows', arch: 'amd64' },
];
// Fields to compare between live and local
let COMPARE_FIELDS = ['version', 'os', 'arch', 'ext', 'libc', 'channel', 'download'];
async function main() {
let failures = 0;
let passes = 0;
let skips = 0;
// Test 1: Unfiltered release list — compare structure and field values
console.log('=== Test 1: Unfiltered /api/releases/{pkg}.json ===');
console.log('');
for (let pkg of ['bat', 'go', 'node', 'rg', 'jq', 'caddy']) {
let liveFile = `${TESTDATA_DIR}/live_${pkg}.json`;
let liveExists = await Fs.access(liveFile).then(
function () { return true; },
function () { return false; },
);
if (!liveExists) {
console.log(` SKIP ${pkg}: no golden data`);
skips++;
continue;
}
let liveJson = await Fs.readFile(liveFile, 'utf8');
let liveReleases = JSON.parse(liveJson);
let localResult = await Releases.getReleases({
pkg: pkg,
ver: '',
os: '',
arch: '',
libc: '',
lts: false,
channel: '',
formats: [],
limit: 100,
});
let localReleases = localResult.releases;
// Compare OS vocabulary
let liveOses = [...new Set(liveReleases.map(function (r) { return r.os; }))].sort();
let localOses = [...new Set(localReleases.map(function (r) { return r.os; }))].sort();
let osMatch = JSON.stringify(liveOses) === JSON.stringify(localOses);
if (!osMatch) {
console.log(` FAIL ${pkg} OS values: live=${JSON.stringify(liveOses)} local=${JSON.stringify(localOses)}`);
failures++;
} else {
console.log(` PASS ${pkg} OS values: ${JSON.stringify(liveOses)}`);
passes++;
}
// Compare arch vocabulary
let liveArches = [...new Set(liveReleases.map(function (r) { return r.arch; }))].sort();
let localArches = [...new Set(localReleases.map(function (r) { return r.arch; }))].sort();
let archMatch = JSON.stringify(liveArches) === JSON.stringify(localArches);
if (!archMatch) {
console.log(` FAIL ${pkg} arch values: live=${JSON.stringify(liveArches)} local=${JSON.stringify(localArches)}`);
failures++;
} else {
console.log(` PASS ${pkg} arch values: ${JSON.stringify(liveArches)}`);
passes++;
}
// Compare latest version
let liveLatest = liveReleases[0]?.version;
let localLatest = localReleases[0]?.version;
if (liveLatest !== localLatest) {
// Version differences may be expected if cache is newer/older
console.log(` WARN ${pkg} latest version: live=${liveLatest} local=${localLatest}`);
} else {
console.log(` PASS ${pkg} latest version: ${liveLatest}`);
passes++;
}
// Compare ext vocabulary
let liveExts = [...new Set(liveReleases.map(function (r) { return r.ext; }))].sort();
let localExts = [...new Set(localReleases.map(function (r) { return r.ext; }))].sort();
let extMatch = JSON.stringify(liveExts) === JSON.stringify(localExts);
if (!extMatch) {
console.log(` FAIL ${pkg} ext values: live=${JSON.stringify(liveExts)} local=${JSON.stringify(localExts)}`);
failures++;
} else {
console.log(` PASS ${pkg} ext values: ${JSON.stringify(liveExts)}`);
passes++;
}
// Compare that version strings don't have 'v' prefix
let localHasVPrefix = localReleases.some(function (r) {
return r.version.startsWith('v');
});
if (localHasVPrefix) {
console.log(` FAIL ${pkg} versions have 'v' prefix (should be stripped)`);
failures++;
} else {
console.log(` PASS ${pkg} no 'v' prefix on versions`);
passes++;
}
}
// Test 2: Filtered queries — compare selected package for specific OS/arch
console.log('');
console.log('=== Test 2: Filtered /api/releases/{pkg}@stable.json?os=...&arch=... ===');
console.log('');
for (let tc of FILTERED_CASES) {
let fname = `live_${tc.pkg}_os_${tc.os}_arch_${tc.arch}.json`;
let liveFile = `${TESTDATA_DIR}/${fname}`;
let liveExists = await Fs.access(liveFile).then(
function () { return true; },
function () { return false; },
);
if (!liveExists) {
skips++;
continue;
}
let liveJson = await Fs.readFile(liveFile, 'utf8');
let liveReleases = JSON.parse(liveJson);
let liveFirst = liveReleases[0];
if (!liveFirst || liveFirst.channel === 'error') {
console.log(` SKIP ${tc.pkg} ${tc.os}/${tc.arch}: live returned error/empty`);
skips++;
continue;
}
let localResult = await Releases.getReleases({
pkg: tc.pkg,
ver: '',
os: tc.os,
arch: tc.arch,
libc: '',
lts: false,
channel: 'stable',
formats: ['tar', 'zip', 'exe', 'xz'],
limit: 1,
});
let localFirst = localResult.releases[0];
if (!localFirst || localFirst.channel === 'error') {
console.log(` FAIL ${tc.pkg} ${tc.os}/${tc.arch}: local returned error/empty, live had ${liveFirst.version}`);
failures++;
continue;
}
let diffs = [];
for (let field of COMPARE_FIELDS) {
let liveVal = String(liveFirst[field] || '');
let localVal = String(localFirst[field] || '');
if (liveVal !== localVal) {
// Version differences are OK if cache age differs
if (field === 'version' || field === 'download' || field === 'date') {
continue;
}
diffs.push(`${field}: live=${liveVal} local=${localVal}`);
}
}
if (diffs.length > 0) {
console.log(` FAIL ${tc.pkg} ${tc.os}/${tc.arch}: ${diffs.join(', ')}`);
failures++;
} else {
let ver = localFirst.version;
let ext = localFirst.ext;
console.log(` PASS ${tc.pkg} ${tc.os}/${tc.arch}: v${ver} .${ext}`);
passes++;
}
}
console.log('');
console.log(`=== Results: ${passes} passed, ${failures} failed, ${skips} skipped ===`);
if (failures > 0) {
process.exit(1);
}
}
main().catch(function (err) {
console.error(err.stack);
process.exit(1);
});

View File

@@ -0,0 +1,77 @@
'use strict';
// Broad sweep: test that all cached packages resolve on macOS arm64
// and Linux amd64. Catches any package that completely fails to resolve.
//
// Usage: node _webi/test-broad-resolve.js
var Path = require('node:path');
var InstallerServer = require('./serve-installer.js');
var Builds = require('./builds.js');
var BuildsCacher = require('./builds-cacher.js');
var UA_CASES = [
{ label: 'macOS arm64', ua: 'aarch64/unknown Darwin/24.2.0 libc' },
{ label: 'Linux amd64', ua: 'x86_64/unknown Linux/5.15.0 libc' },
];
async function main() {
console.log('Initializing build cache...');
await Builds.init();
console.log('');
var bc = BuildsCacher.create({
caches: Path.join(__dirname, '../_cache'),
installers: Path.join(__dirname, '..'),
});
var dirs = await bc.getProjectsByType();
var pkgs = Object.keys(dirs.valid).sort();
console.log('Testing ' + pkgs.length + ' packages...');
console.log('');
var pass = 0;
var fail = 0;
var failures = [];
for (var i = 0; i < pkgs.length; i++) {
var pkg = pkgs[i];
for (var j = 0; j < UA_CASES.length; j++) {
var tc = UA_CASES[j];
try {
var r = await InstallerServer.helper({
unameAgent: tc.ua,
projectName: pkg,
tag: 'stable',
formats: ['tar', 'exe', 'zip', 'xz', 'dmg'],
libc: '',
});
var p = r[0];
if (p.channel === 'error' || p.ext === 'err') {
failures.push(pkg + ' ' + tc.label + ': error (v' + p.version + ')');
fail++;
} else {
pass++;
}
} catch (e) {
failures.push(pkg + ' ' + tc.label + ': ' + e.message.substring(0, 60));
fail++;
}
}
}
if (failures.length > 0) {
console.log('Failures:');
for (var k = 0; k < failures.length; k++) {
console.log(' FAIL ' + failures[k]);
}
console.log('');
}
var total = pkgs.length * UA_CASES.length;
console.log('=== ' + pass + '/' + total + ' passed (' + fail + ' failed) ===');
}
main().catch(function (err) {
console.error(err.stack);
process.exit(1);
});

View File

@@ -0,0 +1,277 @@
'use strict';
// Tests that _cache JSON files are ready for direct use by the API endpoint
// (transform-releases.js) WITHOUT needing normalize.js to fix up fields.
//
// These tests drive GOER to add missing features to ExportLegacy so that
// normalize.js can be eliminated from the API path entirely.
//
// Usage: node _webi/test-cache-api-ready.js
var Fs = require('node:fs');
var Os = require('node:os');
var Path = require('node:path');
var CACHE_DIR = Path.join(Os.homedir(), '.cache/webi/legacy');
// Packages to spot-check (mix of github_releases, custom releases.js, gittag)
var CHECK_PKGS = [
'bat',
'caddy',
'go',
'hugo',
'jq',
'node',
'rg',
'terraform',
'zig',
];
function loadReleases(dir, pkg) {
var file = Path.join(dir, pkg + '.json');
if (!Fs.existsSync(file)) {
return null;
}
try {
return JSON.parse(Fs.readFileSync(file, 'utf8'));
} catch (e) {
return null;
}
}
async function main() {
var passes = 0;
var failures = 0;
if (!Fs.existsSync(CACHE_DIR)) {
console.error('No cache directory at ' + CACHE_DIR);
process.exit(1);
}
console.log('Testing ' + CACHE_DIR);
console.log('');
// ================================================================
// Test 1: Version has no 'v' prefix
// ================================================================
console.log('=== Test 1: Version v-prefix stripped ===');
console.log('');
for (var vi = 0; vi < CHECK_PKGS.length; vi++) {
var vpkg = CHECK_PKGS[vi];
var vdata = loadReleases(CACHE_DIR, vpkg);
if (!vdata) {
console.log(' SKIP ' + vpkg + ': no data');
continue;
}
var vPrefixed = 0;
for (var vri = 0; vri < vdata.releases.length; vri++) {
var ver = vdata.releases[vri].version || '';
if (ver.startsWith('v')) {
vPrefixed++;
}
}
if (vPrefixed === 0) {
console.log(' PASS ' + vpkg + ': no v-prefixed versions');
passes++;
} else {
console.log(' FAIL ' + vpkg + ': ' + vPrefixed + '/' + vdata.releases.length + ' versions have v prefix');
failures++;
}
}
// ================================================================
// Test 2: libc is never empty string (should be "none", "gnu", "musl", "msvc")
// ================================================================
console.log('');
console.log('=== Test 2: libc never empty ===');
console.log('');
for (var li = 0; li < CHECK_PKGS.length; li++) {
var lpkg = CHECK_PKGS[li];
var ldata = loadReleases(CACHE_DIR, lpkg);
if (!ldata) {
console.log(' SKIP ' + lpkg + ': no data');
continue;
}
var emptyLibc = 0;
for (var lri = 0; lri < ldata.releases.length; lri++) {
if (ldata.releases[lri].libc === '') {
emptyLibc++;
}
}
if (emptyLibc === 0) {
console.log(' PASS ' + lpkg + ': all entries have libc set');
passes++;
} else {
console.log(' FAIL ' + lpkg + ': ' + emptyLibc + '/' + ldata.releases.length + ' entries have empty libc (should be "none")');
failures++;
}
}
// ================================================================
// Test 3: ext has no leading dot
// ================================================================
console.log('');
console.log('=== Test 3: ext has no leading dot ===');
console.log('');
for (var ei = 0; ei < CHECK_PKGS.length; ei++) {
var epkg = CHECK_PKGS[ei];
var edata = loadReleases(CACHE_DIR, epkg);
if (!edata) {
console.log(' SKIP ' + epkg + ': no data');
continue;
}
var dotExt = 0;
for (var eri = 0; eri < edata.releases.length; eri++) {
var ext = edata.releases[eri].ext || '';
if (ext.startsWith('.')) {
dotExt++;
}
}
if (dotExt === 0) {
console.log(' PASS ' + epkg + ': no leading dots on ext');
passes++;
} else {
console.log(' FAIL ' + epkg + ': ' + dotExt + '/' + edata.releases.length + ' entries have leading dot on ext');
failures++;
}
}
// ================================================================
// Test 4: bare binaries have ext "exe" (not empty)
// ================================================================
console.log('');
console.log('=== Test 4: bare binaries have ext "exe" ===');
console.log('');
var barePkgs = ['jq', 'bat', 'rg', 'caddy'];
for (var bi = 0; bi < barePkgs.length; bi++) {
var bpkg = barePkgs[bi];
var bdata = loadReleases(CACHE_DIR, bpkg);
if (!bdata) {
console.log(' SKIP ' + bpkg + ': no data');
continue;
}
var emptyExt = 0;
for (var bri = 0; bri < bdata.releases.length; bri++) {
var brel = bdata.releases[bri];
if (brel.ext === '' && brel.name && !brel.name.includes('.')) {
emptyExt++;
}
}
if (emptyExt === 0) {
console.log(' PASS ' + bpkg + ': no bare binaries with empty ext');
passes++;
} else {
console.log(' FAIL ' + bpkg + ': ' + emptyExt + ' bare binaries have empty ext (should be "exe")');
failures++;
}
}
// ================================================================
// Test 5: Top-level summary arrays present
// ================================================================
console.log('');
console.log('=== Test 5: Summary arrays (oses, arches, libcs, formats) ===');
console.log('');
for (var si = 0; si < CHECK_PKGS.length; si++) {
var spkg = CHECK_PKGS[si];
var sdata = loadReleases(CACHE_DIR, spkg);
if (!sdata) {
console.log(' SKIP ' + spkg + ': no data');
continue;
}
var missing = [];
if (!Array.isArray(sdata.oses)) { missing.push('oses'); }
if (!Array.isArray(sdata.arches)) { missing.push('arches'); }
if (!Array.isArray(sdata.libcs)) { missing.push('libcs'); }
if (!Array.isArray(sdata.formats)) { missing.push('formats'); }
if (missing.length === 0) {
// Verify they contain the right values
var hasMacos = sdata.oses.includes('macos') || sdata.oses.includes('darwin');
var hasLinux = sdata.oses.includes('linux');
var ok = true;
if (sdata.releases.some(function (r) { return r.os === 'macos' || r.os === 'darwin'; }) && !hasMacos) {
console.log(' FAIL ' + spkg + ': oses array missing macos/darwin');
failures++;
ok = false;
}
if (sdata.releases.some(function (r) { return r.os === 'linux'; }) && !hasLinux) {
console.log(' FAIL ' + spkg + ': oses array missing linux');
failures++;
ok = false;
}
if (ok) {
console.log(' PASS ' + spkg + ': has oses, arches, libcs, formats');
passes++;
}
} else {
console.log(' FAIL ' + spkg + ': missing top-level arrays: ' + missing.join(', '));
failures++;
}
}
// ================================================================
// Test 6: Version sort order — stable before beta, newest first
// ================================================================
console.log('');
console.log('=== Test 6: Version sort order ===');
console.log('');
var sortPkgs = ['go', 'node', 'terraform'];
for (var oi = 0; oi < sortPkgs.length; oi++) {
var opkg = sortPkgs[oi];
var odata = loadReleases(CACHE_DIR, opkg);
if (!odata) {
console.log(' SKIP ' + opkg + ': no data');
continue;
}
// Find first stable release
var firstStable = odata.releases.find(function (r) { return r.channel === 'stable'; });
// Find first beta release
var firstBeta = odata.releases.find(function (r) { return r.channel === 'beta'; });
if (!firstStable) {
console.log(' SKIP ' + opkg + ': no stable release');
continue;
}
// The first entry overall should be a stable release (newest stable > any beta)
// unless the beta is a newer version number
var firstEntry = odata.releases[0];
if (firstEntry.channel === 'stable') {
console.log(' PASS ' + opkg + ': first entry is stable (' + firstEntry.version + ')');
passes++;
} else {
console.log(' FAIL ' + opkg + ': first entry is ' + firstEntry.channel + ' (' + firstEntry.version + '), expected stable (' + firstStable.version + ')');
failures++;
}
}
// ================================================================
// Summary
// ================================================================
console.log('');
console.log('=== Results: ' + passes + ' passed, ' + failures + ' failed ===');
if (failures > 0) {
process.exit(1);
}
}
main().catch(function (err) {
console.error(err.stack);
process.exit(1);
});

687
_webi/test-cache-compat.js Normal file
View File

@@ -0,0 +1,687 @@
'use strict';
// Cache compatibility tests: verify that Go-generated cache files work
// correctly with the Node build-classifier and installer resolution pipeline.
//
// Tests cover:
// 1. Cache completeness — all packages have releases, required fields present
// 2. Format selection — correct archive format per platform
// 3. Edge-case platforms — FreeBSD, ARM variants, musl/Alpine
// 4. Script generation — installer scripts render without error
// 5. API compat — transform-releases output matches expected vocabulary
// 6. Version format — no 'v' prefix, valid semver-ish
// 7. Channel detection — stable vs beta correctly identified
//
// Usage: node _webi/test-cache-compat.js
var Fs = require('node:fs');
var Path = require('node:path');
var InstallerServer = require('./serve-installer.js');
var Builds = require('./builds.js');
var Releases = require('./transform-releases.js');
var CACHE_DIR = Path.join(require('node:os').homedir(), '.cache/webi/legacy');
// ====================================================================
// Test 1: Cache completeness — every package has releases with valid fields
// ====================================================================
// Packages that are expected to have binary releases (not gittag-only)
var BINARY_PKGS = [
'bat',
'caddy',
'cmake',
'delta',
'deno',
'fd',
'fzf',
'gh',
'go',
'goreleaser',
'hugo',
'jq',
'k9s',
'node',
'ollama',
'rg',
'shellcheck',
'shfmt',
'syncthing',
'terraform',
'xz',
'yq',
'zig',
'zoxide',
];
// Packages that are gittag/source-only — must have releases but os/arch may be special
var GITTAG_PKGS = [
'aliasman',
'serviceman',
'vim-airline',
'vim-go',
'vim-sensible',
];
// ====================================================================
// Test 2: Format selection — correct ext per platform
// ====================================================================
// For these packages, verify the resolved format is correct for each platform.
// Linux/macOS should get .tar.gz or .tar.xz (not .zip except for specific packages).
// Windows should get .zip or .exe (not .tar.gz).
var FORMAT_CASES = [
// Go projects — should be .tar.gz on Linux/macOS, .zip on Windows
{
label: 'go Linux amd64 format',
pkg: 'go',
ua: 'x86_64/unknown Linux/5.15.0 libc',
expectExt: 'tar.gz',
},
{
label: 'go Windows amd64 format',
pkg: 'go',
ua: 'x86_64/unknown Windows/10.0.19041 msvc',
expectExt: 'zip',
},
{
label: 'go macOS arm64 format',
pkg: 'go',
ua: 'aarch64/unknown Darwin/24.2.0 libc',
expectExt: 'tar.gz',
},
// Rust projects — should be .tar.gz on Linux/macOS, .zip on Windows
{
label: 'bat Linux amd64 format',
pkg: 'bat',
ua: 'x86_64/unknown Linux/5.15.0 libc',
expectExt: 'tar.gz',
},
{
label: 'bat Windows amd64 format',
pkg: 'bat',
ua: 'x86_64/unknown Windows/10.0.19041 msvc',
expectExt: 'zip',
},
{
label: 'rg Linux amd64 format',
pkg: 'rg',
ua: 'x86_64/unknown Linux/5.15.0 libc',
expectExt: 'tar.gz',
},
{
label: 'rg Windows amd64 format',
pkg: 'rg',
ua: 'x86_64/unknown Windows/10.0.19041 msvc',
expectExt: 'zip',
},
// Node — uses .tar.xz on Linux/macOS
{
label: 'node Linux amd64 format',
pkg: 'node',
ua: 'x86_64/unknown Linux/5.15.0 libc',
expectExt: 'tar.xz',
},
{
label: 'node macOS arm64 format',
pkg: 'node',
ua: 'aarch64/unknown Darwin/24.2.0 libc',
expectExt: 'tar.xz',
},
// delta — Rust, .tar.gz on Linux/macOS
{
label: 'delta Linux amd64 format',
pkg: 'delta',
ua: 'x86_64/unknown Linux/5.15.0 libc',
expectExt: 'tar.gz',
},
// shfmt — Go, bare exe on Linux, .exe on Windows
{
label: 'shfmt Linux amd64 format',
pkg: 'shfmt',
ua: 'x86_64/unknown Linux/5.15.0 libc',
expectExt: 'exe',
},
];
// ====================================================================
// Test 3: Edge-case platforms
// ====================================================================
var EDGE_CASES = [
// Linux ARM variants
{
label: 'go Linux aarch64',
pkg: 'go',
ua: 'aarch64/unknown Linux/5.15.0 libc',
expectOs: 'linux',
expectNotError: true,
},
{
label: 'node Linux aarch64',
pkg: 'node',
ua: 'aarch64/unknown Linux/5.15.0 libc',
expectOs: 'linux',
expectNotError: true,
},
// Alpine/musl — bat should resolve to musl build
{
label: 'bat Linux musl amd64',
pkg: 'bat',
ua: 'x86_64/unknown Linux/5.15.0 musl',
expectOs: 'linux',
expectNotError: true,
},
{
label: 'rg Linux musl amd64',
pkg: 'rg',
ua: 'x86_64/unknown Linux/5.15.0 musl',
expectOs: 'linux',
expectNotError: true,
},
// node musl — separate musl build
{
label: 'node Linux musl amd64',
pkg: 'node',
ua: 'x86_64/unknown Linux/5.15.0 musl',
expectOs: 'linux',
expectNotError: true,
},
// FreeBSD — packages that have freebsd builds
{
label: 'syncthing FreeBSD amd64',
pkg: 'syncthing',
ua: 'x86_64/unknown FreeBSD/14.0 libc',
expectOs: 'freebsd',
expectNotError: true,
},
{
label: 'caddy FreeBSD amd64',
pkg: 'caddy',
ua: 'x86_64/unknown FreeBSD/14.0 libc',
expectOs: 'freebsd',
expectNotError: true,
},
// Windows aarch64 — should fallback to amd64 for most packages
{
label: 'go Windows aarch64',
pkg: 'go',
ua: 'aarch64/unknown Windows/10.0.22000 msvc',
expectOs: 'windows',
expectNotError: true,
},
];
// ====================================================================
// Test 4: Script generation smoke tests
// ====================================================================
var SCRIPT_CASES = [
{ pkg: 'bat', ua: 'x86_64/unknown Linux/5.15.0 libc', label: 'bat Linux' },
{ pkg: 'go', ua: 'aarch64/unknown Darwin/24.2.0 libc', label: 'go macOS' },
{ pkg: 'node', ua: 'x86_64/unknown Linux/5.15.0 libc', label: 'node Linux' },
{ pkg: 'rg', ua: 'x86_64/unknown Windows/10.0.19041 msvc', label: 'rg Windows' },
{ pkg: 'jq', ua: 'aarch64/unknown Darwin/24.2.0 libc', label: 'jq macOS' },
{ pkg: 'caddy', ua: 'x86_64/unknown Linux/5.15.0 libc', label: 'caddy Linux' },
{ pkg: 'shellcheck', ua: 'x86_64/unknown Linux/5.15.0 libc', label: 'shellcheck Linux' },
{ pkg: 'shfmt', ua: 'aarch64/unknown Darwin/24.2.0 libc', label: 'shfmt macOS' },
{ pkg: 'hugo', ua: 'x86_64/unknown Linux/5.15.0 libc', label: 'hugo Linux' },
{ pkg: 'delta', ua: 'x86_64/unknown Linux/5.15.0 libc', label: 'delta Linux' },
{ pkg: 'fd', ua: 'x86_64/unknown Linux/5.15.0 libc', label: 'fd Linux' },
{ pkg: 'fzf', ua: 'x86_64/unknown Linux/5.15.0 libc', label: 'fzf Linux' },
{ pkg: 'zoxide', ua: 'x86_64/unknown Linux/5.15.0 libc', label: 'zoxide Linux' },
{ pkg: 'k9s', ua: 'x86_64/unknown Linux/5.15.0 libc', label: 'k9s Linux' },
{ pkg: 'yq', ua: 'x86_64/unknown Linux/5.15.0 libc', label: 'yq Linux' },
];
// ====================================================================
// Test 5: API compat — transform-releases vocabulary
// ====================================================================
// The /api/releases/ endpoint uses normalize.js which has its own OS/arch names.
// Verify that the Go cache produces correct values after normalization.
var API_VOCAB_CASES = [
{
label: 'bat has macos+linux+windows',
pkg: 'bat',
expectOses: ['linux', 'macos', 'windows'],
},
{
label: 'go has macos+linux+windows',
pkg: 'go',
expectOses: ['linux', 'macos', 'windows'],
},
{
label: 'node has linux+macos+windows',
pkg: 'node',
expectOses: ['linux', 'macos', 'windows'],
},
{
label: 'syncthing has freebsd+linux+macos+windows',
pkg: 'syncthing',
expectOses: ['freebsd', 'linux', 'macos', 'windows'],
},
{
label: 'bat has amd64+arm64 arches',
pkg: 'bat',
expectArches: ['amd64', 'arm64'],
},
{
label: 'go has amd64+arm64 arches',
pkg: 'go',
expectArches: ['amd64', 'arm64'],
},
];
// ====================================================================
// Test 6: Version format validation
// ====================================================================
var VERSION_CHECKS = [
'bat',
'go',
'node',
'rg',
'caddy',
'hugo',
'terraform',
'jq',
'cmake',
'zig',
];
// ====================================================================
// Test 7: Channel detection
// ====================================================================
// Packages that should have both stable and beta releases
var CHANNEL_CASES = [
{
label: 'go has stable releases',
pkg: 'go',
channel: 'stable',
expectMinCount: 10,
},
{
label: 'node has stable releases',
pkg: 'node',
channel: 'stable',
expectMinCount: 10,
},
{
label: 'cmake has stable releases',
pkg: 'cmake',
channel: 'stable',
expectMinCount: 5,
},
];
// ====================================================================
// Runner
// ====================================================================
async function main() {
var passes = 0;
var failures = 0;
var knowns = 0;
var skips = 0;
console.log('Initializing build cache...');
await Builds.init();
if (!Fs.existsSync(CACHE_DIR)) {
console.error('No cache directory at ' + CACHE_DIR);
process.exit(1);
}
var cachePath = CACHE_DIR;
console.log('Using cache: ' + cachePath);
console.log('');
// ================================================================
// Test 1: Cache completeness
// ================================================================
console.log('=== Test 1: Cache Completeness ===');
console.log('');
for (var bi = 0; bi < BINARY_PKGS.length; bi++) {
var bpkg = BINARY_PKGS[bi];
var bfile = Path.join(cachePath, bpkg + '.json');
if (!Fs.existsSync(bfile)) {
console.log(' FAIL ' + bpkg + ': cache file missing');
failures++;
continue;
}
var bdata = JSON.parse(Fs.readFileSync(bfile, 'utf8'));
if (!bdata.releases || bdata.releases.length === 0) {
console.log(' FAIL ' + bpkg + ': 0 releases');
failures++;
continue;
}
// Check that at least one release has a download URL
var hasDownload = bdata.releases.some(function (r) {
return r.download && r.download.startsWith('http');
});
if (!hasDownload) {
console.log(' FAIL ' + bpkg + ': no releases with download URLs');
failures++;
continue;
}
console.log(' PASS ' + bpkg + ': ' + bdata.releases.length + ' releases');
passes++;
}
for (var gi = 0; gi < GITTAG_PKGS.length; gi++) {
var gpkg = GITTAG_PKGS[gi];
var gfile = Path.join(cachePath, gpkg + '.json');
if (!Fs.existsSync(gfile)) {
console.log(' FAIL ' + gpkg + ' (gittag): cache file missing');
failures++;
continue;
}
var gdata = JSON.parse(Fs.readFileSync(gfile, 'utf8'));
if (!gdata.releases || gdata.releases.length === 0) {
console.log(' FAIL ' + gpkg + ' (gittag): 0 releases');
failures++;
continue;
}
console.log(' PASS ' + gpkg + ' (gittag): ' + gdata.releases.length + ' releases');
passes++;
}
// ================================================================
// Test 2: Format selection
// ================================================================
console.log('');
console.log('=== Test 2: Format Selection ===');
console.log('');
for (var fi = 0; fi < FORMAT_CASES.length; fi++) {
var ftc = FORMAT_CASES[fi];
try {
var fr = await InstallerServer.helper({
unameAgent: ftc.ua,
projectName: ftc.pkg,
tag: 'stable',
formats: ['tar', 'exe', 'zip', 'xz', 'dmg'],
libc: '',
});
var fpkg = fr[0];
if (fpkg.channel === 'error') {
console.log(' FAIL ' + ftc.label + ': resolved to error');
failures++;
continue;
}
if (fpkg.ext !== ftc.expectExt) {
console.log(' FAIL ' + ftc.label + ': got .' + fpkg.ext + ' want .' + ftc.expectExt);
failures++;
} else {
console.log(' PASS ' + ftc.label + ': .' + fpkg.ext);
passes++;
}
} catch (e) {
console.log(' ERROR ' + ftc.label + ': ' + e.message);
failures++;
}
}
// ================================================================
// Test 3: Edge-case platforms
// ================================================================
console.log('');
console.log('=== Test 3: Edge-Case Platforms ===');
console.log('');
for (var ei = 0; ei < EDGE_CASES.length; ei++) {
var etc = EDGE_CASES[ei];
try {
var er = await InstallerServer.helper({
unameAgent: etc.ua,
projectName: etc.pkg,
tag: 'stable',
formats: ['tar', 'exe', 'zip', 'xz', 'dmg'],
libc: '',
});
var epkg = er[0];
if (etc.expectNotError && epkg.channel === 'error') {
console.log(' FAIL ' + etc.label + ': resolved to error (v' + epkg.version + ')');
failures++;
continue;
}
if (etc.expectOs && epkg.os !== etc.expectOs) {
console.log(' FAIL ' + etc.label + ': os=' + epkg.os + ' want=' + etc.expectOs);
failures++;
continue;
}
console.log(' PASS ' + etc.label + ': v' + epkg.version + ' .' + epkg.ext);
passes++;
} catch (e) {
if (etc.known) {
console.log(' KNOWN ' + etc.label + ': ' + e.message);
knowns++;
} else {
console.log(' FAIL ' + etc.label + ': ' + e.message);
failures++;
}
}
}
// ================================================================
// Test 4: Script generation smoke tests
// ================================================================
console.log('');
console.log('=== Test 4: Script Generation ===');
console.log('');
for (var si = 0; si < SCRIPT_CASES.length; si++) {
var stc = SCRIPT_CASES[si];
try {
var script = await InstallerServer.serveInstaller(
'https://webi.sh',
stc.ua,
stc.pkg,
'stable',
'sh',
['tar', 'exe', 'zip', 'xz', 'dmg'],
'',
);
// Script must contain WEBI_PKG_URL and WEBI_VERSION
var hasUrl = /WEBI_PKG_URL='[^']+'/m.test(script);
var hasVersion = /WEBI_VERSION='[^']+'/m.test(script);
var hasExt = /WEBI_EXT='[^']+'/m.test(script);
if (!hasUrl) {
console.log(' FAIL ' + stc.label + ': missing WEBI_PKG_URL');
failures++;
} else if (!hasVersion) {
console.log(' FAIL ' + stc.label + ': missing WEBI_VERSION');
failures++;
} else if (!hasExt) {
console.log(' FAIL ' + stc.label + ': missing WEBI_EXT');
failures++;
} else {
var vMatch = script.match(/WEBI_VERSION='([^']+)'/);
var extMatch = script.match(/WEBI_EXT='([^']+)'/);
console.log(' PASS ' + stc.label + ': v' + vMatch[1] + ' .' + extMatch[1]);
passes++;
}
} catch (e) {
console.log(' FAIL ' + stc.label + ': ' + e.message.substring(0, 80));
failures++;
}
}
// ================================================================
// Test 5: API compat — transform-releases vocabulary
// ================================================================
console.log('');
console.log('=== Test 5: API Vocabulary (transform-releases) ===');
console.log('');
for (var ai = 0; ai < API_VOCAB_CASES.length; ai++) {
var atc = API_VOCAB_CASES[ai];
try {
var ares = await Releases.getReleases({
pkg: atc.pkg,
ver: '',
os: '',
arch: '',
libc: '',
lts: false,
channel: '',
formats: [],
limit: 100,
});
var arels = ares.releases;
if (atc.expectOses) {
var actualOses = [];
for (var oi = 0; oi < arels.length; oi++) {
if (actualOses.indexOf(arels[oi].os) === -1) {
actualOses.push(arels[oi].os);
}
}
actualOses.sort();
var missingOses = [];
for (var mi = 0; mi < atc.expectOses.length; mi++) {
if (actualOses.indexOf(atc.expectOses[mi]) === -1) {
missingOses.push(atc.expectOses[mi]);
}
}
if (missingOses.length > 0) {
console.log(' FAIL ' + atc.label + ': missing ' + JSON.stringify(missingOses) + ' (has ' + JSON.stringify(actualOses) + ')');
failures++;
} else {
console.log(' PASS ' + atc.label + ': ' + JSON.stringify(atc.expectOses));
passes++;
}
}
if (atc.expectArches) {
var actualArches = [];
for (var ari = 0; ari < arels.length; ari++) {
if (arels[ari].arch && actualArches.indexOf(arels[ari].arch) === -1) {
actualArches.push(arels[ari].arch);
}
}
actualArches.sort();
var missingArches = [];
for (var mai = 0; mai < atc.expectArches.length; mai++) {
if (actualArches.indexOf(atc.expectArches[mai]) === -1) {
missingArches.push(atc.expectArches[mai]);
}
}
if (missingArches.length > 0) {
console.log(' FAIL ' + atc.label + ': missing ' + JSON.stringify(missingArches) + ' (has ' + JSON.stringify(actualArches) + ')');
failures++;
} else {
console.log(' PASS ' + atc.label);
passes++;
}
}
} catch (e) {
console.log(' FAIL ' + atc.label + ': ' + e.message);
failures++;
}
}
// ================================================================
// Test 6: Version format
// ================================================================
console.log('');
console.log('=== Test 6: Version Format ===');
console.log('');
// Version format check: the Go cache may have 'v' prefixes (that's OK —
// normalize.js and serve-installer.js strip them). What matters is that
// after normalization via transform-releases, versions have no 'v' prefix.
for (var vi = 0; vi < VERSION_CHECKS.length; vi++) {
var vpkg = VERSION_CHECKS[vi];
try {
var vres = await Releases.getReleases({
pkg: vpkg,
ver: '',
os: '',
arch: '',
libc: '',
lts: false,
channel: '',
formats: [],
limit: 10,
});
var vrels = vres.releases;
var badVersions = [];
for (var vri = 0; vri < vrels.length; vri++) {
var ver = vrels[vri].version;
if (!ver) {
badVersions.push('(empty)');
break;
}
if (ver.startsWith('v')) {
badVersions.push(ver);
break;
}
}
if (badVersions.length > 0) {
console.log(' FAIL ' + vpkg + ': v-prefix not stripped: ' + badVersions[0]);
failures++;
} else {
console.log(' PASS ' + vpkg + ': latest=' + (vrels[0] ? vrels[0].version : '?'));
passes++;
}
} catch (e) {
console.log(' FAIL ' + vpkg + ': ' + e.message);
failures++;
}
}
// ================================================================
// Test 7: Channel detection
// ================================================================
console.log('');
console.log('=== Test 7: Channel Detection ===');
console.log('');
for (var ci = 0; ci < CHANNEL_CASES.length; ci++) {
var ctc = CHANNEL_CASES[ci];
try {
var cres = await Releases.getReleases({
pkg: ctc.pkg,
ver: '',
os: '',
arch: '',
libc: '',
lts: false,
channel: ctc.channel,
formats: [],
limit: 100,
});
var count = cres.releases.length;
if (count < ctc.expectMinCount) {
console.log(' FAIL ' + ctc.label + ': only ' + count + ' (want >=' + ctc.expectMinCount + ')');
failures++;
} else {
console.log(' PASS ' + ctc.label + ': ' + count + ' releases');
passes++;
}
} catch (e) {
console.log(' FAIL ' + ctc.label + ': ' + e.message);
failures++;
}
}
// ================================================================
// Summary
// ================================================================
console.log('');
console.log('=== Results: ' + passes + ' passed, ' + failures + ' failed, ' + knowns + ' known, ' + skips + ' skipped ===');
if (failures > 0) {
process.exit(1);
}
}
main().catch(function (err) {
console.error(err.stack);
process.exit(1);
});

339
_webi/test-fleet-diff.js Normal file
View File

@@ -0,0 +1,339 @@
'use strict';
// Fleet-wide diff: compare a candidate host (e.g. beta.webi.sh) against
// production for every cached package, across multiple OS/arch combos.
// Outputs TSV for grep/sort.
//
// Usage:
// node _webi/test-fleet-diff.js
// --cand-url=https://beta.webi.sh
// --prod-url=https://webinstall.dev # default
// --kind=api # default; or "installer"
// --pkgs=bat,go,... # default: all cached packages
// --concurrency=8 # default
// --out=fleet-api.tsv # default: stdout
let Fs = require('node:fs/promises');
let Os = require('node:os');
let Path = require('node:path');
let Https = require('node:https');
let CACHE_DIR = Path.join(Os.homedir(), '.cache/webi/legacy');
function arg(name, dflt) {
for (let a of process.argv) {
if (a.startsWith(`--${name}=`)) {
return a.slice(name.length + 3);
}
}
return dflt;
}
let CAND_URL = arg('cand-url', 'https://beta.webi.sh').replace(/\/+$/, '');
let PROD_URL = arg('prod-url', 'https://webinstall.dev').replace(/\/+$/, '');
let KIND = arg('kind', 'api');
let PKGS_ARG = arg('pkgs', '');
let CONCURRENCY = parseInt(arg('concurrency', '8'), 10);
let OUT = arg('out', '');
// OS/arch matrix for API mode
let API_MATRIX = [
{ os: 'macos', arch: 'amd64' },
{ os: 'macos', arch: 'arm64' },
{ os: 'linux', arch: 'amd64' },
{ os: 'linux', arch: 'arm64' },
{ os: 'linux', arch: 'armv7l' },
{ os: 'windows', arch: 'amd64' },
{ os: 'freebsd', arch: 'amd64' },
];
// UA strings for installer mode
let INSTALLER_MATRIX = [
{ label: 'macos_arm64', ua: 'aarch64/unknown Darwin/24.2.0 libc' },
{ label: 'macos_amd64', ua: 'x86_64/unknown Darwin/23.0.0 libc' },
{ label: 'linux_amd64', ua: 'x86_64/unknown Linux/5.15.0 libc' },
{ label: 'linux_arm64', ua: 'aarch64/unknown Linux/5.15.0 libc' },
{ label: 'linux_musl', ua: 'x86_64/unknown Linux/5.15.0 musl' },
{ label: 'windows_amd64', ua: 'x86_64/unknown Windows/10.0.19041 msvc' },
];
function httpsGet(url, headers) {
return new Promise(function (resolve, reject) {
let opts = { headers: headers || {}, timeout: 15000 };
let req = Https.get(url, opts, function (res) {
// Follow one redirect
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
let redir = res.headers.location;
if (redir.startsWith('/')) {
let m = url.match(/^(https?:\/\/[^/]+)/);
redir = (m ? m[1] : '') + redir;
}
Https.get(redir, opts, function (res2) {
let data = '';
res2.on('data', function (c) { data += c; });
res2.on('end', function () { resolve({ status: res2.statusCode, body: data }); });
}).on('error', reject);
return;
}
let data = '';
res.on('data', function (c) { data += c; });
res.on('end', function () { resolve({ status: res.statusCode, body: data }); });
});
req.on('error', reject);
req.on('timeout', function () { req.destroy(new Error('timeout')); });
});
}
async function listCachedPkgs() {
let entries = await Fs.readdir(CACHE_DIR);
return entries
.filter(function (n) { return n.endsWith('.json') && !n.endsWith('.updated.txt'); })
.map(function (n) { return n.slice(0, -5); })
.sort();
}
function safeFirst(json) {
try {
let arr = JSON.parse(json);
if (!Array.isArray(arr) || arr.length === 0) {
return null;
}
return arr[0];
} catch (e) {
return null;
}
}
function parseInstallerVars(script) {
let vars = {};
let re = /^(?:export\s+)?(WEBI_\w+|PKG_NAME)='([^']*)'/gm;
let m;
while ((m = re.exec(script)) !== null) {
vars[m[1]] = m[2];
}
return vars;
}
async function diffApi(pkg, os, arch) {
let qs = `?os=${os}&arch=${arch}&limit=1`;
let candUrl = `${CAND_URL}/api/releases/${pkg}@stable.json${qs}`;
let prodUrl = `${PROD_URL}/api/releases/${pkg}@stable.json${qs}`;
let cand, prod;
try {
[cand, prod] = await Promise.all([httpsGet(candUrl), httpsGet(prodUrl)]);
} catch (e) {
return { pkg, os, arch, status: 'fetch_error', detail: e.message };
}
if (cand.status !== 200 || prod.status !== 200) {
return {
pkg, os, arch, status: 'http_error',
detail: `cand=${cand.status} prod=${prod.status}`,
};
}
let candFirst = safeFirst(cand.body);
let prodFirst = safeFirst(prod.body);
let candErr = !candFirst || candFirst.channel === 'error';
let prodErr = !prodFirst || prodFirst.channel === 'error';
if (candErr && prodErr) {
return { pkg, os, arch, status: 'both_error', detail: '' };
}
if (candErr && !prodErr) {
return {
pkg, os, arch, status: 'cand_only_error',
detail: `prod=${prodFirst.version}/${prodFirst.ext}`,
};
}
if (!candErr && prodErr) {
return {
pkg, os, arch, status: 'prod_only_error',
detail: `cand=${candFirst.version}/${candFirst.ext}`,
};
}
// Both succeeded — diff the key fields
let diffs = [];
for (let f of ['os', 'arch', 'libc', 'ext']) {
if (candFirst[f] !== prodFirst[f]) {
diffs.push(`${f}:cand=${candFirst[f]}|prod=${prodFirst[f]}`);
}
}
let ver = candFirst.version === prodFirst.version
? candFirst.version
: `cand=${candFirst.version}|prod=${prodFirst.version}`;
return {
pkg, os, arch,
status: diffs.length === 0 ? 'match' : 'diff',
detail: diffs.length === 0 ? `v${ver} ${candFirst.ext}` : `v${ver} ${diffs.join(',')}`,
};
}
async function diffInstaller(pkg, label, ua) {
let candUrl = `${CAND_URL}/api/installers/${pkg}@stable.sh`;
let prodUrl = `${PROD_URL}/api/installers/${pkg}@stable.sh`;
let headers = { 'User-Agent': ua };
let cand, prod;
try {
[cand, prod] = await Promise.all([
httpsGet(candUrl, headers),
httpsGet(prodUrl, headers),
]);
} catch (e) {
return { pkg, label, status: 'fetch_error', detail: e.message };
}
if (cand.status !== 200 || prod.status !== 200) {
return {
pkg, label, status: 'http_error',
detail: `cand=${cand.status} prod=${prod.status}`,
};
}
let candVars = parseInstallerVars(cand.body);
let prodVars = parseInstallerVars(prod.body);
let candHas = candVars.WEBI_PKG_URL && candVars.WEBI_EXT && candVars.WEBI_EXT !== 'err';
let prodHas = prodVars.WEBI_PKG_URL && prodVars.WEBI_EXT && prodVars.WEBI_EXT !== 'err';
if (!candHas && !prodHas) {
return { pkg, label, status: 'both_error', detail: '' };
}
if (!candHas && prodHas) {
return {
pkg, label, status: 'cand_only_error',
detail: `prod=${prodVars.WEBI_VERSION}/${prodVars.WEBI_EXT}`,
};
}
if (candHas && !prodHas) {
return {
pkg, label, status: 'prod_only_error',
detail: `cand=${candVars.WEBI_VERSION}/${candVars.WEBI_EXT}`,
};
}
// Diff WEBI_OS, WEBI_ARCH, WEBI_EXT (PKG_NAME may differ for aliases)
let diffs = [];
for (let v of ['WEBI_OS', 'WEBI_ARCH', 'WEBI_EXT']) {
if (candVars[v] !== prodVars[v]) {
diffs.push(`${v}:cand=${candVars[v]}|prod=${prodVars[v]}`);
}
}
let ver = candVars.WEBI_VERSION === prodVars.WEBI_VERSION
? candVars.WEBI_VERSION
: `cand=${candVars.WEBI_VERSION}|prod=${prodVars.WEBI_VERSION}`;
return {
pkg, label,
status: diffs.length === 0 ? 'match' : 'diff',
detail: diffs.length === 0 ? `v${ver} ${candVars.WEBI_EXT}` : `v${ver} ${diffs.join(',')}`,
};
}
async function pool(items, fn, concurrency) {
let results = new Array(items.length);
let i = 0;
async function worker() {
while (true) {
let idx = i++;
if (idx >= items.length) {
return;
}
try {
results[idx] = await fn(items[idx], idx);
} catch (e) {
results[idx] = { status: 'exception', detail: e.message, _item: items[idx] };
}
}
}
let workers = [];
for (let k = 0; k < concurrency; k++) {
workers.push(worker());
}
await Promise.all(workers);
return results;
}
async function main() {
let pkgs;
if (PKGS_ARG) {
pkgs = PKGS_ARG.split(',').filter(Boolean);
} else {
pkgs = await listCachedPkgs();
}
console.error(`Comparing ${pkgs.length} packages: ${CAND_URL} (cand) vs ${PROD_URL} (prod)`);
console.error(`Mode: ${KIND}, concurrency: ${CONCURRENCY}`);
let jobs = [];
if (KIND === 'api') {
for (let pkg of pkgs) {
for (let combo of API_MATRIX) {
jobs.push({ pkg, os: combo.os, arch: combo.arch });
}
}
} else if (KIND === 'installer') {
for (let pkg of pkgs) {
for (let combo of INSTALLER_MATRIX) {
jobs.push({ pkg, label: combo.label, ua: combo.ua });
}
}
} else {
console.error(`Unknown kind: ${KIND}`);
process.exit(2);
}
let started = Date.now();
let results = await pool(jobs, async function (job) {
if (KIND === 'api') {
return diffApi(job.pkg, job.os, job.arch);
}
return diffInstaller(job.pkg, job.label, job.ua);
}, CONCURRENCY);
let elapsed = ((Date.now() - started) / 1000).toFixed(1);
// TSV output
let lines = [];
if (KIND === 'api') {
lines.push(['pkg', 'os', 'arch', 'status', 'detail'].join('\t'));
for (let r of results) {
lines.push([r.pkg, r.os, r.arch, r.status, r.detail || ''].join('\t'));
}
} else {
lines.push(['pkg', 'target', 'status', 'detail'].join('\t'));
for (let r of results) {
lines.push([r.pkg, r.label, r.status, r.detail || ''].join('\t'));
}
}
let body = lines.join('\n') + '\n';
if (OUT) {
await Fs.writeFile(OUT, body, 'utf8');
console.error(`Wrote ${OUT}`);
} else {
process.stdout.write(body);
}
// Summary to stderr
let counts = {};
for (let r of results) {
counts[r.status] = (counts[r.status] || 0) + 1;
}
console.error('');
console.error(`=== Summary (${elapsed}s, ${results.length} jobs) ===`);
for (let s of Object.keys(counts).sort()) {
console.error(` ${s}: ${counts[s]}`);
}
}
main().catch(function (err) {
console.error(err.stack);
process.exit(1);
});

View File

@@ -0,0 +1,444 @@
'use strict';
let InstallerServer = require('./serve-installer.js');
let Builds = require('./builds.js');
// Real User-Agent strings sent by webi bootstrap scripts.
//
// Libc taxonomy:
// none = static build, no runtime libc dep (often built with musl, but self-contained)
// musl = requires musl C/C++ runtime at runtime (e.g. node-musl)
// gnu = requires glibc at runtime (crashes on musl-only/Alpine)
// libc = host UA value meaning "I have glibc" (not used in release metadata)
//
// Known issues:
//
// 1. WATERFALL libc vs gnu: The WATERFALL maps `libc` => ['none', 'libc']
// but never tries 'gnu'. Packages with glibc-linked builds (libc='gnu' in
// Go cache) won't match for hosts reporting 'libc'. Fix: update WATERFALL
// to `libc: ['none', 'gnu', 'libc']` in build-classifier submodule.
//
// 2. Go cache .git regression: The Go cache includes .git source repo URLs
// as releases, creating ANYOS/ANYARCH triplets. These match before
// platform-specific binaries. Fix: exclude .git from Go cache output.
let UA_CASES = [
// === macOS (no libc issue — darwin uses libc='none') ===
{
label: 'bat macOS arm64',
pkg: 'bat',
ua: 'aarch64/unknown Darwin/24.2.0 libc',
expectOs: 'darwin',
expectArch: 'aarch64',
expectExt: 'tar.gz',
},
{
label: 'bat macOS amd64',
pkg: 'bat',
ua: 'x86_64/unknown Darwin/23.0.0 libc',
expectOs: 'darwin',
expectArch: 'x86_64',
expectExt: 'tar.gz',
},
{
label: 'go macOS arm64',
pkg: 'go',
ua: 'aarch64/unknown Darwin/24.2.0 libc',
expectOs: 'darwin',
expectArch: 'aarch64',
expectExt: 'tar.gz',
},
{
label: 'node macOS arm64',
pkg: 'node',
ua: 'aarch64/unknown Darwin/24.2.0 libc',
expectOs: 'darwin',
expectArch: 'aarch64',
expectExt: 'tar.xz',
},
{
label: 'rg macOS arm64',
pkg: 'rg',
ua: 'aarch64/unknown Darwin/24.2.0 libc',
expectOs: 'darwin',
expectArch: 'aarch64',
expectExt: 'tar.gz',
},
// === macOS universal2 — packages where recent darwin builds are universal-only ===
// These currently resolve to ancient versions because universal2 entries are
// dropped by the classifier. The GOER's legacy export needs to emit these
// with arch: "x86_64" so the classifier accepts them. The darwin WATERFALL
// (aarch64 falls back to x86_64) handles aarch64 users.
{
label: 'cmake macOS arm64 (universal2)',
pkg: 'cmake',
ua: 'aarch64/unknown Darwin/24.2.0 libc',
expectOs: 'darwin',
expectExt: 'tar.gz',
expectMinVersion: '4.0.0',
known: true,
},
{
label: 'cmake macOS amd64 (universal2)',
pkg: 'cmake',
ua: 'x86_64/unknown Darwin/23.0.0 libc',
expectOs: 'darwin',
expectExt: 'tar.gz',
expectMinVersion: '4.0.0',
known: true,
},
{
label: 'hugo macOS arm64 (universal2)',
pkg: 'hugo',
ua: 'aarch64/unknown Darwin/24.2.0 libc',
expectOs: 'darwin',
expectExt: 'tar.gz',
expectMinVersion: '0.140.0',
known: true,
},
{
label: 'hugo macOS amd64 (universal2)',
pkg: 'hugo',
ua: 'x86_64/unknown Darwin/23.0.0 libc',
expectOs: 'darwin',
expectExt: 'tar.gz',
expectMinVersion: '0.140.0',
known: true,
},
// === Windows ===
{
label: 'bat Windows amd64',
pkg: 'bat',
ua: 'x86_64/unknown Windows/10.0.19041 msvc',
expectOs: 'windows',
expectArch: 'x86_64',
expectExt: 'zip',
},
{
label: 'go Windows amd64',
pkg: 'go',
ua: 'x86_64/unknown Windows/10.0.19041 msvc',
expectOs: 'windows',
expectArch: 'x86_64',
expectExt: 'zip',
},
// === Linux musl (Alpine/Docker) ===
{
label: 'bat Linux musl',
pkg: 'bat',
ua: 'x86_64/unknown Linux/5.15.0 musl',
expectOs: 'linux',
expectArch: 'x86_64',
expectExt: 'tar.gz',
},
// === Linux glibc — packages with libc='none' in cache ===
{
label: 'go Linux amd64',
pkg: 'go',
ua: 'x86_64/unknown Linux/5.15.0 libc',
expectOs: 'linux',
expectArch: 'x86_64',
expectExt: 'tar.gz',
},
// === Linux glibc — packages with libc='gnu' in cache ===
// These previously failed (WATERFALL libc→gnu gap). Fixed by adding
// 'gnu' to the libc candidates for glibc hosts in _enumerateTriplets.
{
label: 'bat Linux amd64',
pkg: 'bat',
ua: 'x86_64/unknown Linux/5.15.0 libc',
expectOs: 'linux',
expectArch: 'x86_64',
expectExt: 'tar.gz',
},
{
label: 'rg Linux amd64',
pkg: 'rg',
ua: 'x86_64/unknown Linux/5.15.0 libc',
expectOs: 'linux',
expectArch: 'x86_64',
expectExt: 'tar.gz',
},
{
label: 'node Linux amd64',
pkg: 'node',
ua: 'x86_64/unknown Linux/5.15.0 libc',
expectOs: 'linux',
expectArch: 'x86_64',
expectExt: 'tar.xz',
},
// === Packages with .git source URLs in old releases ===
// These previously failed (ANYOS .git matched before platform binary).
// Fixed by putting specific OS before ANYOS in triplet enumeration.
{
label: 'jq macOS arm64',
pkg: 'jq',
ua: 'aarch64/unknown Darwin/24.2.0 libc',
expectOs: 'darwin',
expectArch: 'aarch64',
expectExt: 'exe',
},
{
label: 'caddy macOS arm64',
pkg: 'caddy',
ua: 'aarch64/unknown Darwin/24.2.0 libc',
expectOs: 'darwin',
expectArch: 'aarch64',
expectExt: 'tar.gz',
},
{
label: 'caddy Linux amd64',
pkg: 'caddy',
ua: 'x86_64/unknown Linux/5.15.0 libc',
expectOs: 'linux',
expectArch: 'x86_64',
expectExt: 'tar.gz',
},
];
async function main() {
let failures = 0;
let passes = 0;
let knowns = 0;
let errors = 0;
console.log('Initializing build cache...');
await Builds.init();
console.log('');
console.log('=== Installer Resolution Tests ===');
console.log('');
for (let tc of UA_CASES) {
try {
let [pkg, params] = await InstallerServer.helper({
unameAgent: tc.ua,
projectName: tc.pkg,
tag: 'stable',
formats: ['tar', 'exe', 'zip', 'xz', 'dmg'],
libc: '',
});
// Known issue — just verify it fails as expected
if (tc.known) {
let isError = pkg.channel === 'error' || !pkg.download || pkg.download.includes('doesntexist') || pkg.ext === 'git';
let isStale = false;
if (tc.expectMinVersion && pkg.version) {
let got = pkg.version.replace(/^v/, '').split('.').map(Number);
let want = tc.expectMinVersion.split('.').map(Number);
for (let i = 0; i < want.length; i++) {
if ((got[i] || 0) < want[i]) { isStale = true; break; }
if ((got[i] || 0) > want[i]) { break; }
}
}
if (isError || isStale) {
let detail = isStale ? `stale v${pkg.version} < v${tc.expectMinVersion}` : '';
console.log(` KNOWN ${tc.label}${detail ? ': ' + detail : ''}`);
knowns++;
} else {
console.log(` PASS ${tc.label} (known issue resolved!): v${pkg.version} .${pkg.ext} ${(pkg.download || '').split('/').pop()}`);
passes++;
}
continue;
}
if (pkg.channel === 'error') {
console.log(` FAIL ${tc.label}: resolved to error package`);
failures++;
continue;
}
let diffs = [];
if (tc.expectOs && pkg.os !== tc.expectOs) {
diffs.push(`os: got=${pkg.os} want=${tc.expectOs}`);
}
if (tc.expectArch && pkg.arch !== tc.expectArch) {
diffs.push(`arch: got=${pkg.arch} want=${tc.expectArch}`);
}
if (tc.expectExt && pkg.ext !== tc.expectExt) {
diffs.push(`ext: got=${pkg.ext} want=${tc.expectExt}`);
}
if (!pkg.version || pkg.version === '0.0.0') {
diffs.push('version: missing or zero');
}
if (!pkg.download || pkg.download.includes('doesntexist')) {
diffs.push('download: missing or error');
}
if (diffs.length > 0) {
console.log(` FAIL ${tc.label}: ${diffs.join(', ')}`);
failures++;
} else {
console.log(` PASS ${tc.label}: v${pkg.version} .${pkg.ext} ${pkg.download.split('/').pop()}`);
passes++;
}
} catch (err) {
if (tc.known) {
console.log(` KNOWN ${tc.label} (error: ${err.message})`);
knowns++;
continue;
}
console.log(` ERROR ${tc.label}: ${err.message}`);
errors++;
}
}
console.log('');
console.log(`=== Results: ${passes} passed, ${failures} failed, ${knowns} known, ${errors} errors ===`);
if (failures > 0 || errors > 0) {
process.exit(1);
}
// Cache value validation: the classifier re-parses filenames and rejects
// entries where the cache os/arch doesn't match. These checks prevent
// regressions where someone "normalizes" cache values in a way that
// breaks the classifier.
console.log('');
console.log('=== Cache Value Validation ===');
console.log('');
let cacheFailures = await validateCacheValues();
if (cacheFailures > 0) {
process.exit(1);
}
}
// Verify that cache os/arch values match what the Node classifier expects
// to extract from the download filename. The classifier is a submodule and
// is NOT being modified — the cache must emit values it already recognizes.
//
// Known bug (LIVE_cache): the Go legacy export previously translated
// solaris/illumos → sunos in the cache, but the filenames still say
// solaris/illumos. The classifier detects the filename value and rejects
// the entry when it doesn't match. Same issue with universal2 arch.
//
// Rule: cache os/arch must match the filename, not some "canonical" form.
// Cache os/arch values must match what the Node classifier extracts from the
// download filename. The classifier already recognizes solaris, illumos, sunos,
// armhf, armel, etc. — these are not new values. The only value the classifier
// does NOT recognize is "universal2" — use "x86_64" instead.
//
// matchField: which field to check in the release entry ('name' or 'download')
let CACHE_CHECKS = [
// The classifier knows "solaris" as an OS. Filenames/URLs say "solaris".
// Do NOT translate to "sunos" — that creates a mismatch and drops the entry.
{
label: 'terraform solaris entries have os=solaris (not sunos)',
pkg: 'terraform',
matchField: 'download',
filenameMatch: /solaris/,
field: 'os',
expect: 'solaris',
},
{
label: 'syncthing solaris entries have os=solaris (not sunos)',
pkg: 'syncthing',
matchField: 'download',
filenameMatch: /solaris/,
field: 'os',
expect: 'solaris',
},
// The classifier knows "illumos" as an OS. Don't translate to sunos.
{
label: 'syncthing illumos entries have os=illumos (not sunos)',
pkg: 'syncthing',
matchField: 'download',
filenameMatch: /illumos/,
field: 'os',
expect: 'illumos',
},
// node.js uses "sunos" in filenames — cache must say "sunos" (already correct)
{
label: 'node sunos entries have os=sunos',
pkg: 'node',
matchField: 'name',
filenameMatch: /sunos/,
field: 'os',
expect: 'sunos',
},
// The classifier maps "universal" in filenames → x86_64. The classifier does
// NOT recognize "universal2". Cache must say arch="x86_64" for these entries.
// aarch64 users get them via the darwin WATERFALL (aarch64 → x86_64 fallback).
{
label: 'cmake universal entries have arch=x86_64 (not universal2)',
pkg: 'cmake',
matchField: 'download',
filenameMatch: /universal/,
field: 'arch',
expect: 'x86_64',
},
{
label: 'hugo universal entries have arch=x86_64 (not universal2)',
pkg: 'hugo',
matchField: 'download',
filenameMatch: /universal/,
field: 'arch',
expect: 'x86_64',
},
];
async function validateCacheValues() {
let Os = require('node:os');
let Path = require('path');
let Fs = require('fs');
let cachePath = Path.join(Os.homedir(), '.cache/webi/legacy');
if (!Fs.existsSync(cachePath)) {
console.log(' SKIP: no cache directory at ' + cachePath);
return 0;
}
let failures = 0;
for (let check of CACHE_CHECKS) {
let filePath = Path.join(cachePath, `${check.pkg}.json`);
if (!Fs.existsSync(filePath)) {
console.log(` SKIP ${check.label}: no cache file`);
continue;
}
let data = JSON.parse(Fs.readFileSync(filePath, 'utf8'));
let matchField = check.matchField || 'name';
let matched = data.releases.filter(function (r) {
return check.filenameMatch.test(r[matchField]);
});
if (matched.length === 0) {
console.log(` SKIP ${check.label}: no matching filenames`);
continue;
}
let wrong = matched.filter(function (r) {
return r[check.field] !== check.expect;
});
if (wrong.length > 0) {
let sample = wrong[0];
console.log(
` FAIL ${check.label}: ${wrong.length}/${matched.length} entries have` +
` ${check.field}="${sample[check.field]}" (want "${check.expect}")` +
` e.g. ${sample.name}`,
);
failures++;
} else {
console.log(` PASS ${check.label}: ${matched.length} entries OK`);
}
}
console.log('');
console.log(`=== Cache Validation: ${CACHE_CHECKS.length - failures} passed, ${failures} failed ===`);
return failures;
}
main().catch(function (err) {
console.error(err.stack);
process.exit(1);
});

View File

@@ -0,0 +1,450 @@
'use strict';
// Compare _cache vs LIVE_cache for correctness and compatibility.
//
// Rules (from NODER_PURPOSE.md):
// - _cache should be more complete and more correct than LIVE_cache
// - Must NOT introduce new tags (OS, arch, libc) that don't exist in LIVE_cache
// - Must NOT break compatibility with existing data
//
// Usage: node _webi/test-live-cache-diff.js
var Fs = require('node:fs');
var Os = require('node:os');
var Path = require('node:path');
// CACHE_DIR is the live cache produced by webicached (flat layout).
// LIVE_DIR is the historical snapshot taken pre-cutover (month-bucketed).
var CACHE_DIR = Path.join(Os.homedir(), '.cache/webi/legacy');
var LIVE_DIR = Path.join(__dirname, '..', 'LIVE_cache');
// resolveLayout: figures out whether dir uses the flat layout
// (~/.cache/webi/legacy/<pkg>.json) or the legacy month-bucketed layout
// (LIVE_cache/<YYYY-MM>/<pkg>.json) and returns the directory to read from.
function resolveLayout(dir) {
if (!Fs.existsSync(dir)) {
return null;
}
var entries = Fs.readdirSync(dir);
var months = entries
.filter(function (d) { return /^\d{4}-\d{2}$/.test(d); })
.sort()
.reverse();
if (months[0]) {
return Path.join(dir, months[0]);
}
// Flat layout (cache files directly under dir).
return dir;
}
function loadReleases(layoutPath, pkg) {
var file = Path.join(layoutPath, pkg + '.json');
if (!Fs.existsSync(file)) {
return null;
}
try {
return JSON.parse(Fs.readFileSync(file, 'utf8'));
} catch (e) {
return null;
}
}
function uniqueValues(releases, field) {
var seen = {};
for (var i = 0; i < releases.length; i++) {
var val = releases[i][field];
if (val !== null && val !== undefined && val !== '') {
seen[val] = true;
}
}
return Object.keys(seen).sort();
}
async function main() {
var passes = 0;
var failures = 0;
var warns = 0;
var cachePath = resolveLayout(CACHE_DIR);
var livePath = resolveLayout(LIVE_DIR);
if (!cachePath) {
console.error('No _cache directory found');
process.exit(1);
}
if (!livePath) {
console.error('No LIVE_cache directory found');
process.exit(1);
}
console.log('Using cache: ' + cachePath + ' vs ' + livePath);
console.log('');
// Get all packages that exist in both caches
var cacheFiles = Fs.readdirSync(cachePath)
.filter(function (f) { return f.endsWith('.json'); })
.map(function (f) { return f.replace('.json', ''); });
var liveFiles = Fs.readdirSync(livePath)
.filter(function (f) { return f.endsWith('.json'); })
.map(function (f) { return f.replace('.json', ''); });
var cacheSet = {};
var liveSet = {};
cacheFiles.forEach(function (f) { cacheSet[f] = true; });
liveFiles.forEach(function (f) { liveSet[f] = true; });
var common = cacheFiles.filter(function (f) { return liveSet[f]; });
// ================================================================
// Test 1: Global vocabulary — no new OS/arch/libc tags
// ================================================================
console.log('=== Test 1: No New Tags (OS/Arch/Libc) ===');
console.log('');
var allLiveOs = {};
var allLiveArch = {};
var allLiveLibc = {};
var allCacheOs = {};
var allCacheArch = {};
var allCacheLibc = {};
for (var ci = 0; ci < common.length; ci++) {
var pkg = common[ci];
var liveData = loadReleases(livePath, pkg);
var cacheData = loadReleases(cachePath, pkg);
if (!liveData || !cacheData) { continue; }
uniqueValues(liveData.releases, 'os').forEach(function (v) { allLiveOs[v] = true; });
uniqueValues(liveData.releases, 'arch').forEach(function (v) { allLiveArch[v] = true; });
uniqueValues(liveData.releases, 'libc').forEach(function (v) { allLiveLibc[v] = true; });
uniqueValues(cacheData.releases, 'os').forEach(function (v) { allCacheOs[v] = true; });
uniqueValues(cacheData.releases, 'arch').forEach(function (v) { allCacheArch[v] = true; });
uniqueValues(cacheData.releases, 'libc').forEach(function (v) { allCacheLibc[v] = true; });
}
var newOs = Object.keys(allCacheOs).filter(function (v) { return !allLiveOs[v]; }).sort();
var newArch = Object.keys(allCacheArch).filter(function (v) { return !allLiveArch[v]; }).sort();
var newLibc = Object.keys(allCacheLibc).filter(function (v) { return !allLiveLibc[v]; }).sort();
if (newOs.length > 0) {
console.log(' FAIL new OS values in _cache not in LIVE_cache: ' + JSON.stringify(newOs));
failures++;
} else {
console.log(' PASS no new OS values');
passes++;
}
if (newArch.length > 0) {
console.log(' FAIL new arch values in _cache not in LIVE_cache: ' + JSON.stringify(newArch));
failures++;
} else {
console.log(' PASS no new arch values');
passes++;
}
if (newLibc.length > 0) {
console.log(' FAIL new libc values in _cache not in LIVE_cache: ' + JSON.stringify(newLibc));
failures++;
} else {
console.log(' PASS no new libc values');
passes++;
}
// Show what LIVE has that _cache doesn't (informational)
var missingOs = Object.keys(allLiveOs).filter(function (v) { return !allCacheOs[v]; }).sort();
var missingArch = Object.keys(allLiveArch).filter(function (v) { return !allCacheArch[v]; }).sort();
if (missingOs.length > 0) {
console.log(' INFO OS in LIVE but not _cache: ' + JSON.stringify(missingOs));
}
if (missingArch.length > 0) {
console.log(' INFO arch in LIVE but not _cache: ' + JSON.stringify(missingArch));
}
// ================================================================
// Test 2: Per-package release count — _cache should have >= LIVE
// ================================================================
console.log('');
console.log('=== Test 2: Release Count (per package) ===');
console.log('');
// LIVE_cache includes junk entries (.pem, .sig, .sha256, .deb, .rpm, etc.)
// that Go correctly filters out. Only count installable entries.
var junkExts = /\.(pem|sig|asc|sha256|sha512|sha256sum|sha512sum|deb|rpm|apk|sbom|json|txt|sum|md5|cosign-bundle|intoto-jsonl)$/i;
function countInstallable(releases) {
var n = 0;
for (var i = 0; i < releases.length; i++) {
if (!junkExts.test(releases[i].name || '')) {
n++;
}
}
return n;
}
var countIssues = [];
for (var pi = 0; pi < common.length; pi++) {
var ppkg = common[pi];
var pLive = loadReleases(livePath, ppkg);
var pCache = loadReleases(cachePath, ppkg);
if (!pLive || !pCache) { continue; }
var liveCount = countInstallable(pLive.releases);
var cacheCount = pCache.releases.length;
// _cache should have at least as many installable releases as LIVE
var ratio = liveCount > 0 ? cacheCount / liveCount : 1;
if (ratio < 0.5 && liveCount > 10) {
countIssues.push({ pkg: ppkg, live: liveCount, cache: cacheCount, ratio: ratio });
}
}
if (countIssues.length === 0) {
console.log(' PASS all packages have adequate release counts');
passes++;
} else {
for (var ii = 0; ii < countIssues.length; ii++) {
var issue = countIssues[ii];
console.log(' FAIL ' + issue.pkg + ': _cache=' + issue.cache + ' LIVE=' + issue.live + ' (ratio=' + issue.ratio.toFixed(2) + ')');
failures++;
}
}
// ================================================================
// Test 3: Per-package OS coverage — _cache should have same core OSes
// ================================================================
console.log('');
console.log('=== Test 3: OS Coverage (per package) ===');
console.log('');
var coreOses = ['darwin', 'linux', 'windows'];
var osIssues = [];
for (var oi = 0; oi < common.length; oi++) {
var opkg = common[oi];
var oLive = loadReleases(livePath, opkg);
var oCache = loadReleases(cachePath, opkg);
if (!oLive || !oCache) { continue; }
var liveOses = uniqueValues(oLive.releases, 'os');
var cacheOses = uniqueValues(oCache.releases, 'os');
// For each core OS in LIVE, it should also be in _cache
for (var coi = 0; coi < coreOses.length; coi++) {
var os = coreOses[coi];
if (liveOses.indexOf(os) >= 0 && cacheOses.indexOf(os) < 0) {
osIssues.push({ pkg: opkg, os: os });
}
}
}
if (osIssues.length === 0) {
console.log(' PASS all packages have matching core OS coverage');
passes++;
} else {
for (var oii = 0; oii < osIssues.length; oii++) {
var oisue = osIssues[oii];
console.log(' FAIL ' + oisue.pkg + ': missing os=' + oisue.os + ' (present in LIVE)');
failures++;
}
}
// ================================================================
// Test 4: Per-package new tags — flag packages introducing new values
// ================================================================
console.log('');
console.log('=== Test 4: Per-Package New Tags ===');
console.log('');
var tagIssues = [];
var tagSkipped = 0;
for (var ti = 0; ti < common.length; ti++) {
var tpkg = common[ti];
var tLive = loadReleases(livePath, tpkg);
var tCache = loadReleases(cachePath, tpkg);
if (!tLive || !tCache) { continue; }
var tLiveOs = {};
var tLiveArch = {};
uniqueValues(tLive.releases, 'os').forEach(function (v) { tLiveOs[v] = true; });
uniqueValues(tLive.releases, 'arch').forEach(function (v) { tLiveArch[v] = true; });
// Skip packages where LIVE has no classified entries (all empty os/arch).
// These are github_releases packages where classification happens at query
// time. Our _cache filling in values is an improvement, not a regression.
var liveOsKeys = Object.keys(tLiveOs);
var liveArchKeys = Object.keys(tLiveArch);
if (liveOsKeys.length === 0 && liveArchKeys.length === 0) {
tagSkipped++;
continue;
}
var tCacheOs = uniqueValues(tCache.releases, 'os');
var tCacheArch = uniqueValues(tCache.releases, 'arch');
var tNewOs = tCacheOs.filter(function (v) { return !tLiveOs[v]; });
var tNewArch = tCacheArch.filter(function (v) { return !tLiveArch[v]; });
if (tNewOs.length > 0 || tNewArch.length > 0) {
tagIssues.push({
pkg: tpkg,
newOs: tNewOs,
newArch: tNewArch,
});
}
}
if (tagSkipped > 0) {
console.log(' INFO skipped ' + tagSkipped + ' packages with no LIVE classification (unclassified github_releases)');
}
if (tagIssues.length === 0) {
console.log(' PASS no pre-classified packages introduce new tags');
passes++;
} else {
for (var tii = 0; tii < tagIssues.length; tii++) {
var tissue = tagIssues[tii];
var parts = [];
if (tissue.newOs.length > 0) {
parts.push('os: ' + JSON.stringify(tissue.newOs));
}
if (tissue.newArch.length > 0) {
parts.push('arch: ' + JSON.stringify(tissue.newArch));
}
console.log(' WARN ' + tissue.pkg + ': new tags: ' + parts.join(', '));
warns++;
}
}
// ================================================================
// Test 5: Latest stable version — _cache should be >= LIVE
// ================================================================
console.log('');
console.log('=== Test 5: Latest Stable Version ===');
console.log('');
var stableCheckPkgs = ['bat', 'go', 'node', 'rg', 'caddy', 'jq', 'hugo', 'terraform'];
for (var si = 0; si < stableCheckPkgs.length; si++) {
var spkg = stableCheckPkgs[si];
var sLive = loadReleases(livePath, spkg);
var sCache = loadReleases(cachePath, spkg);
if (!sLive || !sCache) {
console.log(' SKIP ' + spkg + ': missing data');
continue;
}
// Find first stable release in each
var liveStable = sLive.releases.find(function (r) { return r.channel === 'stable'; });
var cacheStable = sCache.releases.find(function (r) { return r.channel === 'stable'; });
if (!liveStable || !cacheStable) {
console.log(' SKIP ' + spkg + ': no stable release found');
continue;
}
var lv = (liveStable.version || '').replace(/^v/, '');
var cv = (cacheStable.version || '').replace(/^v/, '');
if (lv === cv) {
console.log(' PASS ' + spkg + ': ' + cv);
passes++;
} else {
// Just warn — versions may differ due to cache age
console.log(' WARN ' + spkg + ': LIVE=' + lv + ' _cache=' + cv);
warns++;
}
}
// ================================================================
// Test 6: Download URLs — all entries should have valid URLs
// ================================================================
console.log('');
console.log('=== Test 6: Download URL Validity ===');
console.log('');
var urlIssues = [];
for (var ui = 0; ui < common.length; ui++) {
var upkg = common[ui];
var uCache = loadReleases(cachePath, upkg);
if (!uCache) { continue; }
var emptyUrls = 0;
var badUrls = 0;
for (var uri = 0; uri < uCache.releases.length; uri++) {
var rel = uCache.releases[uri];
var url = rel.download || '';
if (url === '') {
emptyUrls++;
} else if (!/^https?:\/\//.test(url)) {
badUrls++;
}
}
if (emptyUrls > 0 || badUrls > 0) {
urlIssues.push({ pkg: upkg, empty: emptyUrls, bad: badUrls });
}
}
if (urlIssues.length === 0) {
console.log(' PASS all packages have valid download URLs');
passes++;
} else {
for (var uii = 0; uii < urlIssues.length; uii++) {
var uissue = urlIssues[uii];
var uparts = [];
if (uissue.empty > 0) { uparts.push(uissue.empty + ' empty'); }
if (uissue.bad > 0) { uparts.push(uissue.bad + ' malformed'); }
console.log(' FAIL ' + uissue.pkg + ': ' + uparts.join(', '));
failures++;
}
}
// ================================================================
// Test 7: Required fields — all entries should have version + name
// ================================================================
console.log('');
console.log('=== Test 7: Required Fields ===');
console.log('');
var fieldIssues = [];
for (var fi = 0; fi < common.length; fi++) {
var fpkg = common[fi];
var fCache = loadReleases(cachePath, fpkg);
if (!fCache) { continue; }
var noVersion = 0;
var noName = 0;
for (var fri = 0; fri < fCache.releases.length; fri++) {
var frel = fCache.releases[fri];
if (!frel.version) { noVersion++; }
if (!frel.name && !frel.download) { noName++; }
}
if (noVersion > 0 || noName > 0) {
fieldIssues.push({ pkg: fpkg, noVersion: noVersion, noName: noName });
}
}
if (fieldIssues.length === 0) {
console.log(' PASS all packages have version and name/download');
passes++;
} else {
for (var fii = 0; fii < fieldIssues.length; fii++) {
var fissue = fieldIssues[fii];
var fparts = [];
if (fissue.noVersion > 0) { fparts.push(fissue.noVersion + ' missing version'); }
if (fissue.noName > 0) { fparts.push(fissue.noName + ' missing name+download'); }
console.log(' FAIL ' + fissue.pkg + ': ' + fparts.join(', '));
failures++;
}
}
// ================================================================
// Summary
// ================================================================
console.log('');
console.log('=== Results: ' + passes + ' passed, ' + failures + ' failed, ' + warns + ' warnings ===');
if (failures > 0) {
process.exit(1);
}
}
main().catch(function (err) {
console.error(err.stack);
process.exit(1);
});

577
_webi/test-live-compare.js Normal file
View File

@@ -0,0 +1,577 @@
'use strict';
// Comprehensive live-vs-local comparison test.
// Fetches from a remote API (default: webinstall.dev) and compares against
// local cache-only output to catch regressions.
//
// Usage:
// node _webi/test-live-compare.js # compare against prod
// node _webi/test-live-compare.js --refresh # refresh golden data
// node _webi/test-live-compare.js --base-url=https://beta.webi.sh
// node _webi/test-live-compare.js --all # all cached pkgs
// node _webi/test-live-compare.js --all --tsv # TSV output
// node _webi/test-live-compare.js --base-url=https://webi.sh \
// --cand-url=https://beta.webi.sh # remote-vs-remote
// # (Test 4 only;
// # no local cache
// # needed)
let Fs = require('node:fs/promises');
let Os = require('node:os');
let Path = require('node:path');
let Https = require('node:https');
let Releases = require('./transform-releases.js');
let InstallerServer = require('./serve-installer.js');
let Builds = require('./builds.js');
let TESTDATA_DIR = Path.join(__dirname, 'testdata');
let CACHE_DIR = Path.join(Os.homedir(), '.cache/webi/legacy');
let REFRESH = process.argv.includes('--refresh');
let ALL_PKGS = process.argv.includes('--all');
let TSV = process.argv.includes('--tsv');
let BASE_URL = 'https://webinstall.dev';
let CAND_URL = '';
for (let arg of process.argv) {
if (arg.startsWith('--base-url=')) {
BASE_URL = arg.slice('--base-url='.length).replace(/\/+$/, '');
} else if (arg.startsWith('--cand-url=')) {
CAND_URL = arg.slice('--cand-url='.length).replace(/\/+$/, '');
}
}
// Packages to test — mix of Go-built, Rust-built, and C/C++ projects
let TEST_PKGS = ['bat', 'go', 'node', 'rg', 'jq', 'caddy'];
async function listCachedPkgs() {
let entries;
try {
entries = await Fs.readdir(CACHE_DIR);
} catch (e) {
console.error(`No cache directory: ${CACHE_DIR}`);
return [];
}
let pkgs = entries
.filter(function (n) { return n.endsWith('.json'); })
.map(function (n) { return n.slice(0, -5); })
.sort();
return pkgs;
}
// OS/arch combos for filtered release API tests
let RELEASE_API_CASES = [
{ os: 'macos', arch: 'amd64' },
{ os: 'macos', arch: 'arm64' },
{ os: 'linux', arch: 'amd64' },
{ os: 'windows', arch: 'amd64' },
];
// Older / channel-specific version specs that map to webi <pkg>@<spec>
// invocations. Each case exercises a code path that the unfiltered
// "@stable" sweep above would never hit:
// - LTS filter (lts=true)
// - Channel filter (channel=beta)
// - Major-series prefix (ver=20 → /^20\b/)
// - Older minor (ver=0.18 → /^0.18\b/)
// - Older major (ver=1.21 → /^1.21\b/) for projects with deep history
let VERSION_SPEC_CASES = [
{ pkg: 'node', spec: 'lts', ver: '', channel: '', lts: true, os: 'linux', arch: 'amd64' },
{ pkg: 'node', spec: '20', ver: '20', channel: '', lts: false, os: 'linux', arch: 'amd64' },
{ pkg: 'node', spec: 'beta', ver: '', channel: 'beta', lts: false, os: 'linux', arch: 'amd64' },
{ pkg: 'go', spec: '1.22', ver: '1.22', channel: '', lts: false, os: 'linux', arch: 'amd64' },
{ pkg: 'go', spec: '1.21', ver: '1.21', channel: '', lts: false, os: 'macos', arch: 'arm64' },
{ pkg: 'bat', spec: '0.20', ver: '0.20', channel: '', lts: false, os: 'linux', arch: 'amd64' },
{ pkg: 'bat', spec: '0.18', ver: '0.18', channel: '', lts: false, os: 'linux', arch: 'amd64' },
{ pkg: 'rg', spec: '13', ver: '13', channel: '', lts: false, os: 'linux', arch: 'amd64' },
{ pkg: 'caddy', spec: '2.7', ver: '2.7', channel: '', lts: false, os: 'linux', arch: 'amd64' },
];
// UA strings for installer resolution tests
let INSTALLER_CASES = [
{ label: 'macOS arm64', ua: 'aarch64/unknown Darwin/24.2.0 libc' },
{ label: 'macOS amd64', ua: 'x86_64/unknown Darwin/23.0.0 libc' },
{ label: 'Linux musl', ua: 'x86_64/unknown Linux/5.15.0 musl' },
{ label: 'Windows amd64', ua: 'x86_64/unknown Windows/10.0.19041 msvc' },
];
// Known differences between Go cache and production (not regressions)
// Extensions that the Go cache correctly excludes (non-installable formats)
// OR that the Go cache includes but shouldn't (man pages, etc.)
let KNOWN_EXT_DIFFS = new Set([
'deb', 'rpm', 'sha256', 'sig', 'pem', 'sbom', 'txt',
'1', '2', '3', '4', '5', '6', '7', '8', // man page extensions
]);
function httpsGet(url) {
return new Promise(function (resolve, reject) {
Https.get(url, function (res) {
let data = '';
res.on('data', function (chunk) {
data += chunk;
});
res.on('end', function () {
if (res.statusCode !== 200) {
reject(new Error(`HTTP ${res.statusCode}: ${url}`));
return;
}
resolve(data);
});
}).on('error', reject);
});
}
async function fetchLiveReleases(pkg, os, arch, limit) {
let url = `${BASE_URL}/api/releases/${pkg}@stable.json?limit=${limit || 100}`;
if (os) {
url += `&os=${os}`;
}
if (arch) {
url += `&arch=${arch}`;
}
let json = await httpsGet(url);
return JSON.parse(json);
}
async function fetchAtSpec(baseUrl, pkg, spec, os, arch) {
let url = `${baseUrl}/api/releases/${pkg}@${spec}.json?limit=1`;
if (os) {
url += `&os=${os}`;
}
if (arch) {
url += `&arch=${arch}`;
}
let json = await httpsGet(url);
return JSON.parse(json);
}
async function fetchLiveInstaller(pkg, ua) {
return new Promise(function (resolve, reject) {
let url = `${BASE_URL}/${pkg}@stable.sh`;
let opts = {
headers: { 'User-Agent': ua },
};
Https.get(url, opts, function (res) {
// Follow redirects
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
let redir = res.headers.location;
if (redir.startsWith('/')) {
redir = BASE_URL + redir;
}
Https.get(redir, opts, function (res2) {
let data = '';
res2.on('data', function (chunk) {
data += chunk;
});
res2.on('end', function () {
resolve(data);
});
}).on('error', reject);
return;
}
let data = '';
res.on('data', function (chunk) {
data += chunk;
});
res.on('end', function () {
resolve(data);
});
}).on('error', reject);
});
}
function parseInstallerVars(scriptText) {
let vars = {};
// Match both `export WEBI_FOO='...'` and `WEBI_FOO='...'`
let names = ['WEBI_PKG_URL', 'WEBI_VERSION', 'WEBI_EXT', 'WEBI_OS', 'WEBI_ARCH', 'PKG_NAME'];
for (let name of names) {
let re = new RegExp("^(?:export\\s+)?" + name + "='([^']*)'", 'm');
let m = scriptText.match(re);
if (m) {
vars[name] = m[1];
}
}
return vars;
}
async function saveGolden(name, data) {
await Fs.mkdir(TESTDATA_DIR, { recursive: true });
let file = Path.join(TESTDATA_DIR, name);
await Fs.writeFile(file, JSON.stringify(data), 'utf8');
}
async function loadGolden(name) {
let file = Path.join(TESTDATA_DIR, name);
try {
let json = await Fs.readFile(file, 'utf8');
return JSON.parse(json);
} catch (e) {
if (e.code === 'ENOENT') {
return null;
}
throw e;
}
}
async function main() {
let passes = 0;
let failures = 0;
let skips = 0;
let knowns = 0;
console.log('Initializing build cache...');
await Builds.init();
console.log('');
// ================================================================
// Test 1: Release API — unfiltered
// ================================================================
console.log('=== Test 1: Unfiltered /api/releases/{pkg}.json ===');
console.log('');
for (let pkg of TEST_PKGS) {
let goldenName = `live_${pkg}.json`;
let liveReleases;
if (REFRESH) {
try {
liveReleases = await fetchLiveReleases(pkg);
await saveGolden(goldenName, liveReleases);
console.log(` refreshed ${goldenName}`);
} catch (e) {
console.log(` SKIP ${pkg}: fetch error: ${e.message}`);
skips++;
continue;
}
} else {
liveReleases = await loadGolden(goldenName);
if (!liveReleases) {
console.log(` SKIP ${pkg}: no golden data (run with --refresh)`);
skips++;
continue;
}
}
let localResult = await Releases.getReleases({
pkg: pkg,
ver: '',
os: '',
arch: '',
libc: '',
lts: false,
channel: '',
formats: [],
limit: 100,
});
let localReleases = localResult.releases;
// Compare OS vocabulary — check that local has the core OSes that live has
let liveOses = [...new Set(liveReleases.map(function (r) { return r.os; }))].sort();
let localOses = [...new Set(localReleases.map(function (r) { return r.os; }))].sort();
let coreOses = ['linux', 'macos', 'windows'];
let liveCore = liveOses.filter(function (o) { return coreOses.includes(o); }).sort();
let localCore = localOses.filter(function (o) { return coreOses.includes(o); }).sort();
// Local should have at least the core OSes that live has
let missingCore = liveCore.filter(function (o) { return !localCore.includes(o); });
if (missingCore.length > 0) {
console.log(` FAIL ${pkg} OS: missing core OSes: ${JSON.stringify(missingCore)}`);
failures++;
} else {
console.log(` PASS ${pkg} OS: ${JSON.stringify(localCore)}`);
passes++;
}
// Compare ext vocabulary (excluding known non-installable formats)
let liveExts = [...new Set(liveReleases.map(function (r) { return r.ext; }))].sort();
let localExts = [...new Set(localReleases.map(function (r) { return r.ext; }))].sort();
let liveExtsFiltered = liveExts.filter(function (e) { return !KNOWN_EXT_DIFFS.has(e); });
let localExtsFiltered = localExts.filter(function (e) { return !KNOWN_EXT_DIFFS.has(e); });
let extMatch = true;
for (let ext of localExtsFiltered) {
if (!liveExtsFiltered.includes(ext)) {
// Local has a real ext that live doesn't — may be due to limit or sampling
// Only fail if it's clearly wrong (not a standard installable format)
let installable = ['tar.gz', 'tar.xz', 'tar.bz2', 'zip', 'exe', 'dmg', 'pkg', 'msi', '7z', 'xz'];
if (!installable.includes(ext)) {
console.log(` FAIL ${pkg} ext: local has unexpected '${ext}'`);
failures++;
extMatch = false;
break;
}
}
}
if (extMatch) {
console.log(` PASS ${pkg} ext: ${JSON.stringify(localExtsFiltered)}`);
passes++;
}
// Version format — no 'v' prefix
let hasVPrefix = localReleases.some(function (r) {
return r.version && r.version.startsWith('v');
});
if (hasVPrefix) {
console.log(` FAIL ${pkg}: versions have 'v' prefix`);
failures++;
} else {
console.log(` PASS ${pkg}: no 'v' prefix`);
passes++;
}
}
// ================================================================
// Test 2: Release API — filtered by OS/arch
// ================================================================
console.log('');
console.log('=== Test 2: Filtered /api/releases/{pkg}@stable.json?os=...&arch=... ===');
console.log('');
for (let pkg of TEST_PKGS) {
for (let tc of RELEASE_API_CASES) {
let goldenName = `live_${pkg}_os_${tc.os}_arch_${tc.arch}.json`;
let liveReleases;
if (REFRESH) {
try {
liveReleases = await fetchLiveReleases(pkg, tc.os, tc.arch, 1);
await saveGolden(goldenName, liveReleases);
} catch (e) {
skips++;
continue;
}
} else {
liveReleases = await loadGolden(goldenName);
if (!liveReleases) {
skips++;
continue;
}
}
let liveFirst = liveReleases[0];
if (!liveFirst || liveFirst.channel === 'error') {
skips++;
continue;
}
let localResult = await Releases.getReleases({
pkg: pkg,
ver: '',
os: tc.os,
arch: tc.arch,
libc: '',
lts: false,
channel: 'stable',
formats: [],
limit: 1,
});
let localFirst = localResult.releases[0];
if (!localFirst || localFirst.channel === 'error') {
console.log(` FAIL ${pkg} ${tc.os}/${tc.arch}: local returned error/empty`);
failures++;
continue;
}
let diffs = [];
// Compare os, arch, ext (skip version/download since cache age may differ)
if (liveFirst.os !== localFirst.os) {
diffs.push(`os: live=${liveFirst.os} local=${localFirst.os}`);
}
if (liveFirst.arch !== localFirst.arch) {
diffs.push(`arch: live=${liveFirst.arch} local=${localFirst.arch}`);
}
if (liveFirst.ext !== localFirst.ext) {
if (KNOWN_EXT_DIFFS.has(liveFirst.ext)) {
// Live returns a non-installable format (deb, pem, etc.) — known
console.log(` KNOWN ${pkg} ${tc.os}/${tc.arch}: live ext '${liveFirst.ext}' excluded by Go cache, local='${localFirst.ext}'`);
knowns++;
continue;
}
diffs.push(`ext: live=${liveFirst.ext} local=${localFirst.ext}`);
}
if (diffs.length > 0) {
console.log(` FAIL ${pkg} ${tc.os}/${tc.arch}: ${diffs.join(', ')}`);
failures++;
} else {
console.log(` PASS ${pkg} ${tc.os}/${tc.arch}: v${localFirst.version} .${localFirst.ext}`);
passes++;
}
}
}
// ================================================================
// Test 3: Installer resolution — compare rendered script vars
// ================================================================
console.log('');
console.log('=== Test 3: Installer script variables (local serveInstaller vs live) ===');
console.log('');
for (let pkg of ['bat', 'go', 'rg']) {
for (let tc of INSTALLER_CASES) {
// Get local result
let localVars;
try {
let script = await InstallerServer.serveInstaller(
'https://webi.sh',
tc.ua,
pkg,
'stable',
'sh',
['tar', 'exe', 'zip', 'xz', 'dmg'],
'',
);
localVars = parseInstallerVars(script);
} catch (e) {
console.log(` ERROR ${pkg} ${tc.label}: ${e.message}`);
failures++;
continue;
}
if (!localVars.WEBI_PKG_URL || localVars.WEBI_PKG_URL === '') {
// Check if this is a known issue
if (localVars.WEBI_EXT === 'err') {
console.log(` KNOWN ${pkg} ${tc.label}: no match (WATERFALL gap)`);
knowns++;
continue;
}
console.log(` FAIL ${pkg} ${tc.label}: empty WEBI_PKG_URL`);
failures++;
continue;
}
// Verify the URL looks like a real download
let url = localVars.WEBI_PKG_URL;
let hasRealDomain = url.includes('github.com') ||
url.includes('dl.google.com') ||
url.includes('nodejs.org') ||
url.includes('jqlang');
if (!hasRealDomain) {
console.log(` FAIL ${pkg} ${tc.label}: suspicious URL: ${url}`);
failures++;
continue;
}
// Verify version is set and has no 'v' prefix
if (!localVars.WEBI_VERSION || localVars.WEBI_VERSION === '0.0.0') {
console.log(` FAIL ${pkg} ${tc.label}: bad version: ${localVars.WEBI_VERSION}`);
failures++;
continue;
}
// Verify ext is a real installable format
let ext = localVars.WEBI_EXT;
let goodExts = ['tar.gz', 'tar.xz', 'zip', 'exe', 'dmg', 'pkg', 'msi', '7z'];
if (!goodExts.includes(ext)) {
console.log(` FAIL ${pkg} ${tc.label}: bad ext: ${ext}`);
failures++;
continue;
}
console.log(` PASS ${pkg} ${tc.label}: v${localVars.WEBI_VERSION} .${ext} ${url.split('/').pop()}`);
passes++;
}
}
// ================================================================
// Test 4: @version path-form parity (webi <pkg>@<spec>)
// Exercises lts/channel/version filters that the unfiltered sweep
// doesn't touch. Two modes:
// - --cand-url=<url> set: remote (BASE_URL) vs remote (CAND_URL).
// No local cache needed.
// - --cand-url unset: remote (BASE_URL) vs in-process Releases.
// ================================================================
console.log('');
if (CAND_URL) {
console.log(`=== Test 4: @version filter parity (${BASE_URL} vs ${CAND_URL}) ===`);
} else {
console.log('=== Test 4: @version filter parity (remote vs local resolver) ===');
}
console.log('');
for (let tc of VERSION_SPEC_CASES) {
let label = `${tc.pkg}@${tc.spec} ${tc.os}/${tc.arch}`;
let baseFirst;
try {
let baseRel = await fetchAtSpec(BASE_URL, tc.pkg, tc.spec, tc.os, tc.arch);
baseFirst = baseRel[0];
} catch (e) {
console.log(` SKIP ${label}: base fetch error: ${e.message}`);
skips++;
continue;
}
if (!baseFirst || baseFirst.channel === 'error') {
console.log(` SKIP ${label}: base returned error/empty`);
skips++;
continue;
}
let candFirst;
if (CAND_URL) {
try {
let candRel = await fetchAtSpec(CAND_URL, tc.pkg, tc.spec, tc.os, tc.arch);
candFirst = candRel[0];
} catch (e) {
console.log(` FAIL ${label}: cand fetch error: ${e.message}`);
failures++;
continue;
}
} else {
let localResult = await Releases.getReleases({
pkg: tc.pkg,
ver: tc.ver,
os: tc.os,
arch: tc.arch,
libc: '',
lts: tc.lts,
channel: tc.channel,
formats: [],
limit: 1,
});
candFirst = localResult.releases && localResult.releases[0];
}
if (!candFirst || candFirst.channel === 'error') {
console.log(` FAIL ${label}: cand returned error/empty (base=${baseFirst.version})`);
failures++;
continue;
}
// Both should satisfy the requested spec. A version diff is only a
// real failure if cand picked something the spec excludes (e.g.
// requested '20' but got '26'). Same prefix on both sides is just
// cache-age skew (e.g. 1.21.13 vs 1.21.14).
if (baseFirst.version !== candFirst.version) {
let prefix = tc.ver;
let candMatches = !prefix || new RegExp('^' + prefix + '\\b').test(candFirst.version);
let baseMatches = !prefix || new RegExp('^' + prefix + '\\b').test(baseFirst.version);
if (!candMatches && baseMatches) {
console.log(` FAIL ${label}: cand=${candFirst.version} doesn't match spec; base=${baseFirst.version}`);
failures++;
} else if (candMatches && !baseMatches) {
console.log(` PASS ${label}: cand=${candFirst.version} (base=${baseFirst.version} is wrong)`);
passes++;
} else {
console.log(` PASS ${label}: cand=${candFirst.version} base=${baseFirst.version} (both match '${prefix}'; cache-age skew)`);
passes++;
}
} else {
console.log(` PASS ${label}: v${candFirst.version}`);
passes++;
}
}
// ================================================================
// Summary
// ================================================================
console.log('');
console.log(`=== Results: ${passes} passed, ${failures} failed, ${knowns} known, ${skips} skipped ===`);
if (failures > 0) {
process.exit(1);
}
}
main().catch(function (err) {
console.error(err.stack);
process.exit(1);
});

View File

@@ -0,0 +1,202 @@
'use strict';
// Direct side-by-side comparison: fetch the live installer script from
// a remote host with the same UA, and compare WEBI_* variables against
// what our local serveInstaller() produces.
//
// This is the most direct behavioral equivalence test — if the same UA
// produces the same WEBI_PKG_URL, WEBI_VERSION, WEBI_EXT, and WEBI_OS,
// then the user gets the same binary.
//
// Usage:
// node _webi/test-live-installer-diff.js
// node _webi/test-live-installer-diff.js --base-url=https://beta.webi.sh
let Https = require('node:https');
let InstallerServer = require('./serve-installer.js');
let Builds = require('./builds.js');
let BASE_URL = 'https://webinstall.dev';
for (let arg of process.argv) {
if (arg.startsWith('--base-url=')) {
BASE_URL = arg.slice('--base-url='.length).replace(/\/+$/, '');
}
}
let CASES = [
// bat — Rust project, gnu-linked Linux builds
{ pkg: 'bat', ua: 'aarch64/unknown Darwin/24.2.0 libc', label: 'bat macOS arm64' },
{ pkg: 'bat', ua: 'x86_64/unknown Darwin/23.0.0 libc', label: 'bat macOS amd64' },
{ pkg: 'bat', ua: 'x86_64/unknown Linux/5.15.0 libc', label: 'bat Linux amd64' },
{ pkg: 'bat', ua: 'x86_64/unknown Linux/5.15.0 musl', label: 'bat Linux musl' },
{ pkg: 'bat', ua: 'x86_64/unknown Windows/10.0.19041 msvc', label: 'bat Windows amd64' },
// go — Go project, static builds (libc='none')
{ pkg: 'go', ua: 'aarch64/unknown Darwin/24.2.0 libc', label: 'go macOS arm64' },
{ pkg: 'go', ua: 'x86_64/unknown Darwin/23.0.0 libc', label: 'go macOS amd64' },
{ pkg: 'go', ua: 'x86_64/unknown Linux/5.15.0 libc', label: 'go Linux amd64' },
{ pkg: 'go', ua: 'x86_64/unknown Windows/10.0.19041 msvc', label: 'go Windows amd64' },
// node — C++ project, gnu-linked Linux builds, separate musl build
{ pkg: 'node', ua: 'aarch64/unknown Darwin/24.2.0 libc', label: 'node macOS arm64' },
{ pkg: 'node', ua: 'x86_64/unknown Linux/5.15.0 libc', label: 'node Linux amd64',
known: 'live fails (WATERFALL gap), local correctly resolves gnu build' },
{ pkg: 'node', ua: 'x86_64/unknown Linux/5.15.0 musl', label: 'node Linux musl' },
// rg — Rust project, gnu-linked Linux builds
{ pkg: 'rg', ua: 'aarch64/unknown Darwin/24.2.0 libc', label: 'rg macOS arm64' },
{ pkg: 'rg', ua: 'x86_64/unknown Linux/5.15.0 libc', label: 'rg Linux amd64' },
{ pkg: 'rg', ua: 'x86_64/unknown Linux/5.15.0 musl', label: 'rg Linux musl' },
{ pkg: 'rg', ua: 'x86_64/unknown Windows/10.0.19041 msvc', label: 'rg Windows amd64' },
// jq — C project, had .git source URLs in old releases
{ pkg: 'jq', ua: 'aarch64/unknown Darwin/24.2.0 libc', label: 'jq macOS arm64' },
{ pkg: 'jq', ua: 'x86_64/unknown Linux/5.15.0 libc', label: 'jq Linux amd64' },
{ pkg: 'jq', ua: 'x86_64/unknown Windows/10.0.19041 msvc', label: 'jq Windows amd64' },
// caddy — Go project, had .git source URLs in old releases
{ pkg: 'caddy', ua: 'aarch64/unknown Darwin/24.2.0 libc', label: 'caddy macOS arm64' },
{ pkg: 'caddy', ua: 'x86_64/unknown Linux/5.15.0 libc', label: 'caddy Linux amd64' },
{ pkg: 'caddy', ua: 'x86_64/unknown Windows/10.0.19041 msvc', label: 'caddy Windows amd64' },
// Additional packages for broader coverage
{ pkg: 'shellcheck', ua: 'aarch64/unknown Darwin/24.2.0 libc', label: 'shellcheck macOS arm64' },
{ pkg: 'shellcheck', ua: 'x86_64/unknown Linux/5.15.0 libc', label: 'shellcheck Linux amd64' },
{ pkg: 'shfmt', ua: 'aarch64/unknown Darwin/24.2.0 libc', label: 'shfmt macOS arm64' },
{ pkg: 'shfmt', ua: 'x86_64/unknown Linux/5.15.0 libc', label: 'shfmt Linux amd64' },
{ pkg: 'fd', ua: 'aarch64/unknown Darwin/24.2.0 libc', label: 'fd macOS arm64' },
{ pkg: 'fd', ua: 'x86_64/unknown Linux/5.15.0 libc', label: 'fd Linux amd64' },
{ pkg: 'hugo', ua: 'aarch64/unknown Darwin/24.2.0 libc', label: 'hugo macOS arm64',
known: 'classifier rejects darwin-universal as x86_64!=universal2' },
{ pkg: 'hugo', ua: 'x86_64/unknown Linux/5.15.0 libc', label: 'hugo Linux amd64' },
// Alias tests — these should resolve to the real package
{ pkg: 'golang', ua: 'aarch64/unknown Darwin/24.2.0 libc', label: 'golang alias macOS arm64' },
{ pkg: 'ripgrep', ua: 'aarch64/unknown Darwin/24.2.0 libc', label: 'ripgrep alias macOS arm64' },
];
// Variables that must match between live and local for the install to work
let CRITICAL_VARS = ['PKG_NAME', 'WEBI_OS', 'WEBI_ARCH', 'WEBI_EXT'];
// Variables where version differences are OK (cache age)
let VERSION_VARS = ['WEBI_VERSION', 'WEBI_PKG_URL', 'WEBI_PKG_FILE'];
function fetchLiveInstaller(pkg, ua) {
return new Promise(function (resolve, reject) {
let url = `${BASE_URL}/api/installers/${pkg}@stable.sh`;
let opts = { headers: { 'User-Agent': ua } };
Https.get(url, opts, function (res) {
let data = '';
res.on('data', function (chunk) { data += chunk; });
res.on('end', function () { resolve(data); });
}).on('error', reject);
});
}
function parseVars(script) {
let vars = {};
// Match: PKG_NAME='bat' or WEBI_VERSION='1.2.3' (with or without export)
let re = /^(?:export\s+)?(WEBI_\w+|PKG_NAME)='([^']*)'/gm;
let m;
while ((m = re.exec(script)) !== null) {
vars[m[1]] = m[2];
}
return vars;
}
async function main() {
let passes = 0;
let failures = 0;
let knowns = 0;
let errors = 0;
console.log('Initializing build cache...');
await Builds.init();
console.log('');
console.log('=== Live vs Local Installer Diff ===');
console.log('');
for (let tc of CASES) {
// Fetch live
let liveScript;
try {
liveScript = await fetchLiveInstaller(tc.pkg, tc.ua);
} catch (e) {
console.log(` SKIP ${tc.label}: fetch error: ${e.message}`);
continue;
}
let liveVars = parseVars(liveScript);
if (!liveVars.WEBI_PKG_URL) {
console.log(` SKIP ${tc.label}: live returned no WEBI_PKG_URL`);
continue;
}
// Render local
let localScript;
try {
localScript = await InstallerServer.serveInstaller(
BASE_URL,
tc.ua,
tc.pkg,
'stable',
'sh',
['tar', 'exe', 'zip', 'xz', 'dmg'],
'',
);
} catch (e) {
console.log(` ERROR ${tc.label}: local error: ${e.message}`);
errors++;
continue;
}
let localVars = parseVars(localScript);
if (tc.known) {
let localExt = localVars.WEBI_EXT || 'err';
let liveExt = liveVars.WEBI_EXT || '?';
if (localExt !== liveExt) {
console.log(` KNOWN ${tc.label}: ${tc.known} (live=${liveExt} local=${localExt})`);
knowns++;
} else {
console.log(` PASS ${tc.label}: known issue resolved! v${localVars.WEBI_VERSION} .${localExt}`);
passes++;
}
continue;
}
if (!localVars.WEBI_PKG_URL || localVars.WEBI_EXT === 'err') {
console.log(` KNOWN ${tc.label}: local failed to resolve (live=${liveVars.WEBI_EXT})`);
knowns++;
continue;
}
// Compare critical vars
let diffs = [];
for (let v of CRITICAL_VARS) {
let liveVal = liveVars[v] || '';
let localVal = localVars[v] || '';
if (liveVal !== localVal) {
diffs.push(`${v}: live='${liveVal}' local='${localVal}'`);
}
}
// Log version info (informational, not failure)
let versionNote = '';
if (liveVars.WEBI_VERSION !== localVars.WEBI_VERSION) {
versionNote = ` (version: live=${liveVars.WEBI_VERSION} local=${localVars.WEBI_VERSION})`;
}
if (diffs.length > 0) {
console.log(` FAIL ${tc.label}: ${diffs.join(', ')}${versionNote}`);
failures++;
} else {
let file = (localVars.WEBI_PKG_URL || '').split('/').pop();
console.log(` PASS ${tc.label}: v${localVars.WEBI_VERSION} .${localVars.WEBI_EXT} ${file}${versionNote}`);
passes++;
}
}
console.log('');
console.log(`=== Results: ${passes} passed, ${failures} failed, ${knowns} known, ${errors} errors ===`);
if (failures > 0 || errors > 0) {
process.exit(1);
}
}
main().catch(function (err) {
console.error(err.stack);
process.exit(1);
});

View File

@@ -38,9 +38,8 @@ function getOs(ua) {
// It's the year of the Linux Desktop!
// See also http://www.mslinux.org/
// 'linux' must be tested before 'Microsoft' because WSL
// (TODO: does this affect cygwin / msysgit?)
return 'linux';
} else if (/^ms$|Microsoft|Windows|win32|win|PowerShell/i.test(ua)) {
} else if (/^ms$|Microsoft|Windows|win32|win|PowerShell|CYGWIN|MINGW/i.test(ua)) {
// 'win' must be tested after 'darwin'
return 'windows';
} else if (/Linux|curl|wget/i.test(ua)) {

2
aliasman/releases.conf Normal file
View File

@@ -0,0 +1,2 @@
github_sources = BeyondCodeBootcamp/aliasman
git_url = https://github.com/BeyondCodeBootcamp/aliasman.git

1
arc/releases.conf Normal file
View File

@@ -0,0 +1 @@
github_releases = mholt/archiver

View File

@@ -0,0 +1 @@
github_releases = wez/atomicparsley

1
awless/releases.conf Normal file
View File

@@ -0,0 +1 @@
github_releases = wallix/awless

View File

@@ -28,22 +28,6 @@ __init_bat() {
# chmod a+x ~/.local/opt/bat-v0.15.4/bin/bat
chmod a+x "$pkg_src_cmd"
# install completions if present (autocomplete/)
if test -d ./bat-*/autocomplete; then
mkdir -p "$pkg_src_dir/share/bash-completion/completions"
mkdir -p "$pkg_src_dir/share/fish/vendor_completions.d"
mkdir -p "$pkg_src_dir/share/zsh/site-functions"
mv ./bat-*/autocomplete/bat.bash "$pkg_src_dir/share/bash-completion/completions/bat" 2>/dev/null || true
mv ./bat-*/autocomplete/bat.fish "$pkg_src_dir/share/fish/vendor_completions.d/bat.fish" 2>/dev/null || true
mv ./bat-*/autocomplete/bat.zsh "$pkg_src_dir/share/zsh/site-functions/_bat" 2>/dev/null || true
fi
# install man page if present
if test -f ./bat-*/bat.1; then
mkdir -p "$pkg_src_dir/share/man/man1"
mv ./bat-*/bat.1 "$pkg_src_dir/share/man/man1/bat.1"
fi
if ! [ -e ~/.config/bat/config ]; then
mkdir -p ~/.config/bat/
touch ~/.config/bat/config

1
bat/releases.conf Normal file
View File

@@ -0,0 +1 @@
github_releases = sharkdp/bat

5
bun/releases.conf Normal file
View File

@@ -0,0 +1,5 @@
github_releases = oven-sh/bun
tag_prefix = bun-
default_x86_64 = x86_64_v3
x86_64_v2 = baseline
variants = profile

1
caddy/releases.conf Normal file
View File

@@ -0,0 +1 @@
github_releases = caddyserver/caddy

View File

@@ -0,0 +1 @@
source = chromedist

1
cilium/releases.conf Normal file
View File

@@ -0,0 +1 @@
github_releases = cilium/cilium-cli

1
cmake/releases.conf Normal file
View File

@@ -0,0 +1 @@
github_releases = Kitware/CMake

925
cmd/webicached/main.go Normal file
View File

@@ -0,0 +1,925 @@
// Command webicached is the release cache daemon. It fetches releases
// from upstream sources, classifies build assets, and writes them to
// the _cache/ directory in the format the Node.js server expects.
//
// This is the Go replacement for the Node.js release-fetching pipeline.
// It reads releases.conf files to discover packages, fetches from the
// configured source, classifies assets, and writes to fsstore.
//
// Default mode: classify all from existing rawcache on startup, then
// fetch+refresh one package per tick (round-robin, 15m default).
//
// Usage:
//
// go run ./cmd/webicached # default: round-robin, one per tick
// go run ./cmd/webicached -eager # fetch all packages on startup
// go run ./cmd/webicached -once -no-fetch # classify from rawcache and exit
// go run ./cmd/webicached bat goreleaser # only these packages
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"log"
"math/rand/v2"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/joho/godotenv"
"github.com/webinstall/webi-installers/internal/classifypkg"
"github.com/webinstall/webi-installers/internal/httpclient"
"github.com/webinstall/webi-installers/internal/installerconf"
"github.com/webinstall/webi-installers/internal/rawcache"
"github.com/webinstall/webi-installers/internal/releases/chromedist"
"github.com/webinstall/webi-installers/internal/releases/flutterdist"
"github.com/webinstall/webi-installers/internal/releases/gitea"
"github.com/webinstall/webi-installers/internal/releases/github"
"github.com/webinstall/webi-installers/internal/releases/githubish"
"github.com/webinstall/webi-installers/internal/releases/gittag"
"github.com/webinstall/webi-installers/internal/releases/golang"
"github.com/webinstall/webi-installers/internal/releases/gpgdist"
"github.com/webinstall/webi-installers/internal/releases/hashicorp"
"github.com/webinstall/webi-installers/internal/releases/iterm2dist"
"github.com/webinstall/webi-installers/internal/releases/juliadist"
"github.com/webinstall/webi-installers/internal/releases/mariadbdist"
"github.com/webinstall/webi-installers/internal/releases/nodedist"
"github.com/webinstall/webi-installers/internal/releases/servicemandist"
"github.com/webinstall/webi-installers/internal/releases/zigdist"
"github.com/webinstall/webi-installers/internal/storage"
"github.com/webinstall/webi-installers/internal/storage/fsstore"
)
var (
name = "webicached"
version = "0.0.0-dev"
commit = "0000000"
date = "0001-01-01"
licenseYear = "2024"
licenseOwner = "AJ ONeal"
licenseType = "MPL-2.0"
)
func printVersion(w io.Writer) {
b_ver := strings.TrimPrefix(version, "v")
_, _ = fmt.Fprintf(w, "%s v%s %s (%s)\n", name, b_ver, commit[:7], date)
_, _ = fmt.Fprintf(w, "Copyright (C) %s %s\n", licenseYear, licenseOwner)
_, _ = fmt.Fprintf(w, "Licensed under %s\n", licenseType)
}
type MainConfig struct {
envFile string
confDir string
cacheDir string
rawDir string
token string
once bool
noFetch bool
shallow bool
eager bool
interval time.Duration
pageDelay time.Duration
}
// WebiCache holds the configuration for the cache daemon.
type WebiCache struct {
ConfDir string // root directory with {pkg}/releases.conf files
Store storage.Store // classified asset storage (fsstore)
RawDir string // raw upstream response cache
Client *http.Client // HTTP client for upstream calls
Auth *githubish.Auth // GitHub API auth (optional)
Shallow bool // fetch only the first page of releases
NoFetch bool // skip fetching, classify from existing raw data only
PageDelay time.Duration // delay between paginated API requests
}
// delayTransport wraps an http.RoundTripper to add a delay between requests.
type delayTransport struct {
base http.RoundTripper
delay time.Duration
last time.Time
}
func (t *delayTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if !t.last.IsZero() && t.delay > 0 {
if wait := t.delay - time.Since(t.last); wait > 0 {
time.Sleep(wait)
}
}
t.last = time.Now()
return t.base.RoundTrip(req)
}
func main() {
if len(os.Args) > 1 {
switch os.Args[1] {
case "-V", "-version", "--version", "version":
printVersion(os.Stdout)
os.Exit(0)
case "help", "-help", "--help":
printVersion(os.Stdout)
fmt.Fprintln(os.Stdout, "")
fs := flag.NewFlagSet(os.Args[0], flag.ContinueOnError)
fs.SetOutput(os.Stdout)
registerFlags(fs, &MainConfig{})
fs.Usage()
os.Exit(0)
}
}
cfg := MainConfig{}
fs := flag.NewFlagSet(os.Args[0], flag.ContinueOnError)
registerFlags(fs, &cfg)
if err := fs.Parse(os.Args[1:]); err != nil {
if errors.Is(err, flag.ErrHelp) {
os.Exit(0)
}
os.Exit(1)
}
cfg.cacheDir = expandHome(cfg.cacheDir)
cfg.rawDir = expandHome(cfg.rawDir)
if cfg.envFile != "" {
if err := godotenv.Load(cfg.envFile); err != nil {
log.Fatalf("envfile: %v", err)
}
}
if cfg.token == "" {
cfg.token = os.Getenv("GITHUB_TOKEN")
}
fss, err := fsstore.New(cfg.cacheDir)
if err != nil {
log.Fatalf("fsstore: %v", err)
}
var store storage.Store = fss
var auth *githubish.Auth
if cfg.token != "" {
auth = &githubish.Auth{Token: cfg.token}
}
client := httpclient.New()
if cfg.pageDelay > 0 {
client.Transport = &delayTransport{
base: client.Transport,
delay: cfg.pageDelay,
}
}
wc := &WebiCache{
ConfDir: cfg.confDir,
Store: store,
RawDir: cfg.rawDir,
Client: client,
Auth: auth,
Shallow: cfg.shallow,
NoFetch: cfg.noFetch,
PageDelay: cfg.pageDelay,
}
filterPkgs := fs.Args()
if cfg.eager {
wc.Run(filterPkgs)
if cfg.once {
return
}
} else if cfg.once {
wc.Run(filterPkgs)
return
} else {
saved := wc.NoFetch
wc.NoFetch = true
wc.Run(filterPkgs)
wc.NoFetch = saved
}
packages, err := discover(wc.ConfDir)
if err != nil {
log.Fatalf("discover: %v", err)
}
nameSet := make(map[string]bool, len(filterPkgs))
for _, a := range filterPkgs {
nameSet[a] = true
}
if len(filterPkgs) > 0 {
var filtered []pkgConf
for _, p := range packages {
if nameSet[p.name] {
filtered = append(filtered, p)
}
}
packages = filtered
}
var real []pkgConf
for _, pkg := range packages {
if pkg.conf.AliasOf == "" {
real = append(real, pkg)
}
}
// rescanNew appends any conf files added since the last scan.
// Returns true when at least one new package was added so the caller
// can restart the batch loop and process new packages immediately.
rescanNew := func() bool {
discovered, err := discover(wc.ConfDir)
if err != nil {
log.Printf("rescan: %v", err)
return false
}
known := make(map[string]bool, len(real))
for _, p := range real {
known[p.name] = true
}
added := false
for _, p := range discovered {
if p.conf.AliasOf != "" || known[p.name] {
continue
}
if len(filterPkgs) > 0 && !nameSet[p.name] {
continue
}
log.Printf("discovered new package: %s (source=%s)", p.name, p.conf.Source)
real = append(real, p)
added = true
}
return added
}
log.Printf("refreshing %d packages, interval %s, batch size 20 (ctrl-c to stop)", len(real), cfg.interval)
for {
// Rescan before computing staleness so newly added conf files are
// included immediately. New packages have a zero timestamp and sort
// to the front of the stale list, so they are processed next.
rescanNew()
stale := wc.stalest(real)
if len(stale) == 0 {
log.Printf("all packages fresh, sleeping %s", cfg.interval)
time.Sleep(cfg.interval)
continue
}
batch := stale
if len(batch) > 20 {
batch = batch[:20]
}
rand.Shuffle(len(batch), func(i, j int) {
batch[i], batch[j] = batch[j], batch[i]
})
log.Printf("batch: %d stale, refreshing %d (most stale first)", len(stale), len(batch))
for _, pkg := range batch {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
if err := wc.refreshPackage(ctx, pkg); err != nil {
log.Printf(" ERROR %s: %v", pkg.name, err)
}
cancel()
time.Sleep(cfg.interval)
// Rescan mid-batch so new packages preempt remaining batch items.
if rescanNew() {
break
}
}
}
}
func registerFlags(fs *flag.FlagSet, cfg *MainConfig) {
fs.StringVar(&cfg.envFile, "envfile", "", "path to .env file to load before running")
fs.StringVar(&cfg.confDir, "conf", ".", "root directory containing {pkg}/releases.conf files")
fs.StringVar(&cfg.cacheDir, "legacy", "~/.cache/webi/legacy", "legacy cache directory (fsstore root)")
fs.StringVar(&cfg.rawDir, "raw", "~/.cache/webi/raw", "raw cache directory for upstream responses")
fs.StringVar(&cfg.token, "token", "", "GitHub API token (or set $GITHUB_TOKEN)")
fs.BoolVar(&cfg.once, "once", false, "run once then exit (no periodic refresh)")
fs.BoolVar(&cfg.noFetch, "no-fetch", false, "skip fetching, classify from existing raw data only")
fs.BoolVar(&cfg.shallow, "shallow", false, "fetch only the first page of releases (latest)")
fs.BoolVar(&cfg.eager, "eager", false, "fetch all packages on startup (default: one per tick)")
fs.DurationVar(&cfg.interval, "interval", 9*time.Second, "delay between individual package fetches")
fs.DurationVar(&cfg.pageDelay, "page-delay", 2*time.Second, "delay between paginated API requests")
}
func expandHome(path string) string {
if !strings.HasPrefix(path, "~/") {
return path
}
home, err := os.UserHomeDir()
if err != nil {
return path
}
return filepath.Join(home, path[2:])
}
// stalest returns packages sorted by most stale first (oldest UpdatedAt).
// Packages with no cache entry or empty assets are considered most stale.
func (wc *WebiCache) stalest(packages []pkgConf) []pkgConf {
type stamped struct {
pkg pkgConf
updatedAt time.Time
}
var stale []stamped
ctx := context.Background()
for _, pkg := range packages {
data, err := wc.Store.Load(ctx, pkg.name)
var t time.Time
hasAssets := false
if err == nil && data != nil {
t = data.UpdatedAt
hasAssets = len(data.Assets) > 0
}
// Never fetched, or has no assets despite having a timestamp
// (e.g. classified from empty rawcache), or older than 10 minutes.
if t.IsZero() || !hasAssets || time.Since(t) > 10*time.Minute {
stale = append(stale, stamped{pkg: pkg, updatedAt: t})
}
}
sort.SliceStable(stale, func(i, j int) bool {
ti, tj := stale[i].updatedAt, stale[j].updatedAt
if ti.Equal(tj) {
return stale[i].pkg.name < stale[j].pkg.name
}
return ti.Before(tj)
})
result := make([]pkgConf, len(stale))
for i, s := range stale {
result[i] = s.pkg
}
return result
}
// Run discovers packages and refreshes each one.
func (wc *WebiCache) Run(filterPkgs []string) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
packages, err := discover(wc.ConfDir)
if err != nil {
log.Printf("discover: %v", err)
return
}
if len(filterPkgs) > 0 {
nameSet := make(map[string]bool, len(filterPkgs))
for _, a := range filterPkgs {
nameSet[a] = true
}
var filtered []pkgConf
for _, p := range packages {
if nameSet[p.name] {
filtered = append(filtered, p)
}
}
packages = filtered
}
var real []pkgConf
for _, pkg := range packages {
if pkg.conf.AliasOf != "" {
continue
}
real = append(real, pkg)
}
log.Printf("refreshing %d packages", len(real))
runStart := time.Now()
for _, pkg := range real {
if err := wc.refreshPackage(ctx, pkg); err != nil {
log.Printf(" ERROR %s: %v", pkg.name, err)
}
}
log.Printf("refreshed %d packages in %s", len(real), time.Since(runStart))
}
type pkgConf struct {
name string
conf *installerconf.Conf
}
func discover(dir string) ([]pkgConf, error) {
pattern := filepath.Join(dir, "*", "releases.conf")
matches, err := filepath.Glob(pattern)
if err != nil {
return nil, err
}
var packages []pkgConf
for _, path := range matches {
pkgDir := filepath.Dir(path)
name := filepath.Base(pkgDir)
if strings.HasPrefix(name, "_") {
continue
}
// If the package directory is a symlink, treat it as an alias
// of the symlink target (e.g. rust.vim → vim-rust).
fi, err := os.Lstat(filepath.Join(dir, name))
if err != nil {
log.Printf("warning: %s: %v", name, err)
continue
}
if fi.Mode()&os.ModeSymlink != 0 {
target, err := os.Readlink(filepath.Join(dir, name))
if err != nil {
log.Printf("warning: readlink %s: %v", name, err)
continue
}
packages = append(packages, pkgConf{
name: name,
conf: &installerconf.Conf{AliasOf: target},
})
continue
}
conf, err := installerconf.Read(path)
if err != nil {
log.Printf("warning: %s: %v", path, err)
continue
}
packages = append(packages, pkgConf{name: name, conf: conf})
}
sort.Slice(packages, func(i, j int) bool {
return packages[i].name < packages[j].name
})
return packages, nil
}
// refreshPackage does the full pipeline for one package:
// fetch raw → classify → write to fsstore.
func (wc *WebiCache) refreshPackage(ctx context.Context, pkg pkgConf) error {
pkgStart := time.Now()
name := pkg.name
conf := pkg.conf
// Step 1: Fetch raw upstream data to rawcache (unless -no-fetch).
if !wc.NoFetch {
shallow := wc.Shallow
if !shallow {
d, err := rawcache.Open(filepath.Join(wc.RawDir, name))
if err == nil && d.Populated() {
shallow = true
}
}
fetchStart := time.Now()
if err := wc.fetchRaw(ctx, pkg, shallow); err != nil {
return fmt.Errorf("fetch: %w", err)
}
log.Printf(" %s: fetch %s", name, time.Since(fetchStart))
}
// Step 2: Classify raw data into assets, tag variants, apply config.
classifyStart := time.Now()
d, err := rawcache.Open(filepath.Join(wc.RawDir, name))
if err != nil {
return fmt.Errorf("rawcache open: %w", err)
}
// Open supplementary gittag raw cache if available (for packages with
// git_url that use a non-gittag source type like servicemandist).
var gitTagDir *rawcache.Dir
if conf.GitURL != "" && conf.Source != "gittag" {
gd, gdErr := rawcache.Open(filepath.Join(wc.RawDir, "_gittag", name))
if gdErr == nil && gd.Populated() {
gitTagDir = gd
}
}
assets, err := classifypkg.Package(name, conf, d, gitTagDir)
if err != nil {
return fmt.Errorf("classify: %w", err)
}
classifyDur := time.Since(classifyStart)
// Step 3: Write to fsstore.
writeStart := time.Now()
tx, err := wc.Store.BeginRefresh(ctx, name)
if err != nil {
return fmt.Errorf("begin refresh: %w", err)
}
if err := tx.Put(assets); err != nil {
tx.Rollback()
return fmt.Errorf("put: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit: %w", err)
}
writeDur := time.Since(writeStart)
log.Printf(" %s: %d assets (classify %s, write %s, total %s)",
name, len(assets), classifyDur, writeDur, time.Since(pkgStart))
return nil
}
// --- Fetch raw ---
func (wc *WebiCache) fetchRaw(ctx context.Context, pkg pkgConf, shallow bool) error {
switch pkg.conf.Source {
case "github", "githubsource":
if err := wc.fetchGitHub(ctx, pkg.name, pkg.conf, shallow); err != nil {
return err
}
case "nodedist":
return wc.fetchNodeDist(ctx, pkg.name, pkg.conf)
case "gittag":
return wc.fetchGitTag(ctx, pkg.name, pkg.conf, shallow)
case "gitea":
return wc.fetchGitea(ctx, pkg.name, pkg.conf, shallow)
case "chromedist":
return fetchChromeDist(ctx, wc.Client, wc.RawDir, pkg.name)
case "flutterdist":
return fetchFlutterDist(ctx, wc.Client, wc.RawDir, pkg.name)
case "golang":
return fetchGolang(ctx, wc.Client, wc.RawDir, pkg.name)
case "gpgdist":
return fetchGPGDist(ctx, wc.Client, wc.RawDir, pkg.name)
case "hashicorp":
return fetchHashiCorp(ctx, wc.Client, wc.RawDir, pkg.name, pkg.conf)
case "iterm2dist":
return fetchITerm2Dist(ctx, wc.Client, wc.RawDir, pkg.name)
case "juliadist":
return fetchJuliaDist(ctx, wc.Client, wc.RawDir, pkg.name)
case "mariadbdist":
return fetchMariaDBDist(ctx, wc.Client, wc.RawDir, pkg.name)
case "servicemandist":
if err := servicemandist.Fetch(ctx, wc.Client, wc.RawDir, pkg.name, wc.Auth, shallow); err != nil {
return err
}
case "zigdist":
return fetchZigDist(ctx, wc.Client, wc.RawDir, pkg.name)
default:
log.Printf(" %s: source %q not yet supported, skipping", pkg.name, pkg.conf.Source)
return nil
}
// For non-gittag sources with a git_url, also clone the repo to get
// commit hashes. Git entries are classified from this data in
// refreshPackage, not from the main raw cache.
if pkg.conf.GitURL != "" && pkg.conf.Source != "gittag" {
gitShallow := shallow
if !wc.Shallow {
gd, gdErr := rawcache.Open(filepath.Join(wc.RawDir, "_gittag", pkg.name))
if gdErr == nil && !gd.Populated() {
gitShallow = false
}
}
if err := wc.fetchGitTagSupplementary(ctx, pkg.name, pkg.conf.GitURL, gitShallow); err != nil {
log.Printf(" %s: supplementary gittag fetch: %v", pkg.name, err)
}
}
return nil
}
// fetchGitTagSupplementary clones a git repo to get commit hashes for
// packages that use a non-gittag source type (servicemandist, githubsource)
// but also have a git_url for source installs.
func (wc *WebiCache) fetchGitTagSupplementary(ctx context.Context, pkgName, gitURL string, shallow bool) error {
d, err := rawcache.Open(filepath.Join(wc.RawDir, "_gittag", pkgName))
if err != nil {
return err
}
repoDir := filepath.Join(wc.RawDir, "_repos")
os.MkdirAll(repoDir, 0o755)
for batch, err := range gittag.Fetch(ctx, gitURL, repoDir) {
if err != nil {
return err
}
for _, entry := range batch {
tag := entry.Version
if tag == "" {
tag = "HEAD-" + entry.CommitHash
}
data, _ := json.Marshal(entry)
d.Merge(tag, data)
}
if shallow {
break
}
}
return nil
}
func (wc *WebiCache) fetchGitHub(ctx context.Context, pkgName string, conf *installerconf.Conf, shallow bool) error {
owner, repo := conf.Owner, conf.Repo
if owner == "" || repo == "" {
return fmt.Errorf("missing owner or repo")
}
d, err := rawcache.Open(filepath.Join(wc.RawDir, pkgName))
if err != nil {
return err
}
tagPrefix := conf.TagPrefix
for batch, err := range github.Fetch(ctx, wc.Client, owner, repo, wc.Auth) {
if err != nil {
return fmt.Errorf("github %s/%s: %w", owner, repo, err)
}
for _, rel := range batch {
if rel.Draft {
continue
}
tag := rel.TagName
if tagPrefix != "" && !strings.HasPrefix(tag, tagPrefix) {
continue
}
data, _ := json.Marshal(rel)
d.Merge(tag, data)
}
if shallow {
break
}
}
return nil
}
func (wc *WebiCache) fetchNodeDist(ctx context.Context, pkgName string, conf *installerconf.Conf) error {
baseURL := conf.BaseURL
if baseURL == "" {
return fmt.Errorf("missing url")
}
d, err := rawcache.Open(filepath.Join(wc.RawDir, pkgName))
if err != nil {
return err
}
// Fetch from primary URL. Tag with "official/" prefix so unofficial
// entries for the same version don't overwrite.
for batch, err := range nodedist.Fetch(ctx, wc.Client, baseURL) {
if err != nil {
return err
}
for _, entry := range batch {
data, _ := json.Marshal(entry)
d.Merge("official/"+entry.Version, data)
}
}
// Fetch from unofficial URL if configured (e.g. Node.js unofficial builds
// which add musl, riscv64, loong64 targets).
if unofficialURL := conf.Extra["unofficial_url"]; unofficialURL != "" {
for batch, err := range nodedist.Fetch(ctx, wc.Client, unofficialURL) {
if err != nil {
log.Printf("warning: %s unofficial fetch: %v", pkgName, err)
break
}
for _, entry := range batch {
data, _ := json.Marshal(entry)
d.Merge("unofficial/"+entry.Version, data)
}
}
}
return nil
}
func (wc *WebiCache) fetchGitTag(ctx context.Context, pkgName string, conf *installerconf.Conf, shallow bool) error {
gitURL := conf.BaseURL
if gitURL == "" {
return fmt.Errorf("missing url")
}
d, err := rawcache.Open(filepath.Join(wc.RawDir, pkgName))
if err != nil {
return err
}
repoDir := filepath.Join(wc.RawDir, "_repos")
os.MkdirAll(repoDir, 0o755)
for batch, err := range gittag.Fetch(ctx, gitURL, repoDir) {
if err != nil {
return err
}
for _, entry := range batch {
tag := entry.Version
if tag == "" {
tag = "HEAD-" + entry.CommitHash
}
data, _ := json.Marshal(entry)
d.Merge(tag, data)
}
if shallow {
break
}
}
return nil
}
func (wc *WebiCache) fetchGitea(ctx context.Context, pkgName string, conf *installerconf.Conf, shallow bool) error {
baseURL, owner, repo := conf.BaseURL, conf.Owner, conf.Repo
if baseURL == "" || owner == "" || repo == "" {
return fmt.Errorf("missing base_url, owner, or repo")
}
d, err := rawcache.Open(filepath.Join(wc.RawDir, pkgName))
if err != nil {
return err
}
for batch, err := range gitea.Fetch(ctx, wc.Client, baseURL, owner, repo, nil) {
if err != nil {
return err
}
for _, rel := range batch {
if rel.Draft {
continue
}
data, _ := json.Marshal(rel)
d.Merge(rel.TagName, data)
}
if shallow {
break
}
}
return nil
}
func fetchChromeDist(ctx context.Context, client *http.Client, rawDir, pkgName string) error {
d, err := rawcache.Open(filepath.Join(rawDir, pkgName))
if err != nil {
return err
}
for batch, err := range chromedist.Fetch(ctx, client) {
if err != nil {
return fmt.Errorf("chromedist: %w", err)
}
for _, ver := range batch {
data, _ := json.Marshal(ver)
d.Merge(ver.Version, data)
}
}
return nil
}
func fetchFlutterDist(ctx context.Context, client *http.Client, rawDir, pkgName string) error {
d, err := rawcache.Open(filepath.Join(rawDir, pkgName))
if err != nil {
return err
}
for batch, err := range flutterdist.Fetch(ctx, client) {
if err != nil {
return fmt.Errorf("flutterdist: %w", err)
}
for _, rel := range batch {
// Key by version+channel+os for uniqueness.
key := rel.Version + "-" + rel.Channel + "-" + rel.OS
data, _ := json.Marshal(rel)
d.Merge(key, data)
}
}
return nil
}
func fetchGolang(ctx context.Context, client *http.Client, rawDir, pkgName string) error {
d, err := rawcache.Open(filepath.Join(rawDir, pkgName))
if err != nil {
return err
}
for batch, err := range golang.Fetch(ctx, client) {
if err != nil {
return fmt.Errorf("golang: %w", err)
}
for _, rel := range batch {
data, _ := json.Marshal(rel)
d.Merge(rel.Version, data)
}
}
return nil
}
func fetchGPGDist(ctx context.Context, client *http.Client, rawDir, pkgName string) error {
d, err := rawcache.Open(filepath.Join(rawDir, pkgName))
if err != nil {
return err
}
for batch, err := range gpgdist.Fetch(ctx, client) {
if err != nil {
return fmt.Errorf("gpgdist: %w", err)
}
for _, entry := range batch {
data, _ := json.Marshal(entry)
d.Merge(entry.Version, data)
}
}
return nil
}
func fetchHashiCorp(ctx context.Context, client *http.Client, rawDir, pkgName string, conf *installerconf.Conf) error {
product := conf.Repo
if product == "" {
product = pkgName
}
d, err := rawcache.Open(filepath.Join(rawDir, pkgName))
if err != nil {
return err
}
for idx, err := range hashicorp.Fetch(ctx, client, product) {
if err != nil {
return fmt.Errorf("hashicorp %s: %w", product, err)
}
for ver, vdata := range idx.Versions {
data, _ := json.Marshal(vdata)
d.Merge(ver, data)
}
}
return nil
}
func fetchITerm2Dist(ctx context.Context, client *http.Client, rawDir, pkgName string) error {
d, err := rawcache.Open(filepath.Join(rawDir, pkgName))
if err != nil {
return err
}
for batch, err := range iterm2dist.Fetch(ctx, client) {
if err != nil {
return fmt.Errorf("iterm2dist: %w", err)
}
for _, entry := range batch {
key := entry.Version
if entry.Channel == "beta" {
key += "-beta"
}
data, _ := json.Marshal(entry)
d.Merge(key, data)
}
}
return nil
}
func fetchJuliaDist(ctx context.Context, client *http.Client, rawDir, pkgName string) error {
d, err := rawcache.Open(filepath.Join(rawDir, pkgName))
if err != nil {
return err
}
for batch, err := range juliadist.Fetch(ctx, client) {
if err != nil {
return fmt.Errorf("juliadist: %w", err)
}
for _, rel := range batch {
data, _ := json.Marshal(rel)
d.Merge(rel.Version, data)
}
}
return nil
}
func fetchMariaDBDist(ctx context.Context, client *http.Client, rawDir, pkgName string) error {
d, err := rawcache.Open(filepath.Join(rawDir, pkgName))
if err != nil {
return err
}
for batch, err := range mariadbdist.Fetch(ctx, client) {
if err != nil {
return fmt.Errorf("mariadbdist: %w", err)
}
for _, rel := range batch {
data, _ := json.Marshal(rel)
d.Merge(rel.ReleaseID, data)
}
}
return nil
}
func fetchZigDist(ctx context.Context, client *http.Client, rawDir, pkgName string) error {
d, err := rawcache.Open(filepath.Join(rawDir, pkgName))
if err != nil {
return err
}
for batch, err := range zigdist.Fetch(ctx, client) {
if err != nil {
return fmt.Errorf("zigdist: %w", err)
}
for _, rel := range batch {
data, _ := json.Marshal(rel)
d.Merge(rel.Version, data)
}
}
return nil
}

1
comrak/releases.conf Normal file
View File

@@ -0,0 +1 @@
github_releases = kivikakk/comrak

1
crabz/releases.conf Normal file
View File

@@ -0,0 +1 @@
github_releases = sstadick/crabz

1
curlie/releases.conf Normal file
View File

@@ -0,0 +1 @@
github_releases = rs/curlie

1
dashcore/releases.conf Normal file
View File

@@ -0,0 +1 @@
github_releases = dashpay/dash

1
dashd/releases.conf Normal file
View File

@@ -0,0 +1 @@
alias_of = dashcore

1
dashmsg/releases.conf Normal file
View File

@@ -0,0 +1 @@
github_releases = dashhive/dashmsg

1
delta/releases.conf Normal file
View File

@@ -0,0 +1 @@
github_releases = dandavison/delta

1
deno/releases.conf Normal file
View File

@@ -0,0 +1 @@
github_releases = denoland/deno

121
docs/installer-patterns.md Normal file
View File

@@ -0,0 +1,121 @@
# Installer Archive Patterns
Every package falls into one of these archive structure patterns. When writing
or modifying an `install.sh`, identify the pattern first — it determines the
extraction and installation strategy.
## Pattern A: Bare Binary in Archive
Archive contains the binary (and maybe LICENSE/README) at the top level.
Examples: awless, caddy, cilium, curlie, dashmsg, deno, dotenv, dotenv-linter,
ffuf, fzf, gitdeploy, gprox, grype, hugo, hugo-extended, k9s, keypairs, koji,
lf, monorel, ots, runzip, sclient, sqlc, sqlpkg, sttr, terraform, uuidv7, xcaddy
Install: extract, move binary to `~/.local/opt/{pkg}-{ver}/bin/{binary}`, symlink.
## Pattern B: Subdirectory with Binary Only
Archive contains a version-named directory wrapping the binary and docs.
Examples: delta, hexyl, kubectx, kubens, shellcheck, trip, xsv
Typical directory naming: `{tool}-{ver}-{triplet}/`
Install: extract, find binary in subdirectory, move to opt, symlink.
Special cases:
- `pathman`: bare binary named with full release tag (needs rename)
- `yq`: binary named with platform suffix `yq_linux_amd64` (needs rename)
## Pattern C: Binary + Completions + Man Pages
Archive includes shell completions and/or man pages alongside the binary.
| Package | Completions Dir | Man Page |
|---------|----------------|----------|
| bat | `autocomplete/` | `bat.1` |
| fd | `autocomplete/{fd.bash,.fish,_fd}` | `fd.1` |
| goreleaser | `completions/{.bash,.fish,.zsh}` | `manpages/*.1.gz` |
| lsd | `autocomplete/{lsd.bash-completion,.fish,_lsd}` | `lsd.1` |
| rg | `complete/{rg.bash,.fish,_rg}` | `doc/rg.1` |
| sd | `completions/{sd.bash,.fish,_sd}` | `sd.1` |
| watchexec | `completions/{bash,fish,zsh}` | `watchexec.1` |
| zoxide | `completions/{zoxide.bash,.fish,_zoxide}` | `man/man1/zoxide*.1` |
Install: extract, install binary, install completions to standard dirs, install
man pages. Completion naming varies: `autocomplete/`, `completions/`, `complete/`.
## Pattern D: Binary + Libraries
Complex packages that bundle shared libraries.
| Package | Layout |
|---------|--------|
| ollama (Linux) | `bin/ollama` + `lib/ollama/{cuda_v12,cuda_v13,vulkan}/` |
| pg/postgres/psql | `bin/psql` + `lib/{libpq,libz,...}.so` + `include/` |
| sass | `dart-sass/sass` (wrapper) + `dart-sass/src/{dart,sass.snapshot}` |
| syncthing | `syncthing-{triplet}-{ver}/syncthing` + `etc/{systemd,...}/` |
| xz | `xz-{ver}-{triplet}/xz` + `xz-{ver}-{triplet}/unxz` |
Install: extract entire directory tree into opt, symlink binary.
## Pattern E: FHS-like Layout (bin/ + share/)
Archive already follows standard layout.
| Package | Layout |
|---------|--------|
| gh | `gh_{ver}_{os}_{arch}/bin/gh` + `share/man/man1/*.1` |
| pandoc | `pandoc-{ver}/bin/{pandoc,...}` + `share/man/man1/*.1.gz` |
Install: extract directly into opt (already correct layout).
## Pattern G: Full SDK/Toolchain
Self-contained toolchain with compiler, runtime, standard library.
| Package | Layout |
|---------|--------|
| cmake | `cmake-{ver}-{os}-{arch}/bin/{cmake,ctest,...}` + `share/` + `man/` |
| tinygo | `tinygo/bin/tinygo` + `tinygo/src/` + `tinygo/targets/` |
| go | `go/bin/{go,gofmt}` + `go/src/` + `go/pkg/` |
| zig | `zig-{os}-{arch}-{ver}/zig` + `lib/` |
| flutter | `flutter/bin/flutter` + full SDK |
| julia | `julia-{ver}/bin/julia` + full SDK |
| node | `node-{ver}-{os}-{arch}/bin/{node,npm,npx}` + `lib/` |
Install: extract entire tree into `~/.local/opt/{pkg}-{ver}/`, symlink `bin/*`.
## Pattern H: .NET Runtime Bundle
Flat archive with hundreds of DLLs.
Example: pwsh — `pwsh` binary + `*.dll` + locale dirs
Install: extract entire directory into opt, symlink primary binary.
## Pattern I: Multi-Binary Distribution
Archive contains multiple related binaries + libs.
| Package | Layout |
|---------|--------|
| dashcore | `dashcore-{ver}/bin/{dashd,dash-cli,...}` + `lib/` + `share/man/` |
| mutagen | `mutagen` + `mutagen-agents.tar.gz` (embedded agent archive) |
Install: extract into opt, symlink primary binary.
## Format Changes Over Time
Most packages have stable formats. Notable structural changes:
| Package | When | Change |
|---------|------|--------|
| sd | 2023 | zip → tar.gz, added completions + man page |
| ollama | 2025-2026 | bare binary → no GitHub release → tar.zst with lib/ |
| deno | 2020-2021 | .gz (gzipped binary) → .zip |
| hugo | 2017-2018 | zip → tar.gz; 2024: macOS → .pkg only |
| gh | 2024 | darwin: tar.gz → .pkg |
| sclient | 2023 | tar.gz → tar.xz |
| watchexec | 2019-2020 | tar.gz → tar.xz |

74
docs/version-oddities.md Normal file
View File

@@ -0,0 +1,74 @@
# Version & Release Oddities
Non-standard version formats and tag prefixes that affect parsing, sorting,
and classification. The Go classifier and `internal/lexver` must handle all
of these.
## Non-Numeric Tag Prefixes
| Package | Raw Tag | Cleaned | Transform |
|---------|---------|---------|-----------|
| lf | `r21` | `0.21.0` | `r` prefix → prepend `0.` |
| bun | `bun-v1.0.0` | `1.0.0` | Strip `bun-` prefix |
| jq | `jq-1.7` | `1.7` | Strip `jq-` prefix |
| watchexec | `cli-v1.2.3` | `1.2.3` | Strip `cli-` prefix |
| ffmpeg | `b6.0` | `6.0` | Strip `b` prefix |
## Underscore-Delimited Tags
| Package | Raw Tag | Cleaned | Transform |
|---------|---------|---------|-----------|
| postgres | `REL_17_0` | `17.0` | Strip `REL_`, replace `_` with `.` |
| psql | `REL_17_0` | `17.0` | Same as postgres |
## Platform Suffix in Version
| Package | Raw Tag | Cleaned | Transform |
|---------|---------|---------|-----------|
| git (Windows) | `2.41.0.windows.1` | `2.41.0` | Strip `.windows.N` suffix |
## 4-Part Versions
| Package | Example | Notes |
|---------|---------|-------|
| chromedriver | `121.0.6120.0` | Google Chrome's versioning |
| gpg | `2.2.19.0` | 4th segment is build metadata |
## Date-Based Versions
| Package | Notes |
|---------|-------|
| atomicparsley | Date-based version strings |
## Complex Pre-Release Formats
| Package | Example | Notes |
|---------|---------|-------|
| flutter | `2.3.0-16.0.pre` | Extra dots and numeric segments |
| iterm2 | `iTerm2_3_5_0beta17` | Underscores, beta attached → `3.5.0-beta17` |
## Channel Detection
- Node.js: odd major = "current" not LTS (v15, v17, v19, v21, v23)
- Go: `go` prefix stripped (`go1.23.6``1.23.6`)
- Terraform: `-alpha`, `-beta`, `-rc` suffixes → beta channel
## Directory Symlinks (Aliases)
These are directory-level symlinks. They share all files (including
releases.conf) with their target automatically.
```
msvc-runtime → vcruntime
msvcruntime → vcruntime
rust.vim → vim-rust
vc-redist → vcruntime
vc-runtime → vcruntime
vc_redist → vcruntime
vcredist → vcruntime
vcruntime140 → vcruntime
vim-essential → vim-essentials
vim-mouse → vim-gui
vps-myip → myip
xcode-cli → commandlinetools
```

View File

@@ -0,0 +1 @@
github_releases = dotenv-linter/dotenv-linter

1
dotenv/releases.conf Normal file
View File

@@ -0,0 +1 @@
github_releases = therootcompany/dotenv

2
duckdns.sh/releases.conf Normal file
View File

@@ -0,0 +1,2 @@
github_sources = BeyondCodeBootcamp/DuckDNS.sh
git_url = https://github.com/BeyondCodeBootcamp/DuckDNS.sh.git

View File

@@ -27,22 +27,6 @@ __init_fd() {
# chmod a+x "$HOME/.local/opt/fd-v8.1.1/bin/fd"
chmod a+x "$pkg_src_cmd"
# install completions if present (autocomplete/{fd.bash,fd.fish,_fd})
if test -d ./fd-*/autocomplete; then
mkdir -p "$pkg_src_dir/share/bash-completion/completions"
mkdir -p "$pkg_src_dir/share/fish/vendor_completions.d"
mkdir -p "$pkg_src_dir/share/zsh/site-functions"
mv ./fd-*/autocomplete/fd.bash "$pkg_src_dir/share/bash-completion/completions/fd" 2>/dev/null || true
mv ./fd-*/autocomplete/fd.fish "$pkg_src_dir/share/fish/vendor_completions.d/fd.fish" 2>/dev/null || true
mv ./fd-*/autocomplete/_fd "$pkg_src_dir/share/zsh/site-functions/_fd" 2>/dev/null || true
fi
# install man page if present
if test -f ./fd-*/fd.1; then
mkdir -p "$pkg_src_dir/share/man/man1"
mv ./fd-*/fd.1 "$pkg_src_dir/share/man/man1/fd.1"
fi
}
}

1
fd/releases.conf Normal file
View File

@@ -0,0 +1 @@
github_releases = sharkdp/fd

4
ffmpeg/releases.conf Normal file
View File

@@ -0,0 +1,4 @@
source = ffmpegdist
github_releases = eugeneware/ffmpeg-static
asset_filter = ffmpeg
version_prefix = b

1
ffuf/releases.conf Normal file
View File

@@ -0,0 +1 @@
github_releases = ffuf/ffuf

2
fish/releases.conf Normal file
View File

@@ -0,0 +1,2 @@
github_releases = fish-shell/fish-shell
exclude = bundledpcre fish-static OpenBeta

1
flutter/releases.conf Normal file
View File

@@ -0,0 +1 @@
source = flutterdist

1
fzf/releases.conf Normal file
View File

@@ -0,0 +1 @@
github_releases = junegunn/fzf

View File

@@ -25,12 +25,6 @@ __init_gh() {
# mv ./gh-*/gh ~/.local/opt/gh-v0.99.9/bin/gh
mv ./"$pkg_cmd_name"*/bin/gh "$pkg_src_cmd"
# install man pages if present
if test -d ./"$pkg_cmd_name"*/share/man; then
mkdir -p "$pkg_src_dir/share"
mv ./"$pkg_cmd_name"*/share/man "$pkg_src_dir/share/man"
fi
}
# pkg_get_current_version is recommended, but (soon) not required

1
gh/releases.conf Normal file
View File

@@ -0,0 +1 @@
github_releases = cli/cli

4
git/releases.conf Normal file
View File

@@ -0,0 +1,4 @@
github_releases = git-for-windows/git
asset_filter = MinGit
exclude = busybox
variants = installer

1
gitdeploy/releases.conf Normal file
View File

@@ -0,0 +1 @@
github_releases = therootcompany/gitdeploy

2
gitea/releases.conf Normal file
View File

@@ -0,0 +1,2 @@
github_releases = go-gitea/gitea
exclude = -src- -docs-

1
gnupg/releases.conf Normal file
View File

@@ -0,0 +1 @@
alias_of = gpg

5
go.mod Normal file
View File

@@ -0,0 +1,5 @@
module github.com/webinstall/webi-installers
go 1.26.1
require github.com/joho/godotenv v1.5.1

2
go.sum Normal file
View File

@@ -0,0 +1,2 @@
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=

1
go/releases.conf Normal file
View File

@@ -0,0 +1 @@
source = golang

1
golang/releases.conf Normal file
View File

@@ -0,0 +1 @@
alias_of = go

View File

@@ -25,23 +25,6 @@ __init_goreleaser() {
# mv ./goreleaser-*/goreleaser ~/.local/opt/goreleaser-v1.21.2/bin/goreleaser
mv ./goreleaser "$pkg_src_cmd"
# install completions if present (completions/{goreleaser.bash,.fish,.zsh})
if test -d ./completions; then
mkdir -p "$pkg_src_dir/share/bash-completion/completions"
mkdir -p "$pkg_src_dir/share/fish/vendor_completions.d"
mkdir -p "$pkg_src_dir/share/zsh/site-functions"
mv ./completions/goreleaser.bash "$pkg_src_dir/share/bash-completion/completions/goreleaser" 2>/dev/null || true
mv ./completions/goreleaser.fish "$pkg_src_dir/share/fish/vendor_completions.d/goreleaser.fish" 2>/dev/null || true
mv ./completions/goreleaser.zsh "$pkg_src_dir/share/zsh/site-functions/_goreleaser" 2>/dev/null || true
fi
# install man page if present (manpages/goreleaser.1.gz)
if test -d ./manpages; then
mkdir -p "$pkg_src_dir/share/man/man1"
mv ./manpages/*.1.gz "$pkg_src_dir/share/man/man1/" 2>/dev/null || true
mv ./manpages/*.1 "$pkg_src_dir/share/man/man1/" 2>/dev/null || true
fi
}
# pkg_get_current_version is recommended, but (soon) not required

1
goreleaser/releases.conf Normal file
View File

@@ -0,0 +1 @@
github_releases = goreleaser/goreleaser

1
gpg/releases.conf Normal file
View File

@@ -0,0 +1 @@
source = gpgdist

1
gprox/releases.conf Normal file
View File

@@ -0,0 +1 @@
github_releases = creedasaurus/gprox

1
grype/releases.conf Normal file
View File

@@ -0,0 +1 @@
github_releases = anchore/grype

1
hexyl/releases.conf Normal file
View File

@@ -0,0 +1 @@
github_releases = sharkdp/hexyl

View File

@@ -0,0 +1,3 @@
github_releases = gohugoio/hugo
asset_filter = extended
exclude = Linux-64bit

2
hugo/releases.conf Normal file
View File

@@ -0,0 +1,2 @@
github_releases = gohugoio/hugo
exclude = extended Linux-64bit

View File

@@ -0,0 +1,168 @@
// Package buildmeta is the shared vocabulary for Webi's build targets.
//
// Every package that deals with OS, architecture, libc, archive format, or
// release channel imports these types instead of passing raw strings. This
// prevents typos like "darwn" from compiling and gives a single place to
// enumerate what Webi supports.
package buildmeta
// OS represents a target operating system.
type OS string
const (
OSAny OS = "ANYOS"
OSDarwin OS = "darwin"
OSLinux OS = "linux"
OSWindows OS = "windows"
OSFreeBSD OS = "freebsd"
OSOpenBSD OS = "openbsd"
OSNetBSD OS = "netbsd"
OSDragonFly OS = "dragonfly"
OSSunOS OS = "sunos"
OSIllumos OS = "illumos"
OSSolaris OS = "solaris"
OSAIX OS = "aix"
OSAndroid OS = "android"
OSPlan9 OS = "plan9"
// POSIX compatibility levels — used when a package is a shell script
// or otherwise OS-independent for POSIX systems.
OSPosix2017 OS = "posix_2017"
OSPosix2024 OS = "posix_2024"
)
// Arch represents a target CPU architecture.
type Arch string
const (
ArchAny Arch = "ANYARCH"
ArchAMD64 Arch = "x86_64" // baseline (v1)
ArchAMD64v2 Arch = "x86_64_v2" // +SSE4, +POPCNT, etc.
ArchAMD64v3 Arch = "x86_64_v3" // +AVX2, +BMI, etc.
ArchAMD64v4 Arch = "x86_64_v4" // +AVX-512
ArchARM64 Arch = "aarch64"
ArchARMv7 Arch = "armv7"
ArchARMv6 Arch = "armv6"
ArchARMv5 Arch = "armv5"
ArchX86 Arch = "x86"
ArchPPC64LE Arch = "ppc64le"
ArchPPC64 Arch = "ppc64"
ArchPPC Arch = "powerpc" // 32-bit PowerPC (unsupported by webi, used to prevent gnueabihf over-matching)
ArchRISCV64 Arch = "riscv64"
ArchS390X Arch = "s390x"
ArchLoong64 Arch = "loong64"
ArchMIPS64LE Arch = "mips64le"
ArchMIPS64 Arch = "mips64"
ArchMIPS64R6EL Arch = "mips64r6el"
ArchMIPS64R6 Arch = "mips64r6"
ArchMIPSLE Arch = "mipsle"
ArchMIPS Arch = "mips"
// Universal (fat) binary architectures for macOS.
ArchUniversal1 Arch = "universal1" // PPC + x86 (Rosetta 1 era)
ArchUniversal2 Arch = "universal2" // x86_64 + ARM64 (Rosetta 2 era)
)
// Libc represents the C library a binary is linked against.
type Libc string
const (
LibcNone Libc = "none" // statically linked or no libc dependency (Go, Zig, etc.)
LibcGNU Libc = "gnu" // requires glibc (most Linux distros)
LibcMusl Libc = "musl" // requires musl (Alpine, some Docker images)
LibcMSVC Libc = "msvc" // Microsoft Visual C++ runtime
)
// Format represents an archive or package format.
type Format string
const (
FormatTarGz Format = ".tar.gz"
FormatTarXz Format = ".tar.xz"
FormatTarZst Format = ".tar.zst"
FormatTarBz2 Format = ".tar.bz2"
FormatZip Format = ".zip"
FormatGz Format = ".gz"
FormatXz Format = ".xz"
FormatZst Format = ".zst"
FormatExe Format = ".exe"
FormatExeXz Format = ".exe.xz"
FormatMSI Format = ".msi"
FormatDMG Format = ".dmg"
FormatPkg Format = ".pkg"
FormatAppZip Format = ".app.zip"
Format7z Format = ".7z"
FormatDeb Format = ".deb"
FormatRPM Format = ".rpm"
FormatSnap Format = ".snap"
FormatAppx Format = ".appx"
FormatAPK Format = ".apk"
FormatAppImage Format = ".AppImage"
FormatSh Format = ".sh"
FormatGit Format = ".git"
)
// Channel represents a release stability channel.
type Channel string
const (
ChannelStable Channel = "stable"
ChannelLatest Channel = "latest"
ChannelRC Channel = "rc"
ChannelPreview Channel = "preview"
ChannelBeta Channel = "beta"
ChannelAlpha Channel = "alpha"
ChannelDev Channel = "dev"
)
// Target represents a fully resolved build target.
type Target struct {
OS OS
Arch Arch
Libc Libc
}
// Triplet returns the canonical "os-arch-libc" string.
func (t Target) Triplet() string {
return string(t.OS) + "-" + string(t.Arch) + "-" + string(t.Libc)
}
// CompatArches returns the architectures that the given OS+arch
// combination can execute, ordered from most specific to least.
// The input arch is always first.
//
// These are OS-level facts (hardware + translation layer), not
// package-specific. Per-package overrides belong in installer config.
func CompatArches(os OS, arch Arch) []Arch {
switch os {
case OSDarwin:
switch arch {
case ArchARM64:
// Rosetta 2: Apple Silicon runs x86_64 binaries.
return []Arch{ArchARM64, ArchUniversal2, ArchAMD64}
case ArchAMD64:
return []Arch{ArchAMD64, ArchUniversal2, ArchX86}
}
case OSWindows:
switch arch {
case ArchARM64:
// Windows on ARM emulates x86_64 and x86.
return []Arch{ArchARM64, ArchAMD64, ArchX86}
}
}
// Micro-architecture fallbacks (universal across all OSes).
switch arch {
case ArchAMD64v4:
return []Arch{ArchAMD64v4, ArchAMD64v3, ArchAMD64v2, ArchAMD64}
case ArchAMD64v3:
return []Arch{ArchAMD64v3, ArchAMD64v2, ArchAMD64}
case ArchAMD64v2:
return []Arch{ArchAMD64v2, ArchAMD64}
case ArchARMv7:
return []Arch{ArchARMv7, ArchARMv6}
}
return []Arch{arch}
}

View File

@@ -0,0 +1,283 @@
// Package classify extracts build targets from release asset filenames.
//
// Standard toolchains (goreleaser, cargo-dist, zig build) produce predictable
// filenames like "tool_0.1.0_linux_amd64.tar.gz" or
// "tool-0.1.0-x86_64-unknown-linux-musl.tar.gz". This package matches those
// patterns directly using regex, avoiding heuristic guessing.
//
// Detection order matters: architectures are checked longest-first to prevent
// "x86" from matching inside "x86_64", and OS checks use word boundaries.
package classify
import (
"path"
"regexp"
"strings"
"github.com/webinstall/webi-installers/internal/buildmeta"
)
// Result holds the classification of an asset filename.
type Result struct {
OS buildmeta.OS
Arch buildmeta.Arch
Libc buildmeta.Libc
Format buildmeta.Format
}
// Target returns the build target (OS + Arch + Libc).
func (r Result) Target() buildmeta.Target {
return buildmeta.Target{OS: r.OS, Arch: r.Arch, Libc: r.Libc}
}
// Filename classifies a release asset filename, returning the detected
// OS, architecture, libc, and archive format. Undetected fields are empty.
//
// OS is detected first because it can influence arch interpretation.
// For example, "windows-arm" in modern releases means ARM64, while
// bare "arm" on Linux historically means ARMv6.
func Filename(name string) Result {
lower := strings.ToLower(name)
os := detectOS(lower)
arch := detectArch(lower)
format := detectFormat(lower)
// .deb, .rpm, .snap are Linux-only package formats.
if os == "" && (format == buildmeta.FormatDeb || format == buildmeta.FormatRPM || format == buildmeta.FormatSnap) {
os = buildmeta.OSLinux
}
// .app.zip and .dmg are macOS-only formats.
if os == "" && (format == buildmeta.FormatAppZip || format == buildmeta.FormatDMG) {
os = buildmeta.OSDarwin
}
return Result{
OS: os,
Arch: arch,
Libc: detectLibc(lower),
Format: format,
}
}
// b is a boundary: start/end of string or a non-alphanumeric separator.
// Go's RE2 doesn't support \b, so we use this instead.
const b = `(?:^|[^a-zA-Z0-9])`
const bEnd = `(?:[^a-zA-Z0-9]|$)`
// --- OS detection ---
var osPatterns = []struct {
os buildmeta.OS
pattern *regexp.Regexp
}{
// macos[\d.]* matches versioned names like "macos10.10", "macos11", "macos12.0" (cmake naming).
{buildmeta.OSDarwin, regexp.MustCompile(`(?i)(?:` + b + `(?:darwin|macos[\d.]*|macosx[\d.]*|osx[\d.]*|os-x|apple)` + bEnd + `|` + b + `mac` + bEnd + `)`)},
// linux[\d.]* matches versioned names like "linux64", "linux32" (chromedriver/dashcore naming).
{buildmeta.OSLinux, regexp.MustCompile(`(?i)` + b + `linux[\d.]*` + bEnd)},
{buildmeta.OSWindows, regexp.MustCompile(`(?i)` + b + `(?:windows|win(?:32|64|x64|dows)?)` + bEnd + `|\.exe(?:\.xz)?$|\.msi$`)},
// freebsd\d* matches versioned names like "freebsd13", "freebsd14" (Gitea naming).
{buildmeta.OSFreeBSD, regexp.MustCompile(`(?i)` + b + `freebsd\d*` + bEnd)},
{buildmeta.OSOpenBSD, regexp.MustCompile(`(?i)` + b + `openbsd` + bEnd)},
{buildmeta.OSNetBSD, regexp.MustCompile(`(?i)` + b + `netbsd` + bEnd)},
{buildmeta.OSDragonFly, regexp.MustCompile(`(?i)` + b + `dragonfly(?:bsd)?` + bEnd)},
// solaris, illumos, and sunos are distinct OS values in the Node build-classifier.
// Keep them separate so the legacy cache matches what the classifier extracts.
{buildmeta.OSSolaris, regexp.MustCompile(`(?i)` + b + `solaris` + bEnd)},
{buildmeta.OSIllumos, regexp.MustCompile(`(?i)` + b + `illumos` + bEnd)},
{buildmeta.OSSunOS, regexp.MustCompile(`(?i)` + b + `sunos` + bEnd)},
{buildmeta.OSAIX, regexp.MustCompile(`(?i)` + b + `aix` + bEnd)},
{buildmeta.OSAndroid, regexp.MustCompile(`(?i)` + b + `android` + bEnd)},
{buildmeta.OSPlan9, regexp.MustCompile(`(?i)` + b + `plan9` + bEnd)},
}
func detectOS(lower string) buildmeta.OS {
for _, p := range osPatterns {
if p.pattern.MatchString(lower) {
return p.os
}
}
return ""
}
// --- Arch detection ---
// Order matters: check longer/more-specific patterns first.
var archPatterns = []struct {
arch buildmeta.Arch
pattern *regexp.Regexp
}{
// Universal/fat binaries before specific arches.
{buildmeta.ArchUniversal2, regexp.MustCompile(`(?i)` + b + `(?:universal2?|fat)` + bEnd)},
// amd64 micro-levels before baseline — "amd64v3" must not fall through to amd64.
// amd64_?vN: underscore optional but no dash — dash is ambiguous with version numbers
// (e.g. syncthing "amd64-v2.0.5" where v2 is the release version, not an arch level).
{buildmeta.ArchAMD64v4, regexp.MustCompile(`(?i)(?:x86[_-]64[_-]v4|amd64_?v4|v4-amd64)`)},
{buildmeta.ArchAMD64v3, regexp.MustCompile(`(?i)(?:x86[_-]64[_-]v3|amd64_?v3|v3-amd64)`)},
{buildmeta.ArchAMD64v2, regexp.MustCompile(`(?i)(?:x86[_-]64[_-]v2|amd64_?v2|v2-amd64)`)},
// amd64 baseline before x86 — "x86_64" must not match as x86.
{buildmeta.ArchAMD64, regexp.MustCompile(`(?i)(?:x86[_-]64|amd64|x64|win64)`)},
// arm64 before armv7/armv6 — "aarch64" must not match as arm.
{buildmeta.ArchARM64, regexp.MustCompile(`(?i)(?:aarch64|arm64|armv8)`)},
{buildmeta.ArchARMv7, regexp.MustCompile(`(?i)(?:armv7l?|arm-?v7|arm7|arm32|armhf)`)},
// armel and gnueabihf are ARMv6 soft/hard-float ABI names used in Debian and Rust triplets.
{buildmeta.ArchARMv6, regexp.MustCompile(`(?i)(?:armv6l?|arm-?v6|aarch32|armel|gnueabihf|` + b + `arm` + bEnd + `)`)},
{buildmeta.ArchARMv5, regexp.MustCompile(`(?i)(?:armv5)`)},
// powerpc64le/ppc64le before powerpc64/ppc64 before powerpc32.
// The longer powerpc* forms must come first to prevent shorter matches from
// winning. All powerpc entries must appear BEFORE ARM patterns — otherwise
// "powerpc-linux-gnueabihf" would match gnueabihf → ARMv6.
// ppc64el is an alternative spelling used in Debian/Ubuntu.
{buildmeta.ArchPPC64LE, regexp.MustCompile(`(?i)(?:powerpc64le|ppc64le|ppc64el)`)},
{buildmeta.ArchPPC64, regexp.MustCompile(`(?i)(?:powerpc64|ppc64)`)},
// powerpc (32-bit): webi does not serve powerpc32, but we must classify it
// here to prevent the gnueabihf suffix from matching the ARMv6 pattern.
{buildmeta.ArchPPC, regexp.MustCompile(`(?i)` + b + `powerpc` + bEnd)},
{buildmeta.ArchRISCV64, regexp.MustCompile(`(?i)riscv64`)},
{buildmeta.ArchS390X, regexp.MustCompile(`(?i)s390x`)},
{buildmeta.ArchLoong64, regexp.MustCompile(`(?i)loong(?:arch)?64`)},
// mips64r6 before mips64 — "mips64r6" contains "mips64" as a prefix.
{buildmeta.ArchMIPS64R6EL, regexp.MustCompile(`(?i)mips64r6e(?:l|le)`)},
{buildmeta.ArchMIPS64R6, regexp.MustCompile(`(?i)mips64r6`)},
{buildmeta.ArchMIPS64LE, regexp.MustCompile(`(?i)mips64(?:el|le)`)},
{buildmeta.ArchMIPS64, regexp.MustCompile(`(?i)mips64`)},
{buildmeta.ArchMIPSLE, regexp.MustCompile(`(?i)mips(?:el|le)`)},
{buildmeta.ArchMIPS, regexp.MustCompile(`(?i)` + b + `mips` + bEnd)},
// x86 last — must not steal x86_64.
{buildmeta.ArchX86, regexp.MustCompile(`(?i)(?:` + b + `x86` + bEnd + `|i[3-6]86|ia32|win32|` + b + `386` + bEnd + `)`)},
}
func detectArch(lower string) buildmeta.Arch {
for _, p := range archPatterns {
if p.pattern.MatchString(lower) {
return p.arch
}
}
return ""
}
// --- Libc detection ---
var (
reMusl = regexp.MustCompile(`(?i)` + b + `musl` + bEnd)
reGNU = regexp.MustCompile(`(?i)` + b + `(?:gnu|glibc)` + bEnd)
reMSVC = regexp.MustCompile(`(?i)` + b + `msvc` + bEnd)
reStatic = regexp.MustCompile(`(?i)` + b + `static` + bEnd)
)
func detectLibc(lower string) buildmeta.Libc {
switch {
case reMusl.MatchString(lower):
return buildmeta.LibcMusl
case reGNU.MatchString(lower):
return buildmeta.LibcGNU
case reMSVC.MatchString(lower):
return buildmeta.LibcMSVC
case reStatic.MatchString(lower):
return buildmeta.LibcNone
}
return ""
}
// --- Format detection ---
// formatSuffixes maps file extensions to formats, longest first.
var formatSuffixes = []struct {
suffix string
format buildmeta.Format
}{
{".tar.gz", buildmeta.FormatTarGz},
{".tar.xz", buildmeta.FormatTarXz},
{".tar.zst", buildmeta.FormatTarZst},
{".tar.bz2", buildmeta.FormatTarBz2},
{".exe.xz", buildmeta.FormatExeXz},
{".app.zip", buildmeta.FormatAppZip},
{".tgz", buildmeta.FormatTarGz},
{".zip", buildmeta.FormatZip},
{".gz", buildmeta.FormatGz},
{".xz", buildmeta.FormatXz},
{".zst", buildmeta.FormatZst},
{".7z", buildmeta.Format7z},
{".exe", buildmeta.FormatExe},
{".msi", buildmeta.FormatMSI},
{".dmg", buildmeta.FormatDMG},
{".deb", buildmeta.FormatDeb},
{".rpm", buildmeta.FormatRPM},
{".snap", buildmeta.FormatSnap},
{".appx", buildmeta.FormatAppx},
{".apk", buildmeta.FormatAPK},
{".AppImage", buildmeta.FormatAppImage},
{".pkg", buildmeta.FormatPkg},
}
func detectFormat(lower string) buildmeta.Format {
// Use the base name to avoid directory separators confusing suffix matching.
base := path.Base(lower)
for _, s := range formatSuffixes {
if strings.HasSuffix(base, s.suffix) {
return s.format
}
}
return ""
}
// IsMetaAsset returns true if the filename is a non-installable meta file
// (checksums, signatures, source tarballs, documentation, etc.).
func IsMetaAsset(name string) bool {
lower := strings.ToLower(name)
for _, suffix := range []string{
".txt",
".sha256",
".sha256sum",
".sha512",
".sha512sum",
".md5",
".md5sum",
".sig",
".asc",
".pem",
".sbom",
".spdx",
".json.sig",
".sigstore",
".minisig",
"_src.tar.gz",
"_src.tar.xz",
"_src.zip",
"-src.tar.gz",
".src.tar.gz",
"-src.tar.xz",
"-src.zip",
".d.ts",
".pub",
".bsdiff",
".flatpak",
} {
if strings.HasSuffix(lower, suffix) {
return true
}
}
for _, substr := range []string{
"checksums",
"sha256sum",
"sha512sum",
"buildable-artifact",
".LICENSE",
".README",
} {
if strings.Contains(lower, substr) {
return true
}
}
for _, exact := range []string{
"install.sh",
"install.ps1",
"compat.json",
"b3sums",
"dist-manifest.json",
} {
if lower == exact {
return true
}
}
return false
}

View File

@@ -0,0 +1,352 @@
package classify_test
import (
"testing"
"github.com/webinstall/webi-installers/internal/buildmeta"
"github.com/webinstall/webi-installers/internal/classify"
)
func TestFilename(t *testing.T) {
tests := []struct {
name string
input string
wantOS buildmeta.OS
arch buildmeta.Arch
libc buildmeta.Libc
format buildmeta.Format
}{
// Goreleaser-style
{
name: "goreleaser linux amd64 tar.gz",
input: "hugo_0.145.0_linux-amd64.tar.gz",
wantOS: buildmeta.OSLinux,
arch: buildmeta.ArchAMD64,
format: buildmeta.FormatTarGz,
},
{
name: "goreleaser darwin arm64 tar.gz",
input: "hugo_0.145.0_darwin-arm64.tar.gz",
wantOS: buildmeta.OSDarwin,
arch: buildmeta.ArchARM64,
format: buildmeta.FormatTarGz,
},
{
name: "goreleaser windows amd64 zip",
input: "hugo_0.145.0_windows-amd64.zip",
wantOS: buildmeta.OSWindows,
arch: buildmeta.ArchAMD64,
format: buildmeta.FormatZip,
},
{
name: "goreleaser freebsd",
input: "hugo_0.145.0_freebsd-amd64.tar.gz",
wantOS: buildmeta.OSFreeBSD,
arch: buildmeta.ArchAMD64,
format: buildmeta.FormatTarGz,
},
// Rust/cargo-dist style
{
name: "rust linux musl",
input: "ripgrep-14.1.1-x86_64-unknown-linux-musl.tar.gz",
wantOS: buildmeta.OSLinux,
arch: buildmeta.ArchAMD64,
libc: buildmeta.LibcMusl,
format: buildmeta.FormatTarGz,
},
{
name: "rust linux gnu",
input: "bat-v0.24.0-x86_64-unknown-linux-gnu.tar.gz",
wantOS: buildmeta.OSLinux,
arch: buildmeta.ArchAMD64,
libc: buildmeta.LibcGNU,
format: buildmeta.FormatTarGz,
},
{
name: "rust apple darwin",
input: "ripgrep-14.1.1-x86_64-apple-darwin.tar.gz",
wantOS: buildmeta.OSDarwin,
arch: buildmeta.ArchAMD64,
format: buildmeta.FormatTarGz,
},
{
name: "rust windows msvc",
input: "bat-v0.24.0-x86_64-pc-windows-msvc.zip",
wantOS: buildmeta.OSWindows,
arch: buildmeta.ArchAMD64,
libc: buildmeta.LibcMSVC,
format: buildmeta.FormatZip,
},
{
name: "rust aarch64 linux",
input: "ripgrep-14.1.1-aarch64-unknown-linux-gnu.tar.gz",
wantOS: buildmeta.OSLinux,
arch: buildmeta.ArchARM64,
libc: buildmeta.LibcGNU,
format: buildmeta.FormatTarGz,
},
// Zig-style
{
name: "zig linux x86_64",
input: "zig-linux-x86_64-0.14.0.tar.xz",
wantOS: buildmeta.OSLinux,
arch: buildmeta.ArchAMD64,
format: buildmeta.FormatTarXz,
},
{
name: "zig macos aarch64",
input: "zig-macos-aarch64-0.14.0.tar.xz",
wantOS: buildmeta.OSDarwin,
arch: buildmeta.ArchARM64,
format: buildmeta.FormatTarXz,
},
// Windows executables
{
name: "bare exe",
input: "jq-windows-amd64.exe",
wantOS: buildmeta.OSWindows,
arch: buildmeta.ArchAMD64,
format: buildmeta.FormatExe,
},
{
name: "msi installer",
input: "caddy_2.9.0_windows_amd64.msi",
wantOS: buildmeta.OSWindows,
arch: buildmeta.ArchAMD64,
format: buildmeta.FormatMSI,
},
// macOS formats
{
name: "dmg installer",
input: "MyApp-1.0.0-darwin-arm64.dmg",
wantOS: buildmeta.OSDarwin,
arch: buildmeta.ArchARM64,
format: buildmeta.FormatDMG,
},
// Arch priority: x86_64 must not match x86
{
name: "x86_64 not x86",
input: "tool-x86_64-linux.tar.gz",
wantOS: buildmeta.OSLinux,
arch: buildmeta.ArchAMD64,
format: buildmeta.FormatTarGz,
},
{
name: "actual x86",
input: "tool-x86-linux.tar.gz",
wantOS: buildmeta.OSLinux,
arch: buildmeta.ArchX86,
format: buildmeta.FormatTarGz,
},
{
name: "i386",
input: "tool-linux-i386.tar.gz",
wantOS: buildmeta.OSLinux,
arch: buildmeta.ArchX86,
format: buildmeta.FormatTarGz,
},
// Windows ARM: bare "arm" is armv6 (some tools ship genuine arm32 Windows builds).
// Explicit "arm64" is always aarch64 regardless of OS.
{
name: "windows bare arm stays armv6",
input: "tool-1.0.0-windows-arm.zip",
wantOS: buildmeta.OSWindows,
arch: buildmeta.ArchARMv6,
format: buildmeta.FormatZip,
},
{
name: "windows armv6 stays armv6",
input: "tool-2.0.0-windows-armv6.zip",
wantOS: buildmeta.OSWindows,
arch: buildmeta.ArchARMv6,
format: buildmeta.FormatZip,
},
{
name: "windows arm64 stays arm64",
input: "tool-1.0.0-windows-arm64.zip",
wantOS: buildmeta.OSWindows,
arch: buildmeta.ArchARM64,
format: buildmeta.FormatZip,
},
// armel and gnueabihf are ARMv6 ABI names
{
name: "armel is armv6",
input: "jq-linux-armel",
wantOS: buildmeta.OSLinux,
arch: buildmeta.ArchARMv6,
},
{
name: "gnueabihf is armv6",
input: "tool-arm-unknown-linux-gnueabihf.tar.gz",
wantOS: buildmeta.OSLinux,
arch: buildmeta.ArchARMv6,
format: buildmeta.FormatTarGz,
},
// winx64 is a Windows x86_64 naming used by MariaDB
{
name: "winx64 is windows x86_64",
input: "mariadb-11.4.5-winx64.zip",
wantOS: buildmeta.OSWindows,
arch: buildmeta.ArchAMD64,
format: buildmeta.FormatZip,
},
// win32/win64 naming used by chromedriver, dashcore, etc.
{
name: "win32 is windows x86",
input: "chromedriver-win32.zip",
wantOS: buildmeta.OSWindows,
arch: buildmeta.ArchX86,
format: buildmeta.FormatZip,
},
{
name: "win64 is windows amd64",
input: "dashcore-23.1.2-win64-setup.exe",
wantOS: buildmeta.OSWindows,
arch: buildmeta.ArchAMD64,
format: buildmeta.FormatExe,
},
// ppc64el is a Debian/Ubuntu alias for ppc64le
{
name: "ppc64el is ppc64le",
input: "jq-linux-ppc64el",
arch: buildmeta.ArchPPC64LE,
},
// amd64 micro-architecture levels
{
name: "amd64v2",
input: "tool-linux-amd64v2.tar.gz",
arch: buildmeta.ArchAMD64v2,
},
{
name: "amd64v3",
input: "tool-linux-x86_64_v3.tar.gz",
arch: buildmeta.ArchAMD64v3,
},
{
name: "amd64v4",
input: "tool-linux-amd64v4.tar.gz",
arch: buildmeta.ArchAMD64v4,
},
{
name: "amd64v3 not baseline",
input: "tool-1.0.0-linux-amd64v3.tar.gz",
wantOS: buildmeta.OSLinux,
arch: buildmeta.ArchAMD64v3,
format: buildmeta.FormatTarGz,
},
// ARM variants: arm64 must not match armv7/armv6
{
name: "aarch64 not armv7",
input: "tool-aarch64-linux.tar.gz",
arch: buildmeta.ArchARM64,
},
{
name: "armv7",
input: "tool-armv7l-linux.tar.gz",
arch: buildmeta.ArchARMv7,
},
{
name: "armv6",
input: "tool-armv6l-linux.tar.gz",
arch: buildmeta.ArchARMv6,
},
// ppc64le before ppc64
{
name: "ppc64le",
input: "tool-linux-ppc64le.tar.gz",
arch: buildmeta.ArchPPC64LE,
},
{
name: "ppc64",
input: "tool-linux-ppc64.tar.gz",
arch: buildmeta.ArchPPC64,
},
// Static linking
{
name: "static binary",
input: "tool-linux-amd64-static.tar.gz",
libc: buildmeta.LibcNone,
},
// .exe implies Windows
{
name: "exe implies windows",
input: "tool-amd64.exe",
wantOS: buildmeta.OSWindows,
arch: buildmeta.ArchAMD64,
format: buildmeta.FormatExe,
},
// Compound extensions
{
name: "tar.zst",
input: "tool-linux-amd64.tar.zst",
format: buildmeta.FormatTarZst,
},
{
name: "exe.xz",
input: "tool-windows-amd64.exe.xz",
format: buildmeta.FormatExeXz,
},
{
name: "app.zip",
input: "MyApp-1.0.0.app.zip",
format: buildmeta.FormatAppZip,
},
{
name: "tgz alias",
input: "tool-linux-amd64.tgz",
format: buildmeta.FormatTarGz,
},
// s390x, mips
{
name: "s390x",
input: "tool-linux-s390x.tar.gz",
arch: buildmeta.ArchS390X,
},
{
name: "mips64",
input: "tool-linux-mips64.tar.gz",
arch: buildmeta.ArchMIPS64,
},
// Unknown / no match
{
name: "checksum file",
input: "checksums.txt",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := classify.Filename(tt.input)
if tt.wantOS != "" && got.OS != tt.wantOS {
t.Errorf("OS = %q, want %q", got.OS, tt.wantOS)
}
if tt.arch != "" && got.Arch != tt.arch {
t.Errorf("Arch = %q, want %q", got.Arch, tt.arch)
}
if tt.libc != "" && got.Libc != tt.libc {
t.Errorf("Libc = %q, want %q", got.Libc, tt.libc)
}
if tt.format != "" && got.Format != tt.format {
t.Errorf("Format = %q, want %q", got.Format, tt.format)
}
})
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,154 @@
// Package httpclient provides a well-configured [http.Client] for upstream
// API calls. It exists because [http.DefaultClient] has no timeouts, no TLS
// minimum, and follows redirects from HTTPS to HTTP — none of which are
// acceptable for a server calling GitHub, Gitea, etc. on behalf of users.
//
// Use [New] to create a configured client. Use [Do] to execute a request
// with automatic retries for transient failures.
package httpclient
import (
"context"
"crypto/tls"
"errors"
"fmt"
"math/rand/v2"
"net"
"net/http"
"strconv"
"time"
)
const userAgent = "Webi/2.0 (+https://webinstall.dev)"
// New returns an [http.Client] with secure, production-ready defaults:
// TLS 1.2+, timeouts at every level, connection pooling, no HTTPS→HTTP
// redirect, and a Webi User-Agent.
func New() *http.Client {
return &http.Client{
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
},
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 30 * time.Second,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
ForceAttemptHTTP2: true,
},
Timeout: 60 * time.Second,
CheckRedirect: checkRedirect,
}
}
// checkRedirect prevents HTTPS→HTTP downgrades and limits redirect depth.
func checkRedirect(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("stopped after %d redirects", len(via))
}
if len(via) > 0 && via[0].URL.Scheme == "https" && req.URL.Scheme == "http" {
return errors.New("refused redirect from https to http")
}
return nil
}
// Get performs a GET request with the Webi User-Agent header.
func Get(ctx context.Context, client *http.Client, url string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", userAgent)
return client.Do(req)
}
// Do executes a request with automatic retries for transient errors (429,
// 502, 503, 504). Retries up to 3 times with exponential backoff and jitter.
// Respects Retry-After headers. Only retries GET and HEAD (idempotent).
//
// Sets the Webi User-Agent header if not already present.
func Do(ctx context.Context, client *http.Client, req *http.Request) (*http.Response, error) {
if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", userAgent)
}
// Only retry idempotent methods.
idempotent := req.Method == http.MethodGet || req.Method == http.MethodHead
const maxRetries = 3
var resp *http.Response
var err error
for attempt := range maxRetries + 1 {
if attempt > 0 {
if !idempotent {
break
}
delay := backoff(attempt, resp)
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
timer.Stop()
return nil, ctx.Err()
case <-timer.C:
}
if resp != nil {
resp.Body.Close()
}
}
resp, err = client.Do(req)
if err != nil {
if ctx.Err() != nil {
return nil, ctx.Err()
}
continue
}
if !isRetryable(resp.StatusCode) {
return resp, nil
}
}
if err != nil {
return nil, fmt.Errorf("after %d retries: %w", maxRetries, err)
}
return resp, nil
}
func isRetryable(status int) bool {
return status == http.StatusTooManyRequests ||
status == http.StatusBadGateway ||
status == http.StatusServiceUnavailable ||
status == http.StatusGatewayTimeout
}
// backoff returns a delay before the next retry. Respects Retry-After,
// otherwise uses exponential backoff with jitter.
func backoff(attempt int, resp *http.Response) time.Duration {
if resp != nil {
if ra := resp.Header.Get("Retry-After"); ra != "" {
if seconds, err := strconv.Atoi(ra); err == nil && seconds > 0 && seconds < 300 {
return time.Duration(seconds) * time.Second
}
}
}
// 1s, 2s, 4s base delays
base := time.Second << (attempt - 1)
if base > 30*time.Second {
base = 30 * time.Second
}
// Add jitter: 75% to 125% of base
jitter := float64(base) * (0.75 + 0.5*rand.Float64())
return time.Duration(jitter)
}

View File

@@ -0,0 +1,286 @@
// Package installerconf reads per-package releases.conf files.
//
// The format is simple key=value, one per line. Blank lines and lines
// starting with # are ignored. Keys and values are trimmed of whitespace.
// Multi-value keys are whitespace-delimited.
//
// The source type is inferred from the primary key:
//
// GitHub binary releases:
//
// github_releases = sharkdp/bat
// github_releases = https://github.com/sharkdp/bat
//
// GitHub source archives (for source-installable packages):
//
// github_sources = BeyondCodeBootcamp/aliasman
// git_url = https://github.com/BeyondCodeBootcamp/aliasman.git
//
// Gitea binary releases (self-hosted, requires full URL or base_url):
//
// gitea_releases = https://git.rootprojects.org/root/pathman
//
// GitLab binary releases (defaults to gitlab.com):
//
// gitlab_releases = owner/repo
// gitlab_releases = https://gitlab.example.com/owner/repo
//
// Git tag enumeration (vim plugins, etc.):
//
// git_url = https://github.com/tpope/vim-commentary.git
//
// HashiCorp releases:
//
// hashicorp_product = terraform
//
// Other sources (one-off scrapers):
//
// source = nodedist
// url = https://nodejs.org/download/release
//
// Complex packages that need custom logic beyond what the classifier
// auto-detects (e.g. ollama's universal binaries, ffmpeg's non-standard
// naming) should put that logic in Go code, not in the config.
// The variants key documents known build variants for human readers;
// actual variant detection logic lives in Go.
package installerconf
import (
"bufio"
"fmt"
"net/url"
"os"
"strings"
)
// Conf holds the parsed per-package release configuration.
type Conf struct {
// Source is the fetch source type: "github", "githubsource",
// "gitea", "giteasource", "gitlab", "gitlabsource",
// "gittag", "nodedist", etc.
Source string
// Owner is the repository owner (org or user).
Owner string
// Repo is the repository name.
Repo string
// BaseURL is a custom base URL for non-GitHub sources
// (e.g. a Gitea instance or nodedist index URL).
BaseURL string
// GitURL is the git clone URL for source-installable packages.
// Present alongside github_sources/gitea_sources to provide a
// git clone fallback in addition to release tarballs.
GitURL string
// TagPrefix filters releases in monorepos. Only tags starting with
// this prefix are included, and the prefix is stripped from the
// version string. Example: "tools/monorel/"
TagPrefix string
// VersionPrefixes are stripped from version/tag strings.
// Whitespace-delimited. Each release tag is checked against these
// in order; the first match is stripped. Projects may change tag
// conventions across versions (e.g. "jq-1.7.1" older, "1.8.0" later).
VersionPrefixes []string
// Exclude lists filename substrings to filter out.
// Whitespace-delimited. Assets whose name contains any of these
// are skipped entirely (not stored).
Exclude []string
// AssetFilter is a substring that asset filenames must contain.
// Used when multiple packages share a GitHub release (e.g.
// kubectx/kubens) to select only the relevant assets.
AssetFilter string
// Variants documents known build variant names for this package.
// Whitespace-delimited. This is a human-readable cue — actual
// variant detection logic lives in Go code per-package.
Variants []string
// OS restricts all assets to this OS value when set.
// Use "posix_2017" for POSIX-only shell packages that don't
// support Windows.
OS string
// AliasOf names another package that this one mirrors.
// When set, the package has no releases of its own — it shares
// the cache output of the named target (e.g. dashd → dashcore).
AliasOf string
// Extra holds any unrecognized keys for forward compatibility.
Extra map[string]string
}
// parseRepoRef parses a value that is either "owner/repo" or a full URL
// like "https://github.com/owner/repo". Returns baseURL, owner, repo.
// For short form, baseURL is empty (caller uses the default for the forge).
// For full URL form, baseURL is the scheme+host (e.g. "https://github.com").
func parseRepoRef(val, defaultBase string) (baseURL, owner, repo string) {
if strings.Contains(val, "://") {
u, err := url.Parse(val)
if err == nil {
baseURL = u.Scheme + "://" + u.Host
path := strings.Trim(u.Path, "/")
owner, repo, _ = strings.Cut(path, "/")
return baseURL, owner, repo
}
}
// Short form: "owner/repo"
owner, repo, _ = strings.Cut(val, "/")
return defaultBase, owner, repo
}
// Read parses a releases.conf file.
func Read(path string) (*Conf, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("installerconf: %w", err)
}
defer f.Close()
raw := make(map[string]string)
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || line[0] == '#' {
continue
}
key, val, ok := strings.Cut(line, "=")
if !ok {
continue
}
raw[strings.TrimSpace(key)] = strings.TrimSpace(val)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("installerconf: read %s: %w", path, err)
}
c := &Conf{}
// Infer source from primary key, falling back to explicit "source".
// When both github_releases and source are set, parse the repo ref
// from github_releases but use the explicit source for classification.
switch {
// GitHub binary releases.
case raw["github_releases"] != "":
c.Source = "github"
c.BaseURL, c.Owner, c.Repo = parseRepoRef(raw["github_releases"], "https://github.com")
// GitHub source tarballs.
case raw["github_sources"] != "":
c.Source = "githubsource"
c.BaseURL, c.Owner, c.Repo = parseRepoRef(raw["github_sources"], "https://github.com")
// Gitea binary releases (self-hosted only — requires full URL or base_url).
case raw["gitea_releases"] != "":
c.Source = "gitea"
c.BaseURL, c.Owner, c.Repo = parseRepoRef(raw["gitea_releases"], raw["base_url"])
// Gitea source tarballs (self-hosted only).
case raw["gitea_sources"] != "":
c.Source = "giteasource"
c.BaseURL, c.Owner, c.Repo = parseRepoRef(raw["gitea_sources"], raw["base_url"])
// GitLab binary releases (defaults to gitlab.com).
case raw["gitlab_releases"] != "":
c.Source = "gitlab"
c.BaseURL, c.Owner, c.Repo = parseRepoRef(raw["gitlab_releases"], "https://gitlab.com")
// GitLab source tarballs (defaults to gitlab.com).
case raw["gitlab_sources"] != "":
c.Source = "gitlabsource"
c.BaseURL, c.Owner, c.Repo = parseRepoRef(raw["gitlab_sources"], "https://gitlab.com")
// Explicit source type (servicemandist, nodedist, zigdist, etc.).
// Must come before git_url so that "source = X" + "git_url = ..."
// uses X as the primary source, not gittag.
case raw["source"] != "":
c.Source = raw["source"]
c.BaseURL = raw["url"]
// Git tag enumeration (only when no explicit source is set).
case raw["git_url"] != "":
c.Source = "gittag"
c.BaseURL = raw["git_url"]
// HashiCorp.
case raw["hashicorp_product"] != "":
c.Source = "hashicorp"
c.Repo = raw["hashicorp_product"]
default:
}
// Explicit "source" overrides the inferred source when both are present.
// This lets packages like ffmpeg use github_releases for fetching but
// a custom classifier for classification.
if raw["source"] != "" && c.Source != "" {
c.Source = raw["source"]
}
// git_url can appear alongside any source type (e.g. github_sources)
// to provide a git clone fallback. When it's the only key, it's the
// primary source (gittag).
c.GitURL = raw["git_url"]
c.TagPrefix = raw["tag_prefix"]
if v := raw["version_prefixes"]; v != "" {
c.VersionPrefixes = strings.Fields(v)
} else if v := raw["version_prefix"]; v != "" {
c.VersionPrefixes = strings.Fields(v)
}
// Accept both "exclude" and "asset_exclude" (back-compat).
if v := raw["exclude"]; v != "" {
c.Exclude = strings.Fields(v)
} else if v := raw["asset_exclude"]; v != "" {
c.Exclude = strings.Fields(v)
}
c.AssetFilter = raw["asset_filter"]
c.OS = raw["os"]
c.AliasOf = raw["alias_of"]
if v := raw["variants"]; v != "" {
c.Variants = strings.Fields(v)
}
// Collect unrecognized keys.
known := map[string]bool{
"source": true,
"github_releases": true,
"github_sources": true,
"gitea_releases": true,
"gitea_sources": true,
"gitlab_releases": true,
"gitlab_sources": true,
"git_url": true,
"hashicorp_product": true,
"base_url": true,
"url": true,
"tag_prefix": true,
"version_prefix": true,
"version_prefixes": true,
"exclude": true,
"asset_exclude": true,
"asset_filter": true,
"os": true,
"variants": true,
"alias_of": true,
}
for k, v := range raw {
if !known[k] {
if c.Extra == nil {
c.Extra = make(map[string]string)
}
c.Extra[k] = v
}
}
return c, nil
}

View File

@@ -0,0 +1,217 @@
package installerconf_test
import (
"os"
"path/filepath"
"testing"
"github.com/webinstall/webi-installers/internal/installerconf"
)
func TestGitHubReleases(t *testing.T) {
c := confFromString(t, `
github_releases = sharkdp/bat
`)
assertEqual(t, "Source", c.Source, "github")
assertEqual(t, "Owner", c.Owner, "sharkdp")
assertEqual(t, "Repo", c.Repo, "bat")
assertEqual(t, "BaseURL", c.BaseURL, "https://github.com")
assertEqual(t, "TagPrefix", c.TagPrefix, "")
if len(c.VersionPrefixes) != 0 {
t.Errorf("VersionPrefixes = %v, want empty", c.VersionPrefixes)
}
if len(c.Exclude) != 0 {
t.Errorf("Exclude = %v, want empty", c.Exclude)
}
}
func TestGitHubReleasesFullURL(t *testing.T) {
c := confFromString(t, `
github_releases = https://github.com/sharkdp/bat
`)
assertEqual(t, "Source", c.Source, "github")
assertEqual(t, "BaseURL", c.BaseURL, "https://github.com")
assertEqual(t, "Owner", c.Owner, "sharkdp")
assertEqual(t, "Repo", c.Repo, "bat")
}
func TestGitHubSources(t *testing.T) {
c := confFromString(t, `
github_sources = BeyondCodeBootcamp/aliasman
git_url = https://github.com/BeyondCodeBootcamp/aliasman.git
`)
assertEqual(t, "Source", c.Source, "githubsource")
assertEqual(t, "Owner", c.Owner, "BeyondCodeBootcamp")
assertEqual(t, "Repo", c.Repo, "aliasman")
assertEqual(t, "GitURL", c.GitURL, "https://github.com/BeyondCodeBootcamp/aliasman.git")
}
func TestGitHubSourcesFullURL(t *testing.T) {
c := confFromString(t, `
github_sources = https://github.com/BeyondCodeBootcamp/aliasman
git_url = https://github.com/BeyondCodeBootcamp/aliasman.git
`)
assertEqual(t, "Source", c.Source, "githubsource")
assertEqual(t, "BaseURL", c.BaseURL, "https://github.com")
assertEqual(t, "Owner", c.Owner, "BeyondCodeBootcamp")
assertEqual(t, "Repo", c.Repo, "aliasman")
}
func TestVersionPrefixes(t *testing.T) {
c := confFromString(t, `
github_releases = jqlang/jq
version_prefixes = jq- cli-
`)
if len(c.VersionPrefixes) != 2 {
t.Fatalf("VersionPrefixes has %d items, want 2: %v", len(c.VersionPrefixes), c.VersionPrefixes)
}
assertEqual(t, "VersionPrefixes[0]", c.VersionPrefixes[0], "jq-")
assertEqual(t, "VersionPrefixes[1]", c.VersionPrefixes[1], "cli-")
}
func TestExclude(t *testing.T) {
c := confFromString(t, `
github_releases = gohugoio/hugo
exclude = _extended_ Linux-64bit
`)
if len(c.Exclude) != 2 {
t.Fatalf("Exclude has %d items, want 2: %v", len(c.Exclude), c.Exclude)
}
assertEqual(t, "Exclude[0]", c.Exclude[0], "_extended_")
assertEqual(t, "Exclude[1]", c.Exclude[1], "Linux-64bit")
}
func TestMonorepoTagPrefix(t *testing.T) {
c := confFromString(t, `
github_releases = therootcompany/golib
tag_prefix = tools/monorel/
`)
assertEqual(t, "TagPrefix", c.TagPrefix, "tools/monorel/")
}
func TestNodeDist(t *testing.T) {
c := confFromString(t, `
source = nodedist
url = https://nodejs.org/download/release
`)
assertEqual(t, "Source", c.Source, "nodedist")
assertEqual(t, "BaseURL", c.BaseURL, "https://nodejs.org/download/release")
}
func TestGiteaReleases(t *testing.T) {
c := confFromString(t, `
gitea_releases = https://git.rootprojects.org/root/pathman
`)
assertEqual(t, "Source", c.Source, "gitea")
assertEqual(t, "BaseURL", c.BaseURL, "https://git.rootprojects.org")
assertEqual(t, "Owner", c.Owner, "root")
assertEqual(t, "Repo", c.Repo, "pathman")
}
func TestGiteaReleasesWithBaseURL(t *testing.T) {
c := confFromString(t, `
gitea_releases = root/pathman
base_url = https://git.rootprojects.org
`)
assertEqual(t, "Source", c.Source, "gitea")
assertEqual(t, "BaseURL", c.BaseURL, "https://git.rootprojects.org")
assertEqual(t, "Owner", c.Owner, "root")
assertEqual(t, "Repo", c.Repo, "pathman")
}
func TestGitLabReleases(t *testing.T) {
c := confFromString(t, `
gitlab_releases = owner/repo
`)
assertEqual(t, "Source", c.Source, "gitlab")
assertEqual(t, "BaseURL", c.BaseURL, "https://gitlab.com")
assertEqual(t, "Owner", c.Owner, "owner")
assertEqual(t, "Repo", c.Repo, "repo")
}
func TestGitLabReleasesFullURL(t *testing.T) {
c := confFromString(t, `
gitlab_releases = https://gitlab.example.com/myorg/myrepo
`)
assertEqual(t, "Source", c.Source, "gitlab")
assertEqual(t, "BaseURL", c.BaseURL, "https://gitlab.example.com")
assertEqual(t, "Owner", c.Owner, "myorg")
assertEqual(t, "Repo", c.Repo, "myrepo")
}
func TestBlanksAndComments(t *testing.T) {
c := confFromString(t, `
# Hugo config
github_releases = foo/bar
# exclude line
exclude = extended
`)
assertEqual(t, "Source", c.Source, "github")
assertEqual(t, "Owner", c.Owner, "foo")
assertEqual(t, "Repo", c.Repo, "bar")
}
func TestExtraKeys(t *testing.T) {
c := confFromString(t, `
github_releases = foo/bar
custom_thing = hello
`)
if c.Extra == nil || c.Extra["custom_thing"] != "hello" {
t.Errorf("Extra[custom_thing] = %q, want hello", c.Extra["custom_thing"])
}
}
func TestAssetExcludeAlias(t *testing.T) {
c := confFromString(t, `
github_releases = gohugoio/hugo
asset_exclude = extended
`)
if len(c.Exclude) != 1 {
t.Fatalf("Exclude has %d items, want 1: %v", len(c.Exclude), c.Exclude)
}
assertEqual(t, "Exclude[0]", c.Exclude[0], "extended")
}
func TestVariants(t *testing.T) {
c := confFromString(t, `
github_releases = jmorganca/ollama
variants = rocm jetpack5 jetpack6
`)
if len(c.Variants) != 3 {
t.Fatalf("Variants has %d items, want 3: %v", len(c.Variants), c.Variants)
}
assertEqual(t, "Variants[0]", c.Variants[0], "rocm")
assertEqual(t, "Variants[1]", c.Variants[1], "jetpack5")
assertEqual(t, "Variants[2]", c.Variants[2], "jetpack6")
}
func TestEmptyExclude(t *testing.T) {
c := confFromString(t, "github_releases = foo/bar\n")
if c.Exclude != nil {
t.Errorf("Exclude = %v, want nil", c.Exclude)
}
}
// helpers
func confFromString(t *testing.T, content string) *installerconf.Conf {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "releases.conf")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
c, err := installerconf.Read(path)
if err != nil {
t.Fatal(err)
}
return c
}
func assertEqual(t *testing.T, name, got, want string) {
t.Helper()
if got != want {
t.Errorf("%s = %q, want %q", name, got, want)
}
}

189
internal/lexver/lexver.go Normal file
View File

@@ -0,0 +1,189 @@
// Package lexver makes version strings comparable and sortable.
//
// Not all version strings are semver. Webi handles 4-part versions
// (chromedriver 121.0.6120.0), date-based versions (atomicparsley),
// and pre-releases with extra dots (flutter 2.3.0-16.0.pre). Lexver
// parses these into a struct with an arbitrary-depth numeric segment
// list and provides a comparison function for use with [slices.SortFunc].
//
// Pre-releases sort before their corresponding stable release:
//
// 1.0.0-alpha1 < 1.0.0-beta1 < 1.0.0-rc1 < 1.0.0
//
// When release dates are known, they break ties between versions with
// identical numeric segments.
package lexver
import (
"cmp"
"strconv"
"strings"
"time"
"unicode"
)
// Version is a parsed version with comparable fields.
type Version struct {
// Nums holds the dotted numeric segments in order.
// "1.20.3" → [1, 20, 3], "121.0.6120.0" → [121, 0, 6120, 0].
Nums []int
Channel string // "" for stable, or "alpha", "beta", "dev", "pre", "preview", "rc"
ChannelNum int // e.g. 2 in "rc2"
Date time.Time // release date/time, if known; breaks ties between same-numbered versions
Original string // version string exactly as the releaser published it (e.g. "REL_17_0", "r21")
Raw string // version string after Webi's normalization (e.g. "17.0", "0.21.0")
// ExtraSort is an optional opaque string for package-specific ordering.
// Set by release-fetcher code for packages where Nums alone can't capture
// the sort order (e.g. flutter's "2.3.0-16.0.pre"). Compared as a plain
// string, only consulted when Nums and Channel are equal.
ExtraSort string
}
// Parse breaks a version string into its components.
// Both Original and Raw are set to s; callers that normalize versions
// (e.g. "REL_17_0" → "17.0") should set Original to the upstream tag
// and pass the normalized string to Parse.
func Parse(s string) Version {
v := Version{Original: s, Raw: s}
s = strings.TrimLeft(s, "vV")
numStr, prerelease := splitAtPrerelease(s)
v.Nums = splitNums(numStr)
if prerelease != "" {
v.Channel, v.ChannelNum = splitChannel(prerelease)
}
return v
}
// Major returns the first numeric segment, or 0 if none.
func (v Version) Major() int { return v.num(0) }
// Minor returns the second numeric segment, or 0 if none.
func (v Version) Minor() int { return v.num(1) }
// Patch returns the third numeric segment, or 0 if none.
func (v Version) Patch() int { return v.num(2) }
func (v Version) num(i int) int {
if i < len(v.Nums) {
return v.Nums[i]
}
return 0
}
// IsStable reports whether this is a stable (non-pre-release) version.
func (v Version) IsStable() bool {
return v.Channel == ""
}
// Compare returns -1, 0, or 1 for ordering two versions.
// Stable releases sort after pre-releases of the same numeric version.
func Compare(a, b Version) int {
// Compare numeric segments pairwise, treating missing segments as 0.
n := max(len(a.Nums), len(b.Nums))
for i := range n {
an, bn := a.num(i), b.num(i)
if c := cmp.Compare(an, bn); c != 0 {
return c
}
}
// Break ties with release date when both are known.
if !a.Date.IsZero() && !b.Date.IsZero() {
if c := a.Date.Compare(b.Date); c != 0 {
return c
}
}
// ExtraSort: package-specific tiebreaker set by release-fetcher code.
if a.ExtraSort != "" && b.ExtraSort != "" {
if c := cmp.Compare(a.ExtraSort, b.ExtraSort); c != 0 {
return c
}
}
// Both stable → equal.
if a.Channel == "" && b.Channel == "" {
return 0
}
// Stable beats any pre-release.
if a.Channel == "" {
return 1
}
if b.Channel == "" {
return -1
}
// Both pre-release: alphabetical channel, then number.
if c := cmp.Compare(a.Channel, b.Channel); c != 0 {
return c
}
return cmp.Compare(a.ChannelNum, b.ChannelNum)
}
// HasPrefix reports whether v matches a partial version prefix.
// A prefix with Nums [1, 20] matches any version starting with 1.20
// (e.g. 1.20.0, 1.20.3, 1.20.3.1).
func (v Version) HasPrefix(prefix Version) bool {
for i, pn := range prefix.Nums {
if i >= len(v.Nums) || v.Nums[i] != pn {
return false
}
}
return true
}
// splitAtPrerelease splits "1.20.3-beta1" into ("1.20.3", "beta1").
// Also handles "1.2beta3" (no separator).
func splitAtPrerelease(s string) (string, string) {
for _, sep := range []byte{'-', '+'} {
if idx := strings.IndexByte(s, sep); idx >= 0 {
return s[:idx], s[idx+1:]
}
}
// "1.2beta3": letter following a digit
for i := 1; i < len(s); i++ {
if unicode.IsLetter(rune(s[i])) && unicode.IsDigit(rune(s[i-1])) {
return s[:i], s[i:]
}
}
return s, ""
}
// splitNums parses "1.20.3" into [1, 20, 3].
// Handles any number of dot-separated segments.
func splitNums(s string) []int {
var nums []int
for _, seg := range strings.Split(s, ".") {
n, err := strconv.Atoi(seg)
if err != nil {
break
}
nums = append(nums, n)
}
return nums
}
// splitChannel separates "beta1" into ("beta", 1) or "rc" into ("rc", 0).
func splitChannel(s string) (string, int) {
s = strings.ToLower(s)
s = strings.NewReplacer("-", "", ".", "", "_", "").Replace(s)
i := len(s)
for i > 0 && unicode.IsDigit(rune(s[i-1])) {
i--
}
name := s[:i]
num := 0
if i < len(s) {
num, _ = strconv.Atoi(s[i:])
}
return name, num
}

View File

@@ -0,0 +1,270 @@
package lexver_test
import (
"slices"
"testing"
"time"
"github.com/webinstall/webi-installers/internal/lexver"
)
func TestParse(t *testing.T) {
tests := []struct {
input string
nums []int
channel string
chanNum int
}{
// Standard semver
{"1.0.0", []int{1, 0, 0}, "", 0},
{"v1.2.3", []int{1, 2, 3}, "", 0},
{"1.20.156", []int{1, 20, 156}, "", 0},
// Partial
{"1.20", []int{1, 20}, "", 0},
{"1", []int{1}, "", 0},
// 4-part (chromedriver, gpg)
{"121.0.6120.0", []int{121, 0, 6120, 0}, "", 0},
{"2.2.19.0", []int{2, 2, 19, 0}, "", 0},
// Pre-release
{"1.0.0-beta1", []int{1, 0, 0}, "beta", 1},
{"1.0.0-rc2", []int{1, 0, 0}, "rc", 2},
{"2.0.0-alpha3", []int{2, 0, 0}, "alpha", 3},
{"1.0.0-dev", []int{1, 0, 0}, "dev", 0},
// No separator before channel
{"1.2beta3", []int{1, 2}, "beta", 3},
{"1.0rc1", []int{1, 0}, "rc", 1},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
v := lexver.Parse(tt.input)
if !slices.Equal(v.Nums, tt.nums) {
t.Errorf("Parse(%q).Nums = %v, want %v", tt.input, v.Nums, tt.nums)
}
if v.Channel != tt.channel || v.ChannelNum != tt.chanNum {
t.Errorf("Parse(%q) channel = %q/%d, want %q/%d",
tt.input, v.Channel, v.ChannelNum, tt.channel, tt.chanNum)
}
})
}
}
func TestAccessors(t *testing.T) {
v := lexver.Parse("121.0.6120.0")
if v.Major() != 121 || v.Minor() != 0 || v.Patch() != 6120 {
t.Errorf("got %d.%d.%d, want 121.0.6120", v.Major(), v.Minor(), v.Patch())
}
short := lexver.Parse("1")
if short.Minor() != 0 || short.Patch() != 0 {
t.Error("missing segments should return 0")
}
}
func TestSortOrder(t *testing.T) {
// Must be in ascending order.
ordered := []string{
"0.1.0",
"1.0.0-alpha1",
"1.0.0-alpha2",
"1.0.0-beta1",
"1.0.0-rc1",
"1.0.0-rc2",
"1.0.0",
"1.0.1",
"1.1.0",
"1.2.0",
"1.20.0",
"2.0.0-beta1",
"2.0.0",
}
for i := 1; i < len(ordered); i++ {
a := lexver.Parse(ordered[i-1])
b := lexver.Parse(ordered[i])
if lexver.Compare(a, b) >= 0 {
t.Errorf("expected %q < %q", ordered[i-1], ordered[i])
}
}
}
func TestSortOrder4Part(t *testing.T) {
ordered := []string{
"121.0.6120.0",
"121.0.6120.1",
"121.0.6121.0",
"122.0.6100.0",
}
for i := 1; i < len(ordered); i++ {
a := lexver.Parse(ordered[i-1])
b := lexver.Parse(ordered[i])
if lexver.Compare(a, b) >= 0 {
t.Errorf("expected %q < %q", ordered[i-1], ordered[i])
}
}
}
func TestMismatchedDepth(t *testing.T) {
// "1.0" and "1.0.0" should be equal (trailing zeros).
a := lexver.Parse("1.0")
b := lexver.Parse("1.0.0")
if lexver.Compare(a, b) != 0 {
t.Error("1.0 and 1.0.0 should be equal")
}
// "1.0.0.1" should be greater than "1.0.0".
c := lexver.Parse("1.0.0.1")
d := lexver.Parse("1.0.0")
if lexver.Compare(c, d) <= 0 {
t.Error("1.0.0.1 should be greater than 1.0.0")
}
}
func TestSortFunc(t *testing.T) {
versions := []string{"1.0.0", "2.0.0-rc1", "1.20.3", "1.20.2", "1.19.5", "2.0.0"}
parsed := make([]lexver.Version, len(versions))
for i, s := range versions {
parsed[i] = lexver.Parse(s)
}
// Sort descending (newest first).
slices.SortFunc(parsed, func(a, b lexver.Version) int {
return lexver.Compare(b, a)
})
want := []string{"2.0.0", "2.0.0-rc1", "1.20.3", "1.20.2", "1.19.5", "1.0.0"}
for i, v := range parsed {
if v.Raw != want[i] {
t.Errorf("index %d: got %q, want %q", i, v.Raw, want[i])
}
}
}
func TestIsStable(t *testing.T) {
tests := []struct {
input string
want bool
}{
{"1.0.0", true},
{"121.0.6120.0", true},
{"1.0.0-beta1", false},
{"v2.0.0-dev", false},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
v := lexver.Parse(tt.input)
if v.IsStable() != tt.want {
t.Errorf("Parse(%q).IsStable() = %v, want %v", tt.input, v.IsStable(), tt.want)
}
})
}
}
func TestDateTiebreaker(t *testing.T) {
a := lexver.Parse("1.0.0")
a.Date = time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC)
b := lexver.Parse("1.0.0")
b.Date = time.Date(2024, 6, 1, 14, 30, 0, 0, time.UTC)
if lexver.Compare(a, b) >= 0 {
t.Error("earlier date should sort before later date at same version")
}
// Without dates, same version is equal.
c := lexver.Parse("1.0.0")
d := lexver.Parse("1.0.0")
if lexver.Compare(c, d) != 0 {
t.Error("same version without dates should be equal")
}
// Date only matters when both have it.
e := lexver.Parse("1.0.0")
e.Date = time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC)
f := lexver.Parse("1.0.0")
if lexver.Compare(e, f) != 0 {
t.Error("date should be ignored when only one side has it")
}
}
func TestDateMinutePrecision(t *testing.T) {
a := lexver.Parse("1.0.0")
a.Date = time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
b := lexver.Parse("1.0.0")
b.Date = time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC)
if lexver.Compare(a, b) >= 0 {
t.Error("same date, later time should sort after")
}
}
func TestOriginal(t *testing.T) {
// Parse sets both Original and Raw to the input.
v := lexver.Parse("17.0")
if v.Original != "17.0" {
t.Errorf("Original = %q, want %q", v.Original, "17.0")
}
// Release fetcher would do:
// v := lexver.Parse("17.0")
// v.Original = "REL_17_0"
v.Original = "REL_17_0"
if v.Raw != "17.0" {
t.Errorf("Raw should remain %q after setting Original, got %q", "17.0", v.Raw)
}
}
func TestExtraSort(t *testing.T) {
// Flutter example: 2.3.0-16.0.pre and 2.3.0-16.1.pre
// Nums and Channel are the same; ExtraSort distinguishes them.
a := lexver.Parse("2.3.0-pre")
a.ExtraSort = "0016.0000"
b := lexver.Parse("2.3.0-pre")
b.ExtraSort = "0016.0001"
if lexver.Compare(a, b) >= 0 {
t.Error("ExtraSort 0016.0000 should sort before 0016.0001")
}
// ExtraSort ignored when only one side has it.
c := lexver.Parse("2.3.0-pre")
c.ExtraSort = "0016.0000"
d := lexver.Parse("2.3.0-pre")
if lexver.Compare(c, d) != 0 {
t.Error("ExtraSort should be ignored when only one side has it")
}
}
func TestHasPrefix(t *testing.T) {
v := lexver.Parse("1.20.3")
if !v.HasPrefix(lexver.Parse("1.20")) {
t.Error("1.20.3 should match prefix 1.20")
}
if !v.HasPrefix(lexver.Parse("1")) {
t.Error("1.20.3 should match prefix 1")
}
if v.HasPrefix(lexver.Parse("1.19")) {
t.Error("1.20.3 should not match prefix 1.19")
}
if v.HasPrefix(lexver.Parse("2")) {
t.Error("1.20.3 should not match prefix 2")
}
// 4-part prefix matching
v4 := lexver.Parse("121.0.6120.0")
if !v4.HasPrefix(lexver.Parse("121.0.6120")) {
t.Error("121.0.6120.0 should match prefix 121.0.6120")
}
if !v4.HasPrefix(lexver.Parse("121.0")) {
t.Error("121.0.6120.0 should match prefix 121.0")
}
}

View File

@@ -0,0 +1,63 @@
package rawcache
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
)
// LogEntry records one event in the append-only audit log.
type LogEntry struct {
Time time.Time `json:"time"`
Tag string `json:"tag"`
Action string `json:"action"` // "added", "changed", "removed"
SHA256 string `json:"sha256,omitempty"`
}
// AuditLog is an append-only JSONL file that tracks when releases appear,
// change, or disappear from upstream. One file per package, lives alongside
// the double-buffer slots.
type AuditLog struct {
path string
}
// openLog returns the audit log for a Dir.
func (d *Dir) openLog() *AuditLog {
return &AuditLog{path: filepath.Join(d.root, "audit.jsonl")}
}
// Append writes one log entry.
func (l *AuditLog) Append(entry LogEntry) error {
if entry.Time.IsZero() {
entry.Time = time.Now().UTC()
}
data, err := json.Marshal(entry)
if err != nil {
return fmt.Errorf("rawcache: marshal log entry: %w", err)
}
data = append(data, '\n')
f, err := os.OpenFile(l.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return fmt.Errorf("rawcache: open audit log: %w", err)
}
_, writeErr := f.Write(data)
closeErr := f.Close()
if writeErr != nil {
return fmt.Errorf("rawcache: write audit log: %w", writeErr)
}
if closeErr != nil {
return fmt.Errorf("rawcache: close audit log: %w", closeErr)
}
return nil
}
// ContentHash returns the SHA-256 hex digest of data.
func ContentHash(data []byte) string {
h := sha256.Sum256(data)
return hex.EncodeToString(h[:])
}

View File

@@ -0,0 +1,265 @@
// Package rawcache stores raw upstream API responses on disk, one file per
// release, with double-buffered full refreshes.
//
// Directory layout:
//
// {root}/
// active → a symlink to the current slot
// a/ slot A
// _latest one-line file: newest tag
// v0.145.0.json
// v0.144.1.json
// ...
// b/ slot B (standby)
//
// Incremental updates write directly to the active slot. Each file write
// is atomic (temp file + rename). Full refreshes write to the standby slot,
// then atomically swap the symlink.
package rawcache
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
// Dir manages a raw release cache for one package.
type Dir struct {
root string // e.g. "_cache/raw/github/gohugoio/hugo"
}
// Open returns a Dir for the given root path. Creates the directory
// structure (slots + symlink) if it doesn't exist.
func Open(root string) (*Dir, error) {
d := &Dir{root: root}
slotA := filepath.Join(root, "a")
slotB := filepath.Join(root, "b")
active := filepath.Join(root, "active")
// Create both slots.
for _, slot := range []string{slotA, slotB} {
if err := os.MkdirAll(slot, 0o755); err != nil {
return nil, fmt.Errorf("rawcache: create slot: %w", err)
}
}
// Create the active symlink if it doesn't exist.
if _, err := os.Lstat(active); errors.Is(err, os.ErrNotExist) {
if err := os.Symlink("a", active); err != nil {
return nil, fmt.Errorf("rawcache: create active symlink: %w", err)
}
}
return d, nil
}
// ActivePath returns the absolute path of the currently active slot.
func (d *Dir) ActivePath() (string, error) {
target, err := os.Readlink(filepath.Join(d.root, "active"))
if err != nil {
return "", fmt.Errorf("rawcache: read active symlink: %w", err)
}
return filepath.Join(d.root, target), nil
}
// standbySlot returns the name of the inactive slot ("a" or "b").
func (d *Dir) standbySlot() (string, error) {
target, err := os.Readlink(filepath.Join(d.root, "active"))
if err != nil {
return "", fmt.Errorf("rawcache: read active symlink: %w", err)
}
if target == "a" {
return "b", nil
}
return "a", nil
}
// Populated returns true if the active slot contains at least one release file.
func (d *Dir) Populated() bool {
active, err := d.ActivePath()
if err != nil {
return false
}
entries, err := os.ReadDir(active)
if err != nil {
return false
}
for _, e := range entries {
if !e.IsDir() && !strings.HasPrefix(e.Name(), "_") {
return true
}
}
return false
}
// Has reports whether a release file exists in the active slot.
func (d *Dir) Has(tag string) bool {
active, err := d.ActivePath()
if err != nil {
return false
}
_, err = os.Stat(filepath.Join(active, tagToFilename(tag)))
return err == nil
}
// Latest returns the newest tag from the active slot.
// Returns "" if no latest marker exists.
func (d *Dir) Latest() string {
active, err := d.ActivePath()
if err != nil {
return ""
}
data, err := os.ReadFile(filepath.Join(active, "_latest"))
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
// Read returns the raw cached data for a tag from the active slot.
func (d *Dir) Read(tag string) ([]byte, error) {
active, err := d.ActivePath()
if err != nil {
return nil, err
}
return os.ReadFile(filepath.Join(active, tagToFilename(tag)))
}
// Put writes a release file to the active slot. The write is atomic
// (temp file + rename).
func (d *Dir) Put(tag string, data []byte) error {
active, err := d.ActivePath()
if err != nil {
return err
}
return atomicWrite(filepath.Join(active, tagToFilename(tag)), data)
}
// Merge writes a release to the active slot if it's new or changed.
// Returns the action taken: "added", "changed", or "" (unchanged).
// Logs the event to the audit log when something happens.
func (d *Dir) Merge(tag string, data []byte) (string, error) {
log := d.openLog()
hash := ContentHash(data)
if d.Has(tag) {
existing, err := d.Read(tag)
if err != nil {
return "", err
}
if ContentHash(existing) == hash {
return "", nil // unchanged
}
if err := d.Put(tag, data); err != nil {
return "", err
}
log.Append(LogEntry{Tag: tag, Action: "changed", SHA256: hash})
return "changed", nil
}
if err := d.Put(tag, data); err != nil {
return "", err
}
log.Append(LogEntry{Tag: tag, Action: "added", SHA256: hash})
return "added", nil
}
// SetLatest updates the _latest marker in the active slot.
func (d *Dir) SetLatest(tag string) error {
active, err := d.ActivePath()
if err != nil {
return err
}
return atomicWrite(filepath.Join(active, "_latest"), []byte(tag+"\n"))
}
// BeginRefresh starts a full refresh. Clears the standby slot and returns
// a Refresh handle for writing to it. Call Commit to atomically swap, or
// Abort to discard.
func (d *Dir) BeginRefresh() (*Refresh, error) {
standby, err := d.standbySlot()
if err != nil {
return nil, err
}
standbyPath := filepath.Join(d.root, standby)
// Clear the standby slot.
entries, _ := os.ReadDir(standbyPath)
for _, e := range entries {
os.Remove(filepath.Join(standbyPath, e.Name()))
}
return &Refresh{
dir: d,
slot: standby,
slotDir: standbyPath,
}, nil
}
// Refresh writes releases to the standby slot during a full refresh.
type Refresh struct {
dir *Dir
slot string // "a" or "b"
slotDir string
}
// Put writes a release file to the standby slot.
func (r *Refresh) Put(tag string, data []byte) error {
return atomicWrite(filepath.Join(r.slotDir, tagToFilename(tag)), data)
}
// SetLatest updates the _latest marker in the standby slot.
func (r *Refresh) SetLatest(tag string) error {
return atomicWrite(filepath.Join(r.slotDir, "_latest"), []byte(tag+"\n"))
}
// Commit atomically swaps the active symlink to point to the standby slot.
func (r *Refresh) Commit() error {
active := filepath.Join(r.dir.root, "active")
tmp := active + ".tmp"
// Remove stale temp symlink if it exists.
os.Remove(tmp)
if err := os.Symlink(r.slot, tmp); err != nil {
return fmt.Errorf("rawcache: create temp symlink: %w", err)
}
if err := os.Rename(tmp, active); err != nil {
os.Remove(tmp)
return fmt.Errorf("rawcache: swap active symlink: %w", err)
}
return nil
}
// Abort discards the standby slot contents.
func (r *Refresh) Abort() {
entries, _ := os.ReadDir(r.slotDir)
for _, e := range entries {
os.Remove(filepath.Join(r.slotDir, e.Name()))
}
}
// tagToFilename converts a tag to a safe filename.
// Tags like "v0.145.0" become "v0.145.0". The raw cache stores opaque
// bytes — no extension is assumed because upstream responses may be
// JSON, CSV, XML, or bespoke formats.
func tagToFilename(tag string) string {
// Replace path separators in case a tag contains slashes.
return strings.ReplaceAll(tag, "/", "_")
}
// atomicWrite writes data to path via a temp file + rename.
func atomicWrite(path string, data []byte) error {
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return fmt.Errorf("rawcache: write %s: %w", tmp, err)
}
if err := os.Rename(tmp, path); err != nil {
os.Remove(tmp)
return fmt.Errorf("rawcache: rename %s: %w", path, err)
}
return nil
}

View File

@@ -0,0 +1,173 @@
package rawcache_test
import (
"os"
"path/filepath"
"testing"
"github.com/webinstall/webi-installers/internal/rawcache"
)
func TestOpenCreatesStructure(t *testing.T) {
root := filepath.Join(t.TempDir(), "pkg")
d, err := rawcache.Open(root)
if err != nil {
t.Fatal(err)
}
_ = d
// Verify structure exists.
for _, name := range []string{"a", "b"} {
info, err := os.Stat(filepath.Join(root, name))
if err != nil {
t.Fatalf("slot %s: %v", name, err)
}
if !info.IsDir() {
t.Fatalf("slot %s is not a directory", name)
}
}
target, err := os.Readlink(filepath.Join(root, "active"))
if err != nil {
t.Fatal(err)
}
if target != "a" {
t.Errorf("active symlink = %q, want %q", target, "a")
}
}
func TestPutAndRead(t *testing.T) {
d, err := rawcache.Open(filepath.Join(t.TempDir(), "pkg"))
if err != nil {
t.Fatal(err)
}
data := []byte(`{"tag_name":"v1.0.0"}`)
if err := d.Put("v1.0.0", data); err != nil {
t.Fatal(err)
}
if !d.Has("v1.0.0") {
t.Error("Has(v1.0.0) = false after Put")
}
if d.Has("v2.0.0") {
t.Error("Has(v2.0.0) = true, should be false")
}
got, err := d.Read("v1.0.0")
if err != nil {
t.Fatal(err)
}
if string(got) != string(data) {
t.Errorf("Read = %q, want %q", got, data)
}
}
func TestLatest(t *testing.T) {
d, err := rawcache.Open(filepath.Join(t.TempDir(), "pkg"))
if err != nil {
t.Fatal(err)
}
if latest := d.Latest(); latest != "" {
t.Errorf("Latest() = %q before any writes, want empty", latest)
}
if err := d.SetLatest("v1.0.0"); err != nil {
t.Fatal(err)
}
if latest := d.Latest(); latest != "v1.0.0" {
t.Errorf("Latest() = %q, want %q", latest, "v1.0.0")
}
}
func TestRefreshDoubleBuffer(t *testing.T) {
root := filepath.Join(t.TempDir(), "pkg")
d, err := rawcache.Open(root)
if err != nil {
t.Fatal(err)
}
// Write to active slot (A).
d.Put("v1.0.0", []byte(`{"old":true}`))
d.SetLatest("v1.0.0")
// Start a full refresh — writes to standby (B).
r, err := d.BeginRefresh()
if err != nil {
t.Fatal(err)
}
r.Put("v1.0.0", []byte(`{"new":true}`))
r.Put("v2.0.0", []byte(`{"tag_name":"v2.0.0"}`))
r.SetLatest("v2.0.0")
// Before commit, active still points to A.
if d.Latest() != "v1.0.0" {
t.Error("latest should still be v1.0.0 before commit")
}
old, _ := d.Read("v1.0.0")
if string(old) != `{"old":true}` {
t.Errorf("active slot should still have old data, got %q", old)
}
// Commit swaps to B.
if err := r.Commit(); err != nil {
t.Fatal(err)
}
if d.Latest() != "v2.0.0" {
t.Errorf("Latest() = %q after commit, want %q", d.Latest(), "v2.0.0")
}
if !d.Has("v2.0.0") {
t.Error("v2.0.0 should exist after commit")
}
updated, _ := d.Read("v1.0.0")
if string(updated) != `{"new":true}` {
t.Errorf("v1.0.0 should be updated after commit, got %q", updated)
}
}
func TestRefreshAbort(t *testing.T) {
root := filepath.Join(t.TempDir(), "pkg")
d, err := rawcache.Open(root)
if err != nil {
t.Fatal(err)
}
d.Put("v1.0.0", []byte(`original`))
d.SetLatest("v1.0.0")
r, err := d.BeginRefresh()
if err != nil {
t.Fatal(err)
}
r.Put("v99.0.0", []byte(`aborted`))
r.Abort()
// Active slot should be unchanged.
if d.Latest() != "v1.0.0" {
t.Error("latest should still be v1.0.0 after abort")
}
if d.Has("v99.0.0") {
t.Error("v99.0.0 should not exist after abort")
}
}
func TestOpenIdempotent(t *testing.T) {
root := filepath.Join(t.TempDir(), "pkg")
d1, err := rawcache.Open(root)
if err != nil {
t.Fatal(err)
}
d1.Put("v1.0.0", []byte(`data`))
// Opening again should not lose data.
d2, err := rawcache.Open(root)
if err != nil {
t.Fatal(err)
}
if !d2.Has("v1.0.0") {
t.Error("data lost after re-open")
}
}

View File

@@ -0,0 +1,50 @@
// Package atomicparsley provides OS/arch classification for AtomicParsley releases.
//
// AtomicParsley uses non-standard filenames with no platform terms
// (e.g. "AtomicParsleyLinux.zip", "AtomicParsleyMacOS.zip"). The generic
// filename classifier can't extract OS or arch from these — this package
// applies the same hardcoded mapping that the production releases.js uses.
package atomicparsleydist
import (
"strings"
"github.com/webinstall/webi-installers/internal/storage"
)
// TagVariants sets OS, arch, and libc for AtomicParsley assets based on
// filename keyword matching. Replicates atomicparsley/releases.js mappings:
// - Alpine → linux/x86_64/musl
// - Linux → linux/x86_64/gnu
// - MacOS → darwin/x86_64
// - WindowsX86 → windows/x86/msvc
// - Windows → windows/x86_64/msvc
func TagVariants(assets []storage.Asset) {
for i := range assets {
if assets[i].OS != "" {
continue // already classified
}
lower := strings.ToLower(assets[i].Filename)
switch {
case strings.Contains(lower, "alpine"):
assets[i].OS = "linux"
assets[i].Arch = "x86_64"
assets[i].Libc = "musl"
case strings.Contains(lower, "linux"):
assets[i].OS = "linux"
assets[i].Arch = "x86_64"
assets[i].Libc = "gnu"
case strings.Contains(lower, "macos"):
assets[i].OS = "darwin"
assets[i].Arch = "x86_64"
case strings.Contains(lower, "windowsx86"):
assets[i].OS = "windows"
assets[i].Arch = "x86"
assets[i].Libc = "msvc"
case strings.Contains(lower, "windows"):
assets[i].OS = "windows"
assets[i].Arch = "x86_64"
assets[i].Libc = "msvc"
}
}
}

View File

@@ -0,0 +1,39 @@
// Package bun provides variant tagging for Bun releases.
//
// Bun publishes -profile (debug) builds and uses a non-standard arch
// convention: the default x86_64 build targets x86_64_v3 (AVX2+),
// while -baseline targets plain x86_64.
package bundist
import (
"strings"
"github.com/webinstall/webi-installers/internal/storage"
)
// TagVariants tags bun-specific build variants and remaps arch fields.
//
// Bun's default x86_64 build requires AVX2 (x86_64_v3). The -baseline
// build targets plain x86_64. For legacy export, baseline is the one
// we serve (matching Node.js behavior), so non-baseline gets a variant
// tag. The -baseline suffix is stripped from Filename (but not Download)
// so the legacy server sees a clean name.
func TagVariants(assets []storage.Asset) {
for i := range assets {
lower := strings.ToLower(assets[i].Filename)
if strings.Contains(lower, "-profile") {
assets[i].Variants = append(assets[i].Variants, "profile")
}
if assets[i].Arch == "x86_64" {
if strings.Contains(lower, "-baseline") {
// Baseline is plain x86_64 — strip the suffix from
// Filename so the legacy server sees a clean name.
assets[i].Filename = strings.Replace(assets[i].Filename, "-baseline", "", 1)
} else {
// Non-baseline is v3 — tag as variant (excluded from legacy).
assets[i].Arch = "x86_64_v3"
assets[i].Variants = append(assets[i].Variants, "v3")
}
}
}
}

View File

@@ -0,0 +1,72 @@
// Package chromedist fetches Chrome for Testing release data.
//
// Google publishes a JSON index of known-good Chrome/ChromeDriver versions at:
//
// https://googlechromelabs.github.io/chrome-for-testing/known-good-versions-with-downloads.json
//
// Each version entry has per-platform download URLs for chrome, chromedriver,
// and chrome-headless-shell.
package chromedist
import (
"context"
"encoding/json"
"fmt"
"iter"
"net/http"
)
// Index is the top-level response.
type Index struct {
Timestamp string `json:"timestamp"`
Versions []Version `json:"versions"`
}
// Version is one Chrome for Testing version with its downloads.
type Version struct {
Version string `json:"version"` // "121.0.6120.0"
Revision string `json:"revision"` // "1222902"
Downloads map[string][]Download `json:"downloads"` // "chromedriver" → []Download
}
// Download is one platform-specific download URL.
type Download struct {
Platform string `json:"platform"` // "linux64", "mac-arm64", "mac-x64", "win32", "win64"
URL string `json:"url"`
}
// Fetch retrieves the Chrome for Testing release index.
//
// Yields one batch containing all versions.
func Fetch(ctx context.Context, client *http.Client) iter.Seq2[[]Version, error] {
return func(yield func([]Version, error) bool) {
url := "https://googlechromelabs.github.io/chrome-for-testing/known-good-versions-with-downloads.json"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
yield(nil, fmt.Errorf("chromedist: %w", err))
return
}
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
yield(nil, fmt.Errorf("chromedist: fetch: %w", err))
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
yield(nil, fmt.Errorf("chromedist: fetch: %s", resp.Status))
return
}
var idx Index
if err := json.NewDecoder(resp.Body).Decode(&idx); err != nil {
yield(nil, fmt.Errorf("chromedist: decode: %w", err))
return
}
yield(idx.Versions, nil)
}
}

View File

@@ -0,0 +1,60 @@
package cmakedist
import (
"strings"
"github.com/webinstall/webi-installers/internal/storage"
)
// TagVariants tags cmake-specific build variants for exclusion from legacy export.
//
// cmake ships many formats and platforms that webi can't serve:
//
// - .sh self-extracting installer scripts: webi uses the .tar.gz archives.
//
// - .tar.Z files (old UNIX compress format): format not recognized by webi.
//
// - Darwin64 builds (pre-3.6 macOS naming): ancient format, superseded by
// the macos-universal builds.
//
// - sunos-sparc64 builds: unsupported platform (sparc64 arch not recognized).
//
// - AIX/powerpc builds: unsupported platform.
//
// - IRIX builds: unsupported platform.
//
// Note: macos10.N versioned builds (cmake-*-macos10.10-universal.tar.gz) are
// NOT dropped. Go correctly classifies them as os="darwin". The Node production
// classifier has a gap and can't parse "macos10.10" → that is a known prod bug,
// not a Go correctness issue. NODER should treat these as expected differences.
func TagVariants(assets []storage.Asset) {
for i := range assets {
lower := strings.ToLower(assets[i].Filename)
// Self-extracting installer scripts — webi uses .tar.gz archives.
if strings.HasSuffix(lower, ".sh") {
assets[i].Variants = append(assets[i].Variants, "installer")
continue
}
// Old UNIX compress format (.tar.Z) — not supported by webi.
if strings.HasSuffix(lower, ".tar.z") {
assets[i].Variants = append(assets[i].Variants, "legacy-archive")
continue
}
// Darwin64 builds: pre-cmake-3.6 macOS naming, superseded by macos-universal.
if strings.Contains(lower, "darwin64") {
assets[i].Variants = append(assets[i].Variants, "legacy-mac")
continue
}
// Unsupported platforms.
if strings.Contains(lower, "sunos") ||
strings.Contains(lower, "-aix-") ||
strings.Contains(lower, "irix") {
assets[i].Variants = append(assets[i].Variants, "unsupported-platform")
continue
}
}
}

View File

@@ -0,0 +1,28 @@
// Package fish provides variant tagging for fish shell releases.
//
// Fish publishes .pkg macOS installers alongside the standard archives.
// It also includes a source tarball (fish-{version}.tar.xz) as an
// uploaded release asset — no OS or arch in the name, indistinguishable
// from binaries by content_type. We tag it explicitly as "source".
package fishdist
import "github.com/webinstall/webi-installers/internal/storage"
// TagVariants tags fish-specific build variants.
func TagVariants(assets []storage.Asset) {
for i := range assets {
if assets[i].Format == ".pkg" {
assets[i].Variants = append(assets[i].Variants, "installer")
}
// Source tarball: no OS or arch detected by the classifier.
if assets[i].OS == "" && assets[i].Arch == "" {
assets[i].Variants = append(assets[i].Variants, "source")
}
// fish-*.app.zip is a macOS universal binary. Fish's naming puts
// arch in Linux filenames (e.g. fish-*-aarch64.tar.xz) but not in
// macOS .app.zip. Tag as x86_64; darwin waterfall serves arm64.
if assets[i].OS == "darwin" && assets[i].Arch == "" && assets[i].Format == ".app.zip" {
assets[i].Arch = "x86_64"
}
}
}

View File

@@ -0,0 +1,94 @@
// Package flutterdist fetches Flutter release data from Google Storage.
//
// Flutter publishes per-OS release indexes:
//
// https://storage.googleapis.com/flutter_infra_release/releases/releases_macos.json
// https://storage.googleapis.com/flutter_infra_release/releases/releases_linux.json
// https://storage.googleapis.com/flutter_infra_release/releases/releases_windows.json
//
// Each response has a base_url and a releases array with version, channel,
// release_date, archive path, and sha256.
package flutterdist
import (
"context"
"encoding/json"
"fmt"
"iter"
"net/http"
)
// index is the top-level JSON structure for one OS endpoint.
type index struct {
BaseURL string `json:"base_url"`
Releases []Release `json:"releases"`
}
// Release is one Flutter release entry.
type Release struct {
Hash string `json:"hash"` // git commit hash
Channel string `json:"channel"` // "stable", "beta", "dev"
Version string `json:"version"` // "3.29.2"
ReleaseDate string `json:"release_date"` // "2025-03-13T00:14:34.044690Z"
Archive string `json:"archive"` // "stable/macos/flutter_macos_arm64_3.29.2-stable.zip"
SHA256 string `json:"sha256"`
// DownloadURL is the fully-qualified URL, assembled from base_url + archive.
// Not in the upstream JSON — set by Fetch.
DownloadURL string `json:"download_url"`
// OS is the platform this entry came from ("macos", "linux", "windows").
// Not in the upstream JSON — set by Fetch.
OS string `json:"os"`
}
var defaultOSes = []string{"macos", "linux", "windows"}
// Fetch retrieves Flutter releases for all platforms.
//
// Yields one batch per OS. The iterator interface exists so callers use
// the same pattern as paginated sources.
func Fetch(ctx context.Context, client *http.Client) iter.Seq2[[]Release, error] {
return func(yield func([]Release, error) bool) {
for _, osName := range defaultOSes {
url := fmt.Sprintf(
"https://storage.googleapis.com/flutter_infra_release/releases/releases_%s.json",
osName,
)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
yield(nil, fmt.Errorf("flutterdist: %w", err))
return
}
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
yield(nil, fmt.Errorf("flutterdist: fetch %s: %w", osName, err))
return
}
var idx index
err = json.NewDecoder(resp.Body).Decode(&idx)
resp.Body.Close()
if err != nil {
yield(nil, fmt.Errorf("flutterdist: decode %s: %w", osName, err))
return
}
if resp.StatusCode != http.StatusOK {
yield(nil, fmt.Errorf("flutterdist: fetch %s: %s", osName, resp.Status))
return
}
for i := range idx.Releases {
idx.Releases[i].DownloadURL = idx.BaseURL + "/" + idx.Releases[i].Archive
idx.Releases[i].OS = osName
}
if !yield(idx.Releases, nil) {
return
}
}
}
}

View File

@@ -0,0 +1,16 @@
package flutterdist
import "github.com/webinstall/webi-installers/internal/storage"
// TagVariants handles flutter-specific arch defaults.
//
// Flutter's naming convention: flutter_{os}_{version} for x86_64 builds,
// flutter_{os}_arm64_{version} for arm64. The absence of an arch token
// means x86_64 — arm64 is always explicit.
func TagVariants(assets []storage.Asset) {
for i := range assets {
if assets[i].Arch == "" && assets[i].OS != "" {
assets[i].Arch = "x86_64"
}
}
}

View File

@@ -0,0 +1,52 @@
// Package git provides variant tagging for Git for Windows releases.
//
// Git for Windows publishes GUI installer .exe files (Git-*-bit.exe),
// self-extracting PortableGit archives, and .pdb debug symbol packages
// alongside the MinGit .zip that webi installs.
package gitdist
import (
"strings"
"github.com/webinstall/webi-installers/internal/storage"
)
// TagVariants tags git-specific build variants and fixes OS/arch classification.
// All git-for-windows releases are Windows-only, but MinGit filenames like
// "MinGit-2.33.0-64-bit.zip" have no "windows" indicator — force OS=windows.
// MinGit uses "64-bit"/"32-bit" for arch — a convention specific to this project
// that the general classifier intentionally does not handle.
func TagVariants(assets []storage.Asset) {
for i := range assets {
// All git-for-windows assets are Windows. Filenames like
// "MinGit-2.33.0-64-bit.zip" have no OS term; set it explicitly.
if assets[i].OS == "" {
assets[i].OS = "windows"
}
// MinGit uses "64-bit"→x86_64, "32-bit"→x86 naming.
// "arm64" is already handled by the general classifier.
if assets[i].Arch == "" {
lower := strings.ToLower(assets[i].Filename)
if strings.Contains(lower, "64-bit") {
assets[i].Arch = "x86_64"
} else if strings.Contains(lower, "32-bit") {
assets[i].Arch = "x86"
}
}
lower := strings.ToLower(assets[i].Filename)
if assets[i].Format == ".exe" {
assets[i].Variants = append(assets[i].Variants, "installer")
}
if strings.Contains(lower, "portablegit") {
assets[i].Variants = append(assets[i].Variants, "installer")
}
if strings.Contains(lower, "-pdb") || strings.Contains(lower, "pdbs-for-") {
assets[i].Variants = append(assets[i].Variants, "pdb")
}
if strings.Contains(lower, "-busybox") {
assets[i].Variants = append(assets[i].Variants, "busybox")
}
}
}

View File

@@ -0,0 +1,33 @@
package gitdist
import (
"strings"
"github.com/webinstall/webi-installers/internal/storage"
)
// NormalizeVersions strips the ".windows.N" suffix from Git for Windows
// version strings to match the upstream Git version scheme.
//
// Git for Windows tags are like "v2.53.0.windows.1" or "v2.53.0.windows.2".
// Node.js strips ".windows.1" entirely and replaces ".windows.N" (N>1)
// with ".N":
//
// v2.53.0.windows.1 → v2.53.0
// v2.53.0.windows.2 → v2.53.0.2
func NormalizeVersions(assets []storage.Asset) {
for i := range assets {
v := assets[i].Version
idx := strings.Index(v, ".windows.")
if idx < 0 {
continue
}
suffix := v[idx+len(".windows."):]
base := v[:idx]
if suffix == "1" {
assets[i].Version = base
} else {
assets[i].Version = base + "." + suffix
}
}
}

View File

@@ -0,0 +1,120 @@
// Package gitea fetches releases from a Gitea or Forgejo instance.
//
// Gitea's release API lives under:
//
// GET {baseurl}/api/v1/repos/{owner}/{repo}/releases
//
// The response shape is similar to GitHub's but not identical. This package
// handles pagination, authentication, and deserialization independently.
package gitea
import (
"context"
"encoding/json"
"fmt"
"iter"
"net/http"
"regexp"
"strings"
)
// Release is one release from the Gitea releases API.
type Release struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
Prerelease bool `json:"prerelease"`
Draft bool `json:"draft"`
PublishedAt string `json:"published_at"` // "2023-11-05T06:38:05Z"
Assets []Asset `json:"assets"`
TarballURL string `json:"tarball_url"`
ZipballURL string `json:"zipball_url"`
}
// Asset is one downloadable file attached to a release.
type Asset struct {
Name string `json:"name"` // "pathman-v0.6.0-darwin-amd64.tar.gz"
BrowserDownloadURL string `json:"browser_download_url"` // full URL
Size int64 `json:"size"`
}
// Auth holds optional credentials for authenticated API access.
type Auth struct {
Token string // personal access token or API key
}
// Fetch retrieves releases from a Gitea instance, paginating automatically.
// Each yield is one page of releases.
//
// The baseURL should be the Gitea root (e.g. "https://git.rootprojects.org").
// The /api/v1 prefix is appended automatically.
func Fetch(ctx context.Context, client *http.Client, baseURL, owner, repo string, auth *Auth) iter.Seq2[[]Release, error] {
return func(yield func([]Release, error) bool) {
base := strings.TrimRight(baseURL, "/")
page := 1
for {
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/releases?limit=50&page=%d",
base, owner, repo, page)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
yield(nil, fmt.Errorf("gitea: %w", err))
return
}
req.Header.Set("Accept", "application/json")
if auth != nil && auth.Token != "" {
req.Header.Set("Authorization", "token "+auth.Token)
}
resp, err := client.Do(req)
if err != nil {
yield(nil, fmt.Errorf("gitea: fetch %s: %w", url, err))
return
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
yield(nil, fmt.Errorf("gitea: fetch %s: %s", url, resp.Status))
return
}
var releases []Release
err = json.NewDecoder(resp.Body).Decode(&releases)
resp.Body.Close()
if err != nil {
yield(nil, fmt.Errorf("gitea: decode %s: %w", url, err))
return
}
if !yield(releases, nil) {
return
}
// Gitea uses Link headers like GitHub for pagination.
if nextURL := nextPageURL(resp.Header.Get("Link")); nextURL != "" {
url = nextURL
page++ // not strictly needed since we follow the URL, but keeps logic clear
continue
}
// No next link — also stop if we got fewer results than requested.
if len(releases) < 50 {
return
}
page++
}
}
}
var reNextLink = regexp.MustCompile(`<([^>]+)>;\s*rel="next"`)
func nextPageURL(link string) string {
if link == "" {
return ""
}
m := reNextLink.FindStringSubmatch(link)
if m == nil {
return ""
}
return m[1]
}

View File

@@ -0,0 +1,107 @@
package gitea_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/webinstall/webi-installers/internal/releases/gitea"
)
const testReleases = `[
{
"tag_name": "v0.6.0",
"name": "v0.6.0",
"prerelease": false,
"draft": false,
"published_at": "2023-11-05T06:38:05Z",
"tarball_url": "https://example.com/archive/v0.6.0.tar.gz",
"zipball_url": "https://example.com/archive/v0.6.0.zip",
"assets": [
{
"name": "tool-v0.6.0-linux-amd64.tar.gz",
"browser_download_url": "https://example.com/releases/download/v0.6.0/tool-v0.6.0-linux-amd64.tar.gz",
"size": 89215
}
]
}
]`
func TestFetch(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/repos/root/tool/releases" {
t.Errorf("unexpected path: %s", r.URL.Path)
http.NotFound(w, r)
return
}
w.Write([]byte(testReleases))
}))
defer srv.Close()
ctx := context.Background()
var all []gitea.Release
for releases, err := range gitea.Fetch(ctx, srv.Client(), srv.URL, "root", "tool", nil) {
if err != nil {
t.Fatal(err)
}
all = append(all, releases...)
}
if len(all) != 1 {
t.Fatalf("got %d releases, want 1", len(all))
}
if all[0].TagName != "v0.6.0" {
t.Errorf("TagName = %q, want %q", all[0].TagName, "v0.6.0")
}
if len(all[0].Assets) != 1 {
t.Errorf("got %d assets, want 1", len(all[0].Assets))
}
if all[0].TarballURL == "" {
t.Error("TarballURL is empty")
}
}
func TestFetchAuth(t *testing.T) {
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.Write([]byte("[]"))
}))
defer srv.Close()
ctx := context.Background()
auth := &gitea.Auth{Token: "abc123"}
for _, err := range gitea.Fetch(ctx, srv.Client(), srv.URL, "root", "tool", auth) {
if err != nil {
t.Fatal(err)
}
}
if gotAuth != "token abc123" {
t.Errorf("Authorization = %q, want %q", gotAuth, "token abc123")
}
}
func TestFetchLive(t *testing.T) {
if testing.Short() {
t.Skip("skipping network test in short mode")
}
ctx := context.Background()
client := &http.Client{}
var total int
for releases, err := range gitea.Fetch(ctx, client, "https://git.rootprojects.org", "root", "pathman", nil) {
if err != nil {
t.Fatal(err)
}
total += len(releases)
}
if total < 1 {
t.Errorf("got %d releases, expected at least 1", total)
}
t.Logf("fetched %d releases", total)
}

View File

@@ -0,0 +1,25 @@
// Package gitea provides variant tagging for Gitea releases.
//
// Gitea publishes "gogit" builds that use an alternative pure-Go Git
// backend instead of the default C Git library.
package gitea
import (
"strings"
"github.com/webinstall/webi-installers/internal/storage"
)
// TagVariants tags gitea-specific build variants.
//
// Files containing "-gogit-" in the filename are tagged with the "gogit"
// variant. These use a pure-Go Git backend rather than the default C Git
// library.
func TagVariants(assets []storage.Asset) {
for i := range assets {
lower := strings.ToLower(assets[i].Filename)
if strings.Contains(lower, "gogit") {
assets[i].Variants = append(assets[i].Variants, "gogit")
}
}
}

View File

@@ -0,0 +1,25 @@
// Package giteasrc fetches source archives from Gitea/Forgejo releases.
//
// Some packages are installed from the auto-generated source tarballs
// rather than uploaded binary assets. This package fetches releases and
// exposes the tarball/zipball URLs.
//
// Use [gitea] for packages that use uploaded binary assets.
package giteasrc
import (
"context"
"iter"
"net/http"
"github.com/webinstall/webi-installers/internal/releases/gitea"
)
// Fetch retrieves releases from a Gitea instance for the given owner/repo.
// Paginates automatically, yielding one batch per API page.
//
// Callers should use [gitea.Release.TarballURL] and
// [gitea.Release.ZipballURL] rather than the Assets list.
func Fetch(ctx context.Context, client *http.Client, baseURL, owner, repo string, auth *gitea.Auth) iter.Seq2[[]gitea.Release, error] {
return gitea.Fetch(ctx, client, baseURL, owner, repo, auth)
}

View File

@@ -0,0 +1,22 @@
// Package github fetches releases from the GitHub API.
//
// This is a thin wrapper around [githubish] that sets the base URL to
// https://api.github.com. Use [githubish] directly for Gitea, Forgejo,
// or other GitHub-compatible forges.
package github
import (
"context"
"iter"
"net/http"
"github.com/webinstall/webi-installers/internal/releases/githubish"
)
const baseURL = "https://api.github.com"
// Fetch retrieves releases from GitHub for the given owner/repo.
// Paginates automatically, yielding one batch per API page.
func Fetch(ctx context.Context, client *http.Client, owner, repo string, auth *githubish.Auth) iter.Seq2[[]githubish.Release, error] {
return githubish.Fetch(ctx, client, baseURL, owner, repo, auth)
}

View File

@@ -0,0 +1,112 @@
// Package githubish fetches releases from GitHub-compatible APIs.
//
// GitHub, Gitea, Forgejo, and other forges expose the same releases
// endpoint shape:
//
// GET /repos/{owner}/{repo}/releases
//
// This package handles pagination (Link headers), authentication, and
// deserialization. It does not transform or normalize the data.
package githubish
import (
"context"
"encoding/json"
"fmt"
"iter"
"net/http"
"regexp"
)
// Release is one release from a GitHub-compatible API.
// Fields mirror the upstream JSON — only the fields Webi cares about are
// included; the rest are silently dropped by the decoder.
type Release struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
Prerelease bool `json:"prerelease"`
Draft bool `json:"draft"`
PublishedAt string `json:"published_at"` // "2025-10-22T13:00:26Z"
Assets []Asset `json:"assets"`
TarballURL string `json:"tarball_url"` // auto-generated source tarball
ZipballURL string `json:"zipball_url"` // auto-generated source zipball
}
// Asset is one downloadable file attached to a release.
type Asset struct {
Name string `json:"name"` // "ripgrep-15.1.0-x86_64-apple-darwin.tar.gz"
BrowserDownloadURL string `json:"browser_download_url"` // full URL
Size int64 `json:"size"`
ContentType string `json:"content_type"`
}
// Auth holds optional credentials for authenticated API access.
// Without auth, GitHub's public rate limit is 60 requests/hour.
type Auth struct {
Token string // personal access token or fine-grained token
}
// Fetch retrieves releases from a GitHub-compatible API, paginating
// automatically. Each yield is one page of releases.
//
// The baseURL should be the API root (e.g. "https://api.github.com").
// For Gitea: "https://gitea.example.com/api/v1".
func Fetch(ctx context.Context, client *http.Client, baseURL, owner, repo string, auth *Auth) iter.Seq2[[]Release, error] {
return func(yield func([]Release, error) bool) {
url := fmt.Sprintf("%s/repos/%s/%s/releases?per_page=100", baseURL, owner, repo)
for url != "" {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
yield(nil, fmt.Errorf("githubish: %w", err))
return
}
req.Header.Set("Accept", "application/json")
if auth != nil && auth.Token != "" {
req.Header.Set("Authorization", "Bearer "+auth.Token)
}
resp, err := client.Do(req)
if err != nil {
yield(nil, fmt.Errorf("githubish: fetch %s: %w", url, err))
return
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
yield(nil, fmt.Errorf("githubish: fetch %s: %s", url, resp.Status))
return
}
var releases []Release
err = json.NewDecoder(resp.Body).Decode(&releases)
resp.Body.Close()
if err != nil {
yield(nil, fmt.Errorf("githubish: decode %s: %w", url, err))
return
}
if !yield(releases, nil) {
return
}
url = nextPageURL(resp.Header.Get("Link"))
}
}
}
// reNextLink matches `<URL>; rel="next"` in a Link header.
var reNextLink = regexp.MustCompile(`<([^>]+)>;\s*rel="next"`)
// nextPageURL extracts the "next" URL from a GitHub Link header.
// Returns "" if there is no next page.
func nextPageURL(link string) string {
if link == "" {
return ""
}
m := reNextLink.FindStringSubmatch(link)
if m == nil {
return ""
}
return m[1]
}

View File

@@ -0,0 +1,201 @@
package githubish_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/webinstall/webi-installers/internal/releases/githubish"
)
const page1 = `[
{
"tag_name": "v2.0.0",
"name": "v2.0.0",
"prerelease": false,
"draft": false,
"published_at": "2025-06-01T12:00:00Z",
"assets": [
{
"name": "tool-v2.0.0-linux-amd64.tar.gz",
"browser_download_url": "https://example.com/tool-v2.0.0-linux-amd64.tar.gz",
"size": 5000000,
"content_type": "application/gzip"
}
]
}
]`
const page2 = `[
{
"tag_name": "v1.0.0",
"name": "v1.0.0",
"prerelease": false,
"draft": false,
"published_at": "2024-01-15T08:00:00Z",
"assets": [
{
"name": "tool-v1.0.0-linux-amd64.tar.gz",
"browser_download_url": "https://example.com/tool-v1.0.0-linux-amd64.tar.gz",
"size": 4000000,
"content_type": "application/gzip"
},
{
"name": "tool-v1.0.0-darwin-arm64.tar.gz",
"browser_download_url": "https://example.com/tool-v1.0.0-darwin-arm64.tar.gz",
"size": 4500000,
"content_type": "application/gzip"
}
]
}
]`
func TestFetchPagination(t *testing.T) {
var srvURL string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/repos/acme/tool/releases" {
t.Errorf("unexpected path: %s", r.URL.Path)
http.NotFound(w, r)
return
}
page := r.URL.Query().Get("page")
switch page {
case "", "1":
// Link header pointing to page 2
w.Header().Set("Link",
fmt.Sprintf(`<%s/repos/acme/tool/releases?per_page=100&page=2>; rel="next"`, srvURL))
w.Write([]byte(page1))
case "2":
// No Link header — last page
w.Write([]byte(page2))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
srvURL = srv.URL
ctx := context.Background()
var batches int
var allReleases []githubish.Release
for releases, err := range githubish.Fetch(ctx, srv.Client(), srv.URL, "acme", "tool", nil) {
if err != nil {
t.Fatalf("batch %d: %v", batches, err)
}
batches++
allReleases = append(allReleases, releases...)
}
if batches != 2 {
t.Errorf("got %d batches, want 2", batches)
}
if len(allReleases) != 2 {
t.Fatalf("got %d releases, want 2", len(allReleases))
}
// Page 1: v2.0.0
if allReleases[0].TagName != "v2.0.0" {
t.Errorf("release[0].TagName = %q, want %q", allReleases[0].TagName, "v2.0.0")
}
if len(allReleases[0].Assets) != 1 {
t.Errorf("release[0] has %d assets, want 1", len(allReleases[0].Assets))
}
// Page 2: v1.0.0
if allReleases[1].TagName != "v1.0.0" {
t.Errorf("release[1].TagName = %q, want %q", allReleases[1].TagName, "v1.0.0")
}
if len(allReleases[1].Assets) != 2 {
t.Errorf("release[1] has %d assets, want 2", len(allReleases[1].Assets))
}
}
func TestFetchPrerelease(t *testing.T) {
body := `[{"tag_name":"v1.0.0-rc1","name":"","prerelease":true,"draft":false,"published_at":"2025-01-01T00:00:00Z","assets":[]}]`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(body))
}))
defer srv.Close()
ctx := context.Background()
for releases, err := range githubish.Fetch(ctx, srv.Client(), srv.URL, "acme", "tool", nil) {
if err != nil {
t.Fatal(err)
}
if len(releases) != 1 {
t.Fatalf("got %d releases, want 1", len(releases))
}
if !releases[0].Prerelease {
t.Error("expected Prerelease = true")
}
if releases[0].TagName != "v1.0.0-rc1" {
t.Errorf("TagName = %q, want %q", releases[0].TagName, "v1.0.0-rc1")
}
}
}
func TestFetchAuth(t *testing.T) {
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.Write([]byte("[]"))
}))
defer srv.Close()
ctx := context.Background()
auth := &githubish.Auth{Token: "ghp_test123"}
for _, err := range githubish.Fetch(ctx, srv.Client(), srv.URL, "acme", "tool", auth) {
if err != nil {
t.Fatal(err)
}
}
if gotAuth != "Bearer ghp_test123" {
t.Errorf("Authorization = %q, want %q", gotAuth, "Bearer ghp_test123")
}
}
func TestFetchHTTPError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "not found", http.StatusNotFound)
}))
defer srv.Close()
ctx := context.Background()
for _, err := range githubish.Fetch(ctx, srv.Client(), srv.URL, "acme", "tool", nil) {
if err == nil {
t.Fatal("expected error for 404 response")
}
return
}
}
func TestFetchEarlyBreak(t *testing.T) {
var requests int
var srvURL string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
// Always advertise a next page
w.Header().Set("Link",
fmt.Sprintf(`<%s/repos/acme/tool/releases?per_page=100&page=%d>; rel="next"`, srvURL, requests+1))
w.Write([]byte(`[{"tag_name":"v1.0.0","name":"","prerelease":false,"draft":false,"published_at":"2025-01-01T00:00:00Z","assets":[]}]`))
}))
defer srv.Close()
srvURL = srv.URL
ctx := context.Background()
for _, err := range githubish.Fetch(ctx, srv.Client(), srv.URL, "acme", "tool", nil) {
if err != nil {
t.Fatal(err)
}
break // stop after first page
}
if requests != 1 {
t.Errorf("server received %d requests, want 1 (early break should stop pagination)", requests)
}
}

View File

@@ -0,0 +1,27 @@
// Package githubsrc fetches source archives from GitHub releases.
//
// Some packages (shell scripts, vim plugins) are installed from the
// auto-generated source tarballs rather than uploaded binary assets.
// This package fetches releases and exposes the tarball/zipball URLs.
//
// Use [github] for packages that use uploaded binary assets.
package githubsrc
import (
"context"
"iter"
"net/http"
"github.com/webinstall/webi-installers/internal/releases/githubish"
)
const baseURL = "https://api.github.com"
// Fetch retrieves releases from GitHub for the given owner/repo.
// Paginates automatically, yielding one batch per API page.
//
// Callers should use [githubish.Release.TarballURL] and
// [githubish.Release.ZipballURL] rather than the Assets list.
func Fetch(ctx context.Context, client *http.Client, owner, repo string, auth *githubish.Auth) iter.Seq2[[]githubish.Release, error] {
return githubish.Fetch(ctx, client, baseURL, owner, repo, auth)
}

View File

@@ -0,0 +1,122 @@
// Package gitlab fetches releases from a GitLab instance.
//
// GitLab's releases API differs from GitHub's in structure:
//
// GET /api/v4/projects/:id/releases
//
// Where :id is the URL-encoded project path (e.g. "group%2Frepo") or a
// numeric project ID. Assets are split into auto-generated source archives
// and manually attached links. Pagination uses page/per_page query params
// and X-Total-Pages response headers (not Link headers).
//
// This package handles pagination, authentication, and deserialization.
// It does not transform or normalize the data.
package gitlab
import (
"context"
"encoding/json"
"fmt"
"iter"
"net/http"
"net/url"
"strconv"
)
// Release is one release from the GitLab releases API.
type Release struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
ReleasedAt string `json:"released_at"` // "2025-10-22T13:00:26Z"
Assets Assets `json:"assets"`
}
// Assets holds both auto-generated source archives and attached links.
type Assets struct {
Sources []Source `json:"sources"`
Links []Link `json:"links"`
}
// Source is an auto-generated source archive (tar.gz, zip, etc.).
type Source struct {
Format string `json:"format"` // "zip", "tar.gz", "tar.bz2", "tar"
URL string `json:"url"`
}
// Link is a file attached to a release (binary, package, etc.).
type Link struct {
ID int `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
DirectAssetPath string `json:"direct_asset_path"`
LinkType string `json:"link_type"` // "other", "runbook", "image", "package"
}
// Auth holds optional credentials for authenticated API access.
type Auth struct {
Token string // personal access token or deploy token
}
// Fetch retrieves releases from a GitLab instance, paginating automatically.
// Each yield is one page of releases.
//
// The baseURL should be the GitLab root (e.g. "https://gitlab.com").
// The project is identified by its path (e.g. "group/repo") — it will be
// URL-encoded automatically.
func Fetch(ctx context.Context, client *http.Client, baseURL, project string, auth *Auth) iter.Seq2[[]Release, error] {
return func(yield func([]Release, error) bool) {
encodedProject := url.PathEscape(project)
page := 1
for {
reqURL := fmt.Sprintf("%s/api/v4/projects/%s/releases?per_page=100&page=%d",
baseURL, encodedProject, page)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
yield(nil, fmt.Errorf("gitlab: %w", err))
return
}
req.Header.Set("Accept", "application/json")
if auth != nil && auth.Token != "" {
req.Header.Set("PRIVATE-TOKEN", auth.Token)
}
resp, err := client.Do(req)
if err != nil {
yield(nil, fmt.Errorf("gitlab: fetch %s: %w", reqURL, err))
return
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
yield(nil, fmt.Errorf("gitlab: fetch %s: %s", reqURL, resp.Status))
return
}
var releases []Release
err = json.NewDecoder(resp.Body).Decode(&releases)
resp.Body.Close()
if err != nil {
yield(nil, fmt.Errorf("gitlab: decode %s: %w", reqURL, err))
return
}
if !yield(releases, nil) {
return
}
// Check if there are more pages.
totalPages := 1
if tp := resp.Header.Get("X-Total-Pages"); tp != "" {
if n, err := strconv.Atoi(tp); err == nil {
totalPages = n
}
}
if page >= totalPages {
return
}
page++
}
}
}

View File

@@ -0,0 +1,182 @@
package gitlab_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/webinstall/webi-installers/internal/releases/gitlab"
)
const page1 = `[
{
"tag_name": "v2.0.0",
"name": "v2.0.0",
"released_at": "2025-06-01T12:00:00Z",
"assets": {
"sources": [
{"format": "tar.gz", "url": "https://example.com/archive/v2.0.0.tar.gz"},
{"format": "zip", "url": "https://example.com/archive/v2.0.0.zip"}
],
"links": [
{
"id": 1,
"name": "tool-v2.0.0-linux-amd64.tar.gz",
"url": "https://example.com/tool-v2.0.0-linux-amd64.tar.gz",
"direct_asset_path": "/binaries/linux-amd64",
"link_type": "package"
}
]
}
}
]`
const page2 = `[
{
"tag_name": "v1.0.0",
"name": "v1.0.0",
"released_at": "2024-01-15T08:00:00Z",
"assets": {
"sources": [
{"format": "tar.gz", "url": "https://example.com/archive/v1.0.0.tar.gz"}
],
"links": []
}
}
]`
func TestFetchPagination(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Go's http server decodes %2F back to /, so check RawPath
// for the encoded form or Path for the decoded form.
wantRaw := "/api/v4/projects/group%2Ftool/releases"
wantDecoded := "/api/v4/projects/group/tool/releases"
if r.URL.RawPath != wantRaw && r.URL.Path != wantDecoded {
t.Errorf("unexpected path: raw=%q decoded=%q", r.URL.RawPath, r.URL.Path)
http.NotFound(w, r)
return
}
page := r.URL.Query().Get("page")
w.Header().Set("X-Total-Pages", "2")
switch page {
case "", "1":
w.Write([]byte(page1))
case "2":
w.Write([]byte(page2))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
ctx := context.Background()
var batches int
var allReleases []gitlab.Release
for releases, err := range gitlab.Fetch(ctx, srv.Client(), srv.URL, "group/tool", nil) {
if err != nil {
t.Fatalf("batch %d: %v", batches, err)
}
batches++
allReleases = append(allReleases, releases...)
}
if batches != 2 {
t.Errorf("got %d batches, want 2", batches)
}
if len(allReleases) != 2 {
t.Fatalf("got %d releases, want 2", len(allReleases))
}
// Page 1: v2.0.0
r1 := allReleases[0]
if r1.TagName != "v2.0.0" {
t.Errorf("release[0].TagName = %q, want %q", r1.TagName, "v2.0.0")
}
if len(r1.Assets.Sources) != 2 {
t.Errorf("release[0] has %d sources, want 2", len(r1.Assets.Sources))
}
if len(r1.Assets.Links) != 1 {
t.Errorf("release[0] has %d links, want 1", len(r1.Assets.Links))
}
if r1.Assets.Links[0].LinkType != "package" {
t.Errorf("release[0] link type = %q, want %q", r1.Assets.Links[0].LinkType, "package")
}
// Page 2: v1.0.0
r2 := allReleases[1]
if r2.TagName != "v1.0.0" {
t.Errorf("release[1].TagName = %q, want %q", r2.TagName, "v1.0.0")
}
if len(r2.Assets.Links) != 0 {
t.Errorf("release[1] has %d links, want 0", len(r2.Assets.Links))
}
}
func TestFetchAuth(t *testing.T) {
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("PRIVATE-TOKEN")
w.Write([]byte("[]"))
}))
defer srv.Close()
ctx := context.Background()
auth := &gitlab.Auth{Token: "glpat-test123"}
for _, err := range gitlab.Fetch(ctx, srv.Client(), srv.URL, "group/tool", auth) {
if err != nil {
t.Fatal(err)
}
}
if gotAuth != "glpat-test123" {
t.Errorf("PRIVATE-TOKEN = %q, want %q", gotAuth, "glpat-test123")
}
}
func TestFetchSinglePage(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// No X-Total-Pages header — defaults to 1 page.
w.Write([]byte(page1))
}))
defer srv.Close()
ctx := context.Background()
var batches int
for _, err := range gitlab.Fetch(ctx, srv.Client(), srv.URL, "group/tool", nil) {
if err != nil {
t.Fatal(err)
}
batches++
}
if batches != 1 {
t.Errorf("got %d batches, want 1 (no X-Total-Pages means single page)", batches)
}
}
func TestFetchEarlyBreak(t *testing.T) {
var requests int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
w.Header().Set("X-Total-Pages", "10")
w.Write([]byte(fmt.Sprintf(`[{"tag_name":"v%d.0.0","name":"","released_at":"2025-01-01T00:00:00Z","assets":{"sources":[],"links":[]}}]`, requests)))
}))
defer srv.Close()
ctx := context.Background()
for _, err := range gitlab.Fetch(ctx, srv.Client(), srv.URL, "group/tool", nil) {
if err != nil {
t.Fatal(err)
}
break // stop after first page
}
if requests != 1 {
t.Errorf("server received %d requests, want 1", requests)
}
}

View File

@@ -0,0 +1,25 @@
// Package gitlabsrc fetches source archives from GitLab releases.
//
// Some packages are installed from the auto-generated source archives
// rather than attached binary links. This package fetches releases and
// exposes the source archive URLs.
//
// Use [gitlab] for packages that use attached release links (binaries).
package gitlabsrc
import (
"context"
"iter"
"net/http"
"github.com/webinstall/webi-installers/internal/releases/gitlab"
)
// Fetch retrieves releases from a GitLab instance.
// Paginates automatically, yielding one batch per API page.
//
// Callers should use [gitlab.Release.Assets.Sources] rather than
// [gitlab.Release.Assets.Links].
func Fetch(ctx context.Context, client *http.Client, baseURL, project string, auth *gitlab.Auth) iter.Seq2[[]gitlab.Release, error] {
return gitlab.Fetch(ctx, client, baseURL, project, auth)
}

View File

@@ -0,0 +1,178 @@
// Package gittag fetches release information from git tags in a bare repo.
//
// Some packages (vim plugins, shell scripts) are installed by cloning a git
// repo rather than downloading a binary. For these, each tag is a "release"
// and the download URL is the repo's git URL.
//
// This package clones (or fetches) a bare repo to a local cache directory,
// lists version-like tags, and returns them with their commit metadata.
// HEAD is also included as a potential release.
package gittag
import (
"context"
"fmt"
"iter"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"crypto/rand"
"encoding/hex"
)
// Entry is one tag (or HEAD) from a git repo.
type Entry struct {
Version string // tag name or date-based version for HEAD
GitTag string // the ref that can be passed to `git clone --branch`
CommitHash string // abbreviated commit hash
Date string // ISO 8601 commit date (author date)
}
// reVersionTag matches tags that look like versions: v1, v1.2, 1.0.0-rc, etc.
var reVersionTag = regexp.MustCompile(`^v?\d+(\.\d+)`)
// Fetch clones or updates a bare repo, then yields its version-like tags
// and HEAD as entries. The repoDir is the parent directory where bare repos
// are cached.
//
// Yields one batch containing all tags plus HEAD.
func Fetch(ctx context.Context, gitURL, repoDir string) iter.Seq2[[]Entry, error] {
return func(yield func([]Entry, error) bool) {
repoName := filepath.Base(gitURL)
repoName = strings.TrimSuffix(repoName, ".git")
repoPath := filepath.Join(repoDir, repoName+".git")
if err := ensureRepo(ctx, repoPath, gitURL); err != nil {
yield(nil, fmt.Errorf("gittag: %w", err))
return
}
tags, err := listVersionTags(ctx, repoPath)
if err != nil {
yield(nil, fmt.Errorf("gittag: %w", err))
return
}
var entries []Entry
for _, tag := range tags {
info, err := commitInfo(ctx, repoPath, tag)
if err != nil {
yield(nil, fmt.Errorf("gittag: commit info for %q: %w", tag, err))
return
}
info.Version = tag
info.GitTag = tag
entries = append(entries, info)
}
// HEAD as an additional entry
head, err := commitInfo(ctx, repoPath, "HEAD")
if err != nil {
yield(nil, fmt.Errorf("gittag: commit info for HEAD: %w", err))
return
}
branch, err := headBranch(ctx, repoPath)
if err != nil {
yield(nil, fmt.Errorf("gittag: HEAD branch: %w", err))
return
}
head.GitTag = branch
// Version for HEAD is set by the caller (date-based, etc.)
entries = append(entries, head)
yield(entries, nil)
}
}
// ensureRepo clones the repo if it doesn't exist, or fetches if it does.
func ensureRepo(ctx context.Context, repoPath, gitURL string) error {
if _, err := os.Stat(repoPath); err == nil {
// Exists — fetch updates.
cmd := exec.CommandContext(ctx, "git", "--git-dir="+repoPath, "fetch")
cmd.Stderr = os.Stderr
return cmd.Run()
}
// Clone bare with tree filter (metadata only).
var b [8]byte
rand.Read(b[:])
id := hex.EncodeToString(b[:])
tmpPath := repoPath + "." + id + ".tmp"
cmd := exec.CommandContext(ctx, "git", "clone", "--bare", "--filter=tree:0", gitURL, tmpPath)
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
os.RemoveAll(tmpPath)
return fmt.Errorf("clone %s: %w", gitURL, err)
}
// Atomic swap — if repoPath appeared in a race, keep it and discard ours.
if err := os.Rename(tmpPath, repoPath); err != nil {
os.RemoveAll(tmpPath)
// If rename failed because repoPath now exists, that's fine.
if _, statErr := os.Stat(repoPath); statErr == nil {
return nil
}
return err
}
return nil
}
// listVersionTags returns tags that look like version numbers, newest first.
func listVersionTags(ctx context.Context, repoPath string) ([]string, error) {
cmd := exec.CommandContext(ctx, "git", "--git-dir="+repoPath, "tag")
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("git tag: %w", err)
}
var tags []string
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line == "" {
continue
}
if reVersionTag.MatchString(line) {
tags = append(tags, line)
}
}
// Reverse so newest tags come first (git tag outputs alphabetically).
for i, j := 0, len(tags)-1; i < j; i, j = i+1, j-1 {
tags[i], tags[j] = tags[j], tags[i]
}
return tags, nil
}
// commitInfo returns the abbreviated hash and author date for a commitish.
func commitInfo(ctx context.Context, repoPath, commitish string) (Entry, error) {
cmd := exec.CommandContext(ctx, "git", "--git-dir="+repoPath,
"log", "-1", "--format=%h %ad", "--date=iso-strict", commitish)
out, err := cmd.Output()
if err != nil {
return Entry{}, fmt.Errorf("git log %s: %w", commitish, err)
}
parts := strings.Fields(strings.TrimSpace(string(out)))
if len(parts) < 2 {
return Entry{}, fmt.Errorf("unexpected git log output: %q", out)
}
return Entry{
CommitHash: parts[0],
Date: parts[1],
}, nil
}
// headBranch returns the symbolic ref for HEAD (e.g. "main", "master").
func headBranch(ctx context.Context, repoPath string) (string, error) {
cmd := exec.CommandContext(ctx, "git", "--git-dir="+repoPath,
"rev-parse", "--abbrev-ref", "HEAD")
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("git rev-parse HEAD: %w", err)
}
return strings.TrimSpace(string(out)), nil
}

Some files were not shown because too many files have changed in this diff Show More