Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
4 changes: 2 additions & 2 deletions src/claude-code/init.sh
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
2 changes: 1 addition & 1 deletion src/codex/devcontainer-feature.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions src/codex/init.sh
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
4 changes: 2 additions & 2 deletions src/docker-in-docker/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions src/latex/devcontainer-feature.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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": {
Expand Down
28 changes: 14 additions & 14 deletions src/latex/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -35,33 +35,33 @@ 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}"
ln -sf "${TEXDIR}/bin/${PLAT}"/* /usr/local/bin/
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}"
chown "$_REMOTE_USER:latex" "${STATE_DIR}"
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
26 changes: 26 additions & 0 deletions src/node/devcontainer-feature.json
Original file line number Diff line number Diff line change
@@ -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"
}
17 changes: 17 additions & 0 deletions src/node/init.sh
Original file line number Diff line number Diff line change
@@ -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
83 changes: 83 additions & 0 deletions src/node/install.sh
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions src/sops/devcontainer-feature.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -14,7 +14,7 @@
"stateDir": {
"type": "string",
"default": "",
"description": "Points SOPS_AGE_KEY_FILE at <stateDir>/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 <stateDir>/keys.txt (persist it by mounting a volume there). Empty leaves SOPS_AGE_KEY_FILE unset."
}
},
"dependsOn": {
Expand Down
4 changes: 2 additions & 2 deletions src/uv/devcontainer-feature.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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": {
Expand Down
8 changes: 4 additions & 4 deletions src/uv/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion test/codex/config_toml.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 ]"

Expand Down
11 changes: 11 additions & 0 deletions test/node/latest_channel.sh
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions test/node/major_line.sh
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions test/node/npmrc.sh
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions test/node/pinned_version.sh
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading