mirror of
https://github.com/webinstall/webi-installers.git
synced 2026-06-10 01:36:35 +00:00
Compare commits
13 Commits
feat-insta
...
ref-cache-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
785dc05324 | ||
|
|
73188d50e1 | ||
|
|
bf5cafac18 | ||
|
|
1e499ed6c8 | ||
|
|
f638a25529 | ||
|
|
95418b1023 | ||
|
|
f66822295b | ||
|
|
c538942392 | ||
|
|
af28ddb686 | ||
|
|
631147901a | ||
|
|
b3375d0e24 | ||
|
|
c57757a027 | ||
|
|
0bf485dcc4 |
22
.gitignore
vendored
22
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
DELETEMEnode_modules/**/*
|
||||
node_modules/**/*
|
||||
_webi/test-*.js
|
||||
|
||||
249
AGENTS.md
249
AGENTS.md
@@ -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
3
_example/releases.conf
Normal 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
100
_scripts/deploy-webinstall
Executable 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 "$@"
|
||||
Submodule _webi/build-classifier updated: 9f87804eb4...f9cc9f3e19
@@ -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
208
_webi/test-api-compat.js
Normal 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);
|
||||
});
|
||||
77
_webi/test-broad-resolve.js
Normal file
77
_webi/test-broad-resolve.js
Normal 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);
|
||||
});
|
||||
277
_webi/test-cache-api-ready.js
Normal file
277
_webi/test-cache-api-ready.js
Normal 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
687
_webi/test-cache-compat.js
Normal 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
339
_webi/test-fleet-diff.js
Normal 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);
|
||||
});
|
||||
444
_webi/test-installer-resolve.js
Normal file
444
_webi/test-installer-resolve.js
Normal 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);
|
||||
});
|
||||
450
_webi/test-live-cache-diff.js
Normal file
450
_webi/test-live-cache-diff.js
Normal 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
577
_webi/test-live-compare.js
Normal 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);
|
||||
});
|
||||
202
_webi/test-live-installer-diff.js
Normal file
202
_webi/test-live-installer-diff.js
Normal 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);
|
||||
});
|
||||
@@ -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
2
aliasman/releases.conf
Normal file
@@ -0,0 +1,2 @@
|
||||
github_sources = BeyondCodeBootcamp/aliasman
|
||||
git_url = https://github.com/BeyondCodeBootcamp/aliasman.git
|
||||
1
arc/releases.conf
Normal file
1
arc/releases.conf
Normal file
@@ -0,0 +1 @@
|
||||
github_releases = mholt/archiver
|
||||
1
atomicparsley/releases.conf
Normal file
1
atomicparsley/releases.conf
Normal file
@@ -0,0 +1 @@
|
||||
github_releases = wez/atomicparsley
|
||||
1
awless/releases.conf
Normal file
1
awless/releases.conf
Normal file
@@ -0,0 +1 @@
|
||||
github_releases = wallix/awless
|
||||
@@ -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
1
bat/releases.conf
Normal file
@@ -0,0 +1 @@
|
||||
github_releases = sharkdp/bat
|
||||
5
bun/releases.conf
Normal file
5
bun/releases.conf
Normal 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
1
caddy/releases.conf
Normal file
@@ -0,0 +1 @@
|
||||
github_releases = caddyserver/caddy
|
||||
1
chromedriver/releases.conf
Normal file
1
chromedriver/releases.conf
Normal file
@@ -0,0 +1 @@
|
||||
source = chromedist
|
||||
1
cilium/releases.conf
Normal file
1
cilium/releases.conf
Normal file
@@ -0,0 +1 @@
|
||||
github_releases = cilium/cilium-cli
|
||||
1
cmake/releases.conf
Normal file
1
cmake/releases.conf
Normal file
@@ -0,0 +1 @@
|
||||
github_releases = Kitware/CMake
|
||||
925
cmd/webicached/main.go
Normal file
925
cmd/webicached/main.go
Normal 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
1
comrak/releases.conf
Normal file
@@ -0,0 +1 @@
|
||||
github_releases = kivikakk/comrak
|
||||
1
crabz/releases.conf
Normal file
1
crabz/releases.conf
Normal file
@@ -0,0 +1 @@
|
||||
github_releases = sstadick/crabz
|
||||
1
curlie/releases.conf
Normal file
1
curlie/releases.conf
Normal file
@@ -0,0 +1 @@
|
||||
github_releases = rs/curlie
|
||||
1
dashcore/releases.conf
Normal file
1
dashcore/releases.conf
Normal file
@@ -0,0 +1 @@
|
||||
github_releases = dashpay/dash
|
||||
1
dashd/releases.conf
Normal file
1
dashd/releases.conf
Normal file
@@ -0,0 +1 @@
|
||||
alias_of = dashcore
|
||||
1
dashmsg/releases.conf
Normal file
1
dashmsg/releases.conf
Normal file
@@ -0,0 +1 @@
|
||||
github_releases = dashhive/dashmsg
|
||||
1
delta/releases.conf
Normal file
1
delta/releases.conf
Normal file
@@ -0,0 +1 @@
|
||||
github_releases = dandavison/delta
|
||||
1
deno/releases.conf
Normal file
1
deno/releases.conf
Normal file
@@ -0,0 +1 @@
|
||||
github_releases = denoland/deno
|
||||
121
docs/installer-patterns.md
Normal file
121
docs/installer-patterns.md
Normal 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
74
docs/version-oddities.md
Normal 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
|
||||
```
|
||||
1
dotenv-linter/releases.conf
Normal file
1
dotenv-linter/releases.conf
Normal file
@@ -0,0 +1 @@
|
||||
github_releases = dotenv-linter/dotenv-linter
|
||||
1
dotenv/releases.conf
Normal file
1
dotenv/releases.conf
Normal file
@@ -0,0 +1 @@
|
||||
github_releases = therootcompany/dotenv
|
||||
2
duckdns.sh/releases.conf
Normal file
2
duckdns.sh/releases.conf
Normal file
@@ -0,0 +1,2 @@
|
||||
github_sources = BeyondCodeBootcamp/DuckDNS.sh
|
||||
git_url = https://github.com/BeyondCodeBootcamp/DuckDNS.sh.git
|
||||
@@ -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
1
fd/releases.conf
Normal file
@@ -0,0 +1 @@
|
||||
github_releases = sharkdp/fd
|
||||
4
ffmpeg/releases.conf
Normal file
4
ffmpeg/releases.conf
Normal file
@@ -0,0 +1,4 @@
|
||||
source = ffmpegdist
|
||||
github_releases = eugeneware/ffmpeg-static
|
||||
asset_filter = ffmpeg
|
||||
version_prefix = b
|
||||
1
ffuf/releases.conf
Normal file
1
ffuf/releases.conf
Normal file
@@ -0,0 +1 @@
|
||||
github_releases = ffuf/ffuf
|
||||
2
fish/releases.conf
Normal file
2
fish/releases.conf
Normal file
@@ -0,0 +1,2 @@
|
||||
github_releases = fish-shell/fish-shell
|
||||
exclude = bundledpcre fish-static OpenBeta
|
||||
1
flutter/releases.conf
Normal file
1
flutter/releases.conf
Normal file
@@ -0,0 +1 @@
|
||||
source = flutterdist
|
||||
1
fzf/releases.conf
Normal file
1
fzf/releases.conf
Normal file
@@ -0,0 +1 @@
|
||||
github_releases = junegunn/fzf
|
||||
@@ -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
1
gh/releases.conf
Normal file
@@ -0,0 +1 @@
|
||||
github_releases = cli/cli
|
||||
4
git/releases.conf
Normal file
4
git/releases.conf
Normal file
@@ -0,0 +1,4 @@
|
||||
github_releases = git-for-windows/git
|
||||
asset_filter = MinGit
|
||||
exclude = busybox
|
||||
variants = installer
|
||||
1
gitdeploy/releases.conf
Normal file
1
gitdeploy/releases.conf
Normal file
@@ -0,0 +1 @@
|
||||
github_releases = therootcompany/gitdeploy
|
||||
2
gitea/releases.conf
Normal file
2
gitea/releases.conf
Normal file
@@ -0,0 +1,2 @@
|
||||
github_releases = go-gitea/gitea
|
||||
exclude = -src- -docs-
|
||||
1
gnupg/releases.conf
Normal file
1
gnupg/releases.conf
Normal file
@@ -0,0 +1 @@
|
||||
alias_of = gpg
|
||||
5
go.mod
Normal file
5
go.mod
Normal 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
2
go.sum
Normal 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
1
go/releases.conf
Normal file
@@ -0,0 +1 @@
|
||||
source = golang
|
||||
1
golang/releases.conf
Normal file
1
golang/releases.conf
Normal file
@@ -0,0 +1 @@
|
||||
alias_of = go
|
||||
@@ -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
1
goreleaser/releases.conf
Normal file
@@ -0,0 +1 @@
|
||||
github_releases = goreleaser/goreleaser
|
||||
1
gpg/releases.conf
Normal file
1
gpg/releases.conf
Normal file
@@ -0,0 +1 @@
|
||||
source = gpgdist
|
||||
1
gprox/releases.conf
Normal file
1
gprox/releases.conf
Normal file
@@ -0,0 +1 @@
|
||||
github_releases = creedasaurus/gprox
|
||||
1
grype/releases.conf
Normal file
1
grype/releases.conf
Normal file
@@ -0,0 +1 @@
|
||||
github_releases = anchore/grype
|
||||
1
hexyl/releases.conf
Normal file
1
hexyl/releases.conf
Normal file
@@ -0,0 +1 @@
|
||||
github_releases = sharkdp/hexyl
|
||||
3
hugo-extended/releases.conf
Normal file
3
hugo-extended/releases.conf
Normal file
@@ -0,0 +1,3 @@
|
||||
github_releases = gohugoio/hugo
|
||||
asset_filter = extended
|
||||
exclude = Linux-64bit
|
||||
2
hugo/releases.conf
Normal file
2
hugo/releases.conf
Normal file
@@ -0,0 +1,2 @@
|
||||
github_releases = gohugoio/hugo
|
||||
exclude = extended Linux-64bit
|
||||
168
internal/buildmeta/buildmeta.go
Normal file
168
internal/buildmeta/buildmeta.go
Normal 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}
|
||||
}
|
||||
|
||||
283
internal/classify/classify.go
Normal file
283
internal/classify/classify.go
Normal 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
|
||||
}
|
||||
352
internal/classify/classify_test.go
Normal file
352
internal/classify/classify_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
1439
internal/classifypkg/classifypkg.go
Normal file
1439
internal/classifypkg/classifypkg.go
Normal file
File diff suppressed because it is too large
Load Diff
154
internal/httpclient/httpclient.go
Normal file
154
internal/httpclient/httpclient.go
Normal 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)
|
||||
}
|
||||
286
internal/installerconf/installerconf.go
Normal file
286
internal/installerconf/installerconf.go
Normal 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
|
||||
}
|
||||
217
internal/installerconf/installerconf_test.go
Normal file
217
internal/installerconf/installerconf_test.go
Normal 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
189
internal/lexver/lexver.go
Normal 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
|
||||
}
|
||||
270
internal/lexver/lexver_test.go
Normal file
270
internal/lexver/lexver_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
63
internal/rawcache/auditlog.go
Normal file
63
internal/rawcache/auditlog.go
Normal 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[:])
|
||||
}
|
||||
265
internal/rawcache/rawcache.go
Normal file
265
internal/rawcache/rawcache.go
Normal 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
|
||||
}
|
||||
173
internal/rawcache/rawcache_test.go
Normal file
173
internal/rawcache/rawcache_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
50
internal/releases/atomicparsley/variants.go
Normal file
50
internal/releases/atomicparsley/variants.go
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
39
internal/releases/bun/variants.go
Normal file
39
internal/releases/bun/variants.go
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
72
internal/releases/chromedist/chromedist.go
Normal file
72
internal/releases/chromedist/chromedist.go
Normal 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)
|
||||
}
|
||||
}
|
||||
60
internal/releases/cmake/variants.go
Normal file
60
internal/releases/cmake/variants.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
28
internal/releases/fish/variants.go
Normal file
28
internal/releases/fish/variants.go
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
94
internal/releases/flutterdist/flutterdist.go
Normal file
94
internal/releases/flutterdist/flutterdist.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
internal/releases/flutterdist/variants.go
Normal file
16
internal/releases/flutterdist/variants.go
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
52
internal/releases/git/variants.go
Normal file
52
internal/releases/git/variants.go
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
33
internal/releases/git/versions.go
Normal file
33
internal/releases/git/versions.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
120
internal/releases/gitea/gitea.go
Normal file
120
internal/releases/gitea/gitea.go
Normal 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]
|
||||
}
|
||||
107
internal/releases/gitea/gitea_test.go
Normal file
107
internal/releases/gitea/gitea_test.go
Normal 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)
|
||||
}
|
||||
25
internal/releases/gitea/variants.go
Normal file
25
internal/releases/gitea/variants.go
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
25
internal/releases/giteasrc/giteasrc.go
Normal file
25
internal/releases/giteasrc/giteasrc.go
Normal 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)
|
||||
}
|
||||
22
internal/releases/github/github.go
Normal file
22
internal/releases/github/github.go
Normal 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)
|
||||
}
|
||||
112
internal/releases/githubish/githubish.go
Normal file
112
internal/releases/githubish/githubish.go
Normal 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]
|
||||
}
|
||||
201
internal/releases/githubish/githubish_test.go
Normal file
201
internal/releases/githubish/githubish_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
27
internal/releases/githubsrc/githubsrc.go
Normal file
27
internal/releases/githubsrc/githubsrc.go
Normal 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)
|
||||
}
|
||||
122
internal/releases/gitlab/gitlab.go
Normal file
122
internal/releases/gitlab/gitlab.go
Normal 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++
|
||||
}
|
||||
}
|
||||
}
|
||||
182
internal/releases/gitlab/gitlab_test.go
Normal file
182
internal/releases/gitlab/gitlab_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
25
internal/releases/gitlabsrc/gitlabsrc.go
Normal file
25
internal/releases/gitlabsrc/gitlabsrc.go
Normal 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)
|
||||
}
|
||||
178
internal/releases/gittag/gittag.go
Normal file
178
internal/releases/gittag/gittag.go
Normal 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
Reference in New Issue
Block a user