Compare commits

..

32 Commits

Author SHA1 Message Date
AJ ONeal
d67ac8d9f2 feat(webicached,webid): add pgstore backend and --pg flag
Adds an optional PostgreSQL storage backend for webicached and webid.
When --pg is given a DSN, the daemon writes/reads classified assets to/from
Postgres instead of the legacy fsstore. Brings in pgx/v5 as a direct dependency.
2026-05-16 21:58:59 -06:00
AJ ONeal
b79c5529e1 chore(deps): upgrade pgx v5.8.0 → v5.9.2 2026-05-16 21:58:49 -06:00
AJ ONeal
ac59e4728e feat(webid): add HTTP API server (releases, resolve, installer scripts)
Serves the HTTP API for webinstall.dev:
- GET /api/releases/{pkg}.json — classified release list from fsstore
- GET /v1/resolve/{pkg} — resolve OS/arch/version to a specific asset
- GET /api/installers/{pkg}.sh — render installer shell script
- GET /api/installers/{pkg}.ps1 — render installer PowerShell script

Git-clone packages include git_tag and git_commit_hash in responses.
UA detection infers OS/arch from User-Agent when not query-specified.
2026-05-16 21:53:05 -06:00
AJ ONeal
59b2956d60 feat(webid): add UA detection and platform resolve packages 2026-05-16 21:53:05 -06:00
AJ ONeal
75bd1a3cf9 feat(resolver): add platform/version resolver for webid 2026-05-16 21:53:05 -06:00
AJ ONeal
89e12d22f3 feat(render): add installer script renderer for webid 2026-05-16 21:53:05 -06:00
AJ ONeal
23064a6db7 fix(webicached): don't treat 0-asset packages as perpetually stale
Packages that produce no classifiable assets (e.g. mariadb-galera with
the galera asset_filter) were being refetched every batch because
!hasAssets marked them stale regardless of timestamp. The hasAssets
condition was intended for the startup case (classified from empty
rawcache), but those packages are already caught by t.IsZero() on first
run. Respect the timestamp for 0-asset results as for any other package.
2026-05-16 21:53:01 -06:00
AJ ONeal
bf5cafac18 feat(ffmpeg): add ffmpegdist classifier for eugeneware/ffmpeg-static
Upstream uses non-standard OS/arch names (x64, ia32, win32, arm) and
ships both bare binaries and .gz-compressed copies. classifyFFmpegDist
maps those to canonical names and keeps only bare binaries.

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

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

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

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

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

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

Three fixes:
- build-classifier v1.0.4: CYGWIN/MINGW → windows in termsToTarget
- ua-detect.js: same fix for the Node server's UA detection path
- builds-cacher.js: default hostTarget.libc to 'libc' when unset —
  termsToTarget omits libc for plain UAs, causing triplets like
  'linux-x86_64-undefined' that never matched cache entries
2026-05-14 17:06:06 -06:00
AJ ONeal
c57757a027 fix(docs): fix typos in sshd-prohibit-password, ssh-harden, and ssh-adduser 2026-05-14 16:00:57 -06:00
AJ ONeal
0bf485dcc4 ref!: add releases.conf (replacing releases.js) 2026-05-14 15:11:56 -06:00
AJ ONeal
9f28505af7 ref: delete unreachable upstream-fetcher modules
Stacked on the modifications PR. Now that no live code path references
the per-package fetchers, the shared HTTP/parsing helpers, the
in-process normalizer, or the example template, delete them. Pure
deletion — no behavior change.

- ~93 per-package <pkg>/releases.js fetcher modules.
- _common/{brew,fetcher,git-tag,gitea,github,github-source,
  githubish,githubish-source}.js shared HTTP/parsing helpers.
- _webi/normalize.js in-process normalization layer (cache files
  arrive normalized from webicached).
- _example/releases.js fetcher template for new packages.

The Go cache daemon (webicached) is now the sole producer of release
metadata; the Node process never makes an upstream request.
2026-05-08 16:31:59 -06:00
AJ ONeal
46508b2ec2 ref: drop unreachable upstream-fetcher references and fix classify-one cache path
The Node server's read path now goes through ~/.cache/webi/legacy/ only
(see #1075). A handful of supporting tools and tests still carried
references to the obsolete upstream-fetcher modules and the old
year-month cache layout. Update them in place; the actual deletion of
the orphaned modules follows in #1076.

- _webi/classify-one.js — read from ~/.cache/webi/legacy/<pkg>.json
  instead of ../_cache/<yearMonth>/<pkg>.json.
- _webi/builds-cacher-test.js — drop the bc.freshenRandomPackage(...)
  call; the freshener was removed when fetching went away.
- _webi/builds.js — drop the //Releases: Releases stub comment.
- _webi/lint-builds.js — drop two now-unused require()s.
- _webi/test.js — adjust a single reference to the post-cleanup shape.
2026-05-08 16:31:18 -06:00
AJ ONeal
70067a620e fix(api): only apply libc filter when caller pinned a meaningful libc
filterReleases unconditionally rejected libc=musl entries unless the
host was libc=musl, even when the caller never specified a libc in
the request. serve-releases.js defaults the libc parameter to 'libc'
(the catch-all glibc-host bucket the installer-side resolver uses),
so the website's release table and the WEBI_RELEASES probe were both
stripped of every musl entry that the cache actually contained — even
though the installer would happily consider those builds on a glibc
host (its waterfall is [none, gnu, musl, libc]).

Treat libc='libc' (and missing) as 'no preference' so the filter only
runs when the caller pinned a real libc (musl, gnu, msvc, etc.).
Specific-libc queries (?libc=musl, ?libc=gnu) still filter exactly as
before.
2026-05-08 11:48:24 -06:00
AJ ONeal
e221dafd69 chore(build-classifier): bump to v1.0.3 for parsePrefix fix
Lexver.parsePrefix now produces a true string-prefix of parseVersion
when the input has a release suffix (e.g. '1.0.0-beta',
'2025.11.15-15.42.45'). Unblocks pinned-version queries with a
non-trivial release suffix, including the 'webi zig.vim' alias chain
which redirects through 'vim-zig@2025.11.15-15.42.45' at install time.

See webinstall/webi-build-classifier#22.
2026-05-08 11:48:24 -06:00
AJ ONeal
07ad89ce46 ref(builds-cacher): cache-only Node server, no fetches or writes
Make _webi/builds-cacher.js and _webi/transform-releases.js read
exclusively from ~/.cache/webi/legacy/<name>.json and remove every code
path that fetched from upstream or wrote to disk. The Go cache daemon
(webicached) is now the sole writer; the Node server is a thin reader.

builds-cacher.js:
- Resolve cache files via Os.homedir() + '/.cache/webi/legacy/' instead
  of the cacheDir argument. Drop the 'caches' constructor parameter.
- Remove getLatestBuilds / getLatestBuildsInner — they require()d
  per-package releases.js modules, fetched upstream, and wrote
  <yyyy-mm>/<name>.json + .updated.txt to disk.
- Remove the process.nextTick stale-refresh hook in _doGetPackages.
  Cold reads return what's on disk; if the file is missing, return
  empty meta instead of fetching.
- Remove freshenRandomPackage and its supporting state
  (bc._staleNames, bc._freshenTimeout, bc._staleAge). The hourly
  background freshener competed with webicached for the same files.
- In getProjectTypeByEntry, decide selfhosted vs valid by probing for
  the cache file rather than require()-ing releases.js. Drop the
  not_found / 'PROBLEM/SOLUTION/npm clean-install' diagnostic in
  getProjectsByType — the cache-file probe replaces the module-load
  failure mode.

transform-releases.js:
- Remove Releases.get and the _normalize import. Replace
  getCachedReleases's fetch+race+stale-age machinery with a single
  Fs.readFile of ~/.cache/webi/legacy/<pkg>.json.
- Drop the in-process version re-sort in createFormatsSorter; the
  cache file is already version-sorted by webicached, so the sorter
  only re-orders within the same version.

No callers' public signatures change. Every other file is untouched —
the per-package releases.js modules, _common/*.js fetchers, and
_webi/normalize.js still exist on disk but are no longer reachable
from the request path.
2026-05-08 11:48:24 -06:00
AJ ONeal
2617520555 doc(pg): update postgres and psql docs 2026-05-07 04:04:20 -06:00
AJ ONeal
db312a98fc feat: add pg-essentials 2026-05-07 03:58:53 -06:00
AJ ONeal
cd832d024c ref(setcap-netbind): update variable names as per our conventions 2026-05-07 03:22:14 -06:00
AJ ONeal
a57faa74f3 fix(setcap-netbind): don't quote possibly-empty sudo command 2026-05-07 03:22:14 -06:00
AJ ONeal
da10371c71 chore(build-classifier): bump to v1.0.2 + maybeInstallable filename fix
Pulls in webinstall/webi-build-classifier#21 (merged 2026-05-07,
SHA 574eff5) and the host-target x64/win32 fix from #20 (SHA 71c0768)
that landed alongside it.

#21 fixes `maybeInstallable` rejecting any package version ending in
`.1` whose download URL is a GitHub source-archive endpoint
(`/tarball/vX.Y.1` or `/zipball/vX.Y.1`). Without it, this PR's
`_enumerateTriplets` priority fix is undermined: even after picking
the correct posix_2017 triplet, the newest version (e.g. serviceman
v1.0.1) is silently dropped by the classifier and the resolver falls
back to v1.0.0.

Confirmed on next.webi.sh after deploying this branch with the bumped
submodule: `serviceman@stable.sh` now resolves to v1.0.1/zip on macOS
arm64 (was v1.0.0/zip with the pre-rebase pre-fix submodule).
2026-05-07 00:22:02 -06:00
AJ ONeal
d0b0d54d18 fix(builds-cacher): enumerate specific OS/arch before ANYOS/ANYARCH
In _enumerateTriplets, the order of `oses` and `arches` was
ANYOS/ANYARCH first, specific second. This caused findMatchingPackages
to pick the most-generic triplet (e.g. ANYOS-ANYARCH-none) before
trying specific OS triplets — and packages that have a wildcard git
fallback alongside per-platform binaries would resolve to the git
source instead of the binary, even when the client never asked for
git as an unpacker.

Reverse the order so specific platforms win:
  - oses: hostTarget.os, posix_2017, posix_2024, ANYOS
  - arches: arches.concat(['ANYARCH'])

Concrete example: serviceman has both posix_2017/*/tar.gz and
*/*/git in the cache. Pre-fix, findMatchingPackages picks
ANYOS-ANYARCH-none (containing only the .git entry). The .git gate
in getSortedFormats then correctly excludes git from format
candidates, but the chosen triplet has nothing else, so selectPackage
falls through to packages[0] = git entry. Post-fix,
findMatchingPackages picks posix_2017-ANYARCH-none first (containing
[tar.gz, zip]), and selectPackage returns tar.gz.
2026-05-07 00:22:02 -06:00
AJ ONeal
2d1c082e30 feat(webi): probe zst as unpacker; properly probe formats in webi-pwsh
webi/webi.sh: detect unzstd/zstd alongside the existing git/unxz/
unzip/tar probes. Sends `?formats=...,zst` when zstd is available so
the server can pick a .tar.zst build only on hosts that can extract
it.

webi/webi-pwsh.ps1: replace the hardcoded `formats=zip,exe,tar,git`
TODO with real Get-Command probing for git, zstd, and 7z.
2026-05-06 23:23:02 -06:00
AJ ONeal
28cd129a23 ref(builds-cacher): gate .git on client-provided unpacker
`.git` was pushed unconditionally into getSortedFormats's candidate
ext list, while sibling unpacker formats (.tar.xz, .tar.zst, .zip,
.7z) are gated on whether the caller's `formats` argument signals
the client has the corresponding tool.

Make `.git` consistent: only add it to the candidate list when
formats includes 'git'. The default WEBI_FORMATS ('tar,exe,zip,xz,
dmg') doesn't include git, so the change is a no-op for the
current default. Clients that want git-source packages installed
can pass `?formats=tar,exe,zip,xz,dmg,git` (or set the equivalent
in a future client-side probe).

For packages that have only a git-source asset (e.g. some vim
plugins), the existing fallback to `packages[0]` still returns the
git entry — behavior unchanged. The only observable change is for
packages where both a binary and a git fallback exist for the same
triplet: previously the git entry could win over the binary; now
it wins only when the client opts in.
2026-05-06 23:05:11 -06:00
AJ ONeal
a5c8fc28a4 fix(builds-cacher): coalesce concurrent getPackages for same name
When two HTTP requests arrived simultaneously for the same package on
a cold in-memory cache (bc._caches[name] === undefined), they would
both:
  1. Enter getPackages, see no warm cache,
  2. Read and parse the same _cache/{pkg}.json independently,
  3. Both call transformAndUpdate, which re-runs _classify on every
     build.

The first call populates bc._targetsByBuildIdCache as it classifies.
The second call then hits the cache shortcut at the top of _classify
and skips the projInfo.oses/arches/libcs/formats/triplets
accumulation block entirely. Its projInfo ends up with empty tracking
arrays (because the prior Object.assign(projInfo, meta) reset them),
and that poisoned projInfo gets written to bc._caches[name],
overwriting the first call's good cache.

After this, every subsequent installer request returns errPackage
because serve-installer.js checks projInfo.oses.includes(hostTarget.os)
— and projInfo.oses is now [].

Fix: a per-name in-flight promise. Concurrent callers for a cold
package share a single load. Calls for warm packages take the fast
path with no synchronization.

Reproduced reliably with Promise.all of 6 cold-cache calls for the
same package: 1/6 succeeded before the fix, 6/6 after. On staging at
HTTP concurrency=12, installer cand-only-errors went from 24-229
(cause-dependent) to 0.
2026-05-06 11:45:26 -06:00
327 changed files with 15106 additions and 5335 deletions

16
.gitignore vendored
View File

@@ -3,6 +3,16 @@ install-*.sh
install-*.bat
install-*.ps1
# Go build outputs (from go run/build in repo root)
/classify
/e2etest
/fetchraw
/inspect
/uaparse
/webicached
/zigtest
/distributables.csv
# local config
.env.*
*.env
@@ -18,7 +28,13 @@ node_modules/
*.bak
*.bak.*
# agent session files
agents/
# other
.DS_Store
desktop.ini
.directory
LIVE_cache
/webid
bin/

120
AGENTS.md
View File

@@ -370,3 +370,123 @@ Commit messages: `feat(<pkg>): add installer`, `fix(<pkg>): update install.sh`,
`arch`, or `ext` explicitly in `releases.js`.
- **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
```

View File

@@ -1,62 +0,0 @@
'use strict';
let Fetcher = require('../_common/fetcher.js');
/**
* Gets releases from 'brew'.
*
* @param {null} _
* @param {string} formula
* @returns {Promise<any>}
*/
async function getDistributables(_, formula) {
if (!formula) {
return Promise.reject('missing formula for brew');
}
let resp;
try {
let url = `https://formulae.brew.sh/api/formula/${formula}.json`;
resp = await Fetcher.fetch(url, {
headers: { Accept: 'application/json' },
});
} catch (e) {
/** @type {Error & { code: string, response: { status: number, body: string } }} */ //@ts-expect-error
let err = e;
if (err.code === 'E_FETCH_RELEASES') {
err.message = `failed to fetch '${formula}' release data from 'brew': ${err.response.status} ${err.response.body}`;
}
throw e;
}
let body = JSON.parse(resp.body);
var ver = body.versions.stable;
var dl = (
body.bottle.stable.files.high_sierra || body.bottle.stable.files.catalina
).url.replace(new RegExp(ver.replace(/\./g, '\\.'), 'g'), '{{ v }}');
return [
{
version: ver,
download: dl.replace(/{{ v }}/g, ver),
},
].concat(
body.versioned_formulae.map(
/** @param {String} f */
function (f) {
var ver = f.replace(/.*@/, '');
return {
version: ver,
download: dl,
};
},
),
);
}
module.exports = getDistributables;
if (module === require.main) {
getDistributables(null, 'mariadb').then(function (all) {
console.info(JSON.stringify(all, null, 2));
});
}

View File

@@ -1,56 +0,0 @@
'use strict';
let Fetcher = module.exports;
/**
* @typedef ResponseSummary
* @prop {Boolean} ok
* @prop {Headers} headers
* @prop {Number} status
* @prop {String} body
*/
/**
* @param {String} url
* @param {RequestInit} opts
* @returns {Promise<ResponseSummary>}
*/
Fetcher.fetch = async function (url, opts) {
let resp = await fetch(url, opts);
let summary = Fetcher.throwIfNotOk(resp);
return summary;
};
/**
* @param {Response} resp
* @returns {Promise<ResponseSummary>}
*/
Fetcher.throwIfNotOk = async function (resp) {
let text = await resp.text();
if (!resp.ok) {
let headers = Array.from(resp.headers);
console.error('[Fetcher] error: Response Headers:', headers);
console.error('[Fetcher] error: Response Text:', text);
let err = new Error(`fetch was not ok`);
Object.assign({
status: 503,
code: 'E_FETCH_RELEASES',
response: {
status: resp.status,
headers: headers,
body: text,
},
});
throw err;
}
let summary = {
ok: resp.ok,
headers: resp.headers,
status: resp.status,
body: text,
};
return summary;
};

View File

@@ -1,218 +0,0 @@
'use strict';
require('dotenv').config({ path: '.env' });
var Crypto = require('crypto');
var util = require('util');
var exec = util.promisify(require('child_process').exec);
var Fs = require('node:fs/promises');
var FsSync = require('node:fs');
var Path = require('node:path');
var repoBaseDir = process.env.REPO_BASE_DIR || '';
if (!repoBaseDir) {
repoBaseDir = Path.resolve('./_repos');
// for stderr
console.error(`[Warn] REPO_BASE_DIR= not set, ${repoBaseDir}`);
}
var Repos = {};
Repos.clone = async function (repoPath, gitUrl) {
let uuid = Crypto.randomUUID();
let tmpPath = `${repoPath}.${uuid}.tmp`;
let bakPath = `${repoPath}.${uuid}.dup`;
await exec(`git clone --bare --filter=tree:0 ${gitUrl} ${tmpPath}`);
try {
FsSync.accessSync(repoPath);
return;
} catch (e) {
if (e.code !== 'ENOENT') {
throw e;
}
}
// sync to avoid race conditions
try {
FsSync.renameSync(repoPath, bakPath);
} catch (e) {
if (e.code !== 'ENOENT') {
throw e;
}
}
FsSync.renameSync(tmpPath, repoPath);
await Fs.rm(bakPath, { force: true, recursive: true });
};
Repos.checkExists = async function (repoPath) {
let err = await Fs.access(repoPath).catch(Object);
if (!err) {
return true;
}
if (err.code !== 'ENOENT') {
throw err;
}
return false;
};
Repos.fetch = async function (repoPath) {
await exec(`git --git-dir=${repoPath} fetch`);
};
Repos.getTags = async function (repoPath) {
var { stdout } = await exec(`git --git-dir=${repoPath} tag`);
var rawTags = stdout.trim().split('\n');
let tags = [];
for (let tag of rawTags) {
// ex: v1, v2, v1.1, 1.1.0-rc
let maybeVersionRe = /^(v\d+|v?\d+\.\d+)/;
let maybeVersion = maybeVersionRe.test(tag);
if (maybeVersion) {
tags.push(tag);
}
}
tags = tags.reverse();
return tags;
};
Repos.getTipInfo = async function (repoPath) {
var { stdout } = await exec(
`git --git-dir=${repoPath} rev-parse --abbrev-ref HEAD`,
);
var branch = stdout.trim();
var info = await Repos.getCommitInfo(repoPath, 'HEAD');
info.commitish = branch;
return info;
};
Repos.getCommitInfo = async function (repoPath, commitish) {
var { stdout } = await exec(
`git --git-dir=${repoPath} log -1 --format="%h %H %ad %cd" --date=iso-strict ${commitish}`,
);
stdout = stdout.trim();
var commitParts = stdout.split(/\s+/g);
return {
commitish: commitish,
commit_id: `${commitParts[0]}`,
commit: `${commitParts[1]}`,
date: commitParts[2],
date_authored: commitParts[3],
};
};
/**
* Lists GitHub Releases (w/ uploaded assets)
*
* @param request
* @param {string} owner
* @param {string} gitUrl
* @returns {PromiseLike<any> | Promise<any>}
*/
async function getDistributables(gitUrl) {
let all = {
releases: [],
download: '',
};
let repoName = gitUrl.split('/').pop();
repoName = repoName.replace(/\.git$/, '');
let repoPath = `${repoBaseDir}/${repoName}.git`;
let isCloned = await Repos.checkExists(repoPath);
if (!isCloned) {
await Repos.clone(repoPath, gitUrl);
} else {
await Repos.fetch(repoPath);
}
let commitInfos = [];
let tags = await Repos.getTags(repoPath);
for (let tag of tags) {
let commitInfo = await Repos.getCommitInfo(repoPath, tag);
Object.assign(commitInfo, { version: tag, channel: '' });
commitInfos.push(commitInfo);
}
{
let tipInfo = await Repos.getTipInfo(repoPath);
// "2024-01-01T00:00:00-05:00" => "2024-01-01T05:00:00"
let date = new Date(tipInfo.date);
// "2024-01-01T05:00:00" => "v2024.01.01-05.00.00"
let version = date.toISOString();
// strip '.000Z'
version = version.replace(/\.\d+Z/, '');
version = version.replace(/[:\-]/g, '.');
version = version.replace(/T/, '-');
Object.assign(tipInfo, { version: `v${version}`, channel: '' });
if (commitInfos.length > 1) {
tipInfo.channel = 'beta';
}
commitInfos.push(tipInfo);
}
let releases = [];
for (let commitInfo of commitInfos) {
let version = commitInfo.version.replace(/^v/, '');
let date = new Date(commitInfo.date);
let isoDate = date.toISOString();
isoDate = isoDate.replace(/\.\d+Z/, '');
// tags and HEAD qualify for '--branch <branchish>'
let branch = commitInfo.commitish;
let rel = {
name: `${repoName}-v${version}`,
version: version,
git_tag: branch,
git_commit_hash: commitInfo.commit_id,
lts: false,
channel: commitInfo.channel,
date: isoDate,
os: '*',
arch: '*',
ext: 'git',
download: gitUrl,
};
releases.push(rel);
}
all.releases = releases;
return all;
}
module.exports = getDistributables;
if (module === require.main) {
(async function main() {
let testRepos = [
// just a few tags, and a different HEAD
'https://github.com/tpope/vim-commentary.git',
// no tags, just HEAD
'https://github.com/ziglang/zig.vim.git',
// many, many tags
//'https://github.com/dense-analysis/ale.git',
];
for (let url of testRepos) {
let all = await getDistributables(url);
all = require('../_webi/normalize.js')(all);
console.info(JSON.stringify(all, null, 2));
}
})()
.then(function () {
process.exit(0);
})
.catch(function (err) {
console.error(err);
});
}

View File

@@ -1,47 +0,0 @@
'use strict';
var GitHubish = require('./githubish.js');
/**
* Lists Gitea Releases (w/ uploaded assets)
*
* @param {null} _ - deprecated
* @param {String} owner
* @param {String} repo
* @param {String} baseurl
* @param {String} [username]
* @param {String} [token]
*/
async function getDistributables(
_,
owner,
repo,
baseurl,
username = '',
token = '',
) {
baseurl = `${baseurl}/api/v1`;
let all = await GitHubish.getDistributables({
owner,
repo,
baseurl,
username,
token,
});
return all;
}
module.exports = getDistributables;
if (module === require.main) {
getDistributables(
null,
'root',
'pathman',
'https://git.rootprojects.org',
'',
'',
).then(function (all) {
console.info(JSON.stringify(all, null, 2));
});
}

View File

@@ -1,40 +0,0 @@
'use strict';
require('dotenv').config({ path: '.env' });
let GitHubSource = module.exports;
let GitHubishSource = require('./githubish-source.js');
/**
* @param {Object} opts
* @param {String} opts.owner
* @param {String} opts.repo
* @param {String} [opts.baseurl]
* @param {String} [opts.username]
* @param {String} [opts.token]
*/
GitHubSource.getDistributables = async function ({
owner,
repo,
baseurl = 'https://api.github.com',
username = process.env.GITHUB_USERNAME || '',
token = process.env.GITHUB_TOKEN || '',
}) {
let all = await GitHubishSource.getDistributables({
owner,
repo,
baseurl,
username,
token,
});
return all;
};
if (module === require.main) {
GitHubSource.getDistributables(null, 'BeyondCodeBootcamp', 'DuckDNS.sh').then(
function (all) {
console.info(JSON.stringify(all, null, 2));
},
);
}

View File

@@ -1,42 +0,0 @@
'use strict';
require('dotenv').config({ path: '.env' });
let GitHubish = require('./githubish.js');
/**
* Lists GitHub Releases (w/ uploaded assets)
*
* @param {null} _ - deprecated
* @param {String} owner
* @param {String} repo
* @param {String} [baseurl]
* @param {String} [username]
* @param {String} [token]
*/
module.exports = async function (
_,
owner,
repo,
baseurl = 'https://api.github.com',
username = process.env.GITHUB_USERNAME || '',
token = process.env.GITHUB_TOKEN || '',
) {
let all = await GitHubish.getDistributables({
owner,
repo,
baseurl,
username,
token,
});
return all;
};
let GitHub = module.exports;
GitHub.getDistributables = module.exports;
if (module === require.main) {
GitHub.getDistributables(null, 'BurntSushi', 'ripgrep').then(function (all) {
console.info(JSON.stringify(all, null, 2));
});
}

View File

@@ -1,174 +0,0 @@
'use strict';
let Fetcher = require('../_common/fetcher.js');
let GitHubishSource = module.exports;
/**
* Lists GitHub-Like Releases (source tarball & zip)
*
* @param {Object} opts
* @param {String} opts.owner
* @param {String} opts.repo
* @param {String} opts.baseurl
* @param {String} [opts.username]
* @param {String} [opts.token]
*/
GitHubishSource.getDistributables = async function ({
owner,
repo,
baseurl,
username = '',
token = '',
}) {
if (!owner) {
throw new Error('missing owner for repo');
}
if (!repo) {
throw new Error('missing repo name');
}
if (!baseurl) {
throw new Error('missing baseurl');
}
let url = `${baseurl}/repos/${owner}/${repo}/releases`;
let opts = {
headers: {
'Content-Type': 'appplication/json',
},
};
if (token) {
let userpass = `${username}:${token}`;
let basicAuth = btoa(userpass);
Object.assign(opts.headers, {
Authorization: `Basic ${basicAuth}`,
});
}
let resp;
try {
resp = await Fetcher.fetch(url, opts);
} catch (e) {
/** @type {Error & { code: string, response: { status: number, body: string } }} */ //@ts-expect-error
let err = e;
if (err.code === 'E_FETCH_RELEASES') {
err.message = `failed to fetch '${baseurl}' (githubish-source, user '${username}) release data: ${err.response.status} ${err.response.body}`;
}
throw e;
}
let gHubResp = JSON.parse(resp.body);
let all = {
/** @type {Array<BuildInfo>} */
releases: [],
download: '',
};
for (let release of gHubResp) {
let dists = GitHubishSource.releaseToDistributables(release);
for (let dist of dists) {
let updates =
await GitHubishSource.followDistributableDownloadAttachment(dist);
Object.assign(dist, updates);
all.releases.push(dist);
}
}
return all;
};
/**
* @typedef BuildInfo
* @prop {String} [name] - name to use instead of filename for hash urls
* @prop {String} version
* @prop {String} [_version]
* @prop {String} [arch]
* @prop {String} channel
* @prop {String} date
* @prop {String} download
* @prop {String} [ext]
* @prop {String} [_filename]
* @prop {String} [hash]
* @prop {String} [libc]
* @prop {Boolean} [_musl]
* @prop {Boolean} [lts]
* @prop {String} [size]
* @prop {String} os
*/
/**
* @param {any} ghRelease - TODO
* @returns {Array<BuildInfo>}
*/
GitHubishSource.releaseToDistributables = function (ghRelease) {
let ghTag = ghRelease['tag_name']; // TODO tags aren't always semver / sensical
let lts = /(\b|_)(lts)(\b|_)/.test(ghRelease['tag_name']);
let channel = 'stable';
if (ghRelease['prerelease']) {
channel = 'beta';
}
let date = ghRelease['published_at'] || '';
date = date.replace(/T.*/, '');
let urls = [ghRelease.tarball_url, ghRelease.zipball_url];
/** @type {Array<BuildInfo>} */
let dists = [];
for (let url of urls) {
dists.push({
name: '',
version: ghTag,
lts: lts,
channel: channel,
date: date,
os: '*',
arch: '*',
libc: '',
ext: '',
download: url,
});
}
return dists;
};
/**
* @param {BuildInfo} dist
*/
GitHubishSource.followDistributableDownloadAttachment = async function (dist) {
let abortCtrl = new AbortController();
let resp = await fetch(dist.download, {
method: 'HEAD',
redirect: 'follow',
signal: abortCtrl.signal,
});
let headers = Object.fromEntries(resp.headers);
// Workaround for bug where METHOD changes to GET
abortCtrl.abort();
await resp.text().catch(function (err) {
if (err.name !== 'AbortError') {
throw err;
}
});
// ex: content-disposition: attachment; filename=BeyondCodeBootcamp-DuckDNS.sh-v1.0.1-0-ga2f4bde.zip
// => BeyondCodeBootcamp-DuckDNS.sh-v1.0.1-0-ga2f4bde.zip
let name = headers['content-disposition'].replace(
/.*filename=([^;]+)(;|$)/,
'$1',
);
let download = resp.url;
return { name, download };
};
if (module === require.main) {
GitHubishSource.getDistributables({
owner: 'BeyondCodeBootcamp',
repo: 'DuckDNS.sh',
baseurl: 'https://api.github.com',
}).then(function (all) {
console.info(JSON.stringify(all, null, 2));
});
}

View File

@@ -1,137 +0,0 @@
'use strict';
let Fetcher = require('../_common/fetcher.js');
/**
* @typedef DistributableRaw
* @prop {String} name
* @prop {String} version
* @prop {Boolean} lts
* @prop {String} [channel]
* @prop {String} date
* @prop {String} os
* @prop {String} arch
* @prop {String} ext
* @prop {String} download
*/
let GitHubish = module.exports;
/**
* Lists GitHub-Like Releases (w/ uploaded assets)
*
* @param {Object} opts
* @param {String} opts.owner
* @param {String} opts.repo
* @param {String} opts.baseurl
* @param {String} [opts.username]
* @param {String} [opts.token]
*/
GitHubish.getDistributables = async function ({
owner,
repo,
baseurl,
username = '',
token = '',
}) {
if (!owner) {
throw new Error('missing owner for repo');
}
if (!repo) {
throw new Error('missing repo name');
}
if (!baseurl) {
throw new Error('missing baseurl');
}
let url = `${baseurl}/repos/${owner}/${repo}/releases`;
let opts = {
headers: {
'Content-Type': 'appplication/json',
},
};
if (token) {
let userpass = `${username}:${token}`;
let basicAuth = btoa(userpass);
Object.assign(opts.headers, {
Authorization: `Basic ${basicAuth}`,
});
}
let resp;
try {
resp = await Fetcher.fetch(url, opts);
} catch (e) {
/** @type {Error & { code: string, response: { status: number, body: string } }} */ //@ts-expect-error
let err = e;
if (err.code === 'E_FETCH_RELEASES') {
err.message = `failed to fetch '${baseurl}' (githubish, user '${username}) release data: ${err.response.status} ${err.response.body}`;
}
throw e;
}
let gHubResp = JSON.parse(resp.body);
let all = {
/** @type {Array<DistributableRaw>} */
releases: [],
// todo make this ':baseurl' + ':releasename'
download: '',
};
try {
gHubResp.forEach(transformReleases);
} catch (e) {
/** @type {Error & { code: string, response: { status: number, body: string } }} */ //@ts-expect-error
let err = e;
console.error(err.message);
console.error('Error Headers:', resp.headers);
console.error('Error Body:', resp.body);
let msg = `failed to transform releases from '${baseurl}' with user '${username}'`;
throw new Error(msg);
}
/**
* @param {any} release - TODO
*/
function transformReleases(release) {
for (let asset of release['assets']) {
let name = asset['name'];
let date = release['published_at']?.replace(/T.*/, '');
let download = asset['browser_download_url'];
// TODO tags aren't always semver / sensical
let version = release['tag_name'];
let channel;
if (release['prerelease']) {
// -rcX, -preview, -beta, etc will be checked in _webi/normalize.js
channel = 'beta';
}
let lts = /(\b|_)(lts)(\b|_)/.test(release['tag_name']);
all.releases.push({
name: name,
version: version,
lts: lts,
channel: channel,
date: date,
os: '', // will be guessed by download filename
arch: '', // will be guessed by download filename
ext: '', // will be normalized
download: download,
});
}
}
return all;
};
if (module === require.main) {
GitHubish.getDistributables({
owner: 'BurntSushi',
repo: 'ripgrep',
baseurl: 'https://api.github.com',
}).then(function (all) {
console.info(JSON.stringify(all, null, 2));
});
}

3
_example/releases.conf Normal file
View File

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

View File

@@ -1,37 +0,0 @@
'use strict';
var github = require('../_common/github.js');
var owner = 'BurntSushi';
var repo = 'ripgrep';
/******************************************************************************/
/** Note: Delete this Comment! **/
/** **/
/** Need a an example that filters out miscellaneous release files? **/
/** See `deno`, `gitea`, or `caddy` **/
/** **/
/******************************************************************************/
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);
// just select the first 5 for demonstration
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));
})();
}

View File

@@ -13,7 +13,6 @@ async function main() {
caches: CACHE_DIR,
installers: INSTALLERS_DIR,
});
bc.freshenRandomPackage(600 * 1000);
// let dirs = await bc.getProjectsByType();
// let projNames = Object.keys(dirs.valid);

View File

@@ -3,13 +3,16 @@
var BuildsCacher = module.exports;
let Fs = require('node:fs/promises');
let Os = require('node:os');
let Path = require('node:path');
let LEGACY_CACHE_DIR = Path.join(Os.homedir(), '.cache/webi/legacy');
let HostTargets = require('./build-classifier/host-targets.js');
let Lexver = require('./build-classifier/lexver.js');
let Triplet = require('./build-classifier/triplet.js');
var ALIAS_RE = /^alias: (\w+)$/m;
var ALIAS_RE = /^alias: ([\w.-]+)$/m;
var LEGACY_ARCH_MAP = {
'*': 'ANYARCH',
@@ -126,61 +129,8 @@ async function readFirstBytes(path) {
return str;
}
let promises = {};
async function getLatestBuilds(Releases, installersDir, cacheDir, name, date) {
console.info(`[INFO] getLatestBuilds: ${name}`);
if (!Releases) {
Releases = require(`${installersDir}/${name}/releases.js`);
}
// TODO update all releases files with module.exports.xxxx = 'foo';
if (!Releases.latest) {
Releases.latest = Releases;
}
let id = `${cacheDir}/${name}`;
if (!promises[id]) {
promises[id] = Promise.resolve();
}
promises[id] = promises[id].then(async function () {
return await getLatestBuildsInner(Releases, cacheDir, name, date);
});
return await promises[id];
}
async function getLatestBuildsInner(Releases, cacheDir, name, date) {
let data = await Releases.latest();
if (!date) {
date = new Date();
}
let isoDate = date.toISOString();
let yearMonth = isoDate.slice(0, 7);
// TODO hash file
let dataFile = `${cacheDir}/${yearMonth}/${name}.json`;
// TODO fsstat releases.js vs require-ing time as well
let tsFile = `${cacheDir}/${yearMonth}/${name}.updated.txt`;
let dirPath = Path.dirname(dataFile);
await Fs.mkdir(dirPath, { recursive: true });
let json = JSON.stringify(data, null, 2);
await Fs.writeFile(dataFile, json, 'utf8');
let seconds = date.valueOf();
let ms = seconds / 1000;
let msStr = ms.toFixed(3);
await Fs.writeFile(tsFile, msStr, 'utf8');
return data;
}
BuildsCacher.create = function ({ ALL_TERMS, installers, caches }) {
BuildsCacher.create = function ({ ALL_TERMS, installers }) {
let installersDir = installers;
let cacheDir = caches;
if (!ALL_TERMS) {
ALL_TERMS = Triplet.TERMS_PRIMARY_MAP;
@@ -195,9 +145,11 @@ BuildsCacher.create = function ({ ALL_TERMS, installers, caches }) {
bc._triplets = {};
bc._targetsByBuildIdCache = {};
bc._caches = {};
bc._staleAge = 15 * 60 * 1000;
bc._allFormats = {};
bc._allTriplets = {};
// Per-name lock: serializes cold-cache getPackages so concurrent
// callers can't corrupt bc._caches[name] via a transformAndUpdate race.
bc._inflight = {};
for (let term of TERMS_META) {
delete bc.orphanTerms[term];
@@ -216,19 +168,6 @@ BuildsCacher.create = function ({ ALL_TERMS, installers, caches }) {
let entries = await Fs.readdir(installersDir, { withFileTypes: true });
for (let entry of entries) {
let meta = await bc.getProjectTypeByEntry(entry);
if (meta.type === 'not_found') {
let err = meta.detail;
console.error('');
console.error('PROBLEM');
console.error(` ${err.message}`);
console.error('');
console.error('SOLUTION');
console.error(' npm clean-install');
console.error('');
throw new Error(
'[SANITY FAIL] should never have missing modules in prod',
);
}
dirs[meta.type][entry.name] = meta.detail;
}
@@ -297,19 +236,16 @@ BuildsCacher.create = function ({ ALL_TERMS, installers, caches }) {
return { type: 'alias', detail: link };
}
let releasesPath = Path.join(path, 'releases.js');
try {
void require(releasesPath);
} catch (err) {
if (err.code !== 'MODULE_NOT_FOUND') {
return { type: 'errors', detail: err };
}
if (err.message.includes(`Cannot find module '${releasesPath}'`)) {
return { type: 'selfhosted', detail: true };
}
return { type: 'not_found', detail: err };
let cacheFile = `${LEGACY_CACHE_DIR}/${entry.name}.json`;
let hasCacheFile = await Fs.access(cacheFile)
.then(function () {
return true;
})
.catch(function () {
return false;
});
if (!hasCacheFile) {
return { type: 'selfhosted', detail: true };
}
return { type: 'valid', detail: true };
@@ -317,14 +253,26 @@ BuildsCacher.create = function ({ ALL_TERMS, installers, caches }) {
// Typically a package is organized by release (ex: go has 1.20, 1.21, etc),
// but we will organize by the build (ex: go1.20-darwin-arm64.tar.gz, etc).
bc.getPackages = async function ({ Releases, name, date }) {
if (!date) {
date = new Date();
bc.getPackages = async function (args) {
let name = args.name;
let warm = bc._caches[name];
if (warm) {
return _doGetPackages(args);
}
let isoDate = date.toISOString();
let yearMonth = isoDate.slice(0, 7);
let dataFile = `${cacheDir}/${yearMonth}/${name}.json`;
let tsFile = `${cacheDir}/${yearMonth}/${name}.updated.txt`;
let inflight = bc._inflight[name];
if (inflight) {
return inflight;
}
let p = _doGetPackages(args).finally(function () {
delete bc._inflight[name];
});
bc._inflight[name] = p;
return p;
};
async function _doGetPackages({ name }) {
let dataFile = `${LEGACY_CACHE_DIR}/${name}.json`;
let tsFile = `${LEGACY_CACHE_DIR}/${name}.updated.txt`;
let tsDate;
{
@@ -378,7 +326,7 @@ BuildsCacher.create = function ({ ALL_TERMS, installers, caches }) {
}
}
if (!projInfo) {
projInfo = await getLatestBuilds(Releases, installersDir, cacheDir, name);
return meta;
}
let latestProjInfo = await BuildsCacher.transformAndUpdate(
name,
@@ -389,63 +337,8 @@ BuildsCacher.create = function ({ ALL_TERMS, installers, caches }) {
);
bc._caches[name] = latestProjInfo;
process.nextTick(async function () {
let now = date.valueOf();
let age = now - projInfo.updated;
let fresh = age < bc._staleAge;
if (fresh) {
return;
}
projInfo = await getLatestBuilds(Releases, installersDir, cacheDir, name);
let latestProjInfo = BuildsCacher.transformAndUpdate(
name,
projInfo,
meta,
date,
bc,
);
bc._caches[name] = latestProjInfo;
});
return projInfo;
};
// Makes sure that packages are updated once an hour, on average
bc._staleNames = [];
bc._freshenTimeout = null;
bc.freshenRandomPackage = async function (minDelay) {
if (!minDelay) {
minDelay = 15 * 1000;
}
if (bc._staleNames.length === 0) {
let dirs = await bc.getProjectsByType();
bc._staleNames = Object.keys(dirs.valid);
bc._staleNames.sort(function () {
return 0.5 - Math.random();
});
}
let name = bc._staleNames.pop();
void (await bc.getPackages({
//Releases: Releases,
name: name,
date: new Date(),
}));
console.info(`[INFO] freshenRandomPackage: ${name}`);
let hour = 60 * 60 * 1000;
let delay = minDelay;
let spread = hour / bc._staleNames.length;
let seed = Math.random();
delay += seed * spread;
clearTimeout(bc._freshenTimeout);
bc._freshenTimeout = setTimeout(bc.freshenRandomPackage, delay);
bc._freshenTimeout.unref();
};
return latestProjInfo;
}
/**
* Given a list of acceptable formats, get the sorted list of of formats.
@@ -537,7 +430,10 @@ BuildsCacher.create = function ({ ALL_TERMS, installers, caches }) {
exts.push('.gz');
exts.push('.sh');
}
exts.push('.git');
let hasGit = formats.includes('git') || formats.includes('.git');
if (hasGit) {
exts.push('.git');
}
// Fallbacks
// (we include everything to bubble an extract error over not found)
@@ -646,29 +542,19 @@ BuildsCacher.create = function ({ ALL_TERMS, installers, caches }) {
return null;
}
for (let _triplet of triplets) {
let targetReleases = projInfo.releasesByTriplet[_triplet];
if (!targetReleases) {
continue;
}
// Version-first iteration, not triplet-first: take the newest
// version even when its only build lives in a fallback triplet
// (e.g. serviceman v1.0.1 only exists at posix_2017-ANYARCH-none).
for (let lexver of projInfo.lexvers) {
let ver = projInfo.lexversMap[lexver] || lexver;
let versions = Object.keys(targetReleases);
//console.log('dbg: targetRelease versions', versions);
let lexvers = [];
for (let version of versions) {
let lexPrefix = Lexver.parseVersion(version);
lexvers.push(lexPrefix);
}
lexvers.sort();
lexvers.reverse();
// TODO get the other matchInfo props
for (let _triplet of triplets) {
let targetReleases = projInfo.releasesByTriplet[_triplet];
if (!targetReleases) {
continue;
}
// Make sure that these releases are the expected version
// (ex: jq1.7 => darwin-arm64-libc, jq1.6 => darwin-x86_64-libc)
for (let matchver of lexvers) {
let ver = projInfo.lexversMap[matchver] || matchver;
let packages = targetReleases[ver];
//console.log('dbg: packages', packages);
if (!packages) {
continue;
}
@@ -719,21 +605,33 @@ BuildsCacher.create = function ({ ALL_TERMS, installers, caches }) {
return triplets;
}
// Prefer platform-specific matches over ANYOS/ANYARCH fallbacks.
// This ensures e.g. darwin-aarch64-none matches before
// ANYOS-ANYARCH-none (.git source URLs from old releases).
let oses = [];
if (hostTarget.os === 'windows') {
oses = ['ANYOS', 'windows'];
oses = ['windows', 'ANYOS'];
} else if (hostTarget.os === 'android') {
oses = ['ANYOS', 'posix_2017', 'posix_2024', 'android', 'linux'];
oses = ['android', 'linux', 'posix_2017', 'posix_2024', 'ANYOS'];
} else {
oses = ['ANYOS', 'posix_2017', 'posix_2024', hostTarget.os];
oses = [hostTarget.os, 'posix_2017', 'posix_2024', 'ANYOS'];
}
let waterfall = HostTargets.WATERFALL[hostTarget.os] || {};
let arches = waterfall[hostTarget.arch] ||
HostTargets.WATERFALL.ANYOS[hostTarget.arch] || [hostTarget.arch];
arches = ['ANYARCH'].concat(arches);
let libcs = waterfall[hostTarget.libc] ||
HostTargets.WATERFALL.ANYOS[hostTarget.libc] || [hostTarget.libc];
arches = arches.concat(['ANYARCH']);
// 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 (libc === 'libc' && !libcs.includes('gnu')) {
libcs = ['none', 'gnu', 'musl', 'libc'];
}
for (let os of oses) {
for (let arch of arches) {
@@ -762,10 +660,17 @@ BuildsCacher.create = function ({ ALL_TERMS, installers, caches }) {
};
BuildsCacher._classify = function (bc, projInfo, build) {
/* jshint maxcomplexity: 25 */
let maybeInstallable = Triplet.maybeInstallable(projInfo, build);
if (!maybeInstallable) {
return null;
/* jshint maxcomplexity: 30 */
// Cache entries arrive pre-classified (os/arch/libc/ext set). Skip
// maybeInstallable for those — it false-rejects names ending in a
// version tag (`serviceman-v1.0.1`, `v1.0.1.zip`).
let cacheClassified =
build.os && build.arch && build.libc && build.ext;
if (!cacheClassified) {
let maybeInstallable = Triplet.maybeInstallable(projInfo, build);
if (!maybeInstallable) {
return null;
}
}
if (LEGACY_OS_MAP[build.os]) {
@@ -829,16 +734,26 @@ BuildsCacher._classify = function (bc, projInfo, build) {
bc.unknownTerms[term] = true;
}
// {NAME}.windows.x86_64v2.musl.exe
// windows-x86_64_v2-musl
// Skip termsToTarget for cache-classified entries: it false-flags
// e.g. .git URLs as os=ANYOS while the cache says os=posix_2017,
// and the mismatch check throws.
target = { triplet: '' };
try {
void Triplet.termsToTarget(target, projInfo, build, terms);
} catch (e) {
console.error(`PACKAGE FORMAT CHANGE for '${projInfo.name}':`);
console.error(e.message);
console.error(build);
return null;
if (cacheClassified) {
target.os = build.os;
target.arch = build.arch;
target.libc = build.libc;
target.vendor = build.vendor || 'unknown';
target.android = false;
target.unknownTerms = [];
} else {
try {
void Triplet.termsToTarget(target, projInfo, build, terms);
} catch (e) {
console.error(`PACKAGE FORMAT CHANGE for '${projInfo.name}':`);
console.error(e.message);
console.error(build);
return null;
}
}
target.triplet = `${target.arch}-${target.vendor}-${target.os}-${target.libc}`;

View File

@@ -15,11 +15,8 @@ let bc = BuildsCacher.create({
caches: CACHE_DIR,
installers: INSTALLERS_DIR,
});
bc.freshenRandomPackage(600 * 1000);
Builds.init = async function () {
bc.freshenRandomPackage(600 * 1000);
let dirs = await bc.getProjectsByType();
let projNames = Object.keys(dirs.valid);
@@ -27,7 +24,6 @@ Builds.init = async function () {
await Parallel.run(parallel, projNames, getAll);
async function getAll(name) {
void (await bc.getPackages({
//Releases: Releases,
name: name,
date: new Date(),
}));

View File

@@ -1,11 +1,14 @@
'use strict';
let Fs = require('node:fs/promises');
let Os = require('node:os');
let Path = require('node:path');
// let Builds = require('./builds.js');
let BuildsCacher = require('./builds-cacher.js');
let Triplet = require('./build-classifier/triplet.js');
let LEGACY_CACHE_DIR = Path.join(Os.homedir(), '.cache/webi/legacy');
async function main() {
let projName = process.argv[2];
if (!projName) {
@@ -36,16 +39,11 @@ async function main() {
arches: [],
libcs: [],
formats: [],
// TODO channels: [],
};
let installersDir = Path.join(__dirname, '..');
let Releases = require(`${installersDir}/${projName}/releases.js`);
if (!Releases.latest) {
Releases.latest = Releases;
}
let projInfo = await Releases.latest();
let dataFile = Path.join(LEGACY_CACHE_DIR, `${projName}.json`);
let json = await Fs.readFile(dataFile, 'utf8');
let projInfo = JSON.parse(json);
// let packages = await Builds.getPackage({ name: projName });
// console.log(packages);
@@ -70,9 +68,11 @@ async function main() {
console.log(`[DEBUG] transformed`);
let sample = transformed.packages.slice(0, 20);
console.log('packages:', sample, ':packages');
let firstTriplet = Object.keys(transformed.releasesByTriplet)[0];
let firstVersion = transformed.versions[0];
console.log(
'releasesByTriplet:',
transformed.releasesByTriplet['linux-x86_64-none'][transformed.versions[0]],
`releasesByTriplet[${firstTriplet}][${firstVersion}]:`,
transformed.releasesByTriplet[firstTriplet]?.[firstVersion],
':releasesByTriplet',
);
console.log('versions:', transformed.versions, ':versions');

View File

@@ -7,11 +7,6 @@
New-Item -Path "$Env:USERPROFILE\Downloads\webi" -ItemType Directory -Force | Out-Null
New-Item -Path "$Env:USERPROFILE\.local\bin" -ItemType Directory -Force | Out-Null
if ($null -eq $Env:WEBI_HOST -or $Env:WEBI_HOST -eq "") { $Env:WEBI_HOST = "https://webinstall.dev" }
$b_webi_ps1 = "$Env:USERPROFILE\.local\bin\webi-pwsh.ps1"
curl.exe -s -A "windows" "$Env:WEBI_HOST/packages/webi/webi-pwsh.ps1" | Out-File -Encoding utf8 "$b_webi_ps1"
if ($LASTEXITCODE -ne 0 -or -not (Test-Path "$b_webi_ps1") -or (Get-Item "$b_webi_ps1").Length -lt 100) {
Write-Error "error: failed to download '$Env:WEBI_HOST/packages/webi/webi-pwsh.ps1'"
exit 1
}
curl.exe -s -A "windows" "$Env:WEBI_HOST/packages/webi/webi-pwsh.ps1" -o "$Env:USERPROFILE\.local\bin\webi-pwsh.ps1"
Set-ExecutionPolicy -Scope Process Bypass
& "$b_webi_ps1" "{{ exename }}"
& "$Env:USERPROFILE\.local\bin\webi-pwsh.ps1" "{{ exename }}"

View File

@@ -139,8 +139,6 @@ async function main() {
console.info('');
}
bc.freshenRandomPackage(600 * 1000);
let rows = [];
let triples = [];
let valids = Object.keys(dirs.valid);

View File

@@ -1,259 +0,0 @@
'use strict';
// this may need customizations between packages
var osMap = {
macos: /(\b|_)(apple|os(\s_-)?x\b|mac|darwin|iPhone|iOS|iPad)/i,
linux: /(\b|_)(linux)/i,
freebsd: /(\b|_)(freebsd)/i,
windows: /(\b|_)(win|microsoft|msft)/i,
sunos: /(\b|_)(sun)/i,
aix: /(\b|_)(aix)/i,
};
var maps = {
oses: {},
arches: {},
libcs: {},
formats: {},
};
Object.keys(osMap).forEach(function (name) {
maps.oses[name] = true;
});
var formats = ['zip', 'xz', 'tar', 'pkg', 'msi', 'git', 'exe', 'dmg', 'git'];
formats.forEach(function (name) {
maps.formats[name] = true;
});
// evaluation order matters
// (i.e. otherwise x86 and x64 can cross match)
var arches = [
// arm64/aarch64 has very high specificity, so it comes first
'arm64',
// arm 7 is also generic aarch/arm/arm32
'armv7l',
// arm6 can run on armv7
'armv6l',
// amd64 is more likely and less often specified than arm64
'amd64',
'x86',
'ppc64le',
'ppc64',
's390x',
];
// Used for detecting system arch from package download url, for example:
//
// https://git.com/org/foo/releases/v0.7.9/foo-aarch64-linux-musl.tar.gz
// https://git.com/org/foo/releases/v0.7.9/foo-arm-linux-musleabihf.tar.gz
// https://git.com/org/foo/releases/v0.7.9/foo-armv7-linux-musleabihf.tar.gz
// https://git.com/org/foo/releases/v0.7.9/foo-x86_64-linux-musl.tar.gz
//
var archMap = {
arm64: /(\b|_)(aarch64|arm64)/i,
armv7l: /(\b|_)(arm32|arm[_\-]?v?7l?)/i,
armv6l: /(\b|_)(arm|aarch32|arm[_\-]?v?6l?)(\b|_)/i,
//amd64: /(amd.?64|x64|[_\-]64)/i,
amd64:
/(\b|_|amd|(dar)?win(dows)?|mac(os)?|linux|osx|x)64([_\-]?bit)?(\b|_)/i,
//x86: /(86)(\b|_)/i,
x86: /(\b|_|amd|(dar)?win(dows)?|mac(os)?|linux|osx|x)(86|32|i?386)([_\-]?bit)?(\b|_)/i,
ppc64le: /(\b|_)(ppc64le)/i,
ppc64: /(\b|_)(ppc64)(\b|_)/i,
s390x: /(\b|_)(s390x)/i,
};
arches.forEach(function (name) {
maps.arches[name] = true;
});
var libcs = ['none', 'musl', 'gnu', 'msvc', 'libc'];
libcs.forEach(function (name) {
maps.libcs[name] = true;
});
function normalize(all) {
/* jshint maxcomplexity:50 */
/* jshint maxdepth:10 */
var supported = {
oses: {},
arches: {},
libcs: {},
formats: {},
};
for (let rel of all.releases) {
rel.version = rel.version.replace(/^v/i, '');
if (!rel.name) {
rel.name = rel.download.replace(/.*\//, '');
}
if (!rel.os) {
rel.os = 'unknown';
let osNames = Object.keys(osMap);
for (let osName of osNames) {
let relName = rel.name || rel.download;
let osRegExp = osMap[osName];
let matches = osRegExp.test(relName);
if (matches) {
rel.os = osName;
break;
}
}
}
supported.oses[rel.os] = true;
if (!rel.arch) {
for (let arch of arches) {
let name = rel.name || rel.download;
let isArch = name.match(archMap[arch]);
if (isArch) {
rel.arch = arch;
break;
}
}
}
if (!rel.arch) {
if ('macos' === rel.os) {
rel.arch = 'amd64';
}
}
supported.arches[rel.arch] = true;
// note: depends on rel.os
if (!rel.libc) {
let isMusl;
let isMsvc;
let isStatic;
let isGnu;
// extra blocks to prevent copy pasta errors
{
let muslRe = /(\b|_)(musl)(\b|_)/i;
isMusl = muslRe.test(rel.download) || muslRe.test(rel.name);
}
{
let msvcRe = /(\b|_)(msvc)(\b|_)/i;
isMsvc = msvcRe.test(rel.download) || msvcRe.test(rel.name);
}
{
let staticRe = /(\b|_)(static)(\b|_)/i;
isStatic = staticRe.test(rel.download) || staticRe.test(rel.name);
}
{
let gnuRe = /(\b|_)(gnu|glibc|libc)(\b|_)/i;
isGnu = gnuRe.test(rel.download) || gnuRe.test(rel.name);
}
if (isMusl) {
// we specifically tag things that need musl++ in their own releases
rel.libc = 'none';
} else if (isStatic) {
rel.libc = 'none';
} else if (isGnu) {
rel.libc = 'gnu';
if (rel.os === 'windows') {
// windows gnu is static
rel.libc = 'none';
} else if (rel.os === 'darwin') {
// if glibc is required on macos, it'll be static
rel.libc = 'none';
}
} else if (isMsvc) {
rel.libc = 'msvc';
} else {
// The default is no requirement for any particular libc
// (Go, Zig, POSIX Shell, JS, etc)
// and hopefully we never have to worry about mingw and friends
rel.libc = 'none';
}
}
supported.libcs[rel.libc] = true;
var tarExt;
if (!rel.ext) {
// pkg-v1.0.tar.gz => ['gz', 'tar', '0', 'pkg-v1']
// pkg-v1.0.tar => ['tar', '0' ,'pkg-v1']
// pkg-v1.0.zip => ['zip', '0', 'pkg-v1']
var exts = (rel.name || rel.download).split('.');
if (1 === exts.length) {
// for bare releases in the format of foo-linux-amd64
rel.ext = 'exe';
}
exts = exts.reverse().slice(0, 2);
if ('tar' === exts[1]) {
rel.ext = exts.reverse().join('.');
tarExt = 'tar';
} else if ('tgz' === exts[0]) {
rel.ext = 'tar.gz';
tarExt = 'tar';
} else {
rel.ext = exts[0];
}
if (/\-|linux|mac|os[_\-]?x|arm|amd|86|64|mip/i.test(rel.ext)) {
// for bare releases in the format of foo.linux-amd64
rel.ext = 'exe';
}
}
supported.formats[tarExt || rel.ext] = true;
if (!rel.channel) {
// basically like this: (+.-_)(beta|rc)(0-9)(+.-_)
// matches:
// - v1.0-beta
// - v1.0-beta1.1
// - v1.0-beta-11
// won't match:
// - v1.0beta
// - v1.0-beta1b
let isBetaRe =
/(\b|_)(alpha|beta|dev|developer|prev|preview|rc)(\d+)(\b|_)/;
let isBeta = isBetaRe.test(rel.name);
if (isBeta) {
rel.channel = 'beta';
} else {
rel.channel = 'stable';
}
}
if (all.download) {
rel.download = all.download.replace(/{{ download }}/, rel.download);
}
}
all.oses = Object.keys(supported.oses).filter(function (name) {
return maps.oses[name];
});
all.arches = Object.keys(supported.arches).filter(function (name) {
return maps.arches[name];
});
all.libcs = Object.keys(supported.libcs).filter(function (name) {
return maps.libcs[name];
});
all.formats = Object.keys(supported.formats).filter(function (name) {
return maps.formats[name];
});
return all;
}
module.exports = normalize;
module.exports._debug = function (all) {
all = normalize(all);
all.releases = all.releases
.filter(function (r) {
return ['windows', 'macos', 'linux'].includes(r.os) && 'amd64' === r.arch;
})
.slice(0, 10);
return all;
};
// NOT in order of priority (which would be tar, xz, zip, ...)
module.exports.formats = formats;
module.exports.arches = arches;
module.exports.libcs = libcs;
module.exports.formatsMap = maps.formats;

View File

@@ -17,6 +17,7 @@ $Env:WEBI_HOST = 'https://webinstall.dev'
#$Env:PKG_NAME = node
#$Env:WEBI_VERSION = v12.16.2
#$Env:WEBI_GIT_TAG = 12.16.2
#$Env:WEBI_GIT_COMMIT_HASH =
#$Env:WEBI_PKG_URL = "https://.../node-....zip"
#$Env:WEBI_PKG_FILE = "node-v12.16.2-win-x64.zip"
#$Env:WEBI_PKG_PATHNAME = "node-v12.16.2-win-x64.zip"

View File

@@ -15,6 +15,7 @@ __bootstrap_webi() {
# TODO not sure if BUILD is the best name for this
#WEBI_BUILD=
#WEBI_GIT_TAG=
#WEBI_GIT_COMMIT_HASH=
#WEBI_LTS=
#WEBI_CHANNEL=
#WEBI_EXT=

View File

@@ -49,7 +49,7 @@ var baseurl = 'https://webinstall.dev';
var maxLen = 0;
console.info('');
console.info('Has the necessary files?');
['README.md', 'releases.js', 'install.sh', 'install.ps1']
['README.md', 'install.sh', 'install.ps1']
.map(function (node) {
maxLen = Math.max(maxLen, node.length);
return node;

View File

@@ -2,75 +2,30 @@
var Releases = module.exports;
var Fs = require('node:fs/promises');
var Os = require('node:os');
var path = require('path');
var _normalize = require('./normalize.js');
var cache = {};
//var staleAge = 5 * 1000;
//var expiredAge = 15 * 1000;
var staleAge = 5 * 60 * 1000;
var expiredAge = 15 * 60 * 1000;
let installerDir = path.join(__dirname, '..');
var LEGACY_CACHE_DIR = path.join(Os.homedir(), '.cache/webi/legacy');
Releases.get = async function (pkgdir) {
let get;
try {
get = require(`${pkgdir}/releases.js`);
// TODO update all releases files with module.exports.xxxx = 'foo';
if (!get.latest) {
get.latest = get;
}
} catch (e) {
let err = new Error('no releases.js for', pkgdir.split(/[\/\\]+/).pop());
err.code = 'E_NO_RELEASE';
throw err;
}
let all = await get.latest();
return _normalize(all);
};
// TODO needs a proper test, and more accurate (though perhaps far less simple) code
// Sort releases by ext preference and libc within the same version.
// The cache is already sorted by version (stable before beta, newest first),
// so we only re-order within the same version string.
function createFormatsSorter(formats) {
return function sortByVerExt(a, b) {
function lexver(semver) {
// v1.20.156 => 00001.00020.00156.zzzzz
// TODO BUG: v1.20.156-rc2 => 00001.00020.00156.rc2zz
var parts = semver.split(/[+\.\-]/g);
while (parts.length < 4) {
parts.push('');
}
return parts
.map(function (num, i) {
if (3 === i) {
return num.toString().padEnd(10, 'z');
}
return num.toString().padStart(10, '0');
})
.join('.');
}
var aver = lexver(a.version);
var bver = lexver(b.version);
if (aver > bver) {
//console.log(aver, '>', bver);
return -1;
}
if (aver < bver) {
//console.log(aver, '<', bver);
return 1;
return function sortByExtLibc(a, b) {
if (a.version !== b.version) {
// Array.sort is stable (V8, ES2019), so returning 0 across
// versions preserves the cache's pre-sorted version-desc order.
return 0;
}
var aExtPri = formats.indexOf(a.ext.replace(/tar\..*/, 'tar'));
var bExtPri = formats.indexOf(b.ext.replace(/tar\..*/, 'tar'));
if (aExtPri > bExtPri) {
//console.log(a.ext, aExtPri, '>', b.ext, bExtPri);
return -1;
}
if (aExtPri < bExtPri) {
//console.log(a.ext, aExtPri, '<', b.ext, bExtPri);
return 1;
}
@@ -87,99 +42,39 @@ function createFormatsSorter(formats) {
}
async function getCachedReleases(pkg) {
// returns { download: '<template string>', releases: [{ version, date, os, arch, lts, channel, download}] }
// returns { download: '', releases: [{ version, date, os, arch, lts, channel, download}] }
async function chainCachePromise(fn) {
cache[pkg].promise = cache[pkg].promise.then(fn);
return cache[pkg].promise;
if (cache[pkg]) {
return cache[pkg];
}
async function sleep(ms) {
return await new Promise(function (resolve, reject) {
setTimeout(resolve, ms);
});
}
let dataFile = `${LEGACY_CACHE_DIR}/${pkg}.json`;
async function putCache() {
var age = Date.now() - cache[pkg].updatedAt;
if (age < staleAge) {
//console.debug('NOT STALE ANYMORE - updated in previous promise');
return cache[pkg].all;
let json = await Fs.readFile(dataFile, 'utf8').catch(function (err) {
if (err.code === 'ENOENT') {
return null;
}
throw err;
});
//console.debug('DOWNLOADING NEW "%s" releases', pkg);
var pkgdir = path.join(installerDir, pkg);
// workaround for request timeout seeming to not work
let complete = false;
await Promise.race([
Releases.get(pkgdir)
.catch(function (err) {
if ('E_NO_RELEASE' === err.code) {
let all = { _error: 'E_NO_RELEASE', download: '', releases: [] };
return all;
}
throw err;
})
.catch(function (err) {
let hasReleases = cache[pkg].all?.releases?.length > 1;
if (!hasReleases) {
throw err;
}
console.error(`Error: the BOOGEYMAN got us!`);
console.error(err.stack);
return cache[pkg].all;
})
.then(function (all) {
// Note: it is possible for slightly older data
// to replace slightly newer data, but this is better
// than being in a cycle where release updates _always_
// take longer than expected.
//console.debug('DOWNLOADED NEW "%s" releases', pkg);
cache[pkg].updatedAt = Date.now();
cache[pkg].all = all;
complete = true;
}),
sleep(15000).then(function () {
if (complete) {
return;
}
console.error(`request timeout waiting for '${pkg}' release info`);
}),
]);
return cache[pkg].all;
if (!json) {
let empty = { download: '', releases: [] };
cache[pkg] = empty;
return empty;
}
if (!cache[pkg]) {
cache[pkg] = {
updatedAt: 0,
all: { download: '', releases: [] },
promise: Promise.resolve(),
};
let all;
try {
all = JSON.parse(json);
} catch (e) {
console.error(`error: ${dataFile}:\n\t${e.message}`);
let empty = { download: '', releases: [] };
cache[pkg] = empty;
return empty;
}
var bgRenewal;
var age = Date.now() - cache[pkg].updatedAt;
var fresh = age < staleAge;
if (!fresh) {
bgRenewal = chainCachePromise(putCache);
}
var tooStale = age > expiredAge;
if (!tooStale) {
return await cache[pkg].all;
}
return await Promise.race([
bgRenewal,
sleep(5000).then(function () {
return cache[pkg].all;
}),
]);
cache[pkg] = all;
return all;
}
async function filterReleases(
@@ -190,15 +85,22 @@ async function filterReleases(
// sort the most compatible format first
// (i.e. so that we don't do .pkg on linux except on purpose)
var rformats = formats.slice(0).reverse();
var sortByVerExt = createFormatsSorter(rformats);
var sortByExtLibc = createFormatsSorter(rformats);
var reVer = new RegExp('^' + ver + '\\b');
function selectMatches(rel) {
/* jshint maxcomplexity: 25 */
if (os) {
if (rel.os !== '*') {
if (rel.os !== os) {
return false;
}
// '*' = any OS (matches anything, including windows).
// 'posix' / 'posix_20xx' = any POSIX OS (matches linux, macos,
// freebsd, etc., but NOT windows).
let isPosix = rel.os === 'posix' || rel.os.startsWith('posix_20');
let osMatches =
rel.os === '*' ||
rel.os === os ||
(isPosix && os !== 'windows');
if (!osMatches) {
return false;
}
}
@@ -210,7 +112,10 @@ async function filterReleases(
}
}
if (rel.libc !== 'none') {
// libc='libc' is serve-releases.js's default when the caller
// didn't pin one — treat it as 'no preference', not a filter.
let isMeaningfulLibc = libc && libc !== 'libc' && rel.libc !== 'none';
if (isMeaningfulLibc) {
let releaseRequiresMusl = rel.libc === 'musl';
// goal: handle non-glibc (Alpine / Docker / musl)
let osHasMusl = libc === 'musl';
@@ -257,7 +162,7 @@ async function filterReleases(
return true;
}
var sortedRels = all.releases.filter(selectMatches).sort(sortByVerExt);
var sortedRels = all.releases.filter(selectMatches).sort(sortByExtLibc);
//console.log(sortedRels.slice(0, 4));
return sortedRels.slice(0, limit || 1000);
@@ -416,8 +321,8 @@ Releases.getReleases = function ({
};
if (require.main === module) {
return module
.exports({
return Releases
.getReleases({
pkg: 'node',
ver: '',
os: 'macos',

View File

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

2
aliasman/releases.conf Normal file
View File

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

View File

@@ -1,22 +0,0 @@
'use strict';
let Releases = module.exports;
let GitHubSource = require('../_common/github-source.js');
let owner = 'BeyondCodeBootcamp';
let repo = 'aliasman';
Releases.latest = async function () {
let all = await GitHubSource.getDistributables({ owner, repo });
for (let pkg of all.releases) {
pkg.os = 'posix_2017';
}
return all;
};
if (module === require.main) {
Releases.latest().then(function (all) {
all = require('../_webi/normalize.js')(all);
console.info(JSON.stringify(all, null, 2));
});
}

1
arc/releases.conf Normal file
View File

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

View File

@@ -1,21 +0,0 @@
'use strict';
var github = require('../_common/github.js');
var owner = 'mholt';
var repo = 'archiver';
module.exports = function () {
return github(null, owner, repo).then(function (all) {
all._names = ['archiver', 'arc'];
return all;
});
};
if (module === require.main) {
module.exports().then(function (all) {
all = require('../_webi/normalize.js')(all);
// just select the first 5 for demonstration
all.releases = all.releases.slice(0, 5);
console.info(JSON.stringify(all, null, 2));
});
}

View File

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

View File

@@ -1,79 +0,0 @@
'use strict';
var github = require('../_common/github.js');
var owner = 'wez';
var repo = 'atomicparsley';
let targets = {
x86win: {
os: 'windows',
arch: 'x86',
libc: 'msvc',
},
x64win: {
os: 'windows',
arch: 'amd64',
// https://github.com/wez/atomicparsley/issues/6#issuecomment-1364523028
libc: 'msvc',
},
x64mac: {
os: 'macos',
arch: 'amd64',
},
x64lin: {
os: 'linux',
arch: 'amd64',
libc: 'gnu',
},
x64musl: {
os: 'linux',
arch: 'amd64',
libc: 'musl',
},
};
module.exports = function () {
return github(null, owner, repo).then(function (all) {
for (let rel of all.releases) {
let windows32 = rel.name.includes('WindowsX86.');
if (windows32) {
Object.assign(rel, targets.x86win);
continue;
}
let windows64 = rel.name.includes('Windows.');
if (windows64) {
Object.assign(rel, targets.x64win);
continue;
}
let macos64 = rel.name.includes('MacOS');
if (macos64) {
Object.assign(rel, targets.x64mac);
continue;
}
let musl64 = rel.name.includes('Alpine');
if (musl64) {
Object.assign(rel, targets.x64musl);
continue;
}
let lin64 = rel.name.includes('Linux.');
if (lin64) {
Object.assign(rel, targets.x64lin);
continue;
}
}
all._names = ['AtomicParsley', 'atomicparsley'];
return all;
});
};
if (module === require.main) {
module.exports().then(function (all) {
all = require('../_webi/normalize.js')(all);
console.info(JSON.stringify(all));
//console.info(JSON.stringify(all, null, 2));
});
}

1
awless/releases.conf Normal file
View File

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

View File

@@ -1,22 +0,0 @@
'use strict';
var github = require('../_common/github.js');
var owner = 'wallix';
var repo = 'awless';
module.exports = function () {
return github(null, owner, repo).then(function (all) {
// remove checksums and .deb
all.releases = all.releases.filter(function (rel) {
return !/(\.txt)|(\.deb)$/i.test(rel.name);
});
return all;
});
};
if (module === require.main) {
module.exports().then(function (all) {
all = require('../_webi/normalize.js')(all);
console.info(JSON.stringify(all));
});
}

1
bat/releases.conf Normal file
View File

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

View File

@@ -1,20 +0,0 @@
'use strict';
var github = require('../_common/github.js');
var owner = 'sharkdp';
var repo = 'bat';
module.exports = function () {
return github(null, owner, repo).then(function (all) {
return all;
});
};
if (module === require.main) {
module.exports().then(function (all) {
all = require('../_webi/normalize.js')(all);
all.releases = all.releases.slice(0, 10);
//console.info(JSON.stringify(all));
console.info(JSON.stringify(all, null, 2));
});
}

5
bun/releases.conf Normal file
View File

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

View File

@@ -1,61 +0,0 @@
'use strict';
var github = require('../_common/github.js');
var owner = 'oven-sh';
var repo = 'bun';
module.exports = function () {
return github(null, owner, repo).then(function (all) {
// collect baseline asset names so we can prefer them over non-baseline
// (baseline builds avoid SIGILL on older/container CPUs)
let baselineNames = new Set();
all.releases.forEach(function (r) {
if (r.name.includes('-baseline')) {
baselineNames.add(r.name.replace('-baseline', ''));
}
});
all.releases = all.releases
.filter(function (r) {
if (r.name.includes('-profile')) {
return false;
}
if (r.name.endsWith('.txt') || r.name.endsWith('.asc')) {
return false;
}
// drop the non-baseline asset when a baseline twin exists
if (!r.name.includes('-baseline') && baselineNames.has(r.name)) {
return false;
}
let isMusl = r.name.includes('-musl');
if (isMusl) {
r._musl = true;
r.libc = 'musl';
} else if (r.name.includes('-linux-')) {
r.libc = 'gnu';
}
return true;
})
.map(function (r) {
// bun-linux-x64-baseline.zip => bun-linux-x64
r.name = r.name.replace('-baseline', '').replace(/\.zip$/, '');
// bun-v0.5.1 => v0.5.1
r.version = r.version.replace(/bun-/g, '');
return r;
});
return all;
});
};
if (module === require.main) {
module.exports().then(function (all) {
all = require('../_webi/normalize.js')(all);
// just select the first 5 for demonstration
all.releases = all.releases.slice(0, 5);
console.info(JSON.stringify(all, null, 2));
});
}

1
caddy/releases.conf Normal file
View File

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

View File

@@ -1,27 +0,0 @@
'use strict';
var github = require('../_common/github.js');
var owner = 'caddyserver';
var repo = 'caddy';
module.exports = function () {
return github(null, owner, repo).then(function (all) {
// remove checksums and .deb
all.releases = all.releases.filter(function (rel) {
let isOneOffAsset = rel.download.includes('buildable-artifact');
if (isOneOffAsset) {
return false;
}
return true;
});
return all;
});
};
if (module === require.main) {
module.exports().then(function (all) {
all = require('../_webi/normalize.js')(all);
console.info(JSON.stringify(all));
});
}

View File

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

View File

@@ -1,101 +0,0 @@
'use strict';
let Fetcher = require('../_common/fetcher.js');
// See <https://googlechromelabs.github.io/chrome-for-testing/>
const releaseApiUrl =
'https://googlechromelabs.github.io/chrome-for-testing/known-good-versions-with-downloads.json';
// {
// "timestamp": "2023-11-15T21:08:56.730Z",
// "versions": [
// {
// "version": "121.0.6120.0",
// "revision": "1222902",
// "downloads": {
// "chrome": [],
// "chromedriver": [
// {
// "platform": "linux64",
// "url": "https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/121.0.6120.0/linux64/chromedriver-linux64.zip"
// },
// {
// "platform": "mac-arm64",
// "url": "https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/121.0.6120.0/mac-arm64/chromedriver-mac-arm64.zip"
// },
// {
// "platform": "mac-x64",
// "url": "https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/121.0.6120.0/mac-x64/chromedriver-mac-x64.zip"
// },
// {
// "platform": "win32",
// "url": "https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/121.0.6120.0/win32/chromedriver-win32.zip"
// },
// {
// "platform": "win64",
// "url": "https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/121.0.6120.0/win64/chromedriver-win64.zip"
// }
// ],
// "chrome-headless-shell": []
// }
// }
// ]
// }
module.exports = async function () {
let resp;
try {
resp = await Fetcher.fetch(releaseApiUrl, {
headers: { Accept: 'application/json' },
});
} catch (e) {
/** @type {Error & { code: string, response: { status: number, body: string } }} */ //@ts-expect-error
let err = e;
if (err.code === 'E_FETCH_RELEASES') {
err.message = `failed to fetch 'chromedriver' release data: ${err.response.status} ${err.response.body}`;
}
throw e;
}
let data = JSON.parse(resp.body);
let builds = [];
for (let release of data.versions) {
if (!release.downloads.chromedriver) {
continue;
}
let version = release.version;
for (let asset of release.downloads.chromedriver) {
let build = {
version: version,
download: asset.url,
// I' not sure that this is actually statically built but it
// seems to be and at worst we'll just get bug reports for Alpine
libc: 'none',
};
builds.push(build);
}
}
let all = {
download: '',
releases: builds,
};
return all;
};
if (module === require.main) {
module
.exports()
.then(function (all) {
all = require('../_webi/normalize.js')(all);
// just select the latest 20 for demonstration
all.releases = all.releases.slice(-20);
console.info(JSON.stringify(all, null, 2));
})
.catch(function (err) {
console.error('Error:', err);
});
}

1
cilium/releases.conf Normal file
View File

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

View File

@@ -1,21 +0,0 @@
'use strict';
var github = require('../_common/github.js');
var owner = 'cilium';
var repo = 'cilium-cli';
module.exports = async function () {
let all = await github(null, owner, repo);
return all;
};
if (module === require.main) {
(async function () {
let normalize = require('../_webi/normalize.js');
let all = await module.exports();
all = normalize(all);
// just select the first 5 for demonstration
all.releases = all.releases.slice(0, 5);
console.info(JSON.stringify(all, null, 2));
})();
}

1
cmake/releases.conf Normal file
View File

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

View File

@@ -1,53 +0,0 @@
'use strict';
var github = require('../_common/github.js');
var owner = 'Kitware';
var repo = 'CMake';
module.exports = function () {
return github(null, owner, repo).then(function (all) {
for (let rel of all.releases) {
if (rel.version.startsWith('v')) {
rel._version = rel.version.slice(1);
}
{
let linuxRe = /(\b|_)(linux|gnu)(\b|_)/i;
let isLinux = linuxRe.test(rel.download) || linuxRe.test(rel.name);
if (isLinux) {
let muslRe = /(\b|_)(musl|alpine)(\b|_)/i;
let isMusl = muslRe.test(rel.download) || muslRe.test(rel.name);
if (isMusl) {
rel.libc = 'musl';
} else {
rel.libc = 'gnu';
}
continue;
}
}
{
let windowsRe = /(\b|_)(win\d*|windows\d*)(\b|_)/i;
let isWindows =
windowsRe.test(rel.download) || windowsRe.test(rel.name);
if (isWindows) {
rel.libc = 'msvc';
continue;
}
}
}
return all;
});
};
if (module === require.main) {
module.exports().then(function (all) {
all = require('../_webi/normalize.js')(all);
// just select the first 5 for demonstration
all.releases = all.releases.slice(0, 5);
console.info(JSON.stringify(all, null, 2));
});
}

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

@@ -0,0 +1,936 @@
// 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"
"github.com/webinstall/webi-installers/internal/storage/pgstore"
)
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
pgDSN 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")
}
var store storage.Store
if cfg.pgDSN != "" {
pg, err := pgstore.New(context.Background(), cfg.pgDSN)
if err != nil {
log.Fatalf("pgstore: %v", err)
}
store = pg
} else {
fss, err := fsstore.New(cfg.cacheDir)
if err != nil {
log.Fatalf("fsstore: %v", err)
}
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.pgDSN, "pg", "", "PostgreSQL DSN (enables pgstore; mutually exclusive with -legacy)")
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
if err == nil && data != nil {
t = data.UpdatedAt
}
// Never fetched, or older than 10 minutes.
// 0-asset results are not treated as perpetually stale — packages that
// produce no classifiable assets (e.g. galera) respect the timestamp.
if t.IsZero() || 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
}

146
cmd/webid/bootstrap_test.go Normal file
View File

@@ -0,0 +1,146 @@
package main
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestBootstrapCurlPipe verifies the /{pkg} route returns the curl-pipe bootstrap.
func TestBootstrapCurlPipe(t *testing.T) {
srv, ts := newTestServer(t)
pkg := "bat"
if srv.getPackage(pkg) == nil {
t.Skipf("package %s not in cache", pkg)
}
code, body := get(t, ts, "/bat@stable")
if code != 200 {
t.Fatalf("status %d: %s", code, body[:min(len(body), 200)])
}
// Should contain the bootstrap env vars.
if !strings.Contains(body, "WEBI_PKG=") {
t.Error("missing WEBI_PKG= in bootstrap")
}
if !strings.Contains(body, "WEBI_HOST=") {
t.Error("missing WEBI_HOST= in bootstrap")
}
if !strings.Contains(body, "WEBI_CHECKSUM=") {
t.Error("missing WEBI_CHECKSUM= in bootstrap")
}
// Should NOT contain the full installer (install.sh content).
// The bootstrap just downloads and runs webi.
if strings.Contains(body, "pkg_install()") {
t.Error("bootstrap should not contain pkg_install — that's the full installer")
}
t.Logf("bootstrap size: %d bytes", len(body))
}
// TestInstallerFull verifies /api/installers/{pkg}.sh returns the full installer.
func TestInstallerFull(t *testing.T) {
srv, ts := newTestServer(t)
pkg := "bat"
if srv.getPackage(pkg) == nil {
t.Skipf("package %s not in cache", pkg)
}
// Use a webi-style User-Agent so the server can detect platform.
code, body := getWithUA(t, ts, "/api/installers/bat@stable.sh", "aarch64/unknown Darwin/24.2.0 libc")
if code != 200 {
t.Fatalf("status %d: %s", code, body[:min(len(body), 500)])
}
// Should contain resolved release info.
if !strings.Contains(body, "WEBI_VERSION=") {
t.Error("missing WEBI_VERSION= in installer")
}
if !strings.Contains(body, "WEBI_PKG_URL=") {
t.Error("missing WEBI_PKG_URL= in installer")
}
if !strings.Contains(body, "PKG_NAME=") {
t.Error("missing PKG_NAME= in installer")
}
// Should contain the package's install.sh content (embedded).
if !strings.Contains(body, "pkg_") {
t.Error("installer should contain pkg_ functions from install.sh")
}
t.Logf("installer size: %d bytes", len(body))
}
// TestInstallerPowerShell verifies /api/installers/{pkg}.ps1 returns a PowerShell installer.
func TestInstallerPowerShell(t *testing.T) {
srv, ts := newTestServer(t)
pkg := "node"
if srv.getPackage(pkg) == nil {
t.Skipf("package %s not in cache", pkg)
}
code, body := getWithUA(t, ts, "/api/installers/node@stable.ps1", "AMD64/unknown Windows/10.0.19045 msvc")
if code != 200 {
t.Fatalf("status %d: %s", code, body[:min(len(body), 500)])
}
if !strings.Contains(body, "$Env:WEBI_VERSION") {
t.Error("missing $Env:WEBI_VERSION in PS1 installer")
}
if !strings.Contains(body, "$Env:WEBI_PKG_URL") {
t.Error("missing $Env:WEBI_PKG_URL in PS1 installer")
}
if !strings.Contains(body, "$Env:PKG_NAME") {
t.Error("missing $Env:PKG_NAME in PS1 installer")
}
t.Logf("PS1 installer size: %d bytes", len(body))
}
// TestInstallerSelfHosted verifies selfhosted packages get a script without resolution.
func TestInstallerSelfHosted(t *testing.T) {
_, ts := newTestServer(t)
// ssh-utils is selfhosted — has install.sh but no releases.conf.
code, body := getWithUA(t, ts, "/api/installers/ssh-utils.sh", "aarch64/unknown Darwin/24.2.0 libc")
if code == 404 {
t.Skip("ssh-utils not available as installer")
}
if code != 200 {
t.Skipf("status %d (selfhosted may not render without cache): %s", code, body[:min(len(body), 200)])
}
t.Logf("selfhosted installer size: %d bytes", len(body))
}
// TestBootstrapUnknownPackage verifies 404 for unknown packages.
func TestBootstrapUnknownPackage(t *testing.T) {
_, ts := newTestServer(t)
code, _ := get(t, ts, "/nonexistent-package-xyz")
if code != 404 {
t.Errorf("expected 404, got %d", code)
}
}
// getWithUA fetches a URL with a custom User-Agent header.
func getWithUA(t *testing.T, ts *httptest.Server, path, ua string) (int, string) {
t.Helper()
req, err := http.NewRequest("GET", ts.URL+path, nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("User-Agent", ua)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("GET %s: %v", path, err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(body)
}

1000
cmd/webid/main.go Normal file

File diff suppressed because it is too large Load Diff

298
cmd/webid/main_test.go Normal file
View File

@@ -0,0 +1,298 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/webinstall/webi-installers/internal/resolve"
"github.com/webinstall/webi-installers/internal/storage"
"github.com/webinstall/webi-installers/internal/storage/fsstore"
)
// newTestServer creates a server backed by the real _cache directory
// and returns an httptest.Server with proper routing (so PathValue works).
func newTestServer(t *testing.T) (*server, *httptest.Server) {
t.Helper()
cacheDir := filepath.Join("..", "..", "_cache")
if _, err := os.Stat(cacheDir); err != nil {
t.Skipf("no cache dir at %s", cacheDir)
}
store, err := fsstore.New(cacheDir)
if err != nil {
t.Fatalf("fsstore: %v", err)
}
srv := &server{
store: store,
installersDir: filepath.Join("..", ".."),
packages: make(map[string]*packageCache),
}
// Load packages.
monthDir := time.Now().Format("2006-01")
dir := filepath.Join(store.Root(), monthDir)
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("readdir: %v", err)
}
for _, e := range entries {
if !strings.HasSuffix(e.Name(), ".json") {
continue
}
pkg := strings.TrimSuffix(e.Name(), ".json")
pd, err := store.Load(context.Background(), pkg)
if err != nil || pd == nil || len(pd.Assets) == 0 {
continue
}
pc := &packageCache{
assets: pd.Assets,
dists: assetsToDists(pd.Assets),
}
pc.catalog = resolve.Survey(pc.dists)
srv.packages[pkg] = pc
}
mux := http.NewServeMux()
mux.HandleFunc("GET /api/releases/{rest...}", srv.handleReleasesAPI)
mux.HandleFunc("GET /v1/releases/{rest...}", srv.handleV1Releases)
mux.HandleFunc("GET /v1/resolve/{rest...}", srv.handleV1Resolve)
mux.HandleFunc("GET /api/installers/{rest...}", srv.handleInstaller)
mux.HandleFunc("GET /api/debug", srv.handleDebug)
mux.HandleFunc("GET /{pkgSpec}", srv.handleBootstrap)
ts := httptest.NewServer(mux)
t.Cleanup(ts.Close)
return srv, ts
}
// get fetches a URL from the test server and returns the body.
func get(t *testing.T, ts *httptest.Server, path string) (int, string) {
t.Helper()
resp, err := http.Get(ts.URL + path)
if err != nil {
t.Fatalf("GET %s: %v", path, err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(body)
}
// TestLegacyJSONFormat verifies our JSON output matches the production format.
func TestLegacyJSONFormat(t *testing.T) {
srv, ts := newTestServer(t)
packages := []string{"bat", "node", "go", "jq"}
for _, pkg := range packages {
t.Run(pkg, func(t *testing.T) {
if srv.getPackage(pkg) == nil {
t.Skipf("package %s not in cache", pkg)
}
code, body := get(t, ts, "/api/releases/"+pkg+".json?limit=5")
if code != http.StatusOK {
t.Fatalf("status %d: %s", code, body)
}
body = strings.TrimSpace(body)
// Must be a JSON array, not an object.
if !strings.HasPrefix(body, "[") {
t.Fatalf("expected JSON array, got: %.100s", body)
}
var releases []legacyRelease
if err := json.Unmarshal([]byte(body), &releases); err != nil {
t.Fatalf("decode: %v", err)
}
if len(releases) == 0 {
t.Fatal("no releases returned")
}
// Check field format conventions.
for i, r := range releases {
if strings.HasPrefix(r.Version, "v") {
t.Errorf("release[%d]: version %q should not have v prefix", i, r.Version)
}
if strings.HasPrefix(r.Ext, ".") {
t.Errorf("release[%d]: ext %q should not have . prefix", i, r.Ext)
}
if r.OS == "darwin" {
t.Errorf("release[%d]: os should be 'macos' not 'darwin'", i)
}
if r.Arch == "x86_64" {
t.Errorf("release[%d]: arch should be 'amd64' not 'x86_64'", i)
}
if r.Arch == "aarch64" {
t.Errorf("release[%d]: arch should be 'arm64' not 'aarch64'", i)
}
if r.Libc == "" {
t.Errorf("release[%d]: libc should be 'none' not empty", i)
}
if r.Download == "" {
t.Errorf("release[%d]: download URL is empty", i)
}
}
})
}
}
// TestLegacyTabFormat verifies our .tab output uses real TSV.
func TestLegacyTabFormat(t *testing.T) {
srv, ts := newTestServer(t)
packages := []string{"bat", "node", "go"}
for _, pkg := range packages {
t.Run(pkg, func(t *testing.T) {
if srv.getPackage(pkg) == nil {
t.Skipf("package %s not in cache", pkg)
}
code, body := get(t, ts, "/api/releases/"+pkg+".tab?limit=3")
if code != http.StatusOK {
t.Fatalf("status %d: %s", code, body)
}
lines := strings.Split(strings.TrimSpace(body), "\n")
if len(lines) == 0 {
t.Fatal("no lines returned")
}
for i, line := range lines {
fields := strings.Split(line, "\t")
// Expect 11 tab-separated fields:
// version, lts, channel, date, os, arch, ext, hash, download, (empty), libc
if len(fields) != 11 {
t.Errorf("line[%d]: expected 11 tab fields, got %d: %q", i, len(fields), line)
continue
}
version := fields[0]
lts := fields[1]
ext := fields[6]
if strings.HasPrefix(version, "v") {
t.Errorf("line[%d]: version %q should not have v prefix", i, version)
}
if lts != "-" && lts != "lts" {
t.Errorf("line[%d]: lts should be '-' or 'lts', got %q", i, lts)
}
if strings.HasPrefix(ext, ".") {
t.Errorf("line[%d]: ext %q should not have . prefix", i, ext)
}
}
})
}
}
// TestLegacyJSONAgainstProduction compares our output against live production.
// Run with: WEBI_TEST_PROD=1 go test -run TestLegacyJSONAgainstProduction
func TestLegacyJSONAgainstProduction(t *testing.T) {
if os.Getenv("WEBI_TEST_PROD") == "" {
t.Skip("set WEBI_TEST_PROD=1 to compare against production")
}
srv, ts := newTestServer(t)
packages := []string{"bat", "node", "go", "jq", "rg"}
for _, pkg := range packages {
t.Run(pkg, func(t *testing.T) {
if srv.getPackage(pkg) == nil {
t.Skipf("package %s not in cache", pkg)
}
// Fetch from production.
prodURL := fmt.Sprintf("https://webinstall.dev/api/releases/%s.json?limit=3", pkg)
prodResp, err := http.Get(prodURL)
if err != nil {
t.Fatalf("fetch production: %v", err)
}
defer prodResp.Body.Close()
prodBody, _ := io.ReadAll(prodResp.Body)
var prodReleases []legacyRelease
if err := json.Unmarshal(prodBody, &prodReleases); err != nil {
t.Fatalf("decode production: %v\nbody: %.500s", err, string(prodBody))
}
// Fetch from local.
_, localBody := get(t, ts, "/api/releases/"+pkg+".json?limit=3")
var localReleases []legacyRelease
if err := json.Unmarshal([]byte(localBody), &localReleases); err != nil {
t.Fatalf("decode local: %v", err)
}
if len(prodReleases) == 0 || len(localReleases) == 0 {
t.Skip("empty releases")
}
// Compare the first release's format.
prod := prodReleases[0]
local := localReleases[0]
if strings.HasPrefix(local.Version, "v") != strings.HasPrefix(prod.Version, "v") {
t.Errorf("version prefix mismatch: prod=%q local=%q", prod.Version, local.Version)
}
if strings.HasPrefix(local.Ext, ".") != strings.HasPrefix(prod.Ext, ".") {
t.Errorf("ext prefix mismatch: prod=%q local=%q", prod.Ext, local.Ext)
}
if prod.OS == "macos" && local.OS == "darwin" {
t.Error("OS: prod uses 'macos', local uses 'darwin'")
}
if prod.Arch == "amd64" && local.Arch == "x86_64" {
t.Error("Arch: prod uses 'amd64', local uses 'x86_64'")
}
if prod.Arch == "arm64" && local.Arch == "aarch64" {
t.Error("Arch: prod uses 'arm64', local uses 'aarch64'")
}
t.Logf("prod[0]: version=%q os=%q arch=%q ext=%q libc=%q",
prod.Version, prod.OS, prod.Arch, prod.Ext, prod.Libc)
t.Logf("local[0]: version=%q os=%q arch=%q ext=%q libc=%q",
local.Version, local.OS, local.Arch, local.Ext, local.Libc)
})
}
}
// TestSortOrder verifies releases come back newest-first.
func TestSortOrder(t *testing.T) {
srv, ts := newTestServer(t)
pkg := "bat"
if srv.getPackage(pkg) == nil {
t.Skipf("package %s not in cache", pkg)
}
_, body := get(t, ts, "/api/releases/"+pkg+".json?limit=20")
var releases []legacyRelease
if err := json.Unmarshal([]byte(body), &releases); err != nil {
t.Fatalf("decode: %v", err)
}
if len(releases) < 2 {
t.Skip("need at least 2 releases")
}
// First release should be newest (or equal) version.
first := releases[0].Date
last := releases[len(releases)-1].Date
if first < last {
t.Errorf("not newest-first: first=%q last=%q", first, last)
}
}
// Ensure imports are used.
var _ = storage.Asset{}

459
cmd/webid/v1api.go Normal file
View File

@@ -0,0 +1,459 @@
package main
import (
"bytes"
"encoding/csv"
"encoding/json"
"fmt"
"net/http"
"slices"
"strings"
"github.com/jszwec/csvutil"
"github.com/webinstall/webi-installers/internal/buildmeta"
"github.com/webinstall/webi-installers/internal/lexver"
"github.com/webinstall/webi-installers/internal/resolver"
"github.com/webinstall/webi-installers/internal/storage"
)
// v1Release is a single release in the new API TSV format.
// Field order matters for csvutil — it determines column order.
// Fields are designed to be easy to consume with cut/grep/sort.
type v1Release struct {
Version string `csv:"version"`
Channel string `csv:"channel"`
LTS string `csv:"lts"`
Date string `csv:"date"`
OS string `csv:"os"`
Arch string `csv:"arch"`
Libc string `csv:"libc"`
Format string `csv:"format"`
Variants string `csv:"variants"` // space-separated
Download string `csv:"download"`
Filename string `csv:"filename"`
}
// v1ResolveResult is the response for /v1/resolve/{pkg}.
type v1ResolveResult struct {
Version string `csv:"version" json:"version"`
Channel string `csv:"channel" json:"channel"`
LTS string `csv:"lts" json:"lts"`
Date string `csv:"date" json:"date"`
OS string `csv:"os" json:"os"`
Arch string `csv:"arch" json:"arch"`
Libc string `csv:"libc" json:"libc"`
Format string `csv:"format" json:"format"`
Variants string `csv:"variants" json:"variants"`
Download string `csv:"download" json:"download"`
Filename string `csv:"filename" json:"filename"`
Triplet string `csv:"triplet" json:"triplet"`
}
// handleV1Releases serves /v1/releases/{pkg}.tsv (or .json)
// with Go-native naming and TSV-first format.
//
// Query params:
//
// os — filter by OS (darwin, linux, windows)
// arch — filter by arch (aarch64, x86_64, armv7l)
// libc — filter by libc (gnu, musl, msvc)
// channel — release channel (stable, beta, rc, alpha)
// version — version prefix filter (e.g. "1.20")
// lts — if "true", only LTS releases
// format — filter by format (e.g. "tar.gz")
// variant — filter by variant (e.g. "rocm")
// limit — max results (default 1000)
func (s *server) handleV1Releases(w http.ResponseWriter, r *http.Request) {
rest := r.PathValue("rest")
pkg, version, format, err := parseReleasePath(rest)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
pc := s.getPackage(pkg)
if pc == nil {
if s.isSelfHosted(pkg) {
s.v1ServeEmpty(w, format)
return
}
http.Error(w, fmt.Sprintf("package %q not found", pkg), http.StatusNotFound)
return
}
q := r.URL.Query()
osStr := q.Get("os")
archStr := q.Get("arch")
libcStr := q.Get("libc")
channelStr := q.Get("channel")
ltsStr := q.Get("lts")
formatFilter := q.Get("format")
variantStr := q.Get("variant")
limitStr := q.Get("limit")
// Use version from URL path or query.
if version == "" {
version = q.Get("version")
}
// Handle channel selectors in version field.
switch strings.ToLower(version) {
case "stable", "latest":
version = ""
if channelStr == "" {
channelStr = "stable"
}
case "lts":
version = ""
ltsStr = "true"
case "beta", "pre", "preview":
version = ""
if channelStr == "" {
channelStr = "beta"
}
case "rc":
version = ""
if channelStr == "" {
channelStr = "rc"
}
case "alpha", "dev":
version = ""
if channelStr == "" {
channelStr = "alpha"
}
}
lts := ltsStr == "true" || ltsStr == "1"
limit := 1000
if limitStr != "" {
fmt.Sscanf(limitStr, "%d", &limit)
}
// Filter assets directly (not via resolve.Dist).
filtered := filterAssets(pc.assets, osStr, archStr, libcStr, channelStr, version, formatFilter, variantStr, lts, limit)
// Sort newest-first.
sortAssetsDescending(filtered)
switch format {
case "json":
s.v1ServeJSON(w, filtered)
case "tab":
s.v1ServeTSV(w, filtered)
default:
http.Error(w, "unsupported format: "+format+" (use .json or .tab)", http.StatusBadRequest)
}
}
// handleV1Resolve serves /v1/resolve/{pkg}.tsv (or .json)
// It resolves the single best asset for a given platform.
//
// Query params:
//
// os — target OS (required)
// arch — target arch (required)
// libc — target libc
// version — version prefix
// channel — release channel
// lts — if "true", only LTS
// format — preferred formats (comma-separated, in preference order)
// variant — preferred variant
func (s *server) handleV1Resolve(w http.ResponseWriter, r *http.Request) {
rest := r.PathValue("rest")
pkg, version, format, err := parseReleasePath(rest)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
pc := s.getPackage(pkg)
if pc == nil {
http.Error(w, fmt.Sprintf("package %q not found", pkg), http.StatusNotFound)
return
}
q := r.URL.Query()
osStr := q.Get("os")
archStr := q.Get("arch")
libcStr := q.Get("libc")
channelStr := q.Get("channel")
ltsStr := q.Get("lts")
formatsStr := q.Get("format")
variantStr := q.Get("variant")
if version == "" {
version = q.Get("version")
}
// Handle channel selectors in version field.
switch strings.ToLower(version) {
case "stable", "latest":
version = ""
if channelStr == "" {
channelStr = "stable"
}
case "lts":
version = ""
ltsStr = "true"
case "beta", "pre", "preview":
version = ""
if channelStr == "" {
channelStr = "beta"
}
case "rc":
version = ""
if channelStr == "" {
channelStr = "rc"
}
case "alpha", "dev":
version = ""
if channelStr == "" {
channelStr = "alpha"
}
}
lts := ltsStr == "true" || ltsStr == "1"
var formats []string
if formatsStr != "" {
formats = strings.Split(formatsStr, ",")
}
req := resolver.Request{
OS: osStr,
Arch: archStr,
Libc: libcStr,
Version: version,
Channel: channelStr,
LTS: lts,
Formats: formats,
Variant: variantStr,
}
res, err := resolver.Resolve(pc.assets, req)
if err != nil {
http.Error(w, fmt.Sprintf("no match for %s: %v", pkg, err), http.StatusNotFound)
return
}
result := assetToV1Resolve(res)
switch format {
case "json":
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
enc.Encode(result)
case "tab":
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
data, err := marshalTSV([]v1ResolveResult{result})
if err != nil {
http.Error(w, "encode error: "+err.Error(), http.StatusInternalServerError)
return
}
w.Write(data)
default:
http.Error(w, "unsupported format: "+format, http.StatusBadRequest)
}
}
func assetToV1Release(a storage.Asset) v1Release {
lts := "-"
if a.LTS {
lts = "lts"
}
channel := a.Channel
if channel == "" {
channel = "stable"
}
libc := a.Libc
if libc == "" {
libc = "-"
}
return v1Release{
Version: a.Version,
Channel: channel,
LTS: lts,
Date: a.Date,
OS: a.OS,
Arch: a.Arch,
Libc: libc,
Format: a.Format,
Variants: strings.Join(a.Variants, " "),
Download: a.Download,
Filename: a.Filename,
}
}
func assetToV1Resolve(res resolver.Result) v1ResolveResult {
a := res.Asset
lts := "-"
if a.LTS {
lts = "lts"
}
channel := a.Channel
if channel == "" {
channel = "stable"
}
libc := a.Libc
if libc == "" {
libc = "-"
}
return v1ResolveResult{
Version: a.Version,
Channel: channel,
LTS: lts,
Date: a.Date,
OS: a.OS,
Arch: a.Arch,
Libc: libc,
Format: a.Format,
Variants: strings.Join(a.Variants, " "),
Download: a.Download,
Filename: a.Filename,
Triplet: res.Triplet,
}
}
func (s *server) v1ServeTSV(w http.ResponseWriter, assets []storage.Asset) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
releases := make([]v1Release, len(assets))
for i, a := range assets {
releases[i] = assetToV1Release(a)
}
data, err := marshalTSV(releases)
if err != nil {
http.Error(w, "encode error: "+err.Error(), http.StatusInternalServerError)
return
}
w.Write(data)
}
func (s *server) v1ServeJSON(w http.ResponseWriter, assets []storage.Asset) {
w.Header().Set("Content-Type", "application/json")
releases := make([]v1Release, len(assets))
for i, a := range assets {
releases[i] = assetToV1Release(a)
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
enc.Encode(releases)
}
func (s *server) v1ServeEmpty(w http.ResponseWriter, format string) {
switch format {
case "json":
w.Header().Set("Content-Type", "application/json")
w.Write([]byte("[]\n"))
case "tab":
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
// Just the header.
data, _ := marshalTSV([]v1Release{})
w.Write(data)
}
}
// filterAssets filters storage.Asset slices directly.
func filterAssets(assets []storage.Asset, osStr, archStr, libcStr, channel, version, formatFilter, variant string, lts bool, limit int) []storage.Asset {
var result []storage.Asset
for _, a := range assets {
if osStr != "" && a.OS != osStr && a.OS != "ANYOS" && a.OS != "" {
continue
}
if archStr != "" && a.Arch != archStr && a.Arch != "ANYARCH" && a.Arch != "" {
continue
}
if libcStr != "" && a.Libc != "" && a.Libc != "none" && a.Libc != libcStr {
continue
}
if lts && !a.LTS {
continue
}
if channel != "" && a.Channel != channel {
continue
}
if version != "" {
v := strings.TrimPrefix(a.Version, "v")
vq := strings.TrimPrefix(version, "v")
if !strings.HasPrefix(v, vq) {
continue
}
}
if formatFilter != "" && !strings.Contains(a.Format, formatFilter) {
continue
}
if variant != "" {
if !hasVariant(a.Variants, variant) {
continue
}
}
result = append(result, a)
if len(result) >= limit {
break
}
}
return result
}
// sortAssetsDescending sorts assets newest-first by version.
func sortAssetsDescending(assets []storage.Asset) {
slices.SortStableFunc(assets, func(a, b storage.Asset) int {
va := lexver.Parse(strings.TrimPrefix(a.Version, "v"))
vb := lexver.Parse(strings.TrimPrefix(b.Version, "v"))
return lexver.Compare(vb, va) // descending
})
}
// hasVariant checks if the variant list contains the wanted variant.
// This is a copy of resolver.hasVariant since it's unexported.
func hasVariant(variants []string, want string) bool {
for _, v := range variants {
if v == want {
return true
}
}
return false
}
// marshalTSV encodes a slice of structs as tab-separated values with a header.
// Uses csvutil for struct-to-CSV mapping, with csv.Writer set to tab delimiter.
func marshalTSV[T any](records []T) ([]byte, error) {
var buf bytes.Buffer
w := csv.NewWriter(&buf)
w.Comma = '\t'
enc := csvutil.NewEncoder(w)
for _, r := range records {
if err := enc.Encode(r); err != nil {
return nil, err
}
}
w.Flush()
if err := w.Error(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// normalizeV1Arch maps query arch names to canonical Go names.
func normalizeV1Arch(s string) string {
switch strings.ToLower(s) {
case "amd64":
return string(buildmeta.ArchAMD64) // "x86_64"
case "arm64":
return string(buildmeta.ArchARM64) // "aarch64"
default:
return s
}
}

273
cmd/webid/v1api_test.go Normal file
View File

@@ -0,0 +1,273 @@
package main
import (
"encoding/json"
"strings"
"testing"
)
// TestV1ReleasesTSV verifies the v1 releases endpoint returns proper TSV.
func TestV1ReleasesTSV(t *testing.T) {
srv, ts := newTestServer(t)
packages := []string{"bat", "node", "go"}
for _, pkg := range packages {
t.Run(pkg, func(t *testing.T) {
if srv.getPackage(pkg) == nil {
t.Skipf("package %s not in cache", pkg)
}
code, body := get(t, ts, "/v1/releases/"+pkg+".tab?limit=5")
if code != 200 {
t.Fatalf("status %d: %s", code, body)
}
lines := strings.Split(strings.TrimSpace(body), "\n")
if len(lines) < 2 {
t.Fatal("expected header + data rows")
}
// First line should be header.
header := lines[0]
fields := strings.Split(header, "\t")
expectedHeaders := []string{
"version",
"channel",
"lts",
"date",
"os",
"arch",
"libc",
"format",
"variants",
"download",
"filename",
}
if len(fields) != len(expectedHeaders) {
t.Fatalf("expected %d columns, got %d: %q", len(expectedHeaders), len(fields), header)
}
for i, want := range expectedHeaders {
if fields[i] != want {
t.Errorf("column[%d]: want %q, got %q", i, want, fields[i])
}
}
// Data rows should have same number of fields.
for i, line := range lines[1:] {
dataFields := strings.Split(line, "\t")
if len(dataFields) != len(expectedHeaders) {
t.Errorf("row[%d]: expected %d fields, got %d: %q", i, len(expectedHeaders), len(dataFields), line)
}
}
})
}
}
// TestV1ReleasesJSON verifies the v1 releases JSON format.
func TestV1ReleasesJSON(t *testing.T) {
srv, ts := newTestServer(t)
pkg := "bat"
if srv.getPackage(pkg) == nil {
t.Skipf("package %s not in cache", pkg)
}
code, body := get(t, ts, "/v1/releases/"+pkg+".json?limit=3")
if code != 200 {
t.Fatalf("status %d: %s", code, body)
}
var releases []v1Release
if err := json.Unmarshal([]byte(body), &releases); err != nil {
t.Fatalf("decode: %v", err)
}
if len(releases) == 0 {
t.Fatal("no releases")
}
// v1 API uses Go-native naming — no mapping.
for i, r := range releases {
if r.Version == "" {
t.Errorf("release[%d]: empty version", i)
}
if r.Download == "" {
t.Errorf("release[%d]: empty download", i)
}
if r.Channel == "" {
t.Errorf("release[%d]: empty channel (should be 'stable' or similar)", i)
}
}
}
// TestV1Resolve verifies the v1 resolve endpoint.
func TestV1Resolve(t *testing.T) {
srv, ts := newTestServer(t)
pkg := "bat"
if srv.getPackage(pkg) == nil {
t.Skipf("package %s not in cache", pkg)
}
tests := []struct {
name string
query string
wantOS string
}{
{
name: "linux amd64",
query: "?os=linux&arch=x86_64",
wantOS: "linux",
},
{
name: "darwin arm64",
query: "?os=darwin&arch=aarch64",
wantOS: "darwin",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
code, body := get(t, ts, "/v1/resolve/"+pkg+".json"+tt.query)
if code != 200 {
t.Fatalf("status %d: %s", code, body)
}
var result v1ResolveResult
if err := json.Unmarshal([]byte(body), &result); err != nil {
t.Fatalf("decode: %v", err)
}
if result.Version == "" {
t.Error("empty version")
}
if result.Download == "" {
t.Error("empty download")
}
if result.OS != tt.wantOS {
t.Errorf("os: want %q, got %q", tt.wantOS, result.OS)
}
if result.Triplet == "" {
t.Error("empty triplet")
}
t.Logf("resolved: %s %s %s %s → %s", result.Version, result.OS, result.Arch, result.Format, result.Download)
})
}
}
// TestV1ResolveTSV verifies the TSV format for resolve.
func TestV1ResolveTSV(t *testing.T) {
srv, ts := newTestServer(t)
pkg := "bat"
if srv.getPackage(pkg) == nil {
t.Skipf("package %s not in cache", pkg)
}
code, body := get(t, ts, "/v1/resolve/"+pkg+".tab?os=linux&arch=x86_64")
if code != 200 {
t.Fatalf("status %d: %s", code, body)
}
lines := strings.Split(strings.TrimSpace(body), "\n")
if len(lines) != 2 {
t.Fatalf("expected 2 lines (header + result), got %d", len(lines))
}
header := strings.Split(lines[0], "\t")
data := strings.Split(lines[1], "\t")
if len(header) != len(data) {
t.Fatalf("header has %d fields, data has %d", len(header), len(data))
}
// Should have a "triplet" column.
hasTriplet := false
for _, h := range header {
if h == "triplet" {
hasTriplet = true
}
}
if !hasTriplet {
t.Error("missing triplet column in header")
}
}
// TestV1ResolveJQ verifies jq resolves to binaries, not git.
func TestV1ResolveJQ(t *testing.T) {
srv, ts := newTestServer(t)
pkg := "jq"
if srv.getPackage(pkg) == nil {
t.Skipf("package %s not in cache", pkg)
}
code, body := get(t, ts, "/v1/resolve/"+pkg+".json?os=darwin&arch=aarch64")
if code != 200 {
t.Fatalf("status %d: %s", code, body)
}
var result v1ResolveResult
if err := json.Unmarshal([]byte(body), &result); err != nil {
t.Fatalf("decode: %v", err)
}
if result.Format == "git" {
t.Errorf("resolved to git instead of binary: %+v", result)
}
if result.OS == "" {
t.Errorf("resolved to empty OS (git asset): %+v", result)
}
t.Logf("jq resolved: version=%s os=%s arch=%s format=%s → %s",
result.Version, result.OS, result.Arch, result.Format, result.Download)
}
// TestV1ReleasesFilterOS verifies OS filtering works.
func TestV1ReleasesFilterOS(t *testing.T) {
srv, ts := newTestServer(t)
pkg := "bat"
if srv.getPackage(pkg) == nil {
t.Skipf("package %s not in cache", pkg)
}
code, body := get(t, ts, "/v1/releases/"+pkg+".json?os=darwin&limit=10")
if code != 200 {
t.Fatalf("status %d: %s", code, body)
}
var releases []v1Release
if err := json.Unmarshal([]byte(body), &releases); err != nil {
t.Fatalf("decode: %v", err)
}
for i, r := range releases {
if r.OS != "darwin" && r.OS != "ANYOS" && r.OS != "" {
t.Errorf("release[%d]: os=%q, expected darwin", i, r.OS)
}
}
}
// TestV1NoQuotedFields verifies TSV output has no quoted fields.
func TestV1NoQuotedFields(t *testing.T) {
srv, ts := newTestServer(t)
pkg := "bat"
if srv.getPackage(pkg) == nil {
t.Skipf("package %s not in cache", pkg)
}
code, body := get(t, ts, "/v1/releases/"+pkg+".tab?limit=20")
if code != 200 {
t.Fatalf("status %d: %s", code, body)
}
lines := strings.Split(strings.TrimSpace(body), "\n")
for i, line := range lines {
if strings.Contains(line, "\"") {
t.Errorf("line[%d] contains quotes: %s", i, line)
}
}
}

1
comrak/releases.conf Normal file
View File

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

View File

@@ -1,40 +0,0 @@
'use strict';
var github = require('../_common/github.js');
var owner = 'kivikakk';
var repo = 'comrak';
var ODDITIES = ['-musleabihf.1-'];
module.exports = function () {
return github(null, owner, repo).then(function (all) {
let builds = [];
loopBuilds: for (let build of all.releases) {
let isOddity;
for (let oddity of ODDITIES) {
isOddity = build.name.includes(oddity);
if (isOddity) {
break;
}
}
if (isOddity) {
continue;
}
builds.push(build);
}
all.releases = builds;
return all;
});
};
if (module === require.main) {
module.exports().then(function (all) {
all = require('../_webi/normalize.js')(all);
all.releases = all.releases.slice(0, 10);
//console.info(JSON.stringify(all));
console.info(JSON.stringify(all, null, 2));
});
}

1
crabz/releases.conf Normal file
View File

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

View File

@@ -1,31 +0,0 @@
'use strict';
var github = require('../_common/github.js');
var owner = 'sstadick';
var repo = 'crabz';
module.exports = async function () {
let all = await github(null, owner, repo);
let releases = [];
for (let rel of all.releases) {
let isSrc = rel.download.includes('-src.');
if (isSrc) {
continue;
}
releases.push(rel);
}
all.releases = releases;
return all;
};
if (module === require.main) {
module.exports().then(function (all) {
all = require('../_webi/normalize.js')(all);
// just select the first 5 for demonstration
all.releases = all.releases.slice(0, 5);
console.info(JSON.stringify(all, null, 2));
});
}

1
curlie/releases.conf Normal file
View File

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

View File

@@ -1,21 +0,0 @@
'use strict';
var github = require('../_common/github.js');
var owner = 'rs';
var repo = 'curlie';
module.exports = function () {
return github(null, owner, repo).then(function (all) {
all._names = ['curlie', 'curl-httpie'];
return all;
});
};
if (module === require.main) {
module.exports().then(function (all) {
all = require('../_webi/normalize.js')(all);
all.releases = all.releases.slice(0, 10);
//console.info(JSON.stringify(all));
console.info(JSON.stringify(all, null, 2));
});
}

1
dashcore/releases.conf Normal file
View File

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

View File

@@ -1,31 +0,0 @@
'use strict';
var github = require('../_common/github.js');
var owner = 'dashpay';
var repo = 'dash';
module.exports = function () {
return github(null, owner, repo).then(function (all) {
all.releases.forEach(function (rel) {
if (rel.name.includes('osx64')) {
rel.os = 'macos';
}
if (rel.version.startsWith('v')) {
rel._version = rel.version.slice(1);
}
});
all._names = ['dashd', 'dashcore'];
return all;
});
};
if (module === require.main) {
module.exports().then(function (all) {
all = require('../_webi/normalize.js')(all);
// just select the first 5 for demonstration
all.releases = all.releases.slice(0, 5);
console.info(JSON.stringify(all, null, 2));
});
}

1
dashd/releases.conf Normal file
View File

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

View File

@@ -1,3 +0,0 @@
'use strict';
module.exports = require('../dashcore/releases.js');

1
dashmsg/releases.conf Normal file
View File

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

View File

@@ -1,18 +0,0 @@
'use strict';
var github = require('../_common/github.js');
var owner = 'dashhive';
var repo = 'dashmsg';
module.exports = function () {
return github(null, owner, repo).then(function (all) {
return all;
});
};
if (module === require.main) {
module.exports().then(function (all) {
all = require('../_webi/normalize.js')(all);
console.info(JSON.stringify(all, null, 2));
});
}

1
delta/releases.conf Normal file
View File

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

View File

@@ -1,20 +0,0 @@
'use strict';
var github = require('../_common/github.js');
var owner = 'dandavison';
var repo = 'delta';
module.exports = function () {
return github(null, owner, repo).then(function (all) {
return all;
});
};
if (module === require.main) {
module.exports().then(function (all) {
all = require('../_webi/normalize.js')(all);
// just select the first 15 for demonstration
all.releases = all.releases.slice(0, 15);
console.info(JSON.stringify(all, null, 2));
});
}

1
deno/releases.conf Normal file
View File

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

View File

@@ -1,42 +0,0 @@
'use strict';
var path = require('path');
var github = require('../_common/github.js');
var owner = 'denoland';
var repo = 'deno';
module.exports = function () {
return github(null, owner, repo).then(function (all) {
// remove checksums and .deb
all.releases = all.releases
.filter(function (rel) {
let isMeta = rel.name.endsWith('.d.ts');
if (isMeta) {
return false;
}
return true;
})
.map(function (rel) {
var ext;
if (!rel.name.match(rel.version)) {
ext = path.extname(rel.name);
rel.filename =
rel.name.slice(0, rel.name.length - ext.length) +
'-' +
rel.version +
ext;
}
return rel;
});
return all;
});
};
if (module === require.main) {
module.exports().then(function (all) {
all = require('../_webi/normalize.js')(all);
console.info(JSON.stringify(all, null, 2));
});
}

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

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

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

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

View File

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

View File

@@ -1,20 +0,0 @@
'use strict';
var github = require('../_common/github.js');
var owner = 'dotenv-linter';
var repo = 'dotenv-linter';
module.exports = function () {
return github(null, owner, repo).then(function (all) {
return all;
});
};
if (module === require.main) {
module.exports().then(function (all) {
all = require('../_webi/normalize.js')(all);
// just select the first 5 for demonstration
all.releases = all.releases.slice(0, 5);
console.info(JSON.stringify(all, null, 2));
});
}

1
dotenv/releases.conf Normal file
View File

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

View File

@@ -1,18 +0,0 @@
'use strict';
var github = require('../_common/github.js');
var owner = 'therootcompany';
var repo = 'dotenv';
module.exports = function () {
return github(null, owner, repo).then(function (all) {
return all;
});
};
if (module === require.main) {
module.exports().then(function (all) {
all = require('../_webi/normalize.js')(all);
console.info(JSON.stringify(all));
});
}

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

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

View File

@@ -1,22 +0,0 @@
'use strict';
let Releases = module.exports;
let GitHubSource = require('../_common/github-source.js');
let owner = 'BeyondCodeBootcamp';
let repo = 'DuckDNS.sh';
Releases.latest = async function () {
let all = await GitHubSource.getDistributables({ owner, repo });
for (let pkg of all.releases) {
pkg.os = 'posix_2017';
}
return all;
};
if (module === require.main) {
Releases.latest().then(function (all) {
all = require('../_webi/normalize.js')(all);
console.info(JSON.stringify(all, null, 2));
});
}

1
fd/releases.conf Normal file
View File

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

View File

@@ -1,31 +0,0 @@
'use strict';
var github = require('../_common/github.js');
var owner = 'sharkdp';
var repo = 'fd';
module.exports = function () {
return github(null, owner, repo).then(function (all) {
let builds = [];
for (let build of all.releases) {
if (build.name === 'fd') {
continue;
}
builds.push(build);
}
all.releases = builds;
return all;
});
};
if (module === require.main) {
module.exports().then(function (all) {
all = require('../_webi/normalize.js')(all);
all.releases = all.releases.slice(0, 10);
//console.info(JSON.stringify(all));
console.info(JSON.stringify(all, null, 2));
});
}

4
ffmpeg/releases.conf Normal file
View File

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

View File

@@ -1,45 +0,0 @@
'use strict';
var path = require('path');
var github = require('../_common/github.js');
var owner = 'eugeneware';
var repo = 'ffmpeg-static';
module.exports = function () {
return github(null, owner, repo).then(function (all) {
all.releases = all.releases
.filter(function (rel) {
let isFfmpeg = rel.name.includes('ffmpeg');
if (!isFfmpeg) {
return;
}
// remove README and LICENSE
return !['.README', '.LICENSE'].includes(path.extname(rel.name));
})
.map(function (rel) {
rel.version = rel.version.replace(/^b/, '');
if (/win32/.test(rel.name)) {
rel.os = 'windows';
rel.ext = 'exe';
}
if (/ia32/.test(rel.name)) {
rel.arch = '386';
} else if (/x64/.test(rel.name)) {
rel.arch = 'amd64';
}
return rel;
});
return all;
});
};
if (module === require.main) {
module.exports().then(function (all) {
all = require('../_webi/normalize.js')(all);
console.info(JSON.stringify(all));
});
}

1
ffuf/releases.conf Normal file
View File

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

View File

@@ -1,20 +0,0 @@
'use strict';
var github = require('../_common/github.js');
var owner = 'ffuf';
var repo = 'ffuf';
module.exports = function () {
return github(null, owner, repo).then(function (all) {
return all;
});
};
if (module === require.main) {
module.exports().then(function (all) {
all = require('../_webi/normalize.js')(all);
// just select the first 5 for demonstration
all.releases = all.releases.slice(0, 5);
console.info(JSON.stringify(all, null, 2));
});
}

2
fish/releases.conf Normal file
View File

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

View File

@@ -1,39 +0,0 @@
'use strict';
var github = require('../_common/github.js');
var owner = 'fish-shell';
var repo = 'fish-shell';
var ODDITIES = ['bundledpcre'];
module.exports = function () {
return github(null, owner, repo).then(function (all) {
all.releases = all.releases
.map(function (rel) {
for (let oddity of ODDITIES) {
let isOddity = rel.name.includes(oddity);
if (isOddity) {
return;
}
}
// We can extract the macos bins from the .app
if (/\.app\.zip$/.test(rel.name)) {
rel.os = 'macos';
rel.arch = 'amd64';
return rel;
}
})
.filter(Boolean);
return all;
});
};
if (module === require.main) {
module.exports().then(function (all) {
all = require('../_webi/normalize.js')(all);
// just select the first 5 for demonstration
all.releases = all.releases.slice(0, 5);
console.info(JSON.stringify(all, null, 2));
});
}

1
flutter/releases.conf Normal file
View File

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

View File

@@ -1,132 +0,0 @@
'use strict';
let Fetcher = require('../_common/fetcher.js');
let FLUTTER_OSES = ['macos', 'linux', 'windows'];
/**
* stable, beta, dev
* @type {Object.<String, Boolean>}
*/
let channelMap = {};
// This can be spot-checked against
// https://docs.flutter.dev/release/archive?tab=windows
// The release URLs are
// - 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
// The old release URLs are
// - https://storage.googleapis.com/flutter_infra/releases/releases_macos.json
// - https://storage.googleapis.com/flutter_infra/releases/releases_linux.json
// - https://storage.googleapis.com/flutter_infra/releases/releases_windows.json
// The data looks like
// {
// "base_url": "https://storage.googleapis.com/flutter_infra/releases",
// "current_release": {
// "beta": "b22742018b3edf16c6cadd7b76d9db5e7f9064b5",
// "dev": "fa5883b78e566877613ad1ccb48dd92075cb5c23",
// "stable": "02c026b03cd31dd3f867e5faeb7e104cce174c5f"
// },
// "releases": [
// {
// "hash": "fa5883b78e566877613ad1ccb48dd92075cb5c23",
// "channel": "dev",
// "version": "2.3.0-16.0.pre",
// "release_date": "2021-05-27T23:58:47.683121Z",
// "archive": "dev/macos/flutter_macos_2.3.0-16.0.pre-dev.zip",
// "sha256": "f572b42d36714e6c58a3ed170b93bb414e2ced3ca4bde5094fbe18061cbcba6c"
// },
// {
// "hash": "02c026b03cd31dd3f867e5faeb7e104cce174c5f",
// "channel": "stable",
// "version": "2.2.1",
// "release_date": "2021-05-27T23:06:07.243882Z",
// "archive": "stable/macos/flutter_macos_2.2.1-stable.zip",
// "sha256": "6373d39ec563c337600baf42a42b258420208e4523d85479373e113d61d748df"
// },
// {
// "hash": "b22742018b3edf16c6cadd7b76d9db5e7f9064b5",
// "channel": "beta",
// "version": "2.2.0",
// "release_date": "2021-05-19T21:14:59.281482Z",
// "archive": "beta/macos/flutter_macos_2.2.0-beta.zip",
// "sha256": "31ab530e708f8d1274712211253a27a4ce7d676f139d30f2ec021df22382f052"
// }
// ]
// }
/**
* @typedef BuildInfo
* @prop {String} version
* @prop {String} [_version]
* @prop {Boolean} lts
* @prop {String} channel
* @prop {String} date
* @prop {String} download
* @prop {String} [_filename]
*/
module.exports = async function () {
let all = {
download: '',
/** @type {Array<BuildInfo>} */
releases: [],
/** @type {Array<String>} */
channels: [],
};
for (let osname of FLUTTER_OSES) {
let resp;
try {
let url = `https://storage.googleapis.com/flutter_infra_release/releases/releases_${osname}.json`;
resp = await Fetcher.fetch(url, {
headers: { Accept: 'application/json' },
});
} catch (e) {
/** @type {Error & { code: string, response: { status: number, body: string } }} */ //@ts-expect-error
let err = e;
if (err.code === 'E_FETCH_RELEASES') {
err.message = `failed to fetch 'flutter' release data for ${osname}: ${err.response.status} ${err.response.body}`;
}
throw e;
}
let data = JSON.parse(resp.body);
let osBaseUrl = data.base_url;
let osReleases = data.releases;
for (let asset of osReleases) {
if (!channelMap[asset.channel]) {
channelMap[asset.channel] = true;
}
all.releases.push({
version: asset.version,
_version: `${asset.version}-${asset.channel}`,
lts: false,
channel: asset.channel,
date: asset.release_date.replace(/T.*/, ''),
download: `${osBaseUrl}/${asset.archive}`,
_filename: asset.archive,
});
}
}
all.channels = Object.keys(channelMap);
// note: versions have a waterfall relationship with channels:
// - a release that is in beta today may become stable tomorrow
// - semver prereleases are either beta or dev
return all;
};
if (module === require.main) {
module.exports().then(function (all) {
all.releases = all.releases.slice(25);
console.info(JSON.stringify(all, null, 2));
});
}

1
fzf/releases.conf Normal file
View File

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

View File

@@ -1,20 +0,0 @@
'use strict';
var github = require('../_common/github.js');
var owner = 'junegunn';
var repo = 'fzf';
module.exports = function () {
return github(null, owner, repo).then(function (all) {
return all;
});
};
if (module === require.main) {
module.exports().then(function (all) {
all = require('../_webi/normalize.js')(all);
all.releases = all.releases.slice(0, 10);
//console.info(JSON.stringify(all));
console.info(JSON.stringify(all, null, 2));
});
}

1
gh/releases.conf Normal file
View File

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

View File

@@ -1,20 +0,0 @@
'use strict';
var github = require('../_common/github.js');
var owner = 'cli';
var repo = 'cli';
module.exports = function () {
return github(null, owner, repo).then(function (all) {
return all;
});
};
if (module === require.main) {
module.exports().then(function (all) {
all = require('../_webi/normalize.js')(all);
// just select the first 5 for demonstration
all.releases = all.releases.slice(0, 5);
console.info(JSON.stringify(all, null, 2));
});
}

4
git/releases.conf Normal file
View File

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

View File

@@ -1,33 +0,0 @@
'use strict';
var github = require('../_common/github.js');
var owner = 'git-for-windows';
var repo = 'git';
module.exports = function () {
// TODO support mac and linux tarballs
return github(null, owner, repo).then(function (all) {
// See https://github.com/git-for-windows/git/wiki/MinGit
// also consider https://github.com/git-for-windows/git/wiki/Silent-or-Unattended-Installation
all.releases = all.releases
.filter(function (rel) {
rel.os = 'windows';
rel._version = rel.version.replace(/\.windows.1.*/, '');
rel._version = rel._version.replace(/\.windows(\.\d)/, '$1');
return (
/MinGit/i.test(rel.name || rel.download) &&
!/busybox/i.test(rel.name || rel.download)
);
})
.slice(0, 20);
all._names = ['MinGit', 'git'];
return all;
});
};
if (module === require.main) {
module.exports().then(function (all) {
all = require('../_webi/normalize.js')(all);
console.info(JSON.stringify(all, null, 2));
});
}

1
gitdeploy/releases.conf Normal file
View File

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

View File

@@ -1,18 +0,0 @@
'use strict';
var github = require('../_common/github.js');
var owner = 'therootcompany';
var repo = 'gitdeploy';
module.exports = function () {
return github(null, owner, repo).then(function (all) {
return all;
});
};
if (module === require.main) {
module.exports().then(function (all) {
all = require('../_webi/normalize.js')(all);
console.info(JSON.stringify(all));
});
}

2
gitea/releases.conf Normal file
View File

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

View File

@@ -1,34 +0,0 @@
'use strict';
var github = require('../_common/github.js');
var owner = 'go-gitea';
var repo = 'gitea';
var ODDITIES = ['-gogit-', '-docs-'];
module.exports = function () {
return github(null, owner, repo).then(function (all) {
// remove checksums and .deb
all.releases = all.releases.filter(function (rel) {
for (let oddity of ODDITIES) {
let isOddity = rel.name.includes(oddity);
if (isOddity) {
return false;
}
}
return true;
});
// "windows-4.0" as a nod to Windows NT ¯\_(ツ)_/¯
all._names = ['gitea', '-4.0-'];
return all;
});
};
if (module === require.main) {
module.exports().then(function (all) {
all = require('../_webi/normalize.js')(all);
console.info(JSON.stringify(all));
});
}

1
gnupg/releases.conf Normal file
View File

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

18
go.mod Normal file
View File

@@ -0,0 +1,18 @@
module github.com/webinstall/webi-installers
go 1.26.1
require (
github.com/jackc/pgx/v5 v5.9.2
github.com/joho/godotenv v1.5.1
github.com/jszwec/csvutil v1.10.0
github.com/therootcompany/golib/http/middleware/v2 v2.0.1
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
)

32
go.sum Normal file
View File

@@ -0,0 +1,32 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/jszwec/csvutil v1.10.0 h1:upMDUxhQKqZ5ZDCs/wy+8Kib8rZR8I8lOR34yJkdqhI=
github.com/jszwec/csvutil v1.10.0/go.mod h1:/E4ONrmGkwmWsk9ae9jpXnv9QT8pLHEPcCirMFhxG9I=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/therootcompany/golib/http/middleware/v2 v2.0.1 h1:VNKpHcwyEW7cMct7/eO4fyrxwIQk2ycb6juVXSPs2Sk=
github.com/therootcompany/golib/http/middleware/v2 v2.0.1/go.mod h1:g5gb9qBidw74nW6/mwIauTKMpOKchiN2l0gt5qzJ2aQ=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

1
go/releases.conf Normal file
View File

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

View File

@@ -1,130 +0,0 @@
'use strict';
let Fetcher = require('../_common/fetcher.js');
/** @type {Object.<String, String>} */
let osMap = {
darwin: 'macos',
};
/** @type {Object.<String, String>} */
let archMap = {
386: 'x86',
};
let ODDITIES = ['bootstrap', '-arm6.'];
/**
* @param {String} filename
*/
function isOdd(filename) {
for (let oddity of ODDITIES) {
let isOddity = filename.includes(oddity);
if (isOddity) {
return true;
}
}
}
/**
* @typedef BuildInfo
* @prop {String} version
* @prop {String} [_version]
* @prop {String} arch
* @prop {String} channel
* @prop {String} date
* @prop {String} download
* @prop {String} ext
* @prop {String} [_filename]
* @prop {String} hash
* @prop {Boolean} lts
* @prop {String} os
*/
async function getDistributables() {
/*
{
version: 'go1.13.8',
stable: true,
files: [
{
filename: 'go1.13.8.src.tar.gz',
os: '',
arch: '',
version: 'go1.13.8',
sha256:
'b13bf04633d4d8cf53226ebeaace8d4d2fd07ae6fa676d0844a688339debec34',
size: 21631178,
kind: 'source'
}
]
};
*/
let resp;
try {
let url = 'https://golang.org/dl/?mode=json&include=all';
resp = await Fetcher.fetch(url, {
headers: { Accept: 'application/json' },
});
} catch (e) {
/** @type {Error & { code: string, response: { status: number, body: string } }} */ //@ts-expect-error
let err = e;
if (err.code === 'E_FETCH_RELEASES') {
err.message = `failed to fetch 'Go' release data: ${err.response.status} ${err.response.body}`;
}
throw e;
}
let goReleases = JSON.parse(resp.body);
let all = {
/** @type {Array<BuildInfo>} */
releases: [],
download: '',
};
for (let release of goReleases) {
// Strip 'go' prefix, standardize version
let parts = release.version.slice(2).split('.');
while (parts.length < 3) {
parts.push('0');
}
let version = parts.join('.');
let fileversion = release.version.slice(2);
for (let asset of release.files) {
if (isOdd(asset.filename)) {
continue;
}
let filename = asset.filename;
let os = osMap[asset.os] || asset.os || '-';
let arch = archMap[asset.arch] || asset.arch || '-';
let build = {
version: version,
_version: fileversion,
lts: (parts[0] > 0 && release.stable) || false,
channel: (release.stable && 'stable') || 'beta',
date: '1970-01-01', // the world may never know
os: os,
arch: arch,
ext: '', // let normalize run the split/test/join
hash: '-', // not ready to standardize this yet
download: `https://dl.google.com/go/${filename}`,
};
all.releases.push(build);
}
}
return all;
}
module.exports = getDistributables;
if (module === require.main) {
getDistributables().then(function (all) {
all = require('../_webi/normalize.js')(all);
//@ts-expect-error
all.releases = all.releases.slice(0, 10);
console.info(JSON.stringify(all, null, 2));
});
}

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