metadata.lua plugin name, version, and .node-version and .nvmrc as legacy files
hooks/available.lua vfox search: the version index
hooks/pre_install.lua vfox install: choose the package, its URL and its checksum
hooks/post_install.lua musl: fail when node cannot run, such as without libstdc++ or libatomic
hooks/env_keys.lua PATH: bin on Linux and macOS, the install directory on Windows
hooks/pre_use.lua vfox use: map 24 or 24.21 to an installed release, note a shadowing node
hooks/parse_legacy_file.lua .nvmrc and .node-version
lib/util.lua mirror URL, musl detection and missing libraries, running a command, version comparison and matching, shorthands, PATH probe
tests/hooks_test.lua offline hook tests
tests/e2e/ container tests, see e2e.md
Injection.lua type hints for what vfox injects, never loaded
.devcontainer/ Linux development container, see e2e.md
.github/workflows/ test, lint, license, e2e and publish
vfox loads metadata.lua, calls a hook when a command needs it, and puts
PLUGIN and RUNTIME (osType, archType, version) in scope, and the
modules http and json. osType and archType are Go's GOOS and GOARCH
of the vfox binary. RUNTIME.envType is read when a vfox provides it (none
does yet), see musl.md. lib/util.lua is
loaded with require("util").
| You run | Hooks |
|---|---|
vfox search nodejs |
Available |
vfox install nodejs@24 |
PreInstall returns the release, URL and checksum, and calls Available for latest and for a start of a release. vfox downloads, verifies and unpacks, then calls PostInstall |
vfox use nodejs@24 |
PreUse returns the installed release to select. EnvKeys provides PATH |
a shell hook, cd into a directory with .nvmrc |
ParseLegacyFile, then EnvKeys |
vfox uninstall nodejs@24.21.0 |
none, the name has to be exact |
A few contracts that cost time to find:
PreInstallreturnsversion,url,sha256and sometimesnote.versionis the name of the install, so a start of a release has to be resolved to the full release here. vfox only warns when there is no checksum (Checksum is not provided) and goes on to download, so since 0.5.0 the hook fails when the package is not inSHASUMS256.txtinstead of returning none. Notes that the hook prints withprintshow up in vfox's output.Availableraises on a network error or a status other than 200. Since 0.5.0 it does not return an empty list, which vfox cached. The result is kept in the globalavailable_resultfor the life of the process, and the tests reset it. vfox caches the search itself for 12 hours.PreUsereturns{ version = nil }for what it cannot map, and vfox answersis not installed. Thectx.installedSdksit gets is a table keyed by release, not an array, which is whycalculate_shorthanditerates withpairs.ParseLegacyFilecallsctx.getInstalledVersions()with no argument. vfox 1.x binds it as a plain Go function and rejects the extra argument thatctx:getInstalledVersions()adds. The strategy isctx.strategy. vfox hands over the installed versions as text sorted descending, so22.9.0comes before22.23.3. The test passes the function as a plain function and asserts that it was called without arguments.PostInstallgetsctx.rootPathandctx.sdkInfo, a table keyed by the name of the SDK (nodejs, or the alias) whosepathis the unpacked release.bin/nodeis there by then. The hook walksctx.sdkInfoinstead of readingctx.sdkInfo.nodejs, so an alias works too.io.popenandos.executework in vfox's Lua.io.popen(...):close()returns the exit status as a number, nottrueas in standard Lua, andutil.capturecopes with both.EnvKeyssetsPATHonly, nothing likeNODE_HOME.- vfox removes a leading
vfrom what the user types before the hooks see it.
PreInstall:
latestbecomes the first entry ofAvailable, which is sorted newest first byutil.compare_versions, numbers one by one.- Anything that is not
x.y.z(^%d+%.%d+%.%d+$,util.is_semver_simple) is looked up inutil.calculate_shorthand(Available), a table from24and24.21to the newest release of each. A miss ends inversion not found for provided version <what the user typed>. - A release that does not exist passes both steps and fails when
SHASUMS256.txtanswers 404:get checksum failed.
PreUse does step 2 against the installed releases. ParseLegacyFile has its own
rules, see below.
PreInstall builds the file name node-v<version>-<os>-<arch><ext>
(util.FileName) from RUNTIME: amd64 is x64, 386 is x86, arm is
armv7l, Windows is win and .zip. macOS arm64 with a major below 16 takes
x64. On Linux util.is_musl() adds -musl for x64 and arm64, and errors
for other CPUs. It then reads <mirror>/v<version>/SHASUMS256.txt, finds the sum
with util.get_checksum, and returns the URL next to the sums. A request that
fails ends in get checksum failed: <error> or get checksum failed: HTTP <status> from <address>. If a musl package is missing there, it tries
UnofficialBaseUrl the same way. The tables of what exists are in
platforms.md and musl.md.
util.getBaseUrl() is the mirror: $VFOX_NODEJS_MIRROR without trailing
slashes, or https://nodejs.org/dist. The unofficial site ignores it.
post_install.lua does nothing outside musl. On musl it fails when no
libstdc++.so.6 is found in the usual library directories
(util.has_libstdcxx). Then it runs the unpacked bin/node --version once with
util.capture: the dynamic loader names a library that is missing (Error loading shared library libatomic.so.1), and the hook turns that into a message
with the package and, on Alpine and OpenWrt, the command (util.MuslLibraries
holds the package names, util.install_command builds the command). Any other
failure, and a runtime that cannot run a command, leave the install alone, so a
check that cannot work never blocks an install. See
musl.md.
ParseLegacyFile reads the file, removes all whitespace, and gives the text to
resolve_version(query, strategy): it drops a leading v, lowers the case,
turns lts-x into lts/x, and maps lts/<codename> to a major, lts and
lts/* to the highest major in nodejs_codenames. Under latest_available the
codename comes from the index first (discover_lts), and the table is the
fallback when that finds nothing or the request fails.
resolve_legacy_version(strategy, query) then reads the query with
util.version_prefix: 22 is { 22 }, 22.23 is { 22, 23 }, and 22.x,
22.x.x and 22.* are { 22 }. Text that is no such prefix (node,
lts/unknown, >=20) is returned as it is. For a prefix:
specified: returns the query.latest_installed: the newest ofctx.getInstalledVersions()that starts with the prefix (util.newest_matching, compared as numbers part by part). If none does, it does whatlatest_availabledoes.latest_available: the newest ofAvailablethat starts with the prefix, or the query itself.
vfox then selects the answer if it is installed. User-facing rules and
examples are in nvmrc.md and lts.md.
Each October an even-numbered line gets a codename, see lts.md. To teach the plugin the new one:
- Add
<codename> = <major>tonodejs_codenamesinhooks/parse_legacy_file.lua. - Add a case next to
legacy lts krypton maps to its majorintests/hooks_test.lua. - Add a row to the table in lts.md, and change the "24 at the time of writing" sentences in lts.md and nvmrc.md.
DefaultLTSVersionintests/e2e/common/common.gomay stay: it only has to stay inside the table.- Release.
The hook tests run offline under gopher-lua, the Lua interpreter vfox embeds:
go run github.com/yuin/gopher-lua/cmd/glua@v1.1.1 tests/hooks_test.luaRun it from the repository root. tests/hooks_test.lua stubs http and json
itself (package.preload), sets RUNTIME per case, and answers requests from
state.response, state.decoded and state.responder. A case is
test("name", function() ... end), which resets the state and available_result.
It covers Available and its failures, the package name per platform and the
errors of a failed checksum request, musl and PostInstall (with util.capture,
util.file_exists and util.has_libstdcxx stubbed through with_util),
ParseLegacyFile under each strategy, PreUse with the shadowing node, and
the helpers of lib/util.lua. Add the case a change needs there.
gopher-lua 1.1.1 loses a local x = nil that a closure assigns: the outer scope
still reads nil. A test that collects a value from inside with_util or
pcall has to keep it in a table field.
To call one hook against the real servers, vfox 1.0.13 and later has
vfox plugin run. It does not register the plugin, --os and --arch answer
for another platform, --offline rejects HTTP, and VFOX_NODEJS_MIRROR applies:
vfox plugin run . Available --json
vfox plugin run . PreInstall --input '{"version":"24"}' --os windows --arch arm64 --json
vfox plugin run . PreUse --input '{"version":"22","installedSdks":{"22.23.3":{"version":"22.23.3"}}}' --json
vfox plugin run . ParseLegacyFile --input '{"filename":".nvmrc","filepath":"/tmp/p/.nvmrc","strategy":"latest_installed","installedVersions":["24.21.0"]}' --json
vfox plugin run . EnvKeys --input '{"path":"C:\\vfox\\nodejs-24.21.0"}' --os windows --arch amd64 --jsonvfox --debug <command> shows what vfox does with a hook, which is how to find
an error it swallows, such as one from ParseLegacyFile.
Lint, license headers and the container tests are in
e2e.md. Lua is formatted with StyLua (stylua.toml: 4
spaces, 120 columns), checked by LuaLS at Warning level (.luarc.json declares
PLUGIN and RUNTIME). Source files (Lua, Go, YAML, PowerShell and the
Dockerfiles) carry the license header, Markdown and JSON do not.
platforms.md was taken from nodejs.org. index.json lists the
releases, newest first, with the files each has, and this prints for each package
the first and the last release that has it, and how many releases in between do
not:
curl -s https://nodejs.org/dist/index.json | jq -r '
def span($key):
[to_entries[] | select(.value.files | index($key))] as $hits
| if ($hits | length) == 0 then "none"
else "\($hits[-1].value.version) to \($hits[0].value.version), \($hits | length) releases, \($hits[-1].key - $hits[0].key + 1 - ($hits | length)) missing in between"
end;
("linux-x64", "linux-arm64", "linux-ppc64le", "linux-s390x", "linux-x86", "linux-x64-musl",
"osx-x64-tar", "osx-arm64-tar", "win-x64-zip", "win-x86-zip", "win-arm64-zip") as $key
| "\($key): \(span($key))"'The keys are the index's names for the plugin's files (osx-x64-tar is
darwin-x64.tar.gz, win-x64-zip is win-x64.zip). The index agrees with the
SHASUMS256.txt files, which are what the plugin reads, except for two releases:
it lists 0.11.4 and 0.11.6 for linux-x64, linux-x86 and macOS x64, and they
have no package. For the musl table run the same program against
https://unofficial-builds.nodejs.org/download/release/index.json with
linux-x64-musl and linux-arm64-musl, and against the official index with
linux-x64-musl. Each answers for one site, so combine the two.
- 32-bit ARM takes the
linux-armv7lpackage, up to 23.11.1. The ARMv6 package (linux-armv6l) is never chosen, and from 24 on nothing exists for 32-bit ARM. - riscv64 and loong64 only have packages on Unofficial Builds, which the plugin uses for musl only.
PostInstallrunsnode --versionon musl only. On glibc a missinglibatomic.so.1(Node.js 25 and later) shows whennoderuns: the README leaves system libraries to the user.- The e2e Alpine image gets
libatomicthroughgo, which depends ongcc, so the suite does not cover a stock Alpine without it. The hook tests do. .nvmrcaliases (node,stable,lts/-1), ranges and comments are not understood, andltsfollows a hand-made table, see nvmrc.md.latest_availabledownloadsindex.jsonat every prompt in a directory with a legacy file.- The unofficial site is not covered by
VFOX_NODEJS_MIRROR. - macOS has no e2e job.
A maintainer runs Actions → Plugin → Run workflow on the default branch with
the version, without v. The README describes what the shared workflow does.
Afterwards plugins/nodejs.json in
vfox-plugins shows the version
that vfox add nodejs serves.
While main is ahead of the last release, the documentation marks what is newer
with "Unreleased" ("未发布" in Chinese): the entries sit under an "Unreleased"
heading at the top of changes.md, a page that describes the new
behavior adds a remark that starts with "Unreleased: release X.Y.Z" and tells
what the last release does, and the README has a paragraph that explains the
mark and points at changes.md and
source-build.md. After a release, move the entries in
changes.md under the new version, with the pull request of each,
and take the remarks and the README paragraph out, in both languages:
grep -rn -i -e unreleased -e 未发布 docs README*. Also search for the number of
the previous release: a few sentences compare against it without saying
"Unreleased".
English pages are in docs/, the Chinese ones in docs/zh-CN/ under the same
names (the Chinese README is README_CN.md), and each page starts with a link to
the other language. Prose is wrapped at 80 columns in the English pages, tables
are not. Pages that quote data give the day it was taken (the platform and musl
tables, the mirror list, the LTS table, the outputs of vfox search), and
nothing checks them. CI does not lint Markdown, so run the commands you add, and
the outputs in a page are the ones they printed. When a change in the plugin
makes a page wrong, change the page and its translation in the same pull request.
A link to page.md#heading in a Chinese page uses the heading of the Chinese
page, whose address is not the English one.