diff --git a/.beans/df-g2t5--new-feature-brew.md b/.beans/df-g2t5--new-feature-brew.md index f428531..e617174 100644 --- a/.beans/df-g2t5--new-feature-brew.md +++ b/.beans/df-g2t5--new-feature-brew.md @@ -1,21 +1,75 @@ --- # df-g2t5 -title: 'new feature: Brew' -status: draft +title: 'new feature: Homebrew' +status: done 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. + +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 + +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 + +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 +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)" -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). +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: -Should first check if the required version of brew is already installed. only install brew if a sufficient version of brew cannot be found. +## Implementation -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? +Reimplemented `src/homebrew` with: -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 +- **`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/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 5f2d048..c4f8a8c 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,8 +1,10 @@ { + // 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/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" } 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 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 new file mode 100644 index 0000000..5255c7b --- /dev/null +++ b/src/homebrew/devcontainer-feature.json @@ -0,0 +1,29 @@ +{ + "id": "homebrew", + "version": "1.0.0", + "name": "Homebrew", + "description": "Installs the Homebrew package manager and optionally a brew package", + "options": { + "brewPackage": { + "type": "string", + "default": "", + "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": { + "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..750126c --- /dev/null +++ b/src/homebrew/install.sh @@ -0,0 +1,77 @@ +#!/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 + +BREW_PACKAGE="${BREWPACKAGE:-}" +BREW_PACKAGE_VERSION="${BREWPACKAGEVERSION:-}" +BREW_ARGS="${BREWARGS:-}" +BREW_BIN="/home/linuxbrew/.linuxbrew/bin/brew" + +# 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 + +# 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 +} + +# 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)"' +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 + else + NEEDS_INSTALL=false + fi +fi + +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": { } } }