diff --git a/scripts/update-material.sh b/scripts/update-material.sh index 993d219..807a4d3 100755 --- a/scripts/update-material.sh +++ b/scripts/update-material.sh @@ -14,6 +14,10 @@ MATERIALS='[ { "path": "src/claude-code/claude-code.asc", "url": "https://downloads.claude.ai/keys/claude-code.asc" + }, + { + "path": "src/node/node-keyring.kbx", + "url": "https://github.com/nodejs/release-keys/raw/HEAD/gpg/pubring.kbx" } ]' diff --git a/src/claude-code/devcontainer-feature.json b/src/claude-code/devcontainer-feature.json index 82836c5..8ed6938 100644 --- a/src/claude-code/devcontainer-feature.json +++ b/src/claude-code/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "claude-code", - "version": "1.1.0", + "version": "1.1.1", "name": "Claude Code", "documentationURL": "https://github.com/bare-devcontainer/features/tree/main/src/claude-code", "description": "Installs Claude Code, Anthropic's agentic coding CLI, from the official APT repository.", diff --git a/src/claude-code/install.sh b/src/claude-code/install.sh index e9b900b..1fc02c5 100755 --- a/src/claude-code/install.sh +++ b/src/claude-code/install.sh @@ -62,12 +62,12 @@ fi rm -rf /var/lib/apt/lists/* -USERNAME="${_REMOTE_USER:-root}" +username="${_REMOTE_USER:-root}" CONFIG_DIR="/var/lib/claude-code" CONFIG_GROUP="claude-code" -if ! passwd_entry="$(getent passwd "${USERNAME}")"; then - echo "(!) Remote user '${USERNAME}' was not found in the password database." >&2 +if ! passwd_entry="$(getent passwd "${username}")"; then + echo "(!) Remote user '${username}' was not found in the password database." >&2 exit 1 fi @@ -77,17 +77,17 @@ mkdir -p "${CONFIG_DIR}" if ! getent group "${CONFIG_GROUP}" >/dev/null; then groupadd --system "${CONFIG_GROUP}" fi -usermod -aG "${CONFIG_GROUP}" "${USERNAME}" +usermod -aG "${CONFIG_GROUP}" "${username}" # Transfer any existing configuration from the remote user's home directory to the mounted volume if it exists. -USER_HOME="${_REMOTE_USER_HOME:-$(printf '%s' "${passwd_entry}" | cut -d: -f6)}" -if [ -n "${USER_HOME}" ] && [ -d "${USER_HOME}/.claude" ] && [ -z "$(ls -A "${CONFIG_DIR}")" ]; then - cp -a "${USER_HOME}/.claude/." "${CONFIG_DIR}/" +user_home="${_REMOTE_USER_HOME:-$(printf '%s' "${passwd_entry}" | cut -d: -f6)}" +if [ -n "${user_home}" ] && [ -d "${user_home}/.claude" ] && [ -z "$(ls -A "${CONFIG_DIR}")" ]; then + cp -a "${user_home}/.claude/." "${CONFIG_DIR}/" fi # Set the ownership and permissions of the configuration directory. # The setgid bit keeps entries created later in the shared group. -chown -R "${USERNAME}:${CONFIG_GROUP}" "${CONFIG_DIR}" +chown -R "${username}:${CONFIG_GROUP}" "${CONFIG_DIR}" chmod -R g+rwX "${CONFIG_DIR}" chmod 2775 "${CONFIG_DIR}" diff --git a/src/node/NOTES.md b/src/node/NOTES.md new file mode 100644 index 0000000..d9c2136 --- /dev/null +++ b/src/node/NOTES.md @@ -0,0 +1,55 @@ +## Details + +This feature installs the same Node.js setup as the +[`ghcr.io/bare-devcontainer/node`](https://github.com/bare-devcontainer/images/tree/main/node) +image, for base images that do not already carry it: the official build from +[nodejs.org](https://nodejs.org/dist/) unpacked into `/usr/local`, Corepack from +npm, and no npm left behind. + +- Requires a glibc based `linux-x64` or `linux-arm64` image. +- The download, signature check and unpacking need `wget` (or `curl`), `gpgv`, + `tar`, `xz` and `sha256sum`. Any of those the image is missing are installed + with `apt-get`, so an image without them has to be Debian or Ubuntu based. + +### Version selection + +`version` accepts `lts` (the newest long-term support release), `latest` (the +newest release of any line), a release line such as `24` or `24.19`, or an exact +version such as `24.19.0`. Everything but an exact version is resolved against +`https://nodejs.org/dist/index.json` at install time, so a rebuild picks up +newer patch releases. + +### Corepack instead of npm + +npm and npx are removed after Corepack is installed, so the package manager +comes from the project's `packageManager` field rather than from the image: + +```json +"features": { + "ghcr.io/bare-devcontainer/features/node:1": {} +} +``` + +Corepack is installed but not enabled, matching the image. Its shims go into +`/usr/local/bin`, so enabling them needs root — a `RUN corepack enable` in a +`Dockerfile` layered on the image, for instance. Without root, `corepack enable +--install-directory` writes them somewhere the remote user owns instead. + +Set `keepNpm` to `true` to keep npm and npx, or `corepack` to `none` to leave +Corepack out — including the copy that release lines up to Node.js 24 bundle, +which is removed along with it. `corepack: "none"` together with `keepNpm: false` +yields a Node.js with no package manager at all. + +Corepack downloads package managers into `COREPACK_HOME`, which defaults to +`~/.cache/node/corepack`. The feature creates that directory for the remote user +so the first `corepack` call does not have to write into a root-owned cache. + +### Supply chain + +The release tarball is downloaded from `https://nodejs.org/dist/` and its +checksum is verified against `SHASUMS256.txt.asc`, signed by the Node.js Release +Team. The keyring is vendored with the feature and read from there, so +signatures are checked against keys reviewed in this repository rather than keys +fetched at install time. It is a copy of the keyring published by +[nodejs/release-keys](https://github.com/nodejs/release-keys), refreshed by this +repository's `Update Trusted Material` workflow. diff --git a/src/node/README.md b/src/node/README.md new file mode 100644 index 0000000..cbd324c --- /dev/null +++ b/src/node/README.md @@ -0,0 +1,81 @@ + +# Node.js (node) + +Installs Node.js from nodejs.org, verified against the Node.js release signing keys, with Corepack in place of npm. + +## Example Usage + +```json +"features": { + "ghcr.io/bare-devcontainer/features/node:1": {} +} +``` + +## Options + +| Options Id | Description | Type | Default Value | +|-----|-----|-----|-----| +| version | Node.js version to install. Use "lts" for the newest long-term support release, "latest" for the newest release of any line, a release line such as "24" or "24.19", or an exact version such as "24.19.0". | string | lts | +| corepack | npm version specifier of Corepack to install globally, or "none" to leave Corepack out. | string | latest | +| keepNpm | Keep the npm and npx executables that ship with the Node.js distribution. When false, both are removed so the package manager comes from the project's "packageManager" field through Corepack. | boolean | false | + +## Details + +This feature installs the same Node.js setup as the +[`ghcr.io/bare-devcontainer/node`](https://github.com/bare-devcontainer/images/tree/main/node) +image, for base images that do not already carry it: the official build from +[nodejs.org](https://nodejs.org/dist/) unpacked into `/usr/local`, Corepack from +npm, and no npm left behind. + +- Requires a glibc based `linux-x64` or `linux-arm64` image. +- The download, signature check and unpacking need `wget` (or `curl`), `gpgv`, + `tar`, `xz` and `sha256sum`. Any of those the image is missing are installed + with `apt-get`, so an image without them has to be Debian or Ubuntu based. + +### Version selection + +`version` accepts `lts` (the newest long-term support release), `latest` (the +newest release of any line), a release line such as `24` or `24.19`, or an exact +version such as `24.19.0`. Everything but an exact version is resolved against +`https://nodejs.org/dist/index.json` at install time, so a rebuild picks up +newer patch releases. + +### Corepack instead of npm + +npm and npx are removed after Corepack is installed, so the package manager +comes from the project's `packageManager` field rather than from the image: + +```json +"features": { + "ghcr.io/bare-devcontainer/features/node:1": {} +} +``` + +Corepack is installed but not enabled, matching the image. Its shims go into +`/usr/local/bin`, so enabling them needs root — a `RUN corepack enable` in a +`Dockerfile` layered on the image, for instance. Without root, `corepack enable +--install-directory` writes them somewhere the remote user owns instead. + +Set `keepNpm` to `true` to keep npm and npx, or `corepack` to `none` to leave +Corepack out — including the copy that release lines up to Node.js 24 bundle, +which is removed along with it. `corepack: "none"` together with `keepNpm: false` +yields a Node.js with no package manager at all. + +Corepack downloads package managers into `COREPACK_HOME`, which defaults to +`~/.cache/node/corepack`. The feature creates that directory for the remote user +so the first `corepack` call does not have to write into a root-owned cache. + +### Supply chain + +The release tarball is downloaded from `https://nodejs.org/dist/` and its +checksum is verified against `SHASUMS256.txt.asc`, signed by the Node.js Release +Team. The keyring is vendored with the feature and read from there, so +signatures are checked against keys reviewed in this repository rather than keys +fetched at install time. It is a copy of the keyring published by +[nodejs/release-keys](https://github.com/nodejs/release-keys), refreshed by this +repository's `Update Trusted Material` workflow. + + +--- + +_Note: This file was auto-generated from the [devcontainer-feature.json](https://github.com/bare-devcontainer/features/blob/main/src/node/devcontainer-feature.json). Add additional notes to a `NOTES.md`._ diff --git a/src/node/devcontainer-feature.json b/src/node/devcontainer-feature.json new file mode 100644 index 0000000..7ef262a --- /dev/null +++ b/src/node/devcontainer-feature.json @@ -0,0 +1,37 @@ +{ + "id": "node", + "version": "1.0.0", + "name": "Node.js", + "documentationURL": "https://github.com/bare-devcontainer/features/tree/main/src/node", + "description": "Installs Node.js from nodejs.org, verified against the Node.js release signing keys, with Corepack in place of npm.", + "options": { + "version": { + "type": "string", + "proposals": [ + "lts", + "latest", + "26", + "24" + ], + "default": "lts", + "description": "Node.js version to install. Use \"lts\" for the newest long-term support release, \"latest\" for the newest release of any line, a release line such as \"24\" or \"24.19\", or an exact version such as \"24.19.0\"." + }, + "corepack": { + "type": "string", + "proposals": [ + "latest", + "none" + ], + "default": "latest", + "description": "npm version specifier of Corepack to install globally, or \"none\" to leave Corepack out." + }, + "keepNpm": { + "type": "boolean", + "default": false, + "description": "Keep the npm and npx executables that ship with the Node.js distribution. When false, both are removed so the package manager comes from the project's \"packageManager\" field through Corepack." + } + }, + "installsAfter": [ + "ghcr.io/devcontainers/features/common-utils" + ] +} diff --git a/src/node/install.sh b/src/node/install.sh new file mode 100755 index 0000000..3f7ed40 --- /dev/null +++ b/src/node/install.sh @@ -0,0 +1,249 @@ +#!/usr/bin/env bash +# +# Installs Node.js into /usr/local from the official nodejs.org distribution, +# mirroring the setup of the ghcr.io/bare-devcontainer/node image: the release +# tarball is checked against the signed SHASUMS256.txt.asc using the vendored +# Node.js release keyring, Corepack is installed from npm, and npm itself is +# removed afterwards. +# +# Expected environment variables, from this feature's own options: +# +# VERSION Node.js version to install: "lts", "latest", a release +# line such as "24" or "24.19", or an exact version. +# COREPACK npm version specifier of Corepack to install, or "none". +# KEEPNPM "true" to leave npm and npx in place. +# +# and from the Dev Container specification, injected by the CLI: +# +# _REMOTE_USER The account the container is attached as, and therefore +# the one Corepack caches package managers for. +# _REMOTE_USER_HOME Home directory of _REMOTE_USER. + +set -euo pipefail + +VERSION="${VERSION:-lts}" +COREPACK="${COREPACK:-latest}" +KEEPNPM="${KEEPNPM:-false}" + +FEATURE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +KEYRING="${FEATURE_DIR}/node-keyring.kbx" +PREFIX="/usr/local" +DIST_URL="https://nodejs.org/dist" + +if [ "$(id -u)" -ne 0 ]; then + echo "(!) This feature must be installed as root." >&2 + exit 1 +fi + +# Every download lands here, so nothing the install fetches is left in the +# image, whichever path the script exits by. +tmpdir="$(mktemp -d)" +trap 'rm -rf "${tmpdir}"' EXIT + +# Debian package providing each command the installation needs. +package_for() { + case "$1" in + wget) echo "wget" ;; + gpgv) echo "gpgv" ;; + tar) echo "tar" ;; + xz) echo "xz-utils" ;; + sha256sum) echo "coreutils" ;; + *) echo "$1" ;; + esac +} + +# Installs whatever the base image is missing, and nothing it already has. +install_prerequisites() { + local required=(gpgv tar xz sha256sum) missing=() cmd + + # Either downloader will do, so one is only pulled in when neither is there. + if ! command -v curl >/dev/null 2>&1; then + required+=(wget) + fi + + for cmd in "${required[@]}"; do + if ! command -v "${cmd}" >/dev/null 2>&1; then + missing+=("$(package_for "${cmd}")") + fi + done + + # HTTPS access to nodejs.org needs a CA bundle. + if [ ! -e /etc/ssl/certs/ca-certificates.crt ]; then + missing+=(ca-certificates) + fi + + if [ "${#missing[@]}" -eq 0 ]; then + return + fi + + if ! command -v apt-get >/dev/null 2>&1; then + echo "(!) Missing prerequisites and no apt-get to install them with: ${missing[*]}" >&2 + exit 1 + fi + + export DEBIAN_FRONTEND=noninteractive + apt-get update -y + apt-get install -y --no-install-recommends "${missing[@]}" + rm -rf /var/lib/apt/lists/* +} + +download() { + local url="$1" destination="$2" + + if command -v wget >/dev/null 2>&1; then + wget -q -T 30 -t 3 -O "${destination}" "${url}" + else + curl -fsSL --connect-timeout 30 --retry 3 -o "${destination}" "${url}" + fi +} + +# Turns the requested version into the exact "vX.Y.Z" the distribution is +# published under. Releases in index.json are ordered newest first, so the +# first entry matching a selector is the newest release it covers. +resolve_version() { + local requested="${1#v}" selector index entries resolved + + if [[ "${requested}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "v${requested}" + return + fi + + case "${requested}" in + latest) selector='"version":"v' ;; + # Only long-term support releases carry a codename; the rest are false. + lts) selector='"lts":"' ;; + *) + if [[ ! "${requested}" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then + echo "(!) Unrecognised version '$1'. Use \"lts\", \"latest\", a release line such as \"24\" or \"24.19\", or an exact version." >&2 + exit 1 + fi + selector="\"version\":\"v${requested}." + ;; + esac + + index="${tmpdir}/index.json" + entries="${tmpdir}/releases.txt" + download "${DIST_URL}/index.json" "${index}" + + # Matching is per line, which is how nodejs.org serves the index: one + # release object per line, without spaces around the separators. The sed + # covers the array arriving on a single line instead; any other layout ends + # in the no-match error below rather than a wrong version. The intermediate + # file keeps grep's early exit from ending the split with a broken pipe. + sed 's/},[[:space:]]*{/}\n{/g' "${index}" > "${entries}" + resolved="$(grep -m1 -F "${selector}" "${entries}" | sed -n 's/.*"version":"\(v[^"]*\)".*/\1/p' || true)" + + if [ -z "${resolved}" ]; then + echo "(!) No Node.js release matches '$1'." >&2 + exit 1 + fi + + echo "${resolved}" +} + +node_arch() { + case "$(uname -m)" in + x86_64) echo "x64" ;; + aarch64 | arm64) echo "arm64" ;; + *) + echo "(!) Unsupported architecture: $(uname -m)." >&2 + exit 1 + ;; + esac +} + +install_prerequisites + +node_version="$(resolve_version "${VERSION}")" +arch="$(node_arch)" +tarball="node-${node_version}-linux-${arch}.tar.xz" + +echo "Installing Node.js ${node_version} (linux-${arch}) into ${PREFIX}..." +download "${DIST_URL}/${node_version}/${tarball}" "${tmpdir}/${tarball}" +download "${DIST_URL}/${node_version}/SHASUMS256.txt.asc" "${tmpdir}/SHASUMS256.txt.asc" + +# The checksums are signed by the Node.js Release Team. The keyring is vendored +# with the feature, so the keys are the ones reviewed in this repository rather +# than keys fetched at install time. +gpgv --keyring "${KEYRING}" --output "${tmpdir}/SHASUMS256.txt" < "${tmpdir}/SHASUMS256.txt.asc" + +# SHASUMS256.txt names the tarball without a path, so the entry is rewritten to +# point at the copy just downloaded. +grep " ${tarball}\$" "${tmpdir}/SHASUMS256.txt" \ + | sed "s| ${tarball}\$| ${tmpdir}/${tarball}|" \ + | sha256sum -c - + +# The tarball records uid/gid 1000, which is the remote user in these images, so +# ownership is left to the extracting root rather than restored from the archive. +# It would otherwise be handed to that user, along with the /usr/local +# directories the archive recreates. +tar xJf "${tmpdir}/${tarball}" -C "${PREFIX}" --strip-components=1 --no-same-owner + +username="${_REMOTE_USER:-root}" +if ! passwd_entry="$(getent passwd "${username}")"; then + echo "(!) Remote user '${username}' was not found in the password database." >&2 + exit 1 +fi +user_home="${_REMOTE_USER_HOME:-$(printf '%s' "${passwd_entry}" | cut -d: -f6)}" +user_group="$(id -gn "${username}")" + +if [ "${COREPACK}" != "none" ]; then + if [ ! -x "${PREFIX}/bin/npm" ]; then + echo "(!) Node.js ${node_version} ships no npm to install Corepack with; set the \"corepack\" option to \"none\"." >&2 + exit 1 + fi + + # The global prefix npm installs into comes from the image's npm + # configuration, which need not be this installation, so it is spelled out. + # npm's cache is only of use to this one install, so it is kept with the + # other downloads instead of in a home directory: the default location is + # the remote user's own cache when the remote user is root, and may hold + # entries this feature did not put there in any case. + "${PREFIX}/bin/npm" install -g --prefix "${PREFIX}" --cache "${tmpdir}/npm-cache" \ + --no-audit --no-fund "corepack@${COREPACK}" + + if [ ! -e "${PREFIX}/bin/corepack" ]; then + echo "(!) Corepack was not installed into ${PREFIX}." >&2 + exit 1 + fi + + # Besides corepack itself, the package declares yarn, yarnpkg, pnpm and pnpx + # bins, and npm links every one of them. The distribution ships the corepack + # shim alone and leaves the rest to `corepack enable`, so the extra links are + # dropped again. Only npm's own are touched, in case the image has a real + # package manager of that name. + for shim in yarn yarnpkg pnpm pnpx; do + shim_path="${PREFIX}/bin/${shim}" + [ -L "${shim_path}" ] || continue + case "$(readlink "${shim_path}")" in + */node_modules/corepack/*) rm -f "${shim_path}" ;; + esac + done + + # Corepack downloads package managers into COREPACK_HOME, which defaults to + # ~/.cache/node/corepack. Creating it here keeps the first invocation from + # writing into a root-owned cache directory. Each level is chowned, since + # the remote user may own none of them yet. + if [ -d "${user_home}" ]; then + corepack_cache="${user_home}/.cache/node/corepack" + mkdir -p "${corepack_cache}" + chown "${username}:${user_group}" \ + "${user_home}/.cache" "${user_home}/.cache/node" "${corepack_cache}" + else + echo "(*) Home directory '${user_home}' does not exist; skipping the Corepack cache directory." >&2 + fi +else + # Release lines up to Node.js 24 bundle Corepack, so leaving it out means + # removing the copy the tarball brought along. + rm -rf "${PREFIX}/lib/node_modules/corepack" "${PREFIX}/bin/corepack" +fi + +# Removed last, so Corepack can still be installed with npm above. +if [ "${KEEPNPM}" != "true" ]; then + rm -rf "${PREFIX}/lib/node_modules/npm" "${PREFIX}/bin/npm" "${PREFIX}/bin/npx" +fi + +echo "Installed Node.js $("${PREFIX}/bin/node" --version)." +if [ "${COREPACK}" != "none" ]; then + echo "Installed Corepack $("${PREFIX}/bin/corepack" --version)." +fi diff --git a/src/node/node-keyring.kbx b/src/node/node-keyring.kbx new file mode 100644 index 0000000..2c4353d Binary files /dev/null and b/src/node/node-keyring.kbx differ diff --git a/src/node/smoke_test.sh b/src/node/smoke_test.sh new file mode 100755 index 0000000..9785c78 --- /dev/null +++ b/src/node/smoke_test.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash + +set -euo pipefail + +failures=0 + +# Runs a command and records the outcome instead of aborting, so a single run +# reports every broken check rather than only the first one. +check() { + local label="$1" + shift + + local output + local status=0 + # The `||` keeps the assignment out of `set -e`'s reach, which would + # otherwise abort the whole run on the first failing check. + output="$("$@" 2>&1)" || status=$? + + if [ "${status}" -eq 0 ]; then + echo "ok - ${label}" + else + echo "FAIL - ${label} (exit ${status})" + if [ -n "${output}" ]; then + echo "${output}" | sed 's/^/ /' + fi + failures=$((failures + 1)) + fi +} + +check "node is on PATH" command -v node +check "node reports a version" node --version +check "node is installed under /usr/local" \ + bash -c 'test "$(command -v node)" = /usr/local/bin/node' +check "node runs a program" node -e 'console.log("Hello, world!")' +# The release tarball records uid 1000, the remote user in these images, so an +# extraction that restores it would hand the runtime to the account using it. +check "the installed files are owned by root" \ + bash -c 'test "$(stat -c %u /usr/local/bin/node)" = 0 + test "$(stat -c %u /usr/local/lib/node_modules)" = 0' +check "corepack is on PATH" command -v corepack +check "corepack reports a version" corepack --version +check "npm and npx are removed by default" \ + bash -c '! command -v npm >/dev/null 2>&1 && ! command -v npx >/dev/null 2>&1' +check "corepack is installed but not enabled" \ + bash -c 'for shim in yarn yarnpkg pnpm pnpx; do + path="$(command -v "${shim}" 2>/dev/null || true)" + if [ -n "${path}" ]; then + echo "${shim} is on PATH at ${path}" + exit 1 + fi + done' +check "corepack cache directory is writable by the remote user" \ + bash -c 'touch "${HOME}/.cache/node/corepack/.write-test" + rm "${HOME}/.cache/node/corepack/.write-test"' + +if [ "${failures}" -ne 0 ]; then + echo "${failures} check(s) failed." + exit 1 +fi + +echo "All checks passed."