Add the GitHub and Gitea steps and split the script into library files
Dry run by default: the script reads from both hosts (tokens, owners, names, license, SSH) and prints a plan; --apply creates the repositories and the Gitea -> GitHub push mirror after a final yes. Choosing GitHub applies the AGPL-3.0 license to the Gitea repository. A failed step is reported with what exists and how to continue; nothing is ever deleted. create-project.sh is now the entry point; the work lives in src/lib/, one responsibility per file. .gitignore gets !src/lib (the Python template ignores any lib/ folder). Tests grow to 599 checks, with a stub curl and ssh, and guards for the file structure. Task: MIL-002#1 Task: MIL-002#2 Task: MIL-002#3 Task: MIL-002#4 Task: MIL-002#5 Task: MIL-002#6 Refs #9 Refs #10 Refs #11 Refs #12 Refs #13 Refs #14 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
# 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, validate_credentials, require_github_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"
|
||||
}
|
||||
|
||||
validate_credentials() {
|
||||
if [[ -z ${CREDENTIALS[GITEA_TOKEN]:-} ]]; then
|
||||
die "GITEA_TOKEN is missing in $ENV_FILE (see .env.example)"
|
||||
fi
|
||||
# Register secrets first so that no later message can show them.
|
||||
SECRET_VALUES+=("${CREDENTIALS[GITEA_TOKEN]}")
|
||||
if [[ -n ${CREDENTIALS[GITHUB_PAT]:-} ]]; then
|
||||
SECRET_VALUES+=("${CREDENTIALS[GITHUB_PAT]}")
|
||||
fi
|
||||
is_valid_token "${CREDENTIALS[GITEA_TOKEN]}" ||
|
||||
die "GITEA_TOKEN in $ENV_FILE is not a valid token (8 to 255 letters, digits or _ . ~ + / = -)"
|
||||
if [[ -n ${CREDENTIALS[GITHUB_PAT]:-} ]] &&
|
||||
! is_valid_token "${CREDENTIALS[GITHUB_PAT]}"; then
|
||||
die "GITHUB_PAT in $ENV_FILE is not a valid token (8 to 255 letters, digits or _ . ~ + / = -)"
|
||||
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
|
||||
}
|
||||
|
||||
# GitHub credentials are only needed when the Maintainer chose GitHub.
|
||||
require_github_credentials() {
|
||||
local key
|
||||
for key in GITHUB_PAT GITHUB_USER; do
|
||||
if [[ -z ${CREDENTIALS[$key]:-} ]]; then
|
||||
die "GitHub was chosen but $key is missing in $ENV_FILE (see .env.example)"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
load_configuration() {
|
||||
parse_env_file "$CONFIG_FILE" CONFIG_KEYS CONFIG
|
||||
validate_config
|
||||
parse_env_file "$ENV_FILE" CREDENTIAL_KEYS CREDENTIALS
|
||||
validate_credentials
|
||||
warn_if_env_unsafe "$ENV_FILE"
|
||||
}
|
||||
Reference in New Issue
Block a user