From 0aba5f5530cdc7528530e8d66339a419cb67b65b Mon Sep 17 00:00:00 2001 From: Hansehart <97880342+Hansehart@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:02:14 +0000 Subject: [PATCH 01/10] feat(node): add the Node.js feature Installs the runtime from the official prebuilt tarball into /usr/local and verifies it against SHASUMS256.txt, rather than following the upstream node feature's nvm install. nvm exists to switch versions at runtime, which a pinned dev container does not need, and it would be the only such layer in this repo: every other feature here resolves a version, checks it, and drops a binary on PATH. npm's global prefix defaults to the dev user's ~/.local so `npm i -g` needs no root, matching the ~/.local/bin convention claude-code, codex and uv already set. With a stateDir it moves to the volume alongside npm's cache and user config, so globally installed CLIs survive a rebuild. The npmrc option follows settingsJson/configToml/daemonJson: baked at build, written by the run-once hook, and restored if something overwrites it. Note the tarball ships node, npm and npx only. There is no compiler, so packages needing node-gyp will not build, and corepack is gone as of Node 26, so yarn and pnpm need an explicit npm i -g. --- .github/workflows/test.yaml | 2 +- README.md | 1 + src/node/devcontainer-feature.json | 26 ++++++++++ src/node/init.sh | 16 +++++++ src/node/install.sh | 77 ++++++++++++++++++++++++++++++ test/node/npmrc.sh | 21 ++++++++ test/node/pinned_version.sh | 10 ++++ test/node/scenarios.json | 27 +++++++++++ test/node/state_dir.sh | 20 ++++++++ test/node/test.sh | 16 +++++++ 10 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 src/node/devcontainer-feature.json create mode 100644 src/node/init.sh create mode 100644 src/node/install.sh create mode 100644 test/node/npmrc.sh create mode 100644 test/node/pinned_version.sh create mode 100644 test/node/scenarios.json create mode 100644 test/node/state_dir.sh create mode 100644 test/node/test.sh diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 4e4bd16..50fbb33 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - feature: [age, claude-code, codex, docker-in-docker, headless-chrome, latex, sops, uv] + feature: [age, claude-code, codex, docker-in-docker, headless-chrome, latex, node, sops, uv] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Install the dev container CLI diff --git a/README.md b/README.md index 5fdc54a..e0be5e6 100644 --- a/README.md +++ b/README.md @@ -8,5 +8,6 @@ | **Docker in Docker** | A Docker engine for building and running containers inside the dev container. | `ghcr.io/hansehart/devcontainer-features/docker-in-docker` | | **Headless Chrome** | A headless Chrome build for browser automation and rendering. | `ghcr.io/hansehart/devcontainer-features/headless-chrome` | | **LaTeX (TeX Live)** | The TeX Live distribution for typesetting LaTeX documents and bibliographies. | `ghcr.io/hansehart/devcontainer-features/latex` | +| **Node.js** | The Node.js JavaScript runtime, with npm and npx for installing and running packages. | `ghcr.io/hansehart/devcontainer-features/node` | | **sops** | A tool for encrypting, editing, and injecting secrets in config files. | `ghcr.io/hansehart/devcontainer-features/sops` | | **uv (Python)** | Astral's fast Python package and interpreter manager. | `ghcr.io/hansehart/devcontainer-features/uv` | diff --git a/src/node/devcontainer-feature.json b/src/node/devcontainer-feature.json new file mode 100644 index 0000000..f77b359 --- /dev/null +++ b/src/node/devcontainer-feature.json @@ -0,0 +1,26 @@ +{ + "id": "node", + "version": "1.0.0", + "name": "Node.js", + "description": "Installs the Node.js runtime with npm and npx on a selectable channel.", + "documentationURL": "https://github.com/hansehart/devcontainer-features/tree/main/src/node", + "options": { + "version": { + "type": "string", + "proposals": ["latest"], + "default": "latest", + "description": "Channel (latest) or an exact version (major.minor.patch)." + }, + "stateDir": { + "type": "string", + "default": "", + "description": "If set, exports npm's cache, global prefix, and user config to this path (persist them by mounting a volume there). Empty puts the global prefix in the dev user's ~/.local." + }, + "npmrc": { + "type": "string", + "default": "", + "description": "npm config as INI with \\n line breaks, written to npm's user config file." + } + }, + "postCreateCommand": "/usr/local/share/node/init.sh" +} diff --git a/src/node/init.sh b/src/node/init.sh new file mode 100644 index 0000000..a8c83fa --- /dev/null +++ b/src/node/init.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Prepare npm's config once the volume is mounted. +if [ -r /etc/profile.d/node.sh ]; then . /etc/profile.d/node.sh; fi + +# Create the state dir once the volume is mounted. +if [ -n "${NPM_CONFIG_CACHE:-}" ]; then mkdir -p "${NPM_CONFIG_CACHE}" "${NPM_CONFIG_PREFIX}"; fi + +# Write the requested config to .npmrc (empty leaves the file untouched). +req=/usr/local/share/node/requested-npmrc +if [ -s "$req" ]; then + target="${NPM_CONFIG_USERCONFIG:-$HOME/.npmrc}" + mkdir -p "$(dirname "$target")" + printf '%b\n' "$(cat "$req")" > "$target" +fi diff --git a/src/node/install.sh b/src/node/install.sh new file mode 100644 index 0000000..a9331b4 --- /dev/null +++ b/src/node/install.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Options (uppercased by the CLI): VERSION, STATEDIR, NPMRC. +STATE_DIR="$STATEDIR" + +export DEBIAN_FRONTEND=noninteractive + +# Dependencies: packages this feature needs to install and run. +apt-get update +apt-get install -y --no-install-recommends \ + ca-certificates \ + curl +rm -rf /var/lib/apt/lists/* + +# Resolve: map the CPU arch to Node's release arch token. +arch="$(uname -m)" +case "$arch" in + x86_64 | amd64) nodearch="x64" ;; + aarch64 | arm64) nodearch="arm64" ;; + *) echo "node: unsupported architecture '$arch'" >&2; exit 1 ;; +esac + +# Resolve: asset names embed the version, so read the newest tag from the release index (explicit versions pass through). +base="https://nodejs.org/dist" +case "${VERSION:-latest}" in + latest) + tag="$(curl -fsSL "$base/index.json" | grep -oP '"version":"\Kv[^"]+' | head -n 1)" + [ -n "$tag" ] || { echo "node: could not resolve the latest version" >&2; exit 1; } ;; + v*) tag="$VERSION" ;; + *) tag="v$VERSION" ;; +esac +asset="node-$tag-linux-$nodearch.tar.gz" + +# Fetch: download the tarball and verify it against Node's published checksums. +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +curl -fsSL "$base/$tag/$asset" -o "$tmp/$asset" +curl -fsSL "$base/$tag/SHASUMS256.txt" -o "$tmp/SHASUMS256.txt" +( cd "$tmp" && grep " $asset\$" SHASUMS256.txt | sha256sum -c - ) + +# Install: extract into /usr/local, as root, so node, npm, and npx land on the default PATH. +tar -xzf "$tmp/$asset" -C /usr/local --strip-components=1 --no-same-owner \ + --exclude=CHANGELOG.md --exclude=LICENSE --exclude=README.md + +# Configure: login-shell profile with a global prefix the dev user owns, so npm -g needs no root. +{ + echo 'export PATH="$HOME/.local/bin:$PATH"' + if [ -n "$STATE_DIR" ]; then + echo "export NPM_CONFIG_CACHE=\"$STATE_DIR/cache\"" + echo "export NPM_CONFIG_PREFIX=\"$STATE_DIR/global\"" + echo "export NPM_CONFIG_USERCONFIG=\"$STATE_DIR/npmrc\"" + echo "export PATH=\"$STATE_DIR/global/bin:\$PATH\"" + else + echo 'export NPM_CONFIG_PREFIX="$HOME/.local"' + fi +} > /etc/profile.d/node.sh +chmod 0644 /etc/profile.d/node.sh + +# Configure: own the state dir by a dedicated group so it stays writable after a UID remap. +if [ -n "$STATE_DIR" ]; then + groupadd -r -f node + usermod -aG node "$_REMOTE_USER" || true + install -d -m 0770 "$STATE_DIR" + chown "$_REMOTE_USER:node" "$STATE_DIR" + chmod g+s "$STATE_DIR" +fi + +# Hook: install the run-once hook and save the requested config for it to write. +install -d /usr/local/share/node +install -m 0755 "$(dirname "$0")/init.sh" /usr/local/share/node/init.sh +printf '%s' "$NPMRC" > /usr/local/share/node/requested-npmrc + +# Verify: the runtime and its package tooling resolve on PATH. +node --version +npm --version +command -v npx >/dev/null diff --git a/test/node/npmrc.sh b/test/node/npmrc.sh new file mode 100644 index 0000000..c750b13 --- /dev/null +++ b/test/node/npmrc.sh @@ -0,0 +1,21 @@ +#!/bin/bash +set -e + +# Import the test library +source dev-container-features-test-lib + +# The test harness does not run the hook, so invoke it here to apply the baked config. +/usr/local/share/node/init.sh + +target="${NPM_CONFIG_USERCONFIG:-$HOME/.npmrc}" +check "npmrc written" grep -qF 'audit-level=low' "$target" +check "escaped newlines expanded" bash -c "[ \"\$(wc -l < '$target')\" -eq 2 ]" +check "npm reads the config" bash -lc "npm config get audit-level | grep -qF 'low'" + +# The hook owns the config file, so a re-run restores the requested config. +printf 'audit-level=critical\n' > "$target" +/usr/local/share/node/init.sh +check "npmrc restored on re-run" grep -qF 'audit-level=low' "$target" + +# Report result +reportResults diff --git a/test/node/pinned_version.sh b/test/node/pinned_version.sh new file mode 100644 index 0000000..5316f8a --- /dev/null +++ b/test/node/pinned_version.sh @@ -0,0 +1,10 @@ +#!/bin/bash +set -e + +# Import the test library +source dev-container-features-test-lib + +check "pinned to 22.11.0" bash -c "node --version | grep -qF 'v22.11.0'" + +# Report result +reportResults diff --git a/test/node/scenarios.json b/test/node/scenarios.json new file mode 100644 index 0000000..b23c81f --- /dev/null +++ b/test/node/scenarios.json @@ -0,0 +1,27 @@ +{ + "npmrc": { + "image": "ubuntu:24.04", + "features": { + "node": { + "npmrc": "ignore-scripts=true\\naudit-level=low" + } + } + }, + "pinned_version": { + "image": "ubuntu:24.04", + "features": { + "node": { + "version": "22.11.0" + } + } + }, + "state_dir": { + "image": "ubuntu:24.04", + "remoteUser": "ubuntu", + "features": { + "node": { + "stateDir": "/var/node" + } + } + } +} diff --git a/test/node/state_dir.sh b/test/node/state_dir.sh new file mode 100644 index 0000000..63b8c89 --- /dev/null +++ b/test/node/state_dir.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -e + +# Import the test library +source dev-container-features-test-lib + +# Runs as a non-root remoteUser, which the CLI may remap to a different UID at build time. +check "state dir pre-created" test -d /var/node +check "dev user in the state dir group" bash -c 'id -nG | grep -qw node' +check "state dir writable by the dev user" bash -c 'touch /var/node/.probe && rm /var/node/.probe' + +# The test harness does not run the hook, so invoke it here. +/usr/local/share/node/init.sh + +check "NPM_CONFIG_CACHE exported" bash -lc '[ "$NPM_CONFIG_CACHE" = /var/node/cache ]' +check "NPM_CONFIG_USERCONFIG exported" bash -lc '[ "$NPM_CONFIG_USERCONFIG" = /var/node/npmrc ]' +check "global prefix in the state dir" bash -lc '[ "$(npm prefix -g)" = /var/node/global ]' + +# Report result +reportResults diff --git a/test/node/test.sh b/test/node/test.sh new file mode 100644 index 0000000..538ae00 --- /dev/null +++ b/test/node/test.sh @@ -0,0 +1,16 @@ +#!/bin/bash +set -e + +# Import the test library +source dev-container-features-test-lib + +# Use a login shell so the profile.d snippet setting the npm prefix is sourced. +check "node on PATH" bash -lc "command -v node" +check "node version" bash -lc "node --version" +check "npm on PATH" bash -lc "command -v npm" +check "npx on PATH" bash -lc "command -v npx" +check "global prefix in the dev user's home" bash -lc '[ "$(npm prefix -g)" = "$HOME/.local" ]' +check "global prefix writable by the dev user" bash -lc 'mkdir -p "$(npm prefix -g)/lib" && touch "$(npm prefix -g)/lib/.probe" && rm "$(npm prefix -g)/lib/.probe"' + +# Report result +reportResults From 4e56b36364a670aaa0bc7718bc29af9b489cedcd Mon Sep 17 00:00:00 2001 From: Hansehart <97880342+Hansehart@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:02:21 +0000 Subject: [PATCH 02/10] docs: normalize the descriptions and section comments sops was the only feature claiming ", verified against its published checksums" in its description, though uv verifies checksums too and says nothing. The claim belongs in install.sh, where it already is, not in the feature listing. The stateDir options had split into two idioms. sops now uses the "If set, exports X ... (persist it by mounting a volume there). Empty ..." shape that claude-code and codex already used. install.sh comments use a closed vocabulary of section tags. docker-in-docker had "Repo:", the only tag outside it, and tagged the entrypoint install as "Install:" after a "Configure:" block, which broke the shared order. It is the same ship-a-script step the other features label "Hook:". The init.sh hooks said "once its volume is mounted" in claude-code and codex but "once the volume is mounted" in sops and uv. No version bumps: these change prose only, following 5b2b440. --- src/claude-code/init.sh | 4 ++-- src/codex/init.sh | 4 ++-- src/docker-in-docker/install.sh | 4 ++-- src/sops/devcontainer-feature.json | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/claude-code/init.sh b/src/claude-code/init.sh index 7c2f25d..124c298 100644 --- a/src/claude-code/init.sh +++ b/src/claude-code/init.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash set -euo pipefail -# Prepare Claude's config once its volume is mounted. +# Prepare Claude's config once the volume is mounted. if [ -r /etc/profile.d/claude-code.sh ]; then . /etc/profile.d/claude-code.sh; fi -# Create the state dir once its volume is mounted. +# Create the state dir once the volume is mounted. if [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then mkdir -p "${CLAUDE_CONFIG_DIR}"; fi # Write the requested settings to settings.json (empty leaves the file untouched). diff --git a/src/codex/init.sh b/src/codex/init.sh index 38f7aad..af0df60 100644 --- a/src/codex/init.sh +++ b/src/codex/init.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash set -euo pipefail -# Prepare Codex's config once its volume is mounted. +# Prepare Codex's config once the volume is mounted. if [ -r /etc/profile.d/codex.sh ]; then . /etc/profile.d/codex.sh; fi -# Create the state dir once its volume is mounted (Codex errors on a missing CODEX_HOME). +# Create the state dir once the volume is mounted (Codex errors on a missing CODEX_HOME). if [ -n "${CODEX_HOME:-}" ]; then mkdir -p "${CODEX_HOME}"; fi # Write the requested config to config.toml (empty leaves the file untouched). diff --git a/src/docker-in-docker/install.sh b/src/docker-in-docker/install.sh index 36670f7..1047e64 100644 --- a/src/docker-in-docker/install.sh +++ b/src/docker-in-docker/install.sh @@ -15,7 +15,7 @@ apt-get install -y --no-install-recommends \ iptables \ pigz -# Repo: add Docker's official apt repository and signing key. +# Dependencies: add Docker's official apt repository and signing key. install -m 0755 -d /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc chmod a+r /etc/apt/keyrings/docker.asc @@ -53,7 +53,7 @@ if [ -n "$DAEMON_JSON" ]; then printf '%s\n' "$DAEMON_JSON" > /etc/docker/daemon.json fi -# Install: the entrypoint that starts dockerd at container start, then execs the container command. +# Hook: install the entrypoint that starts dockerd at container start, then execs the container command. install -d /usr/local/share/docker-in-docker install -m 0755 "$(dirname "$0")/docker-init.sh" /usr/local/share/docker-in-docker/docker-init.sh diff --git a/src/sops/devcontainer-feature.json b/src/sops/devcontainer-feature.json index d40d23e..b0140aa 100644 --- a/src/sops/devcontainer-feature.json +++ b/src/sops/devcontainer-feature.json @@ -2,7 +2,7 @@ "id": "sops", "version": "1.0.2", "name": "sops", - "description": "Installs the sops secret editor as a single static binary, verified against its published checksums.", + "description": "Installs the sops secret editor as a single static binary.", "documentationURL": "https://github.com/hansehart/devcontainer-features/tree/main/src/sops", "options": { "version": { @@ -14,7 +14,7 @@ "stateDir": { "type": "string", "default": "", - "description": "Points SOPS_AGE_KEY_FILE at /keys.txt (mount a volume to persist the age key across rebuilds). Empty leaves SOPS_AGE_KEY_FILE unset." + "description": "If set, exports SOPS_AGE_KEY_FILE to /keys.txt (persist it by mounting a volume there). Empty leaves SOPS_AGE_KEY_FILE unset." } }, "dependsOn": { From 87c0d66d5576da7547039cee0b5de7e7949b512d Mon Sep 17 00:00:00 2001 From: Hansehart <97880342+Hansehart@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:02:32 +0000 Subject: [PATCH 03/10] refactor(latex,uv): put the install.sh sections in the shared order Every install.sh tags its sections from the same vocabulary and runs them in the order Dependencies, Resolve, Fetch, Install, Configure, Hook, Verify. Two features broke it. uv baked its default Python in a "Configure:" block after the "Hook:" block. The block only needs the profile, which is written earlier, so it moves up. latex wrote config.env and installed lib.sh and init.sh under "Configure:" before the install branch. That is the hook install, so it is retagged and moved after the branch. install_texlive() reads SCHEME, REPO and INSTALLER_DIR from install.sh's own scope rather than from config.env, so nothing in the branch depends on what the moved block writes, and the hook is not needed until postCreate. Its state dir group block also lost the "Configure:" tag when nested in the else branch. latex keeps Fetch before Resolve: install-tl -print-platform cannot run until the installer is downloaded, as the comment says. Both also pick up the stateDir description idiom. Patch bumps because the shipped install.sh changed and the publish action only pushes new versions. --- src/latex/devcontainer-feature.json | 4 ++-- src/latex/install.sh | 28 ++++++++++++++-------------- src/uv/devcontainer-feature.json | 4 ++-- src/uv/install.sh | 8 ++++---- 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/latex/devcontainer-feature.json b/src/latex/devcontainer-feature.json index 4fe46c2..17f2395 100644 --- a/src/latex/devcontainer-feature.json +++ b/src/latex/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "latex", - "version": "1.1.1", + "version": "1.1.2", "name": "LaTeX (TeX Live)", "description": "Installs TeX Live from a pinned tlnet-final snapshot (selectable scheme) and puts the binaries on PATH.", "documentationURL": "https://github.com/hansehart/devcontainer-features/tree/main/src/latex", @@ -20,7 +20,7 @@ "stateDir": { "type": "string", "default": "", - "description": "Installs TeX Live here at container create and reuses it across rebuilds (mount a volume to persist it). Empty installs into the image at build time." + "description": "If set, installs TeX Live to this path at container create and reuses it on rebuilds (persist it by mounting a volume there). Empty installs into the image at build time." } }, "customizations": { diff --git a/src/latex/install.sh b/src/latex/install.sh index a166f7a..fd7fc83 100644 --- a/src/latex/install.sh +++ b/src/latex/install.sh @@ -35,19 +35,7 @@ cp -a "${boot}"/. "${INSTALLER_DIR}"/ # Resolve: the TeX Live platform id names the binary dir (needs the fetched installer). PLAT="$("${INSTALLER_DIR}/install-tl" -print-platform)" -# Configure: bake the hook's config and install the shared lib + hook script. -{ - echo "STATE_DIR=\"${STATE_DIR}\"" - echo "VERSION=\"${VERSION}\"" - echo "SCHEME=\"${SCHEME}\"" - echo "REPO=\"${REPO}\"" - echo "PLAT=\"${PLAT}\"" - echo "INSTALLER_DIR=\"${INSTALLER_DIR}\"" -} > "${SHARE_DIR}/config.env" -install -m 0644 "$(dirname "$0")/lib.sh" "${SHARE_DIR}/lib.sh" -install -m 0755 "$(dirname "$0")/init.sh" "${SHARE_DIR}/init.sh" - -# Install into the image directly, or set PATH and defer to the hook when a stateDir is set. +# Install: TeX Live into the image directly, or set PATH and defer to the hook when a stateDir is set. if [ -z "${STATE_DIR}" ]; then TEXDIR="/usr/local/texlive/${VERSION}" install_texlive "${TEXDIR}" @@ -55,7 +43,7 @@ if [ -z "${STATE_DIR}" ]; then else echo "export PATH=\"${STATE_DIR}/texlive/${VERSION}/bin/${PLAT}:\$PATH\"" > /etc/profile.d/latex.sh chmod 0644 /etc/profile.d/latex.sh - # Own the state dir by a dedicated group so it stays writable after a UID remap. + # Configure: own the state dir by a dedicated group so it stays writable after a UID remap. groupadd -r -f latex usermod -aG latex "$_REMOTE_USER" || true install -d -m 0770 "${STATE_DIR}" @@ -63,5 +51,17 @@ else chmod g+s "${STATE_DIR}" fi +# Hook: bake the hook's config and install the shared lib + hook script. +{ + echo "STATE_DIR=\"${STATE_DIR}\"" + echo "VERSION=\"${VERSION}\"" + echo "SCHEME=\"${SCHEME}\"" + echo "REPO=\"${REPO}\"" + echo "PLAT=\"${PLAT}\"" + echo "INSTALLER_DIR=\"${INSTALLER_DIR}\"" +} > "${SHARE_DIR}/config.env" +install -m 0644 "$(dirname "$0")/lib.sh" "${SHARE_DIR}/lib.sh" +install -m 0755 "$(dirname "$0")/init.sh" "${SHARE_DIR}/init.sh" + # Verify: build-time install resolves on PATH, and the hook verifies stateDir mode. [ -n "${STATE_DIR}" ] || latex --version diff --git a/src/uv/devcontainer-feature.json b/src/uv/devcontainer-feature.json index 5f71f4f..6fdb67d 100644 --- a/src/uv/devcontainer-feature.json +++ b/src/uv/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "uv", - "version": "1.1.3", + "version": "1.1.4", "name": "uv (Python)", "description": "Installs the uv Python package and interpreter manager as a single static binary.", "documentationURL": "https://github.com/hansehart/devcontainer-features/tree/main/src/uv", @@ -20,7 +20,7 @@ "stateDir": { "type": "string", "default": "", - "description": "Points uv's cache, tools, and managed-Python dirs here (mount a volume to persist them across rebuilds) and sets UV_LINK_MODE=copy. Empty leaves uv's ~/.local defaults." + "description": "If set, exports uv's cache, tools, and managed-Python dirs to this path (persist them by mounting a volume there) and sets UV_LINK_MODE=copy. Empty leaves uv's ~/.local defaults." } }, "customizations": { diff --git a/src/uv/install.sh b/src/uv/install.sh index ab268ce..01ecd86 100644 --- a/src/uv/install.sh +++ b/src/uv/install.sh @@ -64,15 +64,15 @@ if [ -n "$STATE_DIR" ]; then chmod g+s "$STATE_DIR" fi -# Hook: install the create-state-dir hook to run once at container create. -install -d /usr/local/share/uv -install -m 0755 "$(dirname "$0")/init.sh" /usr/local/share/uv/init.sh - # Configure: optionally bake a default Python so python3 exists at open. if [ -n "$PYTHON_VERSION" ]; then su - "$_REMOTE_USER" -c \ "env -u UV_PYTHON_INSTALL_DIR uv python install --default --preview-features python-install-default '$PYTHON_VERSION'" fi +# Hook: install the create-state-dir hook to run once at container create. +install -d /usr/local/share/uv +install -m 0755 "$(dirname "$0")/init.sh" /usr/local/share/uv/init.sh + # Verify: uv resolves on PATH. uv --version From 9e2f1ff5f6a26ade288ef115cf266903a8105aac Mon Sep 17 00:00:00 2001 From: Hansehart <97880342+Hansehart@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:02:32 +0000 Subject: [PATCH 04/10] test(codex): resolve the config target like claude-code The claude-code test resolves its target as ${CLAUDE_CONFIG_DIR:-$HOME/.claude}, so it follows the state dir when one is set. Its codex twin hardcoded $HOME/.codex, which happens to work only because the scenario sets no stateDir. --- test/codex/config_toml.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/codex/config_toml.sh b/test/codex/config_toml.sh index 4634dea..d4f7074 100644 --- a/test/codex/config_toml.sh +++ b/test/codex/config_toml.sh @@ -7,7 +7,7 @@ source dev-container-features-test-lib # The test harness does not run the hook, so invoke it here to apply the baked config. /usr/local/share/codex/init.sh -target="$HOME/.codex/config.toml" +target="${CODEX_HOME:-$HOME/.codex}/config.toml" check "config written" grep -qF 'approval_policy = "untrusted"' "$target" check "escaped newlines expanded" bash -c "[ \"\$(wc -l < '$target')\" -eq 2 ]" From 847fe90268f47d0dd652a99f8386c19798c96354 Mon Sep 17 00:00:00 2001 From: Hansehart <97880342+Hansehart@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:25:03 +0000 Subject: [PATCH 05/10] feat(node): resolve LTS and major release lines Node keeps several supported lines alive at once (today 26 Current, 24 Active LTS, 22/20 Maintenance), so "newest, or one exact patch" did not cover the normal ask. lts resolved to the tag vlts and 24 to v24, both 404s at download time, leaving an exact patch as the only way to hold a line -- and Renovate bumps the feature version, not an option value inside it, so that pin had to be moved by hand. index.json carries "lts": false | "" per release, so the channel and the lines fall out of the file install.sh already downloads. No nvm: that layer exists to switch versions at runtime, which a pinned dev container does not need. The default moves from latest to lts. latest is Node's Current line, which upstream does not recommend for production, so it sat where headless-chrome puts dev and canary rather than where that feature puts its default: latest|stable both resolve to key="Stable" there, two majors behind Canary. The invariant across this repo is that the default gives you the upstream's stable track. Aliasing latest to LTS would have kept the uniform default, but nodejs.org/dist/latest really does mean Current and contradicting upstream's own vocabulary is the worse surprise. Partial versions come free: the three-part glob does not match 24.19, which falls through to the line branch and pins a minor line. Also fixes a SIGPIPE this branch shipped in 0aba5f5. The tag came from `grep -oP ... | head -n 1`, and grep emits 860 lines into a head that exits after one; under `set -euo pipefail` the closed pipe kills grep with 141 and aborts the install. It went unnoticed because the feature was never built here -- no Docker in this environment -- and because a `printf | grep -m1` variant fails the same way while a pre-filtered pipeline survives on buffering alone. grep -m1 now reads a here-string, which bash backs with a temp file, and `|| true` lets a no-match reach the explicit error instead of dying silently under set -e. No other feature has the pattern; age, sops and headless-chrome pipe a source that emits a single match. Scenarios cover both new forms with assertions that survive a floating patch: the 24 line by prefix, and lts by the resolved major being even, which is the property that identifies an LTS line under Node's release policy. --- src/node/devcontainer-feature.json | 6 +++--- src/node/install.sh | 18 +++++++++++------- test/node/lts_channel.sh | 12 ++++++++++++ test/node/major_line.sh | 11 +++++++++++ test/node/scenarios.json | 16 ++++++++++++++++ 5 files changed, 53 insertions(+), 10 deletions(-) create mode 100644 test/node/lts_channel.sh create mode 100644 test/node/major_line.sh diff --git a/src/node/devcontainer-feature.json b/src/node/devcontainer-feature.json index f77b359..c4c74a0 100644 --- a/src/node/devcontainer-feature.json +++ b/src/node/devcontainer-feature.json @@ -7,9 +7,9 @@ "options": { "version": { "type": "string", - "proposals": ["latest"], - "default": "latest", - "description": "Channel (latest) or an exact version (major.minor.patch)." + "proposals": ["lts", "latest", "24", "22"], + "default": "lts", + "description": "Channel (lts|latest), a major line (24 or 24.19), or an exact version (major.minor.patch)." }, "stateDir": { "type": "string", diff --git a/src/node/install.sh b/src/node/install.sh index a9331b4..58c392a 100644 --- a/src/node/install.sh +++ b/src/node/install.sh @@ -21,15 +21,19 @@ case "$arch" in *) echo "node: unsupported architecture '$arch'" >&2; exit 1 ;; esac -# Resolve: asset names embed the version, so read the newest tag from the release index (explicit versions pass through). +# Resolve: asset names embed the version, so map the channel or line to a tag from the release index (explicit versions pass through). base="https://nodejs.org/dist" -case "${VERSION:-latest}" in - latest) - tag="$(curl -fsSL "$base/index.json" | grep -oP '"version":"\Kv[^"]+' | head -n 1)" - [ -n "$tag" ] || { echo "node: could not resolve the latest version" >&2; exit 1; } ;; - v*) tag="$VERSION" ;; - *) tag="v$VERSION" ;; +index="$(curl -fsSL "$base/index.json")" +case "${VERSION:-lts}" in + v[0-9]*.[0-9]*.[0-9]*) tag="$VERSION" ;; + [0-9]*.[0-9]*.[0-9]*) tag="v$VERSION" ;; + # grep -m1 reads a here-string, not a pipe: stopping early on a pipe leaves the writer on a + # closed pipe, and pipefail turns that SIGPIPE into a failed install. + latest) tag="$(grep -m1 -oP '"version":"\Kv[^"]+' <<< "$index" || true)" ;; + lts) tag="$(grep -m1 '"lts":"' <<< "$index" | grep -oP '"version":"\Kv[^"]+' || true)" ;; + *) tag="$(grep -m1 -F "\"version\":\"v${VERSION#v}." <<< "$index" | grep -oP '"version":"\Kv[^"]+' || true)" ;; esac +[ -n "$tag" ] || { echo "node: could not resolve version '$VERSION'" >&2; exit 1; } asset="node-$tag-linux-$nodearch.tar.gz" # Fetch: download the tarball and verify it against Node's published checksums. diff --git a/test/node/lts_channel.sh b/test/node/lts_channel.sh new file mode 100644 index 0000000..c9bb783 --- /dev/null +++ b/test/node/lts_channel.sh @@ -0,0 +1,12 @@ +#!/bin/bash +set -e + +# Import the test library +source dev-container-features-test-lib + +# The channel floats, so assert the property that identifies it: Node only ever promotes +# even-numbered majors to LTS, so an odd major means the wrong line was resolved. +check "resolved an LTS line" bash -c "node --version | grep -qE '^v[0-9]*[02468]\.'" + +# Report result +reportResults diff --git a/test/node/major_line.sh b/test/node/major_line.sh new file mode 100644 index 0000000..17b6657 --- /dev/null +++ b/test/node/major_line.sh @@ -0,0 +1,11 @@ +#!/bin/bash +set -e + +# Import the test library +source dev-container-features-test-lib + +# The patch floats, so assert the line only. +check "resolved the 24 line" bash -c "node --version | grep -qE '^v24\.'" + +# Report result +reportResults diff --git a/test/node/scenarios.json b/test/node/scenarios.json index b23c81f..92b9544 100644 --- a/test/node/scenarios.json +++ b/test/node/scenarios.json @@ -1,4 +1,20 @@ { + "lts_channel": { + "image": "ubuntu:24.04", + "features": { + "node": { + "version": "lts" + } + } + }, + "major_line": { + "image": "ubuntu:24.04", + "features": { + "node": { + "version": "24" + } + } + }, "npmrc": { "image": "ubuntu:24.04", "features": { From e3314d09f794399f81fc02ee2e1ca5903f3622dc Mon Sep 17 00:00:00 2001 From: Hansehart <97880342+Hansehart@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:16:19 +0000 Subject: [PATCH 06/10] fix(node): resolve the tag independently of the index formatting The greps matched one release per line, which held only because nodejs.org serves index.json pretty-printed. Nothing documents that. Against a minified index the lts branch returned v26.8.1, the Current line, silently mislabelled as LTS -- it fails by answering wrongly rather than by erroring, which is the failure mode worth removing. tr '}' '\n' puts one release per line whichever way the file arrives, so both shapes now resolve identically. curl | tr cannot SIGPIPE, since tr reads to EOF. latest was the only resolve branch without a scenario, and the branch that shipped the pipe bug in 847fe90. Its assertion is deliberately weak because the build is the real check: a resolution mistake aborts the install before the script runs. The description said "on a selectable channel", which stopped being true when the option grew release lines. --- src/node/devcontainer-feature.json | 2 +- src/node/install.sh | 3 ++- test/node/latest_channel.sh | 12 ++++++++++++ test/node/scenarios.json | 8 ++++++++ 4 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 test/node/latest_channel.sh diff --git a/src/node/devcontainer-feature.json b/src/node/devcontainer-feature.json index c4c74a0..c00c951 100644 --- a/src/node/devcontainer-feature.json +++ b/src/node/devcontainer-feature.json @@ -2,7 +2,7 @@ "id": "node", "version": "1.0.0", "name": "Node.js", - "description": "Installs the Node.js runtime with npm and npx on a selectable channel.", + "description": "Installs the Node.js runtime with npm and npx on a selectable channel or release line.", "documentationURL": "https://github.com/hansehart/devcontainer-features/tree/main/src/node", "options": { "version": { diff --git a/src/node/install.sh b/src/node/install.sh index 58c392a..0d2cc6a 100644 --- a/src/node/install.sh +++ b/src/node/install.sh @@ -23,7 +23,8 @@ esac # Resolve: asset names embed the version, so map the channel or line to a tag from the release index (explicit versions pass through). base="https://nodejs.org/dist" -index="$(curl -fsSL "$base/index.json")" +# tr puts one release per line, so the greps below hold whether or not the index stays pretty-printed. +index="$(curl -fsSL "$base/index.json" | tr '}' '\n')" case "${VERSION:-lts}" in v[0-9]*.[0-9]*.[0-9]*) tag="$VERSION" ;; [0-9]*.[0-9]*.[0-9]*) tag="v$VERSION" ;; diff --git a/test/node/latest_channel.sh b/test/node/latest_channel.sh new file mode 100644 index 0000000..35b7910 --- /dev/null +++ b/test/node/latest_channel.sh @@ -0,0 +1,12 @@ +#!/bin/bash +set -e + +# Import the test library +source dev-container-features-test-lib + +# The channel floats, so the build itself is the assertion: resolving the newest release +# reads the whole index, and a mistake there fails the install before this script runs. +check "resolved a release" bash -c "node --version | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'" + +# Report result +reportResults diff --git a/test/node/scenarios.json b/test/node/scenarios.json index 92b9544..d6286de 100644 --- a/test/node/scenarios.json +++ b/test/node/scenarios.json @@ -1,4 +1,12 @@ { + "latest_channel": { + "image": "ubuntu:24.04", + "features": { + "node": { + "version": "latest" + } + } + }, "lts_channel": { "image": "ubuntu:24.04", "features": { From 3014ad75f9f8078cb70a47eaa1a63f890de48d5e Mon Sep 17 00:00:00 2001 From: Hansehart <97880342+Hansehart@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:16:55 +0000 Subject: [PATCH 07/10] fix(node): sort the proposals and guard the hook's prefix Two details diverged from the rest of the repo. proposals kept upstream's ordering. Every other manifest here sorts, and uv's mixed list settles the rule for a channel-plus-number option: channels alphabetical, then numbers ascending. latex's scheme is the one exception, and that one is ordered by size on purpose. init.sh read NPM_CONFIG_PREFIX with no default, the only unguarded dereference in any init.sh here. Under set -u that is not a style point: setting NPM_CONFIG_CACHE without a stateDir, through containerEnv or remoteEnv, aborts postCreateCommand with an unbound variable and fails container create. One variable per condition, each with ${...:-}, matches claude-code and codex. The prefix is now created in both modes rather than only alongside the cache, so the comment says what the code does. It costs a mkdir -p of ~/.local, which npm would otherwise create on the first npm i -g. state_dir.sh covers the new line. No version bump: the feature is new on this branch and has never been published. --- src/node/devcontainer-feature.json | 2 +- src/node/init.sh | 5 +++-- test/node/state_dir.sh | 1 + 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/node/devcontainer-feature.json b/src/node/devcontainer-feature.json index c00c951..ca7ba69 100644 --- a/src/node/devcontainer-feature.json +++ b/src/node/devcontainer-feature.json @@ -7,7 +7,7 @@ "options": { "version": { "type": "string", - "proposals": ["lts", "latest", "24", "22"], + "proposals": ["latest", "lts", "22", "24"], "default": "lts", "description": "Channel (lts|latest), a major line (24 or 24.19), or an exact version (major.minor.patch)." }, diff --git a/src/node/init.sh b/src/node/init.sh index a8c83fa..b1d1d2c 100644 --- a/src/node/init.sh +++ b/src/node/init.sh @@ -4,8 +4,9 @@ set -euo pipefail # Prepare npm's config once the volume is mounted. if [ -r /etc/profile.d/node.sh ]; then . /etc/profile.d/node.sh; fi -# Create the state dir once the volume is mounted. -if [ -n "${NPM_CONFIG_CACHE:-}" ]; then mkdir -p "${NPM_CONFIG_CACHE}" "${NPM_CONFIG_PREFIX}"; fi +# Create npm's cache and global prefix once the volume is mounted. +if [ -n "${NPM_CONFIG_CACHE:-}" ]; then mkdir -p "${NPM_CONFIG_CACHE}"; fi +if [ -n "${NPM_CONFIG_PREFIX:-}" ]; then mkdir -p "${NPM_CONFIG_PREFIX}"; fi # Write the requested config to .npmrc (empty leaves the file untouched). req=/usr/local/share/node/requested-npmrc diff --git a/test/node/state_dir.sh b/test/node/state_dir.sh index 63b8c89..4a0bbdf 100644 --- a/test/node/state_dir.sh +++ b/test/node/state_dir.sh @@ -15,6 +15,7 @@ check "state dir writable by the dev user" bash -c 'touch /var/node/.probe && rm check "NPM_CONFIG_CACHE exported" bash -lc '[ "$NPM_CONFIG_CACHE" = /var/node/cache ]' check "NPM_CONFIG_USERCONFIG exported" bash -lc '[ "$NPM_CONFIG_USERCONFIG" = /var/node/npmrc ]' check "global prefix in the state dir" bash -lc '[ "$(npm prefix -g)" = /var/node/global ]' +check "global prefix created" test -d /var/node/global # Report result reportResults From 85994bcd415b256eeb5243688b5062e32396290e Mon Sep 17 00:00:00 2001 From: Hansehart <97880342+Hansehart@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:43:56 +0000 Subject: [PATCH 08/10] fix(node): install libatomic1 for the Node 25+ binaries The latest_channel scenario resolved v26.8.1, verified its checksum, extracted it, and then died on node --version with exit 127: libatomic.so.1 missing. Node's own BUILDING.md states it. From Node 25 the official Linux binaries are linked against libatomic and the runtime has to be present to run them. The gyp rule is unchanged between v24.20.0 and v26.8.1, ['OS=="linux" and clang==1'] adds -latomic; what moved is the release toolchain, from RHEL 8 with gcc-toolset-12 to RHEL 8 with Clang 20.1, so the condition now holds. ubuntu:24.04 ships no libatomic.so.1, and this feature installs no compiler, so nothing pulls it in transitively. The upstream node feature never hit this because nodeGypDependencies defaults to true and gcc brings it along. Not specific to the latest channel: 26 is even-numbered and becomes the active LTS this autumn, at which point the lts default resolves to it. No version bump: the feature is new on this branch and has never been published. --- src/node/install.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/node/install.sh b/src/node/install.sh index 0d2cc6a..9dce857 100644 --- a/src/node/install.sh +++ b/src/node/install.sh @@ -10,7 +10,8 @@ export DEBIAN_FRONTEND=noninteractive apt-get update apt-get install -y --no-install-recommends \ ca-certificates \ - curl + curl \ + libatomic1 rm -rf /var/lib/apt/lists/* # Resolve: map the CPU arch to Node's release arch token. From 17a2e458db79d045fb1a1e492cdf8cb32f08f6fc Mon Sep 17 00:00:00 2001 From: Hansehart <97880342+Hansehart@users.noreply.github.com> Date: Sat, 29 Aug 2026 11:09:31 +0000 Subject: [PATCH 09/10] docs(codex,node): describe the config escapes without literal sequences --- src/codex/devcontainer-feature.json | 2 +- src/node/devcontainer-feature.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/codex/devcontainer-feature.json b/src/codex/devcontainer-feature.json index 2384ccc..b4ce33b 100644 --- a/src/codex/devcontainer-feature.json +++ b/src/codex/devcontainer-feature.json @@ -19,7 +19,7 @@ "configToml": { "type": "string", "default": "", - "description": "Codex config as TOML with backslash-escaped quotes and \\n line breaks, written to config.toml." + "description": "Codex config as TOML with backslash-escaped quotes and line breaks, written to config.toml." } }, "postCreateCommand": "/usr/local/share/codex/init.sh" diff --git a/src/node/devcontainer-feature.json b/src/node/devcontainer-feature.json index ca7ba69..5536c06 100644 --- a/src/node/devcontainer-feature.json +++ b/src/node/devcontainer-feature.json @@ -19,7 +19,7 @@ "npmrc": { "type": "string", "default": "", - "description": "npm config as INI with \\n line breaks, written to npm's user config file." + "description": "npm config as INI with backslash-escaped line breaks, written to .npmrc." } }, "postCreateCommand": "/usr/local/share/node/init.sh" From b1aaba2176966f3dd7a4264883be81893b820c3f Mon Sep 17 00:00:00 2001 From: Hansehart <97880342+Hansehart@users.noreply.github.com> Date: Sat, 29 Aug 2026 11:09:31 +0000 Subject: [PATCH 10/10] test(node): fold the lts scenario into the default run --- test/node/latest_channel.sh | 5 ++--- test/node/lts_channel.sh | 12 ------------ test/node/scenarios.json | 8 -------- test/node/test.sh | 2 ++ 4 files changed, 4 insertions(+), 23 deletions(-) delete mode 100644 test/node/lts_channel.sh diff --git a/test/node/latest_channel.sh b/test/node/latest_channel.sh index 35b7910..b34edd8 100644 --- a/test/node/latest_channel.sh +++ b/test/node/latest_channel.sh @@ -4,9 +4,8 @@ set -e # Import the test library source dev-container-features-test-lib -# The channel floats, so the build itself is the assertion: resolving the newest release -# reads the whole index, and a mistake there fails the install before this script runs. -check "resolved a release" bash -c "node --version | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'" +# The channel floats, so the build is the assertion: an unresolvable tag fails the install. +check "resolved release runs" bash -lc "node --version" # Report result reportResults diff --git a/test/node/lts_channel.sh b/test/node/lts_channel.sh deleted file mode 100644 index c9bb783..0000000 --- a/test/node/lts_channel.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/bash -set -e - -# Import the test library -source dev-container-features-test-lib - -# The channel floats, so assert the property that identifies it: Node only ever promotes -# even-numbered majors to LTS, so an odd major means the wrong line was resolved. -check "resolved an LTS line" bash -c "node --version | grep -qE '^v[0-9]*[02468]\.'" - -# Report result -reportResults diff --git a/test/node/scenarios.json b/test/node/scenarios.json index d6286de..b954237 100644 --- a/test/node/scenarios.json +++ b/test/node/scenarios.json @@ -7,14 +7,6 @@ } } }, - "lts_channel": { - "image": "ubuntu:24.04", - "features": { - "node": { - "version": "lts" - } - } - }, "major_line": { "image": "ubuntu:24.04", "features": { diff --git a/test/node/test.sh b/test/node/test.sh index 538ae00..d3daa65 100644 --- a/test/node/test.sh +++ b/test/node/test.sh @@ -7,6 +7,8 @@ source dev-container-features-test-lib # Use a login shell so the profile.d snippet setting the npm prefix is sourced. check "node on PATH" bash -lc "command -v node" check "node version" bash -lc "node --version" +# The default channel floats, so assert what identifies it: LTS majors are always even. +check "default resolved an LTS line" bash -lc "node --version | grep -qE '^v[0-9]*[02468]\.'" check "npm on PATH" bash -lc "command -v npm" check "npx on PATH" bash -lc "command -v npx" check "global prefix in the dev user's home" bash -lc '[ "$(npm prefix -g)" = "$HOME/.local" ]'