Files
RepoFoundry/src/lib/config.sh
T

256 lines
10 KiB
Bash

# shellcheck shell=bash
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
# config.sh - Reading and checking config.env and .env (parsed, never sourced).
#
# Part of create-project.sh: sourced by it, never run on its own.
#
# Provides: unquote_value, parse_env_file, parse_env_entry, validate_config, check_preset, check_preset_choice, validate_project_presets, validate_credentials, warn_if_env_unsafe, load_configuration
# unquote_value RAW: strip matching quotes (or a trailing " # comment" on an
# unquoted value) and return the value in REPLY. Fails on unbalanced quotes.
unquote_value() {
local raw quote
raw="$(trim "$1")"
quote="${raw:0:1}"
if [[ $quote == '"' || $quote == "'" ]]; then
[[ ${#raw} -ge 2 && ${raw: -1} == "$quote" ]] || return 1
raw="${raw:1:${#raw}-2}"
[[ $raw != *"$quote"* ]] || return 1
else
raw="${raw%%[[:space:]]#*}"
raw="$(trim "$raw")"
[[ $raw != *'"'* && $raw != *"'"* ]] || return 1
fi
REPLY="$raw"
}
# parse_env_file FILE ALLOWED_ARRAY TARGET_ARRAY
# Read KEY=VALUE lines without source or eval. Only keys named in
# ALLOWED_ARRAY are accepted; they are stored in the associative array
# TARGET_ARRAY. Messages name the key and the line, never the value.
parse_env_file() {
local file="$1" line key line_number=0
local pattern='^([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*=(.*)$'
[[ -f $file && -r $file ]] || die "cannot read '$file'"
# shellcheck disable=SC2094 # the loop body only uses $file in messages
while IFS= read -r line || [[ -n $line ]]; do
line_number=$((line_number + 1))
if ((line_number == 1)); then
line="${line#$'\xEF\xBB\xBF'}" # byte order mark from some Windows editors
fi
line="$(trim "${line%$'\r'}")"
if [[ -z $line || $line == \#* ]]; then
continue
fi
[[ $line =~ $pattern ]] ||
die "$file line $line_number: expected KEY=VALUE"
key="${BASH_REMATCH[1]}"
parse_env_entry "$file" "$line_number" "$key" "${BASH_REMATCH[2]}" \
"$2" "$3"
done <"$file"
}
# parse_env_entry FILE LINE KEY RAW_VALUE ALLOWED_ARRAY TARGET_ARRAY
parse_env_entry() {
local file="$1" line_number="$2" key="$3" raw="$4"
local -n allowed_keys="$5"
local -n target_map="$6"
local value
if ! in_list "$key" "${allowed_keys[@]}"; then
if in_list "$key" "${CREDENTIAL_KEYS[@]}"; then
die "$file line $line_number: '$key' is a credential; keep it in the .env file only"
fi
die "$file line $line_number: unknown key '$key'"
fi
if [[ -n ${target_map[$key]+set} ]]; then
die "$file line $line_number: '$key' is set twice"
fi
unquote_value "$raw" ||
die "$file line $line_number: unbalanced or misplaced quotes"
value="$REPLY"
if has_control_character "$value"; then
die "$file line $line_number: '$key' contains a control character"
fi
if ((${#value} > MAX_VALUE_LENGTH)); then
die "$file line $line_number: '$key' is too long"
fi
# shellcheck disable=SC2004 # target_map is an associative array: $key is a string
target_map[$key]="$value"
}
validate_config() {
local key url
if [[ -z ${CONFIG[GITEA_URL]:-} ]]; then
die "GITEA_URL is missing in $CONFIG_FILE (see config.env.example)"
fi
CONFIG[GITHUB_API_URL]="${CONFIG[GITHUB_API_URL]:-https://api.github.com}"
CONFIG[GITHUB_WEB_URL]="${CONFIG[GITHUB_WEB_URL]:-https://github.com}"
for key in GITHUB_API_URL GITHUB_WEB_URL GITEA_URL; do
url="$(normalize_url "${CONFIG[$key]}")"
is_valid_base_url "$url" ||
die "$key in $CONFIG_FILE must be an https URL without credentials, query or fragment"
CONFIG[$key]="$url"
done
CONFIG[GITEA_API_URL]="$(normalize_url "${CONFIG[GITEA_API_URL]:-${CONFIG[GITEA_URL]}/api/v1}")"
is_valid_base_url "${CONFIG[GITEA_API_URL]}" ||
die "GITEA_API_URL in $CONFIG_FILE must be an https URL without credentials, query or fragment"
CONFIG[GITEA_SSH_PORT]="${CONFIG[GITEA_SSH_PORT]:-$DEFAULT_SSH_PORT}"
is_valid_port "${CONFIG[GITEA_SSH_PORT]}" ||
die "GITEA_SSH_PORT in $CONFIG_FILE must be a port number from 1 to 65535"
CONFIG[MIRROR_INTERVAL]="${CONFIG[MIRROR_INTERVAL]:-$DEFAULT_MIRROR_INTERVAL}"
is_valid_interval "${CONFIG[MIRROR_INTERVAL]}" ||
die "MIRROR_INTERVAL in $CONFIG_FILE must look like 10m0s or 8h0m0s"
CONFIG[FRAMEWORK_REPO]="${CONFIG[FRAMEWORK_REPO]:-$DEFAULT_FRAMEWORK_REPO}"
is_valid_framework_repo "${CONFIG[FRAMEWORK_REPO]}" ||
die "FRAMEWORK_REPO in $CONFIG_FILE must look like OWNER/NAME"
validate_project_presets
}
# check_preset KEY VALIDATOR HINT: when KEY is set in config.env its value must
# pass VALIDATOR. A key that is present counts as set; only the description
# may be empty. The message names the key, never the value.
check_preset() {
local key="$1" validator="$2" hint="$3"
[[ -n ${CONFIG[$key]+set} ]] || return 0
if [[ -z ${CONFIG[$key]} && $key != PROJECT_DESCRIPTION ]]; then
die "$key in $CONFIG_FILE is empty; remove the line to be asked, or give a value ($hint)"
fi
"$validator" "${CONFIG[$key]}" ||
die "$key in $CONFIG_FILE is not valid: $hint"
}
# check_preset_choice KEY CHOICE...: like check_preset for a fixed list of
# words; the value is stored in lower case.
check_preset_choice() {
local key="$1"
shift
[[ -n ${CONFIG[$key]+set} ]] || return 0
[[ -n ${CONFIG[$key]} ]] ||
die "$key in $CONFIG_FILE is empty; remove the line to be asked, or give one of: $*"
CONFIG[$key]="${CONFIG[$key],,}"
in_list "${CONFIG[$key]}" "$@" ||
die "$key in $CONFIG_FILE must be one of: $*"
}
# The optional project details that may be preset in config.env.
validate_project_presets() {
check_preset PROJECT_NAME is_valid_repo_name "$HINT_REPO_NAME"
check_preset PROJECT_DESCRIPTION is_valid_description "$HINT_DESCRIPTION"
check_preset_choice PROJECT_VISIBILITY private public
check_preset GITEA_OWNER is_valid_gitea_owner "$HINT_GITEA_OWNER"
check_preset_choice USE_GITHUB yes no
check_preset GITHUB_OWNER is_valid_github_owner "$HINT_GITHUB_OWNER"
check_preset PROJECT_DIRECTORY is_valid_directory "$HINT_DIRECTORY"
check_preset_choice ENABLE_PLAN_GATE yes no
check_preset PROJECT_LICENSE is_valid_license "$HINT_LICENSE"
}
# validate_credentials: check the credentials that .env provides. A credential
# that is not provided is not an error: it is asked later (collect_credentials).
validate_credentials() {
# Register secrets first so that no later message can show them.
if [[ -n ${CREDENTIALS[GITEA_TOKEN]:-} ]]; then
SECRET_VALUES+=("${CREDENTIALS[GITEA_TOKEN]}")
fi
if [[ -n ${CREDENTIALS[GITHUB_PAT]:-} ]]; then
SECRET_VALUES+=("${CREDENTIALS[GITHUB_PAT]}")
fi
if [[ -n ${CREDENTIALS[GITEA_TOKEN]:-} ]] &&
! is_valid_token "${CREDENTIALS[GITEA_TOKEN]}"; then
die "GITEA_TOKEN in $ENV_FILE is not a valid token ($HINT_TOKEN)"
fi
if [[ -n ${CREDENTIALS[GITHUB_PAT]:-} ]] &&
! is_valid_token "${CREDENTIALS[GITHUB_PAT]}"; then
die "GITHUB_PAT in $ENV_FILE is not a valid token ($HINT_TOKEN)"
fi
if [[ -n ${CREDENTIALS[GITHUB_USER]:-} ]] &&
! is_valid_github_owner "${CREDENTIALS[GITHUB_USER]}"; then
die "GITHUB_USER in $ENV_FILE is not a valid GitHub account name"
fi
}
warn_if_env_unsafe() {
local file="$1" dir mode
case "$(uname -s 2>/dev/null || true)" in
MINGW* | MSYS* | CYGWIN*) ;;
*)
mode="$(stat -c '%a' -- "$file" 2>/dev/null ||
stat -f '%Lp' -- "$file" 2>/dev/null || true)"
if [[ -n $mode ]] && (((8#$mode & 8#077) != 0)); then
warn "$file is readable by other users (mode $mode); run: chmod 600 $file"
fi
;;
esac
dir="."
if [[ $file == */* ]]; then
dir="${file%/*}"
fi
if git -C "$dir" rev-parse --is-inside-work-tree >/dev/null 2>&1 &&
! git -C "$dir" check-ignore -q -- "$file"; then
warn "$file is not ignored by git; add it to .gitignore before committing"
fi
}
# locate_config_file NAME FLAG FILE_VAR ORIGIN_VAR: the file named with FLAG;
# otherwise ./NAME in the working folder; otherwise NAME in the checkout.
# The origin is "named", "folder" or "checkout". With a fifth word, optional,
# a file found nowhere is not an error: the file stays empty and the origin
# is "none".
locate_config_file() {
local name="$1" flag="$2" optional="${5:-}"
local -n file_ref="$3" origin_ref="$4"
if [[ -n $file_ref ]]; then
origin_ref="named"
elif [[ $WORKING_FOLDER != "$PROJECT_ROOT" && -f $WORKING_FOLDER/$name ]]; then
file_ref="$WORKING_FOLDER/$name"
origin_ref="folder"
elif [[ -f $PROJECT_ROOT/$name ]]; then
file_ref="$PROJECT_ROOT/$name"
origin_ref="checkout"
elif [[ -n $optional ]]; then
origin_ref="none"
else
die "$name is not named with $flag and is in neither the working folder ($WORKING_FOLDER) nor the checkout ($PROJECT_ROOT); name it with $flag FILE"
fi
}
# origin_words ORIGIN: how the origin of a configuration file reads.
origin_words() {
case "$1" in
named) printf 'named on the command line' ;;
none) printf 'none found; a missing credential is asked' ;;
folder) printf 'from the working folder' ;;
*) printf 'from the checkout' ;;
esac
}
# confirm_folder_files: a file taken from the working folder may point the
# Gitea address elsewhere and so send the token there, so it is named with
# the address and needs a yes (default no) before the first request.
confirm_folder_files() {
local files=()
[[ $CONFIG_ORIGIN == folder ]] && files+=("$CONFIG_FILE")
[[ $ENV_ORIGIN == folder ]] && files+=("$ENV_FILE")
((${#files[@]} > 0)) || return 0
say "The working folder supplies: ${files[*]}"
say "Gitea would be ${CONFIG[GITEA_URL]}; the token from the credentials file is sent there."
prompt_yes_no "Use ${files[*]}" n
((REPLY)) || die "stopped before any request: choose the files with --config and --env, or run from the checkout"
}
load_configuration() {
locate_config_file config.env --config CONFIG_FILE CONFIG_ORIGIN
locate_config_file .env --env ENV_FILE ENV_ORIGIN optional
say "Config file : $CONFIG_FILE ($(origin_words "$CONFIG_ORIGIN"))"
say "Credentials file: ${ENV_FILE:-(none)} ($(origin_words "$ENV_ORIGIN"))"
parse_env_file "$CONFIG_FILE" CONFIG_KEYS CONFIG
validate_config
confirm_folder_files
# .env is optional: a credential it does not provide is asked.
if [[ -n $ENV_FILE && -e $ENV_FILE ]]; then
parse_env_file "$ENV_FILE" CREDENTIAL_KEYS CREDENTIALS
warn_if_env_unsafe "$ENV_FILE"
fi
validate_credentials
}