#!/usr/bin/env bash # This script was generated by bashly 1.4.0 (https://bashly.dev) # Modifying it manually is not recommended # :command.master_script # :command.version_command version_command() { echo "$version" } # :command.usage drupalaibp_usage() { if [[ -n $long_usage ]]; then printf "drupalaibp\n\n" printf " Bootstrap a fresh Drupal site (or any DDEV project) with one command, or\n manage the tooling of an existing DDEV project. Detects macOS or Linux\n automatically.\n\n" else printf "drupalaibp - Bootstrap a fresh Drupal site (or any DDEV project) with one command, or\n\n" fi printf "%s\n" "Usage:" printf " drupalaibp [COMMAND]\n" printf " drupalaibp [COMMAND] --help | -h\n" printf " drupalaibp --version | -v\n" echo # :command.usage_commands printf "%s\n" "Commands:" printf " %s Set up a Drupal site (default action when no command is given). Reads a config from ./.drupalaibp/ in the current directory when present and runs config-driven (add --yolo to run fully unattended). (default)\n" "setup-site" printf " %s Install this tool as a global 'drupalaibp' command in ~/.local/bin.\n" "install " printf " %s Delete a DDEV project (the current one if you are in it, else one picked from a subdirectory) and remove its files.\n" "delete " printf " %s Clone a config repo into ~/.drupalaibp/configs// so it can be reused as a saved config.\n" "pull " echo # :command.long_usage if [[ -n "$long_usage" ]]; then # :command.usage_options printf "%s\n" "Options:" # :command.usage_fixed_flags printf " %s\n" "--help, -h" printf " Show this help\n" echo printf " %s\n" "--version, -v" printf " Show version number\n" echo fi } # :command.usage drupalaibp_setup_site_usage() { printf "drupalaibp setup-site - Set up a Drupal site (default action when no command is given). Reads a config from ./.drupalaibp/ in the current directory when present and runs config-driven (add --yolo to run fully unattended).\n\n" printf "%s\n" "Usage:" printf " drupalaibp [setup-site] [SOURCE] [OPTIONS]\n" printf " drupalaibp setup-site --help | -h\n" echo # :command.long_usage if [[ -n "$long_usage" ]]; then # :command.usage_options printf "%s\n" "Options:" # :command.usage_flags # :flag.usage printf " %s\n" "--yolo" printf " Skip all interactive prompts (take safe defaults; fully non-interactive).\n" echo # :flag.usage printf " %s\n" "--name NAME" printf " Project directory name to create (skips the name prompt).\n" echo # :flag.usage printf " %s\n" "--profile PROFILE" printf " Drupal install: cms (default), standard, or minimal (use --site-template for\n a template).\n" printf " %s\n" "Allowed: cms, standard, minimal" echo # :flag.usage printf " %s\n" "--site-template NAME" printf " drupal.org site-template project short name (implies the 'template'\n profile).\n" echo # :flag.usage printf " %s\n" "--ddev-project-type TYPE" printf " DDEV project type. Drupal: drupal11 (default) / drupal10 / drupal12;\n non-Drupal: e.g. php.\n" echo # :flag.usage printf " %s\n" "--is-drupal BOOL" printf " Whether this is a Drupal build. Default true.\n" printf " %s\n" "Allowed: true, false" echo # :flag.usage printf " %s\n" "--agents LIST" printf " Comma-separated coding agents to install: claude,codex,opencode (skips the\n agent prompt).\n" echo # :flag.usage printf " %s\n" "--default-skills" printf " Install the default agent skills without prompting.\n" echo # :flag.usage printf " %s\n" "--add-skills LIST" printf " Agent skills to install ON TOP of the defaults, comma-separated 'repo|skill'\n (or 'repo' for all of them) — quote it. Skips the skills prompt.\n" echo # :flag.usage printf " %s\n" "--default-denylist" printf " Use the default command denylist (git push, ssh) without prompting.\n" echo # :flag.usage printf " %s\n" "--extras LIST" printf " Comma-separated extra Drupal installations, REPLACING the defaults:\n tool_api,dtk,droost (skips the extras prompt).\n" echo # :flag.usage printf " %s\n" "--add-extras LIST" printf " Comma-separated extra Drupal installations to install ON TOP of the defaults\n (skips the extras prompt).\n" echo # :flag.usage printf " %s\n" "--composer-stability LEVEL" printf " Composer minimum-stability: dev (default), alpha, beta, rc, or stable.\n" printf " %s\n" "Allowed: dev, alpha, beta, rc, stable" echo # :flag.usage printf " %s\n" "--github-setup REPO" printf " Create and push to a new private GitHub repo with this name (skips the\n GitHub prompt).\n" echo # :flag.usage printf " %s\n" "--global-config NAME" printf " Build from a saved config in ~/.drupalaibp/configs//.drupalaibp/\n (fails if not found).\n" echo # :flag.usage printf " %s\n" "--db-path FILE" printf " Local path to a database dump to import (used when a --config-url config\n declares db_import: true).\n" echo # :flag.usage printf " %s\n" "--config-url URL" printf " Build from a .drupalaibp/ config at the root of this git repo (clones it and\n copies it into the project).\n" echo # :flag.usage printf " %s\n" "--config-path DIR" printf " Build from a .drupalaibp/ config at the root of this local directory (copies\n the whole directory into the project; mutually exclusive with\n --config-url/--global-config).\n" echo # :flag.usage printf " %s\n" "--config NAME" printf " Which config to use when a source's .drupalaibp/ offers several (basename,\n no .json; also selects a remote-configs.json entry).\n" echo # :flag.usage printf " %s\n" "--ref REF" printf " Git branch, tag, or ref for a URL source (alternative to an @ suffix).\n" echo # :command.usage_fixed_flags printf " %s\n" "--help, -h" printf " Show this help\n" echo # :command.usage_args printf "%s\n" "Arguments:" # :argument.usage printf " %s\n" "SOURCE" printf " A config source: a git URL (optionally @), a local directory, or a\n saved config name.\n" echo # :command.usage_examples printf "%s\n" "Examples:" printf " bash <(curl -fsSL https://aibp.drupalstarforge.ai/install.sh)\n" printf " bash <(curl -fsSL https://aibp.drupalstarforge.ai/install.sh) --yolo\n" printf " bash <(curl -fsSL https://aibp.drupalstarforge.ai/install.sh) --name mysite\n --agents claude,codex --default-skills --default-denylist\n" printf " bash <(curl -fsSL https://aibp.drupalstarforge.ai/install.sh) --yolo\n --add-extras agent_ready_drupal_build_kit --add-skills\n 'DietrichGebert/ponytail|*'\n" printf " bash <(curl -fsSL https://aibp.drupalstarforge.ai/install.sh) --is-drupal\n false --ddev-project-type php --name storybook\n" printf " bash <(curl -fsSL https://aibp.drupalstarforge.ai/install.sh) --config-path\n ./team-config --yolo\n" printf " bash <(curl -fsSL https://aibp.drupalstarforge.ai/install.sh)\n https://git.drupalcode.org/project/foo\n" printf " bash <(curl -fsSL https://aibp.drupalstarforge.ai/install.sh)\n https://git.drupalcode.org/project/foo@1.2.x --config config2 --yolo\n" echo fi } # :command.usage drupalaibp_install_usage() { printf "drupalaibp install - Install this tool as a global 'drupalaibp' command in ~/.local/bin.\n\n" printf "%s\n" "Usage:" printf " drupalaibp install\n" printf " drupalaibp install --help | -h\n" echo # :command.long_usage if [[ -n "$long_usage" ]]; then # :command.usage_options printf "%s\n" "Options:" # :command.usage_fixed_flags printf " %s\n" "--help, -h" printf " Show this help\n" echo # :command.usage_examples printf "%s\n" "Examples:" printf " bash <(curl -fsSL https://aibp.drupalstarforge.ai/install.sh) install\n" echo fi } # :command.usage drupalaibp_delete_usage() { printf "drupalaibp delete - Delete a DDEV project (the current one if you are in it, else one picked from a subdirectory) and remove its files.\n\n" printf "%s\n" "Usage:" printf " drupalaibp delete\n" printf " drupalaibp delete --help | -h\n" echo # :command.long_usage if [[ -n "$long_usage" ]]; then # :command.usage_options printf "%s\n" "Options:" # :command.usage_fixed_flags printf " %s\n" "--help, -h" printf " Show this help\n" echo # :command.usage_examples printf "%s\n" "Examples:" printf " bash <(curl -fsSL https://aibp.drupalstarforge.ai/install.sh) delete\n" echo fi } # :command.usage drupalaibp_pull_usage() { printf "drupalaibp pull - Clone a config repo into ~/.drupalaibp/configs// so it can be reused as a saved config.\n\n" printf "%s\n" "Usage:" printf " drupalaibp pull REPO [NAME] [OPTIONS]\n" printf " drupalaibp pull --help | -h\n" echo # :command.long_usage if [[ -n "$long_usage" ]]; then # :command.usage_options printf "%s\n" "Options:" # :command.usage_flags # :flag.usage printf " %s\n" "--ref REF" printf " Git branch, tag, or ref to clone (alternative to an @ suffix).\n" echo # :flag.usage printf " %s\n" "--force" printf " Overwrite an existing saved config of the same name.\n" echo # :command.usage_fixed_flags printf " %s\n" "--help, -h" printf " Show this help\n" echo # :command.usage_args printf "%s\n" "Arguments:" # :argument.usage printf " %s\n" "REPO" printf " Git URL (optionally with @) to clone.\n" echo # :argument.usage printf " %s\n" "NAME" printf " Local saved-config name (default: the repo basename).\n" echo # :command.usage_examples printf "%s\n" "Examples:" printf " bash <(curl -fsSL https://aibp.drupalstarforge.ai/install.sh) pull\n https://git.drupalcode.org/project/foo\n" echo fi } # :command.normalize_input # :command.normalize_input_function normalize_input() { local arg passthru flags passthru=false while [[ $# -gt 0 ]]; do arg="$1" if [[ $passthru == true ]]; then input+=("$arg") elif [[ $arg =~ ^(--[a-zA-Z0-9_\-]+)=(.+)$ ]]; then input+=("${BASH_REMATCH[1]}") input+=("${BASH_REMATCH[2]}") elif [[ $arg =~ ^(-[a-zA-Z0-9])=(.+)$ ]]; then input+=("${BASH_REMATCH[1]}") input+=("${BASH_REMATCH[2]}") elif [[ $arg =~ ^-([a-zA-Z0-9][a-zA-Z0-9]+)$ ]]; then flags="${BASH_REMATCH[1]}" for ((i = 0; i < ${#flags}; i++)); do input+=("-${flags:i:1}") done elif [[ "$arg" == "--" ]]; then passthru=true input+=("$arg") else input+=("$arg") fi shift done } # :command.inspect_args inspect_args() { local k if ((${#args[@]})); then readarray -t sorted_keys < <(printf '%s\n' "${!args[@]}" | sort) echo args: for k in "${sorted_keys[@]}"; do echo "- \${args[$k]} = ${args[$k]}" done else echo args: none fi if ((${#deps[@]})); then readarray -t sorted_keys < <(printf '%s\n' "${!deps[@]}" | sort) echo echo deps: for k in "${sorted_keys[@]}"; do echo "- \${deps[$k]} = ${deps[$k]}" done fi if ((${#env_var_names[@]})); then readarray -t sorted_names < <(printf '%s\n' "${env_var_names[@]}" | sort) echo echo "environment variables:" for k in "${sorted_names[@]}"; do echo "- \$$k = ${!k:-}" done fi } # :command.user_lib # src/lib/config/_config_read_list.sh # Prompt for a space-separated free-text list and print each entry on its own # line (nothing when blank). Used by create_config to gather the provisioning # lists (modules, recipes, add-ons, extra skills). Reads from /dev/tty. _config_read_list() { local prompt="$1" line local -a arr=() echo -e "${prompt} ${DIM}[space-separated; blank for none]${RESET}:" >/dev/tty read -r line # when the answer is blank. Used by create_config for scalar config fields (e.g. # composer minimum-stability). Reads from /dev/tty. _config_read_scalar() { local prompt="$1" default="${2-}" line echo -e "${prompt} ${DIM}[default: ${default}]${RESET}:" >/dev/tty read -r line /dev/null 2>&1 || die "git is required but was not found on this host." if [[ -z "$ref" ]]; then git clone --quiet --depth 1 "$url" "$dest" >/dev/null 2>&1 \ || { rm -rf "$dest"; die "Could not clone '${url}'. Check the URL and your git access (SSH keys / credentials)."; } return 0 fi if [[ "$ref" == "!"* ]]; then refspec="refs/merge-requests/${ref#!}/head" elif [[ "$ref" == refs/* ]]; then refspec="$ref" else git clone --quiet --depth 1 -b "$ref" "$url" "$dest" >/dev/null 2>&1 \ || { rm -rf "$dest"; die "Could not clone '${url}' at ref '${ref}'. Check the branch/tag name and your git access."; } return 0 fi git clone --quiet "$url" "$dest" >/dev/null 2>&1 \ || { rm -rf "$dest"; die "Could not clone '${url}'. Check the URL and your git access."; } if ! git -C "$dest" fetch --quiet --depth 1 origin "$refspec" >/dev/null 2>&1 \ || ! git -C "$dest" checkout --quiet FETCH_HEAD >/dev/null 2>&1; then rm -rf "$dest" die "Could not fetch ref '${refspec}' from '${url}'." fi return 0 } # src/lib/config/_git_repo_url.sh _git_repo_url() { # local src="$1" if [[ "$src" == *"://"* || "$src" =~ ^[^/@]+@[^/:]+: || "$src" == *.git ]]; then printf '%s' "$src" return 0 fi if [[ "$src" == */* ]]; then printf 'https://git.drupalcode.org/%s' "$src" return 0 fi return 1 } # src/lib/config/_migrate_legacy_configs.sh _migrate_legacy_configs() { [[ -d "$CONFIG_DIR" ]] || return 0 local f base for f in "$CONFIG_DIR"/*.json; do [[ -e "$f" ]] || continue base="$(basename "$f" .json)" if mkdir -p "${CONFIG_DIR}/${base}/.drupalaibp" 2>/dev/null \ && mv "$f" "${CONFIG_DIR}/${base}/.drupalaibp/${base}.json" 2>/dev/null; then warn "Migrated saved config '${base}' to ${CONFIG_DIR}/${base}/.drupalaibp/." else warn "Could not migrate legacy config '${f}' — leaving it in place." fi done return 0 } # src/lib/config/_remote_configs_read.sh _remote_configs_read() { # [local-config-name…] local root="${1%/}"; shift local file="${root}/.drupalaibp/${REMOTE_CONFIGS_FILE}" [[ -f "$file" ]] || return 0 json_load "$file" [[ "$(json_type .)" == "array" ]] \ || die "${file} must be a JSON array of { repo, config, … } objects." local -a taken=("$@") local i count repo ref name label description weight url taken_name seen count="$(json_count .)" for (( i = 0; i < count; i++ )); do repo="$(json_get "${i}.repo" "")" name="$(json_get "${i}.config" "")" if [[ -z "$repo" || -z "$name" ]]; then warn "Skipping entry $((i + 1)) in ${REMOTE_CONFIGS_FILE}: both 'repo' and 'config' are required." >&2 continue fi if ! url="$(_git_repo_url "$repo")"; then warn "Skipping '${name}' in ${REMOTE_CONFIGS_FILE}: '${repo}' is not a git URL or an owner/repo shorthand." >&2 continue fi seen=0 for taken_name in ${taken[@]+"${taken[@]}"}; do if [[ "$taken_name" == "$name" ]]; then seen=1; break; fi done if [[ $seen -eq 1 ]]; then warn "Skipping '${name}' in ${REMOTE_CONFIGS_FILE}: that name is already taken by a local config or an earlier entry." >&2 continue fi taken+=("$name") ref="$(json_get "${i}.ref" "")" label="$(json_get "${i}.label" "")" [[ -n "$label" ]] || label="$name" weight="$(json_get "${i}.weight" 0)" if [[ ! "$weight" =~ ^-?[0-9]+$ ]]; then [[ -n "$weight" ]] && warn "Ignoring non-integer 'weight' for '${name}' in ${REMOTE_CONFIGS_FILE} — treated as 0." >&2 weight=0 fi # The detail panel says where the config comes from: picking this row # clones another repository, which the label alone would not reveal. description="$(json_get "${i}.description" "")" if [[ -n "$description" ]]; then description="${description} — from ${url}${ref:+ @ ${ref}}" else description="From ${url}${ref:+ @ ${ref}}" fi printf '%s\037%s\037%s\037%s\037%s\037%s\n' "$weight" "$name" "$url" "$ref" "$label" "$description" done return 0 } # src/lib/config/_resolve_config_path_source.sh _resolve_config_path_source() { local raw="$1" RESOLVED_CONFIG_SOURCE="" [[ -d "$raw" ]] || die "'${raw}' is not a directory." [[ -d "${raw%/}/.drupalaibp" ]] || die "No .drupalaibp/ directory in '${raw}'." RESOLVED_CONFIG_SOURCE="$(cd "$raw" && pwd)" } # src/lib/config/create_config.sh # Interactively build a new config and save it as JSON in $CONFIG_DIR. Runs the # normal choice prompts (no site is built, no directory created), then free-text # prompts for the provisioning lists, then writes the file (pure bash, no jq). # The project name is NOT part of a config — it's asked at build time — so it # isn't prompted here. Returns to the use_config menu afterwards. create_config() { local save_yolo=$YOLO name YOLO=0 # force the prompts even if --yolo/--profile were given PROFILE="" # so choose_profile always asks SITE_TEMPLATE="" SKILLS_SPEC=() EXTRAS=(); EXTRAS_SET=0 # so choose_extras always asks step "Create a new config" choose_profile choose_coding_agent choose_skills choose_deny_rules choose_extras # choose_mcp_server # HIDDEN for now (uncomment to include the MCP choice in configs) # Note: the project name and GitHub setup are intentionally NOT part of a config # (asked at build time), so they aren't prompted here. # Provisioning lists applied at build time (composer config + step_provision_from_config). COMPOSER_STABILITY="$(_config_read_scalar "Composer minimum-stability" dev)" local _r local -a _skills=() readarray -t _skills < <(_config_read_list "Skill repos to add (e.g. owner/repo — installs every skill in each)") for _r in ${_skills[@]+"${_skills[@]}"}; do SKILLS_SPEC+=("${_r}|*"); done readarray -t COMPOSER_PROJECTS < <(_config_read_list "Composer projects to require (e.g. drupal/token acme/thing)") readarray -t COMPOSER_ALLOW_PLUGINS < <(_config_read_list "Composer plugins to trust (e.g. tbachert/spi)") readarray -t INSTALL_MODULES < <(_config_read_list "Modules to enable with drush (e.g. token pathauto)") readarray -t RECIPES < <(_config_read_list "Recipes to require & apply (e.g. drupal/some_recipe)") readarray -t DDEV_ADDONS < <(_config_read_list "DDEV add-ons to get (e.g. ddev/ddev-selenium-standalone-chrome)") REPO_TYPES=(); REPO_URLS=(); AGENTS_MD=() # repositories/agents_md/questions/hooks: hand-edit the JSON # The chosen extras are saved as `extra_scripts` ids — a config-driven build # runs the very same extra_ scripts (no package list to keep in sync). # Name to save under. A blank first try shows a red error and re-asks; a blank # second try cancels the save. echo -e "Save this config as ${DIM}[name; letters, digits, - and _]${RESET}:" >/dev/tty read -r name /dev/tty echo -e "Save this config as ${DIM}[name; letters, digits, - and _]${RESET}:" >/dev/tty read -r name ["a","b"] local out="[" first=1 v for v in "$@"; do if [[ $first -eq 1 ]]; then first=0; else out+=","; fi out+="\"$(_json_escape "$v")\"" done printf '%s]' "$out" } _json_skills_array() { # SPEC entries "repo|n1 n2" -> [{"repo":…,"skills":[…]}] local out="[" first=1 entry repo names local -a names_arr for entry in "$@"; do repo="${entry%%|*}"; names="${entry#*|}" if [[ $first -eq 1 ]]; then first=0; else out+=","; fi # Split on spaces without triggering pathname expansion (a literal "*" # skill-name must stay literal, not glob to filenames in $PWD). names_arr=() IFS=' ' read -r -a names_arr <<< "$names" out+="{\"repo\":\"$(_json_escape "$repo")\",\"skills\":$(_json_str_array "${names_arr[@]}")}" done printf '%s]' "$out" } _json_repos_array() { local out="[" first=1 i for i in ${REPO_TYPES[@]+"${!REPO_TYPES[@]}"}; do if [[ $first -eq 1 ]]; then first=0; else out+=","; fi out+="{\"type\":\"$(_json_escape "${REPO_TYPES[$i]}")\",\"url\":\"$(_json_escape "${REPO_URLS[$i]}")\"}" done printf '%s]' "$out" } drupalaibp_export() { local file="$1" { echo "{" echo " \"ddev_project_type\": \"$(_json_escape "${DDEV_PROJECT_TYPE:-drupal11}")\"," echo " \"docroot\": \"$(_json_escape "${DOCROOT:-web}")\"," echo " \"launch_path\": \"$(_json_escape "${LAUNCH_PATH:-}")\"," echo " \"handoff_command\": \"$(_json_escape "${HANDOFF_COMMAND:-}")\"," echo " \"is_drupal\": ${IS_DRUPAL:-true}," echo " \"profile\": \"$(_json_escape "${PROFILE:-cms}")\"," echo " \"site_template\": \"$(_json_escape "${SITE_TEMPLATE:-}")\"," echo " \"agents\": $(_json_str_array ${AGENTS[@]+"${AGENTS[@]}"})," echo " \"composer_stability\": \"$(_json_escape "${COMPOSER_STABILITY:-dev}")\"," echo " \"composer_allow_plugins\": $(_json_str_array ${COMPOSER_ALLOW_PLUGINS[@]+"${COMPOSER_ALLOW_PLUGINS[@]}"})," echo " \"skills\": $(_json_skills_array ${SKILLS_SPEC[@]+"${SKILLS_SPEC[@]}"})," echo " \"deny_commands\": $(_json_str_array ${DENY_COMMANDS[@]+"${DENY_COMMANDS[@]}"})," echo " \"repositories\": $(_json_repos_array)," echo " \"composer_projects\": $(_json_str_array ${COMPOSER_PROJECTS[@]+"${COMPOSER_PROJECTS[@]}"})," echo " \"install_modules\": $(_json_str_array ${INSTALL_MODULES[@]+"${INSTALL_MODULES[@]}"})," echo " \"recipes\": $(_json_str_array ${RECIPES[@]+"${RECIPES[@]}"})," echo " \"ddev_addons\": $(_json_str_array ${DDEV_ADDONS[@]+"${DDEV_ADDONS[@]}"})," echo " \"extra_scripts\": $(_json_str_array ${EXTRAS[@]+"${EXTRAS[@]}"})," echo " \"agents_md\": $(_json_str_array ${AGENTS_MD[@]+"${AGENTS_MD[@]}"})" echo "}" } > "$file" return 0 } # src/lib/config/follow_remote_config.sh follow_remote_config() { local repo="$RESOLVED_CONFIG_REMOTE_REPO" local ref="$RESOLVED_CONFIG_REMOTE_REF" local name="$RESOLVED_CONFIG_REMOTE_NAME" # Cleared before the nested select_config runs so a stale pick can never be # mistaken for a fresh one by the loader we hand off to. RESOLVED_CONFIG_REMOTE_REPO=""; RESOLVED_CONFIG_REMOTE_REF=""; RESOLVED_CONFIG_REMOTE_NAME="" CONFIG_REMOTE_PARENT="${CONFIG_REMOTE_SOURCE:-the source you gave}" CONFIG_REMOTE_TARGET="$repo" CONFIG_REMOTE_DEPTH=1 step "Config '${name}' lives in another repository — fetching it from ${repo}${ref:+ @ ${ref}}" load_config_url "$repo" "$ref" "$name" } # src/lib/config/hooks.sh _EARLY_EVENTS="before_ensure_ddev ensure_ddev_done before_collect_answers ask_questions collect_answers_done before_create_project_dir create_project_dir_done before_configure_ddev configure_ddev_done before_answers_to_env answers_to_env_done before_install_coding_agents coding_agent_added install_coding_agents_done before_start_ddev before_ddev_start ddev_started start_ddev_done before_create_drupal_site" _hook_needs_early_staging() { local target="$1" e for e in $_EARLY_EVENTS; do [[ "$e" == "$target" ]] && return 0 done return 1 } _config_register_hooks() { CONFIG_HOOK_SCRIPTS=() CONFIG_HOOK_SCRIPTS_EARLY=() # COPY_PATHS is only populated by drupalaibp_load (not drupalaibp_load_hooks, # used in management mode); default it so ${#COPY_PATHS[@]} below is always # safe under `set -u`. [[ "$(declare -p COPY_PATHS 2>/dev/null)" == "declare -a"* ]] || COPY_PATHS=() local key event n i script weight where valid s # Fresh-scaffold builds (is_drupal:true, no db_import[_path]) must not # stage anything at the project root before `ddev composer # create-project` runs (create_drupal_site, pipeline step 8) — it # refuses a non-empty directory. Same determination load_config_url.sh # already makes for its own blanket-copy deferral. Computed once here # (not inside the loop below, not again in the tail) since both need it. local existing_codebase=0 [[ "${DB_IMPORT:-false}" == "true" || -n "${DB_IMPORT_PATH:-}" ]] && existing_codebase=1 local fresh_scaffold=0 [[ "${MANAGEMENT_MODE:-0}" -ne 1 && "${IS_DRUPAL:-true}" == "true" && $existing_codebase -eq 0 ]] && fresh_scaffold=1 for key in "${!JSON_TYPE[@]}"; do [[ "$key" =~ ^hooks\.([A-Za-z0-9_]+)$ ]] || continue event="${BASH_REMATCH[1]}" valid=0 for s in "${PIPELINE[@]}"; do if [[ "$event" == "before_${s}" || "$event" == "${s}_done" ]]; then valid=1; fi done for s in $DRUPALAIBP_EVENTS_EXTRA; do [[ "$event" == "$s" ]] && valid=1 done if [[ $valid -eq 0 ]]; then warn "hooks: unknown event '${event}' — its scripts will never run." fi n="$(json_count "hooks.${event}")" for (( i = 0; i < n; i++ )); do script="$(json_get "hooks.${event}.${i}.script" "")" weight="$(json_get "hooks.${event}.${i}.weight" 0)" where="$(json_get "hooks.${event}.${i}.where" web)" if [[ -z "$script" ]]; then warn "hooks.${event}[${i}] has no script — skipped." continue fi if [[ "$script" == *[[:space:]]* ]]; then warn "hooks.${event}[${i}]: script paths must not contain whitespace — skipped." continue fi if [[ $fresh_scaffold -eq 1 ]] && _hook_needs_early_staging "$event"; then CONFIG_HOOK_SCRIPTS_EARLY+=("$script") on "$event" "$weight" _run_hook_script "$where" ".ddev/drupalaibp-hooks/${script#./}" else CONFIG_HOOK_SCRIPTS+=("$script") on "$event" "$weight" _run_hook_script "$where" "$script" fi done done # Stage the config's hook scripts / copy_paths to their runtime paths in the # project (project root, mirroring the .drupalaibp/-relative config path). This # runs for EVERY source type, INCLUDING --config-url. The blanket clone copy # (_copy_config_url_repo) mirrors the whole repo, so a URL build's scripts also # land under .drupalaibp/ — but hooks are INVOKED at their project-root runtime # path, so _copy_hook_scripts must place them there too, else the hook is not # found and silently never runs. All three copies read from CONFIG_BASEDIR (the # clone, for a URL build), so they are correct regardless of when the blanket # copy runs. Same early/late split as any other loader. if [[ ${#CONFIG_HOOK_SCRIPTS_EARLY[@]} -gt 0 ]]; then on create_project_dir_done -100 _copy_early_hook_scripts fi if [[ ${#CONFIG_HOOK_SCRIPTS[@]} -gt 0 ]]; then if [[ $fresh_scaffold -eq 1 ]]; then on create_drupal_site_done -100 _copy_hook_scripts else on create_project_dir_done -100 _copy_hook_scripts fi fi if [[ ${#COPY_PATHS[@]} -gt 0 ]]; then if [[ $fresh_scaffold -eq 1 ]]; then on create_drupal_site_done -100 _copy_config_paths else on create_project_dir_done -100 _copy_config_paths fi fi return 0 } _copy_hook_scripts() { local script src dst for script in ${CONFIG_HOOK_SCRIPTS[@]+"${CONFIG_HOOK_SCRIPTS[@]}"}; do src="${CONFIG_BASEDIR}/${script#./}" dst="./${script#./}" if [[ ! -f "$src" ]]; then warn "Hook script not found: ${src}" continue fi if [[ -e "$dst" && "$src" -ef "$dst" ]]; then continue # management mode: config lives in the project root already fi mkdir -p "$(dirname "$dst")" cp "$src" "$dst" chmod +x "$dst" done return 0 } _copy_early_hook_scripts() { local script src dst for script in ${CONFIG_HOOK_SCRIPTS_EARLY[@]+"${CONFIG_HOOK_SCRIPTS_EARLY[@]}"}; do src="${CONFIG_BASEDIR}/${script#./}" dst=".ddev/drupalaibp-hooks/${script#./}" if [[ ! -f "$src" ]]; then warn "Hook script not found: ${src}" continue fi mkdir -p "$(dirname "$dst")" cp "$src" "$dst" chmod +x "$dst" done return 0 } _copy_config_paths() { local path src dst for path in ${COPY_PATHS[@]+"${COPY_PATHS[@]}"}; do src="${CONFIG_BASEDIR}/${path#./}" dst="./${path#./}" if [[ ! -e "$src" ]]; then warn "copy_paths entry not found: ${src}" continue fi if [[ -e "$dst" && "$src" -ef "$dst" ]]; then continue # management mode: config lives in the project root already fi mkdir -p "$(dirname "$dst")" cp -R "$src" "$dst" done return 0 } _run_hook_script() { local where="$1" script="./${2#./}" case "$where" in host) if [[ ! -f "$script" ]]; then warn "Hook script missing: ${script}"; return 0; fi step "Running hook (host): ${script}" bash "$script" || warn "Hook '${script}' (host) exited non-zero — continuing." ;; web|"") step "Running hook (web): ${script}" ddev exec bash "$script" || warn "Hook '${script}' (web) exited non-zero — continuing." ;; *) step "Running hook (${where}): ${script}" ddev exec -s "$where" bash "$script" || warn "Hook '${script}' (${where}) exited non-zero — continuing." ;; esac return 0 } # src/lib/config/load_config.sh DRUPALAIBP_KNOWN_KEYS="weight label description ddev_project_type docroot launch_path is_drupal profile site_template agents composer_stability composer_allow_plugins composer_with_all_dependencies skills deny_commands repositories composer_projects install_modules recipes ddev_addons extra_scripts agents_md questions hooks copy_paths db_import db_import_path agent_auto_mode append_welcome_text replace_welcome_text handoff_command" DRUPALAIBP_EVENTS_EXTRA="before_ddev_start ddev_started coding_agent_added ddev_addon_added extra_installed skill_added ask_questions" drupalaibp_load() { local file="$1" json_load "$file" CONFIG_BASEDIR="$(cd "$(dirname "$file")" && pwd)" local key known hit for key in "${!JSON_TYPE[@]}"; do [[ "$key" == *.* || "$key" == "." ]] && continue hit=0 for known in $DRUPALAIBP_KNOWN_KEYS; do [[ "$key" == "$known" ]] && hit=1 done if [[ $hit -eq 0 ]]; then warn "Unknown key '${key}' in $(basename "$file") — ignored." fi done DDEV_PROJECT_TYPE="$(json_get ddev_project_type drupal11)" IS_DRUPAL="$(json_get is_drupal true)" DOCROOT="$(json_get docroot "$([[ "$IS_DRUPAL" == "true" ]] && echo web || echo .)")" LAUNCH_PATH="$(json_get launch_path "")" HANDOFF_COMMAND="$(json_get handoff_command "")" PROFILE="$(json_get profile cms)" SITE_TEMPLATE="$(json_get site_template "")" COMPOSER_STABILITY="$(json_get composer_stability dev)" COMPOSER_WITH_ALL_DEPS="$(json_get composer_with_all_dependencies false)" AGENT_AUTO_MODE="$(json_get agent_auto_mode false)" DB_IMPORT="$(json_get db_import false)" DB_IMPORT_PATH="" if json_has db_import_path; then DB_IMPORT_PATH="${CONFIG_BASEDIR}/$(json_get db_import_path | sed 's#^\./##')" fi if [[ "$DB_IMPORT" == "true" && -n "$DB_IMPORT_PATH" ]]; then die "A config cannot set both 'db_import' and 'db_import_path' — pick one (db_import: true if the dump is supplied at runtime via --db-path, or db_import_path: \"\" if the dump is committed in the repo)." fi _config_read_array AGENTS agents _config_read_array DENY_COMMANDS deny_commands _config_read_array COMPOSER_PROJECTS composer_projects _config_read_array COMPOSER_ALLOW_PLUGINS composer_allow_plugins _config_read_array INSTALL_MODULES install_modules _config_read_array RECIPES recipes _config_read_array DDEV_ADDONS ddev_addons _config_read_array EXTRAS extra_scripts _config_read_array AGENTS_MD agents_md _config_read_welcome_text APPEND_WELCOME_TEXT append_welcome_text _config_read_welcome_text REPLACE_WELCOME_TEXT replace_welcome_text _config_read_array COPY_PATHS copy_paths _config_read_skills SKILLS_SPEC skills REPO_TYPES=(); REPO_URLS=() local n i t u n="$(json_count repositories)" for (( i = 0; i < n; i++ )); do t="$(json_get "repositories.${i}.type" "")" u="$(json_get "repositories.${i}.url" "")" if [[ -z "$t" || -z "$u" ]]; then warn "repositories[${i}] needs 'type' and 'url' — skipped." continue fi REPO_TYPES+=("$t"); REPO_URLS+=("$u") done _config_read_questions _config_register_hooks return 0 } drupalaibp_load_hooks() { local file="$1" json_load "$file" CONFIG_BASEDIR="$(cd "$(dirname "$file")" && pwd)" MANAGEMENT_MODE=1 _config_register_hooks return 0 } _config_read_array() { # (no namerefs: bash 4.2) local out="$1" path="$2" n i v eval "${out}=()" n="$(json_count "$path")" for (( i = 0; i < n; i++ )); do v="$(json_get "${path}.${i}")" eval "${out}+=(\"\$v\")" done } _config_read_skills() { # local out="$1" path="$2" n i m j repo names entry eval "${out}=()" n="$(json_count "$path")" for (( i = 0; i < n; i++ )); do if [[ "$(json_type "${path}.${i}")" == "string" ]]; then repo="$(json_get "${path}.${i}")" names="*" else repo="$(json_get "${path}.${i}.repo" "")" names="" m="$(json_count "${path}.${i}.skills")" for (( j = 0; j < m; j++ )); do names="${names:+${names} }$(json_get "${path}.${i}.skills.${j}")" done [[ -z "$names" ]] && names="*" fi if [[ -z "$repo" ]]; then warn "${path}[${i}] has no repo — skipped." continue fi entry="${repo}|${names}" eval "${out}+=(\"\$entry\")" done return 0 } _config_read_welcome_text() { local out="$1" path="$2" type n i v eval "${out}=()" type="$(json_type "$path")" if [[ "$type" == "string" ]]; then v="$(json_get "$path")" eval "${out}+=(\"\$v\")" else n="$(json_count "$path")" for (( i = 0; i < n; i++ )); do v="$(json_get "${path}.${i}")" eval "${out}+=(\"\$v\")" done fi } _config_read_questions() { CONFIG_QUESTION_KEYS=(); CONFIG_QUESTION_LABELS=(); CONFIG_QUESTION_TYPES=() CONFIG_QUESTION_DEFAULTS=(); CONFIG_QUESTION_OPTIONS=() CONFIG_QUESTION_VALUES=(); CONFIG_QUESTION_HASVALUE=() CONFIG_QUESTION_DEP_ENV=(); CONFIG_QUESTION_DEP_KIND=(); CONFIG_QUESTION_DEP_VALUES=() CONFIG_HAS_CONDITIONAL_QUESTION=0 local n i key type label default opts m j hasval val n="$(json_count questions)" for (( i = 0; i < n; i++ )); do key="$(json_get "questions.${i}.env" "")" # the env-var name (schema field: env) type="$(json_get "questions.${i}.type" text)" label="$(json_get "questions.${i}.label" "$key")" default="$(json_get "questions.${i}.default" "")" if [[ ! "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then warn "questions[${i}]: env '${key}' is not a valid env var name — skipped." continue fi case "$type" in text|password|single-select-options|multi-select-options) ;; *) warn "questions[${i}]: unknown type '${type}' — skipped."; continue ;; esac opts="" m="$(json_count "questions.${i}.select_options")" # the choice list (schema field: select_options) for (( j = 0; j < m; j++ )); do opts="${opts:+${opts},}$(json_get "questions.${i}.select_options.${j}")" done # Optional `value` pre-answers the question. json_has distinguishes an # explicit "" value from an absent key. if json_has "questions.${i}.value"; then hasval=1; val="$(json_get "questions.${i}.value")"; else hasval=0; val=""; fi CONFIG_QUESTION_KEYS+=("$key"); CONFIG_QUESTION_LABELS+=("$label") CONFIG_QUESTION_TYPES+=("$type"); CONFIG_QUESTION_DEFAULTS+=("$default") CONFIG_QUESTION_OPTIONS+=("$opts") CONFIG_QUESTION_VALUES+=("$val"); CONFIG_QUESTION_HASVALUE+=("$hasval") _config_read_depends_on "$i" done return 0 } _config_read_depends_on() { local i="$1" dep_env dep_kind dep_val valid=1 m2 j2 found=0 k if ! json_has "questions.${i}.depends_on"; then CONFIG_QUESTION_DEP_ENV+=(""); CONFIG_QUESTION_DEP_KIND+=(""); CONFIG_QUESTION_DEP_VALUES+=("") return 0 fi dep_env="$(json_get "questions.${i}.depends_on.env" "")" # Earlier-only: dep_env must match one of the keys collected before this # question's own index in CONFIG_QUESTION_KEYS (the last element is the # current question itself, so exclude it). for (( j2 = 0; j2 < ${#CONFIG_QUESTION_KEYS[@]} - 1; j2++ )); do k="${CONFIG_QUESTION_KEYS[$j2]}" if [[ "$k" == "$dep_env" ]]; then found=1; break; fi done if [[ $found -eq 0 ]]; then warn "questions[${i}]: depends_on.env '${dep_env}' does not match an earlier question — asking unconditionally." valid=0 fi dep_kind=""; dep_val="" if json_has "questions.${i}.depends_on.equals"; then dep_kind=equals; dep_val="$(json_get "questions.${i}.depends_on.equals")" elif json_has "questions.${i}.depends_on.not_equals"; then dep_kind=not_equals; dep_val="$(json_get "questions.${i}.depends_on.not_equals")" elif json_has "questions.${i}.depends_on.in"; then dep_kind=in m2="$(json_count "questions.${i}.depends_on.in")" for (( j2 = 0; j2 < m2; j2++ )); do dep_val="${dep_val:+${dep_val},}$(json_get "questions.${i}.depends_on.in.${j2}")" done else warn "questions[${i}]: depends_on has no equals/not_equals/in — asking unconditionally." valid=0 fi if [[ $valid -eq 1 ]]; then CONFIG_QUESTION_DEP_ENV+=("$dep_env"); CONFIG_QUESTION_DEP_KIND+=("$dep_kind") CONFIG_QUESTION_DEP_VALUES+=("$dep_val") # shellcheck disable=SC2034 # consumed by ask_config_questions, not this file CONFIG_HAS_CONDITIONAL_QUESTION=1 else CONFIG_QUESTION_DEP_ENV+=(""); CONFIG_QUESTION_DEP_KIND+=(""); CONFIG_QUESTION_DEP_VALUES+=("") fi return 0 } # src/lib/config/load_config_path.sh load_config_path() { local raw="$1" requested="${2:-}" _resolve_config_path_source "$raw" select_config "$RESOLVED_CONFIG_SOURCE" "$requested" # A pick that points at another repository takes over completely: the build # becomes a --config-url build of that repo, and this directory contributes # nothing but the pointer (no blanket copy of it into the project). if [[ -n "${RESOLVED_CONFIG_REMOTE_REPO:-}" ]]; then follow_remote_config return 0 fi step "Using local config: ${RESOLVED_CONFIG_FILE}" # Set before drupalaibp_load so the config-driven globals are marked # config-driven from the outset (same ordering as load_config_url). USE_CONFIG=1 CONFIG_PATH_SOURCE_DIR="$RESOLVED_CONFIG_SOURCE" drupalaibp_load "$RESOLVED_CONFIG_FILE" # Same two copy timings as load_config_url — see the branch comment there. local existing_codebase=0 [[ "$DB_IMPORT" == "true" || -n "$DB_IMPORT_PATH" ]] && existing_codebase=1 if [[ "$IS_DRUPAL" == "true" && $existing_codebase -eq 0 ]]; then on create_drupal_site_done -100 _copy_config_path_repo else on create_project_dir_done -100 _copy_config_path_repo fi ok "Config loaded from ${RESOLVED_CONFIG_FILE}" } _copy_config_path_repo() { step "Copying config directory into the project..." _copy_source_into_project "$CONFIG_PATH_SOURCE_DIR" ok "Project files copied from ${CONFIG_PATH_SOURCE_DIR}" } # src/lib/config/load_config_url.sh load_config_url() { local url="$1" ref="${2:-}" requested="${3:-}" tmpdir command -v git >/dev/null 2>&1 || die "--config-url requires git, which was not found on this host." tmpdir="$(mktemp -d)" step "Cloning config repo: ${url}${ref:+ @ ${ref}}" _git_clone_ref "$url" "$ref" "$tmpdir" if ! ls "$tmpdir"/.drupalaibp/*.json >/dev/null 2>&1; then rm -rf "$tmpdir" die "No .drupalaibp/ configs in '${url}'. (A single .drupalaibp/config.json replaces the old root .drupalaibp.json.)" fi select_config "$tmpdir" "$requested" # The repo turned out to be an umbrella: the pick points at a config in yet # another repository. This clone was only ever the pointer file's home, so # drop it and start over against the repo the entry names. if [[ -n "${RESOLVED_CONFIG_REMOTE_REPO:-}" ]]; then rm -rf "$tmpdir" follow_remote_config return 0 fi ok "Cloned $(basename "$url" .git)" # Set before drupalaibp_load runs so the config-driven globals are marked # config-driven from the outset. _config_register_hooks (called synchronously # by drupalaibp_load) stages hook scripts / copy_paths to their project-root # runtime paths for a URL build just like any other loader — it reads # CONFIG_BASEDIR (the clone), so the blanket _copy_config_url_repo overlay # covers the rest of the repo, not the hooks' runtime placement. USE_CONFIG=1 CONFIG_FROM_URL=1 CONFIG_URL_REPO_DIR="$tmpdir" drupalaibp_load "$RESOLVED_CONFIG_FILE" # A Drupal build (is_drupal:true) needs one of two paths to be valid here: # - existing-codebase mode (db_import / db_import_path set): the clone IS # the Drupal codebase, so the blanket copy can land immediately and # step_provision_existing_codebase (not create-project) stands it up. # - fresh-scaffold mode (neither set): create_drupal_site must scaffold # Drupal into the still-empty project dir FIRST; the blanket copy is # deferred until after it finishes, so the clone's tracked files (incl. # .git) overlay the scaffold afterward instead of blocking it. # IS_DRUPAL defaults to "true" (see load_config.sh), so a repo whose # .drupalaibp.json omits is_drupal entirely also hits this branch. local existing_codebase=0 [[ "$DB_IMPORT" == "true" || -n "$DB_IMPORT_PATH" ]] && existing_codebase=1 if [[ "$IS_DRUPAL" == "true" && $existing_codebase -eq 0 ]]; then on create_drupal_site_done -100 _copy_config_url_repo else on create_project_dir_done -100 _copy_config_url_repo fi } _copy_config_url_repo() { step "Copying cloned repo into the project..." _copy_source_into_project "$CONFIG_URL_REPO_DIR" ok "Project files copied from config repo" } _cleanup_config_url_repo() { [[ "${CONFIG_FROM_URL:-0}" -eq 1 ]] || return 0 [[ -n "${CONFIG_URL_REPO_DIR:-}" && -d "$CONFIG_URL_REPO_DIR" ]] && rm -rf "$CONFIG_URL_REPO_DIR" return 0 } # src/lib/config/load_global_config.sh load_global_config() { local name="$1" requested="${2:-}" dir="${CONFIG_DIR}/${1}" _migrate_legacy_configs if [[ ! -d "${dir}/.drupalaibp" ]]; then die "No saved config named '${name}' at ${dir}/. Create one via 'Use a config → Create a new config', pull one with '${GLOBAL_CMD} pull ${name}', or list what exists: ls '${CONFIG_DIR}'." fi select_config "$dir" "$requested" if [[ -n "${RESOLVED_CONFIG_REMOTE_REPO:-}" ]]; then follow_remote_config return 0 fi step "Using saved config: ${name} (${RESOLVED_CONFIG_FILE})" drupalaibp_load "$RESOLVED_CONFIG_FILE" USE_CONFIG=1 } # src/lib/config/parse_git_ref.sh parse_git_ref() { local raw="$1" candidate_src candidate_ref after_scheme PARSED_SOURCE="$raw" PARSED_REF="" # Plain scp URL: user@host:path with no extra '@' anywhere after it. if [[ "$raw" != *"://"* && "$raw" =~ ^[^/@]+@[^/:]+:[^@]*$ ]]; then return 0 fi [[ "$raw" == *"@"* ]] || return 0 candidate_ref="${raw##*@}" candidate_src="${raw%@*}" [[ -n "$candidate_ref" ]] || return 0 if [[ "$candidate_src" == *"://"* ]]; then after_scheme="${candidate_src#*://}" [[ "$after_scheme" == */* ]] || return 0 fi PARSED_SOURCE="$candidate_src" PARSED_REF="$candidate_ref" return 0 } # src/lib/config/pull_config.sh pull_config() { local raw_repo="$1" name="${2:-}" ref="${args[--ref]:-}" _migrate_legacy_configs parse_git_ref "$raw_repo" local url="$PARSED_SOURCE" [[ -z "$ref" ]] && ref="$PARSED_REF" # owner/repo shorthand -> drupal.org URL (same rule as resolve_config_source). # A reference _git_repo_url does not recognize is left as the user typed it, # so the clone below is what reports the problem. local expanded if expanded="$(_git_repo_url "$url")"; then url="$expanded"; fi [[ -n "$name" ]] || name="$(basename "$url" .git)" name="$(printf '%s' "$name" | tr -cd '[:alnum:]_-')" [[ -n "$name" ]] || die "Could not derive a config name from '${raw_repo}' — pass one explicitly: ${GLOBAL_CMD} pull ." mkdir -p "$CONFIG_DIR" local dest="${CONFIG_DIR}/${name}" if [[ -e "$dest" ]]; then if [[ -n "${args[--force]:-}" ]]; then rm -rf "$dest" else die "A saved config named '${name}' already exists at ${dest}. Pass --force to overwrite, or choose a different name." fi fi step "Pulling config repo '${url}'${ref:+ @ ${ref}} as '${name}'..." _git_clone_ref "$url" "$ref" "$dest" if ! ls "$dest"/.drupalaibp/*.json >/dev/null 2>&1; then rm -rf "$dest" die "'${url}' has no .drupalaibp/ configs — nothing to save. (A single .drupalaibp/config.json is the minimum.)" fi ok "Saved config '${name}' — build from it with: ${GLOBAL_CMD} ${name}" } # src/lib/config/resolve_config_source.sh resolve_config_source() { local raw="$1" SOURCE_KIND=""; SOURCE_VALUE=""; SOURCE_REF="" if [[ -d "$raw" ]]; then SOURCE_KIND="path"; SOURCE_VALUE="$raw"; return 0 fi parse_git_ref "$raw" local src="$PARSED_SOURCE" SOURCE_REF="$PARSED_REF" local url if url="$(_git_repo_url "$src")"; then SOURCE_KIND="url"; SOURCE_VALUE="$url"; return 0 fi if [[ -d "${CONFIG_DIR}/${src}" ]]; then SOURCE_KIND="global"; SOURCE_VALUE="$src"; return 0 fi die "'${raw}' is not a command, a git URL, a local directory, or a saved config name." } # src/lib/config/select_config.sh select_config() { local root="$1" requested="${2:-}" RESOLVED_CONFIG_FILE="" RESOLVED_CONFIG_REMOTE_REPO=""; RESOLVED_CONFIG_REMOTE_REF=""; RESOLVED_CONFIG_REMOTE_NAME="" local dir="${root%/}/.drupalaibp" local -a files=() names=() labels=() weights=() descriptions=() repos=() refs=() f local meta if [[ -d "$dir" ]]; then for f in "$dir"/*.json; do [[ -e "$f" ]] || continue [[ "$(basename "$f")" == "$REMOTE_CONFIGS_FILE" ]] && continue meta="$(_config_picker_meta "$f")" files+=("$f") names+=("$(basename "$f" .json)") weights+=("$(printf '%s' "$meta" | cut -d$'\037' -f1)") labels+=("$(printf '%s' "$meta" | cut -d$'\037' -f2)") descriptions+=("$(printf '%s' "$meta" | cut -d$'\037' -f3-)") repos+=(""); refs+=("") done fi # Configs living in other repositories, read from the repo-dependency file. # Local names are passed in so a remote entry can never shadow one. A # malformed file is fatal (see _remote_configs_read). local remote_tsv="" deep_names="" local w nm rp rf lb ds remote_tsv="$(_remote_configs_read "$root" ${names[@]+"${names[@]}"})" \ || die "Could not read ${dir}/${REMOTE_CONFIGS_FILE}." while IFS=$'\037' read -r w nm rp rf lb ds; do [[ -n "$nm" ]] || continue if [[ "${CONFIG_REMOTE_DEPTH:-0}" -gt 0 ]]; then deep_names+="${deep_names:+ }${nm}" continue fi weights+=("$w"); names+=("$nm"); labels+=("$lb"); descriptions+=("$ds") repos+=("$rp"); refs+=("$rf"); files+=("") done <<< "$remote_tsv" if [[ -n "$deep_names" ]]; then warn "Ignoring the ${REMOTE_CONFIGS_FILE} in ${CONFIG_REMOTE_TARGET:-this repo} — remote configs are followed one level only." fi local n=${#files[@]} if [[ $n -eq 0 ]]; then [[ -n "$deep_names" ]] && _die_second_hop "$deep_names" die "No config found in ${dir}/ — expected at least one *.json file. (A single .drupalaibp/config.json replaces the old root .drupalaibp.json.)" fi [[ $n -gt 1 ]] && _select_config_sort local i if [[ -n "$requested" ]]; then for i in "${!names[@]}"; do [[ "${names[$i]}" == "$requested" ]] || continue _select_config_pick "$i" return 0 done [[ " ${deep_names} " == *" ${requested} "* ]] && _die_second_hop "$requested" die "No config named '${requested}' in ${dir}/. Available: ${names[*]}." fi if [[ $n -eq 1 ]]; then _select_config_pick 0 return 0 fi if [[ "${ASK_UNATTENDED:-0}" -eq 1 ]]; then die "Multiple configs in ${dir}/ — pass --config (one of: ${names[*]})." fi local -a options=() local any_description=0 for i in "${!names[@]}"; do if [[ "${labels[$i]}" == "${names[$i]}" ]]; then options+=("${names[$i]}") else options+=("${labels[$i]} (${names[$i]})") fi [[ -n "${descriptions[$i]}" ]] && any_description=1 done if [[ "$any_description" -eq 1 ]]; then MENU_DETAILS=("${descriptions[@]}") else unset MENU_DETAILS fi select_one "Which configuration would you like to use?" "${options[@]}" _select_config_pick "$MENU_INDEX" return 0 } _select_config_pick() { # local i="$1" if [[ -n "${repos[$i]}" ]]; then RESOLVED_CONFIG_REMOTE_REPO="${repos[$i]}" RESOLVED_CONFIG_REMOTE_REF="${refs[$i]}" RESOLVED_CONFIG_REMOTE_NAME="${names[$i]}" CONFIG_REMOTE_SOURCE="${dir}/${REMOTE_CONFIGS_FILE}" else RESOLVED_CONFIG_FILE="${files[$i]}" fi } _die_second_hop() { # die "'${1}' in ${CONFIG_REMOTE_TARGET:-that repo} is itself a pointer to another repository, and remote configs are followed one level only (this one was reached from ${CONFIG_REMOTE_PARENT:-the source you gave}). Point the entry at a real .drupalaibp/*.json config, or run the installer against that repository directly." } _config_picker_meta() { # local file="$1" base weight label description meta base="$(basename "$file" .json)" meta="$(json_load "$file" >/dev/null 2>&1 && printf '%s\037%s\037%s' "$(json_get weight 0)" "$(json_get label "$base")" "$(json_get description "")")" || meta="" weight="$(printf '%s' "$meta" | cut -d$'\037' -f1)" label="$(printf '%s' "$meta" | cut -d$'\037' -f2)" description="$(printf '%s' "$meta" | cut -d$'\037' -f3-)" if [[ ! "$weight" =~ ^-?[0-9]+$ ]]; then if [[ -n "$weight" ]]; then warn "Ignoring non-integer 'weight' in $(basename "$file") — treated as 0." >&2 fi weight=0 fi [[ -n "$label" ]] || label="$base" printf '%s\037%s\037%s' "$weight" "$label" "$description" } _select_config_sort() { local -a sorted_files=() sorted_names=() sorted_labels=() sorted_weights=() sorted_descriptions=() sorted_repos=() sorted_refs=() local i w nm fl lb rp rf ds while IFS=$'\037' read -r w nm fl lb rp rf ds; do [[ -n "$nm" ]] || continue sorted_weights+=("$w"); sorted_names+=("$nm") sorted_files+=("$fl"); sorted_labels+=("$lb") sorted_repos+=("$rp"); sorted_refs+=("$rf") sorted_descriptions+=("$ds") done < <( for i in "${!names[@]}"; do printf '%s\037%s\037%s\037%s\037%s\037%s\037%s\n' \ "${weights[$i]}" "${names[$i]}" "${files[$i]}" "${labels[$i]}" "${repos[$i]}" "${refs[$i]}" "${descriptions[$i]}" done | LC_ALL=C sort -t $'\037' -k1,1n -k2,2 ) files=("${sorted_files[@]}"); names=("${sorted_names[@]}") labels=("${sorted_labels[@]}"); weights=("${sorted_weights[@]}") repos=("${sorted_repos[@]}"); refs=("${sorted_refs[@]}") descriptions=("${sorted_descriptions[@]}") } # src/lib/config/use_config.sh # "Use a config" run mode (offered only from the installed global command). Loops # a menu of saved config directories (${CONFIG_DIR}//.drupalaibp/) plus # Create / Delete / Cancel: # - pick a config -> resolve + load it, set USE_CONFIG=1 for an unattended build # (YOLO/ASK_UNATTENDED are set AFTER the load, so a multi-config saved bundle # can still prompt interactively via select_config) # - Create -> build a new config, then re-show the menu # - Delete -> remove a config, then re-show the menu # - Cancel -> fall back to interactive choices (YOLO=0) use_config() { mkdir -p "$CONFIG_DIR" _migrate_legacy_configs while true; do local -a dirs=() names=() options=() f base for f in "$CONFIG_DIR"/*/; do [[ -d "${f}.drupalaibp" ]] || continue base="$(basename "$f")" dirs+=("${f%/}"); names+=("$base"); options+=("Use '${base}'") done options+=("Create a new config") options+=("Delete a config") options+=("Cancel — let me pick my own choices") select_one "Configs in ${CONFIG_DIR}:" "${options[@]}" local n=${#dirs[@]} if [[ $MENU_INDEX -lt $n ]]; then select_config "${dirs[$MENU_INDEX]}" if [[ -n "${RESOLVED_CONFIG_REMOTE_REPO:-}" ]]; then follow_remote_config # sets USE_CONFIG itself else drupalaibp_load "$RESOLVED_CONFIG_FILE" fi USE_CONFIG=1 YOLO=1 ASK_UNATTENDED=1 ok "Using config '${names[$MENU_INDEX]}'." return fi case $(( MENU_INDEX - n )) in 0) create_config ;; 1) delete_config ;; 2) YOLO=0; return ;; esac done } # src/lib/core/00_require_bash.sh # Ensure bash >= 4 before Bashly's associative-array parser runs. # # Bashly's generated argument parser uses `declare -A`, which needs bash >= 4. # macOS ships bash 3.2 as /bin/bash, so re-exec under a newer bash when needed. # This block is deliberately bash-3.2-safe (no arrays, no [[ ]]). It runs as # top-level code, before `run "$@"`, so it executes even on bash 3.2. # Print the major version number of the bash at $1 (empty on failure). _oli_bash_major() { # shellcheck disable=SC2016 "$1" -c 'echo ${BASH_VERSINFO[0]}' 2>/dev/null || true } if [ "${BASH_VERSINFO:-0}" -lt 4 ]; then _oli_url="${INSTALLER_URL:-https://project.pages.drupalcode.org/one_line_installer/drupalaibp}" _oli_bash="" # 1. Look for an existing bash >= 4. for _b in /opt/homebrew/bin/bash /usr/local/bin/bash /usr/bin/bash; do _v="$(_oli_bash_major "$_b")" if [ -x "$_b" ] && [ -n "$_v" ] && [ "$_v" -ge 4 ]; then _oli_bash="$_b"; break; fi done # 2. On macOS, install one via Homebrew if none was found. if [ -z "$_oli_bash" ] && [ "$(uname -s)" = "Darwin" ]; then if ! command -v brew >/dev/null 2>&1; then echo "Installing Homebrew (needed to provide bash >= 4)..." >&2 NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" || true fi for _p in /opt/homebrew/bin/brew /usr/local/bin/brew; do if [ -x "$_p" ]; then eval "$("$_p" shellenv)"; break; fi done if command -v brew >/dev/null 2>&1; then echo "Installing bash via Homebrew..." >&2 brew install bash || true fi for _b in /opt/homebrew/bin/bash /usr/local/bin/bash; do _v="$(_oli_bash_major "$_b")" if [ -x "$_b" ] && [ -n "$_v" ] && [ "$_v" -ge 4 ]; then _oli_bash="$_b"; break; fi done fi if [ -z "$_oli_bash" ]; then echo "This installer needs bash 4 or newer (you have ${BASH_VERSION:-unknown})." >&2 echo "Install a newer bash (e.g. 'brew install bash') and re-run." >&2 exit 1 fi # 3. Re-exec under the newer bash. Reuse the file if we were run from one; # otherwise (e.g. bash <(curl ...)) re-download to a temp file, since a # process-substitution pipe cannot be re-read. if [ -f "$0" ] && [ -r "$0" ]; then exec "$_oli_bash" "$0" "$@" else _oli_tmp="$(mktemp "${TMPDIR:-/tmp}/one-line-installer.XXXXXX")" if ! curl -fsSL "$_oli_url" -o "$_oli_tmp"; then echo "Failed to download the installer from $_oli_url" >&2 exit 1 fi exec "$_oli_bash" "$_oli_tmp" "$@" fi fi # src/lib/core/01_constants.sh # Shared constants. Loaded right after the bash-version shim (00_require_bash), # so keep this bash-3.2-safe (plain string assignments only). # Canonical URL of the installer (published to GitLab Pages on the default branch). Mirrors the fallback in # lib/core/00_require_bash.sh (which runs before this file and so can't reuse # it) — keep the two in sync. Overridable via the INSTALLER_URL env var. INSTALLER_URL="${INSTALLER_URL:-https://project.pages.drupalcode.org/one_line_installer/drupalaibp}" # Name of the global command created by the `install` command. GLOBAL_CMD="drupalaibp" # Where the global install keeps its state. CONFIG_DIR holds the saved JSON # configs used by the "Use a config" run mode (created by `install`). CONFIG_ROOT="${HOME}/.drupalaibp" CONFIG_DIR="${CONFIG_ROOT}/configs" # How the installer was invoked (set by detect_invocation): remote|global|local. INVOCATION="" # Reserved file name inside a source's .drupalaibp/ directory. It is NOT an # installer config — it lists configs that live in OTHER repositories (see # lib/config/_remote_configs_read.sh), so every place that globs # .drupalaibp/*.json for configs must skip it. REMOTE_CONFIGS_FILE="remote-configs.json" # src/lib/core/_menu_read_key.sh # Read one keypress from the terminal and print a logical name: # up, down, space, enter, or other. Used by select_one / select_many. _menu_read_key() { local k rest IFS= read -rsn1 k /dev/null || true case "$rest" in '[A' | 'OA') printf 'up' ;; '[B' | 'OB') printf 'down' ;; *) printf 'other' ;; esac ;; *) printf 'other' ;; esac } # src/lib/core/_menu_viewport.sh MENU_CHROME_LINES=3 _menu_term_height() { local h="" h="$(stty size 2>/dev/null local text="$1" reserve="$2" max max=$(( $(_menu_term_width) - reserve )) if (( max < 10 )); then max=10; fi if (( ${#text} > max )); then printf '%s…' "${text:0:max - 1}" else printf '%s' "$text" fi } _menu_wrap() { # local text="$1" width="$2" [[ -n "$text" ]] || return 0 if (( width < 1 )); then width=1; fi local -a words read -r -a words <<< "$text" local line="" word for word in "${words[@]}"; do while (( ${#word} > width )); do printf '%s\n' "${word:0:width}" word="${word:width}" done if [[ -z "$line" ]]; then line="$word" elif (( ${#line} + 1 + ${#word} <= width )); then line="$line $word" else printf '%s\n' "$line" line="$word" fi done [[ -n "$line" ]] && printf '%s\n' "$line" } _menu_viewport() { # local n="$1" avail view avail=$(( $(_menu_term_height) - MENU_CHROME_LINES )) # A terminal this short can't show a usable menu either way; keep it sane # rather than compute a negative window. if (( avail < 5 )); then avail=5; fi if (( n <= avail )); then MENU_VIEW="$n" MENU_CLIPPED=0 else view=$(( avail - 2 )) # the two indicator lines live inside the block if (( view < 3 )); then view=3; fi MENU_VIEW="$view" MENU_CLIPPED=1 fi MENU_OFFSET=0 } _menu_scroll() { # local cur="$1" n="$2" max if (( MENU_VIEW >= n )); then MENU_OFFSET=0 return 0 fi if (( cur < MENU_OFFSET )); then MENU_OFFSET="$cur"; fi if (( cur >= MENU_OFFSET + MENU_VIEW )); then MENU_OFFSET=$(( cur - MENU_VIEW + 1 )); fi max=$(( n - MENU_VIEW )) if (( MENU_OFFSET > max )); then MENU_OFFSET="$max"; fi if (( MENU_OFFSET < 0 )); then MENU_OFFSET=0; fi return 0 } _menu_more_line() { # local arrow="$1" count="$2" printf '\r\033[2K' if (( count > 0 )); then printf ' %b%s %d more%b\n' "${DIM}" "$arrow" "$count" "${RESET}" else printf '\n' fi } # src/lib/core/ask.sh declare -gA ANSWERS=() ask() { local key="$1" label="$2" type="$3" default="${4-}" shift 3 [[ $# -gt 0 ]] && shift # drop the default; the rest are options local -a options=( "$@" ) [[ "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || die "ask: '${key}' is not a valid env var name" [[ -n "${ANSWERS[$key]+x}" ]] && return 0 local reply i case "$type" in text) if [[ "${ASK_UNATTENDED:-0}" -eq 1 ]]; then ANSWERS[$key]="$default"; return 0; fi echo -e "${BOLD}${label}${RESET}${default:+ ${DIM}[default: ${default}]${RESET}}:" read -r reply /dev/null; then warn "No terminal to ask for '${label}' — leaving ${key} empty." ANSWERS[$key]="" return 0 fi echo -e "${BOLD}${label}${RESET} ${DIM}(input hidden)${RESET}:" read -rs reply . # local — running from some other real file (e.g. a git clone, or the temp # copy the bash>=4 bootstrap re-downloads on old macOS). # # The distinction is drawn from $0: a curl/wget pipe leaves $0 as "bash"/stdin, # and process substitution leaves it as /dev/fd/NN (a drained pipe, not # re-readable), whereas an installed or cloned copy is a real file on disk. detect_invocation() { local self="$0" case "$self" in /dev/fd/*|/proc/*/fd/*|bash|-bash|sh|-sh|"") INVOCATION="remote" ;; *) if [ -f "$self" ] && [ -r "$self" ]; then if [ "$self" = "$HOME/.local/bin/$GLOBAL_CMD" ]; then INVOCATION="global" else INVOCATION="local" fi else INVOCATION="remote" fi ;; esac } # src/lib/core/events.sh _EVENT_HANDLERS=() _EVENT_SEQ=0 on() { local event="$1" weight="$2"; shift 2 [[ "$weight" =~ ^-?[0-9]+$ ]] || die "on: weight must be an integer (got '${weight}')" _EVENT_HANDLERS+=("${event}|${weight}|${_EVENT_SEQ}|$*") _EVENT_SEQ=$(( _EVENT_SEQ + 1 )) } emit() { local event="$1"; shift || true local entry local -a matches=() for entry in ${_EVENT_HANDLERS[@]+"${_EVENT_HANDLERS[@]}"}; do if [[ "${entry%%|*}" == "$event" ]]; then matches+=("$entry"); fi done [[ ${#matches[@]} -eq 0 ]] && return 0 # Sort into an array (not a `while read` fed by process substitution) so a # handler that reads/peeks at its own stdin (e.g. an npm/npx subprocess # probing for a TTY) can never steal the next handler's line from the loop's # input stream — that silently dropped later handlers (e.g. a config's # second `ddev_started` hook) with no warning. local -a sorted mapfile -t sorted < <(printf '%s\n' "${matches[@]}" | sort -t'|' -k2,2n -k3,3n) # _ev/_seq are read only to consume their fields. # shellcheck disable=SC2034 local _ev weight _seq handler line for line in "${sorted[@]}"; do IFS='|' read -r _ev weight _seq handler <<< "$line" [[ -n "$handler" ]] || continue if [[ "${DRUPALAIBP_DEBUG:-0}" == "1" ]]; then echo -e "${DIM}[event] ${event} w=${weight} -> ${handler}${RESET}" >&2 fi # The handler string may carry its own args, so it must word-split. # shellcheck disable=SC2086 if ! $handler "$@"; then warn "Handler '${handler}' for event '${event}' failed — continuing." fi done return 0 } # src/lib/core/helpers.sh step() { echo -e "\n${BOLD}$1${RESET}"; } ok() { echo -e "${GREEN}✓ $1${RESET}"; } warn() { echo -e "${YELLOW}⚠ $1${RESET}"; } die() { echo -e "${RED}✗ $1${RESET}" >&2; exit 1; } # src/lib/core/json.sh declare -gA JSON_DATA=() JSON_TYPE=() json_load() { local file="$1" JSON_DATA=(); JSON_TYPE=() [[ -f "$file" ]] || die "Config file not found: ${file}" _JSON_TEXT="$(cat "$file")" _JSON_POS=0 _JSON_LEN=${#_JSON_TEXT} _JSON_FILE="$file" _json_ws _json_value "" _json_ws (( _JSON_POS >= _JSON_LEN )) || _json_die "trailing content" return 0 } _json_die() { die "Malformed JSON in ${_JSON_FILE} at offset ${_JSON_POS}: $1"; } _json_ws() { while (( _JSON_POS < _JSON_LEN )); do case "${_JSON_TEXT:_JSON_POS:1}" in ' '|$'\t'|$'\n'|$'\r') _JSON_POS=$(( _JSON_POS + 1 )) ;; *) return 0 ;; esac done return 0 } _json_store() { # local path="${1:-.}" JSON_DATA["$path"]="$2" JSON_TYPE["$path"]="$3" } _json_value() { # $1 = dotted path prefix ("" = root) local prefix="$1" _json_ws (( _JSON_POS < _JSON_LEN )) || _json_die "unexpected end of input" case "${_JSON_TEXT:_JSON_POS:1}" in '{') _json_object "$prefix" ;; '[') _json_array "$prefix" ;; '"') _json_string; _json_store "$prefix" "$_JSON_STR" string ;; t) _json_literal true "$prefix" bool ;; f) _json_literal false "$prefix" bool ;; n) _json_literal null "$prefix" null ;; *) _json_number "$prefix" ;; esac } _json_literal() { # local lit="$1" path="$2" type="$3" [[ "${_JSON_TEXT:_JSON_POS:${#lit}}" == "$lit" ]] || _json_die "invalid literal" _JSON_POS=$(( _JSON_POS + ${#lit} )) _json_store "$path" "$lit" "$type" } _json_number() { local rest="${_JSON_TEXT:_JSON_POS}" if [[ "$rest" =~ ^(-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?) ]]; then _json_store "$1" "${BASH_REMATCH[1]}" number _JSON_POS=$(( _JSON_POS + ${#BASH_REMATCH[1]} )) else _json_die "invalid value" fi } # SC1003: '\' below is JSON's escape character, quoted literally on purpose. # shellcheck disable=SC1003 _json_string() { # consumes a quoted string, result in _JSON_STR _JSON_POS=$(( _JSON_POS + 1 )) # opening quote local out="" c hex while (( _JSON_POS < _JSON_LEN )); do c="${_JSON_TEXT:_JSON_POS:1}" if [[ "$c" == '"' ]]; then _JSON_POS=$(( _JSON_POS + 1 )); _JSON_STR="$out"; return 0 elif [[ "$c" == '\' ]]; then _JSON_POS=$(( _JSON_POS + 1 )) c="${_JSON_TEXT:_JSON_POS:1}" case "$c" in n) out+=$'\n' ;; t) out+=$'\t' ;; r) out+=$'\r' ;; b) out+=$'\b' ;; f) out+=$'\f' ;; u) hex="${_JSON_TEXT:$(( _JSON_POS + 1 )):4}" # The \u escape IS the format string here — that's the point. # shellcheck disable=SC2059 printf -v c "\\u${hex}" 2>/dev/null || c="?" out+="$c" _JSON_POS=$(( _JSON_POS + 4 )) ;; *) out+="$c" ;; # ", \, / — and be lenient about the rest esac _JSON_POS=$(( _JSON_POS + 1 )) else out+="$c" _JSON_POS=$(( _JSON_POS + 1 )) fi done _json_die "unterminated string" } _json_object() { local prefix="$1" key path _json_store "$prefix" "" object _JSON_POS=$(( _JSON_POS + 1 )) # consume { _json_ws if [[ "${_JSON_TEXT:_JSON_POS:1}" == '}' ]]; then _JSON_POS=$(( _JSON_POS + 1 )); return 0 fi while true; do _json_ws [[ "${_JSON_TEXT:_JSON_POS:1}" == '"' ]] || _json_die "expected object key" _json_string; key="$_JSON_STR" _json_ws [[ "${_JSON_TEXT:_JSON_POS:1}" == ':' ]] || _json_die "expected ':'" _JSON_POS=$(( _JSON_POS + 1 )) path="${prefix:+${prefix}.}${key}" _json_value "$path" _json_ws case "${_JSON_TEXT:_JSON_POS:1}" in ',') _JSON_POS=$(( _JSON_POS + 1 )) ;; '}') _JSON_POS=$(( _JSON_POS + 1 )); return 0 ;; *) _json_die "expected ',' or '}'" ;; esac done } _json_array() { local prefix="$1" i=0 _json_store "$prefix" "" array _JSON_POS=$(( _JSON_POS + 1 )) # consume [ _json_ws if [[ "${_JSON_TEXT:_JSON_POS:1}" == ']' ]]; then _JSON_POS=$(( _JSON_POS + 1 )) JSON_DATA["${prefix:-.}#"]=0 return 0 fi while true; do _json_value "${prefix:+${prefix}.}${i}" i=$(( i + 1 )) _json_ws case "${_JSON_TEXT:_JSON_POS:1}" in ',') _JSON_POS=$(( _JSON_POS + 1 )) ;; ']') _JSON_POS=$(( _JSON_POS + 1 )); JSON_DATA["${prefix:-.}#"]="$i"; return 0 ;; *) _json_die "expected ',' or ']'" ;; esac done } json_has() { [[ -n "${JSON_TYPE[$1]+x}" ]]; } json_get() { printf '%s' "${JSON_DATA[$1]-${2-}}"; } json_type() { printf '%s' "${JSON_TYPE[$1]-}"; } json_count() { printf '%s' "${JSON_DATA[${1}#]:-0}"; } # src/lib/core/pipeline.sh PIPELINE=( ensure_ddev # 1. ddev (+docker) on the host — the only host deps collect_answers # 2. every question up front (built-in + config) create_project_dir # 3. create the dir, cd in configure_ddev # 4. ddev config --project-type=$DDEV_PROJECT_TYPE answers_to_env # 5. config-question answers -> .ddev/.env install_coding_agents # 6. agent add-ons (.ddev-only, safe pre-start); emits coding_agent_added start_ddev # 7. single vanilla start; emits before_ddev_start / ddev_started create_drupal_site # 8. (is_drupal) create-project into the clean dir + stability/allow_plugins + drush si; no-op in existing-codebase config-url mode provision_existing_codebase # 9. (is_drupal, existing-codebase config-url mode) composer install + ddev import-db in place of create-project/drush si setup_core_dev # 10. (is_drupal) built-in dev defaults: core-dev + ai_best_practices + phpunit.xml install_extras # 11. (is_drupal) chosen extra Drupal installations (src/lib/extras/); emits extra_installed install_ddev_addons # 12. config ddev_addons AFTER create-project (may drop files outside .ddev/); emits ddev_addon_added restart_ddev # 13. one restart to apply the config add-ons' services/settings provision_from_config # 14. repositories -> composer_projects -> install_modules -> recipes import_config # 15. (is_drupal) drush config:import from .drupalaibp/config/ (partial) install_skills # 16. skills via npx skills; emits skill_added write_agents_md_block # 17. write_agent_denylist # 18. link_claude_skills # 19. export_drupal_config # 20. (is_drupal) github_finalize # 21. (GH_ENABLED=1) announce_complete # 22. launch_site # 23. detached browser open agent_handoff # 24. hand the shell over ) run_pipeline() { local step_name for step_name in "${PIPELINE[@]}"; do emit "before_${step_name}" "step_${step_name}" emit "${step_name}_done" done } # src/lib/core/project_help.sh _print_project_questions_help() { local cfg cfg="$(_first_local_config)" [[ -n "$cfg" ]] || return 0 json_load "$cfg" 2>/dev/null || return 0 _config_read_questions [[ ${#CONFIG_QUESTION_KEYS[@]} -eq 0 ]] && return 0 local i printf '\n%bPROJECT QUESTIONS%b (from ./.drupalaibp/ — answers land in .ddev/.env):\n' "${BOLD}" "${RESET}" for i in "${!CONFIG_QUESTION_KEYS[@]}"; do printf ' %b%s%b [%s]\n' "${GREEN}" "${CONFIG_QUESTION_KEYS[$i]}" "${RESET}" "${CONFIG_QUESTION_TYPES[$i]}" [[ -n "${CONFIG_QUESTION_LABELS[$i]}" ]] && printf ' %s\n' "${CONFIG_QUESTION_LABELS[$i]}" [[ -n "${CONFIG_QUESTION_OPTIONS[$i]}" ]] && printf ' choices: %s\n' "${CONFIG_QUESTION_OPTIONS[$i]}" if [[ "${CONFIG_QUESTION_HASVALUE[$i]}" -eq 1 ]]; then printf ' pre-answered (value): %s\n' "${CONFIG_QUESTION_VALUES[$i]}" elif [[ -n "${CONFIG_QUESTION_DEFAULTS[$i]}" ]]; then printf ' default: %s\n' "${CONFIG_QUESTION_DEFAULTS[$i]}" fi done return 0 } # src/lib/core/run_install.sh run_install() { detect_project_context case "$PROJECT_CONTEXT" in ddev) step_ensure_ddev manage_existing return 0 ;; composer-no-ddev) require_ddev_message # prints DDEV links and exits 0 ;; esac run_pipeline } # src/lib/core/select_many.sh # Interactive multi-choice menu. Usage: # select_many "Prompt" "Option A" "Option B" ... # Move with ↑/↓ (or j/k), toggle with Space, confirm with Enter. Sets # MENU_SELECTED to a space-separated list of the 0-based chosen options (may be # empty). Reads keys from /dev/tty; falls back to no selection when no terminal # is available. # # A list taller than the terminal scrolls inside a fixed window (_menu_viewport): # only MENU_VIEW rows are painted, MENU_OFFSET follows the cursor, and "N more" # lines mark what's clipped. Painting the whole list instead would scroll the # screen and break the repaint's cursor-up jump — see _menu_viewport.sh. select_many() { local prompt="$1"; shift local -a options=("$@") local n=${#options[@]} local cur=0 key i first=1 local -a chosen for (( i = 0; i < n; i++ )); do chosen[i]=0; done # Optional preselection: MENU_PRESELECT is a space-separated list of indices to # start checked. Consumed (reset) here so it can't leak into the next call. # shellcheck disable=SC2086 for i in ${MENU_PRESELECT:-}; do chosen[i]=1; done MENU_PRESELECT="" printf '%b\n' "${BOLD}${prompt}${RESET}" printf '%b\n' "${DIM} ↑/↓ move · ${RESET}${BOLD}SPACE to select (tick [x])${RESET}${DIM} · enter to confirm${RESET}" # Hide the cursor, and turn OFF line wrap for the duration: a row wider than # the terminal must be clipped, never wrapped onto a second physical line (the # repaint counts options, not physical rows). _menu_fit truncates to the same # end; this is the belt to its braces. printf '\033[?25l\033[?7l' _menu_viewport "$n" local block last label while true; do _menu_scroll "$cur" "$n" block="$MENU_VIEW" if [ "$MENU_CLIPPED" -eq 1 ]; then block=$(( MENU_VIEW + 2 )); fi if [ "$first" -eq 1 ]; then first=0; else printf '\033[%dA' "$block"; fi if [ "$MENU_CLIPPED" -eq 1 ]; then _menu_more_line "↑" "$MENU_OFFSET"; fi last=$(( MENU_OFFSET + MENU_VIEW )) for (( i = MENU_OFFSET; i < last; i++ )); do local mark="[ ]" [ "${chosen[$i]}" -eq 1 ] && mark="[x]" # Row prefix is " ❯ [x] " (8 columns) — reserve it plus a spare column. label="$(_menu_fit "${options[$i]}" 9)" printf '\r\033[2K' if [ "$i" -eq "$cur" ]; then printf ' %b❯ %s %s%b\n' "${GREEN}${BOLD}" "$mark" "$label" "$RESET" else printf ' %s %s\n' "$mark" "$label" fi done if [ "$MENU_CLIPPED" -eq 1 ]; then _menu_more_line "↓" "$(( n - last ))"; fi key="$(_menu_read_key || printf enter)" case "$key" in up) cur=$(( (cur - 1 + n) % n )) ;; down) cur=$(( (cur + 1) % n )) ;; space) chosen[cur]=$(( 1 - chosen[cur] )) ;; enter) break ;; esac done printf '\033[?25h\033[?7h' # cursor back, line wrap back on MENU_SELECTED="" for (( i = 0; i < n; i++ )); do [ "${chosen[$i]}" -eq 1 ] && MENU_SELECTED="$MENU_SELECTED $i" done MENU_SELECTED="${MENU_SELECTED# }" } # src/lib/core/select_one.sh # Interactive single-choice menu. Usage: # select_one "Prompt" "Option A" "Option B" ... # Move with ↑/↓ (or j/k), confirm with Enter. Sets MENU_INDEX to the 0-based # chosen option. Reads keys from /dev/tty; falls back to the pre-selected option # (default the first) when no terminal is available. # # Set MENU_DEFAULT (0-based) before calling to pre-highlight an option — used for # yes/no radios so the prompt's default is the highlighted row and a bare Enter # (or no-terminal fallback) picks it. It is reset after each call. # # Set MENU_DETAILS (array, one entry per option, "" for none) before calling to # show a detail panel below the list holding the HIGHLIGHTED option's full text, # word-wrapped — for text too long to fit the single-row option label (see # _menu_fit, which still truncates the row itself). The panel's row budget is # computed ONCE, from the tallest wrapped entry in MENU_DETAILS, so the block # height stays constant across every repaint regardless of which option is # highlighted — required by the cursor-up jump below. That budget is also # CAPPED to whatever space remains after the option block + chrome (computed # from the terminal height, same clamp-not-overflow philosophy _menu_fit # applies to a too-wide label), so the panel can never push the painted block # past the terminal — a description whose wrapped text still doesn't fit is cut # off with a trailing " …" on its last visible line, never silently dropped. # Reset (unset) after each call, same as MENU_DEFAULT. Omit it entirely for the # old, panel-less behavior. # # A list taller than the terminal scrolls inside a fixed window (_menu_viewport): # only MENU_VIEW rows are painted, MENU_OFFSET follows the cursor, and "N more" # lines mark what's clipped. Painting the whole list instead would scroll the # screen and break the repaint's cursor-up jump — see _menu_viewport.sh. select_one() { local prompt="$1"; shift local -a options=("$@") local n=${#options[@]} local cur=${MENU_DEFAULT:-0} key i first=1 MENU_DEFAULT=0 local -a details=() local has_details=0 detail_rows=0 local -a __menu_details_snapshot=("${MENU_DETAILS[@]+"${MENU_DETAILS[@]}"}") if [ "${#__menu_details_snapshot[@]}" -gt 0 ]; then details=("${__menu_details_snapshot[@]}") has_details=1 fi MENU_DETAILS=() [[ "$cur" -ge 0 && "$cur" -lt "$n" ]] || cur=0 printf '%b\n' "${BOLD}${prompt}${RESET}" printf '%b\n' "${DIM} ↑/↓ move · enter select${RESET}" # Hide the cursor, and turn OFF line wrap for the duration: a row wider than # the terminal must be clipped, never wrapped onto a second physical line (the # repaint counts options, not physical rows). _menu_fit truncates to the same # end; this is the belt to its braces. printf '\033[?25l\033[?7l' _menu_viewport "$n" if [ "$has_details" -eq 1 ]; then local term_width d lines uncapped=0 term_width="$(_menu_term_width)" for d in "${details[@]}"; do [[ -n "$d" ]] || continue lines="$(_menu_wrap "$d" "$term_width" | wc -l)" (( lines > uncapped )) && uncapped="$lines" done # Cap the panel to whatever is left after the option block + chrome, so # the total painted block (see `block` below) can never exceed the # terminal height — same philosophy as _menu_fit truncating a label # that's too wide for one row, applied to "too tall" instead of "too # wide". Spoken for already: MENU_VIEW option rows, +2 if clipped (the # "N more" indicators), + MENU_CHROME_LINES (prompt + hint + spare row). # +1 more for the blank separator line the panel prints before itself. local spoken_for budget spoken_for=$(( MENU_VIEW + MENU_CHROME_LINES )) if [ "$MENU_CLIPPED" -eq 1 ]; then spoken_for=$(( spoken_for + 2 )); fi budget=$(( $(_menu_term_height) - spoken_for - 1 )) if (( budget < 1 )); then budget=1; fi detail_rows="$uncapped" if (( detail_rows > budget )); then detail_rows="$budget"; fi fi local block last label while true; do _menu_scroll "$cur" "$n" block="$MENU_VIEW" if [ "$MENU_CLIPPED" -eq 1 ]; then block=$(( block + 2 )); fi if [ "$has_details" -eq 1 ]; then block=$(( block + 1 + detail_rows )); fi if [ "$first" -eq 1 ]; then first=0; else printf '\033[%dA' "$block"; fi if [ "$MENU_CLIPPED" -eq 1 ]; then _menu_more_line "↑" "$MENU_OFFSET"; fi last=$(( MENU_OFFSET + MENU_VIEW )) for (( i = MENU_OFFSET; i < last; i++ )); do # Row prefix is " ❯ " (4 columns) — reserve it plus a spare column. label="$(_menu_fit "${options[$i]}" 5)" printf '\r\033[2K' if [ "$i" -eq "$cur" ]; then printf ' %b❯ %s%b\n' "${GREEN}${BOLD}" "$label" "$RESET" else printf ' %s\n' "$label" fi done if [ "$MENU_CLIPPED" -eq 1 ]; then _menu_more_line "↓" "$(( n - last ))"; fi if [ "$has_details" -eq 1 ]; then printf '\r\033[2K\n' local -a wrapped=() if [[ -n "${details[$cur]:-}" ]]; then while IFS= read -r label; do wrapped+=("$label"); done < <(_menu_wrap "${details[$cur]}" "$(_menu_term_width)") fi # More wrapped lines than the (possibly capped) row budget: this # option's own description got clamped. Drop the excess and mark the # last visible row with an ellipsis so the cut is visible, never silent. if [ "${#wrapped[@]}" -gt "$detail_rows" ] && [ "$detail_rows" -gt 0 ]; then wrapped=("${wrapped[@]:0:detail_rows}") local last_idx=$(( detail_rows - 1 )) wrapped[last_idx]="${wrapped[$last_idx]} …" fi for (( i = 0; i < detail_rows; i++ )); do printf '\r\033[2K' if [ "$i" -lt "${#wrapped[@]}" ]; then printf ' %b%s%b\n' "${DIM}" "${wrapped[$i]}" "${RESET}" else printf '\n' fi done fi key="$(_menu_read_key || printf enter)" case "$key" in up) cur=$(( (cur - 1 + n) % n )) ;; down) cur=$(( (cur + 1) % n )) ;; enter) break ;; esac done printf '\033[?25h\033[?7h' # cursor back, line wrap back on MENU_INDEX=$cur } # src/lib/data/agents_md_config.sh _agents_md_default_json() { cat <<'AGENTSJSON' { "base": [ "This project runs in DDEV. Open it in a browser from your machine at {{url}} . Inside the DDEV container the site's hostname is `web` — that is internal only, not a URL you open in a browser.", "Running commands: if you are on the host (outside the container) you must run everything through DDEV — `ddev drush …`, `ddev composer …`, `ddev php …`, `ddev exec …`, `ddev npm …`. If you are already inside the container (via `ddev ssh` or a `ddev ` command), run `drush`, `composer`, etc. directly.", "Secrets (API keys, tokens, passwords): never hard-code or commit a secret — always store the value in an environment variable. Save it with DDEV's built-in dotenv command: `ddev dotenv set .ddev/.env --openai-api-key=` (the flag `--openai-api-key` becomes the variable `OPENAI_API_KEY`; never commit .ddev/.env — keep it out of version control), then `ddev restart` so DDEV loads it into the web container." ], "drupal_base": [ "This is a Drupal site running in DDEV. Admin login: admin / admin.", "To consume a secret from Drupal, prefer a Key entity where the module supports one (e.g. AI provider keys): install the Key module if it isn't enabled (`ddev composer require drupal/key` and `ddev drush en key -y`), confirm the variable is present in the container WITHOUT reading its value (`ddev exec 'test -n \"$OPENAI_API_KEY\"'` — exit status 0 means it is set), then create the Key with the built-in env provider: `ddev drush key:save openai_api_key --label='OpenAI API Key' --key-type=authentication --key-provider=env --key-provider-settings='{\"env_variable\":\"OPENAI_API_KEY\",\"base64_encoded\":false,\"strip_line_breaks\":true}' --key-input=none -y`. Where a Key entity does not apply, reference the variable directly from settings.php via `getenv('OPENAI_API_KEY')`." ], "skills": [ { "match": "agent-browser", "text": "When you are uncertain how to solve something or you need a browser to solve it, you may start a browser using the agent-browser skill." }, { "match": "drupal-module-finder", "text": "Before writing any code or making a decision based on your own knowledge, use the drupal-module-finder skill to search for a contributed module that already solves the task. Prefer modules that are covered by the security advisory policy (have had a security review) and are actively maintained. Never install a module without asking the user first." } ] } AGENTSJSON } # src/lib/defaults/selenium_addon.sh _selenium_headed_rewrite() { local addon="$1" [[ "$addon" == *selenium-standalone-chrome* ]] || return 0 local cfg=".ddev/config.selenium-standalone-chrome.yaml" [[ -f "$cfg" ]] || { warn "Selenium config not found yet (${cfg}); leaving defaults."; return 0; } step "Configuring Selenium for headed Chrome + noVNC..." # Replace the whole MINK line (its value is escaped JSON, so an in-place sed on # \"--headless\" never matches). Passed via env so awk keeps the backslashes. local mink=' - MINK_DRIVER_ARGS_WEBDRIVER=[\"chrome\", {\"browserName\":\"chrome\",\"goog:chromeOptions\":{\"w3c\":true,\"args\":[\"--disable-dev-shm-usage\",\"--disable-gpu\",\"--dns-prefetch-disable\",\"--window-size=1920,1080\",\"--start-fullscreen\"]}}, \"http://selenium-chrome:4444/wd/hub\"]' MINK_REPL="$mink" awk '/^ - MINK_DRIVER_ARGS_WEBDRIVER=/{print ENVIRON["MINK_REPL"]; next} {print}' "$cfg" > "${cfg}.tmp" && mv "${cfg}.tmp" "$cfg" if ! grep -q 'window-size=1920,1080' "$cfg"; then warn "Could not set headed Chrome options — edit MINK_DRIVER_ARGS_WEBDRIVER in ${cfg} by hand." fi return 0 } # src/lib/delete/delete_project.sh # Delete a DDEV project. If the current directory is itself a DDEV project, offer # to delete this one (confirm, `ddev delete -Oy`, then `cd ..` and remove the # directory). Otherwise fall back to listing DDEV projects running in immediate # subdirectories and deleting the chosen one. Always requires confirmation. delete_project() { # Case 1: we're standing in a DDEV project — delete this one. if [[ -f ".ddev/config.yaml" ]]; then local cur name cur="$(pwd)" name="$(awk -F': *' '/^name:/{print $2; exit}' ".ddev/config.yaml" 2>/dev/null)" name="${name:-$(basename "$cur")}" warn "This permanently deletes the DDEV project and this directory:" echo -e " ${BOLD}${cur}${RESET}" MENU_DEFAULT=1 # default: No (safe) select_one "Delete '${name}'? This cannot be undone." \ "Yes — delete it permanently" \ "No — cancel" [[ $MENU_INDEX -eq 0 ]] || die "Aborted. Nothing was deleted." step "Deleting DDEV project '${name}'..." ddev delete -Oy step "Removing directory '${cur}'..." cd .. || die "Could not leave the project directory; nothing was removed." rm -rf "$cur" ok "Deleted '${name}'." return fi # Case 2: pick a running DDEV project from an immediate subdirectory. local dirs=() d name target for d in */; do d="${d%/}" [[ -f "$d/.ddev/config.yaml" ]] || continue name="$(awk -F': *' '/^name:/{print $2; exit}' "$d/.ddev/config.yaml" 2>/dev/null)" name="${name:-$d}" # Only offer projects whose web container is currently running. if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "ddev-${name}-web"; then dirs+=("$d") fi done [[ ${#dirs[@]} -eq 0 ]] && die "No DDEV project here, and none running in subdirectories of $(pwd)." select_one "Select a project to delete (in $(pwd)):" "${dirs[@]}" target="${dirs[$MENU_INDEX]}" warn "This permanently deletes the DDEV project and the directory:" echo -e " ${BOLD}$(pwd)/${target}${RESET}" MENU_DEFAULT=1 # default: No (safe) select_one "Delete '${target}'? This cannot be undone." \ "Yes — delete it permanently" \ "No — cancel" [[ $MENU_INDEX -eq 0 ]] || die "Aborted. Nothing was deleted." step "Deleting DDEV project in '${target}'..." ( cd "$target" && ddev delete -Oy ) step "Removing directory '${target}'..." rm -rf "$target" ok "Deleted '${target}'." } # src/lib/extras/_extras_discover.sh _extras_discover() { local fn id meta name desc flag while IFS= read -r fn; do id="${fn#extra_}"; id="${id%_meta}" meta="$("$fn")" || continue IFS='|' read -r name desc flag <<< "$meta" [[ -n "$name" ]] || continue if [[ "$flag" == "default" ]]; then flag=1; else flag=0; fi printf '%s|%s|%s|%s\n' "$id" "$name" "$desc" "$flag" done < <(compgen -A function | grep '^extra_.*_meta$' | LC_ALL=C sort) } _extras_ids() { local line ids="" while IFS= read -r line; do ids+=" ${line%%|*}"; done < <(_extras_discover) printf '%s' "${ids# }" } _extras_default_ids() { local id name desc flag ids="" while IFS='|' read -r id name desc flag; do if [[ "$flag" == "1" ]]; then ids+=" $id"; fi done < <(_extras_discover) printf '%s' "${ids# }" } # src/lib/extras/extra_agent_ready_drupal_build_kit.sh extra_agent_ready_drupal_build_kit_meta() { printf '%s|%s\n' \ "Agent-Ready Drupal Build Kit" \ "agents rebuild an existing site as a reviewable Drupal CMS build — requires the Drupal CMS profile" } _build_kit_skipped_banner() { echo -e "${BOLD}${RED}" echo ' ███████ ██ ██ ██ ██████ ██████ ███████ ██████' echo ' ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██' echo ' ███████ █████ ██ ██████ ██████ █████ ██ ██' echo ' ██ ██ ██ ██ ██ ██ ██ ██ ██' echo ' ███████ ██ ██ ██ ██ ██ ███████ ██████' echo echo " The Agent-Ready Drupal Build Kit was NOT installed." echo echo " It only works on a Drupal CMS site, and Drupal CMS was not picked —" echo " this build used the '${PROFILE:-unknown}' profile." echo echo " To use the kit, build a site with the Drupal CMS profile (the default):" echo " drupalaibp --profile cms --extras agent_ready_drupal_build_kit" echo -e "${RESET}" } extra_agent_ready_drupal_build_kit() { # cms and template both scaffold drupal/cms; standard/minimal do not. if [[ "${PROFILE:-}" != "cms" && "${PROFILE:-}" != "template" ]]; then _build_kit_skipped_banner return 1 fi step "Installing the Agent-Ready Drupal Build Kit..." # Single-quoted through the container shell so nothing globs on the way in. local cmd t cmd="npx --yes skills add 'https://github.com/scottfalconer/agent-ready-drupal-build-kit'" cmd="${cmd} --skill 'agent-ready-drupal-build-kit'" for t in $(_skill_agent_targets); do cmd="${cmd} -a ${t}"; done cmd="${cmd} -y --copy" if ! ddev exec bash -c "$cmd"; then warn "Could not add the agent-ready-drupal-build-kit skill — skipping the build kit." return 1 fi AGENTS_MD+=("This site has the Agent-Ready Drupal Build Kit skill installed. Use it when asked to rebuild an existing website as this Drupal CMS site — it defines the content modelling, verification and review-packet workflow to follow.") ok "Agent-Ready Drupal Build Kit installed — start your agent (e.g. 'ddev claude') and ask it to rebuild a site." } # src/lib/extras/extra_droost.sh extra_droost_meta() { printf '%s|%s\n' \ "Droost (experimental)" \ "lets AI agents build inside the site over MCP — https://droost.org/get-started" } extra_droost() { step "Installing the experimental Droost (AI agents over MCP)..." if ! ddev exec composer require --dev drupal/droost drupal/mcp_server:^2.0@alpha --no-interaction; then warn "Could not require drupal/droost + drupal/mcp_server — skipping Droost." return 1 fi ddev drush en -y droost \ || warn "Could not enable droost — check 'ddev drush pm:list | grep droost'." ddev drush -y droost:install \ || warn "'drush droost:install' failed — run it yourself and see https://droost.org/get-started." ok "Droost installed — see https://droost.org/get-started for what your agent can now do." } # src/lib/extras/extra_dtk.sh extra_dtk_meta() { printf '%s|%s|%s\n' \ "Drush Token Killer" \ "compacts drush output so agents spend far fewer tokens reading it" \ "default" } extra_dtk() { step "Installing the Drush Token Killer (dtk)..." if ! ddev exec composer require drupal/dtk:^1.0 --no-interaction; then warn "Could not require drupal/dtk — skipping the Drush Token Killer." return 1 fi ddev drush en -y dtk \ || warn "Could not enable dtk — check 'ddev drush pm:list | grep dtk'." local agent for agent in ${AGENTS[@]+"${AGENTS[@]}"}; do step "Hooking dtk into ${agent}..." ddev drush -y dtk:install "$agent" \ || warn "'drush dtk:install ${agent}' failed — run it yourself to hook dtk into ${agent}." done if [[ ${#AGENTS[@]} -eq 0 ]]; then warn "No coding agent chosen — run 'ddev drush dtk:install ' to hook dtk into yours." fi ok "Drush Token Killer installed — drush output is now compacted for your agents." } # src/lib/extras/extra_glab.sh _GLAB_VERSION="1.64.0" extra_glab_meta() { printf '%s|%s\n' \ "GitLab CLI (glab) for drupal.org" \ "install glab in the web container and authenticate to git.drupalcode.org with a Personal Access Token" } _glab_read_token() { local t="" read -rs t /dev/null || t="" printf '%s' "$t" } extra_glab() { if [[ "${ASK_UNATTENDED:-0}" -eq 1 ]]; then warn "The glab extra needs an interactive run to paste a token — skipping (run without --yolo/config to authenticate)." return 1 fi step "Installing the GitLab CLI (glab) for drupal.org..." echo -e "${BOLD}Create a Personal Access Token for drupal.org's GitLab:${RESET}" echo " https://git.drupalcode.org/-/user_settings/personal_access_tokens?page=1&state=active&sort=expires_asc" echo echo " 1. Click \"Add new token\"" echo " 2. Pick a token name and select the \"api\" scope" echo " 3. Click \"Generate token\"" echo -e " 4. Copy and paste it here ${DIM}(input hidden)${RESET}:" local token; token="$(_glab_read_token)"; echo if [[ -z "$token" ]]; then warn "No token entered — skipping glab (nothing written)." return 1 fi # Store the token in .ddev/.env (the secret channel) under a NEUTRAL name that # glab does NOT auto-consume as a global auth override — see the header note. # Make sure .ddev/.env is gitignored first (idempotent; usually already done by # _ensure_gitignore_env at slot 8). _ensure_gitignore_env mkdir -p .ddev local envfile=".ddev/.env" esc touch "$envfile" esc="${token//\"/\\\"}" # Strip any prior GITLAB_TOKEN too — earlier versions of this extra wrote it, and # leaving it set would re-introduce the cross-host contamination described above. grep -v -e '^GITLAB_TOKEN=' -e '^DRUPALCODE_GITLAB_TOKEN=' -e '^GITLAB_HOST=' "$envfile" > "${envfile}.tmp" || true mv "${envfile}.tmp" "$envfile" printf 'GITLAB_HOST="%s"\n' "git.drupalcode.org" >> "$envfile" printf 'DRUPALCODE_GITLAB_TOKEN="%s"\n' "$esc" >> "$envfile" chmod 600 "$envfile" ok "Stored the token in .ddev/.env (chmod 600, gitignored)." # Bake glab into the web image so it survives restarts (DDEV web-build fragment). mkdir -p .ddev/web-build cat > .ddev/web-build/Dockerfile.glab < .ddev/commands/web/glab <<'CMD' #!/bin/bash glab "$@" CMD chmod +x .ddev/commands/web/glab ok "Wrote .ddev/commands/web/glab (use 'ddev glab ...')." # Authenticate glab the canonical way, on every container start: a post-start # hook (runs inside the web container, as the web user, with the right HOME) # does a non-interactive `glab auth login --stdin` from DRUPALCODE_GITLAB_TOKEN # and wires a git credential helper for HTTPS git ops. Quoted heredoc — the # hook body must reach the container verbatim ($VAR, backticks, etc. unexpanded). # DDEV merges post-start hooks across config.*.yaml files, so this coexists with # the coding-agent add-ons' own hooks. cat > .ddev/config.glab.yaml <<'YAML' # Authenticates glab to git.drupalcode.org on every start — added by drupalaibp's glab extra. hooks: post-start: - exec: | if [ -n "${DRUPALCODE_GITLAB_TOKEN:-}" ] && command -v glab >/dev/null 2>&1; then printf '%s' "$DRUPALCODE_GITLAB_TOKEN" \ | glab auth login --hostname git.drupalcode.org --git-protocol https --stdin >/dev/null 2>&1 || true git config --global 'credential.https://git.drupalcode.org.helper' '!glab auth git-credential' || true fi YAML ok "Wrote .ddev/config.glab.yaml (glab auth login on every start)." # One restart: build glab into the image and fire the post-start login hook. step "Restarting DDEV to build glab and authenticate..." if ! ddev restart; then warn "ddev restart failed — run it yourself, then verify with 'ddev exec glab api user'." return 1 fi # Verify with a REAL authenticated API call, not `glab auth status`: that # command always probes gitlab.com and misreports drupalcode's token, so it # exits non-zero even when auth works. `glab api user` actually exercises the # token against git.drupalcode.org. step "Verifying glab can reach git.drupalcode.org..." if ddev exec glab api user >/dev/null 2>&1; then ok "glab is authenticated to git.drupalcode.org — try 'ddev glab mr list', or clone/push over HTTPS. (Heads up: 'glab auth status' shows a gitlab.com error and an 'invalid token' warning — that's a glab quirk, not a real failure; use 'glab api user' to check.)" else warn "glab installed but an authenticated call to git.drupalcode.org failed — check the token in .ddev/.env has the 'api' scope, then 'ddev restart'." fi } # src/lib/extras/extra_tool_api.sh extra_tool_api_meta() { printf '%s|%s\n' \ "Tool API + Toolbelt (experimental)" \ "agents discover and run Drupal actions with 'drush tool:*'" } extra_tool_api() { step "Installing the experimental Tool API + Toolbelt..." ddev exec composer config --no-plugins allow-plugins.tbachert/spi true \ || warn "Could not trust the tbachert/spi plugin." if ! ddev exec composer require drupal/tool drupal/tool_belt --no-interaction; then warn "Could not require drupal/tool + drupal/tool_belt — skipping the Tool API." return 1 fi ddev drush en -y tool tool_belt \ tool_belt_content tool_belt_content_translation tool_belt_entity \ tool_belt_system tool_belt_user \ || warn "Some Tool API/Toolbelt modules weren't enabled — check 'ddev drush pm:list | grep tool'." AGENTS_MD+=("This site has the experimental Tool API + Toolbelt. Prefer discovering and running Drupal actions/config with \`drush tool:search\`, \`drush tool:info \`, and \`drush tool:run \` over hand-writing code for the same task.") ok "Tool API + Toolbelt installed — agents can use 'drush tool:search', 'tool:info', 'tool:run'." } # src/lib/install/ensure_local_bin_on_path.sh # Ensure ~/.local/bin is on PATH — for the current process and permanently, by # appending an export to the user's shell rc file (once). Idempotent: skips if # our marker or any existing .local/bin PATH entry is already present. ensure_local_bin_on_path() { local bin="$HOME/.local/bin" rc shellname local marker="# added by one-line-installer (${GLOBAL_CMD})" # Written to the rc file verbatim — $HOME must NOT expand now. # shellcheck disable=SC2016 local line='export PATH="$HOME/.local/bin:$PATH"' # Make it usable in the current process immediately. case ":$PATH:" in *":$bin:"*) ;; *) export PATH="$bin:$PATH" ;; esac shellname="$(basename "${SHELL:-sh}")" case "$shellname" in zsh) rc="${ZDOTDIR:-$HOME}/.zshrc" ;; bash) if [ "$(uname -s)" = "Darwin" ]; then rc="$HOME/.bash_profile"; else rc="$HOME/.bashrc"; fi ;; *) rc="$HOME/.profile" ;; esac # Already persisted (by us, or by an existing .local/bin PATH line)? Skip. if [[ -f "$rc" ]] && { grep -qF "$marker" "$rc" || grep -q '\.local/bin' "$rc"; }; then # shellcheck disable=SC2088 ok "~/.local/bin already on PATH via ${rc}" return fi printf '\n%s\n%s\n' "$marker" "$line" >> "$rc" ok "Added ~/.local/bin to PATH in ${rc} — restart your shell or run: source \"${rc}\"" } # src/lib/install/install_self.sh # `install` command: install this script as a global command in ~/.local/bin and # ensure that directory is on PATH (both now and persistently). When run from a # real on-disk copy the file is copied; when run remotely (curl/wget pipe or # process substitution) a fresh copy is downloaded, since the source isn't a # re-readable file. install_self() { local bin_dir="$HOME/.local/bin" local target="${bin_dir}/${GLOBAL_CMD}" step "Installing '${GLOBAL_CMD}' into ${bin_dir}..." mkdir -p "$bin_dir" detect_invocation if [[ "$INVOCATION" == "remote" ]]; then step "Downloading the installer from ${INSTALLER_URL}..." curl -fsSL "$INSTALLER_URL" -o "$target" || die "Download failed from ${INSTALLER_URL}" elif [[ "$0" -ef "$target" ]]; then # Already running from the install location (e.g. `drupalaibp install`). ok "Already installed at ${target}." else cp "$0" "$target" || die "Could not copy the installer to ${target}" fi chmod +x "$target" ok "Installed: ${target}" _install_man_pages # Set up the state directory + configs/ used by the "Use a config" run mode. mkdir -p "$CONFIG_DIR" ok "Config directory ready: ${CONFIG_DIR}" ensure_local_bin_on_path echo "" ok "Done! Run '${GLOBAL_CMD}' to set up a Drupal site, or '${GLOBAL_CMD} --help' for options." } # Install the man pages (rendered by bin/docs from src/bashly.yml, committed # under man/ and published next to the installer on GitLab Pages) into # ~/.local/share/man/man1, which `man` picks up automatically once # ~/.local/bin is on PATH. Best-effort: a failure warns and never aborts the # install — the man pages are a convenience, not a prerequisite. _install_man_pages() { local man_dir="$HOME/.local/share/man/man1" pages page src_dir failed=0 pages=( "${GLOBAL_CMD}.1" "${GLOBAL_CMD}-setup-site.1" "${GLOBAL_CMD}-install.1" "${GLOBAL_CMD}-delete.1" "${GLOBAL_CMD}-pull.1" ) mkdir -p "$man_dir" # A checkout (or any on-disk copy sitting next to a man/ dir) provides the # pages locally; otherwise fetch them from where the installer itself lives. # (INVOCATION was set by the detect_invocation call above; a remote run's $0 # is not a real file, so its dirname says nothing about where man/ is.) src_dir="$(dirname "$0")/man" if [[ "$INVOCATION" != "remote" && -f "${src_dir}/${pages[0]}" ]]; then for page in "${pages[@]}"; do cp "${src_dir}/${page}" "${man_dir}/${page}" 2>/dev/null || failed=1 done else for page in "${pages[@]}"; do if ! curl -fsSL "${INSTALLER_URL%/*}/man/${page}" -o "${man_dir}/${page}"; then rm -f "${man_dir}/${page}" failed=1 fi done fi if [[ $failed -eq 0 ]]; then ok "Man pages installed: man ${GLOBAL_CMD}" else warn "Could not install the man pages (skipped) — '${GLOBAL_CMD} --help' has the same content." fi } # src/lib/platform/ensure_ddev.sh step_ensure_ddev() { case "$(uname -s)" in Darwin) macos_ensure_docker ;; Linux) linux_detect_distro linux_ensure_docker ;; *) die "Unsupported OS: $(uname -s). This installer supports macOS and Linux only." ;; esac _ensure_ddev_cli } _ensure_ddev_cli() { if command -v ddev >/dev/null 2>&1; then ok "DDEV is installed ($(ddev --version 2>/dev/null || echo 'version unknown'))" return 0 fi step "Installing DDEV..." curl -fsSL https://ddev.com/install.sh | bash \ || die "DDEV installation failed — install it manually: https://ddev.readthedocs.io/en/stable/users/install/" command -v ddev >/dev/null 2>&1 \ || die "DDEV installed but not on PATH — open a new shell or add ~/.local/bin (or /usr/local/bin) to PATH and re-run." } # src/lib/platform/linux_detect_distro.sh # Detect the Linux distro family and select a package manager (sets PKG). # An unmapped distro leaves PKG empty; only linux_ensure_docker's install branch needs it. linux_detect_distro() { [[ "$(uname -s)" == "Linux" ]] || die "This script requires Linux. Detected: $(uname -s)" [[ -f /etc/os-release ]] || die "Cannot detect Linux distribution: /etc/os-release not found." # /etc/os-release is provided by the OS, not this repo. # shellcheck source=/dev/null source /etc/os-release DISTRO_ID="${ID_LIKE:-$ID}" if [[ "$DISTRO_ID" == *debian* || "$DISTRO_ID" == *ubuntu* ]]; then PKG="apt" elif [[ "$DISTRO_ID" == *fedora* || "$DISTRO_ID" == *rhel* || "$DISTRO_ID" == *centos* ]]; then PKG="dnf" else # No warning here. An unmapped distro is only a problem if Docker turns out to be missing, and # linux_ensure_docker says so precisely at that point, naming the distro. Warning eagerly told # every Arch, EndeavourOS or openSUSE user that something was wrong with their machine, one # line before "Docker daemon already running" confirmed nothing was. PKG="" return 0 fi ok "Detected distro: ${PRETTY_NAME:-$ID} (package manager: $PKG)" } # src/lib/platform/linux_ensure_docker.sh linux_ensure_docker() { step "Checking Docker..." if docker info &>/dev/null 2>&1; then ok "Docker daemon already running" return 0 fi if ! command -v docker &>/dev/null; then warn "Docker Engine not found." [[ -n "${PKG:-}" ]] \ || die "Docker Engine is not installed, and this installer cannot install it on ${PRETTY_NAME:-${ID:-this distribution}}. Install it (https://docs.docker.com/engine/install/) and re-run." if [[ $YOLO -eq 0 ]]; then echo -e "${YELLOW}Docker Engine will be installed via the official Docker repository." echo -e "This modifies your system's package sources.${RESET}" MENU_DEFAULT=1 # default: No select_one "Continue with the Docker Engine install?" \ "Yes — install Docker Engine" \ "No — abort" [[ $MENU_INDEX -eq 0 ]] || die "Aborted. Install Docker Engine manually and re-run." fi warn "Installing Docker Engine..." if [[ "$PKG" == "apt" ]]; then sudo apt-get update -qq sudo apt-get install -y ca-certificates curl gnupg lsb-release sudo install -m 0755 -d /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/ubuntu/gpg \ | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg sudo chmod a+r /etc/apt/keyrings/docker.gpg echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" \ | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null sudo apt-get update -qq sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin else sudo dnf -y install dnf-plugins-core sudo dnf config-manager --add-repo https://download.docker.com/linux/fedora/docker-ce.repo sudo dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin fi sudo systemctl enable --now docker sudo usermod -aG docker "$USER" || true ok "Docker Engine installed" else warn "Docker installed but daemon not running — starting..." sudo systemctl start docker # The failure above may be socket permissions, not a stopped daemon: a # pre-installed Docker whose user was never added to the docker group. Add # them so the group re-exec below can activate the membership. if ! id -nG "$USER" 2>/dev/null | grep -qw docker; then warn "Adding $USER to the docker group..." sudo usermod -aG docker "$USER" || true fi fi # usermod adds the group but can't apply it to the already-running shell, so # `docker info` keeps getting EACCES on the socket and the poll below would # time out after 60 s — and every later docker/ddev call would fail the same # way. If we're a docker-group member yet still can't reach the daemon, the # group just isn't active in this session: re-exec the whole installer under # `sg docker` so it is. ensure_ddev is pipeline slot 1 (nothing else has run # yet), so restarting from the top is safe. Guarded against re-looping. if [[ -z "${OLI_DOCKER_GROUP_REEXEC:-}" ]] \ && ! docker info &>/dev/null 2>&1 \ && id -nG "$USER" 2>/dev/null | grep -qw docker; then if command -v sg &>/dev/null; then warn "Activating docker group membership for this session (re-running under 'sg docker')..." _reexec_under_docker_group else warn "You were added to the 'docker' group, but it isn't active in this shell" warn "and 'sg' is unavailable to activate it. Log out and back in (or run" warn "'newgrp docker') and re-run the installer." fi fi warn "Waiting up to 60 s for Docker daemon..." for i in $(seq 1 30); do if docker info &>/dev/null 2>&1; then ok "Docker daemon is ready" break fi if [[ $i -eq 30 ]]; then die "Docker daemon did not start in time. Run 'sudo systemctl start docker' and re-run." fi sleep 2 done } # Re-exec the whole installer under the `docker` group so a freshly-added group # membership is active for this run. Mirrors the bash bouncer's re-download for # the `bash <(curl …)` case, where $0 is a process-substitution pipe that can't # be re-read. Sets OLI_DOCKER_GROUP_REEXEC so the re-run doesn't try again. _reexec_under_docker_group() { local runner if [[ -f "$0" && -r "$0" ]]; then runner="$0" else runner="$(mktemp "${TMPDIR:-/tmp}/one-line-installer.XXXXXX")" curl -fsSL "$INSTALLER_URL" -o "$runner" \ || die "Failed to re-download the installer to re-exec under the docker group." fi exec sg docker -c "$(_docker_group_reexec_cmd "$runner")" } # Build the command string `sg docker -c` runs: re-exec bash on the installer # with the original argv (Bashly's `command_line_args` global), each argument # %q-quoted so it survives the shell `sg` spawns. Pure — prints, execs nothing. _docker_group_reexec_cmd() { local runner="$1" a cmd cmd="export OLI_DOCKER_GROUP_REEXEC=1; exec bash $(printf '%q' "$runner")" for a in ${command_line_args[@]+"${command_line_args[@]}"}; do cmd+=" $(printf '%q' "$a")" done printf '%s' "$cmd" } # src/lib/platform/macos_ensure_docker.sh macos_ensure_docker() { step "Checking Docker..." if docker info &>/dev/null 2>&1; then ok "Docker daemon already running" else if ! command -v docker &>/dev/null; then die "Docker is required. Install Docker Desktop or OrbStack (https://ddev.readthedocs.io/en/stable/users/install/docker-installation/) and re-run." fi ok "Docker Desktop already installed" open -a Docker # Fresh installs require accepting a license and granting permissions before # the daemon starts — polling alone won't work until the user completes that. echo -e "${YELLOW}Docker Desktop is launching. If this is a fresh install, complete any" echo -e "first-run prompts (license agreement, permissions) in the Docker window." echo -e "Press Enter here once Docker Desktop shows it is running.${RESET}" read -r _ /dev/null 2>&1; then ok "Docker daemon is ready" break fi if [[ $i -eq 30 ]]; then die "Docker daemon did not start in time. Open Docker Desktop manually and re-run." fi sleep 2 done fi } # src/lib/shared/denylist/_jq.sh # Run jq, preferring a host jq and falling back to the jq inside the ddev web # container. Always invoked as a stdin->stdout filter (never with file-path # arguments), so it behaves identically whether jq runs on the host or in the # container. The denylist step runs after the site is up, so `ddev exec` is # available when the host has no jq of its own. _jq() { if command -v jq >/dev/null 2>&1; then jq "$@" else ddev exec jq "$@" fi } # src/lib/shared/denylist/_write_claude_denylist.sh # Reconcile our managed deny rules into .claude/settings.json. Claude Code # enforces `permissions.deny` itself (before allow/ask), so these can't be # prompted or instructed away. Compound commands are split and each segment # checked, and mid-pattern wildcards work — so "Bash(git * push:*)" also catches # workarounds like "git -C /path push". # # Merge, don't overwrite: only the entries WE manage are added/removed. The # user's other settings and any hand-added deny rules are preserved. A sidecar # manifest (.claude/.one-line-installer-deny.json) records exactly what we own so # a later run (or edit mode) can withdraw precisely those. An empty selection # removes our rules and deletes the manifest. # The single-quoted strings below are jq programs: $prev / $ARGS are jq # variables that jq itself expands, so the shell must NOT touch them. # shellcheck disable=SC2016 _write_claude_denylist() { local cmd manifest=".claude/.one-line-installer-deny.json" prev existing new local -a desired=() for cmd in ${DENY_COMMANDS[@]+"${DENY_COMMANDS[@]}"}; do case "$cmd" in "git push") desired+=("Bash(git push:*)" "Bash(git * push:*)") ;; ssh) desired+=("Bash(ssh:*)" "Bash(scp:*)" "Bash(sftp:*)" "Bash(ssh-keyscan:*)") ;; *) desired+=("Bash(${cmd}:*)") ;; esac done # Fresh project, nothing selected, nothing we ever wrote → nothing to do. if [[ ${#desired[@]} -eq 0 && ! -f "$manifest" && ! -f .claude/settings.json ]]; then return 0 fi prev="$([[ -f "$manifest" ]] && cat "$manifest" || echo '[]')" existing="$([[ -f .claude/settings.json ]] && cat .claude/settings.json || echo '{}')" mkdir -p .claude if ! new="$(printf '%s' "$existing" | _jq --argjson prev "$prev" \ '.permissions.deny = (((.permissions.deny // []) - $prev + $ARGS.positional) | unique)' \ --args ${desired[@]+"${desired[@]}"})"; then warn "Could not update .claude/settings.json (jq unavailable?) — skipping Claude denylist." return 0 fi printf '%s\n' "$new" > .claude/settings.json if [[ ${#desired[@]} -eq 0 ]]; then rm -f "$manifest" else _jq -n '$ARGS.positional' --args "${desired[@]}" > "$manifest" fi } # src/lib/shared/denylist/_write_codex_denylist.sh # Write our managed Codex deny rules to .codex/rules/one-line-installer.rules. # Codex's "Rules" (execpolicy) feature auto-discovers and *combines* every # .rules file it finds, so we simply own a dedicated file — no merge, no # manifest. Re-running rewrites it with the current selection; an empty selection # deletes it. The user's own rules files are never touched. # # Caveats worth knowing: Codex Rules are experimental and may change, they only # apply when the project is trusted, and a prefix_rule matches a command *prefix* # (token list) — so it can't express the mid-wildcard used for Claude # ("git","push" catches `git push …` but not `git -C /path push`). _write_codex_denylist() { local cmd one part rulesfile=".codex/rules/one-line-installer.rules" local -a rules=() cmds parts for cmd in ${DENY_COMMANDS[@]+"${DENY_COMMANDS[@]}"}; do case "$cmd" in ssh) cmds=(ssh scp sftp) ;; *) cmds=("$cmd") ;; esac for one in "${cmds[@]}"; do # Split the (possibly multi-word) command into tokens and quote each. read -r -a parts <<< "$one" local quoted="" for part in "${parts[@]}"; do quoted="${quoted:+$quoted, }\"$part\"" done [[ -n "$quoted" ]] && rules+=("$quoted") done done if [[ ${#rules[@]} -eq 0 ]]; then rm -f "$rulesfile" return 0 fi mkdir -p .codex/rules { printf '# Managed by one-line-installer — regenerated each run; do not edit.\n' printf '# Codex Rules (execpolicy) are experimental and only apply when this\n' printf '# project is trusted. They block the listed commands from running.\n\n' for one in "${rules[@]}"; do printf 'prefix_rule(\n pattern = [%s],\n decision = "forbidden",\n justification = "Blocked by one-line-installer",\n)\n\n' "$one" done } > "$rulesfile" } # src/lib/shared/denylist/_write_opencode_denylist.sh # Reconcile our managed deny rules into opencode.json. OpenCode resolves bash # permissions by glob with the LAST matching rule winning, so a "*": "allow" # baseline must come first and the deny patterns after it. # # Merge, don't overwrite: only the patterns WE manage are added/removed; the # user's other settings and hand-added rules are preserved. A sidecar manifest # (.one-line-installer-deny.opencode.json) records exactly what we own — including # "*" only when we added the baseline ourselves — so a later run (or edit mode) # can withdraw precisely those. An empty selection removes our rules and deletes # the manifest. If the user already has their own "*" catch-all we leave it as-is # and never claim it. # The single-quoted strings below are jq programs: $prev / $ARGS are jq # variables that jq itself expands, so the shell must NOT touch them. # shellcheck disable=SC2016 _write_opencode_denylist() { local cmd manifest=".one-line-installer-deny.opencode.json" prev existing new user_star local -a desired=() for cmd in ${DENY_COMMANDS[@]+"${DENY_COMMANDS[@]}"}; do case "$cmd" in "git push") desired+=("git push*" "git * push*") ;; ssh) desired+=("ssh*" "scp*" "sftp*") ;; *) desired+=("${cmd}*") ;; esac done if [[ ${#desired[@]} -eq 0 && ! -f "$manifest" && ! -f opencode.json ]]; then return 0 fi prev="$([[ -f "$manifest" ]] && cat "$manifest" || echo '[]')" existing="$([[ -f opencode.json ]] && cat opencode.json || echo '{"$schema":"https://opencode.ai/config.json"}')" # Does the USER already own a "*" catch-all? (present, and not one of ours) — # decides whether we add + record ownership of one. if ! user_star="$(printf '%s' "$existing" | _jq -r --argjson prev "$prev" \ '((.permission.bash // {}) | has("*")) and (($prev | index("*")) == null)')"; then warn "Could not read opencode.json (jq unavailable?) — skipping OpenCode denylist." return 0 fi new="$(printf '%s' "$existing" | _jq --argjson prev "$prev" ' .permission.bash = ( (.permission.bash // {}) | with_entries(select(.key as $k | ($prev | index($k)) | not)) | (if (($ARGS.positional | length) > 0) and (has("*") | not) then {"*":"allow"} + . else . end) | reduce $ARGS.positional[] as $p (.; .[$p] = "deny") )' --args ${desired[@]+"${desired[@]}"})" printf '%s\n' "$new" > opencode.json # Manifest = the patterns we own; add "*" only when we (not the user) supplied # the baseline, so a later withdraw removes it too. if [[ ${#desired[@]} -eq 0 ]]; then rm -f "$manifest" elif [[ "$user_star" == "false" ]]; then _jq -n '$ARGS.positional + ["*"]' --args "${desired[@]}" > "$manifest" else _jq -n '$ARGS.positional' --args "${desired[@]}" > "$manifest" fi } # src/lib/shared/denylist/write_agent_denylist.sh # Reconcile DENY_COMMANDS into each installed agent's project config: Claude Code # (.claude/settings.json), OpenCode (opencode.json) and Codex (.codex/rules/). # Only our own managed rules are added or removed — the user's other settings and # any hand-added rules are preserved. Safe to re-run: an empty selection withdraws # whatever we previously wrote (the basis for a future edit mode). Runs from # inside the project directory. # # Deny rules block the listed commands but are NOT a full sandbox: a determined # agent can still slip past via shell aliases, env-var-prefixed commands # (GIT_SSH=… git push) or a subprocess/script it writes itself. Codex rules are # additionally experimental and only apply when the project is trusted. step_write_agent_denylist() { [[ ${#AGENTS[@]} -eq 0 ]] && return 0 # Fresh project with nothing selected and nothing we ever wrote → nothing to do # (avoids a misleading step message; the writers are individually safe anyway). local have_state=0 [[ -f .claude/.one-line-installer-deny.json \ || -f .one-line-installer-deny.opencode.json \ || -f .codex/rules/one-line-installer.rules ]] && have_state=1 [[ ${#DENY_COMMANDS[@]} -eq 0 && $have_state -eq 0 ]] && return 0 if [[ ${#DENY_COMMANDS[@]} -gt 0 ]]; then step "Locking down the coding agent(s): blocking ${DENY_COMMANDS[*]}" else step "Clearing the coding agent(s) command denylist..." fi local a for a in "${AGENTS[@]}"; do case "$a" in claude) _write_claude_denylist ;; opencode) _write_opencode_denylist ;; codex) _write_codex_denylist ;; esac done ok "Denylist updated. Note: best-effort — this blocks the obvious cases but isn't a full sandbox." } # src/lib/shared/manage/manage_addons.sh manage_addons() { while true; do select_one "DDEV add-ons:" \ "Add an add-on (enter a name, e.g. ddev/ddev-redis)" \ "Remove an installed add-on" \ "Back" case "$MENU_INDEX" in 0) echo -e "Add-on to get ${DIM}[e.g. ddev/ddev-selenium-standalone-chrome]${RESET}:" local addon; read -r addon /dev/null | awk 'NR>1{print $1}') if [[ ${#installed[@]} -eq 0 ]]; then warn "No add-ons installed."; continue; fi select_one "Remove which add-on?" "${installed[@]}" "Cancel" [[ $MENU_INDEX -ge ${#installed[@]} ]] && continue if ddev add-on remove "${installed[$MENU_INDEX]}"; then MANAGE_RESTART_NEEDED=1; ok "Removed ${installed[$MENU_INDEX]}." else warn "Could not remove ${installed[$MENU_INDEX]}." fi ;; 2) break ;; esac done } # src/lib/shared/manage/manage_ai_best_practices.sh # Add or remove the drupal/ai_best_practices dev dependency, based on whether it # is currently installed (ABP_INSTALLED, from manage_detect_state). manage_ai_best_practices() { if [[ ${ABP_INSTALLED:-0} -eq 1 ]]; then echo -e "${GREEN}AI Best Practices is currently installed.${RESET}" MENU_DEFAULT=1 # default: keep select_one "Remove the ai_best_practices module?" \ "Yes — remove it" \ "No — keep it" if [[ $MENU_INDEX -eq 0 ]]; then step "Removing ai_best_practices..." # SC2015 is a false positive here: the middle command is a bare assignment # (ABP_INSTALLED=0), which cannot fail, so `warn` runs iff the remove fails. if [[ -f "web/composer.json" ]]; then # shellcheck disable=SC2015 ddev exec bash -c 'cd web && composer remove --dev drupal/ai_best_practices --no-interaction' \ && ABP_INSTALLED=0 || warn "Could not remove ai_best_practices." else # shellcheck disable=SC2015 ddev exec composer remove --dev drupal/ai_best_practices --no-interaction \ && ABP_INSTALLED=0 || warn "Could not remove ai_best_practices." fi # `if` (not a bare `[[ … ]] && ok`): on the removal-failure path this is the # function's last statement, and a false test would return 1 and abort the # whole installer under set -e (the rule AGENTS.md calls out). if [[ ${ABP_INSTALLED} -eq 0 ]]; then ok "ai_best_practices removed." fi fi else echo -e "${DIM}AI Best Practices is not installed.${RESET}" MENU_DEFAULT=0 # default: add select_one "Add the ai_best_practices module?" \ "Yes — add it" \ "No — leave it out" if [[ $MENU_INDEX -eq 0 ]]; then add_ai_best_practices_existing ABP_INSTALLED=1 fi fi } # src/lib/shared/manage/manage_coding_agents.sh # Reconcile which coding agents are installed (each is an e0ipso/ddev-assistant-* # add-on), then optionally edit their command denylist. Adds via the existing # step_install_coding_agents; removes via `ddev add-on remove` + dropping the # `ddev ` wrapper. Any add-on change flags a restart. manage_coding_agents() { local -a all=(claude codex opencode) local i a preselect="" # Pre-tick the currently-installed agents. for i in "${!all[@]}"; do for a in ${AGENTS[@]+"${AGENTS[@]}"}; do [[ "${all[$i]}" == "$a" ]] && preselect="$preselect $i" done done MENU_PRESELECT="${preselect# }" select_many "Coding agents to install in the container (ticked = installed):" \ "Claude Code" "Codex" "OpenCode" # Desired set from the selection. local -a desired=() for i in $MENU_SELECTED; do desired+=("${all[$i]}"); done # Additions: desired but not currently in AGENTS. local -a to_add=() for a in ${desired[@]+"${desired[@]}"}; do local found=0 c for c in ${AGENTS[@]+"${AGENTS[@]}"}; do [[ "$c" == "$a" ]] && found=1; done [[ $found -eq 0 ]] && to_add+=("$a") done # Removals: currently in AGENTS but no longer desired. local -a to_remove=() for a in ${AGENTS[@]+"${AGENTS[@]}"}; do local found=0 c for c in ${desired[@]+"${desired[@]}"}; do [[ "$c" == "$a" ]] && found=1; done [[ $found -eq 0 ]] && to_remove+=("$a") done # Apply removals (confirmed as a batch). if [[ ${#to_remove[@]} -gt 0 ]]; then MENU_DEFAULT=1 select_one "Remove coding agent(s): ${to_remove[*]}?" "Yes — remove" "No — keep them" if [[ $MENU_INDEX -eq 0 ]]; then local -a removed_ok=() for a in "${to_remove[@]}"; do step "Removing ddev-assistant-${a}..." if ddev add-on remove "e0ipso/ddev-assistant-${a}"; then rm -f ".ddev/commands/web/${a}" removed_ok+=("$a") MANAGE_RESTART_NEEDED=1 else warn "Could not remove ddev-assistant-${a} — keeping it as installed." fi done # Only agents actually removed count as removed for the AGENTS recompute # below; a failed removal leaves the add-on in place, so it stays in AGENTS # (and keeps its denylist/wrapper) rather than being silently dropped. # shellcheck disable=SC2206 to_remove=(${removed_ok[@]+"${removed_ok[@]}"}) # Withdraw each removed agent's managed denylist now, while we still know # which agents to target. Otherwise, if the user removes *all* agents, # AGENTS ends up empty and the finalize write_agent_denylist early-returns, # orphaning the deny rules/manifests with no agent to consume them. An empty # DENY_COMMANDS makes write_agent_denylist withdraw exactly what we wrote. if [[ ${#removed_ok[@]} -gt 0 ]]; then local -a _keep_agents=("${AGENTS[@]}") AGENTS=("${removed_ok[@]}") DENY_COMMANDS=() step_write_agent_denylist AGENTS=("${_keep_agents[@]}") fi else # User backed out of removals — keep them in the desired set. to_remove=() fi fi # Apply additions by reusing step_install_coding_agents with AGENTS set to just the # new ones (it stages the add-ons + writes the ddev wrappers). if [[ ${#to_add[@]} -gt 0 ]]; then local -a saved=("${AGENTS[@]}") AGENTS=("${to_add[@]}") step_install_coding_agents AGENTS=("${saved[@]}") MANAGE_RESTART_NEEDED=1 fi # Recompute AGENTS = (kept) + (added), so downstream (denylist, finalize) is # accurate. Kept = current minus removed. local -a kept=() for a in ${AGENTS[@]+"${AGENTS[@]}"}; do local removed=0 c for c in ${to_remove[@]+"${to_remove[@]}"}; do [[ "$c" == "$a" ]] && removed=1; done [[ $removed -eq 0 ]] && kept+=("$a") done # shellcheck disable=SC2206 AGENTS=(${kept[@]+"${kept[@]}"} ${to_add[@]+"${to_add[@]}"}) # Offer to edit the command denylist for the installed agents. if [[ ${#AGENTS[@]} -gt 0 ]]; then MENU_DEFAULT=1 select_one "Edit the command denylist for the coding agent(s)?" \ "Yes — choose blocked commands" \ "No — leave the denylist as-is" if [[ $MENU_INDEX -eq 0 ]]; then choose_deny_rules # repopulates DENY_COMMANDS step_write_agent_denylist # reconciles (adds new, withdraws deselected) fi fi } # src/lib/shared/manage/manage_detect_state.sh # Inspect the running DDEV project and seed the choice globals so each category # submenu opens pre-populated with what's already installed. Degrades gracefully # on a vanilla DDEV instance (no .claude/.agents/manifests → everything reads as # "not installed"). manage_detect_state() { step "Inspecting the current project..." # DDEV project name drives the *.ddev.site URL used by the reused writers. PROJECT_DIR="$(awk -F': *' '/^name:/{print $2; exit}' .ddev/config.yaml 2>/dev/null)" [[ -n "$PROJECT_DIR" ]] || PROJECT_DIR="$(basename "$(pwd)")" local installed installed="$(ddev add-on list --installed 2>/dev/null || true)" # Coding agents present as e0ipso/ddev-assistant- add-ons. AGENTS=() local a for a in claude codex opencode; do printf '%s\n' "$installed" | grep -q "ddev-assistant-${a}" && AGENTS+=("$a") done # AI Best Practices (dev dependency, root or web/). ABP_INSTALLED=0 if ddev exec composer show drupal/ai_best_practices >/dev/null 2>&1 \ || ddev exec bash -c 'cd web 2>/dev/null && composer show drupal/ai_best_practices' >/dev/null 2>&1; then ABP_INSTALLED=1 fi # GitHub remote already configured? GH_PRESENT=0 git remote get-url origin >/dev/null 2>&1 && GH_PRESENT=1 # Denylist selection starts empty; choose_deny_rules repopulates it if the user # edits the denylist (write_agent_denylist reconciles against its own manifest). DENY_COMMANDS=() } # src/lib/shared/manage/manage_existing.sh # Management mode: run against an existing DDEV instance to add/remove the tooling # this installer manages (AI Best Practices, coding agents + denylist, skills, # add-ons, GitHub). Reached from the platform orchestrators when the current # directory is a DDEV project. Interactive by design — refuses --yolo. Detects the # current state, loops a per-category menu, then syncs the managed docs/config and # restarts DDEV if any add-on changed. manage_existing() { if [[ $YOLO -eq 1 ]]; then echo -e "${YELLOW}Management mode is interactive — you can't --yolo a running instance.${RESET}" echo -e "Re-run without --yolo to add/remove tooling on this project." exit 0 fi echo -e "${BOLD}${GREEN}Management mode${RESET} — add or remove tooling on this DDEV project." # A config in ./.drupalaibp/ can hook management events too (hooks only — the # detected on-disk state, not the file, is the source of truth here). Uses the # first config (non-prompting) for its hooks. local _mgmt_cfg _mgmt_cfg="$(_first_local_config)" if [[ -n "$_mgmt_cfg" ]]; then drupalaibp_load_hooks "$_mgmt_cfg" _copy_hook_scripts fi # Only start DDEV if it isn't already running: `ddev start` on a live project re-provisions it. # `ddev describe` reads state; `ddev exec` would START a stopped project, silently, in ~60s. # The match keeps its leading quote so sibling keys (router_status, status_desc) cannot match. step "Checking DDEV..." if ddev describe -j 2>/dev/null | grep -q '"status":"running"'; then ok "DDEV is already running." else step "Starting DDEV..." ddev start fi MANAGE_RESTART_NEEDED=0 manage_detect_state manage_menu # Finalize: keep the managed AGENTS.md block, denylist and Claude skills link in # sync with the edited state, and restart if any add-on was added/removed. step_write_agents_md_block step_write_agent_denylist step_link_claude_skills if [[ ${MANAGE_RESTART_NEEDED:-0} -eq 1 ]]; then step "Restarting DDEV to apply add-on changes..." ddev restart fi echo "" echo -e "${BOLD}${GREEN}Done.${RESET} Your changes are applied to https://${PROJECT_DIR,,}.ddev.site" echo "" } # src/lib/shared/manage/manage_github.sh # GitHub integration — ADD ONLY (we never delete a repo). If the project already # has an origin remote, report it and do nothing. Otherwise reuse the standard # github_setup (questions) + github_finalize (gh install/auth/create/push). manage_github() { if [[ ${GH_PRESENT:-0} -eq 1 ]]; then local url url="$(git remote get-url origin 2>/dev/null)" echo -e "${GREEN}This project already has a GitHub remote:${RESET} ${url}" echo -e "${DIM}Management mode doesn't remove GitHub remotes.${RESET}" return 0 fi # github_setup returns early under --yolo, but management mode is never --yolo, # so it will ask its questions normally and set GH_ENABLED. github_setup if [[ ${GH_ENABLED:-0} -eq 1 ]]; then step_github_finalize GH_PRESENT=1 fi } # src/lib/shared/manage/manage_menu.sh # Top-level management menu. Each entry opens its own add/remove submenu; loops # until the user picks Done. manage_menu() { while true; do select_one "What would you like to manage?" \ "AI Best Practices module" \ "Coding agents (and their command denylist)" \ "Agent skills" \ "DDEV add-ons" \ "GitHub repository" \ "Done — apply and exit" case "$MENU_INDEX" in 0) manage_ai_best_practices ;; 1) manage_coding_agents ;; 2) manage_skills ;; 3) manage_addons ;; 4) manage_github ;; 5) break ;; esac done } # src/lib/shared/manage/manage_skills.sh # Add or remove agent skills. Adds present the same curated list as choose_skills # (via the shared _curated_skill_specs), then install with _install_skill_specs # (npx skills add, in-container). Removes list the skill directories actually # present under .claude/skills and .agents/skills and delete the chosen ones. # (npx skills has no stable remove subcommand today, so removal is a directory # delete — see the design's open items.) manage_skills() { MENU_DEFAULT=0 select_one "Manage agent skills:" \ "Add skills" \ "Remove skills" \ "Back" case "$MENU_INDEX" in 0) local -a specs=() line to_add=() while IFS= read -r line; do specs+=("$line"); done < <(_curated_skill_specs) local -a labels=( "Drupal Module Finder (ivanboring) — semantic search for Drupal modules" "Superpowers — planning, TDD, reviews, git worktrees" "Find Skills — add more skills to your agent later" "Agent Browser — browser automation for your agents" "Ponytail — lazy senior dev rules: reuse, prefer native/simple, less code" "Drupal Claude Skills (grasmash) — Drupal skill collection: config management, testing, DDEV, Canvas, security" "Drupal Workflow (gkastanis) — Drupal skill collection: patterns, testing, security, caching, Twig, planning" "Drupal AI (edutrul) — Drupal AI module skills" "Drupal Agent Resources (madsnorgaard) — Drupal agent resources and references" "Drupal Canvas (drupal-canvas) — skills specific to building with Drupal Canvas" ) MENU_PRESELECT="" select_many "Which agent skills would you like to add? (via npx skills)" "${labels[@]}" local idx # shellcheck disable=SC2086 for idx in $MENU_SELECTED; do to_add+=("${specs[$idx]}") done if [[ ${#to_add[@]} -gt 0 ]]; then SKILLS_SPEC=("${to_add[@]}") step_install_skills # npx skills add … -a , in-container step_link_claude_skills # keep .claude/skills pointing at .agents/skills fi ;; 1) # Enumerate installed skill directories (real dirs, not the symlink). local -a dirs=() names=() local base d name for base in .claude/skills .agents/skills; do [[ -d "$base" && ! -L "$base" ]] || continue for d in "$base"/*/; do [[ -d "$d" ]] || continue name="$(basename "$d")" dirs+=("${d%/}") names+=("${name} (${base})") done done if [[ ${#dirs[@]} -eq 0 ]]; then warn "No installed skills found to remove." return 0 fi MENU_PRESELECT="" select_many "Select skills to remove:" "${names[@]}" if [[ -z "${MENU_SELECTED// /}" ]]; then ok "No skills removed." return 0 fi local i for i in $MENU_SELECTED; do step "Removing ${names[$i]}..." # SC2015 false positive: `ok` (a plain echo) cannot fail, so `warn` runs # iff the rm fails. # shellcheck disable=SC2015 rm -rf "${dirs[$i]}" && ok "Removed ${names[$i]}." || warn "Could not remove ${names[$i]}." done ;; 2) return 0 ;; esac } # src/lib/shared/mcp/_write_mcp_agent_config.sh # Write/merge the Drupal MCP server into one in-container coding agent's config so # it can reach the site over MCP. Args: $1 = agent (claude|codex|opencode), # $2 = MCP URL (https://.ddev.site/_mcp), $3 = OAuth bearer token (may be empty). JSON is # merged with _jq so existing config (e.g. the OpenCode denylist) is preserved. _write_mcp_agent_config() { local agent="$1" url="$2" token="$3" f case "$agent" in claude) # Claude Code reads project .mcp.json for project-scoped MCP servers. f=".mcp.json"; [[ -f "$f" ]] || echo '{}' > "$f" # shellcheck disable=SC2016 # $url/$auth are jq variables, not shell expansions _jq --arg url "$url" --arg auth "$token" \ '.mcpServers.drupal = ({type:"http", url:$url} + (if $auth=="" then {} else {headers:{Authorization:("Bearer "+$auth)}} end))' \ < "$f" > "${f}.tmp" && mv "${f}.tmp" "$f" ;; opencode) # OpenCode reads project opencode.json (already used by the denylist). f="opencode.json"; [[ -f "$f" ]] || echo '{}' > "$f" # shellcheck disable=SC2016 # $url/$auth are jq variables, not shell expansions _jq --arg url "$url" --arg auth "$token" \ '.mcp.drupal = ({type:"remote", url:$url, enabled:true} + (if $auth=="" then {} else {headers:{Authorization:("Bearer "+$auth)}} end))' \ < "$f" > "${f}.tmp" && mv "${f}.tmp" "$f" ;; codex) # Codex reads ~/.codex/config.toml. Its HTTP/streamable MCP support is # experimental and the config location in-container may differ — best-effort; # verify against your Codex version. mkdir -p .codex { printf '\n[mcp_servers.drupal]\n' printf 'url = "%s"\n' "$url" [[ -n "$token" ]] && printf 'bearer_token = "%s"\n' "$token" } >> ".codex/config.toml" ;; esac } # src/lib/shared/mcp/install_mcp_server.sh # EXPERIMENTAL: enable the Drupal MCP Server (drupal/mcp_server, alpha) and point # the in-container coding agents at it over MCP at https://.ddev.site/_mcp. # Installs + enables the module with NO authentication — no Simple OAuth, no user, # no token — so the agents reach the endpoint directly (the MCP endpoint is open # on the local site). No-op unless MCP_SERVER=1. Runs after the site is up and # after the agents are installed. Only the in-container agents are configured # (bring-your-own host agents are skipped). install_mcp_server() { [[ ${MCP_SERVER:-0} -eq 1 ]] || return 0 local a configured="" pname base url pname="$(printf '%s' "$PROJECT_DIR" | tr '[:upper:]' '[:lower:]')" # Full external hostname — DDEV resolves it both inside the web container and on # the host, so the same URL works for the agents and from the host. base="https://${pname}.ddev.site" url="${base}/_mcp" warn "MCP Server is EXPERIMENTAL (drupal/mcp_server is an alpha module) and is set" warn "up with NO authentication — the MCP endpoint is open on your local site." step "Installing the Drupal MCP Server (experimental)..." if ! ddev exec composer require "drupal/mcp_server:^2.0@alpha"; then warn "Could not require drupal/mcp_server — skipping MCP setup." return 0 fi ddev drush en -y mcp_server \ || { warn "Could not enable mcp_server — skipping MCP setup."; return 0; } # Configure the in-container agents only — no auth header (empty token). for a in "${AGENTS[@]}"; do case "$a" in claude|codex|opencode) _write_mcp_agent_config "$a" "$url" "" configured="${configured} ${a}" ;; esac done if [[ -n "$configured" ]]; then ok "MCP configured (no auth) for:${configured} → ${url}" else warn "No in-container agent to configure for MCP (bring-your-own agents configure themselves)." fi warn "MCP tools default to their module settings — review (and lock down if needed) at:" warn " ${base}/admin/config/services/mcp-server/tools" } # src/lib/shared/prompts/_curated_skill_specs.sh SKILL_DEFAULT_COUNT=4 _curated_skill_specs() { printf '%s\n' \ "ivanboring/drupal-module-finder|drupal-module-finder" \ "obra/superpowers|*" \ "https://github.com/vercel-labs/skills|find-skills" \ "https://github.com/vercel-labs/agent-browser|agent-browser" \ "DietrichGebert/ponytail|*" \ "grasmash/drupal-claude-skills|*" \ "gkastanis/drupal-workflow|*" \ "edutrul/drupal-ai|*" \ "madsnorgaard/drupal-agent-resources|*" \ "drupal-canvas/skills|*" } _default_skill_specs() { _curated_skill_specs | head -n "${SKILL_DEFAULT_COUNT}" } _skill_spec_normalize() { local entry="$1" if [[ "$entry" == *"|"* ]]; then printf '%s' "$entry"; else printf '%s|*' "$entry"; fi } # src/lib/shared/prompts/apply_cli_flags.sh _merge_added() { # local csv="$1"; shift local -a add=() IFS=',' read -r -a add <<< "$csv" local item seen=$'\n' for item in "$@" ${add[@]+"${add[@]}"}; do [[ -n "$item" ]] || continue # Quoted on the right of == so a '*' in the item is a literal, not a glob. if [[ "$seen" == *$'\n'"${item}"$'\n'* ]]; then continue; fi seen+="${item}"$'\n' printf '%s\n' "$item" done return 0 } apply_cli_flags() { [[ -n "${args[--ddev-project-type]:-}" ]] && DDEV_PROJECT_TYPE="${args[--ddev-project-type]}" [[ -n "${args[--is-drupal]:-}" ]] && IS_DRUPAL="${args[--is-drupal]}" [[ -n "${args[--composer-stability]:-}" ]] && COMPOSER_STABILITY="${args[--composer-stability]}" [[ -n "${args[--db-path]:-}" ]] && DB_PATH="${args[--db-path]}" [[ -n "${args[--profile]:-}" ]] && PROFILE="${args[--profile]}" if [[ -n "${args[--site-template]:-}" ]]; then SITE_TEMPLATE="${args[--site-template]}" PROFILE="template" fi if [[ -n "${args[--agents]:-}" ]]; then IFS=',' read -r -a AGENTS <<< "${args[--agents]}" AGENTS_SET=1 fi [[ -n "${args[--default-skills]:-}" ]] && SKILLS_DEFAULT=1 [[ -n "${args[--default-denylist]:-}" ]] && DENYLIST_DEFAULT=1 # --add-skills: the DEFAULT skills PLUS these (where --extras-style flags # replace, the --add-* flags add). In config mode the config's `skills` are the # base instead, so a flag extends a config rather than discarding it. A bare # repo with no '|' means every skill in it. SKILLS_SET makes choose_skills # keep the list we built here. if [[ -n "${args[--add-skills]:-}" ]]; then local -a skill_base=() added_csv=() local line entry if [[ ${USE_CONFIG:-0} -eq 1 && ${#SKILLS_SPEC[@]} -gt 0 ]]; then skill_base=("${SKILLS_SPEC[@]}") else while IFS= read -r line; do skill_base+=("$line"); done < <(_default_skill_specs) fi IFS=',' read -r -a added_csv <<< "${args[--add-skills]}" local normalized="" for entry in ${added_csv[@]+"${added_csv[@]}"}; do [[ -n "$entry" ]] || continue normalized+="$(_skill_spec_normalize "$entry")," done SKILLS_SPEC=() while IFS= read -r line; do SKILLS_SPEC+=("$line"); done \ < <(_merge_added "${normalized%,}" ${skill_base[@]+"${skill_base[@]}"}) SKILLS_SET=1 fi # Tested for *presence*, not emptiness: `--extras ""` is a deliberate "no # extras at all", and must beat the pre-ticked defaults. if [[ -n "${args[--extras]+x}" ]]; then IFS=',' read -r -a EXTRAS <<< "${args[--extras]}" EXTRAS_SET=1 fi # --add-extras: the DEFAULT extras (dtk today) PLUS these. Applied after # --extras, so the two compose — an explicit --extras list becomes the base # instead of the defaults — and so does a config's extra_scripts. if [[ -n "${args[--add-extras]:-}" ]]; then local -a extra_base=() local xline if [[ ${EXTRAS_SET:-0} -eq 1 || ${USE_CONFIG:-0} -eq 1 ]]; then extra_base=( ${EXTRAS[@]+"${EXTRAS[@]}"} ) else IFS=' ' read -r -a extra_base <<< "$(_extras_default_ids)" fi EXTRAS=() while IFS= read -r xline; do EXTRAS+=("$xline"); done \ < <(_merge_added "${args[--add-extras]}" ${extra_base[@]+"${extra_base[@]}"}) EXTRAS_SET=1 fi if [[ -n "${args[--name]:-}" ]]; then PROJECT_DIR="${args[--name]}" PROJECT_NAME_SET=1 fi if [[ -n "${args[--github-setup]:-}" ]]; then GH_ENABLED=1 GH_REPO_NAME="${args[--github-setup]}" GH_VISIBILITY="--private" GH_FROM_FLAG=1 fi return 0 } # src/lib/shared/prompts/ask_config_questions.sh ask_config_questions() { [[ ${#CONFIG_QUESTION_KEYS[@]} -eq 0 ]] && return 0 local i key line dep_env dep_kind dep_val cur skip for i in "${!CONFIG_QUESTION_KEYS[@]}"; do key="${CONFIG_QUESTION_KEYS[$i]}" if [[ "${CONFIG_QUESTION_HASVALUE[$i]}" -eq 1 ]]; then ANSWERS[$key]="${CONFIG_QUESTION_VALUES[$i]}" continue fi dep_env="${CONFIG_QUESTION_DEP_ENV[$i]}" if [[ -n "$dep_env" ]]; then cur="${ANSWERS[$dep_env]-}" dep_kind="${CONFIG_QUESTION_DEP_KIND[$i]}" dep_val="${CONFIG_QUESTION_DEP_VALUES[$i]}" skip=0 case "$dep_kind" in equals) [[ "$cur" != "$dep_val" ]] && skip=1 ;; not_equals) [[ "$cur" == "$dep_val" ]] && skip=1 ;; in) skip=1 local -a dep_opts=(); IFS=',' read -r -a dep_opts <<< "$dep_val" local o for o in "${dep_opts[@]}"; do [[ "$cur" == "$o" ]] && { skip=0; break; }; done ;; esac if [[ $skip -eq 1 ]]; then ANSWERS[$key]="" continue fi fi if [[ "${CONFIG_HAS_CONDITIONAL_QUESTION:-0}" -eq 0 ]] && [[ -f .ddev/.env ]] \ && grep -q "^${key}=" .ddev/.env; then line="$(grep "^${key}=" .ddev/.env | tail -1)" line="${line#*=}"; line="${line#\"}"; line="${line%\"}" ANSWERS[$key]="$line" continue fi local -a opts=() if [[ -n "${CONFIG_QUESTION_OPTIONS[$i]}" ]]; then IFS=',' read -r -a opts <<< "${CONFIG_QUESTION_OPTIONS[$i]}" fi ask "$key" "${CONFIG_QUESTION_LABELS[$i]}" "${CONFIG_QUESTION_TYPES[$i]}" \ "${CONFIG_QUESTION_DEFAULTS[$i]}" ${opts[@]+"${opts[@]}"} done return 0 } # src/lib/shared/prompts/choose_coding_agent.sh # Choose which coding agent(s) to install in the container. Sets the AGENTS array # to the chosen agents (empty = bring your own). Claude Code is the default: it is # pre-ticked in the interactive menu, and installed automatically under --yolo. choose_coding_agent() { if [[ ${USE_CONFIG:-0} -eq 1 ]]; then return 0; fi # --agents already populated AGENTS (apply_cli_flags). if [[ ${AGENTS_SET:-0} -eq 1 ]]; then return 0; fi local idx AGENTS=() if [[ $YOLO -eq 1 ]]; then AGENTS=("claude") return fi MENU_PRESELECT="0" # default: Claude Code select_many "Install coding agent(s) in the DDEV container? (Claude Code preselected; none selected = bring your own)" \ "Claude Code" \ "Codex" \ "OpenCode" # shellcheck disable=SC2086 for idx in $MENU_SELECTED; do case "$idx" in 0) AGENTS+=("claude") ;; 1) AGENTS+=("codex") ;; 2) AGENTS+=("opencode") ;; esac done # Deselecting everything is valid (bring your own) but easy to do by accident, # so make it explicit rather than a silent surprise. if [[ ${#AGENTS[@]} -eq 0 ]]; then warn "No coding agent selected — you'll bring your own. (Tip: highlight one and press SPACE to tick it.)" fi } # src/lib/shared/prompts/choose_db_path.sh choose_db_path() { [[ "${DB_IMPORT:-false}" == "true" ]] || return 0 [[ -n "${DB_PATH:-}" ]] && return 0 ask DB_PATH_ANSWER "Local path to the database dump to import" text "" DB_PATH="${ANSWERS[DB_PATH_ANSWER]}" if [[ -z "$DB_PATH" ]]; then die "This config requires a database dump (db_import: true) but no path was given. Re-run with --db-path , or answer the prompt with a valid path." fi if [[ ! -f "$DB_PATH" ]]; then die "Database dump not found: ${DB_PATH}" fi return 0 } # src/lib/shared/prompts/choose_deny_rules.sh # Ask which commands the project's coding agent(s) must never run, and record # them in DENY_COMMANDS. git push and ssh (plus their common workarounds, added # by the writers) are recommended and pre-ticked; the user can add more. Applied # later by write_agent_denylist, which turns each entry into the agent's own # config format (Claude settings.json, OpenCode opencode.json, Codex # .codex/rules/). Under --yolo it takes the safe defaults silently. choose_deny_rules() { if [[ ${USE_CONFIG:-0} -eq 1 ]]; then return 0; fi DENY_COMMANDS=() # Nothing to constrain if no agent was installed in the container. All three # supported agents (Claude, OpenCode, Codex) can now carry a denylist. [[ ${#AGENTS[@]} -eq 0 ]] && return if [[ $YOLO -eq 1 || ${DENYLIST_DEFAULT:-0} -eq 1 ]]; then DENY_COMMANDS=("git push" "ssh") return fi local idx extra # Pre-tick the two recommended defaults (git push, ssh). MENU_PRESELECT="0 1" select_many "Block these commands for the coding agent(s)? (recommended defaults pre-ticked)" \ "git push — no pushing to remote repositories" \ "ssh — no outbound SSH (also blocks scp/sftp)" \ "sudo — no privilege escalation" \ "curl/wget — no outbound downloads" \ "rm -rf — no recursive force deletes" for idx in $MENU_SELECTED; do case "$idx" in 0) DENY_COMMANDS+=("git push") ;; 1) DENY_COMMANDS+=("ssh") ;; 2) DENY_COMMANDS+=("sudo") ;; 3) DENY_COMMANDS+=("curl" "wget") ;; 4) DENY_COMMANDS+=("rm -rf") ;; esac done # Let the user append their own commands (comma-separated so multi-word # commands like "kubectl delete" survive). echo -e "Any other commands to block? ${DIM}Comma-separated, e.g. 'kubectl delete, terraform apply' — Enter to skip${RESET}" read -r extra .ddev.site/_mcp. # Default OFF, including under --yolo. Sets MCP_SERVER (0/1); applied by # install_mcp_server. Experimental — it installs an alpha module and sets up NO # authentication: the in-container agents reach the MCP endpoint directly (open). choose_mcp_server() { if [[ ${USE_CONFIG:-0} -eq 1 ]]; then return 0; fi MCP_SERVER=0 [[ $YOLO -eq 1 ]] && return MENU_DEFAULT=1 # default: No (experimental, off by default) select_one "Enable the Drupal MCP Server so in-container agents can call Drupal tools? (experimental)" \ "Yes — install it (experimental)" \ "No — skip it" # `if` (not a trailing `[[ … ]] && …`): a false test as the last statement would # return 1 and, under set -e, abort the whole installer. if [[ $MENU_INDEX -eq 0 ]]; then MCP_SERVER=1 fi } # src/lib/shared/prompts/choose_profile.sh # Resolve which Drupal install to create into the PROFILE global. Honors the # --profile flag when given; otherwise prompts interactively (default cms), or # uses cms under --yolo. choose_profile() { if [[ ${USE_CONFIG:-0} -eq 1 ]]; then return 0; fi # Non-Drupal builds don't use a profile (create_drupal_site self-skips). if [[ "${IS_DRUPAL:-true}" != "true" ]]; then return 0; fi if [[ -n "$PROFILE" ]]; then return fi if [[ $YOLO -eq 1 ]]; then PROFILE="cms" return fi select_one "Which Drupal installation would you like?" \ "Drupal CMS (drupal/cms — recommended)" \ "Site template (a pre-built Drupal CMS design — pick one next)" \ "Standard (drupal/recommended-project, standard profile)" \ "Minimal (drupal/recommended-project, minimal profile)" case "$MENU_INDEX" in 0) PROFILE="cms" ;; 1) PROFILE="template"; choose_site_template ;; 2) PROFILE="standard" ;; 3) PROFILE="minimal" ;; esac } # src/lib/shared/prompts/choose_run_mode.sh # Ask up front how to run the installer: trust the defaults (YOLO=1), build # from a config source entered now (a git URL / local directory / saved config # name — the same classification the positional `source` argument gets), pick # choices interactively (YOLO=0), or — only when running from the installed # global command — install from a saved config ("Use a config"). The config # loader/exporter is pure bash (no jq), so the only gate is the global install. # Skipped when --yolo was already passed on the command line. choose_run_mode() { [[ $YOLO -eq 1 ]] && return local -a modes=( "Trust the script — recommended defaults, no more questions" "Build from a config — a git repo URL, a local directory, or a saved config" "Let me pick my own choices" ) # Config-driven installs are only offered from the installed global command. if [[ "${INVOCATION:-}" == "global" ]]; then modes+=("Use a config — install from a saved config file") fi select_one "How would you like to run the installer?" "${modes[@]}" case "$MENU_INDEX" in 0) YOLO=1 ;; 1) _run_mode_config_source ;; 2) YOLO=0 ;; 3) use_config ;; # only reachable when the option above was added esac } _run_mode_read_source() { local s="" read -r s /dev/null || s="" printf '%s' "$s" } _run_mode_config_source() { echo -e "${BOLD}Where is the config?${RESET} A git repo URL (optionally @), a local directory, or a saved config name:" local src; src="$(_run_mode_read_source)" [[ -n "$src" ]] || die "No config source entered." resolve_config_source "$src" case "$SOURCE_KIND" in url) load_config_url "$SOURCE_VALUE" "$SOURCE_REF" ;; path) load_config_path "$SOURCE_VALUE" ;; global) load_global_config "$SOURCE_VALUE" ;; esac } # src/lib/shared/prompts/choose_site_template.sh # Front-loaded sub-prompt for the "Site template" profile. Lists the free Drupal # CMS site templates — each with a title, one-line description and preview URL — # and lets the user pick one by number or type a template's project name (its # "data name") themselves. Sets SITE_TEMPLATE to the drupal.org project short # name; create_drupal_site then runs `composer require drupal/` and installs # with `drush si ../recipes/`. # # A numbered list (not the arrow-key select_one) is used deliberately: each entry # needs a description and a long preview URL on screen while choosing, which the # single-line, self-redrawing select_one menu can't show without wrapping. # # The list is the FREE templates from https://new.drupal.org/browse/site-templates # (paid ones excluded). Refresh it — titles, descriptions, URLs and the machine # names below — if that page changes. choose_site_template() { if [[ ${USE_CONFIG:-0} -eq 1 ]]; then return 0; fi # --site-template already set it (apply_cli_flags) — don't prompt. if [[ -n "${SITE_TEMPLATE:-}" ]]; then return 0; fi SITE_TEMPLATE="" # data name (drupal.org project short name) | title | one-line description | preview URL local -a data=(haven healthcare byte mercury_demo provus_edu caresphere archimedes local convene convivial_gov everbright) local -a title=("Haven" "Healthcare" "Byte" "Mercury Demo" "Provus EDU" "CareSphere" "Archimedes" "Local" "Convene" "Convivial Gov" "Everbright") local -a desc=( "Bright, warm design for nonprofits — landing pages, blog, projects, donations" "Patient-centered template for medical clinics" "SaaS product site with drag-and-drop landing pages and a blog" "Showcases the Mercury theme and its components" "Higher-education site — program pages and a faculty directory" "Structured starting point for nonprofits and community groups" "School website — landing pages, academics and news" "For councils and community services helping people find local information" "Event & conference marketing — schedules, speakers, registration" "Government site built on Drupal Canvas and Recipes (by Morpht)" "Portfolio site with a sharp black-and-white design" ) local -a url=( "https://new.drupal.org/site-template/haven" "https://new.drupal.org/site-template/healthcare" "https://new.drupal.org/site-template/byte" "https://new.drupal.org/site-template/mercury-demo" "https://new.drupal.org/site-template/provusredu" "https://new.drupal.org/site-template/caresphere-non-profit-ngo" "https://new.drupal.org/site-template/archimedes" "https://new.drupal.org/site-template/local" "https://new.drupal.org/site-template/convene-event-conference-marketing-site-template" "https://new.drupal.org/site-template/convivial-gov" "https://new.drupal.org/site-template/everbright" ) echo "" echo -e "${BOLD}Free Drupal CMS site templates:${RESET}" echo "" local i for i in "${!data[@]}"; do printf ' %b%2d)%b %b%s%b — %s\n' "${GREEN}${BOLD}" "$((i + 1))" "$RESET" "$BOLD" "${title[$i]}" "$RESET" "${desc[$i]}" printf ' %b%s%b\n' "$DIM" "${url[$i]}" "$RESET" done echo "" local choice while true; do printf '%bPick a number [1-%d], or type a template project name (e.g. haven):%b ' \ "$BOLD" "${#data[@]}" "$RESET" >/dev/tty IFS= read -r choice = 1 && choice <= ${#data[@]} )); then SITE_TEMPLATE="${data[$((choice - 1))]}" break fi warn "Enter a number between 1 and ${#data[@]}, or a template project name." continue fi # Anything non-numeric is treated as a project name; accept "drupal/haven" too. SITE_TEMPLATE="${choice#drupal/}" break done ok "Site template: ${SITE_TEMPLATE}" } # src/lib/shared/prompts/choose_skills.sh choose_skills() { if [[ ${USE_CONFIG:-0} -eq 1 ]]; then return 0; fi # --add-skills already built the final list (defaults + the added ones). if [[ ${SKILLS_SET:-0} -eq 1 ]]; then return 0; fi SKILLS_SPEC=() local -a specs=() line while IFS= read -r line; do specs+=("$line"); done < <(_curated_skill_specs) if [[ $YOLO -eq 1 || ${SKILLS_DEFAULT:-0} -eq 1 ]]; then while IFS= read -r line; do SKILLS_SPEC+=("$line"); done < <(_default_skill_specs) return 0 fi local -a pre=() local i for (( i = 0; i < SKILL_DEFAULT_COUNT; i++ )); do pre+=("$i"); done MENU_PRESELECT="${pre[*]}" select_many "Which agent skills would you like to add? (via npx skills)" \ "Drupal Module Finder (ivanboring) (default) — semantic search for Drupal modules" \ "Superpowers (default) — planning, TDD, reviews, git worktrees" \ "Find Skills (default) — add more skills to your agent later" \ "Agent Browser (default) — browser automation for your agents" \ "Ponytail — lazy senior dev rules: reuse, prefer native/simple, less code" \ "Drupal Claude Skills (grasmash) — Drupal skill collection: config management, testing, DDEV, Canvas, security" \ "Drupal Workflow (gkastanis) — Drupal skill collection: patterns, testing, security, caching, Twig, planning" \ "Drupal AI (edutrul) — Drupal AI module skills" \ "Drupal Agent Resources (madsnorgaard) — Drupal agent resources and references" \ "Drupal Canvas (drupal-canvas) — skills specific to building with Drupal Canvas" local idx # shellcheck disable=SC2086 for idx in $MENU_SELECTED; do SKILLS_SPEC+=("${specs[$idx]}") done } # src/lib/shared/prompts/collect_answers.sh register_builtin_handlers() { on ask_questions 10 choose_profile on ask_questions 20 choose_coding_agent on ask_questions 30 choose_skills on ask_questions 40 choose_deny_rules on ask_questions 45 choose_extras on ask_questions 85 choose_db_path on ask_questions 90 ask_config_questions # Built-in feature handlers (fire on presence, not on a flag). on ddev_addon_added 0 _selenium_headed_rewrite # Gitignore .ddev/.env only AFTER composer create-project (a root .gitignore # written earlier would make the project dir non-empty and break create-project). on create_drupal_site_done 0 _ensure_gitignore_env } step_collect_answers() { emit ask_questions } # src/lib/steps/_apply_composer_config.sh DRUPAL_DEFAULT_ALLOW_PLUGINS="drupal/ai_best_practices phpstan/extension-installer dealerdirect/phpcodesniffer-composer-installer" _apply_composer_config() { step "Configuring Composer (minimum-stability=${COMPOSER_STABILITY:-dev})..." ddev exec composer config minimum-stability "${COMPOSER_STABILITY:-dev}" || warn "Could not set minimum-stability." ddev exec composer config prefer-stable true || warn "Could not set prefer-stable." local -a plugins=() # Deliberate word-split: DRUPAL_DEFAULT_ALLOW_PLUGINS is a space-separated list. # shellcheck disable=SC2206 [[ "${IS_DRUPAL:-true}" == "true" ]] && plugins+=($DRUPAL_DEFAULT_ALLOW_PLUGINS) plugins+=( ${COMPOSER_ALLOW_PLUGINS[@]+"${COMPOSER_ALLOW_PLUGINS[@]}"} ) local p for p in ${plugins[@]+"${plugins[@]}"}; do ddev exec composer config --no-plugins "allow-plugins.${p}" true || warn "Could not allow plugin '${p}'." done return 0 } # src/lib/steps/_ensure_gitignore_env.sh _ensure_gitignore_env() { if ! grep -qxF '.ddev/.env' .gitignore 2>/dev/null; then printf '%s\n' '.ddev/.env' >> .gitignore ok "Added .ddev/.env to .gitignore" fi return 0 } # src/lib/steps/_gh_manual_help.sh _gh_manual_help() { echo "" echo -e "${DIM}To push to GitHub manually later:" echo " 1. Install gh: https://cli.github.com" echo " 2. Run: gh auth login" echo " 3. Run: gh repo create --private --source=. --push${RESET}" echo "" } # src/lib/steps/_welcome_text_substitute.sh # {TOKEN} placeholder + color expansion for a single append_welcome_text/ # replace_welcome_text paragraph. Built-in tokens (project name, site URL, # admin credentials) and any config `questions` answer (by its own env-var # name, via the global ANSWERS associative array) are always expanded; an # unrecognized {TOKEN} is left untouched so a typo is visible in the output # rather than silently discarded. Color tokens additionally gate on stdout # being a real terminal, so piped/redirected output stays clean, and every # paragraph gets an unconditional trailing reset so an unclosed color token # can never bleed into the next paragraph or the rest of the terminal. _welcome_text_colors_enabled() { [[ -t 1 ]]; } _welcome_text_substitute() { # -> substituted text on stdout local pname="$1" site_url="$2" text="$3" k text="${text//\{PROJECT_NAME\}/$pname}" text="${text//\{SITE_URL\}/$site_url}" text="${text//\{DRUPAL_USERNAME\}/admin}" text="${text//\{DRUPAL_PASSWORD\}/admin}" text="${text//\{DRUPAL_LOGIN_URL\}/${site_url}/user}" for k in ${ANSWERS[@]+"${!ANSWERS[@]}"}; do text="${text//\{${k}\}/${ANSWERS[$k]}}" done if _welcome_text_colors_enabled; then text="${text//\{BOLD\}/$BOLD}" text="${text//\{DIM\}/$DIM}" text="${text//\{GREEN\}/$GREEN}" text="${text//\{YELLOW\}/$YELLOW}" text="${text//\{RED\}/$RED}" text="${text//\{RESET\}/$RESET}" text="${text}${RESET}" else text="${text//\{BOLD\}/}" text="${text//\{DIM\}/}" text="${text//\{GREEN\}/}" text="${text//\{YELLOW\}/}" text="${text//\{RED\}/}" text="${text//\{RESET\}/}" fi printf '%s' "$text" } # src/lib/steps/_write_ddev_agent_command.sh # Write a .ddev/commands/web/ command so `ddev ` starts the agent # inside the web container, in the directory matching the host's cwd. # , if given, is prepended before "$@" verbatim on every invocation # (e.g. an agent's auto-mode flag from a config's agent_auto_mode). _write_ddev_agent_command() { local name="$1" extra_args="${2:-}" local cmd_line="${name} \"\$@\"" [[ -n "$extra_args" ]] && cmd_line="${name} ${extra_args} \"\$@\"" cat > ".ddev/commands/web/${name}" </dev/null; then _agent_handoff_skipped return fi # A config can name a command to hand off to instead of a bare shell. The step is already the # "agent handoff", and for a config whose whole purpose is to set up a tool, landing the operator # in a shell so they can type one known command is a step that config could take for them. local choice if [[ -n "${HANDOFF_COMMAND:-}" ]]; then echo -e "${DIM}Run ${RESET}${HANDOFF_COMMAND}${DIM} in your project? Press Enter to run it, Esc to skip...${RESET}" choice="$(_agent_handoff_read_choice)" # Only an explicit Enter runs it. Opening a shell is harmless and reversible, so it is the # right thing to do when nobody answers; running a command the config chose is neither, and a # run with nobody watching must not start work on its own. That is why the timeout is # distinguishable from Enter here and not in the branch below. if [[ "$choice" != "open" ]]; then _agent_handoff_skipped echo -e "${DIM}Then run: ${RESET}${HANDOFF_COMMAND}" return fi echo -e "${DIM}Running ${RESET}${HANDOFF_COMMAND}${DIM} in $(pwd)...${RESET}" echo "" # Same exec/tty reasoning as the shell branch below: the installer cannot cd the caller's # shell, and the command needs a real stdin because it may well ask something. exec "${SHELL:-bash}" -c "$HANDOFF_COMMAND" 0)) || break IFS= read -rsn1 -t "$remaining" key /dev/null || true if [[ -z "$rest" ]]; then printf 'skip' return 0 fi ;; *) ;; # any other key: keep waiting esac done printf 'timeout' } # src/lib/steps/announce_complete.sh # Print the final "installation complete" banner. Login details, the command # list, next steps, and the global-install tip are printed later by print_welcome # (via agent_handoff); this just marks the end of the build. Launching the site # is platform-specific and left to the caller. step_announce_complete() { _cleanup_config_url_repo echo "" echo -e "${BOLD}${GREEN}Installation complete!${RESET}" echo "" } # src/lib/steps/configure_ddev.sh # Write the DDEV project config (project type + docroot). Runs BEFORE any add-on # fetch or `ddev start`, so the whole `.ddev/` config is staged before the single # build. Same on macOS and Linux. Docroot defaults to `web` for Drupal builds # and the project root (`.`) for non-Drupal builds — a config's `docroot` # overrides either default (e.g. a Storybook build serving `storybook-static`). step_configure_ddev() { if [[ -z "${DOCROOT:-}" ]]; then if [[ "${IS_DRUPAL:-true}" == "true" ]]; then DOCROOT="web"; else DOCROOT="."; fi fi step "Configuring DDEV..." ddev config --project-type="${DDEV_PROJECT_TYPE:-drupal11}" --docroot="$DOCROOT" } # src/lib/steps/create_drupal_site.sh # Create the Drupal site with DDEV. Same flow on macOS and Linux: a single # web/ docroot with the composer root at the project dir. PROFILE selects the # package + install profile. # # In existing-codebase config-url mode (db_import/db_import_path set), this # step is a no-op — step_provision_existing_codebase (composer install + # ddev import-db) stands up the site instead of create-project/drush si. # # Runs AFTER the single vanilla `ddev start` (start_ddev), so `ddev composer # create-project` scaffolds Drupal into the already-running container. Coding- # agent add-ons are staged before that start (they only touch .ddev/), but the # config `ddev_addons` are added AFTER this step (install_ddev_addons + # restart_ddev) because they can drop files outside .ddev/ that would make # create-project refuse a non-empty project dir. step_create_drupal_site() { if [[ "${IS_DRUPAL:-true}" != "true" ]]; then ok "is_drupal=false — skipping create_drupal_site."; return 0; fi if [[ "${DB_IMPORT:-false}" == "true" || -n "${DB_IMPORT_PATH:-}" ]]; then ok "db_import — skipping create-project/drush si (existing codebase; step_provision_existing_codebase handles this build)." return 0 fi local package profile case "$PROFILE" in cms|template) package="drupal/cms"; profile="" ;; standard) package="drupal/recommended-project"; profile="standard" ;; minimal) package="drupal/recommended-project"; profile="minimal" ;; esac step "Installing Drupal — $PROFILE (this may take a few minutes)..." ddev composer create-project "$package" # Stability + allow-plugins BEFORE any require (needs composer.json to exist). _apply_composer_config step "Updating Composer dependencies..." ddev composer update --no-interaction ddev exec composer require --dev drush/drush if [[ "$PROFILE" == "template" && -n "${SITE_TEMPLATE:-}" ]]; then step "Adding the '${SITE_TEMPLATE}' site template..." ddev exec composer require "drupal/${SITE_TEMPLATE}" fi step "Running Drupal site install..." SITE_NAME="$(basename "$(pwd)")" if [[ "$PROFILE" == "template" && -n "${SITE_TEMPLATE:-}" ]]; then ddev drush si "../recipes/${SITE_TEMPLATE}" --site-name="$SITE_NAME" --account-name=admin --account-pass=admin --account-mail=admin@example.com -y else ddev drush si "$profile" --site-name="$SITE_NAME" --account-name=admin --account-pass=admin --account-mail=admin@example.com -y fi } # src/lib/steps/create_project_dir.sh # Choose the project directory, create it, and cd into it. Reads and updates # the PROJECT_DIR variable. Prompts for a project name — including in config # mode, since the project name is never stored in a config. Launched inside # an empty directory whose own name DDEV can use unchanged (never $HOME or # /), building in that directory itself is the default — Enter builds here, a # typed name still creates a new directory (#3601382). Otherwise Enter takes # the default name ONLY when its directory is free; when it already exists # there is no default and the user must type a name, so repeated or failed # runs can't quietly pile up "_1", "_2", ... directories # (#3601382). A plain --yolo run takes whichever default applies silently — # the empty launch directory, or the default name when its directory is free; # an existing default surfaces an error and prompts for a name (or dies with # guidance when there's no terminal to ask on). _launch_dir_is_free() { # local entries entries="$(ls -A "$1" 2>/dev/null)" || return 1 [[ -n "$entries" ]] && return 1 [[ "$1" -ef "${HOME:-}" ]] && return 1 [[ "$1" -ef / ]] && return 1 return 0 } _project_name_read() { local s read -r s .ddev.site URL this # installer prints — from the directory name, so a name DDEV would rewrite # (an underscore) or reject (a space) must never be adopted silently. here_ok=0 if _launch_dir_is_free "$here"; then if [[ "$base" =~ ^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?$ ]]; then here_ok=1 else warn "The current directory name ('$base') is not usable as a DDEV project name — creating a new project directory instead." fi fi if [[ $here_ok -eq 1 ]]; then # The launch directory is usable: building in it IS the default, instead # of nesting a fresh directory inside it (#3601382). --yolo (no config) # takes this default silently; otherwise Enter takes it and a typed name # still creates a new directory as before. if [[ $YOLO -eq 1 && ${USE_CONFIG:-0} -eq 0 ]]; then in_place=1 else while true; do echo -e "Enter a name for the new project ${DIM}[Enter = build here, in '${base}']${RESET}:" reply="$(_project_name_read)" if [[ -z "$reply" ]]; then in_place=1 break fi if [[ -d "$reply" ]]; then warn "'$reply' already exists — choose another name." continue fi PROJECT_DIR="$reply" break done fi else # The default name: the base name when its directory is free, NONE when it # already exists — the user must type a name instead (#3601382). suggested="" [[ -d "$PROJECT_DIR" ]] || suggested="$PROJECT_DIR" if [[ $YOLO -eq 1 && ${USE_CONFIG:-0} -eq 0 && ! -d "$PROJECT_DIR" ]]; then # --yolo takes the default name silently, but only when it is free. PROJECT_DIR="$suggested" else # Under --yolo we only reach here because the default directory already # exists — say so, and ask for a name rather than silently picking one. # With no terminal to prompt on, fail with clear guidance. if [[ $YOLO -eq 1 && ${USE_CONFIG:-0} -eq 0 ]]; then warn "The default project directory '$PROJECT_DIR' already exists in ${here}." if ! { : /dev/null; then die "Choose a different location, pass --name , or remove '$PROJECT_DIR', then re-run." fi echo -e "${YELLOW}Pick a different name below, or Ctrl-C and re-run from another directory.${RESET}" fi while true; do if [[ -n "$suggested" ]]; then echo -e "Enter a name for the new project ${DIM}[default: ${suggested}]${RESET}:" else echo -e "Enter a name for the new project ${DIM}('${PROJECT_DIR}' already exists here)${RESET}:" fi reply="$(_project_name_read)" reply="${reply:-$suggested}" if [[ -z "$reply" ]]; then warn "A name is required — '$PROJECT_DIR' already exists, so there is no default." continue fi if [[ -d "$reply" ]]; then warn "'$reply' already exists — choose another name." continue fi PROJECT_DIR="$reply" break done fi fi if [[ $in_place -eq 1 ]]; then # Build in the launch directory itself: it becomes the project directory, # so no mkdir and no cd — downstream steps already run in it, and DDEV # derives the project name from it just like a created directory. PROJECT_DIR="$base" step "Using the current empty directory as the project: $here" else step "Creating project directory: $PROJECT_DIR" mkdir -p "$PROJECT_DIR" cd "$PROJECT_DIR" fi # GitHub setup needs the project dir to exist (its default repo name derives # from it), so ask it here — last, after the dir is created and cd'd into. github_setup } # src/lib/steps/detect_project_context.sh # Classify the current directory for the setup-site entry point: # ddev — a DDEV project (.ddev/config.yaml present) → management mode # composer-no-ddev — an existing Composer/Drupal project but no DDEV → needs DDEV # none — empty/greenfield → the normal new-site build # Sets PROJECT_CONTEXT. Replaces the old detect_existing_project (which only knew # "existing composer project → add ai_best_practices"). detect_project_context() { if [[ -f ".ddev/config.yaml" ]]; then PROJECT_CONTEXT="ddev" elif [[ -f "composer.json" || -f "web/composer.json" ]]; then PROJECT_CONTEXT="composer-no-ddev" else PROJECT_CONTEXT="none" fi } # src/lib/steps/export_drupal_config.sh # Export the Drupal site configuration to config/sync in the project root. step_export_drupal_config() { if [[ "${IS_DRUPAL:-true}" != "true" ]]; then ok "is_drupal=false — skipping export_drupal_config."; return 0; fi step "Exporting Drupal configuration..." ddev exec mkdir -p /var/www/html/config/sync ddev drush config:export --destination=/var/www/html/config/sync -y ok "Configuration exported to config/sync" } # src/lib/steps/github_finalize.sh # Create and push the GitHub repo using the answers collected up front by # github_setup (GH_ENABLED / GH_REPO_NAME / GH_VISIBILITY). No-op when GitHub # setup was declined. This runs during the unattended work phase, so it asks no # questions of its own — the only interaction is gh's own `gh auth login`. step_github_finalize() { # A config-url build's .git/remote must survive untouched — never git init # over it or create a new GitHub repo for it. [[ ${CONFIG_FROM_URL:-0} -eq 1 ]] && return 0 # Note the explicit `return 0`: a bare `return` here would propagate the failed # test's exit status (1), and under `strict: true` (set -e) that non-zero return # from this bare call aborts the whole install before the completion/handoff steps. [[ ${GH_ENABLED:-0} -eq 1 ]] || return 0 # ── 1. Install gh CLI ─────────────────────────────────────────────────────── step "Checking GitHub CLI (gh)..." if command -v gh &>/dev/null; then ok "gh already installed" else warn "gh not found — installing..." local gh_ok=1 os distro_id os="$(uname -s)" if [[ "$os" == "Darwin" ]]; then brew install gh || gh_ok=0 else # /etc/os-release is provided by the OS, not this repo. # shellcheck source=/dev/null source /etc/os-release 2>/dev/null || true distro_id="${ID_LIKE:-$ID}" if [[ "$distro_id" == *debian* || "$distro_id" == *ubuntu* ]]; then sudo apt-get install -y gh 2>/dev/null || gh_ok=0 elif [[ "$distro_id" == *fedora* || "$distro_id" == *rhel* || "$distro_id" == *centos* ]]; then sudo dnf install -y gh 2>/dev/null || gh_ok=0 else gh_ok=0 fi fi if [[ $gh_ok -eq 0 ]] || ! command -v gh &>/dev/null; then warn "GitHub CLI installation failed; skipping GitHub push." _gh_manual_help return 0 fi ok "gh installed" fi # ── 2. Authenticate (gh's own interactive flow) ───────────────────────────── step "Checking GitHub authentication..." if gh auth status &>/dev/null; then ok "Already authenticated with GitHub" else warn "Not authenticated — running gh auth login..." if ! gh auth login .gitignore </dev/null 2>&1; do name="${GH_REPO_NAME}_${n}" (( n++ )) done fi if gh repo create "$name" "$GH_VISIBILITY" --source=. --push; then ok "Repository created and pushed: https://github.com/${gh_user}/${name}" else warn "Could not create repository '$name'. Push manually later." _gh_manual_help fi } # src/lib/steps/github_setup.sh # Front-loaded GitHub questions: whether to create a repo and, if so, its name # and visibility. Sets GH_ENABLED / GH_REPO_NAME / GH_VISIBILITY. Opt-in and # defaults to "no". Skipped on a plain --yolo run, but always asked in config mode # (USE_CONFIG=1) — GitHub setup is never stored in a config. The gh install/auth # and the actual push happen later in github_finalize (no further questions there). github_setup() { # A config-url build's .git already points at the config repo's remote — # never prompt to create a new one; the developer pushes back manually. if [[ ${CONFIG_FROM_URL:-0} -eq 1 ]]; then if [[ ${GH_FROM_FLAG:-0} -eq 1 ]]; then warn "--github-setup ignored: this is a --config-url build, so the project's existing git remote is kept instead." fi GH_ENABLED=0 return 0 fi # --github-setup already set GH_ENABLED/GH_REPO_NAME/GH_VISIBILITY — keep them. if [[ ${GH_FROM_FLAG:-0} -eq 1 ]]; then ok "GitHub repo (from --github-setup): ${GH_REPO_NAME}" return 0 fi GH_ENABLED=0 [[ $YOLO -eq 1 && ${USE_CONFIG:-0} -eq 0 ]] && return local default_name step "GitHub repository setup..." MENU_DEFAULT=1 # default: No select_one "Set up a GitHub repository for this project?" \ "Yes — create a repo and push to it" \ "No — skip GitHub" if [[ $MENU_INDEX -ne 0 ]]; then ok "Skipping GitHub setup." return 0 fi GH_ENABLED=1 default_name="$(basename "$(pwd)")" echo -e "Enter a name for your GitHub repository ${DIM}[default: ${default_name}]${RESET}:" read -r GH_REPO_NAME ` wrapper for # each. Each agent maps to an e0ipso/ddev-assistant- add-on that bakes the # agent CLI into the web image and mirrors the host's agent config into the # container. These add-ons only write inside .ddev/ (image build + .ddev/commands), # so — unlike the config `ddev_addons`, which can drop settings files outside # .ddev/ and are therefore deferred until after create-project — they are safe to # stage HERE, before the single vanilla `ddev start`, and build into it with no # extra restart. (`ddev composer create-project` only needs the dir empty except # .ddev/.) # # The add-ons put the CLI on $PATH (`ddev exec `) but don't add a # `ddev ` command, so we still write the wrappers (agent_handoff does # `ddev `). No-op when AGENTS is empty. # # When a config's agent_auto_mode is true, each agent's own unattended/ # auto-approval flag is baked into its wrapper (see _agent_auto_mode_flag). step_install_coding_agents() { [[ ${#AGENTS[@]} -eq 0 ]] && return step "Adding coding agent add-on(s): ${AGENTS[*]}" mkdir -p .ddev/commands/web # Codex's CLI needs Node.js 22+; pin it before the first build so the image # comes up ready. Harmless for the other agents. if [[ " ${AGENTS[*]} " == *" codex "* ]]; then ddev config --nodejs-version=22 >/dev/null fi local a addon extra_args for a in "${AGENTS[@]}"; do case "$a" in claude|codex|opencode) addon="e0ipso/ddev-assistant-${a}" ;; *) continue ;; esac step "Fetching ${addon}..." if ddev add-on get "$addon"; then extra_args="" [[ "${AGENT_AUTO_MODE:-false}" == "true" ]] && extra_args="$(_agent_auto_mode_flag "$a")" _write_ddev_agent_command "$a" "$extra_args" emit coding_agent_added "$a" else warn "Could not add ${addon} — skipping ${a}. Re-run 'ddev add-on get ${addon}' then 'ddev restart' to retry." fi done ok "Coding agent add-on(s) staged — they build into the container on first start (ddev ${AGENTS[0]})." } _agent_auto_mode_flag() { case "$1" in claude) printf '%s' "--permission-mode auto" ;; codex) printf '%s' "--dangerously-bypass-approvals-and-sandbox" ;; opencode) printf '%s' "--auto" ;; *) printf '' ;; esac } # src/lib/steps/install_skills.sh _skill_agent_targets() { local -a targets=() a if [[ ${#AGENTS[@]} -eq 0 ]]; then targets=(claude-code codex opencode) else for a in "${AGENTS[@]}"; do case "$a" in claude) targets+=(claude-code) ;; codex) targets+=(codex) ;; opencode) targets+=(opencode) ;; esac done fi printf '%s' "${targets[*]}" } _install_skill_specs() { [[ $# -eq 0 ]] && return 0 local -a targets=() IFS=' ' read -r -a targets <<< "$(_skill_agent_targets)" local entry repo names name t cmd local -a name_arr for entry in "$@"; do repo="${entry%%|*}" names="${entry#*|}" # Split on spaces WITHOUT pathname expansion — an unquoted `$names` would let # a literal "*" (the all-skills spec) glob-expand against the host cwd here, # before it ever reaches the single-quoted container command below. IFS=' ' read -r -a name_arr <<< "$names" for name in "${name_arr[@]}"; do cmd="npx --yes skills add '${repo}' --skill '${name}'" for t in "${targets[@]}"; do cmd="${cmd} -a ${t}"; done cmd="${cmd} -y" if ddev exec bash -c "$cmd"; then ok "Added skill '${name}' from ${repo}" emit skill_added "$repo" "$name" else warn "Could not add skill '${name}' from '${repo}' — continuing." fi done done return 0 } step_install_skills() { [[ ${#SKILLS_SPEC[@]} -eq 0 ]] && return 0 step "Installing agent skills..." _install_skill_specs ${SKILLS_SPEC[@]+"${SKILLS_SPEC[@]}"} return 0 } # src/lib/steps/launch_site.sh # Open the site in the default browser. `ddev launch` hands off to the host's # opener and returns immediately, but the installer process exits moments later — # a plain foreground or backgrounded launch can get torn down as the script ends # before the opener has finished, and the browser never actually appears. Detach # it with nohup + its own stdin/stdout so it survives independently and opens the # site. Best-effort only: the welcome summary also lists `ddev launch` so the user # can always open it by hand. # # A config's `launch_path` overrides the default project root — e.g. a build # whose docroot has nothing to serve (is_drupal:false with a dev-server daemon # reached on its own web_extra_exposed_ports port) sets this to ":" so # `ddev launch` opens the actual live URL instead of a dead-end 403. _resolve_launch_path() { local launch_path="${LAUNCH_PATH:-}" local line if [[ -f .ddev/.env ]] && grep -q '^LAUNCH_PATH=' .ddev/.env; then line="$(grep '^LAUNCH_PATH=' .ddev/.env | tail -1)" line="${line#*=}"; line="${line#\"}"; line="${line%\"}" [[ -n "$line" ]] && launch_path="$line" fi printf '%s' "$launch_path" } step_launch_site() { step "Opening your site in the browser..." local launch_path launch_path="$(_resolve_launch_path)" if [[ -n "$launch_path" ]]; then nohup ddev launch "$launch_path" >/dev/null 2>&1 /dev/null 2>&1 .agents/skills for Claude Code..." # Clear whatever .claude/skills currently is so we can (re)create the symlink: # drop a stale symlink or an empty directory, but never a non-empty real dir # (that would be someone's real skills — link the entries instead). if [[ -L .claude/skills ]]; then rm -f .claude/skills elif [[ -d .claude/skills ]]; then if [[ -n "$(ls -A .claude/skills 2>/dev/null)" ]]; then _link_claude_skills_per_entry return 0 fi rmdir .claude/skills fi mkdir -p .claude if ln -s ../.agents/skills .claude/skills; then ok "Linked .claude/skills -> .agents/skills" else warn "Could not link .claude/skills to .agents/skills." fi } # Fallback: .claude/skills is a non-empty real directory (e.g. Claude already had # its own skills), so symlink each shared skill into it individually instead of # replacing the whole directory. _link_claude_skills_per_entry() { local s name for s in .agents/skills/*/; do [[ -d "$s" ]] || continue name="$(basename "$s")" [[ -e ".claude/skills/${name}" || -L ".claude/skills/${name}" ]] && continue ln -s "../../.agents/skills/${name}" ".claude/skills/${name}" \ || warn "Could not link shared skill '${name}' into .claude/skills." done ok "Linked shared skills into the existing .claude/skills directory" } # src/lib/steps/print_welcome.sh # Print the final "you're all set" summary: the admin login, the handful of # ddev commands the user will actually reach for, their next step, and — when run # straight from curl — how to install this tool globally. Shown at the very end # of a successful build (via agent_handoff). print_welcome() { local agent url pname # DDEV derives the project name (and default URL) from the directory name, # lowercased. PROJECT_DIR is that bare name at this point. Computed first # (even for the replace-short-circuit branch below) so {PROJECT_NAME}/ # {SITE_URL}/etc. placeholders in a config's welcome text can resolve. pname="$(printf '%s' "$PROJECT_DIR" | tr '[:upper:]' '[:lower:]')" url="https://${pname}.ddev.site" # A config's replace_welcome_text fully owns the message — print it and # return before any of the standard summary is built or shown. Copy into a # local first so the count/expansion are set -u-safe: a no-config build never # initializes REPLACE_WELCOME_TEXT (same guard as APPEND_WELCOME_TEXT below). local -a replace=( ${REPLACE_WELCOME_TEXT[@]+"${REPLACE_WELCOME_TEXT[@]}"} ) if [[ ${#replace[@]} -gt 0 ]]; then local first=1 t for t in "${replace[@]}"; do [[ $first -eq 0 ]] && echo "" printf '%s\n' "$(_welcome_text_substitute "$pname" "$url" "$t")" first=0 done return fi # Right-pad the command to a fixed width so the descriptions line up; the # colour codes sit outside the padded field so they don't skew the alignment. _welcome_cmd() { printf ' %b%-15s%b %s\n' "$GREEN" "$1" "$RESET" "$2"; } echo "" if [[ "${IS_DRUPAL:-true}" == "true" ]]; then echo -e "${BOLD}${GREEN}🎉 Your Drupal site is ready!${RESET}" echo "" echo -e "${BOLD}Log in to the admin:${RESET}" echo -e " Site ${GREEN}${url}${RESET}" echo -e " Login ${GREEN}${url}/user${RESET}" echo -e " Username ${GREEN}admin${RESET}" echo -e " Password ${GREEN}admin${RESET}" else echo -e "${BOLD}${GREEN}🎉 Your project is ready!${RESET}" echo "" echo -e " Site ${GREEN}${url}${RESET}" fi echo "" echo -e "${BOLD}Handy commands${RESET} ${DIM}(run from inside the project directory):${RESET}" _welcome_cmd "ddev launch" "Open your site in the browser" for agent in "${AGENTS[@]}"; do _welcome_cmd "ddev ${agent}" "Start ${agent}, your coding agent, in the container" done _welcome_cmd "ddev describe" "Show every URL, service and the project status" _welcome_cmd "ddev stop" "Stop the project (your data is kept)" echo "" # Bring-your-own-agent: no ddev command exists, but install_skills has # already added the chosen skills for all three agents, so whatever they run # is ready to go. if [[ ${#AGENTS[@]} -eq 0 ]]; then echo -e "${BOLD}Bringing your own coding agent?${RESET} The skills are already set up for" echo -e "Claude Code, Codex and OpenCode in this project — just start the agent of" echo -e "your choice and begin." echo "" fi if [[ "${IS_DRUPAL:-true}" == "true" ]]; then echo -e "${BOLD}Next step:${RESET} run ${GREEN}ddev launch${RESET} to open your new site, then log in with ${GREEN}admin / admin${RESET}." else echo -e "${BOLD}Next step:${RESET} run ${GREEN}ddev launch${RESET} to open your new site." fi if [[ ${#AGENTS[@]} -gt 0 ]]; then echo -e " Or start building right away with ${GREEN}ddev ${AGENTS[0]}${RESET}." fi echo "" # Ran straight from curl? Nudge them to keep it around as a global command so # they can run it anywhere without the long curl line. if [[ "${INVOCATION:-}" == "remote" ]]; then echo -e "${BOLD}Like this command?${RESET} Install it globally and run ${GREEN}${GLOBAL_CMD}${RESET} from anywhere:" echo -e "${DIM} bash <(curl -fsSL ${INSTALLER_URL}) install${RESET}" echo "" fi # DDEV powers all of this — point users at its docs and sponsorship. echo -e "${BOLD}Powered by DDEV.${RESET}" echo -e "${DIM} Learn more: https://docs.ddev.com/en/stable/users/usage/${RESET}" echo -e "${DIM} Sponsor it: https://github.com/sponsors/ddev${RESET}" echo "" # How to tear the whole thing down again later. Use the short installed command # if this tool was run from a global install; otherwise the curl one-liner. local run_cmd if [[ "${INVOCATION:-}" == "global" ]]; then run_cmd="$GLOBAL_CMD" else run_cmd="bash <(curl -fsSL ${INSTALLER_URL})" fi echo -e "${DIM}Need to remove this site later? Clean it up with:${RESET}" echo -e "${DIM} ${run_cmd} delete${RESET}" echo "" # A config's append_welcome_text tacks extra paragraphs onto the very end. local t for t in ${APPEND_WELCOME_TEXT[@]+"${APPEND_WELCOME_TEXT[@]}"}; do printf '%s\n' "$(_welcome_text_substitute "$pname" "$url" "$t")" echo "" done } # src/lib/steps/provision_existing_codebase.sh # Stand up an existing-codebase config-url build (db_import / db_import_path # set): the clone IS the Drupal codebase already (its own composer.json, # web/, contrib vendored or lockfile-pinned) rather than something to # scaffold. This step vendors its dependencies and imports its database in # place of create_drupal_site's create-project + drush si. # # DB_IMPORT_PATH (a committed dump, resolved to an absolute path at load # time) wins over DB_PATH (an operator-supplied path via --db-path or the # front-loaded prompt) when both could apply — a config-committed dump takes # priority since it's already validated/present in the clone. step_provision_existing_codebase() { if [[ "${IS_DRUPAL:-true}" != "true" ]]; then return 0; fi if [[ "${DB_IMPORT:-false}" != "true" && -z "${DB_IMPORT_PATH:-}" ]]; then return 0; fi step "Installing Composer dependencies from the existing codebase..." ddev composer install --no-interaction || die "Could not run 'ddev composer install' on the cloned codebase." local db_path if [[ -n "${DB_IMPORT_PATH:-}" ]]; then db_path="$DB_IMPORT_PATH" else db_path="$DB_PATH" fi if [[ -z "$db_path" ]]; then die "db_import is true but no database dump path was resolved. Pass --db-path , or answer the prompt when asked." fi if [[ ! -f "$db_path" ]]; then die "Database dump not found: ${db_path}" fi step "Importing database from ${db_path}..." ddev import-db --file="$db_path" || die "Could not import the database from '${db_path}'." ok "Existing codebase installed and database imported" } # src/lib/steps/provision_from_config.sh step_provision_from_config() { [[ ${USE_CONFIG:-0} -eq 1 ]] || return 0 if [[ "${DB_IMPORT:-false}" == "true" || -n "${DB_IMPORT_PATH:-}" ]]; then ok "db_import — skipping provision_from_config (existing codebase's composer.lock + dump already represent full site state)." return 0 fi local i m recipe_path # Drupal builds already ran _apply_composer_config in create_drupal_site. A # non-Drupal project skipped that, so apply it here before requiring anything # (composer init first if there is no composer.json yet). if [[ "${IS_DRUPAL:-true}" != "true" && ${#COMPOSER_PROJECTS[@]} -gt 0 ]]; then ddev exec test -f composer.json || ddev exec composer init --no-interaction --name=drupalaibp/site _apply_composer_config fi if [[ ${#REPO_URLS[@]} -gt 0 ]]; then step "Registering composer repositories from config..." for i in "${!REPO_URLS[@]}"; do ddev exec composer config "repositories.drupalaibp${i}" "${REPO_TYPES[$i]}" "${REPO_URLS[$i]}" \ || warn "Could not register repository '${REPO_URLS[$i]}'." done fi if [[ ${#COMPOSER_PROJECTS[@]} -gt 0 ]]; then local -a with_deps=() [[ "${COMPOSER_WITH_ALL_DEPS:-false}" == "true" ]] && with_deps=(--with-all-dependencies) step "Requiring composer projects from config: ${COMPOSER_PROJECTS[*]}" ddev exec composer require --no-interaction ${with_deps[@]+"${with_deps[@]}"} "${COMPOSER_PROJECTS[@]}" \ || warn "Some composer projects failed to require." fi if [[ "${IS_DRUPAL:-true}" == "true" && ${#INSTALL_MODULES[@]} -gt 0 ]]; then step "Enabling modules from config: ${INSTALL_MODULES[*]}" ddev drush en -y "${INSTALL_MODULES[@]}" || warn "Some modules failed to enable." fi if [[ "${IS_DRUPAL:-true}" == "true" && ${#RECIPES[@]} -gt 0 ]]; then for m in "${RECIPES[@]}"; do # Apply only — never `composer require` here (fetching is composer_projects' # job). A drupal/ entry is a contrib recipe fetched via # composer_projects and installed under the project-root recipes/ dir; ddev # drush runs from the web/ docroot, so reach it at ../recipes/. # Anything else is a core / already-present recipe locator passed verbatim. if [[ "$m" == drupal/* ]]; then recipe_path="../recipes/${m#drupal/}" else recipe_path="$m" fi step "Applying recipe: ${m}" ddev drush recipe "$recipe_path" -y || warn "Could not apply recipe '${m}'." done fi return 0 } # src/lib/steps/require_ddev_message.sh # Shown when the installer is run inside an existing Composer/Drupal project that # is NOT a DDEV project. Management mode only works against a running DDEV # instance, so point the user at DDEV and stop (exit 0 — this is a normal, # expected outcome, not an error). require_ddev_message() { echo "" echo -e "${YELLOW}This looks like an existing project, but it isn't a DDEV project.${RESET}" echo -e "Management mode works against a running DDEV instance. Set up DDEV here first:" echo -e " ${BOLD}Install DDEV:${RESET} https://ddev.readthedocs.io/en/stable/users/install/" echo -e " ${BOLD}Add to a project:${RESET} run ${BOLD}ddev config${RESET} then ${BOLD}ddev start${RESET} in this directory" echo -e "Then re-run this installer to add/remove AI tooling." echo "" exit 0 } # src/lib/steps/setup_core_dev.sh # Add the built-in Drupal dev defaults (core-dev + ai_best_practices) and write a # starter phpunit.xml into the project root (the composer root, with the docroot # at web/). Allow-plugins for their Composer plugins is already applied by # _apply_composer_config, so this only requires the packages + writes the file. step_setup_core_dev() { if [[ "${IS_DRUPAL:-true}" != "true" ]]; then ok "is_drupal=false — skipping setup_core_dev."; return 0; fi if [[ "${DB_IMPORT:-false}" == "true" || -n "${DB_IMPORT_PATH:-}" ]]; then ok "db_import — skipping setup_core_dev (existing codebase owns its own dev tooling)." return 0 fi step "Installing built-in Drupal dev defaults (core-dev + ai_best_practices)..." ddev exec composer require --dev drupal/core-dev --no-interaction --with-all-dependencies \ || warn "Could not install drupal/core-dev." # ai_best_practices ships only a dev release, so require it explicitly with # :@dev (a bare `require drupal/ai_best_practices` fails to resolve). Its # allow-plugins trust is already set by _apply_composer_config. Required # separately from core-dev so one failing doesn't block the other. ddev exec composer require --dev 'drupal/ai_best_practices:@dev' --no-interaction \ || warn "Could not install drupal/ai_best_practices." cat > phpunit.xml <<'PHPUNIT' ./web/modules/custom/my_module/tests/src/ PHPUNIT ok "core-dev + ai_best_practices installed and phpunit.xml written" } # src/lib/steps/step_answers_to_env.sh step_answers_to_env() { [[ ${#CONFIG_QUESTION_KEYS[@]} -eq 0 && -z "${DOCROOT:-}" ]] && return 0 step "Writing question answers to .ddev/.env..." mkdir -p .ddev local envfile=".ddev/.env" key val tmp touch "$envfile" tmp="$(mktemp)" cp "$envfile" "$tmp" for key in "${CONFIG_QUESTION_KEYS[@]}"; do val="${ANSWERS[$key]-}" val="${val//\"/\\\"}" grep -v "^${key}=" "$tmp" > "${tmp}.n" || true mv "${tmp}.n" "$tmp" printf '%s="%s"\n' "$key" "$val" >> "$tmp" done # DOCROOT: so a config's hooks (e.g. a build step) can target the same # docroot that `configure_ddev` set, instead of hardcoding it twice. if [[ -n "${DOCROOT:-}" ]]; then grep -v '^DOCROOT=' "$tmp" > "${tmp}.n" || true mv "${tmp}.n" "$tmp" printf 'DOCROOT="%s"\n' "$DOCROOT" >> "$tmp" fi mv "$tmp" "$envfile" chmod 600 "$envfile" ok "Wrote ${#CONFIG_QUESTION_KEYS[@]} value(s) to .ddev/.env" } # src/lib/steps/step_import_config.sh step_import_config() { [[ "${IS_DRUPAL:-true}" == "true" ]] || return 0 if [[ "${DB_IMPORT:-false}" == "true" || -n "${DB_IMPORT_PATH:-}" ]]; then ok "db_import — skipping import_config (the imported dump already has final config)." return 0 fi local src="${LAUNCH_DIR:-.}/.drupalaibp/config" [[ -d "$src" ]] || return 0 local -a _cfg=( "${src}"/*.yml ) if [[ ! -e "${_cfg[0]}" ]]; then warn ".drupalaibp/config/ contains no *.yml — skipping config import." return 0 fi step "Importing Drupal config from .drupalaibp/config/..." mkdir -p .drupalaibp/config # Mirror it into the project unless it already resolves there (mount it in the # container). ddev drush runs from web/, so ../ reaches the project-root copy. if [[ ! "$src" -ef ".drupalaibp/config" ]]; then cp -R "${src}/." .drupalaibp/config/ || warn "Could not copy .drupalaibp/config into the project." fi ddev drush config:import --partial --source="../.drupalaibp/config" -y \ || warn "drush config:import failed — check .drupalaibp/config/ and that any modules it references are enabled." ok "Config imported from .drupalaibp/config/" } # src/lib/steps/step_install_ddev_addons.sh step_install_ddev_addons() { [[ ${#DDEV_ADDONS[@]} -eq 0 ]] && return 0 step "Adding DDEV add-ons: ${DDEV_ADDONS[*]}" local addon for addon in "${DDEV_ADDONS[@]}"; do step "Fetching add-on: ${addon}" if ddev add-on get "$addon"; then emit ddev_addon_added "$addon" else warn "Could not add DDEV add-on '${addon}' — continuing." fi done return 0 } # src/lib/steps/step_install_extras.sh step_install_extras() { [[ "${IS_DRUPAL:-true}" == "true" ]] || return 0 if [[ "${DB_IMPORT:-false}" == "true" || -n "${DB_IMPORT_PATH:-}" ]]; then ok "db_import — skipping install_extras (existing codebase owns its own tooling choices)." return 0 fi local -a ids=( ${EXTRAS[@]+"${EXTRAS[@]}"} ) [[ ${#ids[@]} -gt 0 ]] || return 0 local id for id in "${ids[@]}"; do if ! declare -F "extra_${id}" >/dev/null 2>&1; then warn "Unknown extra '${id}' — skipped. Known extras: $(_extras_ids)" continue fi if "extra_${id}"; then emit extra_installed "$id" else warn "Extra '${id}' did not install cleanly — continuing." fi done return 0 } # src/lib/steps/step_restart_ddev.sh step_restart_ddev() { [[ ${#DDEV_ADDONS[@]} -eq 0 ]] && return 0 step "Restarting DDEV to apply add-ons: ${DDEV_ADDONS[*]}" # Fatal, not best-effort: if the restart fails the add-ons are NOT applied and # the container may be down, so the composer/drush/recipe steps that follow # would run against a broken site and the build would falsely report success. if ! ddev restart; then die "ddev restart failed after adding add-ons (${DDEV_ADDONS[*]}) — they are not applied and the site may be down. Resolve the DDEV error above, run 'ddev restart', then continue." fi return 0 } # src/lib/steps/step_start_ddev.sh step_start_ddev() { emit before_ddev_start step "Starting DDEV..." ddev start emit ddev_started } # src/lib/steps/write_agents_md_block.sh # Write/refresh the one-line-installer managed block at the end of AGENTS.md. # The block is delimited by markers and regenerated on every run (anything # inside the markers is overwritten). Its content is DATA-DRIVEN: the base # paragraphs and the per-skill guidance come from a JSON document — the built-in # default (_agents_md_default_json) unless an override is provided at # /overrides/agents_md.json. See src/lib/data/agents_md_config.sh # and overrides/README.md for the schema. # Build the managed-block body from a JSON document. Pure/testable — no file I/O # beyond reading the given JSON, no ddev. Args: . Reads the # global SKILLS_SPEC to decide which skill paragraphs to include. Replaces the # {{url}} token with the site URL. Prints the body to stdout. _build_agents_md_body() { local jf="$1" url="$2" json_load "$jf" local body="" i n line # Which skill names are being installed (the part after "repo|", space-joined). local names_all=" " s for s in ${SKILLS_SPEC[@]+"${SKILLS_SPEC[@]}"}; do names_all+="${s#*|} "; done n="$(json_count base)" for (( i = 0; i < n; i++ )); do line="$(json_get "base.${i}")" line="${line//\{\{url\}\}/$url}" if [[ -z "$body" ]]; then body="$line"; else body="${body}"$'\n\n'"${line}"; fi done if [[ "${IS_DRUPAL:-true}" == "true" ]]; then n="$(json_count drupal_base)" for (( i = 0; i < n; i++ )); do line="$(json_get "drupal_base.${i}")" line="${line//\{\{url\}\}/$url}" body="${body}"$'\n\n'"${line}" done fi local match text n="$(json_count skills)" for (( i = 0; i < n; i++ )); do match="$(json_get "skills.${i}.match")" [[ -z "$match" ]] && continue case "$names_all" in *" ${match} "*) text="$(json_get "skills.${i}.text")" text="${text//\{\{url\}\}/$url}" body="${body}"$'\n\n'"${text}" ;; esac done printf '%s' "$body" } # Resolve which AGENTS.md JSON document to use: a launch-directory override when # present and valid, else the built-in default (written to a temp file). Prints # the path to use on stdout. A malformed override warns and falls back. _agents_md_json_source() { local deffile override deffile="$(mktemp)" _agents_md_default_json > "$deffile" override="${LAUNCH_DIR:-.}/overrides/agents_md.json" if [[ -f "$override" ]]; then # Validate in a subshell so a die() on malformed JSON can't abort the build. if ( json_load "$override" ) >/dev/null 2>&1; then rm -f "$deffile" printf '%s' "$override" return 0 fi # warn writes to stdout; send it to stderr so it can't pollute the path we return. warn "overrides/agents_md.json is not valid JSON — using the built-in AGENTS.md defaults." >&2 fi printf '%s' "$deffile" } step_write_agents_md_block() { local file="AGENTS.md" local start="" local note="" local end="" step "Updating the one-line-installer block in AGENTS.md..." # Strip any previous managed block (and trailing blank lines) so re-runs # regenerate cleanly without accumulating blank lines. if [[ -f "$file" ]]; then awk -v s="$start" -v e="$end" ' $0 == s { inblk = 1; next } $0 == e { inblk = 0; next } inblk { next } { buf[++n] = $0 } END { last = 0 for (i = 1; i <= n; i++) if (buf[i] ~ /[^[:space:]]/) last = i for (i = 1; i <= last; i++) print buf[i] } ' "$file" > "${file}.tmp" && mv "${file}.tmp" "$file" fi # External site URL (what you open in a browser), matching print_welcome. DDEV # derives it from the lowercased project directory name; PROJECT_DIR is that # bare name at this point. local pname url pname="$(printf '%s' "$PROJECT_DIR" | tr '[:upper:]' '[:lower:]')" url="https://${pname}.ddev.site" # Resolve the content source (override or built-in default) and build the body. local src body src="$(_agents_md_json_source)" [[ "$src" == "${LAUNCH_DIR:-.}/overrides/agents_md.json" ]] && ok "Using AGENTS.md content from ${src}" body="$(_build_agents_md_body "$src" "$url")" # Clean up the temp default file if that's what we used. [[ "$src" != "${LAUNCH_DIR:-.}/overrides/agents_md.json" ]] && rm -f "$src" # A --config-url build's .git already points at the config repo's remote — # tell the agent so it pushes back there with plain git, instead of # assuming the installer's own GitHub flow ran. if [[ "${CONFIG_FROM_URL:-0}" -eq 1 ]]; then local origin origin="$(git -C "${CONFIG_URL_REPO_DIR:-.}" remote get-url origin 2>/dev/null || true)" body="${body}"$'\n\n'"This project was bootstrapped from ${origin:-the --config-url repo}. Its .git history and \`origin\` remote are already configured and point back to that repo — commit and \`git push\` your changes there directly; do not run the installer's GitHub setup for this project." fi # An existing-codebase config-url build (db_import/db_import_path) loaded # its data from an imported dump, not a fresh `drush site-install` — the # default admin/admin credentials print_welcome assumes may not apply. if [[ "${DB_IMPORT:-false}" == "true" || -n "${DB_IMPORT_PATH:-}" ]]; then body="${body}"$'\n\n'"This site's database was loaded from an imported dump, not a fresh Drupal install — the default admin/admin login is not guaranteed to work. Check the dump's own user accounts, or reset the admin password with \`ddev drush upwd admin --password=admin\` if you need known credentials." fi # Config-provided project instructions (.drupalaibp.json "agents_md"). local amd for amd in ${AGENTS_MD[@]+"${AGENTS_MD[@]}"}; do body="${body}"$'\n\n'"${amd}" done # Append the fresh block. A leading blank separates it from any prior content. { printf '\n%s\n' "$start" printf '%s\n\n' "$note" printf '%s\n\n' "$body" printf '%s\n' "$end" } >> "$file" ok "AGENTS.md updated" } # src/lib/zzz_project_help_hook.sh case " $* " in *" --help "*|*" -h "*) ( _print_project_questions_help ) 2>/dev/null || true ;; esac # :command.command_functions # :command.function drupalaibp_setup_site_command() { # src/setup_site_command.sh # Record how we were launched (remote curl/wget pipe vs a global/local file) so # the completion tip can suggest installing globally when appropriate. detect_invocation YOLO=0 [[ -n "${args[--yolo]:-}" ]] && YOLO=1 # Name of the project directory created for new sites (create_project_dir # requires a typed name instead when this default already exists, and builds # in the launch directory itself when that directory is empty). PROJECT_DIR="my-drupalcms-site" # Which Drupal install to create: cms, standard, minimal, or template. Empty means # "not specified" — apply_cli_flags fills it from --profile/--site-template, else # choose_profile resolves it (prompt, or cms under --yolo). PROFILE="" # For the "template" profile: the drupal.org site-template project short name to # apply (set by choose_site_template). Empty for every other profile. SITE_TEMPLATE="" # Answers collected up front by collect_answers so the build runs unattended. # GitHub repo creation is opt-in (github_setup asks; github_finalize acts on it). # Repos are created private unless the user explicitly opts into public. GH_ENABLED=0 GH_REPO_NAME="" GH_VISIBILITY="--private" # The directory the installer was launched from, captured BEFORE create_project_dir # cd's into the new project. Used to resolve launch-relative overrides (e.g. # overrides/agents_md.json) that must be found where the user ran the installer. LAUNCH_DIR="$(pwd)" # New-schema globals (defaults; drupalaibp_load overrides them). IS_DRUPAL="true" # 0 unless drupalaibp_load_hooks (management mode) sets it — greenfield builds never touch this. MANAGEMENT_MODE=0 DDEV_PROJECT_TYPE="drupal11" COMPOSER_STABILITY="dev" # Pass --with-all-dependencies to the config's `composer_projects` require (from # a config's composer_with_all_dependencies). Off by default; recipes are # apply-only, so this governs the one fetch step in provision_from_config. COMPOSER_WITH_ALL_DEPS="false" # Prepend each installed agent's own unattended/auto-approval flag to its # `ddev ` wrapper (from a config's agent_auto_mode). Off by default. AGENT_AUTO_MODE="false" # Existing-codebase config-url mode: DB_IMPORT="true" means a dump is needed # but not committed (path comes from --db-path/prompt); DB_IMPORT_PATH holds # an absolute path when the config commits its own dump (db_import_path). # Both default off/empty for a normal build; drupalaibp_load overrides them. DB_IMPORT="false" DB_IMPORT_PATH="" # Local path to a DB dump, supplied via --db-path (or the front-loaded prompt # when db_import: true and no path was given). Empty by default. DB_PATH="" # `ddev launch` argument override (e.g. ":6017" for a dev-server daemon on its # own web_extra_exposed_ports port) — empty means launch_site uses the # project's default root. See a config's `launch_path`. LAUNCH_PATH="" USE_CONFIG=0 ASK_UNATTENDED=0 AGENTS=(); SKILLS_SPEC=(); DENY_COMMANDS=() REPO_TYPES=(); REPO_URLS=(); COMPOSER_PROJECTS=(); COMPOSER_ALLOW_PLUGINS=() INSTALL_MODULES=(); RECIPES=(); DDEV_ADDONS=(); AGENTS_MD=() # Custom end-of-build message paragraphs (from a config's append/replace_welcome_text). # Initialized here so a no-config build leaves them defined for print_welcome under set -u. APPEND_WELCOME_TEXT=(); REPLACE_WELCOME_TEXT=() CONFIG_QUESTION_KEYS=(); CONFIG_QUESTION_LABELS=(); CONFIG_QUESTION_TYPES=() CONFIG_QUESTION_DEFAULTS=(); CONFIG_QUESTION_OPTIONS=() CONFIG_QUESTION_VALUES=(); CONFIG_QUESTION_HASVALUE=() CONFIG_HOOK_SCRIPTS=(); CONFIG_BASEDIR="" CONFIG_FROM_URL=0; CONFIG_URL_REPO_DIR=""; CONFIG_PATH_SOURCE_DIR="" # Extra Drupal installations to run (ids of the extra_ functions in # src/lib/extras/): choose_extras / --extras / a config's extra_scripts, # applied by install_extras. Default: none — every extra is opt-in. EXTRAS=() # Markers set by apply_cli_flags when the matching flag is passed, so the # corresponding interactive prompt is skipped / takes its default. AGENTS_SET=0; PROJECT_NAME_SET=0; GH_FROM_FLAG=0; SKILLS_DEFAULT=0; DENYLIST_DEFAULT=0 EXTRAS_SET=0; SKILLS_SET=0 echo -e "${DIM}────────────────────────────────────────────────────────────────────────────${RESET}" echo -e "${BOLD}${GREEN}" echo " _____ _ ______ " echo " / ____| | | ____| " echo "| (___ | |_ __ _ _ _| |__ ___ _ __ __ _ ___ " echo " \___ \| __/ _\` | '__ __/ _ \| '__/ _\` |/ _ \\" echo " ____) | || (_| | | | || (_) | | | (_| | __/" echo "|_____/ \__\__,_|_| |_| \___/|_| \__, |\___|" echo " __/ | " echo " |___/ " echo -e "${RESET}" echo -e "${BOLD} Bootstrap a fresh Drupal CMS site with DDEV or${RESET}" echo -e "${BOLD} install Drupal AI Best Practices tools in an existing project.${RESET}" echo -e "${DIM} Built by FreelyGive${RESET}" echo "" # Inspect the current directory BEFORE asking anything. Inside an existing DDEV # project we go straight to management mode (add/remove tooling) — the run-mode # question (trust vs. pick vs. use-a-config) only makes sense for a fresh build, # and management mode is interactive (it refuses --yolo). detect_project_context # sets PROJECT_CONTEXT to ddev / composer-no-ddev / none; run_install re-checks it # and dispatches (manage_existing / require_ddev_message / new-site pipeline). detect_project_context # A .drupalaibp.json in the launch directory pre-configures the build. The # structural choices (profile / agents / skills / deny rules / provisioning) # come from the config — USE_CONFIG=1 skips those prompts. The config's # `questions`, however, are still ASKED interactively, with each question's # `default` pre-filled (a `value` pre-answers it and shows no prompt). To take # every default silently and run fully unattended, add --yolo. _config_flag_count=0 [[ -n "${args[--config-url]:-}" ]] && _config_flag_count=$(( _config_flag_count + 1 )) [[ -n "${args[--global-config]:-}" ]] && _config_flag_count=$(( _config_flag_count + 1 )) [[ -n "${args[--config-path]:-}" ]] && _config_flag_count=$(( _config_flag_count + 1 )) if [[ $_config_flag_count -gt 1 ]]; then die "--config-url, --global-config, and --config-path are mutually exclusive — pick one." fi # A positional source (drupalaibp ) is detected and routed to one of the # three flags. It cannot be combined with an explicit --config-* flag. POS_SOURCE="${args[source]:-}" if [[ "$PROJECT_CONTEXT" == "none" && -n "$POS_SOURCE" ]]; then if [[ $_config_flag_count -gt 0 ]]; then die "Pass a config source positionally OR via --config-url/--config-path/--global-config, not both." fi resolve_config_source "$POS_SOURCE" case "$SOURCE_KIND" in url) args[--config-url]="$SOURCE_VALUE" [[ -n "$SOURCE_REF" && -z "${args[--ref]:-}" ]] && args[--ref]="$SOURCE_REF" ;; path) args[--config-path]="$SOURCE_VALUE" ;; global) args[--global-config]="$SOURCE_VALUE" ;; esac fi if [[ "$PROJECT_CONTEXT" == "none" && -n "${args[--config-url]:-}" ]]; then load_config_url "${args[--config-url]}" "${args[--ref]:-}" "${args[--config]:-}" elif [[ "$PROJECT_CONTEXT" == "none" && -n "${args[--global-config]:-}" ]]; then load_global_config "${args[--global-config]}" "${args[--config]:-}" elif [[ "$PROJECT_CONTEXT" == "none" && -n "${args[--config-path]:-}" ]]; then load_config_path "${args[--config-path]}" "${args[--config]:-}" elif [[ "$PROJECT_CONTEXT" == "none" ]] && ls .drupalaibp/*.json >/dev/null 2>&1; then select_config "." "${args[--config]:-}" if [[ -n "${RESOLVED_CONFIG_REMOTE_REPO:-}" ]]; then follow_remote_config # the pick lives in another repo: clone + load it else step "Found .drupalaibp/ config — running config-driven (add --yolo for a fully unattended build)." drupalaibp_load "$RESOLVED_CONFIG_FILE" USE_CONFIG=1 fi elif [[ "$PROJECT_CONTEXT" == "none" ]]; then choose_run_mode fi # CLI flags override the defaults AND any config just loaded, and mark their # prompts to be skipped. Applied last so an explicit flag always wins. apply_cli_flags [[ $YOLO -eq 1 ]] && ASK_UNATTENDED=1 register_builtin_handlers run_install } # :command.function drupalaibp_install_command() { # src/install_command.sh install_self } # :command.function drupalaibp_delete_command() { # src/delete_command.sh delete_project } # :command.function drupalaibp_pull_command() { # src/pull_command.sh # `pull` command: clone a config repo into ~/.drupalaibp/configs// so it # can be reused as a saved config. See pull_config (src/lib/config/pull_config.sh). pull_config "${args[repo]}" "${args[name]:-}" } # :command.parse_requirements parse_requirements() { local key # :command.fixed_flags_filter while [[ $# -gt 0 ]]; do key="$1" case "$key" in --version | -v) version_command exit ;; --help | -h) long_usage=yes drupalaibp_usage exit ;; *) break ;; esac done # :command.command_filter action=${1:-} case $action in setup-site) action="setup-site" shift drupalaibp_setup_site_parse_requirements "$@" shift $# ;; install) action="install" shift drupalaibp_install_parse_requirements "$@" shift $# ;; delete) action="delete" shift drupalaibp_delete_parse_requirements "$@" shift $# ;; pull) action="pull" shift drupalaibp_pull_parse_requirements "$@" shift $# ;; # :command.command_fallback *) action="setup-site" drupalaibp_setup_site_parse_requirements "$@" shift $# ;; esac # :command.parse_requirements_while while [[ $# -gt 0 ]]; do key="$1" case "$key" in -?*) printf "invalid option: %s\n" "$key" >&2 exit 1 ;; *) # :command.parse_requirements_case # :command.parse_requirements_case_simple printf "invalid argument: %s\n" "$key" >&2 exit 1 ;; esac done } # :command.parse_requirements drupalaibp_setup_site_parse_requirements() { local key # :command.fixed_flags_filter while [[ $# -gt 0 ]]; do key="$1" case "$key" in --help | -h) long_usage=yes drupalaibp_setup_site_usage exit ;; *) break ;; esac done # :command.command_filter action="setup-site" # :command.parse_requirements_while while [[ $# -gt 0 ]]; do key="$1" case "$key" in # :flag.case --yolo) # :flag.case_no_arg args['--yolo']=1 shift ;; # :flag.case --name) # :flag.case_arg if [[ -n ${2+x} ]]; then args['--name']="$2" shift shift else printf "%s\n" "--name requires an argument: --name NAME" >&2 exit 1 fi ;; # :flag.case --profile) # :flag.case_arg if [[ -n ${2+x} ]]; then args['--profile']="$2" shift shift else printf "%s\n" "--profile requires an argument: --profile PROFILE" >&2 exit 1 fi ;; # :flag.case --site-template) # :flag.case_arg if [[ -n ${2+x} ]]; then args['--site-template']="$2" shift shift else printf "%s\n" "--site-template requires an argument: --site-template NAME" >&2 exit 1 fi ;; # :flag.case --ddev-project-type) # :flag.case_arg if [[ -n ${2+x} ]]; then args['--ddev-project-type']="$2" shift shift else printf "%s\n" "--ddev-project-type requires an argument: --ddev-project-type TYPE" >&2 exit 1 fi ;; # :flag.case --is-drupal) # :flag.case_arg if [[ -n ${2+x} ]]; then args['--is-drupal']="$2" shift shift else printf "%s\n" "--is-drupal requires an argument: --is-drupal BOOL" >&2 exit 1 fi ;; # :flag.case --agents) # :flag.case_arg if [[ -n ${2+x} ]]; then args['--agents']="$2" shift shift else printf "%s\n" "--agents requires an argument: --agents LIST" >&2 exit 1 fi ;; # :flag.case --default-skills) # :flag.case_no_arg args['--default-skills']=1 shift ;; # :flag.case --add-skills) # :flag.case_arg if [[ -n ${2+x} ]]; then args['--add-skills']="$2" shift shift else printf "%s\n" "--add-skills requires an argument: --add-skills LIST" >&2 exit 1 fi ;; # :flag.case --default-denylist) # :flag.case_no_arg args['--default-denylist']=1 shift ;; # :flag.case --extras) # :flag.case_arg if [[ -n ${2+x} ]]; then args['--extras']="$2" shift shift else printf "%s\n" "--extras requires an argument: --extras LIST" >&2 exit 1 fi ;; # :flag.case --add-extras) # :flag.case_arg if [[ -n ${2+x} ]]; then args['--add-extras']="$2" shift shift else printf "%s\n" "--add-extras requires an argument: --add-extras LIST" >&2 exit 1 fi ;; # :flag.case --composer-stability) # :flag.case_arg if [[ -n ${2+x} ]]; then args['--composer-stability']="$2" shift shift else printf "%s\n" "--composer-stability requires an argument: --composer-stability LEVEL" >&2 exit 1 fi ;; # :flag.case --github-setup) # :flag.case_arg if [[ -n ${2+x} ]]; then args['--github-setup']="$2" shift shift else printf "%s\n" "--github-setup requires an argument: --github-setup REPO" >&2 exit 1 fi ;; # :flag.case --global-config) # :flag.case_arg if [[ -n ${2+x} ]]; then args['--global-config']="$2" shift shift else printf "%s\n" "--global-config requires an argument: --global-config NAME" >&2 exit 1 fi ;; # :flag.case --db-path) # :flag.case_arg if [[ -n ${2+x} ]]; then args['--db-path']="$2" shift shift else printf "%s\n" "--db-path requires an argument: --db-path FILE" >&2 exit 1 fi ;; # :flag.case --config-url) # :flag.case_arg if [[ -n ${2+x} ]]; then args['--config-url']="$2" shift shift else printf "%s\n" "--config-url requires an argument: --config-url URL" >&2 exit 1 fi ;; # :flag.case --config-path) # :flag.case_arg if [[ -n ${2+x} ]]; then args['--config-path']="$2" shift shift else printf "%s\n" "--config-path requires an argument: --config-path DIR" >&2 exit 1 fi ;; # :flag.case --config) # :flag.case_arg if [[ -n ${2+x} ]]; then args['--config']="$2" shift shift else printf "%s\n" "--config requires an argument: --config NAME" >&2 exit 1 fi ;; # :flag.case --ref) # :flag.case_arg if [[ -n ${2+x} ]]; then args['--ref']="$2" shift shift else printf "%s\n" "--ref requires an argument: --ref REF" >&2 exit 1 fi ;; -?*) printf "invalid option: %s\n" "$key" >&2 exit 1 ;; *) # :command.parse_requirements_case # :command.parse_requirements_case_simple # :argument.case if [[ -z ${args['source']+x} ]]; then args['source']=$1 shift else printf "invalid argument: %s\n" "$key" >&2 exit 1 fi ;; esac done # :command.whitelist_filter if [[ ${args['--profile']:-} ]] && [[ ! ${args['--profile']:-} =~ ^(cms|standard|minimal)$ ]]; then printf "%s\n" "--profile must be one of: cms, standard, minimal" >&2 exit 1 fi if [[ ${args['--is-drupal']:-} ]] && [[ ! ${args['--is-drupal']:-} =~ ^(true|false)$ ]]; then printf "%s\n" "--is-drupal must be one of: true, false" >&2 exit 1 fi if [[ ${args['--composer-stability']:-} ]] && [[ ! ${args['--composer-stability']:-} =~ ^(dev|alpha|beta|rc|stable)$ ]]; then printf "%s\n" "--composer-stability must be one of: dev, alpha, beta, rc, stable" >&2 exit 1 fi } # :command.parse_requirements drupalaibp_install_parse_requirements() { local key # :command.fixed_flags_filter while [[ $# -gt 0 ]]; do key="$1" case "$key" in --help | -h) long_usage=yes drupalaibp_install_usage exit ;; *) break ;; esac done # :command.command_filter action="install" # :command.parse_requirements_while while [[ $# -gt 0 ]]; do key="$1" case "$key" in -?*) printf "invalid option: %s\n" "$key" >&2 exit 1 ;; *) # :command.parse_requirements_case # :command.parse_requirements_case_simple printf "invalid argument: %s\n" "$key" >&2 exit 1 ;; esac done } # :command.parse_requirements drupalaibp_delete_parse_requirements() { local key # :command.fixed_flags_filter while [[ $# -gt 0 ]]; do key="$1" case "$key" in --help | -h) long_usage=yes drupalaibp_delete_usage exit ;; *) break ;; esac done # :command.command_filter action="delete" # :command.parse_requirements_while while [[ $# -gt 0 ]]; do key="$1" case "$key" in -?*) printf "invalid option: %s\n" "$key" >&2 exit 1 ;; *) # :command.parse_requirements_case # :command.parse_requirements_case_simple printf "invalid argument: %s\n" "$key" >&2 exit 1 ;; esac done } # :command.parse_requirements drupalaibp_pull_parse_requirements() { local key # :command.fixed_flags_filter while [[ $# -gt 0 ]]; do key="$1" case "$key" in --help | -h) long_usage=yes drupalaibp_pull_usage exit ;; *) break ;; esac done # :command.command_filter action="pull" # :command.parse_requirements_while while [[ $# -gt 0 ]]; do key="$1" case "$key" in # :flag.case --ref) # :flag.case_arg if [[ -n ${2+x} ]]; then args['--ref']="$2" shift shift else printf "%s\n" "--ref requires an argument: --ref REF" >&2 exit 1 fi ;; # :flag.case --force) # :flag.case_no_arg args['--force']=1 shift ;; -?*) printf "invalid option: %s\n" "$key" >&2 exit 1 ;; *) # :command.parse_requirements_case # :command.parse_requirements_case_simple # :argument.case if [[ -z ${args['repo']+x} ]]; then args['repo']=$1 shift # :argument.case elif [[ -z ${args['name']+x} ]]; then args['name']=$1 shift else printf "invalid argument: %s\n" "$key" >&2 exit 1 fi ;; esac done # :command.required_args_filter if [[ -z ${args['repo']+x} ]]; then printf "missing required argument: REPO\nusage: drupalaibp pull REPO [NAME] [OPTIONS]\n" >&2 exit 1 fi } # :command.initialize initialize() { declare -g version="2.0.0" set -euo pipefail } # :command.run run() { # :command.globals declare -g long_usage='' declare -g -A args=() declare -g -A deps=() declare -g -a env_var_names=() declare -g -a input=() normalize_input "$@" parse_requirements "${input[@]}" case "$action" in "setup-site") drupalaibp_setup_site_command ;; "install") drupalaibp_install_command ;; "delete") drupalaibp_delete_command ;; "pull") drupalaibp_pull_command ;; esac } if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then # :command.start command_line_args=("$@") initialize run "${command_line_args[@]}" fi