Skip to content
Merged

Dev #57

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Ignore .dev folder
.dev/*

.devcontainer/.devpod-internal/*
# Devpod internal files
**/.devcontainer/.devpod-internal/*

2 changes: 1 addition & 1 deletion src/gemini-cli/devcontainer-feature.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// Copyright (c) 2026 Stuart Bell
// Licensed under the MIT License. See https://github.com/stu-bell/devcontainer-features/blob/main/LICENSE for license information.
"id": "gemini-cli",
"version": "0.1.4",
"version": "0.1.5",
"name": "Google Gemini CLI (via npm)",
"description": "Installs Google Gemini CLI for AI code assistance https://geminicli.com",
"keywords": ["AI", "agent", "code", "coding", "alpine", "ubuntu", "debian"],
Expand Down
4 changes: 3 additions & 1 deletion src/gemini-cli/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ if semver_gte "$installed_version" "$min_req_ver"; then
else
# Install node feature
install_oci_feature "ghcr.io/stu-bell/devcontainer-features/node" \
"MIN_NODE_VERSION=$min_req_ver"
"MIN_NODE_VERSION=$min_req_ver" \
"NODEGYPDEPENDENCIES=false" \
"INSTALLYARNUSINGAPT=false"
fi

# Check npm is installed
Expand Down
8 changes: 2 additions & 6 deletions src/neovim/devcontainer-feature.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,9 @@
},
"CONFIG_LOCATION": {
"type": "string",
"default": "/config",
"description": "Path git clones config to. Should match environment variable XDG_CONFIG_HOME."
"default": "~/.config",
"description": "Path git clones config to."
}
},
"containerEnv": {
// Path Neovim checks for config. Defaults to a shared location since feature installs as root, which isn't necessarily the devcontainer user.
"XDG_CONFIG_HOME": "/config"
}
}

5 changes: 2 additions & 3 deletions src/neovim/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,7 @@ fi
# Clone git repo for user Neovim config
if [ -n "$CONFIG_GIT_URL" ]; then
# check git installed
has_command git || {

remote_user_has_command git || {
echored "ERROR: CONFIG_GIT_URL specified but git is not installed."
echored "Install git by including devcontainer feature: ghcr.io/devcontainers/features/common-utils"
exit 1
Expand All @@ -55,6 +54,6 @@ if [ -n "$CONFIG_GIT_URL" ]; then
# clone the config
echo "Cloning from $CONFIG_GIT_URL..."
mkdir -p $CONFIG_LOCATION
GIT_TERMINAL_PROMPT=0 git clone --depth 1 "$CONFIG_GIT_URL" "${CONFIG_LOCATION}/nvim"
remote_user_run GIT_TERMINAL_PROMPT=0 git clone --depth 1 "$CONFIG_GIT_URL" "${CONFIG_LOCATION}/nvim"
fi

291 changes: 263 additions & 28 deletions src/neovim/util.sh
Original file line number Diff line number Diff line change
@@ -1,34 +1,8 @@
#!/bin/sh
# Copyright (c) Stuart Bell
# 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.0
# os_debian_like os_alpine ensure_bash_on_alpine echoyel echogrn echored semver_major s_root_user has_command

# check if a command exists
has_command() {
command -v "$1" > /dev/null 2>&1
}
# MISSING_DEPS=""
# for cmd in git node npm; do
# if ! assert_dependency "$cmd"; then
# MISSING_DEPS="$MISSING_DEPS $cmd"
# fi
# done
# if [ -n "$MISSING_DEPS" ]; then
# echo "ERROR: Missing:$MISSING_DEPS"
# exit 1
# fi

# 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
}
# v0.1.5

# Colors for output
RED='\033[0;31m'
Expand All @@ -44,6 +18,54 @@ echogrn() {
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() {
# Return false (1) if first argument is empty/unset
if [ -z "$1" ]; then
return 1
fi

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() {
Expand All @@ -58,7 +80,220 @@ 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
# 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"
}
Loading