From 4e01d473aa955e0abe8b79c069edcfe984dd9157 Mon Sep 17 00:00:00 2001 From: Stuart Bell <141676627+stu-bell@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:28:53 +0000 Subject: [PATCH 1/6] add claude code --- .devcontainer/devcontainer.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 5f2d048..b202ee6 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,8 +1,9 @@ { - "image": "mcr.microsoft.com/devcontainers/javascript-node:1-20-bookworm", + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", "features": { "ghcr.io/devcontainers/features/docker-in-docker:2": {}, - "ghcr.io/stu-bell/devcontainer-features/geminicli": {}, + "ghcr.io/stu-bell/devcontainer-features/claude-code": {}, + // "ghcr.io/stu-bell/devcontainer-features/geminicli": {}, "ghcr.io/stu-bell/devcontainer-features/neovim": { "CONFIG_GIT_URL": "https://github.com/stu-bell/nvim.git" } From 3a00496cc3edfbd35b06a357f70ffc5417d2d985 Mon Sep 17 00:00:00 2001 From: Stuart Bell <141676627+stu-bell@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:29:07 +0000 Subject: [PATCH 2/6] define homebrew feature --- .beans/df-g2t5--new-feature-brew.md | 42 ++++++++++++++++++++---- src/homebrew/devcontainer-feature.json | 19 +++++++++++ src/homebrew/install.sh | 44 ++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 7 deletions(-) create mode 100644 src/homebrew/devcontainer-feature.json create mode 100644 src/homebrew/install.sh diff --git a/.beans/df-g2t5--new-feature-brew.md b/.beans/df-g2t5--new-feature-brew.md index f428531..3dd2bda 100644 --- a/.beans/df-g2t5--new-feature-brew.md +++ b/.beans/df-g2t5--new-feature-brew.md @@ -1,21 +1,49 @@ --- # df-g2t5 -title: 'new feature: Brew' +title: 'new feature: Homebrew' status: draft type: task created_at: 2026-03-12T15:40:26Z updated_at: 2026-03-12T15:40:26Z --- +Read these instructions carefully. If further clarification is required, append questions to the body of this bean and set the status to draft (so the user can review and update). When the user provides the information to continue, they will reset the bean status to todo. -Install script from homebrew: https://brew.sh/ -NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" +For a recap of how to use beans, read the result of `beans prime` + +Implement a new devcontainer feature in src/homebrew that should install homebrew (https://brew.sh/), and optionally, a brew package + +This feature has already been started in src/homebrew, but it may need reimplementing + +feature options: +- brewPackage: Name of Brew package to install +- brewPackageVersion: Minimum required version of brew package to install +- brewArgs: argument string to pass to brew install command -Note it provides instructions at the end on how to add brew to the PATH. We should provide an option in devcontainer.json to do this automatically (so that brew power users can override it manually). +containerEnv: +- "PATH": "/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:${PATH}" -Should first check if the required version of brew is already installed. only install brew if a sufficient version of brew cannot be found. +install.sh: +1. check if brew is installed. if not, install with brew.sh script below +1. verify brew is installed correctly +1. if a brewPackage is not specified, exit here +1. if a brewpackage is specified, ensure we can use brew install, add to path if necessary +1. check if that package is installed, and check that the required brewPackageVersion is installed, if a sufficient version of the required package is not found, install with brew install +1. verify that the brew package is installed correctly + +non-functional requirements: +1. Read other features in the src folder and follow similar implementation patterns where relevant +1. should work on alpine, ubuntu and debian images (may have to be a posix compliant script). bash may need to be installed on alpine +1. Use util functions where appropriate (see ./test/util-test/sample-feature/util.sh) +1. write test scenarios for the feature - run `test/test-builds.sh --help` for scenario file structure +1. When running tests, use test/heartbeat.sh to wrap the test command, to avoid polluting context with test logs. Use grep or tail to read the test logs. + +Install script from homebrew: https://brew.sh/ +NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" -Add an option to install a brew package immediately after brew installs. Must be able to pass brew args. Note we can only install one package this way. can we find a workaround for this? +Features considered for implementation but rejected (DO NOT IMPLEMENT THESE): +- Add an option to remove brew after package install to keep the image small? This is just for the purpose of installing a package with brew -Add an option to remove brew after package install to keep the image small? This is just for the purpose of installing a package with brew +# Implementation comments/notes from agent: +agent to update this section... diff --git a/src/homebrew/devcontainer-feature.json b/src/homebrew/devcontainer-feature.json new file mode 100644 index 0000000..90a3ab8 --- /dev/null +++ b/src/homebrew/devcontainer-feature.json @@ -0,0 +1,19 @@ +{ + "id": "homebrew", + "version": "1.0.0", + "name": "Homebrew", + "description": "Installs the Homebrew package manager and optionally a brew package", + "options": { + "package": { + "type": "string", + "default": "", + "description": "Optional brew package to install (e.g. 'hmans/beans/beans')" + } + }, + "containerEnv": { + "PATH": "/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:${PATH}" + }, + "installsAfter": [ + "ghcr.io/devcontainers/features/common-utils" + ] +} diff --git a/src/homebrew/install.sh b/src/homebrew/install.sh new file mode 100644 index 0000000..099e4cd --- /dev/null +++ b/src/homebrew/install.sh @@ -0,0 +1,44 @@ +#!/bin/bash +set -e + +# Run a command as the remote user for the devcontainer. +remote_user_run() { + command_to_run="$1" + USER_OPTION="${REMOTE_USER_NAME:-automatic}" + _REMOTE_USER="${_REMOTE_USER:-${USER_OPTION}}" + if [ "${_REMOTE_USER}" = "auto" ] || [ "${_REMOTE_USER}" = "automatic" ]; then + _REMOTE_USER="$(id -un 1000 2>/dev/null || echo "vscode")" # vscode fallback + fi + echo "Running as: $_REMOTE_USER, command: $command_to_run" >&2 + # Escape single quotes in command_to_run for the inner sh -lc call + escaped_command_to_run=$(echo "$command_to_run" | sed "s/'/'\\''/g") + + su - "${_REMOTE_USER}" -c "sh -lc '$escaped_command_to_run'" +} + +PACKAGE="${PACKAGE:-}" + +if [ -x "/home/linuxbrew/.linuxbrew/bin/brew" ]; then + echo "Homebrew already installed, skipping." +else + echo "Installing Homebrew..." + remote_user_run 'NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"' + echo "Homebrew installed successfully." + + echo "Verifying installation paths..." + for path in /home/linuxbrew/.linuxbrew/bin /home/linuxbrew/.linuxbrew/sbin; do + if [ ! -d "$path" ]; then + echo "WARNING: expected Homebrew path not found: $path" + echo "You may need to add Homebrew to your PATH manually. Run:" + echo ' eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"' + echo "Or check the Homebrew installation at https://docs.brew.sh/Homebrew-on-Linux" + fi + done +fi + +if [ -n "$PACKAGE" ]; then + echo "Installing brew package: $PACKAGE" + remote_user_run 'eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" && brew install '"$PACKAGE" + echo "$PACKAGE installed successfully." +fi + From 404785625d6db17c506941f96ed6d59f2418e302 Mon Sep 17 00:00:00 2001 From: Stuart Bell <141676627+stu-bell@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:53:49 +0000 Subject: [PATCH 3/6] revert node image --- .devcontainer/devcontainer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index b202ee6..c4f8a8c 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,5 +1,6 @@ { - "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + // node needed for devcontainers cli + "image": "mcr.microsoft.com/devcontainers/javascript-node:1-20-bookworm", "features": { "ghcr.io/devcontainers/features/docker-in-docker:2": {}, "ghcr.io/stu-bell/devcontainer-features/claude-code": {}, From 77e5759ddb2fec39376ad9652f01bec901201a6c Mon Sep 17 00:00:00 2001 From: Stuart Bell <141676627+stu-bell@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:54:33 +0000 Subject: [PATCH 4/6] update brew spec --- .beans/df-g2t5--new-feature-brew.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.beans/df-g2t5--new-feature-brew.md b/.beans/df-g2t5--new-feature-brew.md index 3dd2bda..da121d9 100644 --- a/.beans/df-g2t5--new-feature-brew.md +++ b/.beans/df-g2t5--new-feature-brew.md @@ -23,6 +23,7 @@ containerEnv: - "PATH": "/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:${PATH}" install.sh: +1. Check dependencies are installed: bash, curl. Install them if not 1. check if brew is installed. if not, install with brew.sh script below 1. verify brew is installed correctly 1. if a brewPackage is not specified, exit here @@ -40,8 +41,7 @@ non-functional requirements: Install script from homebrew: https://brew.sh/ NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" -Features considered for implementation but rejected (DO NOT IMPLEMENT THESE): -- Add an option to remove brew after package install to keep the image small? This is just for the purpose of installing a package with brew +Documentation: add to the NOTES.md file the command to uninstall homebrew at https://github.com/homebrew/install#uninstall-homebrew # Implementation comments/notes from agent: From 31d8a8a47550cc5542a5b7e3906a275a45ffcd37 Mon Sep 17 00:00:00 2001 From: Stuart Bell <141676627+stu-bell@users.noreply.github.com> Date: Tue, 24 Mar 2026 19:08:17 +0000 Subject: [PATCH 5/6] homebrew --- .beans/df-g2t5--new-feature-brew.md | 30 +- src/homebrew/NOTES.md | 40 +++ src/homebrew/devcontainer-feature.json | 14 +- src/homebrew/install.sh | 95 ++++-- src/homebrew/util.sh | 295 ++++++++++++++++++ test/homebrew/scenarios.json | 62 ++++ .../.devcontainer/devcontainer.json | 8 +- 7 files changed, 506 insertions(+), 38 deletions(-) create mode 100644 src/homebrew/NOTES.md create mode 100644 src/homebrew/util.sh create mode 100644 test/homebrew/scenarios.json diff --git a/.beans/df-g2t5--new-feature-brew.md b/.beans/df-g2t5--new-feature-brew.md index da121d9..e617174 100644 --- a/.beans/df-g2t5--new-feature-brew.md +++ b/.beans/df-g2t5--new-feature-brew.md @@ -1,7 +1,7 @@ --- # df-g2t5 title: 'new feature: Homebrew' -status: draft +status: done type: task created_at: 2026-03-12T15:40:26Z updated_at: 2026-03-12T15:40:26Z @@ -10,6 +10,16 @@ Read these instructions carefully. If further clarification is required, append For a recap of how to use beans, read the result of `beans prime` +# Changes required + +The following brief has been implemented, however the following changes have been made. Read the brief for context about the original implementation + +Changes: + +- test/homebrew/scenarios.json: jq is not a good tool choice for testing, as it is likely already available on the debian system + +# Brief + Implement a new devcontainer feature in src/homebrew that should install homebrew (https://brew.sh/), and optionally, a brew package This feature has already been started in src/homebrew, but it may need reimplementing @@ -45,5 +55,21 @@ Documentation: add to the NOTES.md file the command to uninstall homebrew at htt # Implementation comments/notes from agent: -agent to update this section... +## Implementation + +Reimplemented `src/homebrew` with: + +- **`devcontainer-feature.json`**: Replaced single `package` option with `brewPackage`, `brewPackageVersion`, `brewArgs`. Kept `containerEnv` PATH entries. +- **`install.sh`**: Full rewrite using `util.sh` patterns: + - Skips gracefully on Alpine (Homebrew requires glibc; Alpine uses musl libc) + - Installs `curl`/`bash` dependencies if missing + - Installs Homebrew via official `brew.sh` script as remote user + - Verifies brew binary at `/home/linuxbrew/.linuxbrew/bin/brew` + - Exits early if no `brewPackage` specified + - Checks if package + `brewPackageVersion` requirement is already satisfied before installing (uses `semver_gte`) + - Verifies package post-install +- **`util.sh`**: Copied from `test/util-test/sample-feature/util.sh` (v0.1.4) +- **`NOTES.md`**: Documents supported OS, usage examples, and Homebrew uninstall command from https://github.com/homebrew/install#uninstall-homebrew +- **`test/homebrew/scenarios.json`**: 5 scenarios — alpine (warns + exits 0), ubuntu, debian, ubuntu-with-package (hello), ubuntu-with-package-version (hello >= 2.10). Replaced `jq` with `hello` (GNU Hello) as `jq` is pre-installed on Debian/Ubuntu and would not validate brew actually installed it. + diff --git a/src/homebrew/NOTES.md b/src/homebrew/NOTES.md new file mode 100644 index 0000000..f6b1040 --- /dev/null +++ b/src/homebrew/NOTES.md @@ -0,0 +1,40 @@ +# Supported OS + +Tested on Debian and Ubuntu. Alpine Linux is not supported (Homebrew requires glibc; Alpine uses musl libc). + +# Get Started + +Add the feature to your devcontainer.json: + +```devcontainer.json +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "ghcr.io/stu-bell/devcontainer-features/homebrew": {} + } +} +``` + +To also install a brew package: + +```devcontainer.json +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "ghcr.io/stu-bell/devcontainer-features/homebrew": { + "brewPackage": "jq", + "brewPackageVersion": "1.6" + } + } +} +``` + +# Uninstall Homebrew + +To uninstall Homebrew from within your container, run: + +```sh +NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/uninstall.sh)" +``` + +See https://github.com/homebrew/install#uninstall-homebrew for full uninstall instructions. diff --git a/src/homebrew/devcontainer-feature.json b/src/homebrew/devcontainer-feature.json index 90a3ab8..5255c7b 100644 --- a/src/homebrew/devcontainer-feature.json +++ b/src/homebrew/devcontainer-feature.json @@ -4,10 +4,20 @@ "name": "Homebrew", "description": "Installs the Homebrew package manager and optionally a brew package", "options": { - "package": { + "brewPackage": { "type": "string", "default": "", - "description": "Optional brew package to install (e.g. 'hmans/beans/beans')" + "description": "Name of Brew package to install (e.g. 'hmans/beans/beans')" + }, + "brewPackageVersion": { + "type": "string", + "default": "", + "description": "Minimum required version of brew package to install (e.g. '1.2.0')" + }, + "brewArgs": { + "type": "string", + "default": "", + "description": "Argument string to pass to the brew install command (e.g. '--build-from-source')" } }, "containerEnv": { diff --git a/src/homebrew/install.sh b/src/homebrew/install.sh index 099e4cd..750126c 100644 --- a/src/homebrew/install.sh +++ b/src/homebrew/install.sh @@ -1,44 +1,77 @@ -#!/bin/bash +#!/bin/sh +# Copyright (c) 2026 Stuart Bell +# Licensed under the MIT License. See https://github.com/stu-bell/devcontainer-features/blob/main/LICENSE for license information. set -e +. ./util.sh -# Run a command as the remote user for the devcontainer. -remote_user_run() { - command_to_run="$1" - USER_OPTION="${REMOTE_USER_NAME:-automatic}" - _REMOTE_USER="${_REMOTE_USER:-${USER_OPTION}}" - if [ "${_REMOTE_USER}" = "auto" ] || [ "${_REMOTE_USER}" = "automatic" ]; then - _REMOTE_USER="$(id -un 1000 2>/dev/null || echo "vscode")" # vscode fallback - fi - echo "Running as: $_REMOTE_USER, command: $command_to_run" >&2 - # Escape single quotes in command_to_run for the inner sh -lc call - escaped_command_to_run=$(echo "$command_to_run" | sed "s/'/'\\''/g") +BREW_PACKAGE="${BREWPACKAGE:-}" +BREW_PACKAGE_VERSION="${BREWPACKAGEVERSION:-}" +BREW_ARGS="${BREWARGS:-}" +BREW_BIN="/home/linuxbrew/.linuxbrew/bin/brew" - su - "${_REMOTE_USER}" -c "sh -lc '$escaped_command_to_run'" -} +# Homebrew requires glibc and does not support Alpine (musl libc) +if os_alpine; then + echoyel "WARNING: Homebrew does not support Alpine Linux (musl libc). Skipping installation." + exit 0 +fi -PACKAGE="${PACKAGE:-}" +# 1. Check dependencies are installed: bash, curl. Install them if not. +has_command curl || { + echo "curl not found, installing..." + apt_get_install curl ca-certificates +} +has_command bash || { + echo "bash not found, installing..." + apt_get_install bash +} -if [ -x "/home/linuxbrew/.linuxbrew/bin/brew" ]; then - echo "Homebrew already installed, skipping." +# 2. Check if brew is installed. If not, install with brew.sh script. +if [ -x "$BREW_BIN" ]; then + echo "Homebrew is already installed." else echo "Installing Homebrew..." remote_user_run 'NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"' - echo "Homebrew installed successfully." - - echo "Verifying installation paths..." - for path in /home/linuxbrew/.linuxbrew/bin /home/linuxbrew/.linuxbrew/sbin; do - if [ ! -d "$path" ]; then - echo "WARNING: expected Homebrew path not found: $path" - echo "You may need to add Homebrew to your PATH manually. Run:" - echo ' eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"' - echo "Or check the Homebrew installation at https://docs.brew.sh/Homebrew-on-Linux" +fi + +# 3. Verify brew is installed correctly. +if ! [ -x "$BREW_BIN" ]; then + echored "ERROR: Homebrew not found at $BREW_BIN. Installation may have failed." + exit 1 +fi +echogrn "Homebrew installed: $($BREW_BIN --version | head -1)" + +# 4. If a brewPackage is not specified, exit here. +[ -z "$BREW_PACKAGE" ] && exit 0 + +# 5. If a brewPackage is specified, ensure we can use brew install, add to PATH if necessary. +export PATH="/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:$PATH" + +# 6. Check if that package is installed and if the required brewPackageVersion is met. +NEEDS_INSTALL=true +if remote_user_run 'eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" && brew list --formula '"$BREW_PACKAGE"' > /dev/null 2>&1'; then + echo "Package '$BREW_PACKAGE' is already installed." + if [ -n "$BREW_PACKAGE_VERSION" ]; then + installed_ver=$(remote_user_run 'eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" && brew list --versions '"$BREW_PACKAGE" | awk '{print $2}' | cut -d'_' -f1) + if semver_gte "$installed_ver" "$BREW_PACKAGE_VERSION"; then + echo "Installed version $installed_ver meets minimum required version $BREW_PACKAGE_VERSION." + NEEDS_INSTALL=false + else + echo "Installed version $installed_ver does not meet minimum required $BREW_PACKAGE_VERSION, reinstalling." fi - done + else + NEEDS_INSTALL=false + fi fi -if [ -n "$PACKAGE" ]; then - echo "Installing brew package: $PACKAGE" - remote_user_run 'eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" && brew install '"$PACKAGE" - echo "$PACKAGE installed successfully." +if [ "$NEEDS_INSTALL" = "true" ]; then + echo "Installing brew package: $BREW_PACKAGE" + remote_user_run 'eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" && brew install '"$BREW_ARGS $BREW_PACKAGE" fi +# 7. Verify that the brew package is installed correctly. +if remote_user_run 'eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" && brew list '"$BREW_PACKAGE"' > /dev/null 2>&1'; then + echogrn "Package '$BREW_PACKAGE' installed successfully." +else + echored "ERROR: Package '$BREW_PACKAGE' installation could not be verified." + exit 1 +fi diff --git a/src/homebrew/util.sh b/src/homebrew/util.sh new file mode 100644 index 0000000..0cd841c --- /dev/null +++ b/src/homebrew/util.sh @@ -0,0 +1,295 @@ +#!/bin/sh +# Copyright (c) 2026 Stuart Bell +# Licensed under the MIT License. See https://github.com/stu-bell/devcontainer-features/blob/main/LICENSE for license information. + +# v0.1.4 + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' +echored() { + echo -e "${RED}$@${NC}" +} +echogrn() { + echo -e "${GREEN}$@${NC}" +} +echoyel() { + echo -e "${YELLOW}$@${NC}" +} +# check if user is root user +is_root_user() { + [ "$(id -u)" -eq 0 ] +} + +# parse major verion from a semantic version string +semver_major() { + echo "${1#v}" | cut -d'.' -f1 +} + +# parse minor version from a semantic version string +semver_minor() { + echo "${1#v}" | cut -d'.' -f2 +} + +semver_pad() { + version="$1" + # Count the number of dots + dots=$(echo "$version" | tr -cd '.' | wc -c) + # If only one dot (major.minor), add .0 + if [ "$dots" -eq 1 ]; then + echo "${version}.0" + else + echo "$version" + fi +} + +# return 0 if semver $1 is greater than or equal to $2 (ignores patch) +semver_gte() { + v1maj=$(semver_major "$1") + v1min=$(semver_minor "$1") + + v2maj=$(semver_major "$2") + v2min=$(semver_minor "$2") + + if [ "$v1maj" -gt "$v2maj" ] || \ + { [ "$v1maj" -eq "$v2maj" ] && [ "$v1min" -ge "$v2min" ]; } + then + return 0 + else + return 1 + fi +} + +# If we're using Alpine, install bash before executing +ensure_bash_on_alpine() { + . /etc/os-release + if [ "${ID}" = "alpine" ]; then + apk add --no-cache bash + fi +} + +# OS detection. Populates ID, ID_LIKE, VERSION +os_alpine() { + . /etc/os-release + [ "${ID}" = "alpine" ] +} + +os_debian_like() { + . /etc/os-release + [ "${ID}" = "debian" ] || [ "${ID_LIKE}" = "debian" ] +} + +# Run a command as the remote user for the devcontainer. +remote_user_run() { +# Use _REMOTE_USER if available, otherwise use the devcontainer.json option USER_NAME + command_to_run="$1" + USER_OPTION="${REMOTE_USER_NAME:-automatic}" + _REMOTE_USER="${_REMOTE_USER:-${USER_OPTION}}" + if [ "${_REMOTE_USER}" = "auto" ] || [ "${_REMOTE_USER}" = "automatic" ]; then + _REMOTE_USER="$(id -un 1000 2>/dev/null || echo "vscode")" # vscode fallback + fi + echo "Running as: $_REMOTE_USER, command: $command_to_run" >&2 + # Escape single quotes in command_to_run for the inner sh -lc call + escaped_command_to_run=$(echo "$command_to_run" | sed "s/'/'\\''/g") + + su - "${_REMOTE_USER}" -c "sh -lc '$escaped_command_to_run'" +} + +# check if a command exists +has_command() { + command -v "$1" > /dev/null 2>&1 +} + +# check if a command exists for remote user +remote_user_has_command() { + remote_user_run "command -v \"$1\" > /dev/null 2>&1" +} + +# append line to common user profile files. eg: +# add_to_user_profiles 'export PATH="$HOME/.local/bin:$PATH"' +add_to_user_profiles() { + echo "$1" | tee -a \ + "$_REMOTE_USER_HOME/.profile" \ + "$_REMOTE_USER_HOME/.ashrc" \ + "$_REMOTE_USER_HOME/.bashrc" \ + "$_REMOTE_USER_HOME/.bash_profile" \ + "$_REMOTE_USER_HOME/.zshrc" \ + "$_REMOTE_USER_HOME/.zprofile" \ + > /dev/null + # set user as owner of the files we've just created as root + [ "$(id -u)" -eq 0 ] && chown -R "$_REMOTE_USER:$_REMOTE_USER" "$_REMOTE_USER_HOME" +} + +# Utility function for installing apk packages with minimal image size +apk_install() { + echo "Installing packages via apk: $* ..." + apk update + apk add --no-cache "$@" +} + +# Utility function for installing apt packages with minimal image size +apt_get_install() { + echo "Installing packages via apt-get: $* ..." + export DEBIAN_FRONTEND=noninteractive + apt-get update + apt-get install -y --no-install-recommends "$@" + apt-get clean + rm -rf /var/lib/apt/lists/* +} + + +# Download and install an OCI feature +# Explicitly add all args, don't rely on default values +# install_oci_feature "ghcr.io/devcontainers/features/python:1.8.0" \ +# "VERSION=3.11" \ +# "INSTALL_JUPYTERLAB=true" \ +# "OPTIMIZE=true" +install_oci_feature() { + FEATURE_URI=$1 + shift # Remove first argument, rest are options + + # dependencies + has_command curl || { + echored "ERROR: This feature requires curl to be installed. Install with devcontainer feature ghcr.io/devcontainers/features/common-utils" + exit 1 + } + has_command sed || { + echored "ERROR: This feature requires sed to be installed. Install with devcontainer feature ghcr.io/devcontainers/features/common-utils" + exit 1 + } + has_command grep || { + echored "ERROR: This feature requires grep to be installed. Install with devcontainer feature ghcr.io/devcontainers/features/common-utils" + exit 1 + } + has_command tar || { + echored "ERROR: This feature requires tar to be installed." + exit 1 + } + + # Parse the URI (format: registry/repo/path:tag or registry/repo/path@digest) + REGISTRY=$(echo "$FEATURE_URI" | cut -d'/' -f1) + REPO_AND_TAG=$(echo "$FEATURE_URI" | cut -d'/' -f2-) + + # Check if tag or digest is present + case "$REPO_AND_TAG" in + *:*|*@*) + REPO=$(echo "$REPO_AND_TAG" | sed 's/[:@].*//') + TAG=$(echo "$REPO_AND_TAG" | sed 's/.*[:@]//') + ;; + *) + REPO="$REPO_AND_TAG" + TAG="latest" + ;; + esac + + echo "Installing OCI feature: $FEATURE_URI" + echo "Registry: $REGISTRY, Repo: $REPO, Tag: $TAG" + + # Display options if provided + if [ $# -gt 0 ]; then + echo "Options:" + for opt in "$@"; do + echo " $opt" + done + fi + + # Create temp directory + TEMP_DIR=$(mktemp -d) + cd "$TEMP_DIR" + + # Get anonymous token (works for most public registries) + echo "Getting authentication token..." + TOKEN=$(curl -s "https://${REGISTRY}/token?scope=repository:${REPO}:pull" | sed -n 's/.*"token":"\([^"]*\)".*/\1/p') + + # If token endpoint fails, try without token first + if [ -z "$TOKEN" ]; then + echo "No token endpoint, attempting without authentication..." + fi + + # Get the manifest + echo "Fetching manifest..." + if [ -n "$TOKEN" ]; then + MANIFEST=$(curl -sL "https://${REGISTRY}/v2/${REPO}/manifests/${TAG}" \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Accept: application/vnd.oci.image.manifest.v1+json" \ + -H "Accept: application/vnd.docker.distribution.manifest.v2+json") + else + MANIFEST=$(curl -sL "https://${REGISTRY}/v2/${REPO}/manifests/${TAG}" \ + -H "Accept: application/vnd.oci.image.manifest.v1+json" \ + -H "Accept: application/vnd.docker.distribution.manifest.v2+json") + fi + + if [ -z "$MANIFEST" ] || echo "$MANIFEST" | grep -q "errors"; then + echo "Error: Failed to fetch manifest" + echo "$MANIFEST" + cd - + rm -rf "$TEMP_DIR" + return 1 + fi + + # Extract layer digests (using sed for better Alpine compatibility) + LAYERS=$(echo "$MANIFEST" | sed -n 's/.*"digest":"\(sha256:[a-f0-9]*\)".*/\1/p') + + if [ -z "$LAYERS" ]; then + echo "Error: No layers found in manifest" + cd - + rm -rf "$TEMP_DIR" + return 1 + fi + + # Download and extract each layer + echo "$LAYERS" | while read -r DIGEST; do + [ -z "$DIGEST" ] && continue + echo "Downloading layer: $DIGEST" + + if [ -n "$TOKEN" ]; then + curl -sL "https://${REGISTRY}/v2/${REPO}/blobs/${DIGEST}" \ + -H "Authorization: Bearer ${TOKEN}" \ + -o layer.tar.gz + else + curl -sL "https://${REGISTRY}/v2/${REPO}/blobs/${DIGEST}" \ + -o layer.tar.gz + fi + + # Try to extract (Alpine's tar handles both gzipped and plain) + if tar -tzf layer.tar.gz >/dev/null 2>&1; then + tar -xzf layer.tar.gz 2>/dev/null || tar -xf layer.tar.gz 2>/dev/null || true + else + tar -xf layer.tar.gz 2>/dev/null || echo "Warning: Could not extract layer" + fi + rm -f layer.tar.gz + done + + # Run the install script if it exists + if [ -f "./install.sh" ]; then + echo "Running install.sh with bash..." + chmod +x ./install.sh + + # Check if bash is available + if ! command -v bash >/dev/null 2>&1; then + echo "Error: bash is required but not found" + cd - + rm -rf "$TEMP_DIR" + return 1 + fi + + # Export all provided options as environment variables and run with bash + for opt in "$@"; do + if [ -n "$opt" ]; then # Only export if the option is not empty + export "$opt" + fi + done + + bash ./install.sh + else + echo "Warning: install.sh not found in feature" + fi + + # Cleanup + cd - + rm -rf "$TEMP_DIR" + + echo "Feature installation complete for $FEATURE_URI" +} diff --git a/test/homebrew/scenarios.json b/test/homebrew/scenarios.json new file mode 100644 index 0000000..c8454ed --- /dev/null +++ b/test/homebrew/scenarios.json @@ -0,0 +1,62 @@ +[ + { + "name": "alpine", + "expected_exit_code": 0, + "expected_output": "WARNING: Homebrew does not support Alpine", + "devcontainer": { + "image": "mcr.microsoft.com/devcontainers/base:alpine", + "features": { + "../../src/homebrew": {} + } + } + }, + { + "name": "ubuntu", + "expected_exit_code": 0, + "expected_output": "Homebrew installed:", + "devcontainer": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "../../src/homebrew": {} + } + } + }, + { + "name": "debian", + "expected_exit_code": 0, + "expected_output": "Homebrew installed:", + "devcontainer": { + "image": "mcr.microsoft.com/devcontainers/base:debian", + "features": { + "../../src/homebrew": {} + } + } + }, + { + "name": "ubuntu-with-package", + "expected_exit_code": 0, + "expected_output": "installed successfully", + "devcontainer": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "../../src/homebrew": { + "brewPackage": "hello" + } + } + } + }, + { + "name": "ubuntu-with-package-version", + "expected_exit_code": 0, + "expected_output": "installed successfully", + "devcontainer": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "../../src/homebrew": { + "brewPackage": "hello", + "brewPackageVersion": "2.10" + } + } + } + } +] diff --git a/test/manual-test/.devcontainer/devcontainer.json b/test/manual-test/.devcontainer/devcontainer.json index cfb5a5e..f5c6e3d 100644 --- a/test/manual-test/.devcontainer/devcontainer.json +++ b/test/manual-test/.devcontainer/devcontainer.json @@ -5,13 +5,15 @@ "ghcr.io/stu-bell/devcontainer-features/claude-code": { }, "ghcr.io/stu-bell/devcontainer-features/cursor-cli": { }, "ghcr.io/stu-bell/devcontainer-features/gemini-cli": { }, - // neovim feature sets XDG_CONFIG_HOME to /config, which clobbers open-code feature - // "ghcr.io/stu-bell/devcontainer-features/neovim": { }, + "ghcr.io/stu-bell/devcontainer-features/neovim": { + "CONFIG_GIT_URL":"https://github.com/stu-bell/nvim" + }, "ghcr.io/stu-bell/devcontainer-features/node": { }, "ghcr.io/stu-bell/devcontainer-features/python": { "min_python_version": "3.12" }, "ghcr.io/stu-bell/devcontainer-features/ttyd": { }, - "ghcr.io/stu-bell/devcontainer-features/open-code": { } + "ghcr.io/stu-bell/devcontainer-features/open-code": { }, + "ghcr.io/stu-bell/devcontainer-features/homebrew": { } } } From 017916d2c33daaa30e06b7040f61ea85f87cedb8 Mon Sep 17 00:00:00 2001 From: Stuart Bell <141676627+stu-bell@users.noreply.github.com> Date: Tue, 24 Mar 2026 19:09:22 +0000 Subject: [PATCH 6/6] add auto test --- .github/workflows/test.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 1f69b0e..c9197c2 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -15,6 +15,7 @@ jobs: - claude-code - cursor-cli - gemini-cli + - homebrew - neovim - node - open-code