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/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/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/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/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/node/devcontainer-feature.json b/src/node/devcontainer-feature.json new file mode 100644 index 0000000..5536c06 --- /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 or release line.", + "documentationURL": "https://github.com/hansehart/devcontainer-features/tree/main/src/node", + "options": { + "version": { + "type": "string", + "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)." + }, + "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 backslash-escaped line breaks, written to .npmrc." + } + }, + "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..b1d1d2c --- /dev/null +++ b/src/node/init.sh @@ -0,0 +1,17 @@ +#!/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 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 +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..9dce857 --- /dev/null +++ b/src/node/install.sh @@ -0,0 +1,83 @@ +#!/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 \ + libatomic1 +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 map the channel or line to a tag from the release index (explicit versions pass through). +base="https://nodejs.org/dist" +# 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" ;; + # 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. +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/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": { 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 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 ]" diff --git a/test/node/latest_channel.sh b/test/node/latest_channel.sh new file mode 100644 index 0000000..b34edd8 --- /dev/null +++ b/test/node/latest_channel.sh @@ -0,0 +1,11 @@ +#!/bin/bash +set -e + +# Import the test library +source dev-container-features-test-lib + +# 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/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/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..b954237 --- /dev/null +++ b/test/node/scenarios.json @@ -0,0 +1,43 @@ +{ + "latest_channel": { + "image": "ubuntu:24.04", + "features": { + "node": { + "version": "latest" + } + } + }, + "major_line": { + "image": "ubuntu:24.04", + "features": { + "node": { + "version": "24" + } + } + }, + "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..4a0bbdf --- /dev/null +++ b/test/node/state_dir.sh @@ -0,0 +1,21 @@ +#!/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 ]' +check "global prefix created" test -d /var/node/global + +# Report result +reportResults diff --git a/test/node/test.sh b/test/node/test.sh new file mode 100644 index 0000000..d3daa65 --- /dev/null +++ b/test/node/test.sh @@ -0,0 +1,18 @@ +#!/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" +# 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" ]' +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