From d3597b8d61872937538977929ffff7edd86a22c0 Mon Sep 17 00:00:00 2001 From: frami Date: Fri, 22 May 2026 11:09:01 -0300 Subject: [PATCH 01/18] fix: test remote user --- test/_global/all_features_non_root.sh | 20 ++++++++++++++++++++ test/_global/scenarios.json | 11 +++++++++++ test/claude/non_root_user.sh | 11 +++++++++++ test/claude/scenarios.json | 7 +++++++ test/gitbutler/non_root_user.sh | 21 +++++++++++++++++++++ test/gitbutler/scenarios.json | 7 +++++++ test/opencode/non_root_user.sh | 12 ++++++++++++ test/opencode/scenarios.json | 7 +++++++ test/pnpm/non_root_user.sh | 12 ++++++++++++ test/pnpm/scenarios.json | 7 +++++++ test/rtk/non_root_user.sh | 10 ++++++++++ test/rtk/scenarios.json | 7 +++++++ 12 files changed, 132 insertions(+) create mode 100755 test/_global/all_features_non_root.sh create mode 100755 test/claude/non_root_user.sh create mode 100755 test/gitbutler/non_root_user.sh create mode 100755 test/opencode/non_root_user.sh create mode 100755 test/pnpm/non_root_user.sh create mode 100755 test/rtk/non_root_user.sh diff --git a/test/_global/all_features_non_root.sh b/test/_global/all_features_non_root.sh new file mode 100755 index 0000000..248b4b5 --- /dev/null +++ b/test/_global/all_features_non_root.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -e +source dev-container-features-test-lib + +check "whoami is node" bash -c '[ "$(whoami)" = "node" ]' +check "home writable" bash -c 'touch "$HOME/.rwtest" && rm "$HOME/.rwtest"' + +check "pnpm on PATH" bash -c "command -v pnpm" +check "claude on PATH" bash -c "command -v claude" +check "but on PATH" bash -c "command -v but" +check "opencode on PATH" bash -c "command -v opencode" +check "rtk on PATH" bash -c "command -v rtk" + +check "pnpm --version" bash -c "pnpm --version" +check "claude --version" bash -c "claude --version" +check "but --version" bash -c "but --version" +check "opencode --version" bash -c "opencode --version" +check "rtk --version" bash -c "rtk --version" + +reportResults diff --git a/test/_global/scenarios.json b/test/_global/scenarios.json index 05b8924..1ce441a 100644 --- a/test/_global/scenarios.json +++ b/test/_global/scenarios.json @@ -8,5 +8,16 @@ "claude": {}, "rtk": {} } + }, + "all_features_non_root": { + "image": "mcr.microsoft.com/devcontainers/typescript-node:24-trixie", + "remoteUser": "node", + "features": { + "pnpm": {}, + "gitbutler": {}, + "opencode": {}, + "claude": {}, + "rtk": {} + } } } diff --git a/test/claude/non_root_user.sh b/test/claude/non_root_user.sh new file mode 100755 index 0000000..405c7d2 --- /dev/null +++ b/test/claude/non_root_user.sh @@ -0,0 +1,11 @@ +#!/bin/bash +set -e +source dev-container-features-test-lib + +check "whoami is vscode" bash -c '[ "$(whoami)" = "vscode" ]' +check "home writable" bash -c 'touch "$HOME/.rwtest" && rm "$HOME/.rwtest"' +check "claude on PATH" bash -c 'command -v claude' +check "claude --version" bash -c "claude --version | grep -E '[0-9]+\\.[0-9]+\\.[0-9]+'" +check "claude dir readable" test -r /usr/local/share/claude + +reportResults diff --git a/test/claude/scenarios.json b/test/claude/scenarios.json index bdef3dc..a5b1c74 100644 --- a/test/claude/scenarios.json +++ b/test/claude/scenarios.json @@ -12,5 +12,12 @@ "version": "2.1.145" } } + }, + "non_root_user": { + "image": "mcr.microsoft.com/devcontainers/base:trixie", + "remoteUser": "vscode", + "features": { + "claude": {} + } } } diff --git a/test/gitbutler/non_root_user.sh b/test/gitbutler/non_root_user.sh new file mode 100755 index 0000000..a8ca4b3 --- /dev/null +++ b/test/gitbutler/non_root_user.sh @@ -0,0 +1,21 @@ +#!/bin/bash +set -e +source dev-container-features-test-lib + +check "whoami is vscode" bash -c '[ "$(whoami)" = "vscode" ]' +check "home writable" bash -c 'touch "$HOME/.rwtest" && rm "$HOME/.rwtest"' +check "but on PATH" bash -c 'command -v but' +check "but --version" bash -c 'but --version' + +check "but setup as vscode" bash -c ' + set -e + mkdir -p "$HOME/proj" + cd "$HOME/proj" + git init -q + git config user.email t@t + git config user.name t + git commit --allow-empty -qm init + but setup +' + +reportResults diff --git a/test/gitbutler/scenarios.json b/test/gitbutler/scenarios.json index 2ea826e..aac2405 100644 --- a/test/gitbutler/scenarios.json +++ b/test/gitbutler/scenarios.json @@ -4,5 +4,12 @@ "features": { "gitbutler": {} } + }, + "non_root_user": { + "image": "mcr.microsoft.com/devcontainers/base:trixie", + "remoteUser": "vscode", + "features": { + "gitbutler": {} + } } } diff --git a/test/opencode/non_root_user.sh b/test/opencode/non_root_user.sh new file mode 100755 index 0000000..9ca9025 --- /dev/null +++ b/test/opencode/non_root_user.sh @@ -0,0 +1,12 @@ +#!/bin/bash +set -e +source dev-container-features-test-lib + +check "whoami is vscode" bash -c '[ "$(whoami)" = "vscode" ]' +check "home writable" bash -c 'touch "$HOME/.rwtest" && rm "$HOME/.rwtest"' +check "opencode on PATH" bash -c 'command -v opencode' +check "opencode --version" bash -c 'opencode --version' +check ".config owned" bash -c '[ "$(stat -c %U "$HOME/.config")" = "$(whoami)" ]' +check ".local owned" bash -c '[ "$(stat -c %U "$HOME/.local")" = "$(whoami)" ]' + +reportResults diff --git a/test/opencode/scenarios.json b/test/opencode/scenarios.json index 6322fb2..ad2655c 100644 --- a/test/opencode/scenarios.json +++ b/test/opencode/scenarios.json @@ -4,5 +4,12 @@ "features": { "opencode": {} } + }, + "non_root_user": { + "image": "mcr.microsoft.com/devcontainers/base:trixie", + "remoteUser": "vscode", + "features": { + "opencode": {} + } } } diff --git a/test/pnpm/non_root_user.sh b/test/pnpm/non_root_user.sh new file mode 100755 index 0000000..df8c3fb --- /dev/null +++ b/test/pnpm/non_root_user.sh @@ -0,0 +1,12 @@ +#!/bin/bash +set -e +source dev-container-features-test-lib + +check "whoami is node" bash -c '[ "$(whoami)" = "node" ]' +check "home writable" bash -c 'touch "$HOME/.rwtest" && rm "$HOME/.rwtest"' +check "pnpm on PATH" bash -c 'command -v pnpm' +check "pnpm --version" bash -c 'pnpm --version' +check "pnpm major is 11" bash -c "pnpm --version | grep -E '^11\\.'" +check "pnpm add -g without sudo" bash -c 'pnpm add -g cowsay && command -v cowsay' + +reportResults diff --git a/test/pnpm/scenarios.json b/test/pnpm/scenarios.json index 20edb3a..29cf330 100644 --- a/test/pnpm/scenarios.json +++ b/test/pnpm/scenarios.json @@ -12,5 +12,12 @@ "version": "9" } } + }, + "non_root_user": { + "image": "mcr.microsoft.com/devcontainers/typescript-node:24-trixie", + "remoteUser": "node", + "features": { + "pnpm": {} + } } } diff --git a/test/rtk/non_root_user.sh b/test/rtk/non_root_user.sh new file mode 100755 index 0000000..f42c7d9 --- /dev/null +++ b/test/rtk/non_root_user.sh @@ -0,0 +1,10 @@ +#!/bin/bash +set -e +source dev-container-features-test-lib + +check "whoami is vscode" bash -c '[ "$(whoami)" = "vscode" ]' +check "home writable" bash -c 'touch "$HOME/.rwtest" && rm "$HOME/.rwtest"' +check "rtk on PATH" bash -c 'command -v rtk' +check "rtk --version" bash -c 'rtk --version' + +reportResults diff --git a/test/rtk/scenarios.json b/test/rtk/scenarios.json index e86647f..bb77cf4 100644 --- a/test/rtk/scenarios.json +++ b/test/rtk/scenarios.json @@ -4,5 +4,12 @@ "features": { "rtk": {} } + }, + "non_root_user": { + "image": "mcr.microsoft.com/devcontainers/base:trixie", + "remoteUser": "vscode", + "features": { + "rtk": {} + } } } From 3bbb58c0586a5aca07cd69853ab19d4f771dd201 Mon Sep 17 00:00:00 2001 From: frami Date: Fri, 22 May 2026 11:30:10 -0300 Subject: [PATCH 02/18] fix: pnpm test --- src/pnpm/devcontainer-feature.json | 2 +- src/pnpm/install.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pnpm/devcontainer-feature.json b/src/pnpm/devcontainer-feature.json index bb8d874..a016086 100644 --- a/src/pnpm/devcontainer-feature.json +++ b/src/pnpm/devcontainer-feature.json @@ -16,6 +16,6 @@ "NPM_CONFIG_PREFIX": "/usr/local/share/npm-global", "PNPM_HOME": "/usr/local/share/pnpm", "PNPM_STORE_DIR": "/usr/local/share/pnpm-store", - "PATH": "/usr/local/share/npm-global/bin:/usr/local/share/pnpm:/usr/local/bin:${PATH}" + "PATH": "/usr/local/share/npm-global/bin:/usr/local/share/pnpm:/usr/local/share/pnpm/bin:/usr/local/bin:${PATH}" } } diff --git a/src/pnpm/install.sh b/src/pnpm/install.sh index b1ba73b..8b53077 100755 --- a/src/pnpm/install.sh +++ b/src/pnpm/install.sh @@ -34,7 +34,7 @@ cat > "${PROFILE_SCRIPT}" << EOF export NPM_CONFIG_PREFIX=${NPM_GLOBAL_PREFIX} export PNPM_HOME=${PNPM_HOME} export PNPM_STORE_DIR=/usr/local/share/pnpm-store -export PATH=${NPM_GLOBAL_PREFIX}/bin:${PNPM_HOME}:/usr/local/bin:\${PATH} +export PATH=${NPM_GLOBAL_PREFIX}/bin:${PNPM_HOME}:${PNPM_HOME}/bin:/usr/local/bin:\${PATH} EOF chmod +x "${PROFILE_SCRIPT}" From c513f5603c86ce85e5d881fe897120024cee663e Mon Sep 17 00:00:00 2001 From: frami Date: Fri, 22 May 2026 11:42:48 -0300 Subject: [PATCH 03/18] feat: gitbutler remote user --- src/gitbutler/install.sh | 12 ++++++++++++ test/gitbutler/non_root_user.sh | 6 ++++++ 2 files changed, 18 insertions(+) diff --git a/src/gitbutler/install.sh b/src/gitbutler/install.sh index ecec42e..1d481c3 100755 --- a/src/gitbutler/install.sh +++ b/src/gitbutler/install.sh @@ -53,4 +53,16 @@ curl -fsSL -o "${BUT_BIN}" "${BUT_URL}" chmod +x "${BUT_BIN}" "${BUT_BIN}" --version +# `but setup` writes to ~/.local/share/com.gitbutler.app at runtime. If another feature +# (or the base image) pre-created ~/.local/share as root via `mkdir -p`, that mkdir +# fails with EACCES. Pre-create the data dir as the remote user so setup never has to +# create a directory under a parent we may not own. +REMOTE_USER="${_REMOTE_USER:-root}" +REMOTE_HOME=$(getent passwd "${REMOTE_USER}" | cut -d: -f6) +if [ -n "${REMOTE_HOME}" ] && [ -d "${REMOTE_HOME}" ]; then + GB_DATA_DIR="${REMOTE_HOME}/.local/share/com.gitbutler.app" + mkdir -p "${GB_DATA_DIR}" + chown -R "${REMOTE_USER}:${REMOTE_USER}" "${GB_DATA_DIR}" +fi + echo "==> GitButler feature installation complete!" diff --git a/test/gitbutler/non_root_user.sh b/test/gitbutler/non_root_user.sh index a8ca4b3..6c990d9 100755 --- a/test/gitbutler/non_root_user.sh +++ b/test/gitbutler/non_root_user.sh @@ -9,6 +9,12 @@ check "but --version" bash -c 'but --version' check "but setup as vscode" bash -c ' set -e + # Simulate a real devcontainer where another root-running feature has already + # created ~/.local/share, leaving it root-owned. `but setup` writes to + # ~/.local/share/com.gitbutler.app — without the install-time pre-create, the + # mkdir there would fail with EACCES. + sudo mkdir -p "$HOME/.local/share" + sudo chown root:root "$HOME/.local/share" mkdir -p "$HOME/proj" cd "$HOME/proj" git init -q From bb29fa78f192b6d8259574f7e9cd38dcec9daa92 Mon Sep 17 00:00:00 2001 From: frami Date: Fri, 22 May 2026 11:53:12 -0300 Subject: [PATCH 04/18] fix: pnpm install --- src/pnpm/install.sh | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/pnpm/install.sh b/src/pnpm/install.sh index 8b53077..1152930 100755 --- a/src/pnpm/install.sh +++ b/src/pnpm/install.sh @@ -26,7 +26,7 @@ mkdir -p "${NPM_GLOBAL_PREFIX}" npm config set prefix "${NPM_GLOBAL_PREFIX}" export PNPM_HOME="/usr/local/share/pnpm" -mkdir -p "${PNPM_HOME}" +mkdir -p "${PNPM_HOME}/bin" export PATH="${PNPM_HOME}:${PATH}" PROFILE_SCRIPT="/etc/profile.d/pnpm.sh" @@ -49,7 +49,11 @@ PNPM_STORE="/usr/local/share/pnpm-store" mkdir -p "${PNPM_STORE}" pnpm config set store-dir "${PNPM_STORE}" -echo "==> Adjusting permissions for ${_REMOTE_USER} user..." -chown -R "${_REMOTE_USER}:${_REMOTE_USER}" /usr/local/share/pnpm /usr/local/share/npm-global /usr/local/share/pnpm-store 2>/dev/null || true +REMOTE_USER="${_REMOTE_USER:-root}" +echo "==> Adjusting permissions for ${REMOTE_USER} user..." +chown -R "${REMOTE_USER}:${REMOTE_USER}" \ + /usr/local/share/pnpm \ + /usr/local/share/npm-global \ + /usr/local/share/pnpm-store echo "==> pnpm feature installation complete!" From 0046e634645bcbfaed1236ce4ee6a9df2a90e998 Mon Sep 17 00:00:00 2001 From: frami Date: Fri, 22 May 2026 12:14:36 -0300 Subject: [PATCH 05/18] chore: default pnpm to version 11 --- src/pnpm/install.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pnpm/install.sh b/src/pnpm/install.sh index 1152930..975eb67 100755 --- a/src/pnpm/install.sh +++ b/src/pnpm/install.sh @@ -12,7 +12,7 @@ if [ "$(id -u)" -ne 0 ]; then exit 1 fi -PNPM_VERSION="${VERSION:-10}" +PNPM_VERSION="${VERSION:-11}" export DEBIAN_FRONTEND=noninteractive From 05afed9b9a493c27adb2d69a920e06bd36b47913 Mon Sep 17 00:00:00 2001 From: frami Date: Fri, 22 May 2026 13:02:31 -0300 Subject: [PATCH 06/18] chore: add tests? --- test/gitbutler/non_root_user.sh | 4 +-- test/gitbutler/scenarios.json | 24 ++++++++--------- test/pnpm/pnpm_10.sh | 5 ++++ test/pnpm/scenarios.json | 48 +++++++++++++++++++-------------- 4 files changed, 47 insertions(+), 34 deletions(-) create mode 100755 test/pnpm/pnpm_10.sh diff --git a/test/gitbutler/non_root_user.sh b/test/gitbutler/non_root_user.sh index 6c990d9..c865bbf 100755 --- a/test/gitbutler/non_root_user.sh +++ b/test/gitbutler/non_root_user.sh @@ -2,12 +2,12 @@ set -e source dev-container-features-test-lib -check "whoami is vscode" bash -c '[ "$(whoami)" = "vscode" ]' +check "whoami is node" bash -c '[ "$(whoami)" = "node" ]' check "home writable" bash -c 'touch "$HOME/.rwtest" && rm "$HOME/.rwtest"' check "but on PATH" bash -c 'command -v but' check "but --version" bash -c 'but --version' -check "but setup as vscode" bash -c ' +check "but setup as node" bash -c ' set -e # Simulate a real devcontainer where another root-running feature has already # created ~/.local/share, leaving it root-owned. `but setup` writes to diff --git a/test/gitbutler/scenarios.json b/test/gitbutler/scenarios.json index aac2405..a4a28e2 100644 --- a/test/gitbutler/scenarios.json +++ b/test/gitbutler/scenarios.json @@ -1,15 +1,15 @@ { - "default": { - "image": "mcr.microsoft.com/devcontainers/base:trixie", - "features": { - "gitbutler": {} - } - }, - "non_root_user": { - "image": "mcr.microsoft.com/devcontainers/base:trixie", - "remoteUser": "vscode", - "features": { - "gitbutler": {} - } + "default": { + "image": "mcr.microsoft.com/devcontainers/base:trixie", + "features": { + "gitbutler": {} } + }, + "non_root_user": { + "image": "mcr.microsoft.com/devcontainers/base:trixie", + "remoteUser": "node", + "features": { + "gitbutler": {} + } + } } diff --git a/test/pnpm/pnpm_10.sh b/test/pnpm/pnpm_10.sh new file mode 100755 index 0000000..cdf4cb9 --- /dev/null +++ b/test/pnpm/pnpm_10.sh @@ -0,0 +1,5 @@ +#!/bin/bash +set -e +source dev-container-features-test-lib +check "pnpm major is 10" bash -c "pnpm --version | grep -E '^10\\.'" +reportResults diff --git a/test/pnpm/scenarios.json b/test/pnpm/scenarios.json index 29cf330..abefc24 100644 --- a/test/pnpm/scenarios.json +++ b/test/pnpm/scenarios.json @@ -1,23 +1,31 @@ { - "default": { - "image": "mcr.microsoft.com/devcontainers/typescript-node:24-trixie", - "features": { - "pnpm": {} - } - }, - "pnpm_9": { - "image": "mcr.microsoft.com/devcontainers/typescript-node:24-trixie", - "features": { - "pnpm": { - "version": "9" - } - } - }, - "non_root_user": { - "image": "mcr.microsoft.com/devcontainers/typescript-node:24-trixie", - "remoteUser": "node", - "features": { - "pnpm": {} - } + "default": { + "image": "mcr.microsoft.com/devcontainers/typescript-node:24-trixie", + "features": { + "pnpm": {} } + }, + "pnpm_9": { + "image": "mcr.microsoft.com/devcontainers/typescript-node:24-trixie", + "features": { + "pnpm": { + "version": "9" + } + } + }, + "pnpm_10": { + "image": "mcr.microsoft.com/devcontainers/typescript-node:24-trixie", + "features": { + "pnpm": { + "version": "10" + } + } + }, + "non_root_user": { + "image": "mcr.microsoft.com/devcontainers/typescript-node:24-trixie", + "remoteUser": "node", + "features": { + "pnpm": {} + } + } } From d55c10afd6a2eb8d758253965e4e3a08edd64ee5 Mon Sep 17 00:00:00 2001 From: frami Date: Fri, 22 May 2026 17:39:44 -0300 Subject: [PATCH 07/18] chore: update local devcontainer --- .devcontainer/devcontainer-lock.json | 23 ++++++++++++++++++++++- .devcontainer/devcontainer.json | 10 +++++----- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json index 219acb6..8b0d4f1 100644 --- a/.devcontainer/devcontainer-lock.json +++ b/.devcontainer/devcontainer-lock.json @@ -1,3 +1,24 @@ { - "features": {} + "features": { + "ghcr.io/synergy-shock/devcontainer-features/claude:2": { + "version": "2.1.145", + "resolved": "ghcr.io/synergy-shock/devcontainer-features/claude@sha256:4ad94850485d53ec0a7ccf1fbef5e48286dde8be1811bdb926434637085e0c4e", + "integrity": "sha256:4ad94850485d53ec0a7ccf1fbef5e48286dde8be1811bdb926434637085e0c4e" + }, + "ghcr.io/synergy-shock/devcontainer-features/gitbutler:0": { + "version": "0.1.0", + "resolved": "ghcr.io/synergy-shock/devcontainer-features/gitbutler@sha256:28e79b97e473e948f1d4ff8d35690ef1191f1f0bf2bb3f0df398bfb507b16ae2", + "integrity": "sha256:28e79b97e473e948f1d4ff8d35690ef1191f1f0bf2bb3f0df398bfb507b16ae2" + }, + "ghcr.io/synergy-shock/devcontainer-features/pnpm:11": { + "version": "11.0.0", + "resolved": "ghcr.io/synergy-shock/devcontainer-features/pnpm@sha256:9ad82ac4ab3b0cf1d8422adbfd09ce61b12d99acd21d5083718ee55c87d9a2f9", + "integrity": "sha256:9ad82ac4ab3b0cf1d8422adbfd09ce61b12d99acd21d5083718ee55c87d9a2f9" + }, + "ghcr.io/synergy-shock/devcontainer-features/rtk:0": { + "version": "0.1.0", + "resolved": "ghcr.io/synergy-shock/devcontainer-features/rtk@sha256:69779045064cdc8a6d92461949d15974d0e19bc78d0f2b791969d5d719f392d4", + "integrity": "sha256:69779045064cdc8a6d92461949d15974d0e19bc78d0f2b791969d5d719f392d4" + } + } } diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 3437a17..6c603b4 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -2,11 +2,11 @@ "name": "Devcontainer Features", "image": "mcr.microsoft.com/devcontainers/typescript-node:24-trixie", "features": { - "../src/pnpm": {}, - "../src/gitbutler": {}, - // "../src/opencode": {}, - "../src/rtk": {}, - "../src/claude": {}, + "ghcr.io/synergy-shock/devcontainer-features/pnpm:11": {}, + "ghcr.io/synergy-shock/devcontainer-features/claude:2": {}, + // "ghcr.io/synergy-shock/devcontainer-features/opencode:1": {}, + "ghcr.io/synergy-shock/devcontainer-features/rtk:0": {}, + "ghcr.io/synergy-shock/devcontainer-features/gitbutler:0": {}, }, "mounts": [ "source=pnpm-store,target=/home/node/.pnpm-store,type=volume", From f7e65477ffd524eb6ee30ee1f3d3f3ecf4682209 Mon Sep 17 00:00:00 2001 From: frami Date: Fri, 22 May 2026 17:40:18 -0300 Subject: [PATCH 08/18] test: gitbutler + opencode --- .../opencode_and_gitbutler_non_root.sh | 21 +++++++++++++++++++ test/_global/scenarios.json | 8 +++++++ test/gitbutler/non_root_user.sh | 6 ------ 3 files changed, 29 insertions(+), 6 deletions(-) create mode 100755 test/_global/opencode_and_gitbutler_non_root.sh diff --git a/test/_global/opencode_and_gitbutler_non_root.sh b/test/_global/opencode_and_gitbutler_non_root.sh new file mode 100755 index 0000000..b98382a --- /dev/null +++ b/test/_global/opencode_and_gitbutler_non_root.sh @@ -0,0 +1,21 @@ +#!/bin/bash +set -e +source dev-container-features-test-lib + +check "whoami is node" bash -c '[ "$(whoami)" = "node" ]' +check "home writable" bash -c 'touch "$HOME/.rwtest" && rm "$HOME/.rwtest"' +check "but on PATH" bash -c 'command -v but' +check "opencode on PATH" bash -c 'command -v opencode' + +check "but setup as node with opencode installed" bash -c ' + set -e + mkdir -p "$HOME/proj" + cd "$HOME/proj" + git init -q + git config user.email t@t + git config user.name t + git commit --allow-empty -qm init + but setup +' + +reportResults diff --git a/test/_global/scenarios.json b/test/_global/scenarios.json index 1ce441a..6288e6e 100644 --- a/test/_global/scenarios.json +++ b/test/_global/scenarios.json @@ -19,5 +19,13 @@ "claude": {}, "rtk": {} } + }, + "opencode_and_gitbutler_non_root": { + "image": "mcr.microsoft.com/devcontainers/typescript-node:24-trixie", + "remoteUser": "node", + "features": { + "opencode": {}, + "gitbutler": {} + } } } diff --git a/test/gitbutler/non_root_user.sh b/test/gitbutler/non_root_user.sh index c865bbf..2fd228e 100755 --- a/test/gitbutler/non_root_user.sh +++ b/test/gitbutler/non_root_user.sh @@ -9,12 +9,6 @@ check "but --version" bash -c 'but --version' check "but setup as node" bash -c ' set -e - # Simulate a real devcontainer where another root-running feature has already - # created ~/.local/share, leaving it root-owned. `but setup` writes to - # ~/.local/share/com.gitbutler.app — without the install-time pre-create, the - # mkdir there would fail with EACCES. - sudo mkdir -p "$HOME/.local/share" - sudo chown root:root "$HOME/.local/share" mkdir -p "$HOME/proj" cd "$HOME/proj" git init -q From 6b15388bb75d177e8d9a6ee0ddd055e3dd2953ff Mon Sep 17 00:00:00 2001 From: frami Date: Fri, 22 May 2026 17:51:14 -0300 Subject: [PATCH 09/18] chore: update gitbutler --- test/gitbutler/non_root_user.sh | 4 ++-- test/gitbutler/scenarios.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/test/gitbutler/non_root_user.sh b/test/gitbutler/non_root_user.sh index 2fd228e..a8ca4b3 100755 --- a/test/gitbutler/non_root_user.sh +++ b/test/gitbutler/non_root_user.sh @@ -2,12 +2,12 @@ set -e source dev-container-features-test-lib -check "whoami is node" bash -c '[ "$(whoami)" = "node" ]' +check "whoami is vscode" bash -c '[ "$(whoami)" = "vscode" ]' check "home writable" bash -c 'touch "$HOME/.rwtest" && rm "$HOME/.rwtest"' check "but on PATH" bash -c 'command -v but' check "but --version" bash -c 'but --version' -check "but setup as node" bash -c ' +check "but setup as vscode" bash -c ' set -e mkdir -p "$HOME/proj" cd "$HOME/proj" diff --git a/test/gitbutler/scenarios.json b/test/gitbutler/scenarios.json index a4a28e2..3f1e6bd 100644 --- a/test/gitbutler/scenarios.json +++ b/test/gitbutler/scenarios.json @@ -7,7 +7,7 @@ }, "non_root_user": { "image": "mcr.microsoft.com/devcontainers/base:trixie", - "remoteUser": "node", + "remoteUser": "vscode", "features": { "gitbutler": {} } From 0453baf07be77e640aa6fa632cda992e773f4efa Mon Sep 17 00:00:00 2001 From: frami Date: Fri, 22 May 2026 20:30:25 -0300 Subject: [PATCH 10/18] refactor: remove pnpm --- .github/workflows/test.yaml | 10 +--- Makefile | 2 +- README.md | 71 ++++++++++++--------------- src/pnpm/NOTES.md | 32 ------------ src/pnpm/README.md | 55 --------------------- src/pnpm/devcontainer-feature.json | 21 -------- src/pnpm/install.sh | 59 ---------------------- test/_global/all_features.sh | 2 - test/_global/all_features_non_root.sh | 2 - test/_global/scenarios.json | 2 - test/pnpm/default.sh | 5 -- test/pnpm/non_root_user.sh | 12 ----- test/pnpm/pnpm_10.sh | 5 -- test/pnpm/pnpm_9.sh | 5 -- test/pnpm/scenarios.json | 31 ------------ test/pnpm/test.sh | 12 ----- 16 files changed, 33 insertions(+), 293 deletions(-) delete mode 100644 src/pnpm/NOTES.md delete mode 100644 src/pnpm/README.md delete mode 100644 src/pnpm/devcontainer-feature.json delete mode 100755 src/pnpm/install.sh delete mode 100755 test/pnpm/default.sh delete mode 100755 test/pnpm/non_root_user.sh delete mode 100755 test/pnpm/pnpm_10.sh delete mode 100755 test/pnpm/pnpm_9.sh delete mode 100644 test/pnpm/scenarios.json delete mode 100755 test/pnpm/test.sh diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 1c5ceef..e2aa655 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -14,14 +14,6 @@ jobs: fail-fast: false matrix: include: - # pnpm requires Node — only typescript-node:24 has it. - - feature: pnpm - base-image: mcr.microsoft.com/devcontainers/typescript-node:24-trixie - - feature: pnpm - base-image: mcr.microsoft.com/devcontainers/typescript-node:24-bookworm - - feature: pnpm - base-image: mcr.microsoft.com/devcontainers/typescript-node:24-bullseye - # claude / gitbutler / opencode are Node-less — test on the bare base # across all current Debian + Ubuntu LTS releases. - feature: claude @@ -95,7 +87,7 @@ jobs: strategy: fail-fast: false matrix: - feature: [claude, gitbutler, opencode, pnpm] + feature: [claude, gitbutler, opencode] steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 diff --git a/Makefile b/Makefile index 210a4d4..ee0ad66 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -FEATURES ?= claude,gitbutler,opencode,pnpm +FEATURES ?= claude,gitbutler,opencode,rtk BASE_IMAGE ?= mcr.microsoft.com/devcontainers/typescript-node:24-trixie BASE_PATH ?= ./src OCI_REGISTRY ?= ghcr.io diff --git a/README.md b/README.md index 35af222..31544fd 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,6 @@ A collection of [Dev Container Features](https://containers.dev/features) publis | [`claude`](./src/claude) | Claude Code | Installs the Claude Code CLI (`@anthropic-ai/claude-code`). | `ghcr.io/synergy-shock/devcontainer-features/claude` | | [`gitbutler`](./src/gitbutler) | GitButler CLI | Installs the GitButler CLI (`but`). | `ghcr.io/synergy-shock/devcontainer-features/gitbutler` | | [`opencode`](./src/opencode) | OpenCode | Installs the OpenCode AI CLI. | `ghcr.io/synergy-shock/devcontainer-features/opencode` | -| [`pnpm`](./src/pnpm) | pnpm | Installs pnpm globally and configures the store and npm prefix. | `ghcr.io/synergy-shock/devcontainer-features/pnpm` | | [`rtk`](./src/rtk) | rtk (Rust Token Killer) | Installs the `rtk` CLI proxy. Pairs with `claude` and/or `opencode`. | `ghcr.io/synergy-shock/devcontainer-features/rtk` | ## Usage @@ -18,9 +17,8 @@ Reference a feature from any `devcontainer.json` by its GHCR URL and version tag ```jsonc { - "image": "mcr.microsoft.com/devcontainers/typescript-node:24-trixie", + "image": "mcr.microsoft.com/devcontainers/base:trixie", "features": { - "ghcr.io/synergy-shock/devcontainer-features/pnpm:11": {}, "ghcr.io/synergy-shock/devcontainer-features/claude:2": {}, "ghcr.io/synergy-shock/devcontainer-features/opencode:1": {}, "ghcr.io/synergy-shock/devcontainer-features/rtk:0": {}, @@ -31,56 +29,49 @@ Reference a feature from any `devcontainer.json` by its GHCR URL and version tag Version tags follow the `MAJOR`, `MAJOR.MINOR`, and `MAJOR.MINOR.PATCH` aliases that `devcontainers/action` publishes automatically. -## Wiring host state into the container +## Recommended pairings -The features install binaries; the `mounts` and `containerEnv` in this repo's own [`.devcontainer/devcontainer.json`](./.devcontainer/devcontainer.json) are a worked example of how to thread host credentials, caches, and services through so those binaries have something to talk to. +These features are intentionally narrow — one upstream CLI each. For everything else (a shell, language runtimes, git, GitHub auth), reach for **Microsoft's official base images** and the **`devcontainers/features`** catalog before writing your own. Both are first-party, versioned, and reviewed. -### Conventions worth copying +**Start from an official base image.** Pick the one that already ships the runtime you need so you don't reinstall it as a feature: -- **Bind host config, not container config.** Treat the container as disposable. Anything you would lose on rebuild (auth tokens, AI session state, shell history for the tools you care about) should live on the host and be mounted in. -- **Named volumes for caches.** Bind mounts share the host filesystem's permissions and inode layout, which is fine for config but slow and fragile for large package caches. Use a Docker named volume (like `pnpm-store` below) for anything write-heavy. -- **`readonly` for credentials you only need to read.** `.npmrc` and the 1Password signing helper are mounted readonly so a misbehaving tool inside the container cannot corrupt host state. -- **Pair every socket bind with an env var.** A forwarded socket with no `SSH_AUTH_SOCK` (or equivalent) pointing at it is just a file. +- [`mcr.microsoft.com/devcontainers/base:`](https://github.com/devcontainers/images/tree/main/src/base-debian) — minimal Debian/Ubuntu with `vscode` user, sudo, common utilities. The right default for our `claude` / `gitbutler` / `opencode` / `rtk` features, none of which need Node. +- [`mcr.microsoft.com/devcontainers/typescript-node:-`](https://github.com/devcontainers/images/tree/main/src/typescript-node) — base + Node.js + `pnpm` and `yarn` via corepack, ships with a `node` user. Use this when you actually need a JS toolchain; it removes the need for any pnpm feature. -### Forward PNPM configuration to all devcontainers +**Layer official features for shared tooling.** From [`ghcr.io/devcontainers/features`](https://github.com/devcontainers/features/tree/main/src): -Mount the host's `~/.npmrc` readonly so the container inherits your registry, auth tokens, and pnpm settings without copying secrets into the image. `PNPM_HOME` and `PNPM_STORE_DIR` keep the store path predictable across rebuilds. +- [`node:1`](https://github.com/devcontainers/features/tree/main/src/node) — install Node + npm, and pnpm via its `pnpmVersion` option (and yarn via `installYarnUsingApt`). Prefer this over a hand-rolled pnpm feature. +- [`common-utils:2`](https://github.com/devcontainers/features/tree/main/src/common-utils) — sudo, curl/wget, useful shells, the canonical non-root user setup. Add it when you start from a non-`devcontainers/base` image. +- [`git:1`](https://github.com/devcontainers/features/tree/main/src/git) — newer git than what's in older distros. +- [`git-lfs:1`](https://github.com/devcontainers/features/tree/main/src/git-lfs) — Git LFS, if your repo uses it. +- [`github-cli:1`](https://github.com/devcontainers/features/tree/main/src/github-cli) — `gh` for PR/issue workflows and `gh auth` against the host. + +A typical Node-flavored stack ends up looking like: ```jsonc { "image": "mcr.microsoft.com/devcontainers/typescript-node:24-trixie", "features": { - "ghcr.io/synergy-shock/devcontainer-features/pnpm:11": {} - }, - "mounts": [ - "source=${localEnv:HOME}/.npmrc,target=/home/node/.npmrc,type=bind,readonly", - ], - "containerEnv": { - "PNPM_HOME": "/home/node/.pnpm-store", - "PNPM_STORE_DIR": "/home/node/.pnpm-store" + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/devcontainers/features/git-lfs:1": {}, + "ghcr.io/synergy-shock/devcontainer-features/claude:2": {}, + "ghcr.io/synergy-shock/devcontainer-features/gitbutler:0": {} } } ``` -### Unify PNPM cache across all devcontainers +If you don't need Node at all, drop the image down to `mcr.microsoft.com/devcontainers/base:trixie` and skip the `node` feature entirely. -Back the pnpm store with a Docker named volume so every devcontainer on the host shares one cache — first install in a fresh container is fast, and disk usage stops scaling with the number of projects. +## Wiring host state into the container -```jsonc -{ - "image": "mcr.microsoft.com/devcontainers/typescript-node:24-trixie", - "features": { - "ghcr.io/synergy-shock/devcontainer-features/pnpm:11": {} - }, - "mounts": [ - "source=pnpm-store,target=/home/node/.pnpm-store,type=volume", - ], - "containerEnv": { - "PNPM_HOME": "/home/node/.pnpm-store", - "PNPM_STORE_DIR": "/home/node/.pnpm-store" - } -} -``` +The features install binaries; the `mounts` and `containerEnv` in this repo's own [`.devcontainer/devcontainer.json`](./.devcontainer/devcontainer.json) are a worked example of how to thread host credentials, caches, and services through so those binaries have something to talk to. + +### Conventions worth copying + +- **Bind host config, not container config.** Treat the container as disposable. Anything you would lose on rebuild (auth tokens, AI session state, shell history for the tools you care about) should live on the host and be mounted in. +- **Named volumes for caches.** Bind mounts share the host filesystem's permissions and inode layout, which is fine for config but slow and fragile for large package caches. Use a Docker named volume for anything write-heavy (a shared pnpm/yarn store, build caches, Docker layer caches inside the container). +- **`readonly` for credentials you only need to read.** `.npmrc` and the 1Password signing helper are mounted readonly so a misbehaving tool inside the container cannot corrupt host state. +- **Pair every socket bind with an env var.** A forwarded socket with no `SSH_AUTH_SOCK` (or equivalent) pointing at it is just a file. ### 1Password SSH agent and commit signing (macOS) @@ -116,7 +107,7 @@ Then inside the container verify the binaries you touched: claude --version but --version opencode --version -pnpm --version +rtk --version ``` ## Testing @@ -131,8 +122,8 @@ Then from the repo root: ```bash # Autogenerated checks for a single feature -devcontainer features test -f pnpm \ - -i mcr.microsoft.com/devcontainers/typescript-node:24-trixie \ +devcontainer features test -f claude \ + -i mcr.microsoft.com/devcontainers/base:trixie \ -p . # Custom scenarios for a single feature diff --git a/src/pnpm/NOTES.md b/src/pnpm/NOTES.md deleted file mode 100644 index 1e91b16..0000000 --- a/src/pnpm/NOTES.md +++ /dev/null @@ -1,32 +0,0 @@ -## OS support - -Debian/Ubuntu-based images with Node.js and `npm` pre-installed (e.g., the `mcr.microsoft.com/devcontainers/typescript-node` family). - -## Implementation details - -- `npm config set prefix /usr/local/share/npm-global` so global installs land in a system-wide location. -- `PNPM_HOME=/usr/local/share/pnpm` and `PNPM_STORE_DIR=/usr/local/share/pnpm-store`. -- `pnpm setup` is run under `SHELL=/bin/bash`. -- A `/etc/profile.d/pnpm.sh` snippet exports `NPM_CONFIG_PREFIX`, `PNPM_HOME`, `PNPM_STORE_DIR`, and prepends both bin dirs to `PATH` so login shells pick them up. -- The directories are `chown`-ed to `_REMOTE_USER` after install. - -## Unify the pnpm store across devcontainers - -Back the pnpm store with a Docker named volume and forward the host `~/.npmrc` readonly. Every devcontainer on the host then shares one cache — first install in a fresh container is fast, and registry/auth config stays on the host instead of being baked into images. - -```jsonc -{ - "image": "mcr.microsoft.com/devcontainers/typescript-node:24-trixie", - "features": { - "ghcr.io/synergy-shock/devcontainer-features/pnpm:11": {} - }, - "mounts": [ - "source=pnpm-store,target=/home/node/.pnpm-store,type=volume", - "source=${localEnv:HOME}/.npmrc,target=/home/node/.npmrc,type=bind,readonly" - ], - "containerEnv": { - "PNPM_HOME": "/home/node/.pnpm-store", - "PNPM_STORE_DIR": "/home/node/.pnpm-store" - } -} -``` diff --git a/src/pnpm/README.md b/src/pnpm/README.md deleted file mode 100644 index f38f4ad..0000000 --- a/src/pnpm/README.md +++ /dev/null @@ -1,55 +0,0 @@ - -# pnpm (pnpm) - -Installs pnpm globally and configures the pnpm store directory and npm global prefix. - -## Example Usage - -```json -"features": { - "ghcr.io/synergy-shock/devcontainer-features/pnpm:11": {} -} -``` - -## Options - -| Options Id | Description | Type | Default Value | -|-----|-----|-----|-----| -| version | Select or enter the version of pnpm to install globally. | string | 11 | - -## OS support - -Debian/Ubuntu-based images with Node.js and `npm` pre-installed (e.g., the `mcr.microsoft.com/devcontainers/typescript-node` family). - -## Implementation details - -- `npm config set prefix /usr/local/share/npm-global` so global installs land in a system-wide location. -- `PNPM_HOME=/usr/local/share/pnpm` and `PNPM_STORE_DIR=/usr/local/share/pnpm-store`. -- `pnpm setup` is run under `SHELL=/bin/bash`. -- A `/etc/profile.d/pnpm.sh` snippet exports `NPM_CONFIG_PREFIX`, `PNPM_HOME`, `PNPM_STORE_DIR`, and prepends both bin dirs to `PATH` so login shells pick them up. -- The directories are `chown`-ed to `_REMOTE_USER` after install. - -## Unify the pnpm store across devcontainers - -Back the pnpm store with a Docker named volume and forward the host `~/.npmrc` readonly. Every devcontainer on the host then shares one cache — first install in a fresh container is fast, and registry/auth config stays on the host instead of being baked into images. - -```jsonc -{ - "image": "mcr.microsoft.com/devcontainers/typescript-node:24-trixie", - "features": { - "ghcr.io/synergy-shock/devcontainer-features/pnpm:11": {} - }, - "mounts": [ - "source=pnpm-store,target=/home/node/.pnpm-store,type=volume", - "source=${localEnv:HOME}/.npmrc,target=/home/node/.npmrc,type=bind,readonly" - ], - "containerEnv": { - "PNPM_HOME": "/home/node/.pnpm-store", - "PNPM_STORE_DIR": "/home/node/.pnpm-store" - } -} -``` - ---- - -_Note: This file was auto-generated from the [devcontainer-feature.json](https://github.com/Synergy-Shock/devcontainer-features/blob/main/src/pnpm/devcontainer-feature.json). Add additional notes to a `NOTES.md`._ diff --git a/src/pnpm/devcontainer-feature.json b/src/pnpm/devcontainer-feature.json deleted file mode 100644 index a016086..0000000 --- a/src/pnpm/devcontainer-feature.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "id": "pnpm", - "version": "11.0.0", - "name": "pnpm", - "description": "Installs pnpm globally and configures the pnpm store directory and npm global prefix.", - "documentationURL": "https://github.com/Synergy-Shock/devcontainer-features/tree/main/src/pnpm", - "options": { - "version": { - "type": "string", - "default": "11", - "description": "Select or enter the version of pnpm to install globally.", - "proposals": ["latest", "10", "9", "8"] - } - }, - "containerEnv": { - "NPM_CONFIG_PREFIX": "/usr/local/share/npm-global", - "PNPM_HOME": "/usr/local/share/pnpm", - "PNPM_STORE_DIR": "/usr/local/share/pnpm-store", - "PATH": "/usr/local/share/npm-global/bin:/usr/local/share/pnpm:/usr/local/share/pnpm/bin:/usr/local/bin:${PATH}" - } -} diff --git a/src/pnpm/install.sh b/src/pnpm/install.sh deleted file mode 100755 index 975eb67..0000000 --- a/src/pnpm/install.sh +++ /dev/null @@ -1,59 +0,0 @@ -#!/bin/bash -set -e - -#----------------------------------------------------------------------------------------------------- -# pnpm Dev Container Feature -# Installs: pnpm, configures npm global prefix and pnpm store directory -# Intended for use on Debian/Ubuntu-based images with Node.js/npm pre-installed -#----------------------------------------------------------------------------------------------------- - -if [ "$(id -u)" -ne 0 ]; then - echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' - exit 1 -fi - -PNPM_VERSION="${VERSION:-11}" - -export DEBIAN_FRONTEND=noninteractive - -apt-get update -apt-get install -y --no-install-recommends curl -rm -rf /var/lib/apt/lists/* - -echo "==> Configuring npm global prefix..." -NPM_GLOBAL_PREFIX="/usr/local/share/npm-global" -mkdir -p "${NPM_GLOBAL_PREFIX}" -npm config set prefix "${NPM_GLOBAL_PREFIX}" - -export PNPM_HOME="/usr/local/share/pnpm" -mkdir -p "${PNPM_HOME}/bin" -export PATH="${PNPM_HOME}:${PATH}" - -PROFILE_SCRIPT="/etc/profile.d/pnpm.sh" -cat > "${PROFILE_SCRIPT}" << EOF -export NPM_CONFIG_PREFIX=${NPM_GLOBAL_PREFIX} -export PNPM_HOME=${PNPM_HOME} -export PNPM_STORE_DIR=/usr/local/share/pnpm-store -export PATH=${NPM_GLOBAL_PREFIX}/bin:${PNPM_HOME}:${PNPM_HOME}/bin:/usr/local/bin:\${PATH} -EOF -chmod +x "${PROFILE_SCRIPT}" - -echo "==> Installing pnpm@${PNPM_VERSION}..." -npm install -g "pnpm@${PNPM_VERSION}" -export SHELL=/bin/bash -pnpm setup -pnpm --version - -echo "==> Configuring pnpm store directory..." -PNPM_STORE="/usr/local/share/pnpm-store" -mkdir -p "${PNPM_STORE}" -pnpm config set store-dir "${PNPM_STORE}" - -REMOTE_USER="${_REMOTE_USER:-root}" -echo "==> Adjusting permissions for ${REMOTE_USER} user..." -chown -R "${REMOTE_USER}:${REMOTE_USER}" \ - /usr/local/share/pnpm \ - /usr/local/share/npm-global \ - /usr/local/share/pnpm-store - -echo "==> pnpm feature installation complete!" diff --git a/test/_global/all_features.sh b/test/_global/all_features.sh index 01d5af2..b897099 100755 --- a/test/_global/all_features.sh +++ b/test/_global/all_features.sh @@ -3,13 +3,11 @@ set -e source dev-container-features-test-lib -check "pnpm on PATH" bash -c "command -v pnpm" check "claude on PATH" bash -c "command -v claude" check "but on PATH" bash -c "command -v but" check "opencode on PATH" bash -c "command -v opencode" check "rtk on PATH" bash -c "command -v rtk" -check "pnpm --version" bash -c "pnpm --version" check "claude --version" bash -c "claude --version" check "but --version" bash -c "but --version" check "opencode --version" bash -c "opencode --version" diff --git a/test/_global/all_features_non_root.sh b/test/_global/all_features_non_root.sh index 248b4b5..07c460b 100755 --- a/test/_global/all_features_non_root.sh +++ b/test/_global/all_features_non_root.sh @@ -5,13 +5,11 @@ source dev-container-features-test-lib check "whoami is node" bash -c '[ "$(whoami)" = "node" ]' check "home writable" bash -c 'touch "$HOME/.rwtest" && rm "$HOME/.rwtest"' -check "pnpm on PATH" bash -c "command -v pnpm" check "claude on PATH" bash -c "command -v claude" check "but on PATH" bash -c "command -v but" check "opencode on PATH" bash -c "command -v opencode" check "rtk on PATH" bash -c "command -v rtk" -check "pnpm --version" bash -c "pnpm --version" check "claude --version" bash -c "claude --version" check "but --version" bash -c "but --version" check "opencode --version" bash -c "opencode --version" diff --git a/test/_global/scenarios.json b/test/_global/scenarios.json index 6288e6e..4f87072 100644 --- a/test/_global/scenarios.json +++ b/test/_global/scenarios.json @@ -2,7 +2,6 @@ "all_features": { "image": "mcr.microsoft.com/devcontainers/typescript-node:24-trixie", "features": { - "pnpm": {}, "gitbutler": {}, "opencode": {}, "claude": {}, @@ -13,7 +12,6 @@ "image": "mcr.microsoft.com/devcontainers/typescript-node:24-trixie", "remoteUser": "node", "features": { - "pnpm": {}, "gitbutler": {}, "opencode": {}, "claude": {}, diff --git a/test/pnpm/default.sh b/test/pnpm/default.sh deleted file mode 100755 index 944de6e..0000000 --- a/test/pnpm/default.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash -set -e -source dev-container-features-test-lib -check "pnpm major is 11" bash -c "pnpm --version | grep -E '^11\\.'" -reportResults diff --git a/test/pnpm/non_root_user.sh b/test/pnpm/non_root_user.sh deleted file mode 100755 index df8c3fb..0000000 --- a/test/pnpm/non_root_user.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/bash -set -e -source dev-container-features-test-lib - -check "whoami is node" bash -c '[ "$(whoami)" = "node" ]' -check "home writable" bash -c 'touch "$HOME/.rwtest" && rm "$HOME/.rwtest"' -check "pnpm on PATH" bash -c 'command -v pnpm' -check "pnpm --version" bash -c 'pnpm --version' -check "pnpm major is 11" bash -c "pnpm --version | grep -E '^11\\.'" -check "pnpm add -g without sudo" bash -c 'pnpm add -g cowsay && command -v cowsay' - -reportResults diff --git a/test/pnpm/pnpm_10.sh b/test/pnpm/pnpm_10.sh deleted file mode 100755 index cdf4cb9..0000000 --- a/test/pnpm/pnpm_10.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash -set -e -source dev-container-features-test-lib -check "pnpm major is 10" bash -c "pnpm --version | grep -E '^10\\.'" -reportResults diff --git a/test/pnpm/pnpm_9.sh b/test/pnpm/pnpm_9.sh deleted file mode 100755 index 1d16e14..0000000 --- a/test/pnpm/pnpm_9.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash -set -e -source dev-container-features-test-lib -check "pnpm major is 9" bash -c "pnpm --version | grep -E '^9\\.'" -reportResults diff --git a/test/pnpm/scenarios.json b/test/pnpm/scenarios.json deleted file mode 100644 index abefc24..0000000 --- a/test/pnpm/scenarios.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "default": { - "image": "mcr.microsoft.com/devcontainers/typescript-node:24-trixie", - "features": { - "pnpm": {} - } - }, - "pnpm_9": { - "image": "mcr.microsoft.com/devcontainers/typescript-node:24-trixie", - "features": { - "pnpm": { - "version": "9" - } - } - }, - "pnpm_10": { - "image": "mcr.microsoft.com/devcontainers/typescript-node:24-trixie", - "features": { - "pnpm": { - "version": "10" - } - } - }, - "non_root_user": { - "image": "mcr.microsoft.com/devcontainers/typescript-node:24-trixie", - "remoteUser": "node", - "features": { - "pnpm": {} - } - } -} diff --git a/test/pnpm/test.sh b/test/pnpm/test.sh deleted file mode 100755 index a55f463..0000000 --- a/test/pnpm/test.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/bash -set -e - -source dev-container-features-test-lib - -check "pnpm on PATH" bash -c "command -v pnpm" -check "pnpm --version" bash -c "pnpm --version | grep -E '[0-9]+\\.[0-9]+\\.[0-9]+'" -check "store dir configured" bash -c "test -d /usr/local/share/pnpm-store" -check "npm prefix configured" bash -c "test -d /usr/local/share/npm-global" -check "profile.d snippet" bash -c "test -x /etc/profile.d/pnpm.sh" - -reportResults From adaa7782d47ae15db7845ec28c2752944e6eb74f Mon Sep 17 00:00:00 2001 From: frami Date: Fri, 22 May 2026 21:25:06 -0300 Subject: [PATCH 11/18] feat: node featue --- .github/workflows/test.yaml | 18 ++- README.md | 7 +- src/claude/devcontainer-feature.json | 7 +- src/gitbutler/install.sh | 12 -- src/node/NOTES.md | 30 +++++ src/node/devcontainer-feature.json | 35 +++++ src/node/install.sh | 161 +++++++++++++++++++++++ test/_global/common_utils_node_claude.sh | 20 +++ test/_global/scenarios.json | 8 ++ test/node/default.sh | 7 + test/node/lts_codename.sh | 5 + test/node/non_root_user.sh | 7 + test/node/pinned_version.sh | 7 + test/node/scenarios.json | 37 ++++++ test/node/test.sh | 16 +++ 15 files changed, 356 insertions(+), 21 deletions(-) create mode 100644 src/node/NOTES.md create mode 100644 src/node/devcontainer-feature.json create mode 100755 src/node/install.sh create mode 100755 test/_global/common_utils_node_claude.sh create mode 100755 test/node/default.sh create mode 100755 test/node/lts_codename.sh create mode 100755 test/node/non_root_user.sh create mode 100755 test/node/pinned_version.sh create mode 100644 test/node/scenarios.json create mode 100755 test/node/test.sh diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index e2aa655..4a1e08f 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -53,6 +53,22 @@ jobs: base-image: mcr.microsoft.com/devcontainers/base:jammy - feature: opencode base-image: mcr.microsoft.com/devcontainers/base:focal + + # node uses the official Linux prebuilt tarball; Node 24 LTS requires + # glibc >= 2.28, which is satisfied by all six base images (focal and + # bullseye both ship glibc 2.31). + - feature: node + base-image: mcr.microsoft.com/devcontainers/base:trixie + - feature: node + base-image: mcr.microsoft.com/devcontainers/base:bookworm + - feature: node + base-image: mcr.microsoft.com/devcontainers/base:bullseye + - feature: node + base-image: mcr.microsoft.com/devcontainers/base:noble + - feature: node + base-image: mcr.microsoft.com/devcontainers/base:jammy + - feature: node + base-image: mcr.microsoft.com/devcontainers/base:focal steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 @@ -87,7 +103,7 @@ jobs: strategy: fail-fast: false matrix: - feature: [claude, gitbutler, opencode] + feature: [claude, gitbutler, opencode, node] steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 diff --git a/README.md b/README.md index 31544fd..9c33538 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ A collection of [Dev Container Features](https://containers.dev/features) publis |----|------|-------------|-----------| | [`claude`](./src/claude) | Claude Code | Installs the Claude Code CLI (`@anthropic-ai/claude-code`). | `ghcr.io/synergy-shock/devcontainer-features/claude` | | [`gitbutler`](./src/gitbutler) | GitButler CLI | Installs the GitButler CLI (`but`). | `ghcr.io/synergy-shock/devcontainer-features/gitbutler` | +| [`node`](./src/node) | Node.js | Installs Node.js from the official nodejs.org prebuilt binaries, with optional `npm` and `pnpm` pinning. NVM-free. | `ghcr.io/synergy-shock/devcontainer-features/node` | | [`opencode`](./src/opencode) | OpenCode | Installs the OpenCode AI CLI. | `ghcr.io/synergy-shock/devcontainer-features/opencode` | | [`rtk`](./src/rtk) | rtk (Rust Token Killer) | Installs the `rtk` CLI proxy. Pairs with `claude` and/or `opencode`. | `ghcr.io/synergy-shock/devcontainer-features/rtk` | @@ -40,8 +41,8 @@ These features are intentionally narrow — one upstream CLI each. For everythin **Layer official features for shared tooling.** From [`ghcr.io/devcontainers/features`](https://github.com/devcontainers/features/tree/main/src): -- [`node:1`](https://github.com/devcontainers/features/tree/main/src/node) — install Node + npm, and pnpm via its `pnpmVersion` option (and yarn via `installYarnUsingApt`). Prefer this over a hand-rolled pnpm feature. -- [`common-utils:2`](https://github.com/devcontainers/features/tree/main/src/common-utils) — sudo, curl/wget, useful shells, the canonical non-root user setup. Add it when you start from a non-`devcontainers/base` image. +- [`node:1`](https://github.com/devcontainers/features/tree/main/src/node) — install Node + npm, and pnpm via its `pnpmVersion` option (and yarn via `installYarnUsingApt`). NVM-based. Use it if you want NVM's multi-version management; reach for this repo's [`node`](./src/node) feature instead if you want a single, NVM-free Node install (official `nodejs.org` prebuilt binaries straight into `/usr/local`). +- [`common-utils:2`](https://github.com/devcontainers/features/tree/main/src/common-utils) — sudo, curl/wget, useful shells, the canonical non-root user setup. Add it when you start from a non-`devcontainers/base` image. **Required before** this repo's `node` feature. - [`git:1`](https://github.com/devcontainers/features/tree/main/src/git) — newer git than what's in older distros. - [`git-lfs:1`](https://github.com/devcontainers/features/tree/main/src/git-lfs) — Git LFS, if your repo uses it. - [`github-cli:1`](https://github.com/devcontainers/features/tree/main/src/github-cli) — `gh` for PR/issue workflows and `gh auth` against the host. @@ -106,7 +107,9 @@ Then inside the container verify the binaries you touched: ```bash claude --version but --version +node --version opencode --version +pnpm --version rtk --version ``` diff --git a/src/claude/devcontainer-feature.json b/src/claude/devcontainer-feature.json index 9545076..24aa5ee 100644 --- a/src/claude/devcontainer-feature.json +++ b/src/claude/devcontainer-feature.json @@ -12,10 +12,5 @@ "proposals": ["latest"] } }, - "containerEnv": { - "NPM_CONFIG_PREFIX": "/usr/local/share/npm-global", - "PNPM_HOME": "/usr/local/share/pnpm", - "PNPM_STORE_DIR": "/usr/local/share/pnpm-store", - "PATH": "/usr/local/share/npm-global/bin:/usr/local/share/pnpm:/usr/local/bin:${PATH}" - } + "containerEnv": {} } diff --git a/src/gitbutler/install.sh b/src/gitbutler/install.sh index 1d481c3..ecec42e 100755 --- a/src/gitbutler/install.sh +++ b/src/gitbutler/install.sh @@ -53,16 +53,4 @@ curl -fsSL -o "${BUT_BIN}" "${BUT_URL}" chmod +x "${BUT_BIN}" "${BUT_BIN}" --version -# `but setup` writes to ~/.local/share/com.gitbutler.app at runtime. If another feature -# (or the base image) pre-created ~/.local/share as root via `mkdir -p`, that mkdir -# fails with EACCES. Pre-create the data dir as the remote user so setup never has to -# create a directory under a parent we may not own. -REMOTE_USER="${_REMOTE_USER:-root}" -REMOTE_HOME=$(getent passwd "${REMOTE_USER}" | cut -d: -f6) -if [ -n "${REMOTE_HOME}" ] && [ -d "${REMOTE_HOME}" ]; then - GB_DATA_DIR="${REMOTE_HOME}/.local/share/com.gitbutler.app" - mkdir -p "${GB_DATA_DIR}" - chown -R "${REMOTE_USER}:${REMOTE_USER}" "${GB_DATA_DIR}" -fi - echo "==> GitButler feature installation complete!" diff --git a/src/node/NOTES.md b/src/node/NOTES.md new file mode 100644 index 0000000..f410663 --- /dev/null +++ b/src/node/NOTES.md @@ -0,0 +1,30 @@ +## OS support + +Debian/Ubuntu-based images. The feature does **not** call `apt-get` itself — it expects `curl`, `jq`, and `tar` to already be installed. The expected provider is [`ghcr.io/devcontainers/features/common-utils:2`](https://github.com/devcontainers/features/tree/main/src/common-utils), which must be listed **before** the `node` feature in your `devcontainer.json`. If any required command is missing, the install script aborts with a clear error pointing at `common-utils:2`. + +## Implementation details + +- **Node.js** is the official Linux prebuilt tarball from `https://nodejs.org/dist/`, extracted into `/usr/local` with `tar --strip-components=1 --exclude=CHANGELOG.md --exclude=LICENSE --exclude=README.md` so that `node`, `npm`, and `npx` land directly under `/usr/local/bin` — already on every shell's `PATH`. No NVM, no NodeSource apt repo, no shell-rc edits. +- **Version resolution** is done against the official `https://nodejs.org/dist/index.json` index. Accepted forms for the `version` option: `lts`, `latest`/`current`, `lts/` (e.g. `lts/krypton`), `` (e.g. `24`), `.`, or an exact version with or without a leading `v`. +- **npm** is pinned with `npm install -g npm@`. The default `latest` always installs the newest published npm. +- **pnpm** is installed by downloading the official `pnpm/pnpm` GitHub-release binary directly to `/usr/local/bin/pnpm` (this is the standalone-binary path documented at ). No Corepack indirection, no shell-rc edits. + +## Feature ordering + +Add `common-utils` **before** `node`, since this feature checks its dependencies up front: + +```jsonc +{ + "image": "mcr.microsoft.com/devcontainers/base:trixie", + "features": { + "ghcr.io/devcontainers/features/common-utils:2": {}, + "ghcr.io/synergy-shock/devcontainer-features/node:0": { + "version": "lts", + "npmVersion": "latest", + "pnpmVersion": "latest" + } + } +} +``` + +If you already start from `mcr.microsoft.com/devcontainers/base:*`, `common-utils` essentials are baked in; the explicit feature line is still the safest way to guarantee `curl` / `jq` / `tar` regardless of the base image. diff --git a/src/node/devcontainer-feature.json b/src/node/devcontainer-feature.json new file mode 100644 index 0000000..c0c9935 --- /dev/null +++ b/src/node/devcontainer-feature.json @@ -0,0 +1,35 @@ +{ + "id": "node", + "version": "1.0.0", + "name": "Node.js", + "description": "Installs Node.js from the official nodejs.org prebuilt binaries, with optional npm and pnpm pinning. Assumes 'ghcr.io/devcontainers/features/common-utils:2' has run.", + "documentationURL": "https://github.com/Synergy-Shock/devcontainer-features/tree/main/src/node", + "options": { + "version": { + "type": "string", + "default": "lts", + "description": "Node.js version. 'lts' (default), 'latest'/'current', an lts codename ('lts/krypton'), a major ('24'), a major.minor ('24.16'), or an exact version ('24.16.0' / 'v24.16.0').", + "proposals": [ + "lts", + "latest", + "lts/krypton", + "lts/jod", + "lts/iron", + "26", + "24" + ] + }, + "npmVersion": { + "type": "string", + "default": "latest", + "description": "npm version (passed to 'npm install -g npm@'). Use 'latest' for the newest, or pin (e.g. '11.15.0').", + "proposals": ["latest", "11.15.0", "10.9.0"] + }, + "pnpmVersion": { + "type": "string", + "default": "latest", + "description": "pnpm version. Installed by downloading the official pnpm/pnpm GitHub-release binary directly to /usr/local/bin/pnpm. Use 'latest' for the newest, or pin (e.g. '11.2.2').", + "proposals": ["latest", "11.2.2", "10.0.0", "9.15.9"] + } + } +} diff --git a/src/node/install.sh b/src/node/install.sh new file mode 100755 index 0000000..9e3bf49 --- /dev/null +++ b/src/node/install.sh @@ -0,0 +1,161 @@ +#!/bin/bash +set -e + +#----------------------------------------------------------------------------------------------------- +# Node.js Dev Container Feature +# Installs the official Node.js prebuilt binary from https://nodejs.org/dist/, then pins npm +# (via `npm install -g`) and pnpm (via the official GitHub-release binary from pnpm/pnpm). +# +# Intended for use on Debian/Ubuntu-based images that already have +# `ghcr.io/devcontainers/features/common-utils:2` applied (for curl, jq, tar, ca-certificates). +#----------------------------------------------------------------------------------------------------- + +if [ "$(id -u)" -ne 0 ]; then + echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' + exit 1 +fi + +NODE_VERSION="${VERSION:-lts}" +NPM_VERSION_OPT="${NPMVERSION:-latest}" +PNPM_VERSION_OPT="${PNPMVERSION:-latest}" + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "(!) Required command '$1' is not installed." + echo "(!) This feature assumes 'ghcr.io/devcontainers/features/common-utils:2'" + echo " has been added to your devcontainer.json *before* the 'node' feature." + echo " See: https://github.com/devcontainers/features/tree/main/src/common-utils" + exit 1 + fi +} + +require_cmd curl +require_cmd jq +require_cmd tar + +ARCH=$(uname -m) +case "${ARCH}" in + x86_64) + NODE_ARCH="x64" + PNPM_ARCH="x64" + ;; + aarch64|arm64) + NODE_ARCH="arm64" + PNPM_ARCH="arm64" + ;; + *) + echo "(!) Architecture ${ARCH} is not supported by this feature." + exit 1 + ;; +esac + +# ------------------------------------------------------------------ +# Resolve Node version against the official nodejs.org index +# ------------------------------------------------------------------ +NODE_INDEX_URL="https://nodejs.org/dist/index.json" + +resolve_node_version() { + local req="$1" + local index="$2" + + case "${req}" in + lts) + echo "${index}" | jq -r '[.[] | select(.lts != false)][0].version' + ;; + latest|current) + echo "${index}" | jq -r '.[0].version' + ;; + lts/*) + local codename="${req#lts/}" + echo "${index}" | jq -r --arg c "${codename}" ' + [.[] | select((.lts | type) == "string" and (.lts | ascii_downcase) == ($c | ascii_downcase))][0].version' + ;; + v[0-9]*.[0-9]*.[0-9]*) + echo "${req}" + ;; + [0-9]*.[0-9]*.[0-9]*) + echo "v${req}" + ;; + v[0-9]*.[0-9]*) + echo "${index}" | jq -r --arg p "${req}." '[.[] | select(.version | startswith($p))][0].version' + ;; + [0-9]*.[0-9]*) + echo "${index}" | jq -r --arg p "v${req}." '[.[] | select(.version | startswith($p))][0].version' + ;; + v[0-9]*) + echo "${index}" | jq -r --arg p "${req}." '[.[] | select(.version | startswith($p))][0].version' + ;; + [0-9]*) + echo "${index}" | jq -r --arg p "v${req}." '[.[] | select(.version | startswith($p))][0].version' + ;; + *) + echo "" + ;; + esac +} + +echo "==> Resolving Node version '${NODE_VERSION}'..." +NODE_INDEX=$(curl -fsSL "${NODE_INDEX_URL}") +RESOLVED_VERSION=$(resolve_node_version "${NODE_VERSION}" "${NODE_INDEX}") + +if [ -z "${RESOLVED_VERSION}" ] || [ "${RESOLVED_VERSION}" = "null" ]; then + echo "(!) Could not resolve Node version '${NODE_VERSION}' from ${NODE_INDEX_URL}." + echo " Accepted forms: lts | latest | lts/ | | . | " + exit 1 +fi + +echo "==> Resolved Node ${NODE_VERSION} -> ${RESOLVED_VERSION}" + +# ------------------------------------------------------------------ +# Download & extract Node into /usr/local (FHS merge) +# ------------------------------------------------------------------ +NODE_TARBALL="node-${RESOLVED_VERSION}-linux-${NODE_ARCH}.tar.gz" +NODE_URL="https://nodejs.org/dist/${RESOLVED_VERSION}/${NODE_TARBALL}" + +echo "==> Downloading ${NODE_URL}" +curl -fsSL "${NODE_URL}" \ + | tar -xz -C /usr/local --strip-components=1 --no-same-owner \ + --exclude='CHANGELOG.md' \ + --exclude='LICENSE' \ + --exclude='README.md' + +node --version +npm --version + +# ------------------------------------------------------------------ +# Pin / update npm +# ------------------------------------------------------------------ +echo "==> Installing npm@${NPM_VERSION_OPT}..." +npm install -g --no-fund --no-audit "npm@${NPM_VERSION_OPT}" +npm --version + +# ------------------------------------------------------------------ +# Install pnpm directly from the official GitHub release binary +# (https://pnpm.io/installation – the standalone binary path) +# ------------------------------------------------------------------ +echo "==> Resolving pnpm version '${PNPM_VERSION_OPT}'..." +if [ "${PNPM_VERSION_OPT}" = "latest" ]; then + PNPM_TAG=$(curl -fsSL https://api.github.com/repos/pnpm/pnpm/releases/latest | jq -r '.tag_name') + if [ -z "${PNPM_TAG}" ] || [ "${PNPM_TAG}" = "null" ]; then + echo "(!) Failed to resolve latest pnpm release from api.github.com/repos/pnpm/pnpm/releases/latest" + exit 1 + fi +else + case "${PNPM_VERSION_OPT}" in + v*) PNPM_TAG="${PNPM_VERSION_OPT}" ;; + *) PNPM_TAG="v${PNPM_VERSION_OPT}" ;; + esac +fi + +PNPM_URL="https://github.com/pnpm/pnpm/releases/download/${PNPM_TAG}/pnpm-linux-${PNPM_ARCH}.tar.gz" +echo "==> Downloading ${PNPM_URL}" +# The tarball ships a self-contained `pnpm` ELF at its top level alongside a +# `dist/` directory of auxiliary node-script form; we only need the binary. +curl -fsSL "${PNPM_URL}" | tar -xz -C /usr/local/bin --no-same-owner pnpm +chmod +x /usr/local/bin/pnpm +pnpm --version + +echo "==> Node feature installation complete!" +echo " node: $(node --version)" +echo " npm: $(npm --version)" +echo " pnpm: $(pnpm --version)" diff --git a/test/_global/common_utils_node_claude.sh b/test/_global/common_utils_node_claude.sh new file mode 100755 index 0000000..b163e32 --- /dev/null +++ b/test/_global/common_utils_node_claude.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -e + +source dev-container-features-test-lib + +# Proves the `node` feature (plus prerequisite `common-utils:2`) plays nicely +# alongside `claude` on a vanilla Node-less base image, without falling back +# to the `typescript-node:*` base. + +check "node on PATH" bash -c "command -v node" +check "npm on PATH" bash -c "command -v npm" +check "pnpm on PATH" bash -c "command -v pnpm" +check "claude on PATH" bash -c "command -v claude" + +check "node --version" bash -c "node --version | grep -E '^v[0-9]+\\.[0-9]+\\.[0-9]+'" +check "npm --version" bash -c "npm --version | grep -E '^[0-9]+\\.[0-9]+\\.[0-9]+'" +check "pnpm --version" bash -c "pnpm --version | grep -E '^[0-9]+\\.[0-9]+\\.[0-9]+'" +check "claude --version" bash -c "claude --version | grep -E '[0-9]+\\.[0-9]+\\.[0-9]+'" + +reportResults diff --git a/test/_global/scenarios.json b/test/_global/scenarios.json index 4f87072..c9d78f7 100644 --- a/test/_global/scenarios.json +++ b/test/_global/scenarios.json @@ -25,5 +25,13 @@ "opencode": {}, "gitbutler": {} } + }, + "common_utils_node_claude": { + "image": "mcr.microsoft.com/devcontainers/base:trixie", + "features": { + "ghcr.io/devcontainers/features/common-utils:2": {}, + "node": {}, + "claude": {} + } } } diff --git a/test/node/default.sh b/test/node/default.sh new file mode 100755 index 0000000..af41a8b --- /dev/null +++ b/test/node/default.sh @@ -0,0 +1,7 @@ +#!/bin/bash +set -e +source dev-container-features-test-lib +check "node --version" bash -c "node --version | grep -E '^v[0-9]+\\.[0-9]+\\.[0-9]+'" +check "npm --version" bash -c "npm --version | grep -E '^[0-9]+\\.[0-9]+\\.[0-9]+'" +check "pnpm --version" bash -c "pnpm --version | grep -E '^[0-9]+\\.[0-9]+\\.[0-9]+'" +reportResults diff --git a/test/node/lts_codename.sh b/test/node/lts_codename.sh new file mode 100755 index 0000000..a38f6f0 --- /dev/null +++ b/test/node/lts_codename.sh @@ -0,0 +1,5 @@ +#!/bin/bash +set -e +source dev-container-features-test-lib +check "lts/krypton resolves to v24.x" bash -c "node --version | grep -E '^v24\\.[0-9]+\\.[0-9]+'" +reportResults diff --git a/test/node/non_root_user.sh b/test/node/non_root_user.sh new file mode 100755 index 0000000..a65ef59 --- /dev/null +++ b/test/node/non_root_user.sh @@ -0,0 +1,7 @@ +#!/bin/bash +set -e +source dev-container-features-test-lib +check "node --version as non-root" bash -c "node --version | grep -E '^v[0-9]+\\.[0-9]+\\.[0-9]+'" +check "pnpm --version as non-root" bash -c "pnpm --version | grep -E '^[0-9]+\\.[0-9]+\\.[0-9]+'" +check "running as non-root" bash -c "test \"$(id -un)\" = 'vscode'" +reportResults diff --git a/test/node/pinned_version.sh b/test/node/pinned_version.sh new file mode 100755 index 0000000..6b57396 --- /dev/null +++ b/test/node/pinned_version.sh @@ -0,0 +1,7 @@ +#!/bin/bash +set -e +source dev-container-features-test-lib +check "node v24.16.0" bash -c "node --version | grep -F 'v24.16.0'" +check "npm 11.15.0" bash -c "npm --version | grep -F '11.15.0'" +check "pnpm 11.2.2" bash -c "pnpm --version | grep -F '11.2.2'" +reportResults diff --git a/test/node/scenarios.json b/test/node/scenarios.json new file mode 100644 index 0000000..c55e8d8 --- /dev/null +++ b/test/node/scenarios.json @@ -0,0 +1,37 @@ +{ + "default": { + "image": "mcr.microsoft.com/devcontainers/base:trixie", + "features": { + "ghcr.io/devcontainers/features/common-utils:2": {}, + "node": {} + } + }, + "pinned_version": { + "image": "mcr.microsoft.com/devcontainers/base:trixie", + "features": { + "ghcr.io/devcontainers/features/common-utils:2": {}, + "node": { + "version": "24.16.0", + "npmVersion": "11.15.0", + "pnpmVersion": "11.2.2" + } + } + }, + "lts_codename": { + "image": "mcr.microsoft.com/devcontainers/base:trixie", + "features": { + "ghcr.io/devcontainers/features/common-utils:2": {}, + "node": { + "version": "lts/krypton" + } + } + }, + "non_root_user": { + "image": "mcr.microsoft.com/devcontainers/base:trixie", + "remoteUser": "vscode", + "features": { + "ghcr.io/devcontainers/features/common-utils:2": {}, + "node": {} + } + } +} diff --git a/test/node/test.sh b/test/node/test.sh new file mode 100755 index 0000000..0deac47 --- /dev/null +++ b/test/node/test.sh @@ -0,0 +1,16 @@ +#!/bin/bash +set -e + +source dev-container-features-test-lib + +check "node on PATH" bash -c "command -v node" +check "node --version" bash -c "node --version | grep -E '^v[0-9]+\\.[0-9]+\\.[0-9]+'" +check "npm on PATH" bash -c "command -v npm" +check "npm --version" bash -c "npm --version | grep -E '^[0-9]+\\.[0-9]+\\.[0-9]+'" +check "pnpm on PATH" bash -c "command -v pnpm" +check "pnpm --version" bash -c "pnpm --version | grep -E '^[0-9]+\\.[0-9]+\\.[0-9]+'" +check "node at /usr/local/bin/node" bash -c "test -x /usr/local/bin/node" +check "pnpm at /usr/local/bin/pnpm" bash -c "test -x /usr/local/bin/pnpm" +check "no /usr/local/CHANGELOG.md" bash -c "test ! -e /usr/local/CHANGELOG.md" + +reportResults From 0ea612ad8f18e8eba7b2c4533edc04d25265664c Mon Sep 17 00:00:00 2001 From: frami Date: Fri, 22 May 2026 21:25:30 -0300 Subject: [PATCH 12/18] docs: update --- src/node/README.md | 55 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 src/node/README.md diff --git a/src/node/README.md b/src/node/README.md new file mode 100644 index 0000000..24014a5 --- /dev/null +++ b/src/node/README.md @@ -0,0 +1,55 @@ + +# Node.js (node) + +Installs Node.js from the official nodejs.org prebuilt binaries, with optional npm and pnpm pinning. Assumes 'ghcr.io/devcontainers/features/common-utils:2' has run. + +## Example Usage + +```json +"features": { + "ghcr.io/synergy-shock/devcontainer-features/node:1": {} +} +``` + +## Options + +| Options Id | Description | Type | Default Value | +|-----|-----|-----|-----| +| version | Node.js version. 'lts' (default), 'latest'/'current', an lts codename ('lts/krypton'), a major ('24'), a major.minor ('24.16'), or an exact version ('24.16.0' / 'v24.16.0'). | string | lts | +| npmVersion | npm version (passed to 'npm install -g npm@'). Use 'latest' for the newest, or pin (e.g. '11.15.0'). | string | latest | +| pnpmVersion | pnpm version. Installed by downloading the official pnpm/pnpm GitHub-release binary directly to /usr/local/bin/pnpm. Use 'latest' for the newest, or pin (e.g. '11.2.2'). | string | latest | + +## OS support + +Debian/Ubuntu-based images. The feature does **not** call `apt-get` itself — it expects `curl`, `jq`, and `tar` to already be installed. The expected provider is [`ghcr.io/devcontainers/features/common-utils:2`](https://github.com/devcontainers/features/tree/main/src/common-utils), which must be listed **before** the `node` feature in your `devcontainer.json`. If any required command is missing, the install script aborts with a clear error pointing at `common-utils:2`. + +## Implementation details + +- **Node.js** is the official Linux prebuilt tarball from `https://nodejs.org/dist/`, extracted into `/usr/local` with `tar --strip-components=1 --exclude=CHANGELOG.md --exclude=LICENSE --exclude=README.md` so that `node`, `npm`, and `npx` land directly under `/usr/local/bin` — already on every shell's `PATH`. No NVM, no NodeSource apt repo, no shell-rc edits. +- **Version resolution** is done against the official `https://nodejs.org/dist/index.json` index. Accepted forms for the `version` option: `lts`, `latest`/`current`, `lts/` (e.g. `lts/krypton`), `` (e.g. `24`), `.`, or an exact version with or without a leading `v`. +- **npm** is pinned with `npm install -g npm@`. The default `latest` always installs the newest published npm. +- **pnpm** is installed by downloading the official `pnpm/pnpm` GitHub-release binary directly to `/usr/local/bin/pnpm` (this is the standalone-binary path documented at ). No Corepack indirection, no shell-rc edits. + +## Feature ordering + +Add `common-utils` **before** `node`, since this feature checks its dependencies up front: + +```jsonc +{ + "image": "mcr.microsoft.com/devcontainers/base:trixie", + "features": { + "ghcr.io/devcontainers/features/common-utils:2": {}, + "ghcr.io/synergy-shock/devcontainer-features/node:0": { + "version": "lts", + "npmVersion": "latest", + "pnpmVersion": "latest" + } + } +} +``` + +If you already start from `mcr.microsoft.com/devcontainers/base:*`, `common-utils` essentials are baked in; the explicit feature line is still the safest way to guarantee `curl` / `jq` / `tar` regardless of the base image. + +--- + +_Note: This file was auto-generated from the [devcontainer-feature.json](https://github.com/Synergy-Shock/devcontainer-features/blob/main/src/node/devcontainer-feature.json). Add additional notes to a `NOTES.md`._ From c88018dc631a12adba45f4c21a6620df3e51f84d Mon Sep 17 00:00:00 2001 From: frami Date: Fri, 22 May 2026 21:26:13 -0300 Subject: [PATCH 13/18] docs: update gitbutler version --- src/gitbutler/devcontainer-feature.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gitbutler/devcontainer-feature.json b/src/gitbutler/devcontainer-feature.json index 735b690..c5c2e66 100644 --- a/src/gitbutler/devcontainer-feature.json +++ b/src/gitbutler/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "gitbutler", - "version": "0.1.0", + "version": "0.1.1", "name": "GitButler CLI", "description": "Installs the GitButler CLI (but).", "documentationURL": "https://github.com/Synergy-Shock/devcontainer-features/tree/main/src/gitbutler", From bd99ded337d682097572f7c1ecc3d888b7889a31 Mon Sep 17 00:00:00 2001 From: frami Date: Fri, 22 May 2026 22:23:00 -0300 Subject: [PATCH 14/18] feat: update pnpm install --- src/node/NOTES.md | 2 +- src/node/README.md | 4 +-- src/node/devcontainer-feature.json | 2 +- src/node/install.sh | 43 +++++++++++++++--------------- 4 files changed, 26 insertions(+), 25 deletions(-) diff --git a/src/node/NOTES.md b/src/node/NOTES.md index f410663..b2985fc 100644 --- a/src/node/NOTES.md +++ b/src/node/NOTES.md @@ -7,7 +7,7 @@ Debian/Ubuntu-based images. The feature does **not** call `apt-get` itself — i - **Node.js** is the official Linux prebuilt tarball from `https://nodejs.org/dist/`, extracted into `/usr/local` with `tar --strip-components=1 --exclude=CHANGELOG.md --exclude=LICENSE --exclude=README.md` so that `node`, `npm`, and `npx` land directly under `/usr/local/bin` — already on every shell's `PATH`. No NVM, no NodeSource apt repo, no shell-rc edits. - **Version resolution** is done against the official `https://nodejs.org/dist/index.json` index. Accepted forms for the `version` option: `lts`, `latest`/`current`, `lts/` (e.g. `lts/krypton`), `` (e.g. `24`), `.`, or an exact version with or without a leading `v`. - **npm** is pinned with `npm install -g npm@`. The default `latest` always installs the newest published npm. -- **pnpm** is installed by downloading the official `pnpm/pnpm` GitHub-release binary directly to `/usr/local/bin/pnpm` (this is the standalone-binary path documented at ). No Corepack indirection, no shell-rc edits. +- **pnpm** is installed via the official `https://get.pnpm.io/install.sh` script (documented at ). It runs with `PNPM_HOME=/usr/local/share/pnpm` and the binary is symlinked to `/usr/local/bin/pnpm` so it lands on every shell's `PATH` without needing the rc-file shim. The `pnpmVersion` option is passed via the `PNPM_VERSION` env var (omitted when set to `latest`). No Corepack indirection. ## Feature ordering diff --git a/src/node/README.md b/src/node/README.md index 24014a5..dab33ae 100644 --- a/src/node/README.md +++ b/src/node/README.md @@ -17,7 +17,7 @@ Installs Node.js from the official nodejs.org prebuilt binaries, with optional n |-----|-----|-----|-----| | version | Node.js version. 'lts' (default), 'latest'/'current', an lts codename ('lts/krypton'), a major ('24'), a major.minor ('24.16'), or an exact version ('24.16.0' / 'v24.16.0'). | string | lts | | npmVersion | npm version (passed to 'npm install -g npm@'). Use 'latest' for the newest, or pin (e.g. '11.15.0'). | string | latest | -| pnpmVersion | pnpm version. Installed by downloading the official pnpm/pnpm GitHub-release binary directly to /usr/local/bin/pnpm. Use 'latest' for the newest, or pin (e.g. '11.2.2'). | string | latest | +| pnpmVersion | pnpm version. Installed via the official 'https://get.pnpm.io/install.sh' script (PNPM_HOME=/usr/local/share/pnpm, symlinked to /usr/local/bin/pnpm). Use 'latest' for the newest, or pin (e.g. '11.2.2'). | string | latest | ## OS support @@ -28,7 +28,7 @@ Debian/Ubuntu-based images. The feature does **not** call `apt-get` itself — i - **Node.js** is the official Linux prebuilt tarball from `https://nodejs.org/dist/`, extracted into `/usr/local` with `tar --strip-components=1 --exclude=CHANGELOG.md --exclude=LICENSE --exclude=README.md` so that `node`, `npm`, and `npx` land directly under `/usr/local/bin` — already on every shell's `PATH`. No NVM, no NodeSource apt repo, no shell-rc edits. - **Version resolution** is done against the official `https://nodejs.org/dist/index.json` index. Accepted forms for the `version` option: `lts`, `latest`/`current`, `lts/` (e.g. `lts/krypton`), `` (e.g. `24`), `.`, or an exact version with or without a leading `v`. - **npm** is pinned with `npm install -g npm@`. The default `latest` always installs the newest published npm. -- **pnpm** is installed by downloading the official `pnpm/pnpm` GitHub-release binary directly to `/usr/local/bin/pnpm` (this is the standalone-binary path documented at ). No Corepack indirection, no shell-rc edits. +- **pnpm** is installed via the official `https://get.pnpm.io/install.sh` script (documented at ). It runs with `PNPM_HOME=/usr/local/share/pnpm` and the binary is symlinked to `/usr/local/bin/pnpm` so it lands on every shell's `PATH` without needing the rc-file shim. The `pnpmVersion` option is passed via the `PNPM_VERSION` env var (omitted when set to `latest`). No Corepack indirection. ## Feature ordering diff --git a/src/node/devcontainer-feature.json b/src/node/devcontainer-feature.json index c0c9935..9f65cb8 100644 --- a/src/node/devcontainer-feature.json +++ b/src/node/devcontainer-feature.json @@ -28,7 +28,7 @@ "pnpmVersion": { "type": "string", "default": "latest", - "description": "pnpm version. Installed by downloading the official pnpm/pnpm GitHub-release binary directly to /usr/local/bin/pnpm. Use 'latest' for the newest, or pin (e.g. '11.2.2').", + "description": "pnpm version. Installed via the official 'https://get.pnpm.io/install.sh' script (PNPM_HOME=/usr/local/share/pnpm, symlinked to /usr/local/bin/pnpm). Use 'latest' for the newest, or pin (e.g. '11.2.2').", "proposals": ["latest", "11.2.2", "10.0.0", "9.15.9"] } } diff --git a/src/node/install.sh b/src/node/install.sh index 9e3bf49..df39141 100755 --- a/src/node/install.sh +++ b/src/node/install.sh @@ -4,7 +4,7 @@ set -e #----------------------------------------------------------------------------------------------------- # Node.js Dev Container Feature # Installs the official Node.js prebuilt binary from https://nodejs.org/dist/, then pins npm -# (via `npm install -g`) and pnpm (via the official GitHub-release binary from pnpm/pnpm). +# (via `npm install -g`) and pnpm (via the official install script at https://get.pnpm.io/install.sh). # # Intended for use on Debian/Ubuntu-based images that already have # `ghcr.io/devcontainers/features/common-utils:2` applied (for curl, jq, tar, ca-certificates). @@ -37,11 +37,9 @@ ARCH=$(uname -m) case "${ARCH}" in x86_64) NODE_ARCH="x64" - PNPM_ARCH="x64" ;; aarch64|arm64) NODE_ARCH="arm64" - PNPM_ARCH="arm64" ;; *) echo "(!) Architecture ${ARCH} is not supported by this feature." @@ -130,29 +128,32 @@ npm install -g --no-fund --no-audit "npm@${NPM_VERSION_OPT}" npm --version # ------------------------------------------------------------------ -# Install pnpm directly from the official GitHub release binary -# (https://pnpm.io/installation – the standalone binary path) +# Install pnpm via the official install script +# (https://pnpm.io/installation – the get.pnpm.io/install.sh path) # ------------------------------------------------------------------ -echo "==> Resolving pnpm version '${PNPM_VERSION_OPT}'..." +echo "==> Installing pnpm@${PNPM_VERSION_OPT} via get.pnpm.io/install.sh..." + +export PNPM_HOME="/usr/local/share/pnpm" +mkdir -p "${PNPM_HOME}" + +# The install script reads PNPM_VERSION (optional) and PNPM_HOME (install +# target), and consults $SHELL to decide which profile to touch. Set SHELL +# explicitly so it doesn't bail with "Could not infer shell" under the +# non-interactive feature build. if [ "${PNPM_VERSION_OPT}" = "latest" ]; then - PNPM_TAG=$(curl -fsSL https://api.github.com/repos/pnpm/pnpm/releases/latest | jq -r '.tag_name') - if [ -z "${PNPM_TAG}" ] || [ "${PNPM_TAG}" = "null" ]; then - echo "(!) Failed to resolve latest pnpm release from api.github.com/repos/pnpm/pnpm/releases/latest" - exit 1 - fi + curl -fsSL https://get.pnpm.io/install.sh \ + | env PNPM_HOME="${PNPM_HOME}" SHELL="/bin/bash" sh - else - case "${PNPM_VERSION_OPT}" in - v*) PNPM_TAG="${PNPM_VERSION_OPT}" ;; - *) PNPM_TAG="v${PNPM_VERSION_OPT}" ;; - esac + # Accept both '10.0.0' and 'v10.0.0' — the install script wants no leading 'v'. + PNPM_VER="${PNPM_VERSION_OPT#v}" + curl -fsSL https://get.pnpm.io/install.sh \ + | env PNPM_VERSION="${PNPM_VER}" PNPM_HOME="${PNPM_HOME}" SHELL="/bin/bash" sh - fi -PNPM_URL="https://github.com/pnpm/pnpm/releases/download/${PNPM_TAG}/pnpm-linux-${PNPM_ARCH}.tar.gz" -echo "==> Downloading ${PNPM_URL}" -# The tarball ships a self-contained `pnpm` ELF at its top level alongside a -# `dist/` directory of auxiliary node-script form; we only need the binary. -curl -fsSL "${PNPM_URL}" | tar -xz -C /usr/local/bin --no-same-owner pnpm -chmod +x /usr/local/bin/pnpm +# Symlink into a directory already on every shell's PATH so the binary is +# reachable without sourcing the rc-file shim the install script writes. +ln -sf "${PNPM_HOME}/pnpm" /usr/local/bin/pnpm + pnpm --version echo "==> Node feature installation complete!" From 75c14f4e25a4e60c38a3471351e57a4e12572afd Mon Sep 17 00:00:00 2001 From: frami Date: Fri, 22 May 2026 22:30:36 -0300 Subject: [PATCH 15/18] fix: pnpm install --- src/node/install.sh | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/node/install.sh b/src/node/install.sh index df39141..c5d04c5 100755 --- a/src/node/install.sh +++ b/src/node/install.sh @@ -150,9 +150,21 @@ else | env PNPM_VERSION="${PNPM_VER}" PNPM_HOME="${PNPM_HOME}" SHELL="/bin/bash" sh - fi +# Locate the pnpm binary the install script wrote. pnpm v11+ uses +# $PNPM_HOME/bin/pnpm; older versions wrote it directly to $PNPM_HOME/pnpm. +if [ -x "${PNPM_HOME}/bin/pnpm" ]; then + PNPM_BIN="${PNPM_HOME}/bin/pnpm" +elif [ -x "${PNPM_HOME}/pnpm" ]; then + PNPM_BIN="${PNPM_HOME}/pnpm" +else + echo "(!) pnpm install did not produce a binary under ${PNPM_HOME}." + ls -la "${PNPM_HOME}" || true + exit 1 +fi + # Symlink into a directory already on every shell's PATH so the binary is # reachable without sourcing the rc-file shim the install script writes. -ln -sf "${PNPM_HOME}/pnpm" /usr/local/bin/pnpm +ln -sf "${PNPM_BIN}" /usr/local/bin/pnpm pnpm --version From 4d99db6197011ba68649f3f96e56a694cac1a3ef Mon Sep 17 00:00:00 2001 From: frami Date: Fri, 22 May 2026 22:58:02 -0300 Subject: [PATCH 16/18] fix: pnpm install --- src/node/NOTES.md | 4 +- src/node/README.md | 6 +-- src/node/devcontainer-feature.json | 2 +- src/node/install.sh | 63 ++++++++++++++---------------- 4 files changed, 35 insertions(+), 40 deletions(-) diff --git a/src/node/NOTES.md b/src/node/NOTES.md index b2985fc..48ec54a 100644 --- a/src/node/NOTES.md +++ b/src/node/NOTES.md @@ -6,8 +6,8 @@ Debian/Ubuntu-based images. The feature does **not** call `apt-get` itself — i - **Node.js** is the official Linux prebuilt tarball from `https://nodejs.org/dist/`, extracted into `/usr/local` with `tar --strip-components=1 --exclude=CHANGELOG.md --exclude=LICENSE --exclude=README.md` so that `node`, `npm`, and `npx` land directly under `/usr/local/bin` — already on every shell's `PATH`. No NVM, no NodeSource apt repo, no shell-rc edits. - **Version resolution** is done against the official `https://nodejs.org/dist/index.json` index. Accepted forms for the `version` option: `lts`, `latest`/`current`, `lts/` (e.g. `lts/krypton`), `` (e.g. `24`), `.`, or an exact version with or without a leading `v`. -- **npm** is pinned with `npm install -g npm@`. The default `latest` always installs the newest published npm. -- **pnpm** is installed via the official `https://get.pnpm.io/install.sh` script (documented at ). It runs with `PNPM_HOME=/usr/local/share/pnpm` and the binary is symlinked to `/usr/local/bin/pnpm` so it lands on every shell's `PATH` without needing the rc-file shim. The `pnpmVersion` option is passed via the `PNPM_VERSION` env var (omitted when set to `latest`). No Corepack indirection. +- **npm** is pinned with `npm install -g npm@` into the Node tarball's `/usr/local` prefix, overwriting `/usr/local/bin/npm`. The default `latest` always installs the newest published npm. +- **pnpm** is installed with `npm install -g pnpm@` (no `get.pnpm.io` script, no Corepack indirection). It lands in `NPM_CONFIG_PREFIX=/usr/local/share/npm-global` — a dedicated dir for npm globals so user-installed packages don't mix with the Node tarball's `/usr/local/bin`. The install script writes `/etc/profile.d/node.sh` exporting `NPM_CONFIG_PREFIX`, `PNPM_HOME=/usr/local/share/pnpm` (where pnpm itself stores globally-installed packages on later `pnpm add -g …`), and prepending both `${NPM_CONFIG_PREFIX}/bin` and `${PNPM_HOME}` to `PATH`. `/etc/profile` is sourced by login shells, and `common-utils:2` (a prerequisite) also wires `/etc/bash.bashrc` to source `/etc/profile.d/*.sh`, so the binaries are reachable from both login and interactive non-login shells. ## Feature ordering diff --git a/src/node/README.md b/src/node/README.md index dab33ae..5564175 100644 --- a/src/node/README.md +++ b/src/node/README.md @@ -17,7 +17,7 @@ Installs Node.js from the official nodejs.org prebuilt binaries, with optional n |-----|-----|-----|-----| | version | Node.js version. 'lts' (default), 'latest'/'current', an lts codename ('lts/krypton'), a major ('24'), a major.minor ('24.16'), or an exact version ('24.16.0' / 'v24.16.0'). | string | lts | | npmVersion | npm version (passed to 'npm install -g npm@'). Use 'latest' for the newest, or pin (e.g. '11.15.0'). | string | latest | -| pnpmVersion | pnpm version. Installed via the official 'https://get.pnpm.io/install.sh' script (PNPM_HOME=/usr/local/share/pnpm, symlinked to /usr/local/bin/pnpm). Use 'latest' for the newest, or pin (e.g. '11.2.2'). | string | latest | +| pnpmVersion | pnpm version (passed to 'npm install -g pnpm@'). Installed into NPM_CONFIG_PREFIX=/usr/local/share/npm-global, which is added to PATH via /etc/profile.d/node.sh. Use 'latest' for the newest, or pin (e.g. '11.2.2'). | string | latest | ## OS support @@ -27,8 +27,8 @@ Debian/Ubuntu-based images. The feature does **not** call `apt-get` itself — i - **Node.js** is the official Linux prebuilt tarball from `https://nodejs.org/dist/`, extracted into `/usr/local` with `tar --strip-components=1 --exclude=CHANGELOG.md --exclude=LICENSE --exclude=README.md` so that `node`, `npm`, and `npx` land directly under `/usr/local/bin` — already on every shell's `PATH`. No NVM, no NodeSource apt repo, no shell-rc edits. - **Version resolution** is done against the official `https://nodejs.org/dist/index.json` index. Accepted forms for the `version` option: `lts`, `latest`/`current`, `lts/` (e.g. `lts/krypton`), `` (e.g. `24`), `.`, or an exact version with or without a leading `v`. -- **npm** is pinned with `npm install -g npm@`. The default `latest` always installs the newest published npm. -- **pnpm** is installed via the official `https://get.pnpm.io/install.sh` script (documented at ). It runs with `PNPM_HOME=/usr/local/share/pnpm` and the binary is symlinked to `/usr/local/bin/pnpm` so it lands on every shell's `PATH` without needing the rc-file shim. The `pnpmVersion` option is passed via the `PNPM_VERSION` env var (omitted when set to `latest`). No Corepack indirection. +- **npm** is pinned with `npm install -g npm@` into the Node tarball's `/usr/local` prefix, overwriting `/usr/local/bin/npm`. The default `latest` always installs the newest published npm. +- **pnpm** is installed with `npm install -g pnpm@` (no `get.pnpm.io` script, no Corepack indirection). It lands in `NPM_CONFIG_PREFIX=/usr/local/share/npm-global` — a dedicated dir for npm globals so user-installed packages don't mix with the Node tarball's `/usr/local/bin`. The install script writes `/etc/profile.d/node.sh` exporting `NPM_CONFIG_PREFIX`, `PNPM_HOME=/usr/local/share/pnpm` (where pnpm itself stores globally-installed packages on later `pnpm add -g …`), and prepending both `${NPM_CONFIG_PREFIX}/bin` and `${PNPM_HOME}` to `PATH`. `/etc/profile` is sourced by login shells, and `common-utils:2` (a prerequisite) also wires `/etc/bash.bashrc` to source `/etc/profile.d/*.sh`, so the binaries are reachable from both login and interactive non-login shells. ## Feature ordering diff --git a/src/node/devcontainer-feature.json b/src/node/devcontainer-feature.json index 9f65cb8..f9cdaa7 100644 --- a/src/node/devcontainer-feature.json +++ b/src/node/devcontainer-feature.json @@ -28,7 +28,7 @@ "pnpmVersion": { "type": "string", "default": "latest", - "description": "pnpm version. Installed via the official 'https://get.pnpm.io/install.sh' script (PNPM_HOME=/usr/local/share/pnpm, symlinked to /usr/local/bin/pnpm). Use 'latest' for the newest, or pin (e.g. '11.2.2').", + "description": "pnpm version (passed to 'npm install -g pnpm@'). Installed into NPM_CONFIG_PREFIX=/usr/local/share/npm-global, which is added to PATH via /etc/profile.d/node.sh. Use 'latest' for the newest, or pin (e.g. '11.2.2').", "proposals": ["latest", "11.2.2", "10.0.0", "9.15.9"] } } diff --git a/src/node/install.sh b/src/node/install.sh index c5d04c5..e414067 100755 --- a/src/node/install.sh +++ b/src/node/install.sh @@ -121,53 +121,48 @@ node --version npm --version # ------------------------------------------------------------------ -# Pin / update npm +# Pin / update npm — installs into the Node tarball's prefix (/usr/local), +# overwriting /usr/local/bin/npm. # ------------------------------------------------------------------ echo "==> Installing npm@${NPM_VERSION_OPT}..." npm install -g --no-fund --no-audit "npm@${NPM_VERSION_OPT}" npm --version # ------------------------------------------------------------------ -# Install pnpm via the official install script -# (https://pnpm.io/installation – the get.pnpm.io/install.sh path) +# Install pnpm via npm. From here on, npm globals live in a dedicated +# directory exposed on PATH via containerEnv, keeping user-installed +# globals separate from the Node tarball's /usr/local/bin. # ------------------------------------------------------------------ -echo "==> Installing pnpm@${PNPM_VERSION_OPT} via get.pnpm.io/install.sh..." +export NPM_CONFIG_PREFIX="/usr/local/share/npm-global" +mkdir -p "${NPM_CONFIG_PREFIX}/bin" +echo "==> Installing pnpm@${PNPM_VERSION_OPT} via npm..." +# Accept both '10.0.0' and 'v10.0.0' — npm semver wants no leading 'v'. +PNPM_SPEC="${PNPM_VERSION_OPT#v}" +npm install -g --no-fund --no-audit "pnpm@${PNPM_SPEC}" + +# PNPM_HOME is where pnpm stores globally-installed packages (populated +# when users later run `pnpm add -g …`). containerEnv puts it on PATH. export PNPM_HOME="/usr/local/share/pnpm" mkdir -p "${PNPM_HOME}" -# The install script reads PNPM_VERSION (optional) and PNPM_HOME (install -# target), and consults $SHELL to decide which profile to touch. Set SHELL -# explicitly so it doesn't bail with "Could not infer shell" under the -# non-interactive feature build. -if [ "${PNPM_VERSION_OPT}" = "latest" ]; then - curl -fsSL https://get.pnpm.io/install.sh \ - | env PNPM_HOME="${PNPM_HOME}" SHELL="/bin/bash" sh - -else - # Accept both '10.0.0' and 'v10.0.0' — the install script wants no leading 'v'. - PNPM_VER="${PNPM_VERSION_OPT#v}" - curl -fsSL https://get.pnpm.io/install.sh \ - | env PNPM_VERSION="${PNPM_VER}" PNPM_HOME="${PNPM_HOME}" SHELL="/bin/bash" sh - -fi - -# Locate the pnpm binary the install script wrote. pnpm v11+ uses -# $PNPM_HOME/bin/pnpm; older versions wrote it directly to $PNPM_HOME/pnpm. -if [ -x "${PNPM_HOME}/bin/pnpm" ]; then - PNPM_BIN="${PNPM_HOME}/bin/pnpm" -elif [ -x "${PNPM_HOME}/pnpm" ]; then - PNPM_BIN="${PNPM_HOME}/pnpm" -else - echo "(!) pnpm install did not produce a binary under ${PNPM_HOME}." - ls -la "${PNPM_HOME}" || true - exit 1 -fi - -# Symlink into a directory already on every shell's PATH so the binary is -# reachable without sourcing the rc-file shim the install script writes. -ln -sf "${PNPM_BIN}" /usr/local/bin/pnpm - +# Make pnpm reachable for the verification call below; persisted to future +# shells via /etc/profile.d/node.sh, written next. +export PATH="${NPM_CONFIG_PREFIX}/bin:${PNPM_HOME}:${PATH}" pnpm --version +# ------------------------------------------------------------------ +# Persist env vars and PATH additions for future shells. +# /etc/profile is sourced by login shells; common-utils:2 (a prerequisite) +# also wires /etc/bash.bashrc to source /etc/profile.d/*.sh. +# ------------------------------------------------------------------ +cat > /etc/profile.d/node.sh <<'EOF' +export NPM_CONFIG_PREFIX="/usr/local/share/npm-global" +export PNPM_HOME="/usr/local/share/pnpm" +export PATH="${NPM_CONFIG_PREFIX}/bin:${PNPM_HOME}:${PATH}" +EOF +chmod 0644 /etc/profile.d/node.sh + echo "==> Node feature installation complete!" echo " node: $(node --version)" echo " npm: $(npm --version)" From bc585191c00fbc2c44903eb371a993adffd68685 Mon Sep 17 00:00:00 2001 From: frami Date: Fri, 22 May 2026 23:12:14 -0300 Subject: [PATCH 17/18] fix: pnpm install --- src/node/NOTES.md | 2 +- src/node/README.md | 2 +- src/node/install.sh | 5 +++++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/node/NOTES.md b/src/node/NOTES.md index 48ec54a..e43f79c 100644 --- a/src/node/NOTES.md +++ b/src/node/NOTES.md @@ -7,7 +7,7 @@ Debian/Ubuntu-based images. The feature does **not** call `apt-get` itself — i - **Node.js** is the official Linux prebuilt tarball from `https://nodejs.org/dist/`, extracted into `/usr/local` with `tar --strip-components=1 --exclude=CHANGELOG.md --exclude=LICENSE --exclude=README.md` so that `node`, `npm`, and `npx` land directly under `/usr/local/bin` — already on every shell's `PATH`. No NVM, no NodeSource apt repo, no shell-rc edits. - **Version resolution** is done against the official `https://nodejs.org/dist/index.json` index. Accepted forms for the `version` option: `lts`, `latest`/`current`, `lts/` (e.g. `lts/krypton`), `` (e.g. `24`), `.`, or an exact version with or without a leading `v`. - **npm** is pinned with `npm install -g npm@` into the Node tarball's `/usr/local` prefix, overwriting `/usr/local/bin/npm`. The default `latest` always installs the newest published npm. -- **pnpm** is installed with `npm install -g pnpm@` (no `get.pnpm.io` script, no Corepack indirection). It lands in `NPM_CONFIG_PREFIX=/usr/local/share/npm-global` — a dedicated dir for npm globals so user-installed packages don't mix with the Node tarball's `/usr/local/bin`. The install script writes `/etc/profile.d/node.sh` exporting `NPM_CONFIG_PREFIX`, `PNPM_HOME=/usr/local/share/pnpm` (where pnpm itself stores globally-installed packages on later `pnpm add -g …`), and prepending both `${NPM_CONFIG_PREFIX}/bin` and `${PNPM_HOME}` to `PATH`. `/etc/profile` is sourced by login shells, and `common-utils:2` (a prerequisite) also wires `/etc/bash.bashrc` to source `/etc/profile.d/*.sh`, so the binaries are reachable from both login and interactive non-login shells. +- **pnpm** is installed with `npm install -g pnpm@` (no `get.pnpm.io` script, no Corepack indirection). It lands in `NPM_CONFIG_PREFIX=/usr/local/share/npm-global` — a dedicated dir for npm globals so user-installed packages don't mix with the Node tarball's `/usr/local/bin` — and is symlinked at `/usr/local/bin/pnpm` so it's reachable even before `/etc/profile.d/node.sh` is sourced. The install script writes `/etc/profile.d/node.sh` exporting `NPM_CONFIG_PREFIX`, `PNPM_HOME=/usr/local/share/pnpm` (where pnpm itself stores globally-installed packages on later `pnpm add -g …`), and prepending both `${NPM_CONFIG_PREFIX}/bin` and `${PNPM_HOME}` to `PATH`. `/etc/profile` is sourced by login shells, and `common-utils:2` (a prerequisite) also wires `/etc/bash.bashrc` to source `/etc/profile.d/*.sh`, so the binaries are reachable from both login and interactive non-login shells. ## Feature ordering diff --git a/src/node/README.md b/src/node/README.md index 5564175..5aebcf5 100644 --- a/src/node/README.md +++ b/src/node/README.md @@ -28,7 +28,7 @@ Debian/Ubuntu-based images. The feature does **not** call `apt-get` itself — i - **Node.js** is the official Linux prebuilt tarball from `https://nodejs.org/dist/`, extracted into `/usr/local` with `tar --strip-components=1 --exclude=CHANGELOG.md --exclude=LICENSE --exclude=README.md` so that `node`, `npm`, and `npx` land directly under `/usr/local/bin` — already on every shell's `PATH`. No NVM, no NodeSource apt repo, no shell-rc edits. - **Version resolution** is done against the official `https://nodejs.org/dist/index.json` index. Accepted forms for the `version` option: `lts`, `latest`/`current`, `lts/` (e.g. `lts/krypton`), `` (e.g. `24`), `.`, or an exact version with or without a leading `v`. - **npm** is pinned with `npm install -g npm@` into the Node tarball's `/usr/local` prefix, overwriting `/usr/local/bin/npm`. The default `latest` always installs the newest published npm. -- **pnpm** is installed with `npm install -g pnpm@` (no `get.pnpm.io` script, no Corepack indirection). It lands in `NPM_CONFIG_PREFIX=/usr/local/share/npm-global` — a dedicated dir for npm globals so user-installed packages don't mix with the Node tarball's `/usr/local/bin`. The install script writes `/etc/profile.d/node.sh` exporting `NPM_CONFIG_PREFIX`, `PNPM_HOME=/usr/local/share/pnpm` (where pnpm itself stores globally-installed packages on later `pnpm add -g …`), and prepending both `${NPM_CONFIG_PREFIX}/bin` and `${PNPM_HOME}` to `PATH`. `/etc/profile` is sourced by login shells, and `common-utils:2` (a prerequisite) also wires `/etc/bash.bashrc` to source `/etc/profile.d/*.sh`, so the binaries are reachable from both login and interactive non-login shells. +- **pnpm** is installed with `npm install -g pnpm@` (no `get.pnpm.io` script, no Corepack indirection). It lands in `NPM_CONFIG_PREFIX=/usr/local/share/npm-global` — a dedicated dir for npm globals so user-installed packages don't mix with the Node tarball's `/usr/local/bin` — and is symlinked at `/usr/local/bin/pnpm` so it's reachable even before `/etc/profile.d/node.sh` is sourced. The install script writes `/etc/profile.d/node.sh` exporting `NPM_CONFIG_PREFIX`, `PNPM_HOME=/usr/local/share/pnpm` (where pnpm itself stores globally-installed packages on later `pnpm add -g …`), and prepending both `${NPM_CONFIG_PREFIX}/bin` and `${PNPM_HOME}` to `PATH`. `/etc/profile` is sourced by login shells, and `common-utils:2` (a prerequisite) also wires `/etc/bash.bashrc` to source `/etc/profile.d/*.sh`, so the binaries are reachable from both login and interactive non-login shells. ## Feature ordering diff --git a/src/node/install.sh b/src/node/install.sh index e414067..50c820d 100755 --- a/src/node/install.sh +++ b/src/node/install.sh @@ -141,6 +141,11 @@ echo "==> Installing pnpm@${PNPM_VERSION_OPT} via npm..." PNPM_SPEC="${PNPM_VERSION_OPT#v}" npm install -g --no-fund --no-audit "pnpm@${PNPM_SPEC}" +# Symlink pnpm into /usr/local/bin so it is reachable from any shell even +# before /etc/profile.d/node.sh is sourced (matches npm's own /usr/local/bin/npm +# symlink that the Node tarball ships). +ln -sf "${NPM_CONFIG_PREFIX}/bin/pnpm" /usr/local/bin/pnpm + # PNPM_HOME is where pnpm stores globally-installed packages (populated # when users later run `pnpm add -g …`). containerEnv puts it on PATH. export PNPM_HOME="/usr/local/share/pnpm" From cb2e05d6d171d1435023c29a98e5fb9a899e8744 Mon Sep 17 00:00:00 2001 From: frami Date: Fri, 22 May 2026 23:15:06 -0300 Subject: [PATCH 18/18] chore: bump versions --- .devcontainer/devcontainer.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 6c603b4..62314b1 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,18 +1,20 @@ { "name": "Devcontainer Features", - "image": "mcr.microsoft.com/devcontainers/typescript-node:24-trixie", + "image": "mcr.microsoft.com/devcontainers/base:trixie", "features": { - "ghcr.io/synergy-shock/devcontainer-features/pnpm:11": {}, + "ghcr.io/devcontainers/features/common-utils:2": {}, + "ghcr.io/devcontainers/features/git:1": {}, + "ghcr.io/devcontainers/features/git-lfs:1": {}, + "ghcr.io/devcontainers/features/node:2": { + "version": "24", + "npmVersion": "latest", + "pnpmVersion": "latest", + }, "ghcr.io/synergy-shock/devcontainer-features/claude:2": {}, - // "ghcr.io/synergy-shock/devcontainer-features/opencode:1": {}, "ghcr.io/synergy-shock/devcontainer-features/rtk:0": {}, "ghcr.io/synergy-shock/devcontainer-features/gitbutler:0": {}, }, "mounts": [ - "source=pnpm-store,target=/home/node/.pnpm-store,type=volume", - "source=${localEnv:HOME}/.npmrc,target=/home/node/.npmrc,type=bind,readonly", - // "source=${localEnv:HOME}/.config/opencode,target=/home/node/.config/opencode,type=bind", - // "source=${localEnv:HOME}/.local/share/opencode,target=/home/node/.local/share/opencode,type=bind", "source=${localEnv:HOME}/.claude,target=/home/node/.claude,type=bind", "source=${localEnv:HOME}/.claude.json,target=/home/node/.claude.json,type=bind", "source=/Applications/1Password.app/Contents/MacOS/op-ssh-sign,target=/op-ssh-sign,type=bind,readonly", @@ -22,8 +24,6 @@ "runArgs": [], "containerEnv": { "SSH_AUTH_SOCK": "/agent.sock", - "PNPM_HOME": "/home/node/.pnpm-store", - "PNPM_STORE_DIR": "/home/node/.pnpm-store", }, "forwardPorts": [], "remoteUser": "node",