diff --git a/install.sh b/install.sh index 2866950..2faf4ac 100755 --- a/install.sh +++ b/install.sh @@ -8,11 +8,17 @@ source=$(dirname $(readlink -f $0)) alias zz_log=$source/src/common-utils/_zz_log.sh +# Track whether this is the root-level invocation to control symlink lifecycle +_install_root="${INSTALL_ROOT_CALL:-1}" +export INSTALL_ROOT_CALL=0 + # Prepare for local installation by creating a temporary directory and linking common utils -find $source/src/common-utils/ -type f -name "_*.sh" -exec echo {} \; -exec chmod +x {} \; | while read file; do - ln -sf $file $source/src/common-utils/$(basename $file | sed 's/^_//;s/.sh$//') -done -export PATH=$PATH:$source/src/common-utils +if [ "$_install_root" = "1" ]; then + find $source/src/common-utils/ -type f -name "_*.sh" -exec echo {} \; -exec chmod +x {} \; | while read file; do + ln -sf $file $source/src/common-utils/$(basename $file | sed 's/^_//;s/.sh$//') + done + export PATH=$PATH:$source/src/common-utils +fi # Load arguments for the script eval $( @@ -27,27 +33,50 @@ help zz_log i "Installing devcontainer/features" +# Extract tomgrv devcontainer features listed in a devcontainer.json file +devcontainer_features() { + _file="$1" + [ -f "$_file" ] || return 0 + sed '/^\s*\/\//d' "$_file" | \ + jq -r '.features // {} | to_entries[] | + select(.key | contains("tomgrv/devcontainer-features")) | + .key | split("/")[-1] | split(":")[0]' 2>/dev/null +} + +# Find devcontainer.json files in standard locations and extract features +find_devcontainer_features() { + _search_dir="${1:-.}" + + # Check standard devcontainer file locations in priority order + for _f in \ + "$_search_dir/.devcontainer/devcontainer.json" \ + "$_search_dir/devcontainer.json" \ + "$_search_dir/.devcontainer.json"; do + if [ -f "$_f" ]; then + devcontainer_features "$_f" + return 0 + fi + done + + # Check .devcontainer//devcontainer.json for multiple configurations + _found=$(find "$_search_dir/.devcontainer" -maxdepth 2 -mindepth 2 -name "devcontainer.json" 2>/dev/null | head -1) + if [ -n "$_found" ]; then + devcontainer_features "$_found" + fi +} + # If 'all' argument is provided, set stubs and features to install all default features if [ -n "$all" ]; then echo "${Yellow}Add default features${End}" stubs=1 - features=$(sed '/^\s*\/\//d' $source/stubs/.devcontainer/devcontainer.json | jq -r '.features | to_entries[] | select(.key | contains("tomgrv/devcontainer-features"))| .key| - split("/")[-1] | split(":")[0]') + features=$(find_devcontainer_features "$source/stubs") fi # If 'upd' argument is provided, set stubs and features to update all features if [ -n "$upd" ]; then echo "${Green}Update features${End}" stubs=1 - features=$( - sed '/^\s*\/\//d' .devcontainer/devcontainer.json | - jq -r '.features | to_entries[] | - select(.key | contains("tomgrv/devcontainer-features")) | - .key | - split("/")[-1] | - split(":")[0]' | - tr '\n' ' ' - ) + features=$(find_devcontainer_features "." | tr '\n' ' ') fi # If 'package' argument is provided, use the specified package.json file @@ -62,10 +91,25 @@ if [ -n "$package" ]; then # Extract features from the package.json file if not already set if [ -z "$features" ]; then - features=$(cat $file | jq -r '.devcontainer.features | to_entries[] | select(.key | contains("tomgrv/devcontainer-features"))| .key| split("/")[-1] | split(":")[0]') + features=$(jq -r '.devcontainer.features // {} | to_entries[] | select(.key | contains("tomgrv/devcontainer-features"))| .key| split("/")[-1] | split(":")[0]' "$file") + fi +fi + +# If no features specified so far, auto-detect from devcontainer files in current directory +if [ -z "$features" ] && [ -z "$stubs" ] && [ -z "$all" ]; then + detected=$(find_devcontainer_features "." | tr '\n' ' ') + if [ -n "$detected" ]; then + features="$detected" + echo "${Green}Detected features from devcontainer files: $features${End}" fi fi +# Resolve transitive dependencies and expand the feature list in topological order +# using install-deps as the dedicated resolver +if [ -n "$features" ]; then + features=$(install-deps "$source" $features | tr '\n' ' ') +fi + # Merge all files from the stub folder to the root using git merge-file if stubs are selected if [ -n "$stubs" ]; then echo "${Yellow}Installing stubs from ${UYellow}$source/src/common-utils/${Yellow}...${End}" @@ -73,75 +117,25 @@ if [ -n "$stubs" ]; then echo "${Green}Stubs installed${End}" fi -# If features are selected, proceed with installation +# If features are selected, delegate installation of each to install-feat if [ -n "$features" ]; then echo "${Green}Selected features: $features${End}" - # Create an alias for the _install-feature.sh script - alias install-feature=$(dirname $0)/src/common-utils/_install-feature.sh - - # Check if the script is running inside a container - if [ "$CODESPACES" != "true" ] && [ "$REMOTE_CONTAINERS" != "true" ] && [ -z "$DEV_CONTAINER_FILE_PATH" ]; then - - echo "${Red}You are not in a container${End}" - - # Run the install.sh script for each selected feature - for feature in $features; do - if [ -f "$source/src/$feature/install.sh" ]; then - echo "${Yellow}Running src/$feature/install.sh...${End}" - if sh $source/src/$feature/install.sh; then - echo "${Green}$feature installed${End}" - else - echo "${Red}$feature installation failed${End}" - exit 1 - fi - else - echo "${Red}$feature not found${End}" - exit 1 - fi - done - - # Run the configure.sh script for each selected feature - for feature in $features; do - featureSource="" - if [ -d "/tmp/$feature" ]; then - featureSource="/tmp/$feature" - elif [ -d "/usr/local/share/$feature" ]; then - featureSource="/usr/local/share/$feature" - fi - - if [ -n "$featureSource" ]; then - echo "${Yellow}Configuring $featureSource...${End}" - sh $source/src/common-utils/_configure-feature.sh -s $featureSource $feature - echo "${Green}$feature configured${End}" - else - echo "${Red}$feature not found${End}" - exit 1 - fi - done - - elif [ -n "$stubs" ]; then - - # stubs are selected, configure stubs of the selected features - for feature in $features; do - echo "${Yellow}Deploying stubs for $feature...${End}" - $source/src/common-utils/_configure-feature.sh -s $source/src/$feature $feature - echo "${Green}Stubs for $feature deployed${End}" - done - - else - # If inside a container, suggest using devutils as devcontainer features - echo "${Purple}You are in a container: use as devcontainer features:${End}" - for feature in $features; do - echo "${Purple}ghcr.io/tomgrv/devcontainer-features/$feature${End}" - done + for feature in $features; do + if [ -n "$stubs" ]; then + install-feat "$source" "$feature" --stubs + else + install-feat "$source" "$feature" + fi + done - fi fi -# Remoce all links to common utils -echo "Remove temp files..." -find $source/src/common-utils/ -type f -name "_*.sh" -exec echo {} \; -exec chmod +x {} \; | while read file; do - rm $source/src/common-utils/$(basename $file | sed 's/^_//;s/.sh$//') -done +# Remove all links to common utils (only at root-level invocation) +if [ "$_install_root" = "1" ]; then + echo "Remove temp files..." + find $source/src/common-utils/ -type f -name "_*.sh" -exec echo {} \; -exec chmod +x {} \; | while read file; do + rm $source/src/common-utils/$(basename $file | sed 's/^_//;s/.sh$//') + done +fi diff --git a/package.json b/package.json index ae6c496..c13fd54 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "url": "https://buymeacoffee.com/tomgrv" }, "scripts": { + "install": "sh ./install.sh -s", "lint": "npx --yes lint-staged", "release": "commit-and-tag-version --no-verify --", "test": "echo \"Warning: no test specified\"", diff --git a/src/act/package.json b/src/act/package.json index 986ad6c..f2825df 100644 --- a/src/act/package.json +++ b/src/act/package.json @@ -13,6 +13,8 @@ "act-run": "./_act-run.sh" }, "scripts": { + "install": "if [ \"$INIT_CWD\" = \"$PWD\" ]; then sh ./install.sh; fi", + "postinstall": "if [ \"$INIT_CWD\" = \"$PWD\" ]; then configure-feature -s . act; fi", "install-act": "./install-act.sh" }, "peerDependencies": { diff --git a/src/common-utils/README.md b/src/common-utils/README.md index efbdbe4..b6eef1b 100644 --- a/src/common-utils/README.md +++ b/src/common-utils/README.md @@ -64,6 +64,15 @@ The feature also includes the following VS Code customizations: - Installs specified common utilities such as jq and dos2unix. - Supports specifying additional utilities to install. +## Install internals + +- `_install-bin.sh` installs links for common-utils scripts in a writable bin directory. +- `_select-bin.sh` resolves the writable bin directory with this fallback order: + 1. `INSTALL_BIN_DIR` (or `/usr/local/bin` by default) + 2. `~/.local/bin` + 3. First writable directory found in `$PATH` (excluding `node_modules/.bin`) + 4. `/bin` + ## Additional utilities In addition to the specified utilities, some additional local utilities are also provided: diff --git a/src/common-utils/_install-bin.sh b/src/common-utils/_install-bin.sh index a25d957..f61d765 100755 --- a/src/common-utils/_install-bin.sh +++ b/src/common-utils/_install-bin.sh @@ -12,9 +12,23 @@ fi zz_log i "Installing bin scripts for {Purple $feature}..." -# Find all shell scripts in the target directory, make them executable, and create symbolic links in /usr/local/bin -find $target -type f -name "_*.sh" -exec echo {} \; -exec chmod +x {} \; | while read file; do - # Create a symbolic link in /usr/local/bin with the script name (without the leading underscore and .sh extension) - link=/usr/local/bin/$(basename $file | sed 's/^_//;s/.sh$//') - ln -sf $file $link && zz_log s "Linked {U $file} to {U $link}" +link_dir="$(sh "$(dirname "$0")/_select-bin.sh" "$target")" +[ -n "$link_dir" ] || { + zz_log e "No writable bin directory found" + exit 1 +} + +case ":$PATH:" in +*":$link_dir:"*) ;; +*) + export PATH="$link_dir:$PATH" + zz_log w "Added {U $link_dir} to PATH for current install session" + ;; +esac + +# Find all shell scripts in the target directory, make them executable, and create symbolic links in the selected bin directory +find "$target" -type f -name "_*.sh" -exec echo {} \; -exec chmod +x {} \; | while IFS= read -r file; do + # Create a symbolic link in the selected bin directory with the script name (without the leading underscore and .sh extension) + link="$link_dir/$(basename "$file" | sed 's/^_//;s/.sh$//')" + ln -sf "$file" "$link" && zz_log s "Linked {U $file} to {U $link}" done diff --git a/src/common-utils/_install-deps.sh b/src/common-utils/_install-deps.sh new file mode 100755 index 0000000..5a87bb9 --- /dev/null +++ b/src/common-utils/_install-deps.sh @@ -0,0 +1,78 @@ +#!/bin/sh + +# Resolves transitive tomgrv/devcontainer-features dependencies from devcontainer-feature.json files. +# Usage: install-deps [...] +# Outputs the complete dependency list in topological order (deps before dependents), one per line. + +_source="$1" +shift + +if [ -z "$_source" ]; then + echo "Usage: install-deps ..." >&2 + exit 1 +fi + +features="$*" +[ -z "$features" ] && exit 0 + +# Extract direct tomgrv/devcontainer-features dependencies of a single feature +_feature_deps() { + _manifest="$_source/src/$1/devcontainer-feature.json" + [ -f "$_manifest" ] || return 0 + jq -r '.dependsOn // {} | to_entries[] | + select(.key | contains("tomgrv/devcontainer-features")) | + .key | split("/")[-1] | split(":")[0]' "$_manifest" 2>/dev/null +} + +# BFS: collect the full set of reachable features (input + all transitive deps) +_all="" +_queue="$features" +while [ -n "$_queue" ]; do + _next="" + for _f in $_queue; do + case " $_all " in + *" $_f "*) continue ;; + esac + _all="$_all $_f" + for _dep in $(_feature_deps "$_f"); do + case " $_all $_next " in + *" $_dep "*) ;; + *) _next="$_next $_dep" ;; + esac + done + done + _queue="$_next" +done + +# Topological sort: emit features whose tomgrv deps are all already emitted +_emitted="" +_pending="$_all" +_changed=1 +while [ "$_changed" = "1" ] && [ -n "$_pending" ]; do + _changed=0 + _still_pending="" + for _f in $_pending; do + _deps_ok=1 + for _dep in $(_feature_deps "$_f"); do + case " $_emitted " in + *" $_dep "*) ;; + *) + _deps_ok=0 + break + ;; + esac + done + if [ "$_deps_ok" = "1" ]; then + _emitted="$_emitted $_f" + _changed=1 + else + _still_pending="$_still_pending $_f" + fi + done + _pending="$_still_pending" +done + +# Append any remaining features (handles cycles or missing manifests) +_emitted="$_emitted $_pending" + +echo "$_emitted" | tr ' ' '\n' | grep -v '^$' diff --git a/src/common-utils/_install-feat.sh b/src/common-utils/_install-feat.sh new file mode 100755 index 0000000..9670daf --- /dev/null +++ b/src/common-utils/_install-feat.sh @@ -0,0 +1,73 @@ +#!/bin/sh + +# Installs a single devcontainer feature and its tomgrv/devcontainer-features dependencies. +# Uses install.sh as the orchestrator for recursive dependency installation. +# A tracker file (INSTALL_FEAT_TRACKER) prevents re-installation within the same session. +# +# Usage: install-feat [--stubs] + +_source="$1" +_feature="$2" +_stubs="${3:-}" + +if [ -z "$_source" ] || [ -z "$_feature" ]; then + echo "Usage: install-feat [--stubs]" >&2 + exit 1 +fi + +# Prevent re-installation within the same install session +_tracker="${INSTALL_FEAT_TRACKER:-/tmp/.install-feat-$$}" +export INSTALL_FEAT_TRACKER="$_tracker" + +if grep -qxF "$_feature" "$_tracker" 2>/dev/null; then + exit 0 +fi +echo "$_feature" >>"$_tracker" + +# Install each dependency first, using install.sh as orchestrator (recursive) +for _dep in $(install-deps "$_source" "$_feature"); do + [ "$_dep" = "$_feature" ] && continue + if [ -n "$_stubs" ]; then + sh "$_source/install.sh" -s "$_dep" + else + sh "$_source/install.sh" "$_dep" + fi +done + +# Check if the script is running inside a container +if [ "$CODESPACES" != "true" ] && [ "$REMOTE_CONTAINERS" != "true" ] && [ -z "$DEV_CONTAINER_FILE_PATH" ]; then + + # Install the feature itself + if [ -f "$_source/src/$_feature/install.sh" ]; then + sh "$_source/src/$_feature/install.sh" || exit 1 + else + echo "Feature $_feature not found in $_source/src/" >&2 + exit 1 + fi + + # Configure the feature after installation + _featureSource="" + if [ -d "/tmp/$_feature" ]; then + _featureSource="/tmp/$_feature" + elif [ -d "/usr/local/share/$_feature" ]; then + _featureSource="/usr/local/share/$_feature" + fi + + if [ -n "$_featureSource" ]; then + sh "$_source/src/common-utils/_configure-feature.sh" -s "$_featureSource" "$_feature" + else + echo "Feature $_feature installation target not found" >&2 + exit 1 + fi + +elif [ -n "$_stubs" ]; then + + # In container with stubs: deploy stubs for this feature + sh "$_source/src/common-utils/_configure-feature.sh" -s "$_source/src/$_feature" "$_feature" + +else + + # Inside a container without stubs: suggest using as devcontainer feature + echo "You are in a container: use as devcontainer feature: ghcr.io/tomgrv/devcontainer-features/$_feature" + +fi diff --git a/src/common-utils/_select-bin.sh b/src/common-utils/_select-bin.sh new file mode 100755 index 0000000..28b2fbc --- /dev/null +++ b/src/common-utils/_select-bin.sh @@ -0,0 +1,77 @@ +#!/bin/sh + +target="${1:-$target}" + +candidates="" +creatable="" + +add_candidate() { + candidate="$1" + can_create="${2:-0}" + [ -n "$candidate" ] || return 0 + + case ":$candidates:" in + *:"$candidate":*) ;; + *) + if [ -n "$candidates" ]; then + candidates="$candidates:$candidate" + else + candidates="$candidate" + fi + if [ "$can_create" = "1" ]; then + if [ -n "$creatable" ]; then + creatable="$creatable:$candidate" + else + creatable="$candidate" + fi + fi + ;; + esac +} + +add_candidate "${INSTALL_BIN_DIR:-/usr/local/bin}" 1 + +if [ -n "$HOME" ]; then + add_candidate "$HOME/.local/bin" 1 +fi + +old_ifs=$IFS +IFS=':' +for dir in $PATH; do + case "$dir" in + "" | "." | "$PWD" | */node_modules/.bin) continue ;; + esac + add_candidate "$dir" +done +IFS=$old_ifs + +add_candidate "$target/bin" 1 + +link_dir="" +old_ifs=$IFS +IFS=':' + +# First pass: find an existing writable and executable directory +for candidate in $candidates; do + if [ -d "$candidate" ] && [ -w "$candidate" ] && [ -x "$candidate" ]; then + link_dir="$candidate" + break + fi +done + +# Second pass: try to create known/safe directories if none found yet +if [ -z "$link_dir" ]; then + for candidate in $creatable; do + mkdir -p "$candidate" 2>/dev/null || true + if [ -d "$candidate" ] && [ -w "$candidate" ] && [ -x "$candidate" ]; then + link_dir="$candidate" + break + fi + done +fi + +IFS=$old_ifs + +[ -n "$link_dir" ] || exit 1 + +echo "$link_dir" diff --git a/src/common-utils/package.json b/src/common-utils/package.json index f3cc9c4..8d25e0b 100644 --- a/src/common-utils/package.json +++ b/src/common-utils/package.json @@ -26,6 +26,9 @@ "zz-json": "./_zz_json.sh", "zz-log": "./_zz_log.sh" }, - "scripts": {}, + "scripts": { + "install": "if [ \"$INIT_CWD\" = \"$PWD\" ]; then sh ./install.sh; fi", + "postinstall": "if [ \"$INIT_CWD\" = \"$PWD\" ]; then configure-feature -s . common-utils; fi" + }, "dependencies": {} } diff --git a/src/gateway/package.json b/src/gateway/package.json index 3f378e3..6faaa1b 100644 --- a/src/gateway/package.json +++ b/src/gateway/package.json @@ -10,7 +10,10 @@ "bin": { "gateway-curl": "./_gateway-curl.sh" }, - "scripts": {}, + "scripts": { + "install": "if [ \"$INIT_CWD\" = \"$PWD\" ]; then sh ./install.sh; fi", + "postinstall": "if [ \"$INIT_CWD\" = \"$PWD\" ]; then configure-feature -s . gateway; fi" + }, "dependencies": {}, "peerDependencies": { "@tomgrv-devcontainer-features/common-utils": ">=5.0.0" diff --git a/src/githooks/package.json b/src/githooks/package.json index 680941b..89c3d81 100644 --- a/src/githooks/package.json +++ b/src/githooks/package.json @@ -21,6 +21,8 @@ "update-version": "./_update-version.sh" }, "scripts": { + "install": "if [ \"$INIT_CWD\" = \"$PWD\" ]; then sh ./install.sh; fi", + "postinstall": "if [ \"$INIT_CWD\" = \"$PWD\" ]; then configure-feature -s . githooks; fi", "configure-cz": "./configure-cz.sh", "configure-hooks": "./configure-hooks.sh" }, diff --git a/src/gitutils/package.json b/src/gitutils/package.json index 089561b..7bf5d16 100644 --- a/src/gitutils/package.json +++ b/src/gitutils/package.json @@ -36,7 +36,10 @@ "setrights": "./_setrights.sh", "unset": "./_unset.sh" }, - "scripts": {}, + "scripts": { + "install": "if [ \"$INIT_CWD\" = \"$PWD\" ]; then sh ./install.sh; fi", + "postinstall": "if [ \"$INIT_CWD\" = \"$PWD\" ]; then configure-feature -s . gitutils; fi" + }, "dependencies": {}, "peerDependencies": { "@tomgrv-devcontainer-features/common-utils": ">=5.0.0", diff --git a/src/gitversion/package.json b/src/gitversion/package.json index e26ec93..cde9348 100644 --- a/src/gitversion/package.json +++ b/src/gitversion/package.json @@ -16,7 +16,10 @@ "checkout-version": "./_checkout-version.sh", "gv": "./_gv.sh" }, - "scripts": {}, + "scripts": { + "install": "if [ \"$INIT_CWD\" = \"$PWD\" ]; then sh ./install.sh; fi", + "postinstall": "if [ \"$INIT_CWD\" = \"$PWD\" ]; then configure-feature -s . gitversion; fi" + }, "dependencies": {}, "peerDependencies": { "@tomgrv-devcontainer-features/common-utils": ">=5.0.0" diff --git a/src/larasets/package.json b/src/larasets/package.json index 603bb5c..1f90eaf 100644 --- a/src/larasets/package.json +++ b/src/larasets/package.json @@ -21,6 +21,8 @@ "srv": "./_srv.sh" }, "scripts": { + "install": "if [ \"$INIT_CWD\" = \"$PWD\" ]; then sh ./install.sh; fi", + "postinstall": "if [ \"$INIT_CWD\" = \"$PWD\" ]; then configure-feature -s . larasets; fi", "configure-composer": "./configure-composer.sh", "configure-deployer": "./configure-deployer.sh", "configure-sail": "./configure-sail.sh", diff --git a/src/minikube/package.json b/src/minikube/package.json index a6a48bf..0adc5f8 100644 --- a/src/minikube/package.json +++ b/src/minikube/package.json @@ -10,7 +10,10 @@ "stubs/**/*" ], "bin": {}, - "scripts": {}, + "scripts": { + "install": "if [ \"$INIT_CWD\" = \"$PWD\" ]; then sh ./install.sh; fi", + "postinstall": "if [ \"$INIT_CWD\" = \"$PWD\" ]; then configure-feature -s . minikube; fi" + }, "dependencies": {}, "peerDependencies": { "@tomgrv-devcontainer-features/common-utils": ">=5.0.0"